diff --git a/docs/login-methods.md b/docs/login-methods.md index 43a17cb..b21efe2 100644 --- a/docs/login-methods.md +++ b/docs/login-methods.md @@ -1,8 +1,10 @@ # BitLogin as the Single Login Surface — NIP-07 and NIP-46 Methods -**Document version:** 0.1 (design proposal) -**Status:** Proposal only — nothing here is implemented. §LM6 records a settled -rejection (nsec paste as a login method), kept on the record. +**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. **Depends on:** `docs/spec.md` (§ references point there unless marked §LM or §SF), `docs/second-factor.md` §SF10 (imported identities, implemented) @@ -222,9 +224,10 @@ Each step ships independently; the login UI grows one option at a time. # LM9. Open questions -1. Method presentation: is password-first with an "other ways to sign in" - expander the right default, or should extension detection promote NIP-07 - when present? +1. **Resolved 2026-08-04 (owner decision):** username/password stays the + primary presentation; alternative methods appear as secondary affordances + below it, never promoted above the password path even when an extension is + detected. Pinned by `securityPolicy.test.ts`. 2. Should a NIP-07 session survive reload by silently re-asking the extension, or require an explicit click each visit (extension prompt fatigue vs. surprise sessions)? diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 26840de..8125d8b 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -15,9 +15,12 @@ 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"; type Screen = | "welcome" + | "extension-confirm" | "import-key" | "create-name" | "create-credential" @@ -70,7 +73,17 @@ export class BitLoginAuthElement extends HTMLElement { private offlineExportFile: RecoveryExportFile | null = null; private offlineExportFileNotice: string | undefined; - private session: { publicKey: string; npub: string; accountId?: string } | null = null; + 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. + private pendingExtensionSigner: Nip07Signer | null = null; + private activeExtensionSigner: Nip07Signer | null = null; + private extensionPreviewNpub = ""; + private extensionPreviewPubkey = ""; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; private exportedNsec = ""; @@ -170,10 +183,15 @@ export class BitLoginAuthElement extends HTMLElement { } // ---- Public API mirroring window.nostr, scoped to this element instance ---- + // Routed through the active signer: the worker for BitLogin-account sessions (as + // 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(); 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); return this.worker.signEvent(event); } /** @@ -185,9 +203,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); return (await this.worker.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext; } async nip44Decrypt(peerPublicKey: string, payload: string): Promise { + if (this.activeExtensionSigner) return this.activeExtensionSigner.nip44Decrypt(peerPublicKey, payload); return (await this.worker.nip44Decrypt({ peerPublicKey, payload })).plaintext; } /** @@ -196,12 +216,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); return (await this.worker.nip04Encrypt({ peerPublicKey, plaintext })).ciphertext; } async nip04Decrypt(peerPublicKey: string, payload: string): Promise { + if (this.activeExtensionSigner) return this.activeExtensionSigner.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; + this.session = null; + this.dispatchEvent(new CustomEvent("bitlogin-logout")); + this.goto("welcome"); + return; + } await this.worker.logout(); this.session = null; this.releaseSigner(); @@ -226,6 +257,9 @@ export class BitLoginAuthElement extends HTMLElement { */ async requestNwcConnection(options: { appName?: string; reason?: string } = {}): Promise { 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; const origin = window.location.origin; const appName = options.appName?.trim() || window.location.hostname || "This app"; return new Promise((resolve) => { @@ -507,6 +541,26 @@ export class BitLoginAuthElement extends HTMLElement { * relay-disagreement warnings -- so a user isn't left wondering why some other app still * seems to be using a different signer. */ + /** Single dispatch point for bitlogin-login so every sign-in path reports the same + * 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 ?? { + nip44: true, + nip04: true, + getRelays: true + }; + this.dispatchEvent( + new CustomEvent("bitlogin-login", { + detail: { + publicKey: this.session?.publicKey, + method: this.session?.method ?? "bitlogin", + capabilities + } + }) + ); + } + private noteSignerClaim(claimed: boolean): void { if (claimed) return; this.sessionWarnings = [ @@ -661,8 +715,14 @@ export class BitLoginAuthElement extends HTMLElement { return; case "goto-welcome": this.pendingRollback = null; + this.pendingExtensionSigner = null; this.goto("welcome"); return; + case "extension-signin": + return this.handleExtensionSignIn(); + case "extension-continue": + this.handleExtensionConfirm(); + return; case "goto-dashboard": this.goto("dashboard"); return; @@ -857,7 +917,7 @@ export class BitLoginAuthElement extends HTMLElement { const words = this.recoveryPhrase.split(" "); const indices = pickRandomIndices(words.length, 3); this.confirmSlots = indices.map((index) => ({ index, value: "" })); - this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId }; + this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" }; this.busy = false; this.goto("confirm-phrase"); // §15.8/§19.6 — publish relay preferences immediately after successful registration, @@ -885,7 +945,7 @@ export class BitLoginAuthElement extends HTMLElement { } this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); - this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); + this.dispatchLogin(); this.flashSuccess("dashboard", "Account created"); } @@ -895,6 +955,56 @@ export class BitLoginAuthElement extends HTMLElement { await this.attemptLogin(loginName, password); } + /** + * "Use your Nostr extension instead" (§LM4) -- asks the detected extension for its + * public key, then shows it for the user to confirm before any session exists. The + * provider reference is snapshotted here so a later change of window.nostr occupant + * can never swap the backend mid-session. + */ + private async handleExtensionSignIn(): Promise { + if (this.busy) return; + const provider = detectForeignNip07Provider(); + if (!provider) { + this.errorMessage = "No Nostr signer extension was found in this browser."; + this.render(); + return; + } + this.setBusy(true); + try { + const signer = new Nip07Signer(provider); + const publicKey = await signer.getPublicKey(); + this.pendingExtensionSigner = signer; + this.extensionPreviewPubkey = publicKey; + this.extensionPreviewNpub = encodeNpub(publicKey); + this.busy = false; + this.goto("extension-confirm"); + } catch (err) { + this.pendingExtensionSigner = null; + this.fail(err); + } + } + + /** The user confirmed the npub the extension reported is theirs; grant the thin + * session (§LM7). Deliberately no claimSigner(): the extension owns window.nostr + * and this session's whole point is to use it, not replace it (§LM4). */ + private handleExtensionConfirm(): void { + const signer = this.pendingExtensionSigner; + if (!signer || !this.extensionPreviewPubkey) { + this.goto("welcome"); + return; + } + this.pendingExtensionSigner = null; + this.activeExtensionSigner = 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 + // extension session has no vault, so settle it honestly instead of letting the + // dashboard hook run it into worker calls that cannot succeed (§LM7). + if (this.vaultRequest) this.finishVaultRequest(null); + 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 @@ -912,10 +1022,11 @@ export class BitLoginAuthElement extends HTMLElement { this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), - accountId: result.accountId + accountId: result.accountId, + method: "bitlogin" }; this.noteSignerClaim(this.claimSigner()); - this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } })); + this.dispatchLogin(); this.goto("dashboard"); } catch { // No cached session, or the worker/IndexedDB isn't available -- fall @@ -933,11 +1044,11 @@ export class BitLoginAuthElement extends HTMLElement { try { const result = await this.worker.login({ loginName, password, acknowledgeRollback }); this.loginName = loginName; - this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId }; + this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" }; this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w); this.busy = false; this.noteSignerClaim(this.claimSigner()); - this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: result.everydayPublicKey } })); + this.dispatchLogin(); this.flashSuccess("dashboard", "Signed in"); void this.offerToSaveCredential(loginName, password); } catch (err) { @@ -976,7 +1087,7 @@ export class BitLoginAuthElement extends HTMLElement { dmRelaysCount: result.dmRelays.length, chainWarning: result.chainWarning }; - this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId }; + this.session = { publicKey: result.everydayPublicKey, npub: encodeNpub(result.everydayPublicKey), accountId: result.accountId, method: "bitlogin" }; this.newCredentialAfterRecovery = generatePassphrase().secret; this.busy = false; this.goto("recover-new-credentials"); @@ -996,7 +1107,7 @@ export class BitLoginAuthElement extends HTMLElement { this.busy = false; this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); - this.dispatchEvent(new CustomEvent("bitlogin-login", { detail: { publicKey: this.session?.publicKey } })); + this.dispatchLogin(); this.flashSuccess("dashboard", "Account recovered"); void this.offerToSaveCredential(newLoginName, newPassword); } @@ -1046,9 +1157,15 @@ export class BitLoginAuthElement extends HTMLElement { } private async handleSignTestEvent(): Promise { - const event = await this.worker.signEvent({ kind: 1, content: `Hello from BitLogin at ${new Date().toISOString()}` }); - this.lastSignedEventJson = JSON.stringify(event, null, 2); - this.render(); + // this.signEvent, not this.worker.signEvent: routes to the extension for a NIP-07 session. + try { + const event = await this.signEvent({ kind: 1, content: `Hello from BitLogin at ${new Date().toISOString()}` }); + this.lastSignedEventJson = JSON.stringify(event, null, 2); + this.render(); + } catch (err) { + // An extension can decline or time out where the worker path couldn't (§LM4). + this.fail(err); + } } private async handleRevealNsec(): Promise { @@ -1119,7 +1236,14 @@ export class BitLoginAuthElement extends HTMLElement { private renderScreen(): string { switch (this.screen) { - case "welcome": + case "welcome": { + // Username/password is the primary method; a detected extension appears as a + // secondary affordance only (§LM9.1, resolved: password-first). + const extensionOption = detectForeignNip07Provider() + ? `` + : ""; return ` ${this.renderBrandLockup()}

A portable Nostr identity with a familiar login name and password.

@@ -1128,6 +1252,19 @@ export class BitLoginAuthElement extends HTMLElement { + ${extensionOption} + `; + } + + case "extension-confirm": + return ` +

Sign in with your extension

+

Your Nostr signer extension reports this identity:

+
${escapeHtml(this.extensionPreviewNpub)}
+

Check this is the profile you expect — extensions can hold more than one. BitLogin never sees this identity's private key; your extension signs on its behalf. This session lasts until you log out or leave the page, and BitLogin account features (wallet connections, password rotation, recovery) stay with BitLogin accounts.

+ ${this.renderError()} + + `; case "import-key": { @@ -1295,7 +1432,17 @@ export class BitLoginAuthElement extends HTMLElement { `; } - case "dashboard": + 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.

` + : ` + + + `; return `

Signed in

${this.renderWarnings()} @@ -1308,11 +1455,10 @@ export class BitLoginAuthElement extends HTMLElement { : "" }
- - - + ${accountActions} `; + } case "vault-consent": { const request = this.vaultRequest; diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index 68be467..0ffc04c 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -29,6 +29,31 @@ describe("widget security policy", () => { expect(widgetSource).not.toContain("entropyBits"); }); + it("keeps username/password primary; the extension method is a secondary affordance", () => { + // Owner decision 2026-08-04 (login-methods.md §LM9.1): alternative methods must + // not displace the password path. The welcome screen's primary sign-in button has + // to appear before the extension option in the rendered template. + const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in'); + const extensionSlot = widgetSource.indexOf("${extensionOption}"); + expect(passwordSignIn).toBeGreaterThan(-1); + expect(extensionSlot).toBeGreaterThan(-1); + expect(passwordSignIn).toBeLessThan(extensionSlot); + }); + + 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"), + ); + expect(confirmBody.length).toBeGreaterThan(0); + expect(confirmBody).not.toContain("claimSigner"); + expect(confirmBody).not.toContain("localStorage"); + expect(confirmBody).not.toContain("sessionCache"); + }); + it("renders vault integrity warnings on the management screen", () => { expect(widgetSource).toContain("buildVaultIntegrityWarnings(listed)"); expect(widgetSource).toContain("this.vaultIntegrityWarnings.map"); diff --git a/packages/widget/src/signers/nip07.test.ts b/packages/widget/src/signers/nip07.test.ts new file mode 100644 index 0000000..02db44b --- /dev/null +++ b/packages/widget/src/signers/nip07.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { detectForeignNip07Provider, Nip07Signer, type ForeignNip07Provider } from "./nip07.js"; +import { SignerTimeoutError, SignerUnsupportedError } from "./types.js"; + +const PUBKEY = "a".repeat(64); + +function fullProvider(overrides: Partial = {}): ForeignNip07Provider { + return { + getPublicKey: async () => PUBKEY, + signEvent: async (event) => ({ + ...event, + id: "e".repeat(64), + pubkey: PUBKEY, + sig: "f".repeat(128) + }), + getRelays: async () => ({}), + nip44: { + encrypt: async (peer, plaintext) => `nip44(${peer.slice(0, 4)},${plaintext})`, + decrypt: async (peer, payload) => `plain44(${payload})` + }, + nip04: { + encrypt: async (peer, plaintext) => `nip04(${plaintext})`, + decrypt: async (peer, payload) => `plain04(${payload})` + }, + ...overrides + }; +} + +describe("detectForeignNip07Provider", () => { + it("returns null when window.nostr is empty", () => { + expect(detectForeignNip07Provider({})).toBeNull(); + expect(detectForeignNip07Provider({ nostr: undefined })).toBeNull(); + expect(detectForeignNip07Provider({ nostr: null })).toBeNull(); + }); + + it("never mistakes BitLogin's own provider (this or another widget instance) for an extension", () => { + const ours = { ...fullProvider(), _bitlogin: true }; + expect(detectForeignNip07Provider({ nostr: ours })).toBeNull(); + }); + + it("rejects occupants missing the two mandatory NIP-07 calls", () => { + expect(detectForeignNip07Provider({ nostr: { getPublicKey: async () => PUBKEY } })).toBeNull(); + expect(detectForeignNip07Provider({ nostr: { signEvent: async () => ({}) } })).toBeNull(); + expect(detectForeignNip07Provider({ nostr: "not-an-object" })).toBeNull(); + }); + + it("returns the same reference it was given for a real extension provider", () => { + const extension = fullProvider(); + expect(detectForeignNip07Provider({ nostr: extension })).toBe(extension); + }); +}); + +describe("Nip07Signer capability probing", () => { + it("reports full capabilities for a complete provider", () => { + const signer = new Nip07Signer(fullProvider()); + expect(signer.method).toBe("nip07"); + expect(signer.capabilities).toEqual({ nip44: true, nip04: true, getRelays: true }); + }); + + it("reports honestly when nip44 or nip04 is missing or partial", () => { + expect(new Nip07Signer(fullProvider({ nip44: undefined })).capabilities.nip44).toBe(false); + expect( + new Nip07Signer(fullProvider({ nip44: { encrypt: async () => "" } })).capabilities.nip44 + ).toBe(false); + expect(new Nip07Signer(fullProvider({ nip04: undefined })).capabilities.nip04).toBe(false); + expect(new Nip07Signer(fullProvider({ getRelays: undefined })).capabilities.getRelays).toBe(false); + }); + + it("rejects unsupported calls with a typed error instead of a TypeError", async () => { + const signer = new Nip07Signer(fullProvider({ nip44: undefined })); + await expect(signer.nip44Encrypt(PUBKEY, "hi")).rejects.toBeInstanceOf(SignerUnsupportedError); + await expect(signer.nip44Decrypt(PUBKEY, "payload")).rejects.toBeInstanceOf(SignerUnsupportedError); + }); +}); + +describe("Nip07Signer delegation", () => { + it("returns the extension's public key, lowercased", async () => { + const signer = new Nip07Signer(fullProvider({ getPublicKey: async () => PUBKEY.toUpperCase() })); + expect(await signer.getPublicKey()).toBe(PUBKEY); + }); + + it("rejects a malformed public key instead of passing it downstream", async () => { + const signer = new Nip07Signer(fullProvider({ getPublicKey: async () => "npub1notrawhex" })); + await expect(signer.getPublicKey()).rejects.toThrow(/invalid public key/); + }); + + it("normalizes the unsigned event: fills created_at and tags, preserves them when given", async () => { + let seen: { kind: number; tags: string[][]; content: string; created_at: number } | null = null; + const signer = new Nip07Signer( + fullProvider({ + signEvent: async (event) => { + seen = event; + return { ...event, id: "e".repeat(64), pubkey: PUBKEY, sig: "f".repeat(128) }; + } + }) + ); + await signer.signEvent({ kind: 1, content: "hello" }); + expect(seen!.tags).toEqual([]); + expect(typeof seen!.created_at).toBe("number"); + + await signer.signEvent({ kind: 1, content: "hello", tags: [["t", "x"]], created_at: 1234 }); + expect(seen!.tags).toEqual([["t", "x"]]); + expect(seen!.created_at).toBe(1234); + }); + + it("passes nip44/nip04 calls through and surfaces the extension's own rejections", async () => { + const signer = new Nip07Signer(fullProvider()); + expect(await signer.nip44Encrypt(PUBKEY, "hi")).toBe(`nip44(${PUBKEY.slice(0, 4)},hi)`); + expect(await signer.nip04Decrypt(PUBKEY, "x")).toBe("plain04(x)"); + + const declining = new Nip07Signer( + fullProvider({ signEvent: async () => Promise.reject(new Error("User rejected")) }) + ); + await expect(declining.signEvent({ kind: 1, content: "" })).rejects.toThrow("User rejected"); + }); +}); + +describe("Nip07Signer deadlines (no silent hangs)", () => { + const never = () => new Promise(() => {}); + + it("times out getPublicKey with a typed, retryable error", async () => { + const signer = new Nip07Signer(fullProvider({ getPublicKey: never }), { timeoutMs: 20 }); + await expect(signer.getPublicKey()).rejects.toBeInstanceOf(SignerTimeoutError); + }); + + it("times out signEvent and encryption calls too", async () => { + const signer = new Nip07Signer( + fullProvider({ + signEvent: never, + nip44: { encrypt: never, decrypt: never } + }), + { timeoutMs: 20 } + ); + await expect(signer.signEvent({ kind: 1, content: "" })).rejects.toBeInstanceOf(SignerTimeoutError); + await expect(signer.nip44Encrypt(PUBKEY, "hi")).rejects.toBeInstanceOf(SignerTimeoutError); + }); + + it("does not time out a call that answers in time", async () => { + const signer = new Nip07Signer(fullProvider(), { timeoutMs: 1000 }); + expect(await signer.getPublicKey()).toBe(PUBKEY); + }); +}); diff --git a/packages/widget/src/signers/nip07.ts b/packages/widget/src/signers/nip07.ts new file mode 100644 index 0000000..b3faad6 --- /dev/null +++ b/packages/widget/src/signers/nip07.ts @@ -0,0 +1,140 @@ +/** NIP-07 delegation: sign in with a signer extension already in the browser (§LM4). + * + * No key custody, no stored secrets, no relay traffic -- the extension's provider is the + * backend and this signer is a passthrough with three additions: capability probing, + * deadlines on every call, and normalization of the unsigned event. A NIP-07 session + * persists nothing and the widget leaves window.nostr alone -- the extension already + * owns that slot, and fighting it (claimSigner) is reserved for BitLogin-account + * sessions where the user explicitly chose BitLogin as their signer. + */ +import type { NostrEvent } from "@bitlogin/core/nostr"; +import type { UnsignedEventForSigning } from "../provider.js"; +import { + SignerTimeoutError, + SignerUnsupportedError, + type Signer, + type SignerCapabilities +} from "./types.js"; + +interface EncryptDecrypt { + encrypt(peerPublicKey: string, plaintext: string): Promise; + decrypt(peerPublicKey: string, payload: string): Promise; +} + +/** The subset of window.nostr this signer relies on. getPublicKey and signEvent are the + * only calls NIP-07 actually mandates; everything else is probed (§LM4). */ +export interface ForeignNip07Provider { + getPublicKey(): Promise; + signEvent(event: { + kind: number; + tags: string[][]; + content: string; + created_at: number; + }): Promise; + getRelays?(): Promise>; + nip44?: Partial; + nip04?: Partial; +} + +/** + * Returns the window.nostr occupant if -- and only if -- it is someone else's NIP-07 + * provider: a browser extension (Alby, nos2x, …), not this widget's own provider nor + * another instance's (both marked `_bitlogin`). The snapshot is taken + * when called; the caller holds the returned reference so a later change of occupant + * (including our own claimSigner) can never swap backends mid-session. + * + * `host` exists for tests; real callers use the default. + */ +export function detectForeignNip07Provider( + host: { nostr?: unknown } = window as unknown as { nostr?: unknown } +): ForeignNip07Provider | null { + const candidate = host.nostr as + | { _bitlogin?: unknown; getPublicKey?: unknown; signEvent?: unknown } + | undefined + | null; + if (!candidate || typeof candidate !== "object") return null; + if (candidate._bitlogin === true) return null; + if (typeof candidate.getPublicKey !== "function" || typeof candidate.signEvent !== "function") return null; + return candidate as unknown as ForeignNip07Provider; +} + +/** Generous because these calls are interactive -- most extensions pop an approval + * prompt the user has to find and click -- but finite, per §LM3's no-silent-hangs rule. */ +const DEFAULT_TIMEOUT_MS = 120_000; + +function withDeadline(promise: Promise, call: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new SignerTimeoutError(call, timeoutMs)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + +export class Nip07Signer implements Signer { + readonly method = "nip07" as const; + readonly capabilities: SignerCapabilities; + private provider: ForeignNip07Provider; + private timeoutMs: number; + + constructor(provider: ForeignNip07Provider, options: { timeoutMs?: number } = {}) { + this.provider = provider; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.capabilities = { + nip44: + typeof provider.nip44?.encrypt === "function" && typeof provider.nip44?.decrypt === "function", + nip04: + typeof provider.nip04?.encrypt === "function" && typeof provider.nip04?.decrypt === "function", + getRelays: typeof provider.getRelays === "function" + }; + } + + async getPublicKey(): Promise { + const raw = await withDeadline(this.provider.getPublicKey(), "getPublicKey", this.timeoutMs); + const publicKey = typeof raw === "string" ? raw.toLowerCase() : ""; + if (!/^[0-9a-f]{64}$/.test(publicKey)) { + throw new Error("The extension returned an invalid public key (expected 64 hex characters)."); + } + return publicKey; + } + + async signEvent(event: UnsignedEventForSigning): Promise { + // NIP-07 leaves it to the caller to complete the unsigned event; some extensions + // reject when created_at or tags are absent, so fill both here rather than making + // every caller remember to. + const normalized = { + kind: event.kind, + content: event.content, + tags: (event.tags ?? []) as string[][], + created_at: event.created_at ?? Math.floor(Date.now() / 1000) + }; + return withDeadline(this.provider.signEvent(normalized), "signEvent", this.timeoutMs); + } + + async nip44Encrypt(peerPublicKey: string, plaintext: string): Promise { + if (!this.capabilities.nip44) throw new SignerUnsupportedError("nip44.encrypt", this.method); + return withDeadline(this.provider.nip44!.encrypt!(peerPublicKey, plaintext), "nip44.encrypt", this.timeoutMs); + } + + async nip44Decrypt(peerPublicKey: string, payload: string): Promise { + if (!this.capabilities.nip44) throw new SignerUnsupportedError("nip44.decrypt", this.method); + return withDeadline(this.provider.nip44!.decrypt!(peerPublicKey, payload), "nip44.decrypt", this.timeoutMs); + } + + async nip04Encrypt(peerPublicKey: string, plaintext: string): Promise { + if (!this.capabilities.nip04) throw new SignerUnsupportedError("nip04.encrypt", this.method); + return withDeadline(this.provider.nip04!.encrypt!(peerPublicKey, plaintext), "nip04.encrypt", this.timeoutMs); + } + + async nip04Decrypt(peerPublicKey: string, payload: string): Promise { + if (!this.capabilities.nip04) throw new SignerUnsupportedError("nip04.decrypt", this.method); + return withDeadline(this.provider.nip04!.decrypt!(peerPublicKey, payload), "nip04.decrypt", this.timeoutMs); + } +} diff --git a/packages/widget/src/signers/types.ts b/packages/widget/src/signers/types.ts new file mode 100644 index 0000000..320175a --- /dev/null +++ b/packages/widget/src/signers/types.ts @@ -0,0 +1,54 @@ +/** Signer backends behind the widget's provider facade (docs/login-methods.md §LM3). + * + * A Signer is one way of producing signatures and encryption for the identity the user + * chose at sign-in. The derived BitLogin account path stays worker-backed and is not + * wrapped in this interface today (the element talks to WorkerClient directly, as it + * always has); alternative methods -- NIP-07 now, NIP-46 later -- implement this + * interface, and the element routes its public API through whichever is active. + */ +import type { NostrEvent } from "@bitlogin/core/nostr"; +import type { UnsignedEventForSigning } from "../provider.js"; + +export type SignerMethod = "bitlogin" | "nip07"; + +/** 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 + * TypeError off an undefined property (§LM4). Surfaced to the host in the + * bitlogin-login event detail. */ +export interface SignerCapabilities { + nip44: boolean; + nip04: boolean; + getRelays: boolean; +} + +export interface Signer { + readonly method: SignerMethod; + readonly capabilities: SignerCapabilities; + 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 call the active signer's backend doesn't implement. Deliberately a typed, stable + * name so hosts can distinguish "this extension can't do nip44" from a real failure. */ +export class SignerUnsupportedError extends Error { + constructor(call: string, method: SignerMethod) { + super(`The active signer (${method}) does not support ${call}.`); + this.name = "SignerUnsupportedError"; + } +} + +/** No silent hangs (§LM3): every facade call on an external signer has a deadline. + * Timing out is retryable -- the extension may just be waiting on a user prompt that + * got lost, or a remote signer may be asleep. */ +export class SignerTimeoutError extends Error { + constructor(call: string, timeoutMs: number) { + super( + `The signer did not answer ${call} within ${Math.round(timeoutMs / 1000)}s. It may be waiting for your approval in another window, or unavailable -- check your signer and try again.` + ); + this.name = "SignerTimeoutError"; + } +}