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;
+ }
+}