Files
bitlogin/docs/centralized-onramps.md
thePR0M3TH3ANandClaude Fable 5 0f066750b6 Correct two errors in the superseded on-ramps doc
The recorded rejection of a shared ecosystem client ID was written
against a credential-RELAY hub (one that hands the unlock secret back to
the calling site) and stated too broadly. A hub that stays the SIGNER --
exposing only NIP-07-shaped operations over an origin/nonce-bound
postMessage channel, never releasing the credential -- is not refuted by
that objection. Recorded, with the observation that such a hub is
architecturally a NIP-46 bunker, which BitLogin already supports: the
open question is whether anyone runs a Google-gated one, not whether to
add a protocol. Residual cost restated plainly: the hub's served
JavaScript becomes the security of every user of every integrating site.

Also corrects drive.appdata's classification -- non-sensitive per
Google's current Drive scope documentation, not sensitive as written.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 15:17:12 -04:00

380 lines
21 KiB
Markdown

# Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody
**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
existing-account group — extension, remote signer, key import, and
centralized rails — with the custody statement carried by each row's
sub-label), client-side registration with optional §SF10 import, and the
Tier B2 "Secure your account" dashboard card. Reference services live
outside this repository by definition and do not yet exist.
**Handshake contract as implemented (v1):** the widget opens
`onramp-url?provider=<id>&state=<nonce>&origin=<page origin>` in a popup;
the popup posts to its opener exactly one
`{ type: "bitlogin:onramp:unlock", state, unlock: { loginName, password } }`
(returning user) or `{ …, register: { registrationToken,
suggestedLoginName? } }` (new user), accepted only from the configured
origin with the exact state nonce. For new users the widget registers
client-side, then `POST onramp-url` with
`{ type: "bitlogin:onramp:store", registrationToken, loginName, password }`;
a non-OK response forces the phrase ceremony immediately instead of
deferring it. Hosts must add the service origin to their CSP `connect-src`. Depends on
`docs/spec.md`, `docs/login-methods.md` (the one-login-surface model), and
`docs/second-factor.md` §SF10 (imported identities, implemented), which turns
out to be the graduation mechanism.
**Framing decision (owner, 2026-08-04):** centralized options are offered
openly and labeled honestly. The goal is normies using Nostr sites with
whatever sign-in they are already comfortable with — Google, GitHub, Facebook
— and a designed path that nudges them toward self-custody over time. "It's a
journey for all of us."
---
# CO1. Purpose and scope
BitLogin's native account is already the low-friction end of self-custody: a
login name and password, no extension, no server. But the true beginner does
not want *any* new credential — they want the button they already trust:
**Sign in with Google**.
This document designs that button as a fourth method behind the same
`<bitlogin-auth>` surface, such that:
1. The user gets a working Nostr identity — post, react, set a profile photo
— with nothing but their existing OAuth account.
2. The centralized dependency is **labeled, bounded, and escapable**: the
design maximizes what survives if the service or the OAuth provider
disappears, and every tier has a defined graduation path to full
self-custody **that keeps the same npub**.
3. The core protocol is untouched. Everything here is a deployment pattern
composed from existing primitives — capsule accounts, NIP-46, §SF10
import. `knownAnswer.test.ts` never notices.
Out of scope: implementing the services (they are server-side by nature and
cannot live in this static repo), and any change that would make a
centralized rail *required*. The native password path remains the default
presented to new users (login-methods.md §LM9.1).
# CO2. The principle: centralization as a rail, not a foundation
The test every design below must pass: **what does the user keep when the
centralized party is gone?**
BitLogin's answer is layered. The identity key lives in capsules on public
relays (or in the user's eventual export), not in the service's database
wherever the tier allows it. The centralized party holds an *unlock
credential* — the thing that makes everyday login effortless — and its
disappearance costs convenience, not identity, at every tier above the
lowest. This is the structural difference from "Sign in with Google" as
normally deployed, where the provider's disappearance costs the account.
# CO3. Two architectures
## CO3.1 Architecture A — the OAuth-gated bunker (works today)
A service runs a NIP-46 remote signer. First OAuth sign-in mints a keypair
server-side; thereafter, a valid OAuth session authorizes signing. BitLogin
connects to it exactly as it connects to any bunker — the widget's existing
NIP-46 support (§LM5) is the whole client.
- **Custody:** full. The service holds the signing key and sees every event
it signs.
- **What survives the service:** nothing, unless the service offers nsec
export (it must — see CO6).
- **Cost to build:** zero widget changes. nsec.app's noauth already proves
the pattern in production with email/passkey gating.
- **Role in this design:** the fastest possible proof point, and the
compatibility answer for services that already exist. Not the recommended
destination.
## CO3.3 Architecture C — provider storage: no server anywhere (implemented)
**Owner decision (2026-08-04): static only is the way.** Architecture B still
assumes *someone* runs the unlock service. For providers that expose
user-owned storage to pure browser clients, even that party disappears: the
credential of a standard capsule account lives in **the user's own cloud
storage**, readable only through the host's OAuth client ID. Google
authenticates the user; Google stores the user's own secret; the static
widget does everything else. Nobody else exists to trust, pay, or lose.
Implemented for Google (`onramp-google-client-id` attribute):
1. The "Continue with Google" row opens a popup on Google's OAuth endpoint —
plain implicit grant, `response_type=token`, no external script, so a
strict `script-src 'self'` CSP survives. Scope is `drive.appdata` only:
no identity claims, no email, minimum disclosure.
2. The registered redirect URI is **the widget page itself** — no callback
artifact to deploy. The widget instance in the popup recognizes the token
fragment, relays it to the opener (same-origin, exact state nonce),
scrubs the URL, and closes.
3. The opener reads `bitlogin-credential-v1.json` from the Drive app-data
folder: present → ordinary password login (§16); absent → first-time
setup (fresh identity or §SF10 import), client-side registration, then
the credential is uploaded. An upload failure triggers the no-orphan
rule: the phrase ceremony runs immediately.
Threat model: Google-account compromise = password compromise, exactly the
spec's §14 analysis; there is no second party to compromise. The app-data
file is plaintext by necessity — any encryption key would have to come from
the same OAuth gate it would be protecting against. Deployment notes: the
host registers its own OAuth client ID and the widget page's exact URL, and
adds `https://www.googleapis.com` to `connect-src`. Known dependency: the
implicit grant is legacy at Google; if it is ever sunset, the fallback is
Google Identity Services, which requires relaxing the CSP for one external
script — recorded here so that trade arrives as a decision, not a surprise.
The same pattern extends to Dropbox and Microsoft OneDrive (PKCE browser
flows with app-scoped storage); GitHub and Facebook offer no equivalent and
remain Architecture A/B territory.
Password rotation while Google-managed is the graduation step (CO6 step 2):
the Drive copy simply goes stale and dead, and the rotate screen says so
before the user commits.
**Scoping and blast radius (recorded 2026-08-04).** Drive app data is siloed
per OAuth client ID, so the Google rail is a per-site (or per-site-family)
convenience: a credential stored via Site A's client ID is invisible to
Site B, whose "Continue with Google" would offer to create a NEW account.
Cross-site portability is the underlying capsule account's job — password or
phrase work on every BitLogin site. If a HOST'S Google project is banned or
deleted, no Nostr account is lost (capsules live on public relays), but
ungraduated users are locked out in practice: their credential file sits in
their own Drive, readable only through an app bearing the dead client ID.
Graduated users lose nothing but a button. This is why the Tier B2 phrase
card is load-bearing, not decorative. A shared ecosystem-wide client ID
behind a static **credential-relay** hub on bitlogin.network was considered
and rejected: it would make one domain and one Google project a chokepoint
for every deployment, and a hub that hands the credential *back to the
calling site* lets any federated site read the user's unlock secret — worse
centralization than the per-site silo it would fix.
**Correction (2026-08-04, second look).** That rejection was written against
the credential-relay variant and is too broad as stated. A different shape —
a **hub that stays the signer** and exposes only NIP-07-shaped operations
(`getPublicKey`, `signEvent`, NIP-44/04) over an exact-origin, nonce-bound
`postMessage` channel, never releasing the credential, token, or nsec — is
not refuted by that objection, and would give integrating developers
"Continue with Google" with no provider registration of their own. Note what
that architecture *is*, though: a remote origin holding key material and
answering signing requests over an origin-bound channel is **a NIP-46
bunker**, which BitLogin already supports as a shipped, audited rail (§LM5).
The open question is therefore not "add a hub protocol" but "does someone
run a Google-gated bunker" — an ops commitment, not a widget feature. Its
residual cost is unchanged and severe: the hub's *served JavaScript* becomes
the security of every user of every integrating site (one XSS or supply-chain
compromise reaches everyone, where the per-site silo caps the blast radius at
one site), and one Google project's suspension or policy state gates the
whole ecosystem while seeing every site's login pattern in one place.
`drive.appdata` was described here as a Google "sensitive" scope requiring
app-verification review before ~100 users. Per Google's current Drive API
scope documentation it is classified **non-sensitive** (it is the
recommended narrow alternative to full-Drive scopes) and needs only basic
OAuth app verification — corrected on the record; not independently
re-verified here.
## CO3.2 Architecture B — OAuth as the password manager (recommended)
The insight: a BitLogin account requires a high-entropy password, but the
protocol never cares *who remembers it*. So let the service remember it.
**First sign-in (registration):**
1. The widget completes the OAuth dance with the service (CO5).
2. The service verifies the OAuth token, sees a new user, and returns
`register: true` plus a service-generated login name.
3. The **client** — the static widget, in the worker — runs the ordinary
registration flow (§15): generates the password, derives everything,
builds both capsules, publishes to relays. The recovery phrase is
generated and the recovery capsule written, per spec.
4. The client sends the service the password (over TLS, after OAuth). The
service stores `{oauth_subject → login_name, password}` and nothing else.
5. Phrase handling per deployment tier (CO4).
**Every later sign-in:** OAuth token in, `{loginName, password}` out, then
the client runs the standard password login (§16) — Argon2id, locator,
capsule decrypt, rollback checks, all client-side, all existing code.
- **Custody:** the service holds an unlock secret, never the signing key.
Compromise of the service is password compromise, which the spec already
analyzes (§14): no cryptographic authority over the recovery capsule,
detectable rollback, phrase path unaffected.
- **What survives the service:** the account, fully — it is a standard
capsule account on public relays. Anyone holding the password or phrase
can use it with zero service involvement.
- **Privacy:** the service learns login times. It does not see events,
contacts, or activity — the client talks to relays directly.
- **The service is one endpoint** plus OAuth plumbing:
`POST /unlock {provider, oauth_token} → {loginName, password, register?}`.
No relay access, no Nostr code, no key material. Small enough to audit.
# CO4. Phrase handling in Architecture B — the custody dial
Registration necessarily has the phrase in memory (§13); what happens to it
is a deployment choice, and each choice is a labeled tier:
- **Tier B1 — phrase shown at signup.** Maximum sovereignty, maximum
friction; defeats the purpose for true normies. Offered, not default.
- **Tier B2 — phrase held for the first session, then discarded
(recommended default).** The phrase stays in worker memory for the first
session only. The dashboard shows a persistent, dismissible "Secure your
account" card: view and verify the phrase any time before the session
ends; afterward it is gone — nobody has it, and recovery rests on the
OAuth rail until the user graduates (CO6). Honest failure mode: losing
the OAuth account *and* the service before graduating loses account
access (the npub itself can still be preserved — CO6, step 0).
- **Tier B3 — phrase escrowed by the service,** encrypted at rest, released
only through the OAuth gate. Deepest custody, fullest recovery. A
deployment offering B3 must label it as holding everything needed to
take the account — functionally similar trust to Architecture A, with
better privacy.
The tier is the deployment's call; the widget's labeling (CO7) states it in
one sentence either way.
# CO5. Widget integration
A fifth option row in the "More sign-in options" menu (login-methods.md UI),
under an explicit group heading:
```
Centralized options
ⓖ Continue with Google
Convenient — AcmeSigner manages your sign-in
```
- The row is configured, never built-in: a host opts in via element
attributes (`onramp-url`, `onramp-providers="google,github"`,
`onramp-name="AcmeSigner"`). No configuration, no row — BitLogin ships no
centralized dependency by default.
- Flow: the row opens the service's OAuth page (new tab, like the NIP-46
auth_url ceremony). The service finishes OAuth and hands the widget its
result — for Architecture B, `{loginName, password}` into the existing
login flow; for a service that is an Architecture-A bunker, a `bunker://`
URI into the existing NIP-46 flow. Either way the widget reuses a flow
that already exists and already has tests.
- Session shape: an Architecture-B session **is** a BitLogin account session
— vault, rotation, export all work. `bitlogin-login` gains an optional
`detail.onramp` (e.g. `"oauth:google"`) so a host can know the rail
without a new method value; `detail.method` stays `"bitlogin"` because
that is what the session is. Architecture-A sessions are `"nip46"`
sessions, unchanged.
- The credential handoff (password in a redirect/postMessage payload) must
be same-origin-verified, single-use, and never appear in a URL that hits
server logs (fragment or postMessage, not query string).
# CO6. Graduation — the journey, mechanically
The nudge sequence, from most passive to full self-custody. Every step is
optional and every step keeps the same npub.
**Step 0 — know your npub.** The dashboard already shows it. Add a "save
your npub" affordance (copy/share card) with one line of copy: *this is your
public identity; it works on every Nostr app, not just this site.* Even a
user who saves nothing else has learned their identity is portable — and a
lost account's npub can still be referenced, cited, followed.
**Step 1 — secure your account (Tier B2's first-session card).** View the
phrase, verify three words, done: the user now holds the sovereign recovery
path and the OAuth rail becomes pure convenience. This is the cheapest
meaningful graduation and the one to nudge hardest.
**Step 2 — hold your own password.** Rotate the password (existing §18
flow) to one the user stores in their own password manager, and tell the
service to drop its copy. OAuth stops working as a rail; the account is now
fully native. Offered when engagement suggests readiness (CO8).
**Step 3 — full export.** The existing identity-export screen (npub, nsec,
recovery export file). For users leaving for another signer entirely —
that is also success. The goal is Nostr users, not captive BitLogin users.
**Graduating out of custody (Architecture A, or B3, or a B2 user past their
first session):** the service exposes "export my key" (required — CO9), and
the user runs the **implemented §SF10 import flow**: paste the nsec once,
wrap it in a self-chosen BitLogin account with a fresh phrase. Same npub,
new custody. For Architecture A the service *held* the key, so the honest
copy must say so and recommend watching for misuse — or, for the cautious,
identity rotation (§18.4) with its social cost stated plainly. For
Architecture B the service never had the key, so import-graduation carries
no such asterisk; only the password should be rotated afterward.
# CO7. Labeling — the honesty contract
Every centralized option carries, at the point of choice and on the
dashboard, plain-language answers to three questions:
1. **Who can access this account?** ("AcmeSigner can sign in as you" /
"AcmeSigner holds your key and signs for you" per tier.)
2. **What happens if they disappear?** ("Your account keeps working with
your recovery phrase" / "your password" / "your account is lost —
secure it below to change that.")
3. **How do I leave?** One tap to the relevant graduation step.
No dark patterns in either direction: centralized options are not shamed
(they are the on-ramp, not the enemy), and self-custody is not oversold
(a phrase the user loses is worse than a Google account they keep). The
nudge is informed comfort, not fear.
# CO8. Nudge mechanics
- First session (B2): the "Secure your account" card, persistent until
completed or explicitly dismissed with "later."
- Milestone nudges, capped and dismissible: after the account's Nth sign-in
or first vault connection, one card — never a modal, never blocking —
offering step 1 or 2. A dismissed nudge stays dismissed for a long, fixed
interval; nagging teaches users to dismiss, not to graduate.
- The dashboard always shows the current standing ("Signed in via Google ·
AcmeSigner manages your sign-in") with the three CO7 answers one tap away
— the ambient reminder that costs no interruption.
# CO9. Requirements on a conforming service
A service a BitLogin deployment points at must:
1. Export on demand: password + recovery-export file (B), or nsec (A/B3),
through the OAuth gate, without support tickets.
2. Delete on demand (its stored credential), completing step 2.
3. State its tier in a machine-readable descriptor the widget can render
(`/.well-known/bitlogin-onramp.json`: tier, custody statement, export
endpoints), so the CO7 labels are generated, not hand-maintained lies.
4. Rate-limit and log unlock attempts; the endpoint is the account's
front door.
# CO10. Threat model summary
| Actor compromised | Architecture A | Architecture B (B2) |
| --- | --- | --- |
| OAuth provider account | Attacker signs as user until service session revoked | Attacker gets password → full session access (§14 limits); phrase path unaffected; detectable rollback |
| On-ramp service | Key compromised; identity rotation required | Password compromised (§14); identity key never present; rotate password |
| Both gone (no compromise, just gone) | Account lost unless previously exported | Account intact; sign in with password/phrase if graduated, else lost access (npub preservable) |
| Relays | Availability only, as core spec | Availability only, as core spec |
# CO11. Open questions
1. Should the widget cache the Architecture-B password in the existing
session cache (it already caches session material for reload-survival),
or re-OAuth every visit?
2. `detail.onramp` shape: string rail identifier vs. structured
`{ provider, service, tier }`.
3. Whether Tier B2's first-session phrase window should survive a reload
within some time budget, or end strictly with the worker.
4. **Resolved 2026-08-04 (owner decision):** the group heading is "Use an
account you already have," covering every existing-account path — Nostr
extension, remote signer, key import, and centralized rails alike — with
the custody statement carried entirely by each centralized row's
sub-label ("<Service> manages your sign-in").