diff --git a/docs/login-methods.md b/docs/login-methods.md index b21efe2..4b0143c 100644 --- a/docs/login-methods.md +++ b/docs/login-methods.md @@ -1,10 +1,14 @@ # BitLogin as the Single Login Surface — NIP-07 and NIP-46 Methods -**Document version:** 0.2 -**Status:** §LM4 (NIP-07) is **implemented** (2026-08-04): `signers/` in -`@bitlogin/widget`, thin sessions per §LM7, password-first presentation per -§LM9.1. §LM5 (NIP-46) remains a proposal. §LM6 records a settled rejection -(nsec paste as a login method), kept on the record. +**Document version:** 0.3 +**Status:** §LM4 (NIP-07) and §LM5 (NIP-46, memory-only phase) are +**implemented** (2026-08-04): `signers/` in `@bitlogin/widget`, the NIP-46 +client in `@bitlogin/core`'s `nostr/nip46.ts` with the ephemeral key held in +the crypto worker, both `bunker://` paste and the nostrconnect QR (rendered +as dot-style inline SVG), thin sessions per §LM7, password-first per §LM9.1. +NIP-46 *persistence* (§LM5.3 phase two) remains gated on the external vault +review. §LM6 records a settled rejection (nsec paste as a login method), +kept on the record. **Depends on:** `docs/spec.md` (§ references point there unless marked §LM or §SF), `docs/second-factor.md` §SF10 (imported identities, implemented) @@ -214,9 +218,11 @@ not propose. 1. **NIP-07** — smallest increment: detection, delegation, capability probing, arbitration inversion. No new secrets, no relay traffic, no - review gate touched. -2. **NIP-46, memory-only** — connect ceremony, pending/timeout UX, typed - errors. Still nothing persisted. + review gate touched. **Shipped 2026-08-04.** +2. **NIP-46, memory-only** — connect ceremony (bunker:// paste and a + nostrconnect QR), pending/timeout UX, typed errors. Still nothing + persisted. **Shipped 2026-08-04.** Transport is NIP-44 only, per the + current NIP-46 spec; legacy NIP-04-only bunkers are not supported. 3. **NIP-46 persistence as a vault profile** — after the external security review the vault is already gated on. diff --git a/package-lock.json b/package-lock.json index f263174..e7a3dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1991,14 +1991,14 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@noble/ciphers": "^2.2.0", - "@noble/curves": "^1.9.1", - "@noble/hashes": "^1.7.1", - "@scure/base": "^1.2.6", - "@scure/bip39": "^1.5.4", - "canonicalize": "^2.0.0", - "diceware-wordlist-en-eff": "^1.0.1", - "hash-wasm": "^4.12.0" + "@noble/ciphers": "2.2.0", + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@scure/bip39": "1.6.0", + "canonicalize": "2.1.0", + "diceware-wordlist-en-eff": "1.0.1", + "hash-wasm": "4.12.0" }, "devDependencies": { "@types/node": "^22.10.0", @@ -2019,12 +2019,19 @@ "license": "MIT", "dependencies": { "@bitlogin/core": "*", - "@getalby/bitcoin-connect": "^3.12.3" + "@getalby/bitcoin-connect": "3.12.3", + "qrcode-generator": "^2.0.4" }, "devDependencies": { "typescript": "^5.7.3", "vite": "^6.1.0" } + }, + "packages/widget/node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" } } } diff --git a/packages/core/src/nostr/index.ts b/packages/core/src/nostr/index.ts index a8d8a4c..f8a578a 100644 --- a/packages/core/src/nostr/index.ts +++ b/packages/core/src/nostr/index.ts @@ -6,3 +6,4 @@ export * from "./relayUrl.js"; export * from "./pool.js"; export * from "./bootstrap.js"; export * from "./nip19.js"; +export * from "./nip46.js"; diff --git a/packages/core/src/nostr/kinds.ts b/packages/core/src/nostr/kinds.ts index a850094..ca90c9b 100644 --- a/packages/core/src/nostr/kinds.ts +++ b/packages/core/src/nostr/kinds.ts @@ -5,6 +5,7 @@ export const KIND_DELETION_REQUEST = 5; // NIP-09 export const KIND_RELAY_LIST = 10002; // NIP-65 export const KIND_DM_RELAY_LIST = 10050; // NIP-17 export const KIND_AUTH = 22242; // NIP-42 +export const KIND_NOSTR_CONNECT = 24133; // NIP-46 remote signing export const KIND_GIFT_WRAP = 1059; // NIP-59 (Phase 2 messaging, not wired up by this MVP) export const KIND_APP_DATA = 30078; // NIP-78 diff --git a/packages/core/src/nostr/nip46.test.ts b/packages/core/src/nostr/nip46.test.ts new file mode 100644 index 0000000..2dcb28d --- /dev/null +++ b/packages/core/src/nostr/nip46.test.ts @@ -0,0 +1,326 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MockRelay } from "../test-support/mockRelay.js"; +import { RelayConnection } from "./relay.js"; +import { signNostrEvent, type NostrEvent } from "./event.js"; +import { KIND_NOSTR_CONNECT } from "./kinds.js"; +import { + buildNostrconnectUri, + listenForNostrconnect, + Nip46Client, + Nip46ErrorResponse, + Nip46RequestTimeoutError, + parseBunkerUri +} from "./nip46.js"; +import { getConversationKey, nip44Decrypt, nip44Encrypt } from "../crypto/nip44.js"; +import { generatePrivateKey, getPublicKeyHex } from "../crypto/secp256k1.js"; +import { randomBytes } from "../crypto/random.js"; +import { bytesToHex } from "../crypto/encoding.js"; + +const SIGNER_HEX = "ab".repeat(32); + +describe("parseBunkerUri", () => { + it("parses signer pubkey, relays, and secret", () => { + const parsed = parseBunkerUri( + `bunker://${SIGNER_HEX}?relay=wss://one.example&relay=wss://two.example&secret=tok3n` + ); + expect(parsed.signerPubkey).toBe(SIGNER_HEX); + expect(parsed.relayUrls).toEqual(["wss://one.example", "wss://two.example"]); + expect(parsed.secret).toBe("tok3n"); + }); + + it("accepts an uppercase pubkey and omitted secret", () => { + const parsed = parseBunkerUri(`bunker://${SIGNER_HEX.toUpperCase()}?relay=wss://one.example`); + expect(parsed.signerPubkey).toBe(SIGNER_HEX); + expect(parsed.secret).toBeUndefined(); + }); + + it("rejects wrong schemes, bad pubkeys, and relay-less URIs", () => { + expect(() => parseBunkerUri(`nostrconnect://${SIGNER_HEX}?relay=wss://x.example`)).toThrow( + /bunker:\/\// + ); + expect(() => parseBunkerUri("bunker://nothex?relay=wss://x.example")).toThrow(/public key/); + expect(() => parseBunkerUri(`bunker://${SIGNER_HEX}`)).toThrow(/no relays/); + expect(() => parseBunkerUri("not a uri at all")).toThrow(/valid URI/); + }); +}); + +describe("buildNostrconnectUri", () => { + it("round-trips through URL parsing with relays, secret, and name", () => { + const uri = buildNostrconnectUri({ + clientPubkey: SIGNER_HEX, + relayUrls: ["wss://one.example", "wss://two.example"], + secret: "s3cret", + name: "BitLogin" + }); + expect(uri.startsWith(`nostrconnect://${SIGNER_HEX}?`)).toBe(true); + const url = new URL(uri); + expect(url.searchParams.getAll("relay")).toEqual(["wss://one.example", "wss://two.example"]); + expect(url.searchParams.get("secret")).toBe("s3cret"); + expect(url.searchParams.get("name")).toBe("BitLogin"); + }); +}); + +/** + * A minimal in-test remote signer: listens for kind-24133 requests addressed + * to its pubkey, decrypts them per client, and answers per `handle`. + */ +class FakeBunker { + readonly signerKey = generatePrivateKey(); + readonly signerPubkey = getPublicKeyHex(this.signerKey); + readonly userKey = generatePrivateKey(); + readonly userPubkey = getPublicKeyHex(this.userKey); + readonly seen: Array<{ method: string; params: string[] }> = []; + private conn: RelayConnection; + private sub: { close: () => void } | null = null; + /** Per-request interception; return the responses to send (in order). */ + handle: (request: { id: string; method: string; params: string[] }) => Array<{ + id: string; + result?: string; + error?: string; + }>; + + constructor(relayUrl: string, private connectSecret = "") { + this.conn = new RelayConnection(relayUrl); + this.handle = (request) => { + this.seen.push({ method: request.method, params: request.params }); + if (request.method === "connect") { + if (this.connectSecret && request.params[1] !== this.connectSecret) { + return [{ id: request.id, error: "invalid secret" }]; + } + return [{ id: request.id, result: "ack" }]; + } + if (request.method === "get_public_key") return [{ id: request.id, result: this.userPubkey }]; + if (request.method === "sign_event") { + const unsigned = JSON.parse(request.params[0]!) as { + kind: number; + content: string; + tags: string[][]; + created_at: number; + }; + const signed = signNostrEvent( + { + pubkey: this.userPubkey, + created_at: unsigned.created_at, + kind: unsigned.kind, + tags: unsigned.tags, + content: unsigned.content + }, + this.userKey + ); + return [{ id: request.id, result: JSON.stringify(signed) }]; + } + if (request.method === "nip44_encrypt") { + return [{ id: request.id, result: `cipher(${request.params[1]})` }]; + } + return [{ id: request.id, error: `unsupported method ${request.method}` }]; + }; + } + + async start(): Promise { + this.sub = await this.conn.subscribeLive( + { kinds: [KIND_NOSTR_CONNECT], "#p": [this.signerPubkey] }, + (event) => void this.onRequest(event) + ); + } + + async respond(clientPubkey: string, payload: { id: string; result?: string; error?: string }): Promise { + const conversationKey = getConversationKey(this.signerKey, clientPubkey); + const event = signNostrEvent( + { + pubkey: this.signerPubkey, + created_at: Math.floor(Date.now() / 1000), + kind: KIND_NOSTR_CONNECT, + tags: [["p", clientPubkey]], + content: nip44Encrypt(conversationKey, JSON.stringify(payload)) + }, + this.signerKey + ); + await this.conn.publish(event); + } + + private async onRequest(event: NostrEvent): Promise { + const conversationKey = getConversationKey(this.signerKey, event.pubkey); + const request = JSON.parse(nip44Decrypt(conversationKey, event.content)) as { + id: string; + method: string; + params: string[]; + }; + for (const response of this.handle(request)) { + await this.respond(event.pubkey, response); + } + } + + close(): void { + this.sub?.close(); + this.conn.close(); + } +} + +describe("Nip46Client against a fake bunker", () => { + let relay: MockRelay; + let bunker: FakeBunker; + let client: Nip46Client; + + beforeEach(async () => { + relay = await MockRelay.start(); + }); + + afterEach(async () => { + client?.close(); + bunker?.close(); + await relay.close(); + }); + + function makeClient(secret?: string, timeoutMs = 5000): Nip46Client { + client = new Nip46Client({ + clientSecretKey: generatePrivateKey(), + pointer: { signerPubkey: bunker.signerPubkey, relayUrls: [relay.url], secret }, + requestTimeoutMs: timeoutMs + }); + return client; + } + + it("connects with the secret, fetches the user pubkey, and signs a verifying event", async () => { + bunker = new FakeBunker(relay.url, "tok3n"); + await bunker.start(); + const c = makeClient("tok3n"); + await c.connect(); + expect(await c.getUserPublicKey()).toBe(bunker.userPubkey); + + const signed = await c.signEvent({ + kind: 1, + content: "hello", + tags: [], + created_at: 1_700_000_000 + }); + expect(signed.pubkey).toBe(bunker.userPubkey); + expect(signed.content).toBe("hello"); + // The user's pubkey travels with subsequent sign requests once known. + const signParams = bunker.seen.find((r) => r.method === "sign_event")!.params; + expect(JSON.parse(signParams[0]!).pubkey).toBe(bunker.userPubkey); + }); + + it("rejects a wrong connect secret with the signer's error", async () => { + bunker = new FakeBunker(relay.url, "tok3n"); + await bunker.start(); + const c = makeClient("wrong"); + await expect(c.connect()).rejects.toBeInstanceOf(Nip46ErrorResponse); + }); + + it("surfaces auth_url once and still resolves the eventual answer", async () => { + bunker = new FakeBunker(relay.url); + const inner = bunker.handle.bind(bunker); + bunker.handle = (request) => { + if (request.method === "get_public_key") { + return [ + { id: request.id, result: "auth_url", error: "https://approve.example/req" }, + // Same id, later: the real answer after "approval". + ...inner(request) + ]; + } + return inner(request); + }; + await bunker.start(); + + const authUrls: string[] = []; + client = new Nip46Client({ + clientSecretKey: generatePrivateKey(), + pointer: { signerPubkey: bunker.signerPubkey, relayUrls: [relay.url] }, + onAuthUrl: (url) => authUrls.push(url), + requestTimeoutMs: 5000 + }); + expect(await client.getUserPublicKey()).toBe(bunker.userPubkey); + expect(authUrls).toEqual(["https://approve.example/req"]); + }); + + it("times out with a typed error when the signer never answers", async () => { + bunker = new FakeBunker(relay.url); + bunker.handle = () => []; // listens, never replies + await bunker.start(); + const c = makeClient(undefined, 300); + await expect(c.connect()).rejects.toBeInstanceOf(Nip46RequestTimeoutError); + }); + + it("rejects a signed event from the wrong identity", async () => { + bunker = new FakeBunker(relay.url); + const impostorKey = generatePrivateKey(); + bunker.handle = (request) => { + bunker.seen.push({ method: request.method, params: request.params }); + if (request.method === "connect") return [{ id: request.id, result: "ack" }]; + if (request.method === "get_public_key") return [{ id: request.id, result: bunker.userPubkey }]; + const unsigned = JSON.parse(request.params[0]!) as { kind: number; content: string; tags: string[][]; created_at: number }; + const signed = signNostrEvent( + { + pubkey: getPublicKeyHex(impostorKey), + created_at: unsigned.created_at, + kind: unsigned.kind, + tags: unsigned.tags, + content: unsigned.content + }, + impostorKey + ); + return [{ id: request.id, result: JSON.stringify(signed) }]; + }; + await bunker.start(); + const c = makeClient(); + await c.connect(); + await c.getUserPublicKey(); + await expect( + c.signEvent({ kind: 1, content: "x", tags: [], created_at: 1_700_000_000 }) + ).rejects.toThrow(/different identity/); + }); + + it("passes nip44 encryption RPCs through", async () => { + bunker = new FakeBunker(relay.url); + await bunker.start(); + const c = makeClient(); + await c.connect(); + expect(await c.nip44Encrypt("cd".repeat(32), "hi")).toBe("cipher(hi)"); + }); +}); + +describe("listenForNostrconnect", () => { + let relay: MockRelay; + + beforeEach(async () => { + relay = await MockRelay.start(); + }); + + afterEach(async () => { + await relay.close(); + }); + + it("resolves the signer pubkey when the secret is echoed, ignoring wrong secrets", async () => { + const clientSecretKey = generatePrivateKey(); + const clientPubkey = getPublicKeyHex(clientSecretKey); + const secret = bytesToHex(randomBytes(8)); + + const wait = listenForNostrconnect({ + clientSecretKey, + relayUrls: [relay.url], + secret, + timeoutMs: 5000 + }); + + const impostor = new FakeBunker(relay.url); + await impostor.respond(clientPubkey, { id: "x", result: "not-the-secret" }); + const genuine = new FakeBunker(relay.url); + await genuine.respond(clientPubkey, { id: "y", result: secret }); + + const { signerPubkey } = await wait; + expect(signerPubkey).toBe(genuine.signerPubkey); + impostor.close(); + genuine.close(); + }); + + it("times out when no signer connects", async () => { + await expect( + listenForNostrconnect({ + clientSecretKey: generatePrivateKey(), + relayUrls: [relay.url], + secret: "s", + timeoutMs: 300 + }) + ).rejects.toThrow(/No signer connected/); + }); +}); diff --git a/packages/core/src/nostr/nip46.ts b/packages/core/src/nostr/nip46.ts new file mode 100644 index 0000000..18cf631 --- /dev/null +++ b/packages/core/src/nostr/nip46.ts @@ -0,0 +1,389 @@ +/** + * NIP-46 remote-signer ("bunker") client (docs/login-methods.md §LM5). + * + * The user's key lives in their signer (Amber, nsec.app, an nsecBunker …); this + * client holds only an ephemeral keypair and a connection secret, and requests + * signatures over relays as kind-24133 events whose content is NIP-44-encrypted + * between the client key and the remote signer's key. Memory-only by design: + * nothing here persists anything, and closing the client ends the session. + * + * Two ways to establish a session: + * - `bunker://` (parseBunkerUri): the user pastes a URI naming the signer's + * pubkey, relays, and an optional secret; the client calls `connect`. + * - nostrconnect (buildNostrconnectUri + listenForNostrconnect): the client + * displays a URI/QR naming ITS pubkey, relays, and a fresh secret; the + * signer initiates and proves itself by echoing the secret. + * + * Transport encryption is NIP-44 only, per the current NIP-46 spec; legacy + * NIP-04-only bunkers are not supported. + */ +import { RelayConnection } from "./relay.js"; +import { + signNostrEvent, + verifyNostrEvent, + type NostrEvent, + type NostrTag +} from "./event.js"; +import { KIND_NOSTR_CONNECT } from "./kinds.js"; +import { getConversationKey, nip44Encrypt, nip44Decrypt } from "../crypto/nip44.js"; +import { getPublicKeyHex } from "../crypto/secp256k1.js"; +import { randomBytes } from "../crypto/random.js"; +import { bytesToHex } from "../crypto/encoding.js"; + +const HEX64 = /^[0-9a-f]{64}$/u; + +export interface BunkerPointer { + signerPubkey: string; + relayUrls: string[]; + secret?: string; +} + +export class BunkerUriParseError extends Error { + override name = "BunkerUriParseError"; +} + +export class Nip46RequestTimeoutError extends Error { + override name = "Nip46RequestTimeoutError"; + constructor(method: string, timeoutMs: number) { + super( + `The remote signer did not answer ${method} within ${Math.round(timeoutMs / 1000)}s. It may be offline, or waiting for your approval on another device -- check your signer and try again.` + ); + } +} + +/** The signer answered, and the answer was a refusal or failure. */ +export class Nip46ErrorResponse extends Error { + override name = "Nip46ErrorResponse"; +} + +export function parseBunkerUri(uri: string): BunkerPointer { + let url: URL; + try { + url = new URL(uri.trim()); + } catch { + throw new BunkerUriParseError("That doesn't look like a valid URI."); + } + if (url.protocol !== "bunker:") { + throw new BunkerUriParseError("A remote-signer URI must start with bunker://"); + } + // Non-special schemes parse inconsistently across engines: the pubkey may + // land in host or in pathname ("//"). Accept both. + const signerPubkey = (url.host || url.pathname.replace(/^\/+/u, "")).toLowerCase(); + if (!HEX64.test(signerPubkey)) { + throw new BunkerUriParseError("The bunker URI does not name a valid signer public key."); + } + const relayUrls = url.searchParams + .getAll("relay") + .map((value) => value.trim()) + .filter(Boolean); + if (relayUrls.length === 0) { + throw new BunkerUriParseError( + "The bunker URI names no relays -- without at least one relay= parameter there is no way to reach the signer." + ); + } + const secret = url.searchParams.get("secret") ?? undefined; + return { signerPubkey, relayUrls, secret: secret || undefined }; +} + +/** The URI a signer app scans (or receives via paste) in the nostrconnect flow. + * Names the CLIENT's pubkey, the relays to answer on, and a fresh secret the + * signer must echo back to prove it scanned this exact code. */ +export function buildNostrconnectUri(options: { + clientPubkey: string; + relayUrls: string[]; + secret: string; + name?: string; + perms?: string[]; +}): string { + if (!HEX64.test(options.clientPubkey)) throw new Error("Client public key must be 64 hex characters."); + if (options.relayUrls.length === 0) throw new Error("At least one relay is required."); + if (!options.secret) throw new Error("A nostrconnect secret is required."); + const params = new URLSearchParams(); + for (const relay of options.relayUrls) params.append("relay", relay); + params.set("secret", options.secret); + if (options.name) params.set("name", options.name); + if (options.perms?.length) params.set("perms", options.perms.join(",")); + return `nostrconnect://${options.clientPubkey}?${params.toString()}`; +} + +interface PendingRequest { + resolve: (result: string) => void; + reject: (error: Error) => void; + timer: ReturnType; + authUrlSeen: boolean; +} + +export interface Nip46ClientOptions { + clientSecretKey: Uint8Array; + pointer: BunkerPointer; + /** Called (once per request) when the signer asks for interactive approval + * at a URL before it will answer -- surface it to the user and keep waiting. */ + onAuthUrl?: (url: string) => void; + requestTimeoutMs?: number; +} + +/** Deadline generous enough for a human to approve a prompt on another device, + * but finite -- no silent hangs (§LM3). */ +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; + +export class Nip46Client { + private readonly clientSecretKey: Uint8Array; + private readonly clientPubkey: string; + private readonly pointer: BunkerPointer; + private readonly conversationKey: Uint8Array; + private readonly conns: RelayConnection[]; + private readonly onAuthUrl?: (url: string) => void; + private readonly requestTimeoutMs: number; + private readonly pending = new Map(); + private subs: Array<{ close: () => void }> = []; + private startPromise: Promise | null = null; + private userPubkeyKnown: string | null = null; + private closed = false; + + constructor(options: Nip46ClientOptions) { + this.clientSecretKey = options.clientSecretKey; + this.clientPubkey = getPublicKeyHex(options.clientSecretKey); + this.pointer = options.pointer; + this.conversationKey = getConversationKey(options.clientSecretKey, options.pointer.signerPubkey); + this.onAuthUrl = options.onAuthUrl; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.conns = options.pointer.relayUrls.map( + (url) => new RelayConnection(url, { authPrivateKey: options.clientSecretKey }) + ); + } + + /** The user pubkey this session signs for, once get_public_key has answered. */ + get userPublicKey(): string | null { + return this.userPubkeyKnown; + } + + private async start(): Promise { + if (this.closed) throw new Error("This signer connection is closed."); + if (!this.startPromise) { + this.startPromise = (async () => { + const filter = { + kinds: [KIND_NOSTR_CONNECT], + authors: [this.pointer.signerPubkey], + "#p": [this.clientPubkey] + }; + const results = await Promise.allSettled( + this.conns.map((conn) => conn.subscribeLive(filter, (event) => this.handleResponse(event))) + ); + this.subs = results + .filter((r): r is PromiseFulfilledResult<{ close: () => void }> => r.status === "fulfilled") + .map((r) => r.value); + if (this.subs.length === 0) { + this.startPromise = null; + throw new Error("Could not reach any of the signer's relays."); + } + })(); + } + return this.startPromise; + } + + private handleResponse(event: NostrEvent): void { + // The subscription filter already constrains author and #p; re-check the + // author anyway -- relays are untrusted (§16.1) and this is the identity + // the whole session's security rests on. + if (event.pubkey !== this.pointer.signerPubkey) return; + let payload: { id?: unknown; result?: unknown; error?: unknown }; + try { + payload = JSON.parse(nip44Decrypt(this.conversationKey, event.content)) as typeof payload; + } catch { + return; + } + if (typeof payload.id !== "string") return; + const request = this.pending.get(payload.id); + if (!request) return; // answered already by a faster relay, or stale + if (payload.result === "auth_url") { + // Interim, not an answer: the signer wants interactive approval at the + // URL (carried in `error`, per NIP-46). Surface once, keep waiting. + if (!request.authUrlSeen) { + request.authUrlSeen = true; + if (typeof payload.error === "string" && payload.error) this.onAuthUrl?.(payload.error); + } + return; + } + this.pending.delete(payload.id); + clearTimeout(request.timer); + if (typeof payload.error === "string" && payload.error) { + request.reject(new Nip46ErrorResponse(payload.error)); + return; + } + if (typeof payload.result !== "string") { + request.reject(new Error("The signer returned a malformed response.")); + return; + } + request.resolve(payload.result); + } + + private async rpc(method: string, params: string[]): Promise { + await this.start(); + const id = bytesToHex(randomBytes(8)); + const content = nip44Encrypt(this.conversationKey, JSON.stringify({ id, method, params })); + const tags: NostrTag[] = [["p", this.pointer.signerPubkey]]; + const event = signNostrEvent( + { + pubkey: this.clientPubkey, + created_at: Math.floor(Date.now() / 1000), + kind: KIND_NOSTR_CONNECT, + tags, + content + }, + this.clientSecretKey + ); + + const wait = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Nip46RequestTimeoutError(method, this.requestTimeoutMs)); + }, this.requestTimeoutMs); + this.pending.set(id, { resolve, reject, timer, authUrlSeen: false }); + }); + // Swallow the local rejection path used below; callers await `wait` only + // when at least one relay accepted the request. + wait.catch(() => {}); + + const publishes = await Promise.allSettled(this.conns.map((conn) => conn.publish(event))); + const accepted = publishes.some((p) => p.status === "fulfilled" && p.value.ok); + if (!accepted) { + const request = this.pending.get(id); + if (request) { + this.pending.delete(id); + clearTimeout(request.timer); + } + throw new Error("No relay accepted the request to the signer."); + } + return wait; + } + + /** bunker:// flow: announce ourselves; any non-error answer is acceptance. */ + async connect(): Promise { + await this.rpc("connect", [this.pointer.signerPubkey, this.pointer.secret ?? ""]); + } + + async getUserPublicKey(): Promise { + const result = (await this.rpc("get_public_key", [])).trim().toLowerCase(); + if (!HEX64.test(result)) throw new Error("The signer returned an invalid public key."); + this.userPubkeyKnown = result; + return result; + } + + async signEvent(unsigned: { + kind: number; + content: string; + tags: string[][]; + created_at: number; + }): Promise { + const toSign = this.userPubkeyKnown ? { ...unsigned, pubkey: this.userPubkeyKnown } : unsigned; + const result = await this.rpc("sign_event", [JSON.stringify(toSign)]); + let event: unknown; + try { + event = JSON.parse(result); + } catch { + throw new Error("The signer returned a malformed signed event."); + } + if (!verifyNostrEvent(event)) { + throw new Error("The signer returned an event that does not verify."); + } + if (this.userPubkeyKnown && event.pubkey !== this.userPubkeyKnown) { + throw new Error("The signer returned an event signed by a different identity."); + } + return event; + } + + async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise { + return this.rpc("nip44_encrypt", [peerPublicKey, plaintext]); + } + + async nip44Decrypt(peerPublicKey: string, payload: string): Promise { + return this.rpc("nip44_decrypt", [peerPublicKey, payload]); + } + + async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise { + return this.rpc("nip04_encrypt", [peerPublicKey, plaintext]); + } + + async nip04Decrypt(peerPublicKey: string, payload: string): Promise { + return this.rpc("nip04_decrypt", [peerPublicKey, payload]); + } + + close(): void { + this.closed = true; + for (const sub of this.subs) sub.close(); + this.subs = []; + for (const conn of this.conns) conn.close(); + for (const [, request] of this.pending) { + clearTimeout(request.timer); + request.reject(new Error("The signer connection was closed.")); + } + this.pending.clear(); + } +} + +/** + * nostrconnect flow: after the URI from buildNostrconnectUri is shown to the + * signer (QR or paste), wait for ANY signer to send a kind-24133 event to the + * client pubkey whose decrypted payload echoes the secret. The echo is the + * proof of possession -- an attacker who saw the client pubkey on a relay but + * not the URI cannot produce it. Resolves the signer's pubkey; hand it to + * Nip46Client as a BunkerPointer (same clientSecretKey) to run the session. + */ +export async function listenForNostrconnect(options: { + clientSecretKey: Uint8Array; + relayUrls: string[]; + secret: string; + timeoutMs?: number; +}): Promise<{ signerPubkey: string }> { + const clientPubkey = getPublicKeyHex(options.clientSecretKey); + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const conns = options.relayUrls.map( + (url) => new RelayConnection(url, { authPrivateKey: options.clientSecretKey }) + ); + const closeAll = () => { + for (const conn of conns) conn.close(); + }; + + return new Promise<{ signerPubkey: string }>((resolve, reject) => { + let settled = false; + const settle = (outcome: { signerPubkey: string } | Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + closeAll(); + if (outcome instanceof Error) reject(outcome); + else resolve(outcome); + }; + const timer = setTimeout( + () => + settle( + new Error( + `No signer connected within ${Math.round(timeoutMs / 1000)}s. Scan or paste the connection code in your signer app, then try again.` + ) + ), + timeoutMs + ); + + const filter = { kinds: [KIND_NOSTR_CONNECT], "#p": [clientPubkey] }; + const onEvent = (event: NostrEvent) => { + // The sender is unknown until the secret verifies, so the conversation + // key is derived per candidate event. + let payload: { result?: unknown }; + try { + const conversationKey = getConversationKey(options.clientSecretKey, event.pubkey); + payload = JSON.parse(nip44Decrypt(conversationKey, event.content)) as typeof payload; + } catch { + return; + } + if (payload.result === options.secret) settle({ signerPubkey: event.pubkey }); + }; + + void Promise.allSettled(conns.map((conn) => conn.subscribeLive(filter, onEvent))).then( + (results) => { + if (!results.some((r) => r.status === "fulfilled")) { + settle(new Error("Could not reach any relay to listen for the signer.")); + } + } + ); + }); +} diff --git a/packages/core/src/nostr/relay.ts b/packages/core/src/nostr/relay.ts index f605864..49cf29a 100644 --- a/packages/core/src/nostr/relay.ts +++ b/packages/core/src/nostr/relay.ts @@ -56,7 +56,14 @@ export class RelayConnection { private connectPromise: Promise | null = null; private subs = new Map< string, - { events: NostrEvent[]; filter: NostrFilter; onEose: () => void } + { + events: NostrEvent[]; + filter: NostrFilter; + onEose: () => void; + /** Present on live subscriptions (subscribeLive): each verified, + * filter-matching event is delivered here instead of buffered. */ + onEvent?: (event: NostrEvent) => void; + } >(); private pendingPublishes = new Map void>(); private readonly authPrivateKey?: Uint8Array; @@ -86,6 +93,13 @@ export class RelayConnection { socket.addEventListener("open", () => { clearTimeout(timer); + // Re-issue REQ for every subscription that predates this (re)connection. + // A socket that dropped and reopened lost its server-side subscriptions; + // without this, a live subscription (subscribeLive) would appear open + // client-side while the relay no longer delivers anything to it. + for (const [subId, sub] of this.subs) { + socket.send(JSON.stringify(["REQ", subId, sub.filter])); + } resolve(); }); socket.addEventListener("error", () => { @@ -136,8 +150,10 @@ export class RelayConnection { sub && verifyNostrEvent(event) && matchesNostrFilter(event, sub.filter) - ) - sub.events.push(event); + ) { + if (sub.onEvent) sub.onEvent(event); + else sub.events.push(event); + } return; } if (type === "EOSE") { @@ -207,6 +223,33 @@ export class RelayConnection { }); } + /** + * Opens a subscription that stays live past EOSE: every verified, + * filter-matching event is delivered to `onEvent` as it arrives, until the + * returned handle is closed. Survives socket drops -- connect() re-issues + * REQ for all open subscriptions on reopen -- though events published while + * the socket was down are only seen if the relay replays them on the new REQ. + */ + async subscribeLive( + filter: NostrFilter, + onEvent: (event: NostrEvent) => void, + ): Promise<{ close: () => void }> { + await this.connect(); + const subId = bytesToHex(randomBytes(8)); + this.subs.set(subId, { events: [], filter, onEose: () => {}, onEvent }); + this.send(["REQ", subId, filter]); + return { + close: () => { + if (!this.subs.delete(subId)) return; + try { + this.send(["CLOSE", subId]); + } catch { + // Socket already gone; the relay-side subscription died with it. + } + }, + }; + } + async queryOnce( filter: NostrFilter, timeoutMs = 8000, diff --git a/packages/core/src/test-support/mockRelay.ts b/packages/core/src/test-support/mockRelay.ts index 3481d1b..fe8e058 100644 --- a/packages/core/src/test-support/mockRelay.ts +++ b/packages/core/src/test-support/mockRelay.ts @@ -7,6 +7,7 @@ */ import { WebSocketServer, type WebSocket } from "ws"; import { compareNip01ReplacementOrder, verifyNostrEvent, findTagValue, type NostrEvent } from "../nostr/event.js"; +import { matchesNostrFilter, type NostrFilter } from "../nostr/relay.js"; function storageKey(event: NostrEvent): string { if (event.kind >= 30000 && event.kind < 40000) { @@ -28,6 +29,10 @@ export class MockRelay { private wss: WebSocketServer; private events = new Map(); private plainEvents: NostrEvent[] = []; + /** Open subscriptions per socket, for live post-EOSE delivery -- real relays + * push newly published events to every matching open REQ, and NIP-46 round + * trips depend on that. */ + private liveSubs = new Map>(); public requireAuthForKinds: Set = new Set(); /** Pubkeys whose publishes are refused. Models the partially-hostile relay * that accepts some writes and drops others -- the case that makes write @@ -88,6 +93,7 @@ export class MockRelay { } this.store(event); socket.send(JSON.stringify(["OK", event.id, true, ""])); + this.broadcast(event); return; } if (type === "REQ") { @@ -96,12 +102,34 @@ export class MockRelay { for (const event of this.unsolicitedQueryEvents) socket.send(JSON.stringify(["EVENT", subId, event])); for (const event of matches) socket.send(JSON.stringify(["EVENT", subId, event])); socket.send(JSON.stringify(["EOSE", subId])); + let subsForSocket = this.liveSubs.get(socket); + if (!subsForSocket) { + subsForSocket = new Map(); + this.liveSubs.set(socket, subsForSocket); + } + subsForSocket.set(subId, (filters[0] ?? {}) as NostrFilter); return; } if (type === "CLOSE") { + const [subId] = rest as [string]; + if (typeof subId === "string") this.liveSubs.get(socket)?.delete(subId); return; } }); + socket.on("close", () => { + this.liveSubs.delete(socket); + }); + } + + /** Pushes a just-published event to every open subscription it matches. */ + private broadcast(event: NostrEvent): void { + for (const [socket, subs] of this.liveSubs) { + for (const [subId, filter] of subs) { + if (matchesNostrFilter(event, filter)) { + socket.send(JSON.stringify(["EVENT", subId, event])); + } + } + } } private store(event: NostrEvent): void { diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 981294a..71f2c88 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -63,7 +63,7 @@ bl.addEventListener('bitlogin-login', (e) => { console.log('signed in as', e.detail.publicKey); - console.log('via', e.detail.method); // 'bitlogin' | 'nip07' + console.log('via', e.detail.method); // 'bitlogin' | 'nip07' | 'nip46' console.log('supports', e.detail.capabilities); // { nip44, nip04, getRelays } }); @@ -73,8 +73,9 @@ bl.addEventListener('bitlogin-logout', () => {
One login surface, several methods. If the visitor's browser has a NIP-07 signer extension (Alby, nos2x, …), the widget's welcome screen offers "Use your Nostr extension instead" - below the primary username/password sign-in — you embed one element and both kinds of users are - covered. detail.method tells you which way the user came in, and + below the primary username/password sign-in, and every visitor gets "Use a remote signer (bunker)" — + a NIP-46 connect screen with a scannable QR for phone signers (Amber, nsec.app) and a + bunker:// paste box. You embed one element and all three kinds of users are covered. detail.method tells you which way the user came in, and detail.capabilities what the active signer supports (some extensions lack nip44). Extension sessions are signer-only: signing and encryption work through the element exactly the same, but BitLogin account features — wallet connections, password rotation, diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index 958a1c2..7bae64f 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -30,12 +30,16 @@ deployment above. password rotation. - One login surface, several methods: if the visitor's browser has a NIP-07 signer extension, the welcome screen offers "Use your Nostr extension - instead" below the primary username/password path, and the element routes - `getPublicKey`/`signEvent`/`nip44*`/`nip04*` through the extension for that - session. Extension sessions are signer-only: no vault - (`requestNwcConnection` resolves null), no persistence (a reload ends the - session), and `window.nostr` is left to the extension. `bitlogin-login`'s - `detail.method` (`'bitlogin' | 'nip07'`) and `detail.capabilities` + instead" below the primary username/password path, and every visitor gets + "Use a remote signer (bunker)" -- a NIP-46 connect screen with a scannable + nostrconnect QR for phone signers (Amber, nsec.app, ...) and a bunker:// + paste box. The element routes `getPublicKey`/`signEvent`/`nip44*`/`nip04*` + through whichever signer backs the session. Alternative-method sessions are + signer-only: no vault (`requestNwcConnection` resolves null), no + persistence (a reload ends the session). NIP-07 sessions leave + `window.nostr` to the extension; NIP-46 sessions claim it, backed by the + worker-held remote-signer client. `bitlogin-login`'s `detail.method` + (`'bitlogin' | 'nip07' | 'nip46'`) and `detail.capabilities` (`{ nip44, nip04, getRelays }`) tell your app which backend is active and what it supports. - A `window.nostr` provider shaped like the de facto NIP-07 interface diff --git a/packages/widget/package.json b/packages/widget/package.json index 1317756..18629cd 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@bitlogin/core": "*", - "@getalby/bitcoin-connect": "3.12.3" + "@getalby/bitcoin-connect": "3.12.3", + "qrcode-generator": "^2.0.4" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 8125d8b..f8cfa1e 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -9,18 +9,22 @@ import { import { encodeNpub } from "@bitlogin/core/nostr"; import { randomUniformInt } from "@bitlogin/core/crypto"; import { WorkerClient } from "./worker/workerClient.js"; -import { createNip07Provider, type Nip07Provider } from "./provider.js"; +import { createElementRoutedProvider, type Nip07Provider } from "./provider.js"; import { readConfigFromElement } from "./config.js"; import { WIDGET_STYLES } from "./styles.js"; import { chooseWalletViaBitcoinConnect } from "./vault/bitcoinConnect.js"; import type { VaultConnectionSummary } from "./worker/protocol.js"; import { buildVaultIntegrityWarnings } from "./vault/integrityWarnings.js"; import { detectForeignNip07Provider, Nip07Signer } from "./signers/nip07.js"; -import type { SignerCapabilities, SignerMethod } from "./signers/types.js"; +import { Nip46Signer } from "./signers/nip46.js"; +import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js"; +import { renderQrSvg } from "./qr.js"; type Screen = | "welcome" | "extension-confirm" + | "bunker-connect" + | "bunker-confirm" | "import-key" | "create-name" | "create-credential" @@ -75,15 +79,22 @@ export class BitLoginAuthElement extends HTMLElement { private session: { publicKey: string; npub: string; accountId?: string; method: SignerMethod } | null = null; - // ---- NIP-07 extension sessions (docs/login-methods.md §LM4, §LM7) ---- - // `pending` holds the signer between "the extension answered getPublicKey" and the - // user confirming that npub is theirs; only `active` routes the public API. Thin by - // design: no capsule, no vault, no persistence -- a reload simply signs the user out, - // and the extension is asked again next visit. + // ---- Alternative-method sessions (docs/login-methods.md §LM4, §LM5, §LM7) ---- + // `pending*` state holds a candidate between "the signer answered" and the user + // confirming that npub is theirs; only `altSigner` routes the public API. Thin by + // design: no capsule, no vault, no persistence -- a reload simply signs the user + // out, and the signer is asked again next visit. private pendingExtensionSigner: Nip07Signer | null = null; - private activeExtensionSigner: Nip07Signer | null = null; + private altSigner: Signer | null = null; private extensionPreviewNpub = ""; private extensionPreviewPubkey = ""; + + // NIP-46 connect flow (§LM5). The QR/copyable nostrconnect URI, the signer's + // interactive-approval URL if one arrives mid-connect, and the confirmed-but- + // not-yet-adopted user identity. + private bunkerConnectUri = ""; + private bunkerAuthUrl = ""; + private pendingBunker: { userPubkey: string; npub: string } | null = null; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; private exportedNsec = ""; @@ -149,6 +160,13 @@ export class BitLoginAuthElement extends HTMLElement { sheet.replaceSync(WIDGET_STYLES); this.root.adoptedStyleSheets = [sheet]; this.worker = new WorkerClient(); + this.worker.onNotification = (notification) => { + if (notification.notify === "nip46-auth-url") { + // Arrives mid-connect, while nip46Connect is still pending (§LM5.1). + this.bunkerAuthUrl = notification.url; + this.render(); + } + }; } connectedCallback(): void { @@ -164,7 +182,10 @@ export class BitLoginAuthElement extends HTMLElement { this.root.addEventListener("change", (e) => void this.onFileChange(e)); this.render(); - this.installedProvider = createNip07Provider(this.worker, () => this.vaultRelayUrls); + // Routed through this element, not bound to the worker directly, so the + // provider follows the ACTIVE signer -- worker for BitLogin accounts, + // worker-held NIP-46 client for remote-signer sessions (§LM3, §LM5). + this.installedProvider = createElementRoutedProvider(this, () => this.vaultRelayUrls); if (!(window as unknown as { nostr?: unknown }).nostr) { // See claimSigner()'s doc comment: an extension can make this property // non-configurable, which makes even this guarded assignment throw. @@ -187,11 +208,11 @@ export class BitLoginAuthElement extends HTMLElement { // always), the extension's provider for a NIP-07 session (§LM3). Hosts holding a // reference to this element get the right backend either way. async getPublicKey(): Promise { - if (this.activeExtensionSigner) return this.activeExtensionSigner.getPublicKey(); + if (this.altSigner) return this.altSigner.getPublicKey(); return (await this.worker.getPublicKey()).publicKey; } async signEvent(event: { kind: number; tags?: string[][]; content: string; created_at?: number }) { - if (this.activeExtensionSigner) return this.activeExtensionSigner.signEvent(event); + if (this.altSigner) return this.altSigner.signEvent(event); return this.worker.signEvent(event); } /** @@ -203,11 +224,11 @@ export class BitLoginAuthElement extends HTMLElement { * racing other providers for that slot. */ async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise { - if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Encrypt(peerPublicKey, plaintext); + if (this.altSigner) return this.altSigner.nip44Encrypt(peerPublicKey, plaintext); return (await this.worker.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext; } async nip44Decrypt(peerPublicKey: string, payload: string): Promise { - if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Decrypt(peerPublicKey, payload); + if (this.altSigner) return this.altSigner.nip44Decrypt(peerPublicKey, payload); return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext; } /** @@ -216,19 +237,23 @@ export class BitLoginAuthElement extends HTMLElement { * (or their older DM code paths) that still expect NIP-04 rather than NIP-44. */ async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise { - if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Encrypt(peerPublicKey, plaintext); + if (this.altSigner) return this.altSigner.nip04Encrypt(peerPublicKey, plaintext); return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext; } async nip04Decrypt(peerPublicKey: string, payload: string): Promise { - if (this.activeExtensionSigner) return this.activeExtensionSigner.nip04Decrypt(peerPublicKey, payload); + if (this.altSigner) return this.altSigner.nip04Decrypt(peerPublicKey, payload); return (await this.worker.nip04Decrypt({ peerPublicKey, payload })).plaintext; } async logout(): Promise { - if (this.activeExtensionSigner) { - // Nothing to tear down beyond our own reference: the session held no worker - // state, persisted nothing, and never claimed window.nostr (§LM4). - this.activeExtensionSigner = null; + if (this.altSigner) { + // Thin sessions persist nothing (§LM7). A NIP-46 session additionally + // holds a live client in the worker to close, and claimed window.nostr + // (its backend is ours) -- releaseSigner is a safe no-op for NIP-07, + // which never claimed the slot (§LM4). + if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect(); + this.altSigner = null; this.session = null; + this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); this.goto("welcome"); return; @@ -259,7 +284,7 @@ export class BitLoginAuthElement extends HTMLElement { if (this.vaultRequest) throw new Error("A wallet connection request is already in progress."); // Extension sessions are signer-only (§LM7): no vault, and prompting a password // sign-in over an active session would be worse than an honest decline. - if (this.activeExtensionSigner) return null; + if (this.altSigner) return null; const origin = window.location.origin; const appName = options.appName?.trim() || window.location.hostname || "This app"; return new Promise((resolve) => { @@ -545,7 +570,7 @@ export class BitLoginAuthElement extends HTMLElement { * detail shape: which method granted the session and what it can do (§LM3). The * pre-existing `publicKey` field is unchanged for hosts written before `method`. */ private dispatchLogin(): void { - const capabilities: SignerCapabilities = this.activeExtensionSigner?.capabilities ?? { + const capabilities: SignerCapabilities = this.altSigner?.capabilities ?? { nip44: true, nip04: true, getRelays: true @@ -716,6 +741,15 @@ export class BitLoginAuthElement extends HTMLElement { case "goto-welcome": this.pendingRollback = null; this.pendingExtensionSigner = null; + // Backing out of an unfinished bunker connect abandons the worker-side + // attempt -- but never a live session (altSigner) that's merely + // navigating around. + if (!this.altSigner && (this.bunkerConnectUri || this.pendingBunker)) { + void this.worker.nip46Disconnect(); + } + this.pendingBunker = null; + this.bunkerConnectUri = ""; + this.bunkerAuthUrl = ""; this.goto("welcome"); return; case "extension-signin": @@ -723,6 +757,36 @@ export class BitLoginAuthElement extends HTMLElement { case "extension-continue": this.handleExtensionConfirm(); return; + case "goto-bunker": + this.pendingBunker = null; + this.bunkerConnectUri = ""; + this.bunkerAuthUrl = ""; + this.errorMessage = undefined; + this.goto("bunker-connect"); + void this.startNostrconnect(); + return; + case "bunker-continue": + this.handleBunkerConfirm(); + return; + case "copy-bunker-uri": { + const button = target; + const original = button.textContent ?? "Copy"; + const flash = (text: string) => { + button.textContent = text; + setTimeout(() => { + if (button.isConnected) button.textContent = original; + }, 2000); + }; + if (!this.bunkerConnectUri || !navigator.clipboard?.writeText) { + flash("Copy not available"); + return; + } + navigator.clipboard.writeText(this.bunkerConnectUri).then( + () => flash("Copied"), + () => flash("Copy failed") + ); + return; + } case "goto-dashboard": this.goto("dashboard"); return; @@ -811,6 +875,8 @@ export class BitLoginAuthElement extends HTMLElement { return await this.handleChangePasswordSubmit(); case "vault-import": return await this.handleVaultImportSubmit(); + case "bunker-connect": + return await this.handleBunkerSubmit(); default: return; } @@ -994,7 +1060,7 @@ export class BitLoginAuthElement extends HTMLElement { return; } this.pendingExtensionSigner = null; - this.activeExtensionSigner = signer; + this.altSigner = signer; this.session = { publicKey: this.extensionPreviewPubkey, npub: this.extensionPreviewNpub, method: "nip07" }; this.sessionWarnings = []; // A wallet request that was waiting on sign-in expected a BitLogin account; an @@ -1005,6 +1071,84 @@ export class BitLoginAuthElement extends HTMLElement { this.flashSuccess("dashboard", "Signed in"); } + /** + * nostrconnect leg of the remote-signer flow (§LM5.1): the worker mints an + * ephemeral client key and a secret, we show the resulting URI as a QR for a + * phone signer to scan, and the worker listens for whichever signer echoes + * the secret. Runs alongside the paste form -- whichever leg completes first + * wins, and the worker refuses a superseded listen. + */ + private async startNostrconnect(): Promise { + try { + const { uri } = await this.worker.nip46NostrconnectStart({ + appName: window.location.hostname || "BitLogin" + }); + if (this.screen !== "bunker-connect") return; // user navigated away + this.bunkerConnectUri = uri; + this.render(); + const { userPubkey } = await this.worker.nip46NostrconnectAwait(); + if (this.screen !== "bunker-connect" || this.pendingBunker) { + // The paste leg (or another flow entirely) won while we listened. + return; + } + this.adoptBunkerPreview(userPubkey); + } catch (err) { + // Only worth surfacing if the user is still looking at this screen and + // nothing else succeeded -- a superseded or abandoned listen is expected. + if (this.screen === "bunker-connect" && !this.pendingBunker && !this.session) { + this.bunkerConnectUri = ""; + this.fail(err); + } + } + } + + /** bunker:// paste leg (§LM5.1). */ + private async handleBunkerSubmit(): Promise { + const uri = this.field("bunkerUri").trim(); + if (!uri) { + this.errorMessage = "Paste a bunker:// address first."; + this.render(); + return; + } + this.setBusy(true); + this.bunkerAuthUrl = ""; + try { + const { userPubkey } = await this.worker.nip46Connect({ uri }); + this.busy = false; + this.adoptBunkerPreview(userPubkey); + } catch (err) { + this.fail(err); + } + } + + private adoptBunkerPreview(userPubkey: string): void { + this.pendingBunker = { userPubkey, npub: encodeNpub(userPubkey) }; + this.bunkerConnectUri = ""; + this.bunkerAuthUrl = ""; + this.errorMessage = undefined; + this.goto("bunker-confirm"); + } + + /** The user confirmed the identity their remote signer reported (§LM5). */ + private handleBunkerConfirm(): void { + const pending = this.pendingBunker; + if (!pending) { + this.goto("welcome"); + return; + } + this.pendingBunker = null; + this.altSigner = new Nip46Signer(this.worker, pending.userPubkey); + this.session = { publicKey: pending.userPubkey, npub: pending.npub, method: "nip46" }; + this.sessionWarnings = []; + if (this.vaultRequest) this.finishVaultRequest(null); + // Unlike NIP-07 there is no extension owning window.nostr here -- the + // element-routed provider IS this session's public surface, so claim the + // slot (best-effort, §LM4's claim semantics unchanged). + this.noteSignerClaim(this.claimSigner()); + this.dispatchLogin(); + this.flashSuccess("dashboard", "Signed in"); + } + /** * Called once per connectedCallback, right after "configure" -- restores whatever * a prior login/register/rotate cached locally (§21), so a page reload lands @@ -1253,6 +1397,7 @@ export class BitLoginAuthElement extends HTMLElement { ${extensionOption} + `; } @@ -1267,6 +1412,47 @@ export class BitLoginAuthElement extends HTMLElement { `; + case "bunker-connect": { + const authNotice = this.bunkerAuthUrl + ? `
Your signer asks you to approve this connection first. + Open the approval page, then return here.
` + : ""; + const qrBlock = this.bunkerConnectUri + ? `
${renderQrSvg(this.bunkerConnectUri, "Nostr Connect QR code")}
+ +

Waiting for your signer to connect…

` + : this.errorMessage + ? `` + : `

Preparing connection code…

`; + return ` +

Use a remote signer

+

Scan with a signer app on your phone (Amber, nsec.app, …). Your key stays in the signer; BitLogin only requests signatures.

+ ${this.renderError()} + ${authNotice} + ${qrBlock} +
+
+ + + +
+ + `; + } + + case "bunker-confirm": + return ` +

Remote signer connected

+

Your signer reports this identity:

+
${escapeHtml(this.pendingBunker?.npub ?? "")}
+

Check this is the profile you expect. Your private key stays in your signer — each signature is requested over relays, and your signer can require approval or be disconnected at any time. This session lasts until you log out or leave the page; BitLogin account features (wallet connections, password rotation, recovery) stay with BitLogin accounts.

+ ${this.renderError()} + + + `; + case "import-key": { const previewed = !!this.importPreviewNpub; return ` @@ -1433,13 +1619,19 @@ export class BitLoginAuthElement extends HTMLElement { } case "dashboard": { - // Extension sessions are signer-only (§LM7): the account actions below the - // divider are all worker-backed (vault, rotation, export) and have no meaning - // without a BitLogin account, so they are omitted rather than left to fail. - const isExtensionSession = this.session?.method === "nip07"; - const accountActions = isExtensionSession - ? `

Signed in through your Nostr extension. Wallet connections, password rotation, and identity export are features of BitLogin accounts — create one to get your settings on every device.

` - : ` + // Alternative-method sessions are signer-only (§LM7): the account actions + // below the divider are all account-backed (vault, rotation, export) and have + // no meaning without a BitLogin account, so they are omitted rather than left + // to fail. + const method = this.session?.method ?? "bitlogin"; + const altNote = + method === "nip07" + ? "Signed in through your Nostr extension." + : "Signed in through your remote signer — each signature is requested from it over relays."; + const accountActions = + method !== "bitlogin" + ? `

${altNote} Wallet connections, password rotation, and identity export are features of BitLogin accounts — create one to get your settings on every device.

` + : ` `; diff --git a/packages/widget/src/provider.ts b/packages/widget/src/provider.ts index 4055e45..b442184 100644 --- a/packages/widget/src/provider.ts +++ b/packages/widget/src/provider.ts @@ -34,6 +34,50 @@ export interface Nip07Provider { readonly _bitlogin: true; } +/** The signing surface a element exposes; the routed provider + * below delegates to it so window.nostr always follows the ACTIVE signer. */ +export interface ElementSignerApi { + getPublicKey(): Promise; + signEvent(event: UnsignedEventForSigning): Promise; + nip44Encrypt(peerPublicKey: string, plaintext: string): Promise; + nip44Decrypt(peerPublicKey: string, payload: string): Promise; + nip04Encrypt(peerPublicKey: string, plaintext: string): Promise; + nip04Decrypt(peerPublicKey: string, payload: string): Promise; +} + +/** + * A window.nostr provider that routes through the element rather than binding + * the worker directly. The element dispatches each call to whichever signer + * backs the current session -- the crypto worker for a BitLogin account, the + * worker-held NIP-46 client for a remote-signer session (§LM3, §LM5) -- so a + * host page reading window.nostr gets the right backend without caring which + * method the user picked. (NIP-07 sessions never claim the slot at all; the + * extension that IS the backend already owns it, §LM4.) + */ +export function createElementRoutedProvider( + element: ElementSignerApi, + configuredRelays: () => string[] +): Nip07Provider { + return { + getPublicKey: () => element.getPublicKey(), + signEvent: (event) => element.signEvent(event), + async getRelays(): Promise> { + const out: Record = {}; + for (const url of configuredRelays()) out[url] = { read: true, write: true }; + return out; + }, + nip44: { + encrypt: (peerPublicKey, plaintext) => element.nip44Encrypt(peerPublicKey, plaintext), + decrypt: (peerPublicKey, payload) => element.nip44Decrypt(peerPublicKey, payload) + }, + nip04: { + encrypt: (peerPublicKey, plaintext) => element.nip04Encrypt(peerPublicKey, plaintext), + decrypt: (peerPublicKey, payload) => element.nip04Decrypt(peerPublicKey, payload) + }, + _bitlogin: true + }; +} + const NOT_UNLOCKED_MESSAGE = "BitLogin: no identity is unlocked yet. Add to the page and let the user sign in, or call it programmatically before invoking window.nostr."; diff --git a/packages/widget/src/qr.test.ts b/packages/widget/src/qr.test.ts new file mode 100644 index 0000000..b2e19c5 --- /dev/null +++ b/packages/widget/src/qr.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import qrcodeGenerator from "qrcode-generator"; +import { renderQrSvg } from "./qr.js"; + +const SAMPLE = + "nostrconnect://" + + "ab".repeat(32) + + "?relay=wss%3A%2F%2Frelay.example&secret=0123456789abcdef&name=BitLogin"; + +describe("renderQrSvg", () => { + it("renders a complete standalone SVG with backing, eyes, and dots", () => { + const svg = renderQrSvg(SAMPLE); + expect(svg.startsWith("")).toBe(true); + // One opaque backing rect plus three finder eyes of two rects each. + expect(svg.match(/ { + const qr = qrcodeGenerator(0, "M"); + qr.addData(SAMPLE); + qr.make(); + const n = qr.getModuleCount(); + let expected = 0; + const inEye = (r: number, c: number) => + (r < 7 && c < 7) || (r < 7 && c >= n - 7) || (r >= n - 7 && c < 7); + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + if (!inEye(r, c) && qr.isDark(r, c)) expected++; + } + } + const svg = renderQrSvg(SAMPLE); + expect(svg.match(/ { + const svg = renderQrSvg(SAMPLE); + const centers = [...svg.matchAll(/ [ + Number(m[1]), + Number(m[2]) + ]); + const viewBox = /viewBox="0 0 (\d+) (\d+)"/u.exec(svg)!; + const size = Number(viewBox[1]); + for (const [cx, cy] of centers) { + expect(cx).toBeGreaterThanOrEqual(4); + expect(cy).toBeGreaterThanOrEqual(4); + expect(cx).toBeLessThanOrEqual(size - 4); + expect(cy).toBeLessThanOrEqual(size - 4); + } + }); + + it("escapes nothing it shouldn't: the payload never appears in the markup", () => { + // The QR encodes the URI as modules; the raw string (with its secret) must + // not leak into the SVG text. + const svg = renderQrSvg(SAMPLE); + expect(svg).not.toContain("nostrconnect"); + expect(svg).not.toContain("secret"); + }); +}); diff --git a/packages/widget/src/qr.ts b/packages/widget/src/qr.ts new file mode 100644 index 0000000..6116d3f --- /dev/null +++ b/packages/widget/src/qr.ts @@ -0,0 +1,61 @@ +/** Dot-style QR rendering for the nostrconnect connection code (§LM5). + * + * Renders the module matrix as inline SVG: data modules become circles, and + * the three finder "eyes" become rounded squares -- dots everywhere would look + * uniform but measurably hurts camera lock-on, since scanners find a code BY + * its three square eyes. Inline SVG keeps this self-contained under the + * strict deployment CSP (no canvas, no image assets) and crisp at any size. + * + * The white backing rect is part of the QR, not styling: scanners need quiet + * zone and contrast regardless of the host page's theme. + */ +import qrcodeGenerator from "qrcode-generator"; + +const QUIET_ZONE = 4; // modules of white margin, per the QR spec +const DOT_RADIUS = 0.42; // of a 1x1 module cell; large enough to scan, round enough to read as dots +const DARK = "#14101f"; +const LIGHT = "#ffffff"; + +function inFinderZone(row: number, col: number, moduleCount: number): boolean { + const nearStart = (v: number) => v < 7; + const nearEnd = (v: number) => v >= moduleCount - 7; + return ( + (nearStart(row) && nearStart(col)) || + (nearStart(row) && nearEnd(col)) || + (nearEnd(row) && nearStart(col)) + ); +} + +function finderEye(x: number, y: number): string { + // Outer 7x7 ring drawn as a stroked rounded square, inner 3x3 filled. + return ( + `` + + `` + ); +} + +/** Encodes `data` and returns a complete `` element string. */ +export function renderQrSvg(data: string, ariaLabel = "QR code"): string { + const qr = qrcodeGenerator(0, "M"); + qr.addData(data); + qr.make(); + const moduleCount = qr.getModuleCount(); + const size = moduleCount + QUIET_ZONE * 2; + + const parts: string[] = []; + parts.push(``); + parts.push(finderEye(QUIET_ZONE, QUIET_ZONE)); + parts.push(finderEye(QUIET_ZONE + moduleCount - 7, QUIET_ZONE)); + parts.push(finderEye(QUIET_ZONE, QUIET_ZONE + moduleCount - 7)); + + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (inFinderZone(row, col, moduleCount) || !qr.isDark(row, col)) continue; + const cx = QUIET_ZONE + col + 0.5; + const cy = QUIET_ZONE + row + 0.5; + parts.push(``); + } + } + + return `${parts.join("")}`; +} diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index 20dc62c..06725bb 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -40,13 +40,36 @@ describe("widget security policy", () => { expect(passwordSignIn).toBeLessThan(extensionSlot); }); + it("keeps alternative methods below the password path on the welcome screen", () => { + // Same owner decision as the extension pin above, applied to NIP-46: the + // remote-signer option renders after the primary sign-in button. + const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in'); + const bunkerOption = widgetSource.indexOf('data-action="goto-bunker">Use a remote signer'); + expect(passwordSignIn).toBeGreaterThan(-1); + expect(bunkerOption).toBeGreaterThan(-1); + expect(passwordSignIn).toBeLessThan(bunkerOption); + }); + + it("treats the bunker URI as a secret and keeps the element storage-free", () => { + // A bunker:// URI carries a connection token; it gets a password input + // (no shoulder-surfing, no autofill heuristics), and the element itself + // must never touch web storage -- alternative-method sessions are + // memory-only by design (§LM5, §LM7), and the ONLY persistence in the + // widget lives behind the worker's session cache. + expect(widgetSource).toContain('type="password" name="bunkerUri"'); + expect(widgetSource).not.toContain("localStorage"); + expect(widgetSource).not.toContain("sessionStorage"); + }); + it("extension sessions never claim window.nostr and never persist", () => { // §LM4: NIP-07 mode delegates to the extension's provider; fighting it for the // window.nostr slot is reserved for BitLogin-account sessions. There must be no // claimSigner() call in the extension confirm path, and no storage write. const confirmBody = widgetSource.slice( widgetSource.indexOf("private handleExtensionConfirm"), - widgetSource.indexOf("private async tryRestoreSession"), + // Ends where the NIP-46 handlers begin -- those claim the slot + // legitimately (§LM5: the routed provider IS that session's backend). + widgetSource.indexOf("private async startNostrconnect"), ); expect(confirmBody.length).toBeGreaterThan(0); expect(confirmBody).not.toContain("claimSigner"); diff --git a/packages/widget/src/signers/nip46.test.ts b/packages/widget/src/signers/nip46.test.ts new file mode 100644 index 0000000..fd01d52 --- /dev/null +++ b/packages/widget/src/signers/nip46.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { Nip46Signer } from "./nip46.js"; +import type { WorkerClient } from "../worker/workerClient.js"; + +const USER = "ab".repeat(32); + +function fakeWorker(calls: Array<{ action: string; payload: unknown }>): WorkerClient { + const record = + (action: string, result: unknown) => + (payload: unknown): Promise => { + calls.push({ action, payload }); + return Promise.resolve(result); + }; + return { + nip46SignEvent: record("nip46SignEvent", { id: "e".repeat(64), pubkey: USER, sig: "f".repeat(128), kind: 1, tags: [], content: "x", created_at: 1 }), + nip46Nip44Encrypt: record("nip46Nip44Encrypt", { ciphertext: "c44" }), + nip46Nip44Decrypt: record("nip46Nip44Decrypt", { plaintext: "p44" }), + nip46Nip04Encrypt: record("nip46Nip04Encrypt", { ciphertext: "c04" }), + nip46Nip04Decrypt: record("nip46Nip04Decrypt", { plaintext: "p04" }) + } as unknown as WorkerClient; +} + +describe("Nip46Signer", () => { + it("answers getPublicKey locally with the connect-time identity", async () => { + const signer = new Nip46Signer(fakeWorker([]), USER); + expect(signer.method).toBe("nip46"); + expect(await signer.getPublicKey()).toBe(USER); + }); + + it("reports the full RPC capability surface", () => { + const signer = new Nip46Signer(fakeWorker([]), USER); + expect(signer.capabilities).toEqual({ nip44: true, nip04: true, getRelays: false }); + }); + + it("delegates signing and encryption to the worker-held client, mapping fields", async () => { + const calls: Array<{ action: string; payload: unknown }> = []; + const signer = new Nip46Signer(fakeWorker(calls), USER); + + await signer.signEvent({ kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 }); + expect(calls[0]).toEqual({ + action: "nip46SignEvent", + payload: { kind: 1, content: "hello", tags: [["t", "x"]], created_at: 123 } + }); + + expect(await signer.nip44Encrypt("cd".repeat(32), "hi")).toBe("c44"); + expect(await signer.nip44Decrypt("cd".repeat(32), "payload")).toBe("p44"); + expect(await signer.nip04Encrypt("cd".repeat(32), "hi")).toBe("c04"); + expect(await signer.nip04Decrypt("cd".repeat(32), "payload")).toBe("p04"); + expect(calls.map((c) => c.action)).toEqual([ + "nip46SignEvent", + "nip46Nip44Encrypt", + "nip46Nip44Decrypt", + "nip46Nip04Encrypt", + "nip46Nip04Decrypt" + ]); + expect(calls[1]!.payload).toEqual({ peerPublicKey: "cd".repeat(32), plaintext: "hi" }); + }); +}); diff --git a/packages/widget/src/signers/nip46.ts b/packages/widget/src/signers/nip46.ts new file mode 100644 index 0000000..7e32810 --- /dev/null +++ b/packages/widget/src/signers/nip46.ts @@ -0,0 +1,54 @@ +/** NIP-46 remote-signer sessions (§LM5): the signer backend for a connected bunker. + * + * Thin by construction: the actual client -- ephemeral keypair, relay + * subscriptions, request correlation -- lives in the crypto worker (§LM5.4), + * and this class only forwards facade calls to it. The user pubkey is fixed at + * connect time (the worker validated it), so getPublicKey answers locally. + * Deadlines and typed errors come from the worker/core layers. + */ +import type { NostrEvent } from "@bitlogin/core/nostr"; +import type { UnsignedEventForSigning } from "../provider.js"; +import type { WorkerClient } from "../worker/workerClient.js"; +import type { Signer, SignerCapabilities } from "./types.js"; + +export class Nip46Signer implements Signer { + readonly method = "nip46" as const; + /** The NIP-46 RPC surface always carries all four encryption calls; whether a + * particular signer approves them is a per-request policy decision that + * surfaces as its error, not a missing capability. */ + readonly capabilities: SignerCapabilities = { nip44: true, nip04: true, getRelays: false }; + + constructor( + private readonly worker: WorkerClient, + private readonly userPubkey: string + ) {} + + getPublicKey(): Promise { + return Promise.resolve(this.userPubkey); + } + + signEvent(event: UnsignedEventForSigning): Promise { + return this.worker.nip46SignEvent({ + kind: event.kind, + content: event.content, + tags: event.tags, + created_at: event.created_at + }); + } + + async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise { + return (await this.worker.nip46Nip44Encrypt({ peerPublicKey, plaintext })).ciphertext; + } + + async nip44Decrypt(peerPublicKey: string, payload: string): Promise { + return (await this.worker.nip46Nip44Decrypt({ peerPublicKey, payload })).plaintext; + } + + async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise { + return (await this.worker.nip46Nip04Encrypt({ peerPublicKey, plaintext })).ciphertext; + } + + async nip04Decrypt(peerPublicKey: string, payload: string): Promise { + return (await this.worker.nip46Nip04Decrypt({ peerPublicKey, payload })).plaintext; + } +} diff --git a/packages/widget/src/signers/types.ts b/packages/widget/src/signers/types.ts index 320175a..f53c9b9 100644 --- a/packages/widget/src/signers/types.ts +++ b/packages/widget/src/signers/types.ts @@ -9,7 +9,7 @@ import type { NostrEvent } from "@bitlogin/core/nostr"; import type { UnsignedEventForSigning } from "../provider.js"; -export type SignerMethod = "bitlogin" | "nip07"; +export type SignerMethod = "bitlogin" | "nip07" | "nip46"; /** What the active signer actually supports. NIP-07 extensions vary -- some lack nip44, * older ones lack nip04 -- and the facade reports honestly instead of throwing a diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index 3ed0607..0af14c9 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -236,4 +236,14 @@ button.link { margin-right: 6px; } @keyframes bl-spin { to { transform: rotate(360deg); } } + +/* nostrconnect QR (§LM5). The white backing and quiet zone are drawn inside + the SVG itself -- scanners need them regardless of host theme -- so the + wrapper only sizes and centers it. */ +.qr-wrap { + display: block; + width: min(240px, 100%); + margin: 14px auto; +} +.qr-wrap svg { display: block; width: 100%; height: auto; } `; diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts index c2f580d..e527ff9 100644 --- a/packages/widget/src/worker/cryptoWorker.ts +++ b/packages/widget/src/worker/cryptoWorker.ts @@ -18,8 +18,19 @@ import { NostrSigner, type RecoveredIdentity } from "@bitlogin/core/account"; -import { RelayPool, BUILTIN_VAULT_RELAYS, BUILTIN_DISCOVERY_RELAYS, encodeNsec, encodeNpub, type NostrEvent } from "@bitlogin/core/nostr"; -import { getPublicKeyHex, base64urlToBytes, wipe } from "@bitlogin/core/crypto"; +import { + RelayPool, + BUILTIN_VAULT_RELAYS, + BUILTIN_DISCOVERY_RELAYS, + encodeNsec, + encodeNpub, + Nip46Client, + parseBunkerUri, + buildNostrconnectUri, + listenForNostrconnect, + type NostrEvent +} from "@bitlogin/core/nostr"; +import { getPublicKeyHex, base64urlToBytes, wipe, generatePrivateKey, randomBytes, bytesToHex } from "@bitlogin/core/crypto"; import { VaultSession, parseNwcUri, @@ -48,7 +59,9 @@ import type { Nip44DecryptPayload, Nip04EncryptPayload, Nip04DecryptPayload, - PreviewImportKeyPayload + PreviewImportKeyPayload, + Nip46ConnectPayload, + Nip46NostrconnectStartPayload } from "./protocol.js"; interface SessionState { @@ -232,8 +245,131 @@ async function findConnection(connectionId: string): Promise<{ return { vault, connection }; } +// ---- NIP-46 remote-signer session (§LM5) ---- +// Deliberately separate from `session`: a remote-signer session has no account, +// no vault, and nothing persisted. The ephemeral client key never leaves this +// worker (§LM5.4); close() ends everything. +let nip46: { client: Nip46Client; userPubkey: string } | null = null; +let nostrconnectPending: { clientSecretKey: Uint8Array; relayUrls: string[]; secret: string } | null = null; + +function postAuthUrlNotification(url: string): void { + (self as unknown as Worker).postMessage({ notify: "nip46-auth-url", url }); +} + +function requireNip46(): { client: Nip46Client; userPubkey: string } { + if (!nip46) throw new Error("No remote signer is connected."); + return nip46; +} + +function adoptNip46Client(client: Nip46Client, userPubkey: string): void { + nip46?.client.close(); + nip46 = { client, userPubkey }; +} + async function handle(action: string, payload: unknown): Promise { switch (action) { + case "nip46Connect": { + const p = payload as Nip46ConnectPayload; + const pointer = parseBunkerUri(p.uri); + const client = new Nip46Client({ + clientSecretKey: generatePrivateKey(), + pointer, + onAuthUrl: postAuthUrlNotification + }); + try { + await client.connect(); + const userPubkey = await client.getUserPublicKey(); + adoptNip46Client(client, userPubkey); + return { userPubkey }; + } catch (err) { + client.close(); + throw err; + } + } + + case "nip46NostrconnectStart": { + const p = payload as Nip46NostrconnectStartPayload; + const relayUrls = vaultRelayUrls; + const clientSecretKey = generatePrivateKey(); + // The secret is the proof-of-scan: whoever echoes it back becomes this + // session's signer, so it gets real entropy, not a friendly string. + const secret = bytesToHex(randomBytes(16)); + nostrconnectPending = { clientSecretKey, relayUrls, secret }; + const uri = buildNostrconnectUri({ + clientPubkey: getPublicKeyHex(clientSecretKey), + relayUrls, + secret, + name: p.appName?.trim() || "BitLogin", + perms: ["sign_event", "nip44_encrypt", "nip44_decrypt", "nip04_encrypt", "nip04_decrypt"] + }); + return { uri }; + } + + case "nip46NostrconnectAwait": { + const pending = nostrconnectPending; + if (!pending) throw new Error("No signer-connection attempt is in progress."); + const { signerPubkey } = await listenForNostrconnect({ + clientSecretKey: pending.clientSecretKey, + relayUrls: pending.relayUrls, + secret: pending.secret, + timeoutMs: 180_000 + }); + if (nostrconnectPending !== pending) throw new Error("This signer-connection attempt was superseded."); + nostrconnectPending = null; + const client = new Nip46Client({ + clientSecretKey: pending.clientSecretKey, + pointer: { signerPubkey, relayUrls: pending.relayUrls }, + onAuthUrl: postAuthUrlNotification + }); + try { + // The signer initiated (it echoed the secret), so no connect RPC is + // needed -- go straight to asking whose key it signs for. + const userPubkey = await client.getUserPublicKey(); + adoptNip46Client(client, userPubkey); + return { userPubkey }; + } catch (err) { + client.close(); + throw err; + } + } + + case "nip46SignEvent": { + const p = payload as SignEventPayload; + return requireNip46().client.signEvent({ + kind: p.kind, + content: p.content, + tags: (p.tags ?? []) as string[][], + created_at: p.created_at ?? Math.floor(Date.now() / 1000) + }); + } + + case "nip46Nip44Encrypt": { + const p = payload as Nip44EncryptPayload; + return { ciphertext: await requireNip46().client.nip44Encrypt(p.peerPublicKey, p.plaintext) }; + } + + case "nip46Nip44Decrypt": { + const p = payload as Nip44DecryptPayload; + return { plaintext: await requireNip46().client.nip44Decrypt(p.peerPublicKey, p.payload) }; + } + + case "nip46Nip04Encrypt": { + const p = payload as Nip04EncryptPayload; + return { ciphertext: await requireNip46().client.nip04Encrypt(p.peerPublicKey, p.plaintext) }; + } + + case "nip46Nip04Decrypt": { + const p = payload as Nip04DecryptPayload; + return { plaintext: await requireNip46().client.nip04Decrypt(p.peerPublicKey, p.payload) }; + } + + case "nip46Disconnect": { + nip46?.client.close(); + nip46 = null; + nostrconnectPending = null; + return {}; + } + case "configure": { const p = payload as ConfigurePayload; if (p.vaultRelayUrls?.length) vaultRelayUrls = p.vaultRelayUrls; @@ -655,7 +791,10 @@ const KNOWN_ACTIONS = new Set([ "nip44Encrypt", "nip44Decrypt", "nip04Encrypt", "nip04Decrypt", "exportIdentity", "buildRecoveryExport", "repairReplicas", "getSessionStatus", "restoreSession", "logout", "vaultStatus", "vaultList", "vaultSaveNwc", "vaultFindForOrigin", "vaultRevealNwc", - "vaultSetBinding", "vaultDelete", "vaultOfferCheck" + "vaultSetBinding", "vaultDelete", "vaultOfferCheck", + "nip46Connect", "nip46NostrconnectStart", "nip46NostrconnectAwait", "nip46SignEvent", + "nip46Nip44Encrypt", "nip46Nip44Decrypt", "nip46Nip04Encrypt", "nip46Nip04Decrypt", + "nip46Disconnect" ]); self.addEventListener("message", (event: MessageEvent) => { @@ -676,7 +815,15 @@ self.addEventListener("message", (event: MessageEvent) => { return; } const { id, action, payload } = event.data; - requestQueue.run(() => handle(action, payload)).then( + // NIP-46 calls run OUTSIDE the serial queue: they touch none of the + // account/vault state the queue exists to order, and a signer waiting + // minutes for a human's approval must not block every other worker call + // behind it (getSessionStatus, vault requests, even logout). + const run = + action.startsWith("nip46") + ? (operation: () => Promise) => operation() + : (operation: () => Promise) => requestQueue.run(operation); + run(() => handle(action, payload)).then( (result) => { const response: WorkerResponse = { id, ok: true, result }; (self as unknown as Worker).postMessage(response); diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts index 1973f9e..7c06cb1 100644 --- a/packages/widget/src/worker/protocol.ts +++ b/packages/widget/src/worker/protocol.ts @@ -235,6 +235,39 @@ export interface VaultOfferCheckResult { connection?: VaultConnectionSummary; } +// ---- NIP-46 remote-signer session (docs/login-methods.md §LM5) ---- +// Memory-only: the ephemeral client key and Nip46Client live in the worker +// (§LM5.4) and vanish with it. Nothing below touches account/vault state. + +export interface Nip46ConnectPayload { + /** A bunker:// URI pasted by the user. */ + uri: string; +} +export interface Nip46ConnectResult { + userPubkey: string; +} +export interface Nip46NostrconnectStartPayload { + appName?: string; +} +export interface Nip46NostrconnectStartResult { + /** The nostrconnect:// URI to show as a QR / copyable string. */ + uri: string; +} +export interface Nip46NostrconnectAwaitResult { + userPubkey: string; +} + +/** + * Unsolicited worker -> main-thread frame. Distinguished from WorkerResponse + * by having `notify` and no `id`; used where a request/response pair cannot + * carry the information -- the signer's interactive-approval URL arrives in + * the middle of a still-pending connect call. + */ +export interface WorkerNotification { + notify: "nip46-auth-url"; + url: string; +} + export type WorkerActionMap = { configure: [ConfigurePayload, Record]; register: [RegisterPayload, RegisterResult]; @@ -264,6 +297,15 @@ export type WorkerActionMap = { vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary]; vaultDelete: [VaultDeletePayload, Record]; vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult]; + nip46Connect: [Nip46ConnectPayload, Nip46ConnectResult]; + nip46NostrconnectStart: [Nip46NostrconnectStartPayload, Nip46NostrconnectStartResult]; + nip46NostrconnectAwait: [Record, Nip46NostrconnectAwaitResult]; + nip46SignEvent: [SignEventPayload, NostrEvent]; + nip46Nip44Encrypt: [Nip44EncryptPayload, { ciphertext: string }]; + nip46Nip44Decrypt: [Nip44DecryptPayload, { plaintext: string }]; + nip46Nip04Encrypt: [Nip04EncryptPayload, { ciphertext: string }]; + nip46Nip04Decrypt: [Nip04DecryptPayload, { plaintext: string }]; + nip46Disconnect: [Record, Record]; }; export type WorkerAction = keyof WorkerActionMap; diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts index 0cf671d..7284a3e 100644 --- a/packages/widget/src/worker/workerClient.ts +++ b/packages/widget/src/worker/workerClient.ts @@ -22,7 +22,10 @@ import type { VaultRevealNwcPayload, VaultSetBindingPayload, VaultDeletePayload, - VaultOfferCheckPayload + VaultOfferCheckPayload, + Nip46ConnectPayload, + Nip46NostrconnectStartPayload, + WorkerNotification } from "./protocol.js"; /** @@ -32,6 +35,16 @@ import type { */ const CALL_TIMEOUT_MS = 60_000; +/** + * NIP-46 calls wait on a HUMAN approving a prompt on another device, on top of + * relay round trips -- the worker-side deadlines (120s per signer request, + * 180s for the nostrconnect listen) are the real limits; these exist only so + * a dead worker still rejects. Kept above the worst worker-side sum so the + * worker's own, better-worded timeout error is the one callers see. + */ +const NIP46_CALL_TIMEOUT_MS = 260_000; +const NIP46_AWAIT_TIMEOUT_MS = 320_000; + export class WorkerClient { private readonly worker: Worker; private readonly pending = new Map< @@ -40,6 +53,8 @@ export class WorkerClient { >(); private counter = 0; private dead = false; + /** Set by the element to receive unsolicited worker frames (auth_url). */ + onNotification: ((notification: WorkerNotification) => void) | null = null; constructor() { // Deliberately NOT the literal `new Worker(new URL("./x.js", import.meta.url))` shape: Vite @@ -55,7 +70,15 @@ export class WorkerClient { const msg = event.data; // Shape-check before trusting: a malformed frame used to throw inside // this listener, which posts no response and hangs the caller forever. - if (!msg || typeof msg !== "object" || typeof (msg as WorkerResponse).id !== "string") return; + if (!msg || typeof msg !== "object") return; + // Unsolicited notification frames (no id) -- currently only the NIP-46 + // auth_url, which arrives mid-call and cannot ride a response. + const maybeNotification = msg as unknown as Partial; + if (maybeNotification.notify === "nip46-auth-url" && typeof maybeNotification.url === "string") { + this.onNotification?.(maybeNotification as WorkerNotification); + return; + } + if (typeof (msg as WorkerResponse).id !== "string") return; const entry = this.pending.get(msg.id); if (!entry) return; this.pending.delete(msg.id); @@ -91,7 +114,11 @@ export class WorkerClient { } } - private call(action: A, payload: WorkerActionMap[A][0]): Promise { + private call( + action: A, + payload: WorkerActionMap[A][0], + timeoutMs = CALL_TIMEOUT_MS + ): Promise { if (this.dead) { return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again.")); } @@ -100,7 +127,7 @@ export class WorkerClient { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`BitLogin's ${action} call timed out.`)); - }, CALL_TIMEOUT_MS); + }, timeoutMs); this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); const request: WorkerRequest = { id, action, payload }; this.worker.postMessage(request); @@ -188,6 +215,35 @@ export class WorkerClient { vaultOfferCheck(payload: VaultOfferCheckPayload) { return this.call("vaultOfferCheck", payload); } + + // ---- NIP-46 remote-signer session (§LM5) ---- + nip46Connect(payload: Nip46ConnectPayload) { + return this.call("nip46Connect", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46NostrconnectStart(payload: Nip46NostrconnectStartPayload) { + return this.call("nip46NostrconnectStart", payload); + } + nip46NostrconnectAwait() { + return this.call("nip46NostrconnectAwait", {}, NIP46_AWAIT_TIMEOUT_MS); + } + nip46SignEvent(payload: SignEventPayload) { + return this.call("nip46SignEvent", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46Nip44Encrypt(payload: Nip44EncryptPayload) { + return this.call("nip46Nip44Encrypt", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46Nip44Decrypt(payload: Nip44DecryptPayload) { + return this.call("nip46Nip44Decrypt", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46Nip04Encrypt(payload: Nip04EncryptPayload) { + return this.call("nip46Nip04Encrypt", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46Nip04Decrypt(payload: Nip04DecryptPayload) { + return this.call("nip46Nip04Decrypt", payload, NIP46_CALL_TIMEOUT_MS); + } + nip46Disconnect() { + return this.call("nip46Disconnect", {}); + } logout() { return this.call("logout", {}); }