Replace OAuth on-ramps with passkey sign-in: zero registration, zero servers

Owner decision: static only, and no per-site provider registration --
which every OAuth path requires (and GitHub/Facebook additionally require
a server). Both on-ramp implementations (service handshake, serverless
Google Drive) are removed the day they shipped; centralized-onramps.md
stays on the record as the analysis of why, superseded by
passkey-login.md.

The passkey rail: a WebAuthn PRF-capable passkey (synced for most users
by Google Password Manager or iCloud Keychain) deterministically derives
the login name and password of a standard capsule account -- passkey.ts
holds the derivation behind a FROZEN v1 known-answer contract with the
same standing rule as knownAnswer.test.ts. The ordinary password flows
do everything else; nothing is stored outside the user's authenticator,
and the rail needs no network of its own.

Flow is idempotent by construction: sign-in against a passkey with no
account routes into setup with the derived credential
(AccountNotFoundError); setup against an existing account signs in
(AccountAlreadyExistsError). Fresh identity or §SF10 nsec import at
setup; Tier B2 phrase card retained ('your account stays yours even if
you lose your passkey'); rotation is the graduation step and says so.

Pins updated: passkey row with honest custody label inside the menu,
OAuth actions provably absent, element still network-free. 11 passkey
tests incl. the frozen vectors; menu and screens verified in Chrome.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 12:20:26 -04:00
1 parent be5dd529e9
commit 8079d90cf8
15 files changed
+561 -1059

No files matched your search

+7 -4
View File
@@ -15,10 +15,13 @@ for compatible applications.
- [`login-methods.md`](./login-methods.md) — the widget as the single login
surface: NIP-07 and NIP-46 sign-in behind the existing provider facade
(implemented; nsec-paste login rejected on the record)
- [`centralized-onramps.md`](./centralized-onramps.md) — proposal: clearly
labeled OAuth sign-in (Google, GitHub, …) as a fourth method, with OAuth as
a password manager for a real capsule account and a graduation ladder to
self-custody that keeps the same npub
- [`passkey-login.md`](./passkey-login.md) — passkey sign-in via the WebAuthn
PRF extension (implemented): zero registration, zero servers, a frozen
derivation contract, and the graduation ladder to self-custody
- [`centralized-onramps.md`](./centralized-onramps.md) — superseded, kept on
the record: the OAuth on-ramp designs (external service, serverless Google
Drive) and the analysis of why OAuth cannot be static without per-site
provider registration — the requirement that led to the passkey rail
## Portable account layer
+14 -5
View File
@@ -1,10 +1,19 @@
# Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody
**Document version:** 0.3
**Status:** **§CO3.3 (serverless Google, provider storage) is implemented
end to end** — it needs no service, so nothing remains to build for it
beyond a host registering an OAuth client ID. The **widget side of §CO5 is
implemented** (2026-08-04):
**Document version:** 0.4
**Status:** **SUPERSEDED — kept on the record.** All OAuth on-ramp
implementations described below (the §CO5 service handshake and the §CO3.3
serverless Google Drive rail) were **removed on 2026-08-04**, the same day
they shipped, by owner decision: every OAuth path requires per-site
provider registration (and for GitHub/Facebook, a server), which fails the
"static only, zero setup" requirement. The replacement is
[`passkey-login.md`](./passkey-login.md) — zero registration, zero servers,
custody still landing in the user's Google/Apple account via passkey sync.
What survives from this document: the journey framing (§CO6 graduation,
§CO7 honesty contract, Tier B2 phrase handling) all carried over to the
passkey rail, and the architecture analysis below remains the reference for
why OAuth cannot be done statically without a registered client.
Historical status at removal: the widget side of §CO5 was implemented
`onramp-url` / `onramp-name` / `onramp-providers` attributes, the popup
handshake below, "Continue with <Provider>" rows under the "Use an account
you already have" group (§CO11.1 resolved: that heading covers the whole
+5 -3
View File
@@ -59,9 +59,11 @@ never needs a login UI other than BitLogin, and BitLogin's security story —
*no method ever puts a root key on the page* — holds across every method it
offers (§LM6).
A fourth family of methods — clearly labeled centralized OAuth on-ramps
(Google, GitHub, …) that manage the credential while the account stays a
standard capsule account — is designed separately in
A fourth method — passkey sign-in, where a WebAuthn PRF-capable passkey
(synced by the user's Google/Apple account) deterministically derives the
credential of a standard capsule account with zero site setup and zero
servers — is designed in [`passkey-login.md`](./passkey-login.md). The OAuth
on-ramps considered before it are superseded and kept on the record in
[`centralized-onramps.md`](./centralized-onramps.md).
# LM3. Architecture: one facade, pluggable signers
+108
View File
@@ -0,0 +1,108 @@
# 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, 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.
- **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 skips the upfront phrase ceremony (Tier B2): the dashboard card
holds the phrase for the first session and nudges until claimed.
- 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 (Chrome/Android and Google
Password Manager, recent Safari/iOS, hardware keys with hmac-secret).
Support cannot be feature-detected without running a ceremony, so an
unsupported combination surfaces as an honest in-flow message pointing at
the other methods. The password path remains the universal fallback — and
the primary method, per the standing password-first decision (§LM9.1).