Files
bitlogin/docs/passkey-login.md
thePR0M3TH3ANandClaude Fable 5 30b4f34300 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]>
2026-08-04 15:41:24 -04:00

141 lines
8.2 KiB
Markdown

# Passkey Sign-In — Zero Registration, Zero Servers
**Document version:** 0.1
**Status:** **Implemented** (2026-08-04). Supersedes the OAuth on-ramps of
[`centralized-onramps.md`](./centralized-onramps.md) (removed the same day;
that document stays on the record with the analysis of why every OAuth path
requires per-site provider registration — the requirement this design
exists to escape).
**Depends on:** `docs/spec.md` (standard registration §15 and login §16 are
reused unchanged), `docs/second-factor.md` §SF10 (import), WebAuthn PRF
extension.
---
# PK1. What it is
"Continue with a passkey": the browser's own credential sheet — for most
users backed by Google Password Manager or iCloud Keychain, so it *feels*
like signing in with the account they already have — unlocks a standard
BitLogin capsule account. The site ships the static widget and does nothing
else. No OAuth client ID, no provider console, no app review, no server, no
stored credential file anywhere: the credential is **derived, not fetched**.
Mechanically: the passkey's WebAuthn **PRF extension** evaluates a fixed,
domain-separated input (`bitlogin:passkey-credential:v1`) and returns 32
bytes only that passkey can produce. `passkey.ts` derives from it — via
HKDF, under a frozen v1 contract — a login name (`pk-<12 hex>`) and a
256-bit password, which feed the ordinary, audited password flows. The
derivation chain of the core spec is untouched; to the protocol this is
just a password that arrives from an authenticator instead of a keyboard.
**The derivation is a compatibility contract.** `passkey.test.ts` pins
known-answer vectors; a failure there is never "update the expected value"
— same standing rule as `knownAnswer.test.ts`.
# PK2. Why this beats OAuth on-ramps
Every OAuth provider requires a pre-registered per-application credential —
that gatekeeping *is* their product — and most additionally require a
client secret (a server). Passkeys are a web-platform API: no registration
with anyone, works on every site the moment the widget loads, and the
custody still lands where normies already keep their digital lives (their
Google or Apple account, via passkey sync). It is the only design that is
simultaneously zero-site-setup, zero-server, and zero-third-party.
# PK3. Custody, loss, and recourse
Stated the way §CO7 demands — who can access, what happens on loss, how to
leave:
- **Who holds what:** the passkey (and thus the account credential it
derives) lives in the user's authenticator — typically synced by Google
Password Manager or iCloud Keychain, optionally a hardware key or a
third-party manager. The site holds nothing. BitLogin holds nothing.
- **Lost phone or laptop: usually fine.** Synced passkeys are exactly the
failure mode passkeys were built for — sign into the platform account on
a new device and the passkey is there. This is *better* than a
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: 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
(or a held password), not a second passkey. The sync fabric is the
passkey's own redundancy.
- **Per-site scoping.** Passkeys are bound to the site's domain (rpId), so
like every on-ramp this is per-site convenience; cross-site portability
is the capsule account's job, via password or phrase.
# PK4. Graduation
Unchanged ladder, same npub throughout: save the npub; claim the phrase
(one tap from the dashboard card while it's still in session memory);
rotate to a self-held password — the rotate screen tells passkey users
plainly that this retires the passkey unlock ("you hold it, nobody else");
or full export. §SF10 import remains the same-identity escape hatch in the
other direction: an existing nsec can be wrapped into a passkey-unlocked
account at setup.
# PK5. UX flow
- Menu row (shown when WebAuthn exists): "Continue with a passkey" / "Kept
in your phone or browser's password manager."
- The passkey screen offers "Use my passkey" (primary) and "Create a new
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 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
(`AccountNotFoundError`). Both directions are idempotent by construction.
# PK6. Support boundaries
PRF requires a modern authenticator stack, and support is **genuinely
uneven across password managers today** — this is the method's real
limitation, not a rounding error. Support cannot be feature-detected
without running a ceremony (the widget asks for `prf` in the ceremony and
inspects the result), so an unsupported combination surfaces as an honest
in-flow message pointing at the other methods rather than a mystery
failure or a half-created account. The password path remains the universal
fallback — and the primary method, per the standing password-first
decision (§LM9.1).
Known provider status, as observed 2026-08-04. Treat as a snapshot: this
landscape moves, and only the ceremony is authoritative.
| Provider | PRF | Notes |
| --- | --- | --- |
| Chrome / Android + Google Password Manager | Yes | The mainstream path; passkeys sync with the platform account. |
| Safari / iOS + iCloud Keychain (recent versions) | Yes | Same shape on Apple platforms. |
| Bitwarden browser extension | Yes | Uses PRF itself for passkey vault unlock; open source, self-hostable via Vaultwarden — the practical choice on Linux desktops. |
| Hardware keys with hmac-secret (YubiKey 5, Nitrokey 3, SoloKeys v2) | Yes | PRF rides on CTAP2 hmac-secret. |
| KeePassXC 2.7.12 | **No** | Stores and manages passkeys (`credProps` present) but ships no `prf`/`hmac-secret` support — verified by binary inspection on 2.7.12, and PRF evaluation must happen inside the authenticator, so the browser extension cannot supply it. Users see the unsupported message. |
| de-Googled Android (/e/OS, GrapheneOS without Play Services) | Not reliably | Browser ceremonies typically route through Play Services' FIDO2 API; third-party credential-provider coverage is still patchy. Such users are well served by the NIP-46 remote-signer rail instead (§LM5), which needs no platform account at all. |
For a deployment, the practical reading: offer passkeys, expect a
meaningful minority to fall back, and never make the passkey the only
route to an account — which the graduation ladder (§PK4) already
guarantees.