|
| 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 | +} |
0 commit comments