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