diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index 94aaeecd..c4c3eca4 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -1,14 +1,14 @@ import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify'; -import { bech32 } from '@scure/base'; import { escape } from 'entities'; -import { generateSecretKey, getPublicKey } from 'nostr-tools'; +import { generateSecretKey } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; import { parseBody } from '@/utils/api.ts'; -import { Storages } from '@/storages.ts'; +import { encryptSecretKey, generateToken } from '@/utils/auth.ts'; const passwordGrantSchema = z.object({ grant_type: z.literal('password'), @@ -82,38 +82,30 @@ async function getToken( { pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, ): Promise<`token1${string}`> { const kysely = await Storages.kysely(); - const token = generateToken(); + const { token, hash } = await generateToken(); - const serverSeckey = generateSecretKey(); - const serverPubkey = getPublicKey(serverSeckey); + const nip46Seckey = generateSecretKey(); const signer = new NConnectSigner({ pubkey, - signer: new NSecSigner(serverSeckey), + signer: new NSecSigner(nip46Seckey), relay: await Storages.pubsub(), // TODO: Use the relays from the request. timeout: 60_000, }); await signer.connect(secret); - await kysely.insertInto('nip46_tokens').values({ - api_token: token, - user_pubkey: pubkey, - server_seckey: serverSeckey, - server_pubkey: serverPubkey, - relays: JSON.stringify(relays), - connected_at: new Date(), + await kysely.insertInto('auth_tokens').values({ + token_hash: hash, + pubkey, + nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey), + nip46_relays: relays, + created_at: new Date(), }).execute(); return token; } -/** Generate a bech32 token for the API. */ -function generateToken(): `token1${string}` { - const words = bech32.toWords(generateSecretKey()); - return bech32.encode('token', words); -} - /** Display the OAuth form. */ const oauthController: AppController = (c) => { const encodedUri = c.req.query('redirect_uri'); diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index cee7c57e..9693a16c 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -14,6 +14,7 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; +import { getTokenHash } from '@/utils/auth.ts'; import { bech32ToPubkey, Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; @@ -233,14 +234,15 @@ async function topicToFilter( async function getTokenPubkey(token: string): Promise { if (token.startsWith('token1')) { const kysely = await Storages.kysely(); + const tokenHash = await getTokenHash(token as `token1${string}`); - const { user_pubkey } = await kysely - .selectFrom('nip46_tokens') - .select(['user_pubkey', 'server_seckey', 'relays']) - .where('api_token', '=', token) + const { pubkey } = await kysely + .selectFrom('auth_tokens') + .select('pubkey') + .where('token_hash', '=', tokenHash) .executeTakeFirstOrThrow(); - return user_pubkey; + return pubkey; } else { return bech32ToPubkey(token); } diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index c05ffe66..b6fa93f4 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -4,7 +4,7 @@ import { NPostgresSchema } from '@nostrify/db'; export interface DittoTables extends NPostgresSchema { nostr_events: NostrEventsRow; - nip46_tokens: NIP46TokenRow; + auth_tokens: AuthTokenRow; author_stats: AuthorStatsRow; event_stats: EventStatsRow; pubkey_domains: PubkeyDomainRow; @@ -33,13 +33,12 @@ interface EventStatsRow { zaps_amount: number; } -interface NIP46TokenRow { - api_token: string; - user_pubkey: string; - server_seckey: Uint8Array; - server_pubkey: string; - relays: string; - connected_at: Date; +interface AuthTokenRow { + token_hash: Uint8Array; + pubkey: string; + nip46_sk_enc: Uint8Array; + nip46_relays: string[]; + created_at: Date; } interface PubkeyDomainRow { diff --git a/src/db/migrations/037_auth_tokens.ts b/src/db/migrations/037_auth_tokens.ts new file mode 100644 index 00000000..9df133f5 --- /dev/null +++ b/src/db/migrations/037_auth_tokens.ts @@ -0,0 +1,52 @@ +import { Kysely, sql } from 'kysely'; + +import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts'; +import { Conf } from '@/config.ts'; + +interface DB { + nip46_tokens: { + api_token: `token1${string}`; + user_pubkey: string; + server_seckey: Uint8Array; + server_pubkey: string; + relays: string; + connected_at: Date; + }; + auth_tokens: { + token_hash: Uint8Array; + pubkey: string; + nip46_sk_enc: Uint8Array; + nip46_relays: string[]; + created_at: Date; + }; +} + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('auth_tokens') + .addColumn('token_hash', 'bytea', (col) => col.primaryKey()) + .addColumn('pubkey', 'char(64)', (col) => col.notNull()) + .addColumn('nip46_sk_enc', 'bytea', (col) => col.notNull()) + .addColumn('nip46_relays', 'jsonb', (col) => col.defaultTo('[]')) + .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .execute(); + + // There are probably not that many tokens in the database yet, so this should be fine. + const tokens = await db.selectFrom('nip46_tokens').selectAll().execute(); + + for (const token of tokens) { + await db.insertInto('auth_tokens').values({ + token_hash: await getTokenHash(token.api_token), + pubkey: token.user_pubkey, + nip46_sk_enc: await encryptSecretKey(Conf.seckey, token.server_seckey), + nip46_relays: JSON.parse(token.relays), + created_at: token.connected_at, + }).execute(); + } + + await db.schema.dropTable('nip46_tokens').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('auth_tokens').execute(); +} diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 344e14ef..8200ae1d 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -3,9 +3,11 @@ import { NSecSigner } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AppMiddleware } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { Storages } from '@/storages.ts'; +import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts'; /** We only accept "Bearer" type. */ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); @@ -21,14 +23,17 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { if (bech32.startsWith('token1')) { try { const kysely = await Storages.kysely(); + const tokenHash = await getTokenHash(bech32 as `token1${string}`); - const { user_pubkey, server_seckey, relays } = await kysely - .selectFrom('nip46_tokens') - .select(['user_pubkey', 'server_seckey', 'relays']) - .where('api_token', '=', bech32) + const { pubkey, nip46_sk_enc, nip46_relays } = await kysely + .selectFrom('auth_tokens') + .select(['pubkey', 'nip46_sk_enc', 'nip46_relays']) + .where('token_hash', '=', tokenHash) .executeTakeFirstOrThrow(); - c.set('signer', new ConnectSigner(user_pubkey, new NSecSigner(server_seckey), JSON.parse(relays))); + const nep46Seckey = await decryptSecretKey(Conf.seckey, nip46_sk_enc); + + c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays)); } catch { throw new HTTPException(401); } diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index c33da877..f83fcddb 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -1,9 +1,10 @@ import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { encodeHex } from '@std/encoding/hex'; import { EventTemplate, nip13 } from 'nostr-tools'; import { decode64Schema } from '@/schema.ts'; import { signedEventSchema } from '@/schemas/nostr.ts'; -import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts'; +import { eventAge, findTag, nostrNow } from '@/utils.ts'; import { Time } from '@/utils/time.ts'; /** Decode a Nostr event from a base64 encoded string. */ @@ -41,11 +42,10 @@ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthReque .refine((event) => pow ? nip13.getPow(event.id) >= pow : true, 'Insufficient proof of work') .refine(validateBody, 'Event payload does not match request body'); - function validateBody(event: NostrEvent) { + async function validateBody(event: NostrEvent): Promise { if (!validatePayload) return true; - return req.clone().text() - .then(sha256) - .then((hash) => hash === tagValue(event, 'payload')); + const payload = await getPayload(req); + return payload === tagValue(event, 'payload'); } return schema.safeParseAsync(event); @@ -62,7 +62,7 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = ]; if (validatePayload) { - const payload = await req.clone().text().then(sha256); + const payload = await getPayload(req); tags.push(['payload', payload]); } @@ -74,6 +74,14 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = }; } +/** Get a SHA-256 hash of the request body encoded as a hex string. */ +async function getPayload(req: Request): Promise { + const text = await req.clone().text(); + const bytes = new TextEncoder().encode(text); + const buffer = await crypto.subtle.digest('SHA-256', bytes); + return encodeHex(buffer); +} + /** Get the value for the first matching tag name in the event. */ function tagValue(event: NostrEvent, tagName: string): string | undefined { return findTag(event.tags, tagName)?.[1];