From 61f5acc937ef8eeaa291327b27f9fb4de19d9a8f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 2 Sep 2023 18:32:01 -0500 Subject: [PATCH] nip98: add a dedicated nip98 module, refactor auth98.ts --- src/middleware/auth98.ts | 44 ++++++-------------------------------- src/utils.ts | 2 +- src/utils/nip98.ts | 46 ++++++++++++++++++++++++++++++++++++++++ src/utils/web.ts | 18 +++++++++++++++- 4 files changed, 71 insertions(+), 39 deletions(-) create mode 100644 src/utils/nip98.ts diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 003ad488..4ef54617 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -1,50 +1,20 @@ import { type AppMiddleware } from '@/app.ts'; -import { Conf } from '@/config.ts'; -import { type Event, HTTPException } from '@/deps.ts'; -import { decode64Schema, jsonSchema } from '@/schema.ts'; -import { signedEventSchema } from '@/schemas/nostr.ts'; -import { eventAge, findTag, sha256, Time } from '@/utils.ts'; - -const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); - -interface Auth98Opts { - timeout?: number; -} +import { HTTPException } from '@/deps.ts'; +import { parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts'; +import { localRequest } from '@/utils/web.ts'; /** * NIP-98 auth. * https://github.com/nostr-protocol/nips/blob/master/98.md */ -function auth98(opts: Auth98Opts = {}): AppMiddleware { +function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware { return async (c, next) => { - const authHeader = c.req.headers.get('authorization'); - const base64 = authHeader?.match(/^Nostr (.+)$/)?.[1]; - const { timeout = Time.minutes(1) } = opts; - - const schema = decodeEventSchema - .refine((event) => event.kind === 27235) - .refine((event) => eventAge(event) < timeout) - .refine((event) => findTag(event.tags, 'method')?.[1] === c.req.method) - .refine((event) => { - const url = findTag(event.tags, 'u')?.[1]; - try { - return url === Conf.local(c.req.url); - } catch (_e) { - return false; - } - }) - .refine(async (event) => { - const body = await c.req.raw.clone().text(); - if (!body) return true; - const hash = findTag(event.tags, 'payload')?.[1]; - return hash === await sha256(body); - }); - - const result = await schema.safeParseAsync(base64); + const req = localRequest(c); + const result = await parseAuthRequest(req, opts); if (result.success) { c.set('pubkey', result.data.pubkey); - c.set('proof', result.data as Event<27235>); + c.set('proof', result.data); } await next(); diff --git a/src/utils.ts b/src/utils.ts index 525c47f8..ede1e9c5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -67,7 +67,7 @@ async function lookupAccount(value: string): Promise | undefined> { /** Return the event's age in milliseconds. */ function eventAge(event: Event): number { - return new Date().getTime() - nostrDate(event.created_at).getTime(); + return Date.now() - nostrDate(event.created_at).getTime(); } function findTag(tags: string[][], name: string): string[] | undefined { diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts new file mode 100644 index 00000000..123a6ef2 --- /dev/null +++ b/src/utils/nip98.ts @@ -0,0 +1,46 @@ +import { type Event } from '@/deps.ts'; +import { decode64Schema, jsonSchema } from '@/schema.ts'; +import { signedEventSchema } from '@/schemas/nostr.ts'; +import { eventAge, findTag, sha256 } from '@/utils.ts'; +import { Time } from '@/utils/time.ts'; + +/** Decode a Nostr event from a base64 encoded string. */ +const decode64EventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); + +interface ParseAuthRequestOpts { + /** Max event age (in ms). */ + maxAge?: number; + /** Whether to validate the request body of the request with the payload of the auth event. (default: `true`) */ + validatePayload?: boolean; +} + +/** Parse the auth event from a Request, returning a zod SafeParse type. */ +function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { + const { maxAge = Time.minutes(1), validatePayload = true } = opts; + + const header = req.headers.get('authorization'); + const base64 = header?.match(/^Nostr (.+)$/)?.[1]; + + const schema = decode64EventSchema + .refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235') + .refine((event) => eventAge(event) < maxAge, 'Event expired') + .refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method') + .refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL') + .refine(validateBody, 'Event payload does not match request body'); + + function validateBody(event: Event<27235>) { + if (!validatePayload) return true; + return req.clone().text() + .then(sha256) + .then((hash) => hash === tagValue(event, 'payload')); + } + + return schema.safeParseAsync(base64); +} + +/** Get the value for the first matching tag name in the event. */ +function tagValue(event: Event, tagName: string): string | undefined { + return findTag(event.tags, tagName)?.[1]; +} + +export { parseAuthRequest, type ParseAuthRequestOpts }; diff --git a/src/utils/web.ts b/src/utils/web.ts index 5bae5c9c..b02209f1 100644 --- a/src/utils/web.ts +++ b/src/utils/web.ts @@ -123,4 +123,20 @@ function activityJson(c: Context, object: T) { return response; } -export { activityJson, createAdminEvent, createEvent, paginated, type PaginationParams, paginationSchema, parseBody }; +/** Rewrite the URL of the request object to use the local domain. */ +function localRequest(c: Context): Request { + return Object.create(c.req.raw, { + url: { value: Conf.local(c.req.url) }, + }); +} + +export { + activityJson, + createAdminEvent, + createEvent, + localRequest, + paginated, + type PaginationParams, + paginationSchema, + parseBody, +};