diff --git a/docs/passkey-login.md b/docs/passkey-login.md index f38b58d..0580698 100644 --- a/docs/passkey-login.md +++ b/docs/passkey-login.md @@ -58,12 +58,20 @@ leave: remembered password, which dies with the memory, and better than the removed Drive rail, which died with a client ID. - **Lost platform account (Google/Apple ban or lockout), or a device-bound - key that's gone: the passkey is gone.** Recourse, in order: the **12-word - recovery phrase** (the dashboard's "Secure your account" card exists - precisely for this — Tier B2 of the on-ramps design, retained); or a - previously done password graduation (PK4). A user who claimed neither - and lost the platform account has lost access — the same honest bottom - line as every rail, which is why the phrase card is load-bearing. + key that's gone: the passkey is gone.** Recourse: the **12-word recovery + phrase**, which every passkey account has, because the ceremony is + **mandatory at registration** (owner decision 2026-08-04) — the session + is not granted until the user has seen the phrase and answered the + three-word quiz, exactly as password signup works. The deferred "secure + your account later" card this replaced was inherited from the OAuth + on-ramp design, where a service still held a copy of the credential; + nothing holds a copy here, so deferring the only recovery path was + strictly worse than the design it was borrowed from. +- **Lost site domain.** Worth stating because it surprises people: the + passkey is bound to the site's WebAuthn RP id, so if a deployment changes + domain, its users' passkeys no longer unlock anything there. The account + is untouched on relays and the phrase (or password) still works — another + reason the ceremony is not optional. - **One passkey = one account.** PRF output is per-credential, so a second passkey derives a *different* account — there is no "add a backup passkey" for the same account. The backup for the account is the phrase @@ -91,8 +99,12 @@ account at setup. passkey." Sign-in with a passkey that has no account on this site routes into first-time setup *with the credential it already derived* — fresh identity or import — rather than erroring. -- Setup skips the upfront phrase ceremony (Tier B2): the dashboard card - holds the phrase for the first session and nudges until claimed. +- Setup runs the recovery ceremony before granting a session: phrase shown, + three words verified, then the dashboard. The setup screen says so up + front ("Next you'll save a 12-word recovery phrase… it isn't optional"), + so it reads as part of the flow rather than an interruption. Net secret + count is still lower than password signup — one phrase, versus a password + *and* a phrase — and daily sign-in is a biometric tap. - Re-creating setup against a passkey whose account already exists simply signs in (`AccountAlreadyExistsError` → login with the same derived credential); a passkey sign-in against no account offers setup diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 5f82ff9..6e62279 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -165,9 +165,10 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declinedThat includes "Continue with a passkey" — zero setup on your side, no OAuth client to register, no server, no CSP changes. A PRF-capable passkey (synced for most users by their Google or Apple account) deterministically derives the credential of a real, portable Nostr account; nothing is stored - anywhere but the user's own authenticator. Users can bring an existing Nostr key at signup and are nudged - toward claiming their recovery phrase; rotating the password later "graduates" the account to fully - self-held. See docs/passkey-login.md in the repo for the derivation contract and loss/recovery + anywhere but the user's own authenticator. Users can bring an existing Nostr key at signup, and every passkey + account completes the 12-word recovery ceremony before its first session — it is mandatory, so a lost + passkey, a lost Google/Apple account, or a changed site domain can never trap anyone. Rotating the + password later "graduates" the account to fully self-held. See docs/passkey-login.md in the repo for the derivation contract and loss/recovery model.

Two different questions have two different APIs, and conflating them is the classic integration bug:

"Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod() diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index c160035..22bff8e 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -35,8 +35,10 @@ deployment above. account (frozen v1 contract in the widget's passkey.ts); the ordinary password flows do the rest, and nothing is stored outside the user's own authenticator. Synced passkeys survive device loss; losing the PLATFORM - account is recovered by the 12-word phrase, which the dashboard nudges - users to claim right after signup. One passkey = one account (PRF is + account (or the site changing domain, which changes the WebAuthn RP id) + is recovered by the 12-word phrase, whose ceremony is MANDATORY at + signup -- no session is granted until it is completed, because a passkey + account has no custodian who could help later. One passkey = one account (PRF is per-credential); passkeys are per-site (rpId) -- cross-site portability is the capsule account's job. Rotating the password is the graduation step to fully self-held. Requires a PRF-capable browser/authenticator; diff --git a/packages/widget/src/e2e/passkey.e2e.test.ts b/packages/widget/src/e2e/passkey.e2e.test.ts index e4a3138..335b476 100644 --- a/packages/widget/src/e2e/passkey.e2e.test.ts +++ b/packages/widget/src/e2e/passkey.e2e.test.ts @@ -91,6 +91,28 @@ function startServer(relayUrls: string[]): Promise<{ server: Server; origin: str }); } +/** + * Drives the mandatory recovery ceremony that every passkey registration now + * runs before a session exists: read the 12 words, answer the three-word quiz + * from them, submit. Returns the phrase. + */ +async function completeRecoveryCeremony(widget: { + locator: (selector: string) => any; +}): Promise { + await widget.locator(".phrase-word").first().waitFor({ timeout: 60_000 }); + const cells: string[] = await widget.locator(".phrase-word").allTextContents(); + const words = cells.map((cell) => cell.replace(/^\s*\d+\.\s*/u, "").trim()); + await widget.locator('[data-action="goto-verify-phrase"]').click(); + const inputs = widget.locator('form[data-form="verify-phrase"] input'); + const count: number = await inputs.count(); + for (let i = 0; i < count; i++) { + const name: string = await inputs.nth(i).getAttribute("name"); + await inputs.nth(i).fill(words[Number(name.replace("confirm-", ""))]!); + } + await widget.locator('form[data-form="verify-phrase"] button[type="submit"]').click(); + return words; +} + describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () => { let relays: MockRelay[] = []; let server: Server; @@ -158,8 +180,14 @@ describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () await widget.locator('[data-action="passkey-new"]').waitFor({ timeout: 30_000 }); await widget.locator('[data-action="passkey-new"]').click(); - // Registration publishes capsules to the mock relay, then lands on the - // dashboard showing the npub. + // Recovery is MANDATORY for passkey accounts: registration lands on + // the phrase screens, and NO session exists until they're completed. + await widget.locator(".phrase-word").first().waitFor({ timeout: 60_000 }); + expect(await page.evaluate(() => window.bitlogin?.activeMethod())).toBeNull(); + const words = await completeRecoveryCeremony(widget); + expect(words).toHaveLength(12); + + // Only now does the session exist and the dashboard appear. const pubkeyLine = widget.locator(".pubkey"); await pubkeyLine.waitFor({ timeout: 60_000 }); const firstNpub = (await pubkeyLine.textContent())?.trim() ?? ""; @@ -170,8 +198,9 @@ describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () const capsuleKinds = relays.flatMap((r) => r.storedEventKinds()); expect(capsuleKinds.filter((kind) => kind === 30078).length).toBeGreaterThan(0); - // Tier B2: the phrase card is offered in the first session. - expect(await widget.locator('[data-action="goto-confirm-phrase"]').count()).toBe(1); + // No deferred "secure it later" card exists any more -- recovery was + // completed before the session was granted. + expect(await widget.locator('[data-action="goto-confirm-phrase"]').count()).toBe(0); // The passkey session is reported to the host, method-aware. const session = await page.evaluate(() => window.bitlogin?.activeSession()); @@ -189,8 +218,8 @@ describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () const secondNpub = (await pubkeyLine.textContent())?.trim() ?? ""; expect(secondNpub).toBe(firstNpub); - // A returning session is NOT offered the first-session phrase card. - expect(await widget.locator('[data-action="goto-confirm-phrase"]').count()).toBe(0); + // A returning sign-in does not repeat the ceremony. + expect(await widget.locator(".phrase-word").count()).toBe(0); await page.screenshot({ path: fileURLToPath(new URL("../../../../.e2e-passkey-dashboard.png", import.meta.url)) @@ -235,6 +264,7 @@ describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", () await widget.locator('[data-action="passkey-register"]').click(); await widget.locator('[data-action="passkey-new"]').waitFor({ timeout: 30_000 }); await widget.locator('[data-action="passkey-new"]').click(); + await completeRecoveryCeremony(widget); const pubkey = widget.locator(".pubkey"); await pubkey.waitFor({ timeout: 60_000 }); npubs.push((await pubkey.textContent())?.trim() ?? ""); diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index c989d1c..7212821 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -129,11 +129,11 @@ export class BitLoginAuthElement extends HTMLElement { // passkey.ts), and the ordinary password flows do everything else. // `pendingPasskey` holds the derived credential between "the ceremony // succeeded" and the new user's fresh-vs-import choice. `passkeySession` - // marks the standing for dashboard labeling. `securePhrasePending` is the - // Tier B2 deferred phrase ceremony behind the dashboard card. + // marks the standing for dashboard labeling. There is deliberately NO + // deferred-phrase state: passkey registration runs the recovery ceremony + // before granting the session (see runPasskeyRegistration). private pendingPasskey: { loginName: string; password: string } | null = null; private passkeySession = false; - private securePhrasePending = false; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; private exportedNsec = ""; @@ -294,7 +294,6 @@ export class BitLoginAuthElement extends HTMLElement { this.altSigner = null; this.session = null; this.passkeySession = false; - this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); @@ -304,7 +303,6 @@ export class BitLoginAuthElement extends HTMLElement { await this.worker.logout(); this.session = null; this.passkeySession = false; - this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); @@ -1161,10 +1159,15 @@ export class BitLoginAuthElement extends HTMLElement { }); this.busy = false; this.passkeySession = true; - this.securePhrasePending = true; - this.noteSignerClaim(this.claimSigner()); - this.dispatchLogin(); - this.flashSuccess("dashboard", "Account created"); + // MANDATORY recovery ceremony, exactly as password signup does it + // (owner decision 2026-08-04). A passkey account has NO custodian: lose + // the passkey -- platform account gone, or this site's domain changed, + // which changes the WebAuthn RP id -- and the phrase is the only way + // back. The deferred "secure it later" card this replaced came from the + // OAuth on-ramp design, where a service still held a copy; nothing + // holds a copy here. Sign-in is dispatched only once the phrase is + // verified, in handleVerifyPhraseSubmit. + this.goto("confirm-phrase"); } catch (err) { if (err instanceof Error && err.name === "AccountAlreadyExistsError") { // This passkey already has an account here (an earlier setup finished @@ -1245,14 +1248,6 @@ export class BitLoginAuthElement extends HTMLElement { return; } } - if (this.securePhrasePending) { - // Tier B2 deferred ceremony (§CO4): the session already exists and - // bitlogin-login already fired at registration -- this just retires - // the "Secure your account" card. - this.securePhrasePending = false; - this.flashSuccess("dashboard", "Recovery phrase secured"); - return; - } this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); this.dispatchLogin(); @@ -1708,7 +1703,7 @@ export class BitLoginAuthElement extends HTMLElement { ${this.busy ? 'Creating your account…' : "Create a fresh identity"} -

Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.

+

Next you'll save a 12-word recovery phrase. It's the only way back if you ever lose the passkey, so it isn't optional — and it's the same phrase that lets you take full control of this account, or leave for any other Nostr app, without losing your identity.

`; case "extension-confirm": @@ -1949,18 +1944,11 @@ export class BitLoginAuthElement extends HTMLElement { `; - // Tier B2 (§CO4): the phrase exists only in this session's memory - // until the user claims it -- the one nudge that stays until acted on. - const securePhraseCard = this.securePhrasePending - ? `
Secure your account: your recovery phrase is available only during this session. Save it now and your account stays yours even if you lose your passkey. -
` - : ""; const passkeyStanding = this.passkeySession - ? `

Signed in with a passkey — it unlocks this account from your device's password manager; your identity lives on the open Nostr network.

` + ? `

Signed in with a passkey — it unlocks this account from your device's password manager; your identity lives on the open Nostr network. Your recovery phrase works even if the passkey is lost.

` : ""; return `

Signed in

- ${securePhraseCard} ${this.renderWarnings()}

${escapeHtml(this.session?.npub ?? "")}

${passkeyStanding} diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index f76dd89..eacacd6 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -67,6 +67,21 @@ describe("widget security policy", () => { } }); + it("passkey registration cannot grant a session before the recovery ceremony", () => { + // Owner decision 2026-08-04: a passkey account has no custodian, so the + // phrase ceremony is mandatory. runPasskeyRegistration must hand off to + // the phrase screens and must NOT dispatch a session itself; the deferred + // "secure it later" state is gone entirely. + const body = widgetSource.slice( + widgetSource.indexOf("private async runPasskeyRegistration"), + widgetSource.indexOf("private async handlePasskeySignInWith"), + ); + expect(body).toContain('this.goto("confirm-phrase")'); + expect(body).not.toContain("dispatchLogin"); + expect(body).not.toContain("flashSuccess"); + expect(widgetSource).not.toContain("securePhrasePending"); + }); + it("the passkey row lives inside the menu with an honest custody label", () => { // docs/passkey-login.md: zero registration, zero servers -- and the // OAuth on-ramps that preceded it stay removed (owner decision