diff --git a/deno.json b/deno.json index ac13eb4..d7b454f 100644 --- a/deno.json +++ b/deno.json @@ -24,6 +24,7 @@ "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.22.4", + "@scure/base": "npm:@scure/base@^1.1.6", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", @@ -51,7 +52,7 @@ "linkifyjs": "npm:linkifyjs@^4.1.1", "lru-cache": "npm:lru-cache@^10.2.2", "nostr-relaypool": "npm:nostr-relaypool2@0.6.34", - "nostr-tools": "npm:nostr-tools@^2.5.1", + "nostr-tools": "npm:nostr-tools@2.5.1", "nostr-wasm": "npm:nostr-wasm@^0.1.0", "tldts": "npm:tldts@^6.0.14", "tseep": "npm:tseep@^1.2.1", diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index c1407f4..242646b 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -1,12 +1,14 @@ -import { encodeBase64 } from '@std/encoding/base64'; +import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify'; +import { bech32 } from '@scure/base'; import { escape } from 'entities'; -import { nip19 } from 'nostr-tools'; +import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; +import { DittoDB } from '@/db/DittoDB.ts'; import { nostrNow } from '@/utils.ts'; import { parseBody } from '@/utils/api.ts'; -import { getClientConnectUri } from '@/utils/connect.ts'; +import { Storages } from '@/storages.ts'; const passwordGrantSchema = z.object({ grant_type: z.literal('password'), @@ -22,10 +24,18 @@ const credentialsGrantSchema = z.object({ grant_type: z.literal('client_credentials'), }); +const nostrGrantSchema = z.object({ + grant_type: z.literal('nostr_bunker'), + pubkey: n.id(), + relays: z.string().url().array().optional(), + secret: z.string().optional(), +}); + const createTokenSchema = z.discriminatedUnion('grant_type', [ passwordGrantSchema, codeGrantSchema, credentialsGrantSchema, + nostrGrantSchema, ]); const createTokenController: AppController = async (c) => { @@ -37,6 +47,13 @@ const createTokenController: AppController = async (c) => { } switch (result.data.grant_type) { + case 'nostr_bunker': + return c.json({ + access_token: await getToken(result.data), + token_type: 'Bearer', + scope: 'read write follow push', + created_at: nostrNow(), + }); case 'password': return c.json({ access_token: result.data.password, @@ -61,50 +78,63 @@ const createTokenController: AppController = async (c) => { } }; +async function getToken( + { pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, +): Promise<`token1${string}`> { + const kysely = await DittoDB.getInstance(); + const token = generateToken(); + + const serverSeckey = generateSecretKey(); + const serverPubkey = getPublicKey(serverSeckey); + + const signer = new NConnectSigner({ + pubkey, + signer: new NSecSigner(serverSeckey), + 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(), + }).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 = async (c) => { +const oauthController: AppController = (c) => { const encodedUri = c.req.query('redirect_uri'); if (!encodedUri) { return c.text('Missing `redirect_uri` query param.', 422); } const redirectUri = maybeDecodeUri(encodedUri); - const connectUri = await getClientConnectUri(c.req.raw.signal); - - const script = ` - window.addEventListener('load', function() { - if ('nostr' in window) { - nostr.getPublicKey().then(function(pubkey) { - document.getElementById('pubkey').value = pubkey; - document.getElementById('oauth_form').submit(); - }); - } - }); - `; - - const hash = encodeBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(script))); - - c.res.headers.set( - 'content-security-policy', - `default-src 'self' 'sha256-${hash}'`, - ); return c.html(` Log in with Ditto -
- - +
-
- Nostr Connect `); @@ -125,16 +155,8 @@ function maybeDecodeUri(uri: string): string { /** Schema for FormData POSTed to the OAuthController. */ const oauthAuthorizeSchema = z.object({ - pubkey: z.string().regex(/^[0-9a-f]{64}$/).optional().catch(undefined), - nip19: z.string().regex(new RegExp(`^${nip19.BECH32_REGEX.source}$`)).optional().catch(undefined), + bunker_uri: z.string().url().refine((v) => v.startsWith('bunker://')), redirect_uri: z.string().url(), -}).superRefine((data, ctx) => { - if (!data.pubkey && !data.nip19) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Missing `pubkey` or `nip19`.', - }); - } }); /** Controller the OAuth form is POSTed to. */ @@ -147,18 +169,19 @@ const oauthAuthorizeController: AppController = async (c) => { } // Parsed FormData values. - const { pubkey, nip19: nip19id, redirect_uri: redirectUri } = result.data; + const { bunker_uri, redirect_uri: redirectUri } = result.data; - if (pubkey) { - const encoded = nip19.npubEncode(pubkey!); - const url = addCodeToRedirectUri(redirectUri, encoded); - return c.redirect(url); - } else if (nip19id) { - const url = addCodeToRedirectUri(redirectUri, nip19id); - return c.redirect(url); - } + const bunker = new URL(bunker_uri); - return c.text('The Nostr ID was not provided or invalid.', 422); + const token = await getToken({ + pubkey: bunker.hostname, + secret: bunker.searchParams.get('secret') || undefined, + relays: bunker.searchParams.getAll('relay'), + }); + + const url = addCodeToRedirectUri(redirectUri, token); + + return c.redirect(url); }; /** Append the given `code` as a query param to the `redirect_uri`. */ diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index c2d1f86..65bc426 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -2,6 +2,7 @@ export interface DittoTables { nostr_events: EventRow; nostr_tags: TagRow; nostr_fts5: EventFTSRow; + nip46_tokens: NIP46TokenRow; unattached_media: UnattachedMediaRow; author_stats: AuthorStatsRow; event_stats: EventStatsRow; @@ -44,6 +45,15 @@ interface TagRow { value: string; } +interface NIP46TokenRow { + api_token: string; + user_pubkey: string; + server_seckey: Uint8Array; + server_pubkey: string; + relays: string; + connected_at: Date; +} + interface UnattachedMediaRow { id: string; pubkey: string; diff --git a/src/db/migrations/023_add_nip46_tokens.ts b/src/db/migrations/023_add_nip46_tokens.ts new file mode 100644 index 0000000..144bd1e --- /dev/null +++ b/src/db/migrations/023_add_nip46_tokens.ts @@ -0,0 +1,17 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('nip46_tokens') + .addColumn('api_token', 'text', (col) => col.primaryKey().unique().notNull()) + .addColumn('user_pubkey', 'text', (col) => col.notNull()) + .addColumn('server_seckey', 'bytea', (col) => col.notNull()) + .addColumn('server_pubkey', 'text', (col) => col.notNull()) + .addColumn('relays', 'text', (col) => col.defaultTo('[]')) + .addColumn('connected_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('nip46_tokens').execute(); +} diff --git a/src/middleware/auth98Middleware.ts b/src/middleware/auth98Middleware.ts index abecea7..34d6937 100644 --- a/src/middleware/auth98Middleware.ts +++ b/src/middleware/auth98Middleware.ts @@ -3,7 +3,7 @@ import { HTTPException } from 'hono'; import { type AppContext, type AppMiddleware } from '@/app.ts'; import { findUser, User } from '@/db/users.ts'; -import { ConnectSigner } from '@/signers/ConnectSigner.ts'; +import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { localRequest } from '@/utils/api.ts'; import { buildAuthEventTemplate, @@ -22,7 +22,7 @@ function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware { const result = await parseAuthRequest(req, opts); if (result.success) { - c.set('signer', new ConnectSigner(result.data.pubkey)); + c.set('signer', new ReadOnlySigner(result.data.pubkey)); c.set('proof', result.data); } @@ -70,7 +70,8 @@ function withProof( opts?: ParseAuthRequestOpts, ): AppMiddleware { return async (c, next) => { - const pubkey = await c.get('signer')?.getPublicKey(); + const signer = c.get('signer'); + const pubkey = await signer?.getPublicKey(); const proof = c.get('proof') || await obtainProof(c, opts); // Prevent people from accidentally using the wrong account. This has no other security implications. @@ -79,8 +80,12 @@ function withProof( } if (proof) { - c.set('signer', new ConnectSigner(proof.pubkey)); c.set('proof', proof); + + if (!signer) { + c.set('signer', new ReadOnlySigner(proof.pubkey)); + } + await handler(c, proof, next); } else { throw new HTTPException(401, { message: 'No proof' }); diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 1d35708..5ea4235 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -1,11 +1,11 @@ import { NSecSigner } from '@nostrify/nostrify'; -import { Stickynotes } from '@soapbox/stickynotes'; import { nip19 } from 'nostr-tools'; import { AppMiddleware } from '@/app.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; - -const console = new Stickynotes('ditto:signerMiddleware'); +import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; +import { HTTPException } from 'hono'; +import { DittoDB } from '@/db/DittoDB.ts'; /** We only accept "Bearer" type. */ const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); @@ -18,22 +18,38 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { if (match) { const [_, bech32] = match; - try { - const decoded = nip19.decode(bech32!); + if (bech32.startsWith('token1')) { + try { + const kysely = await DittoDB.getInstance(); - switch (decoded.type) { - case 'npub': - c.set('signer', new ConnectSigner(decoded.data)); - break; - case 'nprofile': - c.set('signer', new ConnectSigner(decoded.data.pubkey, decoded.data.relays)); - break; - case 'nsec': - c.set('signer', new NSecSigner(decoded.data)); - break; + const { user_pubkey, server_seckey, relays } = await kysely + .selectFrom('nip46_tokens') + .select(['user_pubkey', 'server_seckey', 'relays']) + .where('api_token', '=', bech32) + .executeTakeFirstOrThrow(); + + c.set('signer', new ConnectSigner(user_pubkey, new NSecSigner(server_seckey), JSON.parse(relays))); + } catch { + throw new HTTPException(401); + } + } else { + try { + const decoded = nip19.decode(bech32!); + + switch (decoded.type) { + case 'npub': + c.set('signer', new ReadOnlySigner(decoded.data)); + break; + case 'nprofile': + c.set('signer', new ReadOnlySigner(decoded.data.pubkey)); + break; + case 'nsec': + c.set('signer', new NSecSigner(decoded.data)); + break; + } + } catch { + throw new HTTPException(401); } - } catch { - console.debug('The user is not logged in'); } } diff --git a/src/signers/ConnectSigner.ts b/src/signers/ConnectSigner.ts index f482413..d4cf603 100644 --- a/src/signers/ConnectSigner.ts +++ b/src/signers/ConnectSigner.ts @@ -1,7 +1,6 @@ // deno-lint-ignore-file require-await import { NConnectSigner, NostrEvent, NostrSigner } from '@nostrify/nostrify'; -import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; /** @@ -12,17 +11,17 @@ import { Storages } from '@/storages.ts'; export class ConnectSigner implements NostrSigner { private signer: Promise; - constructor(private pubkey: string, private relays?: string[]) { - this.signer = this.init(); + constructor(private pubkey: string, signer: NostrSigner, private relays?: string[]) { + this.signer = this.init(signer); } - async init(): Promise { + async init(signer: NostrSigner): Promise { return new NConnectSigner({ pubkey: this.pubkey, // TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list) relay: await Storages.pubsub(), - signer: new AdminSigner(), - timeout: 60000, + signer, + timeout: 60_000, }); } diff --git a/src/signers/ReadOnlySigner.ts b/src/signers/ReadOnlySigner.ts new file mode 100644 index 0000000..8ba1555 --- /dev/null +++ b/src/signers/ReadOnlySigner.ts @@ -0,0 +1,17 @@ +// deno-lint-ignore-file require-await +import { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import { HTTPException } from 'hono'; + +export class ReadOnlySigner implements NostrSigner { + constructor(private pubkey: string) {} + + async signEvent(): Promise { + throw new HTTPException(401, { + message: 'Log out and back in', + }); + } + + async getPublicKey(): Promise { + return this.pubkey; + } +}