mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Recovery phrase is mandatory for passkey accounts
Owner decision: keep the architecture (static, independently hostable, no BitLogin-operated OAuth service, shared signer, or central login domain) and strengthen recovery instead. Passkey registration now runs the phrase ceremony BEFORE granting a session -- phrase shown, three words verified, then dashboard -- exactly as password signup does. The deferred 'Secure your account' card is gone, along with the securePhrasePending state behind it: that design was inherited from the OAuth on-ramps, where a service still held a copy of the credential. Nothing holds a copy for a passkey account, so deferring the only recovery path was strictly worse than the design it came from. Note the recovery EXPORT is not an alternative here: by design it carries no phrase-derived key and cannot recover an account alone (§19.5), so the phrase ceremony is the only thing that closes the trap. Also documents the loss mode that surprises people: a passkey is bound to the site's WebAuthn RP id, so a deployment changing domain leaves its users' passkeys unable to unlock anything there -- account untouched on relays, phrase still works. The e2e now drives the real ceremony (reads the 12 words from the DOM, answers the three-word quiz) and asserts activeMethod() is null until it completes, so 'no session before recovery' is enforced against a real browser. New policy pin: runPasskeyRegistration must hand off to the phrase screens and must not dispatch a session itself. 269 tests green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
0f066750b6
commit
30b4f34300
6 files changed
+93
-45
No files matched your search
+20
-8
@@ -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
|
||||
|
||||
@@ -165,9 +165,10 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
|
||||
<p>That includes <strong>"Continue with a passkey"</strong> — 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 <code>docs/passkey-login.md</code> 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 <code>docs/passkey-login.md</code> in the repo for the derivation contract and loss/recovery
|
||||
model.</p>
|
||||
<p>Two different questions have two different APIs, and conflating them is the classic integration bug:</p>
|
||||
<p><strong>"Is someone signed in through BitLogin?"</strong> — use <code>window.bitlogin.activeMethod()</code>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string[]> {
|
||||
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() ?? "");
|
||||
|
||||
@@ -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 ? '<span class="spinner"></span>Creating your account…' : "Create a fresh identity"}
|
||||
</button>
|
||||
<button class="secondary" type="button" data-action="passkey-import" ${this.busy ? "disabled" : ""}>I already have a Nostr key</button>
|
||||
<p class="small">Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.</p>
|
||||
<p class="small">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.</p>
|
||||
`;
|
||||
|
||||
case "extension-confirm":
|
||||
@@ -1949,18 +1944,11 @@ export class BitLoginAuthElement extends HTMLElement {
|
||||
<button class="secondary" type="button" data-action="goto-vault-manage">Wallet connections</button>
|
||||
<button class="secondary" type="button" data-action="goto-change-password">Rotate password</button>
|
||||
<button class="secondary" type="button" data-action="goto-export">Export identity</button>`;
|
||||
// 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
|
||||
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even if you lose your passkey.
|
||||
<button class="link-inline" type="button" data-action="goto-confirm-phrase">View and save it</button></div>`
|
||||
: "";
|
||||
const passkeyStanding = this.passkeySession
|
||||
? `<p class="small">Signed in with a passkey — it unlocks this account from your device's password manager; your identity lives on the open Nostr network.</p>`
|
||||
? `<p class="small">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.</p>`
|
||||
: "";
|
||||
return `
|
||||
<h2>Signed in</h2>
|
||||
${securePhraseCard}
|
||||
${this.renderWarnings()}
|
||||
<p class="pubkey">${escapeHtml(this.session?.npub ?? "")}</p>
|
||||
${passkeyStanding}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user