Implement login via bunker

This commit is contained in:
dtonon 2026-08-25 18:47:11 +01:00
parent f734ff030c
commit 03395adf85
7 changed files with 382 additions and 13 deletions

View file

@ -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<NostrUser | null>(null);
let signer = $state<Signer | null>(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<void>;
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();
}

100
src/lib/bunker.ts Normal file
View file

@ -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<BunkerSession> {
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<BunkerSession>;
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<BunkerSigner> {
const bp = await parseBunkerInput(bunkerUrl);
if (!bp) throw new Error("Invalid bunker");
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
}

View file

@ -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<View>("extension");
let nsec = $state("");
let bunkerUrl = $state("");
let error = $state<string | null>(null);
let busy = $state(false);
let copied = $state(false);
let nsecInput = $state<HTMLInputElement | null>(null);
let bunkerInput = $state<HTMLInputElement | null>(null);
// Client-initiated NIP-46 flow, alive only while the bunker view is shown
let connect = $state<ReturnType<typeof loginWithNostrConnect> | null>(null);
const qrSvg = $derived(
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
);
const hasExtension = $derived(
typeof window !== "undefined" && !!window.nostr,
);
const titles: Record<View, string> = {
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);
</script>
<svelte:window onkeydown={onKeydown} />
@ -88,7 +172,7 @@
onclick={onClose}
></button>
<div
class="relative w-full max-w-sm rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
@ -97,13 +181,16 @@
type="button"
onclick={onClose}
aria-label="Close"
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300"
>
×
</button>
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{view === "extension" ? "Log in" : "Log in with nsec"}
<h2
id="login-title"
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
{titles[view]}
</h2>
{#if error}
@ -128,6 +215,14 @@
No Nostr extension detected in this browser.
</p>
{/if}
<button
type="button"
onclick={showBunkerView}
disabled={busy}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
Log in with a bunker
</button>
<button
type="button"
onclick={showNsecView}
@ -135,6 +230,80 @@
>
Or log in using your nsec
</button>
{:else if view === "bunker"}
{#if connect}
<a
href={connect.uri}
aria-label="Open in your signer app"
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
>
{@html qrSvg}
</a>
<button
type="button"
onclick={copyUri}
aria-label={copied ? "Copied" : "Copy the connection string"}
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<span class="min-w-0 flex-1 truncate font-mono">{connect.uri}</span>
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
</button>
<p
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
aria-live="polite"
>
Scan or paste this in your signer app, then approve the connection.
</p>
{/if}
<div class="my-4 flex items-center gap-3" aria-hidden="true">
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
<span class="text-xs text-neutral-400">or</span>
<div
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
></div>
</div>
<label
for="bunker-input"
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
>
Paste a bunker URL
</label>
<input
id="bunker-input"
bind:this={bunkerInput}
type="text"
placeholder="bunker://…"
bind:value={bunkerUrl}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleBunker()}
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleBunker}
disabled={busy || !bunkerUrl.trim()}
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in"}
</button>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
A NIP-05 address that points to a bunker works too.
</p>
<button
type="button"
onclick={showExtensionView}
disabled={busy}
class="text-accent mt-4 block w-full text-center text-sm hover:underline disabled:opacity-50"
>
Back
</button>
{:else}
<label for="nsec-input" class="sr-only">nsec</label>
<input
@ -149,7 +318,7 @@
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleNsec()}
class="focus:ring-accent w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
<button
onclick={handleNsec}

View file

@ -18,6 +18,15 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
if (MODE === "simple" && !GROUP_ID) {
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
}
// Relays used to hand a nostrconnect:// URI to a remote signer (NIP-46).
// Comma-separated override via PUBLIC_NOSTRCONNECT_RELAYS.
export const NOSTRCONNECT_RELAYS: string[] = (
env.PUBLIC_NOSTRCONNECT_RELAYS ||
"wss://bucket.coracle.social,wss://relay.primal.net"
)
.split(",")
.map((r) => r.trim())
.filter(Boolean);
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
export const SSR_ENABLED = __SQUALK_SSR__;