Implement login via bunker
This commit is contained in:
parent
f734ff030c
commit
03395adf85
7 changed files with 382 additions and 13 deletions
|
|
@ -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
100
src/lib/bunker.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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__;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue