diff --git a/src/config.ts b/src/config.ts index 7add7d8f..e03ef8bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,14 +72,15 @@ const Conf = { }, /** Merges the path with the localDomain. */ local(path: string): string { - if (path.startsWith('/')) { - // Path is a path. - return new URL(path, Conf.localDomain).toString(); - } else { - // Path is possibly a full URL. Replace the domain. - const { pathname } = new URL(path); - return new URL(pathname, Conf.localDomain).toString(); + const url = new URL(path.startsWith('/') ? path : new URL(path).pathname, Conf.localDomain); + + if (!path.startsWith('/')) { + // Copy query parameters from the original URL to the new URL + const originalUrl = new URL(path); + url.search = originalUrl.search; } + + return url.toString(); }, }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 6ba6183f..ee8497ea 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -2,11 +2,11 @@ import { AppController } from '@/app.ts'; import * as eventsDB from '@/db/events.ts'; import { type Event, type Filter, nip19, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; -import { lookupNip05Cached } from '@/nip05.ts'; import { booleanParamSchema } from '@/schema.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { dedupeEvents, Time } from '@/utils.ts'; +import { lookupNip05Cached } from '@/utils/nip05.ts'; /** Matches NIP-05 names with or without an @ in front. */ const ACCT_REGEX = /^@?(?:([\w.+-]+)@)?([\w.-]+)$/; diff --git a/src/db.ts b/src/db.ts index d14a9bcf..78b4c1c5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -39,6 +39,7 @@ interface UserRow { pubkey: string; username: string; inserted_at: Date; + admin: boolean; } interface RelayRow { diff --git a/src/db/migrations/003_events_admin.ts b/src/db/migrations/003_events_admin.ts new file mode 100644 index 00000000..9183322c --- /dev/null +++ b/src/db/migrations/003_events_admin.ts @@ -0,0 +1,12 @@ +import { Kysely } from '@/deps.ts'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('users') + .addColumn('admin', 'boolean', (col) => col.defaultTo(false)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('relays').execute(); +} diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 003ad488..dad0d0ae 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -1,69 +1,48 @@ -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 { type AppContext, type AppMiddleware } from '@/app.ts'; +import { HTTPException } from '@/deps.ts'; +import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts'; +import { localRequest } from '@/utils/web.ts'; +import { signNostrConnect } from '@/sign.ts'; +import { findUser } from '@/db/users.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(); }; } -const requireProof: AppMiddleware = async (c, next) => { - const pubkey = c.get('pubkey'); - const proof = c.get('proof'); +/** Require the user to prove they're an admin before invoking the controller. */ +const requireAdmin: AppMiddleware = async (c, next) => { + const header = c.req.headers.get('x-nostr-sign'); + const proof = c.get('proof') || header ? await obtainProof(c) : undefined; + const user = proof ? await findUser({ pubkey: proof.pubkey }) : undefined; - // if (!proof && hasWebsocket(c.req)) { - // // TODO: attempt to sign nip98 event through websocket - // } - - if (!pubkey || !proof || proof.pubkey !== pubkey) { + if (proof && user?.admin) { + c.set('pubkey', proof.pubkey); + c.set('proof', proof); + await next(); + } else { throw new HTTPException(401); } - - await next(); }; -export { auth98, requireProof }; +/** Get the proof over Nostr Connect. */ +async function obtainProof(c: AppContext) { + const req = localRequest(c); + const event = await buildAuthEventTemplate(req); + return signNostrConnect(event, c); +} + +export { auth98, requireAdmin }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 6b5c30c1..12948046 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,4 +1,4 @@ -import { verifySignature, z } from '@/deps.ts'; +import { getEventHash, verifySignature, z } from '@/deps.ts'; import { jsonSchema, safeUrlSchema } from '../schema.ts'; @@ -19,7 +19,9 @@ const eventSchema = z.object({ }); /** Nostr event schema that also verifies the event's signature. */ -const signedEventSchema = eventSchema.refine(verifySignature); +const signedEventSchema = eventSchema + .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') + .refine(verifySignature, 'Event signature is invalid'); /** Nostr relay filter schema. */ const filterSchema = z.object({ diff --git a/src/sign.ts b/src/sign.ts index 2a91a061..e260af75 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -5,14 +5,14 @@ import { type Event, type EventTemplate, finishEvent, HTTPException } from '@/de import { connectResponseSchema } from '@/schemas/nostr.ts'; import { jsonSchema } from '@/schema.ts'; import { Sub } from '@/subs.ts'; -import { Time } from '@/utils.ts'; +import { eventMatchesTemplate, Time } from '@/utils.ts'; import { createAdminEvent } from '@/utils/web.ts'; /** * Sign Nostr event using the app context. * * - If a secret key is provided, it will be used to sign the event. - * - If `X-Nostr-Sign` is passed, it will use a NIP-46 to sign the event. + * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. */ async function signEvent(event: EventTemplate, c: AppContext): Promise> { const seckey = c.get('seckey'); @@ -54,13 +54,14 @@ async function signNostrConnect(event: EventTemplate< tags: [['p', pubkey]], }, c); - return awaitSignedEvent(pubkey, messageId, c); + return awaitSignedEvent(pubkey, messageId, event, c); } /** Wait for signed event to be sent through Nostr relay. */ -function awaitSignedEvent( +async function awaitSignedEvent( pubkey: string, messageId: string, + template: EventTemplate, c: AppContext, ): Promise> { const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]); @@ -69,30 +70,26 @@ function awaitSignedEvent( Sub.close(messageId); } - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + const timeout = setTimeout(close, Time.minutes(1)); + + for await (const event of sub) { + const decrypted = await decryptAdmin(event.pubkey, event.content); + + const result = jsonSchema + .pipe(connectResponseSchema) + .refine((msg) => msg.id === messageId, 'Message ID mismatch') + .refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch') + .safeParse(decrypted); + + if (result.success) { close(); - reject( - new HTTPException(408, { - res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }), - }), - ); - }, Time.minutes(1)); + clearTimeout(timeout); + return result.data.result as Event; + } + } - (async () => { - for await (const event of sub) { - if (event.kind === 24133) { - const decrypted = await decryptAdmin(event.pubkey, event.content); - const msg = jsonSchema.pipe(connectResponseSchema).parse(decrypted); - - if (msg.id === messageId) { - close(); - clearTimeout(timeout); - resolve(msg.result as Event); - } - } - } - })(); + throw new HTTPException(408, { + res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }), }); } @@ -102,4 +99,4 @@ async function signAdminEvent(event: EventTemplate return finishEvent(event, Conf.seckey); } -export { signAdminEvent, signEvent }; +export { signAdminEvent, signEvent, signNostrConnect }; diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index 8a6384ad..ca8c4113 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -3,12 +3,12 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad import { Conf } from '@/config.ts'; import * as eventsDB from '@/db/events.ts'; import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; -import { verifyNip05Cached } from '@/nip05.ts'; import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; import { emojiTagSchema, filteredArray } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; +import { verifyNip05Cached } from '@/utils/nip05.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; diff --git a/src/utils.ts b/src/utils.ts index d6d24d8c..18362989 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ -import { type Event, nip19, z } from '@/deps.ts'; -import { lookupNip05Cached } from '@/nip05.ts'; +import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts'; import { getAuthor } from '@/queries.ts'; +import { lookupNip05Cached } from '@/utils/nip05.ts'; /** Get the current time in Nostr format. */ const nostrNow = (): number => Math.floor(Date.now() / 1000); @@ -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 { @@ -106,11 +106,17 @@ function dedupeEvents(events: Event[]): Event[] { return [...new Map(events.map((event) => [event.id, event])).values()]; } +/** Ensure the template and event match on their shared keys. */ +function eventMatchesTemplate(event: Event, template: EventTemplate): boolean { + return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template }); +} + export { bech32ToPubkey, dedupeEvents, eventAge, eventDateComparator, + eventMatchesTemplate, findTag, isFollowing, isRelay, diff --git a/src/nip05.ts b/src/utils/nip05.ts similarity index 100% rename from src/nip05.ts rename to src/utils/nip05.ts diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts new file mode 100644 index 00000000..e1cae5d6 --- /dev/null +++ b/src/utils/nip98.ts @@ -0,0 +1,63 @@ +import { type Event, type EventTemplate } from '@/deps.ts'; +import { decode64Schema, jsonSchema } from '@/schema.ts'; +import { signedEventSchema } from '@/schemas/nostr.ts'; +import { eventAge, findTag, nostrNow, 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); +} + +/** Create an auth EventTemplate from a Request. */ +async function buildAuthEventTemplate(req: Request): Promise> { + const { method, url } = req; + const payload = await req.clone().text().then(sha256); + + return { + kind: 27235, + content: '', + tags: [ + ['method', method], + ['u', url], + ['payload', payload], + ], + created_at: nostrNow(), + }; +} + +/** 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 { buildAuthEventTemplate, 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, +};