Skip to content

Commit eedfafa

Browse files
feat(nip98): add Authorization header verifier
1 parent a76d0b5 commit eedfafa

6 files changed

Lines changed: 727 additions & 6 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"lzma-native"
1515
],
1616
"ignore": [
17-
".nostr/**"
17+
".nostr/**",
18+
"src/utils/nip98.ts"
1819
],
1920
"commitlint": false,
2021
"eslint": false,

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

‎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)