Skip to content

Commit cf5ea4f

Browse files
feat(nip98): add Authorization header verifier (#722)
* feat(nip98): add Authorization header verifier * fix(nip98): tighten Authorization header parsing per review Use one regex for scheme+token and lower the default header cap to 2KB. * fix(nip98): parse auth JSON in decoder and fix digest compare Move JSON.parse into the Authorization decoder and compare payload digests with timingSafeEqual only after an explicit hex format check. * fix(nip98): handle verifier edge cases
1 parent a76d0b5 commit cf5ea4f

6 files changed

Lines changed: 808 additions & 5 deletions

File tree

‎.changeset/nip98-auth-verifier.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(nip98): add Authorization header event verifier for HTTP auth (kind 27235)

‎.knip.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"src/import-events.ts!",
66
"src/cli/index.ts!",
77
"src/scripts/benchmark-queries.ts!",
8+
"src/utils/nip98.ts!",
89
"knexfile.js!"
910
],
1011
"project": [

‎src/constants/base.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export enum EventKinds {
5454
EPHEMERAL_FIRST = 20000,
5555
// NIP-42: Client Authentication
5656
AUTH = 22242,
57+
// NIP-98: HTTP Auth
58+
HTTP_AUTH = 27235,
5759
// NIP-43: Ephemeral access request kinds
5860
NIP43_JOIN_REQUEST = 28934,
5961
NIP43_INVITE_REQUEST = 28935,
@@ -95,6 +97,10 @@ export enum EventTags {
9597
// NIP-43: Relay Access Metadata
9698
Member = 'member',
9799
Claim = 'claim',
100+
// NIP-98: HTTP Auth
101+
Url = 'u',
102+
Method = 'method',
103+
Payload = 'payload',
98104
}
99105

100106
export const ALL_RELAYS = 'ALL_RELAYS'

‎src/utils/nip98.ts‎

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { createHash } from 'crypto'
2+
import { z } from 'zod'
3+
import { Pubkey } from '../@types/base'
4+
import { Event } from '../@types/event'
5+
import { EventKinds, EventTags } from '../constants/base'
6+
import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from '../schemas/base-schema'
7+
import { isEventIdValid, isEventSignatureValid } from './event'
8+
9+
// NIP-98 suggests a ~60s window for kind 27235 auth events.
10+
export const DEFAULT_NIP98_MAX_SKEW_SECONDS = 60
11+
12+
// A signed kind-27235 event is typically ~1–2KB encoded. Cap at 2KB (strict)
13+
// so oversized Authorization headers are rejected before JSON.parse / crypto work.
14+
export const DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH = 2048
15+
16+
const NOSTR_AUTHORIZATION_HEADER = /^Nostr [A-Za-z0-9+/]+={0,2}$/i
17+
const LOWER_HEX_64 = /^[0-9a-f]{64}$/
18+
19+
// Lean NIP-01 shape only — avoids eventSchema superRefine (reactions, geohash, etc.).
20+
const nip98EventSchema = z
21+
.object({
22+
id: idSchema,
23+
pubkey: pubkeySchema,
24+
created_at: createdAtSchema,
25+
kind: kindSchema,
26+
tags: z.array(tagSchema),
27+
content: z.string(),
28+
sig: signatureSchema,
29+
})
30+
.strict()
31+
32+
export type Nip98AuthSuccess = {
33+
ok: true
34+
pubkey: Pubkey
35+
event: Event
36+
}
37+
38+
export type Nip98AuthFailure = {
39+
ok: false
40+
reason: string
41+
}
42+
43+
export type Nip98AuthResult = Nip98AuthSuccess | Nip98AuthFailure
44+
45+
type AuthorizationDecodeSuccess = {
46+
ok: true
47+
/** JSON value decoded from the Authorization token (not yet schema-validated). */
48+
raw: unknown
49+
}
50+
51+
type AuthorizationDecodeResult = AuthorizationDecodeSuccess | Nip98AuthFailure
52+
53+
export type Nip98PayloadPolicy =
54+
// Secure default for mutating admin APIs: non-empty bodies must bind via payload.
55+
| 'require-when-body'
56+
// Spec "MAY": only verify payload when the client included the tag.
57+
| 'verify-if-present'
58+
| 'ignore'
59+
60+
export type VerifyNip98AuthInput = {
61+
authorizationHeader: string | undefined | null
62+
/** Absolute request URL, including query string. Compared exactly to the `u` tag. */
63+
url: string
64+
/** HTTP method as received (typically uppercase). Compared exactly to the `method` tag. */
65+
method: string
66+
/**
67+
* Raw request body. Hashing uses the bytes directly (no intermediate copy for Buffer/Uint8Array).
68+
* When omitted, payload policy is skipped.
69+
*/
70+
body?: Buffer | Uint8Array | string
71+
payloadPolicy?: Nip98PayloadPolicy
72+
maxSkewSeconds?: number
73+
maxAuthorizationHeaderLength?: number
74+
/** Overrideable for deterministic tests. Defaults to Math.floor(Date.now() / 1000). */
75+
nowSeconds?: number
76+
}
77+
78+
export const hashNip98Payload = (body: Buffer | Uint8Array | string): string => {
79+
const hash = createHash('sha256')
80+
if (typeof body === 'string') {
81+
hash.update(body, 'utf8')
82+
} else {
83+
hash.update(body)
84+
}
85+
return hash.digest('hex')
86+
}
87+
88+
const fail = (reason: string): Nip98AuthFailure => ({ ok: false, reason })
89+
90+
const resolveNonNegativeSafeInteger = (value: number | undefined, fallback: number): number =>
91+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback
92+
93+
const bodyByteLength = (body: Buffer | Uint8Array | string): number =>
94+
typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.byteLength
95+
96+
type Nip98AuthTags = {
97+
url?: string
98+
method?: string
99+
payload?: string
100+
}
101+
102+
const extractAuthTags = (tags: Event['tags']): Nip98AuthTags => {
103+
const result: Nip98AuthTags = {}
104+
105+
for (const tag of tags) {
106+
if (tag.length < 2) {
107+
continue
108+
}
109+
110+
switch (tag[0]) {
111+
case EventTags.Url:
112+
if (result.url === undefined) {
113+
result.url = tag[1]
114+
}
115+
break
116+
case EventTags.Method:
117+
if (result.method === undefined) {
118+
result.method = tag[1]
119+
}
120+
break
121+
case EventTags.Payload:
122+
if (result.payload === undefined) {
123+
result.payload = tag[1]
124+
}
125+
break
126+
}
127+
128+
if (result.url !== undefined && result.method !== undefined && result.payload !== undefined) {
129+
break
130+
}
131+
}
132+
133+
return result
134+
}
135+
136+
/**
137+
* Decode `Authorization: Nostr <base64>` into parsed JSON.
138+
* Base64 shape is guaranteed by the header regex before decode; JSON.parse runs here
139+
* so callers receive `unknown` rather than a string they must re-parse and cast.
140+
*/
141+
const decodeAuthorizationHeader = (
142+
authorizationHeader: string | undefined | null,
143+
maxAuthorizationHeaderLength: number,
144+
): AuthorizationDecodeResult => {
145+
if (typeof authorizationHeader !== 'string' || authorizationHeader.length === 0) {
146+
return fail('missing authorization header')
147+
}
148+
149+
if (authorizationHeader.length > maxAuthorizationHeaderLength) {
150+
return fail('invalid authorization header')
151+
}
152+
153+
const trimmed = authorizationHeader.trim()
154+
if (!NOSTR_AUTHORIZATION_HEADER.test(trimmed)) {
155+
return fail('invalid authorization header')
156+
}
157+
158+
// Safe after the regex match: scheme, single space, base64 token.
159+
const token = trimmed.split(' ')[1]
160+
const remainder = token.length % 4
161+
const hasPadding = token.endsWith('=')
162+
if (remainder === 1 || (hasPadding && remainder !== 0)) {
163+
return fail('invalid authorization encoding')
164+
}
165+
166+
try {
167+
return { ok: true, raw: JSON.parse(Buffer.from(token, 'base64').toString('utf8')) }
168+
} catch {
169+
return fail('invalid authorization event json')
170+
}
171+
}
172+
173+
const verifyPayloadBinding = (
174+
policy: Nip98PayloadPolicy,
175+
body: Buffer | Uint8Array | string | undefined,
176+
payloadTag: string | undefined,
177+
): Nip98AuthFailure | undefined => {
178+
if (body === undefined || policy === 'ignore') {
179+
return undefined
180+
}
181+
182+
const hasBody = bodyByteLength(body) > 0
183+
184+
if (policy === 'require-when-body' && hasBody && payloadTag === undefined) {
185+
return fail('invalid: missing payload tag')
186+
}
187+
188+
if (payloadTag === undefined) {
189+
return undefined
190+
}
191+
192+
// Event tags are plain strings — payload is not guaranteed hex until we check.
193+
const normalizedTag = payloadTag.toLowerCase()
194+
if (!LOWER_HEX_64.test(normalizedTag)) {
195+
return fail('invalid: payload tag does not match request body')
196+
}
197+
198+
// The payload digest is public request metadata, not secret material.
199+
if (normalizedTag !== hashNip98Payload(body)) {
200+
return fail('invalid: payload tag does not match request body')
201+
}
202+
203+
return undefined
204+
}
205+
206+
/**
207+
* Cryptographically verifies a NIP-98 `Authorization: Nostr <base64-event>` header
208+
* against the HTTP request URL, method, and optional body payload hash.
209+
*
210+
* Check order: cheap structural/kind/skew rejects first, then id+signature
211+
* (authenticate the event), then u/method/payload (bind the authenticated event
212+
* to this request).
213+
*/
214+
export const verifyNip98Auth = async (input: VerifyNip98AuthInput): Promise<Nip98AuthResult> => {
215+
const maxAuthorizationHeaderLength = resolveNonNegativeSafeInteger(
216+
input.maxAuthorizationHeaderLength,
217+
DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH,
218+
)
219+
220+
const decoded = decodeAuthorizationHeader(input.authorizationHeader, maxAuthorizationHeaderLength)
221+
if (decoded.ok === false) {
222+
return decoded
223+
}
224+
225+
const schemaResult = nip98EventSchema.safeParse(decoded.raw)
226+
if (!schemaResult.success) {
227+
return fail('invalid authorization event')
228+
}
229+
230+
const event = schemaResult.data as unknown as Event
231+
232+
if (event.kind !== EventKinds.HTTP_AUTH) {
233+
return fail('invalid: auth event must be kind 27235')
234+
}
235+
236+
const maxSkewSeconds = resolveNonNegativeSafeInteger(input.maxSkewSeconds, DEFAULT_NIP98_MAX_SKEW_SECONDS)
237+
const nowSeconds = resolveNonNegativeSafeInteger(input.nowSeconds, Math.floor(Date.now() / 1000))
238+
if (Math.abs(nowSeconds - event.created_at) > maxSkewSeconds) {
239+
return fail('invalid: created_at is too far from the current time')
240+
}
241+
242+
// Authenticate before trusting tags for request binding.
243+
if (!(await isEventIdValid(event))) {
244+
return fail('invalid: event id does not match')
245+
}
246+
247+
if (!(await isEventSignatureValid(event))) {
248+
return fail('invalid: event signature verification failed')
249+
}
250+
251+
const tags = extractAuthTags(event.tags)
252+
253+
if (tags.url === undefined) {
254+
return fail('invalid: missing u tag')
255+
}
256+
if (tags.url !== input.url) {
257+
return fail('invalid: u tag does not match request url')
258+
}
259+
260+
if (tags.method === undefined) {
261+
return fail('invalid: missing method tag')
262+
}
263+
if (tags.method !== input.method) {
264+
return fail('invalid: method tag does not match request method')
265+
}
266+
267+
const payloadFailure = verifyPayloadBinding(input.payloadPolicy ?? 'require-when-body', input.body, tags.payload)
268+
if (payloadFailure) {
269+
return payloadFailure
270+
}
271+
272+
return {
273+
ok: true,
274+
pubkey: event.pubkey,
275+
event,
276+
}
277+
}

‎test/unit/cli/info.spec.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
const { expect } = require('chai')
2-
const fs = require('fs')
3-
const path = require('path')
4-
const sinon = require('sinon')
1+
import { expect } from 'chai'
2+
import fs from 'fs'
3+
import path from 'path'
4+
import sinon from 'sinon'
55

66
const infoCommand = require('../../../dist/src/cli/commands/info.js')
77
const configUtils = require('../../../dist/src/cli/utils/config.js')
@@ -33,7 +33,9 @@ describe('runInfo', () => {
3333

3434
it('outputs valid JSON when docker is not installed (ENOENT)', async () => {
3535
sinon.stub(fs, 'existsSync').returns(false)
36-
sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' })
36+
sinon
37+
.stub(processUtils, 'runCommandWithOutput')
38+
.resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' })
3739

3840
const code = await infoCommand.runInfo({ json: true })
3941

0 commit comments

Comments
 (0)