From 03395adf8535afe558875b354c84d4f38efe3bff Mon Sep 17 00:00:00 2001 From: dtonon Date: Tue, 25 Aug 2026 18:47:11 +0100 Subject: [PATCH] Implement login via bunker --- .env.example | 1 + package-lock.json | 9 +- package.json | 3 +- src/lib/auth.svelte.ts | 90 ++++++++++++- src/lib/bunker.ts | 100 +++++++++++++++ src/lib/components/LoginModal.svelte | 183 ++++++++++++++++++++++++++- src/lib/config.ts | 9 ++ 7 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 src/lib/bunker.ts diff --git a/.env.example b/.env.example index 96a08a7..802684c 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ PUBLIC_SSR_CACHE_STALE=21600 PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support) PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question) +PUBLIC_NOSTRCONNECT_RELAYS= # comma-separated relays for nostrconnect:// (bunker) login; defaults to bucket.coracle.social,relay.primal.net PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net) # Theme color overrides — quote the value, a leading # is read as a comment otherwise PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived diff --git a/package-lock.json b/package-lock.json index 8d2cf8d..81958d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@djot/djot": "^0.3.2", "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8", - "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3" + "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3", + "uqr": "^0.1.3" }, "devDependencies": { "@eslint/compat": "^2.0.4", @@ -4022,6 +4023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 2452a67..961ad12 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "dependencies": { "@djot/djot": "^0.3.2", "@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8", - "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3" + "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3", + "uqr": "^0.1.3" }, "overrides": { "lightningcss": "^1.33.0" diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 2fc1a23..700b3b2 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -4,6 +4,14 @@ import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core"; import * as nip19 from "@nostr/tools/nip19"; import { finalizeEvent, getPublicKey } from "@nostr/tools/pure"; import { resetJoinState, initJoinForUser } from "$lib/join.svelte"; +import { + connectBunker, + restoreBunker, + startNostrConnect, + type BunkerSession, + type NostrConnect, +} from "$lib/bunker"; +import type { BunkerSigner } from "@nostr/tools/nip46"; declare global { interface Window { @@ -18,6 +26,8 @@ export type Signer = { let user = $state(null); let signer = $state(null); +// Kept outside $state (see makeExtensionSigner) so it can be closed on logout +let bunker: BunkerSigner | null = null; let loginModalOpen = $state(false); // Optional action to run once login succeeds, so an intent like "post" started // while logged out resumes (login => join => composer) instead of being dropped. @@ -45,6 +55,8 @@ export const auth = { const PUBKEY_KEY = "nostr_pubkey"; const METHOD_KEY = "nostr_login_method"; const NSEC_KEY = "nostr_nsec"; +const BUNKER_KEY = "nostr_bunker"; +const BUNKER_CLIENT_KEY = "nostr_bunker_client_key"; export function openLogin(after?: () => void) { afterLogin = after ?? null; @@ -94,6 +106,30 @@ function makeExtensionSigner(): Signer { }; } +function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer { + return { + async getPublicKey() { + return pubkey; + }, + async signEvent(template) { + return remote.signEvent(template); + }, + }; +} + +function closeBunker() { + bunker?.close().catch(() => {}); + bunker = null; +} + +function clearStoredSession() { + localStorage.removeItem(PUBKEY_KEY); + localStorage.removeItem(METHOD_KEY); + localStorage.removeItem(NSEC_KEY); + localStorage.removeItem(BUNKER_KEY); + localStorage.removeItem(BUNKER_CLIENT_KEY); +} + async function setUser(pubkey: string) { const { loadNostrUser } = await import("@nostr/gadgets/metadata"); user = await loadNostrUser(pubkey); @@ -105,10 +141,11 @@ export async function loginWithExtension() { throw new Error("No Nostr extension found"); } const pubkey = await window.nostr.getPublicKey(); + closeBunker(); signer = makeExtensionSigner(); + clearStoredSession(); localStorage.setItem(PUBKEY_KEY, pubkey); localStorage.setItem(METHOD_KEY, "extension"); - localStorage.removeItem(NSEC_KEY); await setUser(pubkey); sessionEpoch++; runAfterLogin(); @@ -137,7 +174,9 @@ export async function loginWithNsec(input: string) { const sk = parseSecretKey(input); const nsec = nip19.nsecEncode(sk); const pubkey = getPublicKey(sk); + closeBunker(); signer = makeNsecSigner(sk); + clearStoredSession(); localStorage.setItem(PUBKEY_KEY, pubkey); localStorage.setItem(METHOD_KEY, "nsec"); localStorage.setItem(NSEC_KEY, nsec); @@ -146,12 +185,45 @@ export async function loginWithNsec(input: string) { runAfterLogin(); } +async function loginWithBunkerSession(session: BunkerSession) { + closeBunker(); + bunker = session.signer; + signer = makeBunkerSigner(session.signer, session.pubkey); + clearStoredSession(); + localStorage.setItem(PUBKEY_KEY, session.pubkey); + localStorage.setItem(METHOD_KEY, "bunker"); + localStorage.setItem(BUNKER_KEY, session.bunkerUrl); + localStorage.setItem(BUNKER_CLIENT_KEY, session.clientSecretKey); + await setUser(session.pubkey); + sessionEpoch++; + runAfterLogin(); +} + +// Pasted bunker:// URL or a NIP-05 that advertises one +export async function loginWithBunker(input: string) { + await loginWithBunkerSession(await connectBunker(input)); +} + +// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops +// waiting when the user leaves the view. +export function loginWithNostrConnect(): { + uri: string; + done: Promise; + cancel: () => void; +} { + const nc: NostrConnect = startNostrConnect(); + return { + uri: nc.uri, + done: nc.session.then(loginWithBunkerSession), + cancel: nc.cancel, + }; +} + export function logout() { user = null; signer = null; - localStorage.removeItem(PUBKEY_KEY); - localStorage.removeItem(METHOD_KEY); - localStorage.removeItem(NSEC_KEY); + closeBunker(); + clearStoredSession(); resetJoinState(); sessionEpoch++; } @@ -171,6 +243,16 @@ export async function restoreSession() { } catch { return; } + } else if (method === "bunker") { + const url = localStorage.getItem(BUNKER_KEY); + const clientKey = localStorage.getItem(BUNKER_CLIENT_KEY); + if (!url || !clientKey) return; + try { + bunker = await restoreBunker(url, clientKey); + } catch { + return; + } + signer = makeBunkerSigner(bunker, pubkey); } else { signer = makeExtensionSigner(); } diff --git a/src/lib/bunker.ts b/src/lib/bunker.ts new file mode 100644 index 0000000..11184dc --- /dev/null +++ b/src/lib/bunker.ts @@ -0,0 +1,100 @@ +import { + BunkerSigner, + createNostrConnectURI, + parseBunkerInput, + toBunkerURL, +} from "@nostr/tools/nip46"; +import { generateSecretKey, getPublicKey } from "@nostr/tools/pure"; +import { bytesToHex, hexToBytes } from "@nostr/tools/utils"; +import { NOSTRCONNECT_RELAYS } from "$lib/config"; + +// NIP-46 remote signing. Two ways in: the user pastes a bunker:// URL (or a +// NIP-05 that advertises one), or the app shows a nostrconnect:// URI and the +// signer app calls back. Either way the result is a live BunkerSigner plus the +// data needed to reconnect silently next time: the bunker URL (secret +// stripped) and the client key the conversation is bound to. + +export type BunkerSession = { + signer: BunkerSigner; + pubkey: string; + bunkerUrl: string; + clientSecretKey: string; +}; + +const signerParams = { + onauth: (url: string) => { + window.open(url, "_blank"); + }, +}; + +function stripSecret(url: string): string { + const u = new URL(url); + u.searchParams.delete("secret"); + return u.toString(); +} + +export async function connectBunker(input: string): Promise { + const bp = await parseBunkerInput(input.trim()); + if (!bp) throw new Error("Invalid bunker"); + if (bp.relays.length === 0) throw new Error("Bunker URL has no relay"); + const sk = generateSecretKey(); + const signer = BunkerSigner.fromBunker(sk, bp, signerParams); + try { + await signer.connect(); + const pubkey = await signer.getPublicKey(); + return { + signer, + pubkey, + bunkerUrl: stripSecret(toBunkerURL(bp)), + clientSecretKey: bytesToHex(sk), + }; + } catch (e) { + signer.close().catch(() => {}); + throw e; + } +} + +export type NostrConnect = { + uri: string; + // Resolves once a signer answers the URI; rejects on abort or relay close + session: Promise; + cancel: () => void; +}; + +export function startNostrConnect(): NostrConnect { + const sk = generateSecretKey(); + const uri = createNostrConnectURI({ + clientPubkey: getPublicKey(sk), + relays: NOSTRCONNECT_RELAYS, + secret: Math.random().toString(36).substring(2, 10), + name: location.host, + url: location.origin, + }); + const abort = new AbortController(); + const session = BunkerSigner.fromURI( + sk, + uri, + signerParams, + abort.signal, + ).then(async (signer) => { + const pubkey = await signer.getPublicKey(); + return { + signer, + pubkey, + bunkerUrl: stripSecret(toBunkerURL(signer.bp)), + clientSecretKey: bytesToHex(sk), + }; + }); + return { uri, session, cancel: () => abort.abort() }; +} + +// Rebuilds the signer from stored data without a connect round-trip: the +// bunker already knows this client key, so the first sign request is enough. +export async function restoreBunker( + bunkerUrl: string, + clientSecretKey: string, +): Promise { + const bp = await parseBunkerInput(bunkerUrl); + if (!bp) throw new Error("Invalid bunker"); + return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams); +} diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte index 01941a4..fdcb025 100644 --- a/src/lib/components/LoginModal.svelte +++ b/src/lib/components/LoginModal.svelte @@ -2,25 +2,53 @@ import { auth, closeLogin, + loginWithBunker, loginWithExtension, + loginWithNostrConnect, loginWithNsec, } from "$lib/auth.svelte"; - import { tick } from "svelte"; + import { onDestroy, tick } from "svelte"; + import { renderSVG } from "uqr"; - let view = $state<"extension" | "nsec">("extension"); + type View = "extension" | "bunker" | "nsec"; + + let view = $state("extension"); let nsec = $state(""); + let bunkerUrl = $state(""); let error = $state(null); let busy = $state(false); + let copied = $state(false); let nsecInput = $state(null); + let bunkerInput = $state(null); + + // Client-initiated NIP-46 flow, alive only while the bunker view is shown + let connect = $state | null>(null); + const qrSvg = $derived( + connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "", + ); const hasExtension = $derived( typeof window !== "undefined" && !!window.nostr, ); + const titles: Record = { + extension: "Log in", + bunker: "Log in with a bunker", + nsec: "Log in with nsec", + }; + + function stopConnect() { + connect?.cancel(); + connect = null; + } + function reset() { + stopConnect(); view = "extension"; nsec = ""; + bunkerUrl = ""; error = null; + copied = false; } async function handleExtension() { @@ -53,6 +81,50 @@ } } + async function handleBunker() { + if (busy || !bunkerUrl.trim()) return; + error = null; + busy = true; + try { + await loginWithBunker(bunkerUrl); + closeLogin(); + reset(); + } catch (e) { + error = + e instanceof Error ? e.message : "Failed to connect to the bunker"; + } finally { + busy = false; + } + } + + function startConnect() { + stopConnect(); + const nc = loginWithNostrConnect(); + connect = nc; + nc.done + .then(() => { + if (connect !== nc) return; + closeLogin(); + reset(); + }) + .catch((e) => { + if (connect !== nc) return; // cancelled by leaving the view + connect = null; + error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`; + }); + } + + async function copyUri() { + if (!connect) return; + try { + await navigator.clipboard.writeText(connect.uri); + copied = true; + setTimeout(() => (copied = false), 2000); + } catch { + error = "Could not copy to the clipboard"; + } + } + function onClose() { if (busy) return; closeLogin(); @@ -60,13 +132,23 @@ } async function showNsecView() { + stopConnect(); view = "nsec"; error = null; await tick(); nsecInput?.focus(); } + async function showBunkerView() { + view = "bunker"; + error = null; + startConnect(); + await tick(); + bunkerInput?.focus(); + } + function showExtensionView() { + stopConnect(); view = "extension"; error = null; } @@ -75,6 +157,8 @@ if (!auth.loginModalOpen) return; if (e.key === "Escape") onClose(); } + + onDestroy(stopConnect); @@ -88,7 +172,7 @@ onclick={onClose} >