diff --git a/src/app.ts b/src/app.ts index 0fe0b62b..9104e2bb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import { type Context, cors, type Handler, Hono, type HonoEnv, logger, type MiddlewareHandler } from '@/deps.ts'; +import { type Event } from '@/event.ts'; import { accountController, @@ -26,7 +27,8 @@ import { } from './controllers/api/statuses.ts'; import { streamingController } from './controllers/api/streaming.ts'; import { indexController } from './controllers/site.ts'; -import { requireAuth, setAuth } from './middleware/auth.ts'; +import { auth19, requireAuth } from './middleware/auth19.ts'; +import { auth98 } from './middleware/auth98.ts'; interface AppEnv extends HonoEnv { Variables: { @@ -36,6 +38,8 @@ interface AppEnv extends HonoEnv { seckey?: string; /** UUID from the access token. Used for WebSocket event signing. */ session?: string; + /** NIP-98 signed event proving the pubkey is owned by the user. */ + proof?: Event<27235>; }; } @@ -50,7 +54,7 @@ app.use('*', logger()); app.get('/api/v1/streaming', streamingController); app.get('/api/v1/streaming/', streamingController); -app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), setAuth); +app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98()); app.get('/api/v1/instance', instanceController); diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index b99000cf..412e0240 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,5 +1,5 @@ import { AppController } from '@/app.ts'; -import { TOKEN_REGEX } from '@/middleware/auth.ts'; +import { TOKEN_REGEX } from '@/middleware/auth19.ts'; import { streamSchema, ws } from '@/stream.ts'; import { bech32ToPubkey } from '@/utils.ts'; diff --git a/src/middleware/auth.ts b/src/middleware/auth19.ts similarity index 89% rename from src/middleware/auth.ts rename to src/middleware/auth19.ts index 358c5bd5..46f125f9 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth19.ts @@ -1,4 +1,4 @@ -import { AppMiddleware } from '@/app.ts'; +import { type AppMiddleware } from '@/app.ts'; import { getPublicKey, HTTPException, nip19 } from '@/deps.ts'; /** The token includes a Bech32 Nostr ID (npub, nsec, etc) and an optional session ID. */ @@ -7,7 +7,7 @@ const TOKEN_REGEX = new RegExp(`(${nip19.BECH32_REGEX.source})(?:_(\\w+))?`); const BEARER_REGEX = new RegExp(`^Bearer (${TOKEN_REGEX.source})$`); /** NIP-19 auth middleware. */ -const setAuth: AppMiddleware = async (c, next) => { +const auth19: AppMiddleware = async (c, next) => { const authHeader = c.req.headers.get('authorization'); const match = authHeader?.match(BEARER_REGEX); @@ -47,4 +47,4 @@ const requireAuth: AppMiddleware = async (c, next) => { await next(); }; -export { requireAuth, setAuth, TOKEN_REGEX }; +export { auth19, requireAuth, TOKEN_REGEX }; diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts new file mode 100644 index 00000000..4910b0ed --- /dev/null +++ b/src/middleware/auth98.ts @@ -0,0 +1,74 @@ +import { type AppMiddleware } from '@/app.ts'; +import { Conf } from '@/config.ts'; +import { HTTPException } from '@/deps.ts'; +import { type Event } from '@/event.ts'; +import { decode64Schema, jsonSchema, signedEventSchema } from '@/schema.ts'; +import { eventAge, findTag, sha256, Time } from '@/utils.ts'; + +const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema); + +interface Auth98Opts { + timeout?: number; +} + +/** + * NIP-98 auth. + * https://github.com/nostr-protocol/nips/blob/master/98.md + */ +function auth98(opts: Auth98Opts = {}): 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 === localUrl(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); + + if (result.success) { + c.set('pubkey', result.data.pubkey); + c.set('proof', result.data as Event<27235>); + } + + await next(); + }; +} + +function localUrl(url: string): string { + const { pathname } = new URL(url); + return new URL(pathname, Conf.localDomain).toString(); +} + +const requireProof: AppMiddleware = async (c, next) => { + const pubkey = c.get('pubkey'); + const proof = c.get('proof'); + + // if (!proof && hasWebsocket(c.req)) { + // // TODO: attempt to sign nip98 event through websocket + // } + + if (!pubkey || !proof || proof.pubkey !== pubkey) { + throw new HTTPException(401); + } + + await next(); +}; + +export { auth98, requireProof }; diff --git a/src/schema.ts b/src/schema.ts index 53febf5a..9af967e7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -17,7 +17,7 @@ function filteredArray(schema: T) { const jsonSchema = z.string().transform((value, ctx) => { try { - return JSON.parse(value); + return JSON.parse(value) as unknown; } catch (_e) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' }); return z.NEVER; @@ -77,13 +77,27 @@ const eventSchema = z.object({ created_at: z.number(), pubkey: nostrIdSchema, sig: z.string(), -}).refine(verifySignature); +}); + +const signedEventSchema = eventSchema.refine(verifySignature); const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]); +/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */ +const decode64Schema = z.string().transform((value, ctx) => { + try { + const binString = atob(value); + const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!); + return new TextDecoder().decode(bytes); + } catch (_e) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64' }); + return z.NEVER; + } +}); + export { + decode64Schema, emojiTagSchema, - eventSchema, filteredArray, jsonSchema, type MetaContent, @@ -91,4 +105,5 @@ export { parseMetaContent, parseRelay, relaySchema, + signedEventSchema, }; diff --git a/src/sign.ts b/src/sign.ts index f222e11d..228496f7 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,6 +1,6 @@ import { type AppContext } from '@/app.ts'; import { getEventHash, getPublicKey, getSignature, HTTPException, z } from '@/deps.ts'; -import { eventSchema } from '@/schema.ts'; +import { signedEventSchema } from '@/schema.ts'; import { ws } from '@/stream.ts'; import type { Event, EventTemplate, SignedEvent } from '@/event.ts'; @@ -18,7 +18,7 @@ function getSignStream(c: AppContext): WebSocket | undefined { const nostrStreamingEventSchema = z.object({ type: z.literal('nostr.sign'), - data: eventSchema, + data: signedEventSchema, }); /** diff --git a/src/utils.ts b/src/utils.ts index c78e1cf7..191d5e72 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -101,10 +101,46 @@ function buildLinkHeader(url: string, events: Event[]): string | undefined { return `<${next}>; rel="next", <${prev}>; rel="prev"`; } +/** Return the event's age in milliseconds. */ +function eventAge(event: Event): number { + return new Date().getTime() - nostrDate(event.created_at).getTime(); +} + +const Time = { + milliseconds: (ms: number) => ms, + seconds: (s: number) => s * 1000, + minutes: (m: number) => m * Time.seconds(60), + hours: (h: number) => h * Time.minutes(60), + days: (d: number) => d * Time.hours(24), + weeks: (w: number) => w * Time.days(7), + months: (m: number) => m * Time.days(30), + years: (y: number) => y * Time.days(365), +}; + +function findTag(tags: string[][], name: string): string[] | undefined { + return tags.find((tag) => tag[0] === name); +} + +/** + * Get sha256 hash (hex) of some text. + * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string + */ +async function sha256(message: string): Promise { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return hashHex; +} + export { bech32ToPubkey, buildLinkHeader, + eventAge, eventDateComparator, + findTag, lookupAccount, type Nip05, nostrDate, @@ -113,4 +149,6 @@ export { paginationSchema, parseBody, parseNip05, + sha256, + Time, };