mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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]>
141 lines
8.2 KiB
Markdown
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.
|