From 8079d90cf8dda2215392168f29f78b4e942f1e17 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:20:26 -0400
Subject: [PATCH] Replace OAuth on-ramps with passkey sign-in: zero
registration, zero servers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
docs/README.md | 11 +-
docs/centralized-onramps.md | 19 +-
docs/login-methods.md | 8 +-
docs/passkey-login.md | 108 +++++
packages/demo/public/docs.html | 17 +-
packages/demo/public/llms.txt | 25 +-
packages/widget/src/config.ts | 19 +-
packages/widget/src/driveOnramp.test.ts | 160 --------
packages/widget/src/driveOnramp.ts | 203 ----------
packages/widget/src/element.ts | 447 ++++++++-------------
packages/widget/src/onramp.test.ts | 167 --------
packages/widget/src/onramp.ts | 177 --------
packages/widget/src/passkey.test.ts | 104 +++++
packages/widget/src/passkey.ts | 119 ++++++
packages/widget/src/securityPolicy.test.ts | 36 +-
15 files changed, 561 insertions(+), 1059 deletions(-)
create mode 100644 docs/passkey-login.md
delete mode 100644 packages/widget/src/driveOnramp.test.ts
delete mode 100644 packages/widget/src/driveOnramp.ts
delete mode 100644 packages/widget/src/onramp.test.ts
delete mode 100644 packages/widget/src/onramp.ts
create mode 100644 packages/widget/src/passkey.test.ts
create mode 100644 packages/widget/src/passkey.ts
diff --git a/docs/README.md b/docs/README.md
index 1043a27..6c1203a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/centralized-onramps.md b/docs/centralized-onramps.md
index 146d3c3..17e8ccc 100644
--- a/docs/centralized-onramps.md
+++ b/docs/centralized-onramps.md
@@ -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 " rows under the "Use an account
you already have" group (§CO11.1 resolved: that heading covers the whole
diff --git a/docs/login-methods.md b/docs/login-methods.md
index f6dfa3b..edb95af 100644
--- a/docs/login-methods.md
+++ b/docs/login-methods.md
@@ -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
diff --git a/docs/passkey-login.md b/docs/passkey-login.md
new file mode 100644
index 0000000..6c6b613
--- /dev/null
+++ b/docs/passkey-login.md
@@ -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).
diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html
index 7020066..5f82ff9 100644
--- a/packages/demo/public/docs.html
+++ b/packages/demo/public/docs.html
@@ -162,16 +162,13 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one
surface, and routes every signing call to whichever the user picked. You don't need your own method picker,
and your app code doesn't branch by method.
-
Sites that want a "Continue with Google" option can have it with no server anywhere:
- set onramp-google-client-id to your own Google OAuth client ID, register this page's exact URL
- as a redirect URI, and add https://www.googleapis.com to your CSP's connect-src.
- The credential of a real, portable Nostr account is stored in the user's own Google Drive app data
- — nobody else holds anything. Users can bring an existing Nostr key at signup and are nudged toward claiming
- their recovery phrase over time; rotating the password later "graduates" the account to fully self-held.
-
For providers without browser-accessible storage (GitHub, Facebook, …) the widget can instead point at an
- external OAuth unlock service (onramp-url, onramp-name, onramp-providers;
- the service's origin goes in connect-src). Nothing centralized ships unless you configure it.
- See docs/centralized-onramps.md in the repo for contracts and threat models.
+
That includes "Continue with a passkey" — 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 docs/passkey-login.md in the repo for the derivation contract and loss/recovery
+ model.
Two different questions have two different APIs, and conflating them is the classic integration bug:
"Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod()
(or the richer activeSession()), or listen for bitlogin-login. This answers correctly
diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt
index 386cc4b..d758478 100644
--- a/packages/demo/public/llms.txt
+++ b/packages/demo/public/llms.txt
@@ -28,18 +28,19 @@ deployment above.
- A `` custom element (Shadow DOM, no CSS collisions) handling
create account, sign in, forgot-password recovery via a 12-word phrase, and
password rotation.
-- Serverless "Continue with Google" (`onramp-google-client-id` attribute):
- NO server anywhere -- the credential of a standard capsule account lives
- in the user's own Google Drive app data, readable only through the host's
- OAuth client ID. The host registers the widget page's exact URL as the
- OAuth redirect URI (the page relays the token to its opener itself; no
- callback artifact) and adds https://www.googleapis.com to connect-src.
- Scope is drive.appdata only. Rotating the password is the graduation
- step: the Drive copy goes stale and the user holds the credential.
-- Optional centralized on-ramps ("Continue with GitHub/Facebook/..." via an
- external service): a host may point the widget at an OAuth unlock service
- via `onramp-url`, `onramp-name`, and `onramp-providers` attributes --
- nothing centralized ships by default. The service acts as the password manager for a REAL
+- "Continue with a passkey": zero site setup, zero servers, zero
+ registration with any provider. A WebAuthn PRF-capable passkey (synced
+ for most users by Google Password Manager or iCloud Keychain)
+ deterministically derives the login name + password of a standard capsule
+ 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
+ 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;
+ the widget shows an honest fallback message otherwise. The service acts as the password manager for a REAL
capsule account (it holds the unlock secret, never the key); rows render
under "Use an account you already have" with the custody statement
(" manages your sign-in") as the sub-label, new users can create
diff --git a/packages/widget/src/config.ts b/packages/widget/src/config.ts
index ae5a749..5a5197f 100644
--- a/packages/widget/src/config.ts
+++ b/packages/widget/src/config.ts
@@ -1,18 +1,7 @@
/** Host-page-configurable options for (§19.1, §19.6). */
-import { parseOnrampConfig, type OnrampConfig } from "./onramp.js";
-import { parseGoogleOnrampConfig, type GoogleOnrampConfig } from "./driveOnramp.js";
-
export interface BitLoginConfig {
vaultRelayUrls?: string[];
discoveryRelayUrls?: string[];
- /** Centralized on-ramp via an external service (centralized-onramps.md
- * §CO3.2/§CO5). Absent unless the host explicitly configures one --
- * BitLogin ships no centralized dependency by default. */
- onramp?: OnrampConfig;
- /** Serverless Google on-ramp (§CO3.3): the credential lives in the user's
- * own Google Drive app data; no third party exists. Enabled by the host
- * registering an OAuth client ID. */
- onrampGoogle?: GoogleOnrampConfig;
}
export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
@@ -20,12 +9,6 @@ export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
const discoveryAttr = el.getAttribute("discovery-relays");
return {
vaultRelayUrls: vaultAttr ? vaultAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
- discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
- onramp: parseOnrampConfig({
- url: el.getAttribute("onramp-url"),
- name: el.getAttribute("onramp-name"),
- providers: el.getAttribute("onramp-providers")
- }),
- onrampGoogle: parseGoogleOnrampConfig(el.getAttribute("onramp-google-client-id"))
+ discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined
};
}
diff --git a/packages/widget/src/driveOnramp.test.ts b/packages/widget/src/driveOnramp.test.ts
deleted file mode 100644
index 5c0ba5e..0000000
--- a/packages/widget/src/driveOnramp.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- buildGoogleAuthUrl,
- buildGoogleTokenMessage,
- currentRedirectUri,
- findDriveCredential,
- newGoogleOnrampState,
- parseGoogleCallbackFragment,
- parseGoogleOnrampConfig,
- storeDriveCredential,
- validateGoogleTokenMessage
-} from "./driveOnramp.js";
-
-const CLIENT = { clientId: "abc123.apps.googleusercontent.com" };
-
-describe("config and auth URL", () => {
- it("parses a client id and ignores blanks", () => {
- expect(parseGoogleOnrampConfig(" abc.apps.googleusercontent.com ")).toEqual({
- clientId: "abc.apps.googleusercontent.com"
- });
- expect(parseGoogleOnrampConfig(null)).toBeUndefined();
- expect(parseGoogleOnrampConfig(" ")).toBeUndefined();
- });
-
- it("builds an implicit-grant URL with the app-data scope only", () => {
- const url = new URL(buildGoogleAuthUrl(CLIENT, "https://site.example/login.html", "st4te"));
- expect(url.origin + url.pathname).toBe("https://accounts.google.com/o/oauth2/v2/auth");
- expect(url.searchParams.get("client_id")).toBe(CLIENT.clientId);
- expect(url.searchParams.get("redirect_uri")).toBe("https://site.example/login.html");
- expect(url.searchParams.get("response_type")).toBe("token");
- expect(url.searchParams.get("scope")).toBe("https://www.googleapis.com/auth/drive.appdata");
- expect(url.searchParams.get("state")).toBe("st4te");
- });
-
- it("derives the redirect URI from the page, stripping query and fragment", () => {
- expect(currentRedirectUri({ origin: "https://site.example", pathname: "/account.html" })).toBe(
- "https://site.example/account.html"
- );
- });
-
- it("mints distinct states", () => {
- expect(newGoogleOnrampState()).not.toBe(newGoogleOnrampState());
- });
-});
-
-describe("callback fragment and relay message", () => {
- it("parses a token callback and round-trips through the relay message", () => {
- const parsed = parseGoogleCallbackFragment("#state=st4te&access_token=ya29.token&token_type=Bearer");
- expect(parsed).toEqual({ state: "st4te", accessToken: "ya29.token" });
- expect(buildGoogleTokenMessage(parsed!)).toEqual({
- type: "bitlogin:onramp:google-token",
- state: "st4te",
- accessToken: "ya29.token"
- });
- });
-
- it("parses an error callback", () => {
- expect(parseGoogleCallbackFragment("#error=access_denied&state=st4te")).toEqual({
- state: "st4te",
- error: "access_denied"
- });
- });
-
- it("returns null for ordinary fragments and callbacks without state", () => {
- expect(parseGoogleCallbackFragment("")).toBeNull();
- expect(parseGoogleCallbackFragment("#section-2")).toBeNull();
- expect(parseGoogleCallbackFragment("#access_token=tok")).toBeNull();
- });
-
- it("validates the relay message against origin and state", () => {
- const good = {
- origin: "https://site.example",
- data: { type: "bitlogin:onramp:google-token", state: "st4te", accessToken: "tok" }
- };
- const expected = { origin: "https://site.example", state: "st4te" };
- expect(validateGoogleTokenMessage(good, expected)).toEqual({ accessToken: "tok" });
- expect(
- validateGoogleTokenMessage({ ...good, origin: "https://evil.example" }, expected)
- ).toBeNull();
- expect(
- validateGoogleTokenMessage(
- { origin: good.origin, data: { ...good.data, state: "other" } },
- expected
- )
- ).toBeNull();
- expect(
- validateGoogleTokenMessage(
- { origin: good.origin, data: { type: "bitlogin:onramp:google-token", state: "st4te", error: "access_denied" } },
- expected
- )
- ).toEqual({ error: "access_denied" });
- });
-});
-
-describe("Drive credential I/O", () => {
- const CRED = { loginName: "sunny-otter", password: "corr-ect-hor-se" };
-
- function jsonResponse(status: number, body?: unknown): Response {
- return {
- ok: status >= 200 && status < 300,
- status,
- json: () => Promise.resolve(body)
- } as unknown as Response;
- }
-
- it("finds and reads a returning user's credential", async () => {
- const fetchImpl = vi
- .fn()
- .mockResolvedValueOnce(jsonResponse(200, { files: [{ id: "f1" }] }))
- .mockResolvedValueOnce(jsonResponse(200, { v: 1, ...CRED }));
- expect(await findDriveCredential("tok", fetchImpl as unknown as typeof fetch)).toEqual(CRED);
- const [listUrl, listInit] = fetchImpl.mock.calls[0]!;
- expect(String(listUrl)).toContain("https://www.googleapis.com/drive/v3/files?");
- expect(String(listUrl)).toContain("appDataFolder");
- expect((listInit as RequestInit).headers).toEqual({ authorization: "Bearer tok" });
- expect(String(fetchImpl.mock.calls[1]![0])).toContain("/files/f1?alt=media");
- });
-
- it("resolves null for a first-time user", async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse(200, { files: [] }));
- expect(await findDriveCredential("tok", fetchImpl as unknown as typeof fetch)).toBeNull();
- });
-
- it("rejects on auth failure and on malformed stored data", async () => {
- const unauthorized = vi.fn().mockResolvedValueOnce(jsonResponse(401));
- await expect(findDriveCredential("tok", unauthorized as unknown as typeof fetch)).rejects.toThrow(
- /did not accept/
- );
- const malformed = vi
- .fn()
- .mockResolvedValueOnce(jsonResponse(200, { files: [{ id: "f1" }] }))
- .mockResolvedValueOnce(jsonResponse(200, { v: 1, loginName: "x" }));
- await expect(findDriveCredential("tok", malformed as unknown as typeof fetch)).rejects.toThrow(
- /incomplete/
- );
- });
-
- it("stores a new credential as multipart app-data upload", async () => {
- const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(200, {}));
- await storeDriveCredential("tok", CRED, fetchImpl as unknown as typeof fetch);
- const [url, init] = fetchImpl.mock.calls[0]!;
- expect(String(url)).toBe("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart");
- const request = init as RequestInit;
- expect(request.method).toBe("POST");
- const body = String(request.body);
- expect(body).toContain('"appDataFolder"');
- expect(body).toContain('"bitlogin-credential-v1.json"');
- expect(body).toContain('"sunny-otter"');
- expect((request.headers as Record)["content-type"]).toMatch(
- /^multipart\/related; boundary=/u
- );
- });
-
- it("throws a phrase-preserving error when the upload fails", async () => {
- const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(500));
- await expect(
- storeDriveCredential("tok", CRED, fetchImpl as unknown as typeof fetch)
- ).rejects.toThrow(/recovery phrase/);
- });
-});
diff --git a/packages/widget/src/driveOnramp.ts b/packages/widget/src/driveOnramp.ts
deleted file mode 100644
index 0f90a28..0000000
--- a/packages/widget/src/driveOnramp.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-/** Serverless Google on-ramp (centralized-onramps.md §CO3.3) — pure logic.
- *
- * "Continue with Google" with NO third party and NO server anywhere: the
- * credential of a standard BitLogin capsule account lives in the USER'S OWN
- * Google Drive app-data folder, 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.
- *
- * Flow: a popup navigates to Google's OAuth endpoint (plain implicit grant,
- * `response_type=token` — no external script, so a strict `script-src 'self'`
- * CSP survives). Google redirects back to the HOST'S OWN page; the widget
- * instance in the popup finds the token fragment and posts it to the opener
- * (same-origin, exact state nonce). The opener then talks to the Drive API
- * directly: read the credential file and run the ordinary password login, or
- * — for a first visit — register client-side and store the credential.
- *
- * Custody statement (§CO7): compromise of the Google account is compromise of
- * the password (§14 of the spec analyzes exactly that); nobody else holds
- * anything. Hosts must allow https://www.googleapis.com in connect-src and
- * register the widget page's exact URL as an OAuth redirect URI.
- */
-import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
-
-export const GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
-export const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
-export const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
-const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.appdata";
-const CREDENTIAL_FILE_NAME = "bitlogin-credential-v1.json";
-
-export interface GoogleOnrampConfig {
- clientId: string;
-}
-
-export function parseGoogleOnrampConfig(clientId: string | null): GoogleOnrampConfig | undefined {
- const trimmed = clientId?.trim();
- if (!trimmed) return undefined;
- return { clientId: trimmed };
-}
-
-export function newGoogleOnrampState(): string {
- return bytesToHex(randomBytes(16));
-}
-
-/**
- * The popup's destination. redirectUri must be the exact page URL the host
- * registered with Google — by design the page embedding the widget itself,
- * so no extra callback artifact needs deploying.
- */
-export function buildGoogleAuthUrl(config: GoogleOnrampConfig, redirectUri: string, state: string): string {
- const url = new URL(GOOGLE_AUTH_ENDPOINT);
- url.searchParams.set("client_id", config.clientId);
- url.searchParams.set("redirect_uri", redirectUri);
- url.searchParams.set("response_type", "token");
- url.searchParams.set("scope", DRIVE_SCOPE);
- url.searchParams.set("state", state);
- return url.toString();
-}
-
-/** The current page's URL with query and fragment stripped — what the host
- * registers as the redirect URI, and what the popup is sent back to. */
-export function currentRedirectUri(location: { origin: string; pathname: string }): string {
- return `${location.origin}${location.pathname}`;
-}
-
-/**
- * Parses Google's redirect fragment in the popup. Returns null when the
- * fragment is not an OAuth callback at all (the common case: every ordinary
- * page load), a token on success, or the error Google reported.
- */
-export function parseGoogleCallbackFragment(
- hash: string
-): { state: string; accessToken: string } | { state: string; error: string } | null {
- const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
- if (!fragment) return null;
- const params = new URLSearchParams(fragment);
- const state = params.get("state");
- if (!state) return null;
- const error = params.get("error");
- if (error) return { state, error };
- const accessToken = params.get("access_token");
- if (!accessToken) return null;
- return { state, accessToken };
-}
-
-/** The popup -> opener relay frame. Same-origin by construction (the popup IS
- * a page of this site); still validated against the exact state nonce. */
-export interface GoogleTokenMessage {
- type: "bitlogin:onramp:google-token";
- state: string;
- accessToken?: string;
- error?: string;
-}
-
-export function buildGoogleTokenMessage(
- parsed: { state: string; accessToken: string } | { state: string; error: string }
-): GoogleTokenMessage {
- return "accessToken" in parsed
- ? { type: "bitlogin:onramp:google-token", state: parsed.state, accessToken: parsed.accessToken }
- : { type: "bitlogin:onramp:google-token", state: parsed.state, error: parsed.error };
-}
-
-export function validateGoogleTokenMessage(
- event: { origin: string; data: unknown },
- expected: { origin: string; state: string }
-): { accessToken: string } | { error: string } | null {
- if (event.origin !== expected.origin) return null;
- const data = event.data as Partial | null | undefined;
- if (!data || typeof data !== "object") return null;
- if (data.type !== "bitlogin:onramp:google-token") return null;
- if (data.state !== expected.state) return null;
- if (typeof data.accessToken === "string" && data.accessToken) return { accessToken: data.accessToken };
- if (typeof data.error === "string" && data.error) return { error: data.error };
- return null;
-}
-
-export interface DriveCredential {
- loginName: string;
- password: string;
-}
-
-function authHeaders(accessToken: string): Record {
- return { authorization: `Bearer ${accessToken}` };
-}
-
-async function requireOk(response: Response, doing: string): Promise {
- if (response.status === 401 || response.status === 403) {
- throw new Error(`Google did not accept the sign-in while ${doing} (HTTP ${response.status}). Try "Continue with Google" again.`);
- }
- if (!response.ok) {
- throw new Error(`Google Drive failed while ${doing} (HTTP ${response.status}). Try again in a moment.`);
- }
- return response;
-}
-
-/**
- * Looks up the stored credential in the user's app-data folder. Resolves the
- * credential for a returning user, or null for a first-time user.
- */
-export async function findDriveCredential(
- accessToken: string,
- fetchImpl: typeof fetch = fetch
-): Promise {
- const query = new URLSearchParams({
- spaces: "appDataFolder",
- q: `name = '${CREDENTIAL_FILE_NAME}'`,
- fields: "files(id)"
- });
- const listResponse = await requireOk(
- await fetchImpl(`${GOOGLE_DRIVE_API}/files?${query.toString()}`, { headers: authHeaders(accessToken) }),
- "looking up your account"
- );
- const listing = (await listResponse.json()) as { files?: Array<{ id?: string }> };
- const fileId = listing.files?.[0]?.id;
- if (!fileId) return null;
-
- const readResponse = await requireOk(
- await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}?alt=media`, {
- headers: authHeaders(accessToken)
- }),
- "reading your sign-in"
- );
- let parsed: { v?: unknown; loginName?: unknown; password?: unknown };
- try {
- parsed = (await readResponse.json()) as typeof parsed;
- } catch {
- throw new Error("The sign-in data stored in your Google account is unreadable.");
- }
- if (typeof parsed.loginName !== "string" || !parsed.loginName || typeof parsed.password !== "string" || !parsed.password) {
- throw new Error("The sign-in data stored in your Google account is incomplete.");
- }
- return { loginName: parsed.loginName, password: parsed.password };
-}
-
-/**
- * Stores a freshly registered credential as the user's app-data file. Called
- * once per registration; a failure means nobody is holding the credential, so
- * the caller must force the phrase ceremony (the no-orphan rule, §CO5).
- */
-export async function storeDriveCredential(
- accessToken: string,
- credential: DriveCredential,
- fetchImpl: typeof fetch = fetch
-): Promise {
- const boundary = `bitlogin-${bytesToHex(randomBytes(8))}`;
- const metadata = JSON.stringify({ name: CREDENTIAL_FILE_NAME, parents: ["appDataFolder"] });
- const content = JSON.stringify({ v: 1, loginName: credential.loginName, password: credential.password });
- const body =
- `--${boundary}\r\ncontent-type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
- `--${boundary}\r\ncontent-type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
- const response = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files?uploadType=multipart`, {
- method: "POST",
- headers: {
- ...authHeaders(accessToken),
- "content-type": `multipart/related; boundary=${boundary}`
- },
- body
- });
- if (!response.ok) {
- throw new Error(
- `Google Drive could not save your sign-in (HTTP ${response.status}). Your account was created — save the recovery phrase shown next, and you can retry "Continue with Google" later.`
- );
- }
-}
diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts
index 3e202ee..c9820c7 100644
--- a/packages/widget/src/element.ts
+++ b/packages/widget/src/element.ts
@@ -21,32 +21,20 @@ import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.j
import { renderQrSvg } from "./qr.js";
import { setActiveSession, clearActiveSession } from "./globalSession.js";
import {
- buildOnrampAuthUrl,
- newOnrampState,
- onrampProviderLabel,
- storeOnrampCredential,
- validateOnrampMessage,
- type OnrampConfig
-} from "./onramp.js";
-import {
- buildGoogleAuthUrl,
- buildGoogleTokenMessage,
- currentRedirectUri,
- findDriveCredential,
- newGoogleOnrampState,
- parseGoogleCallbackFragment,
- storeDriveCredential,
- validateGoogleTokenMessage,
- type GoogleOnrampConfig
-} from "./driveOnramp.js";
-import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
+ buildPasskeyCreateOptions,
+ buildPasskeyGetOptions,
+ deriveCredentialFromPrf,
+ extractPrfOutput,
+ passkeySupported
+} from "./passkey.js";
type Screen =
| "welcome"
| "extension-confirm"
| "bunker-connect"
| "bunker-confirm"
- | "onramp-create"
+ | "passkey"
+ | "passkey-create"
| "import-key"
| "create-name"
| "create-credential"
@@ -76,7 +64,7 @@ const OPTION_ICONS = {
remote: ``,
importKey: ``,
recover: ``,
- onramp: ``
+ passkey: ``
} as const;
const CHEVRON_DOWN = ``;
@@ -135,88 +123,17 @@ export class BitLoginAuthElement extends HTMLElement {
private bunkerAuthUrl = "";
private pendingBunker: { userPubkey: string; npub: string } | null = null;
- // ---- Centralized on-ramp (centralized-onramps.md §CO5, Architecture B) ----
- // Configured by the host or absent entirely. `attempt` spans one popup round
- // trip (state nonce + popup handle); `registration` spans a new user's
- // choice between a fresh identity and importing one (§SF10); `session` marks
- // the standing for dashboard labeling (the session itself is an ordinary
- // BitLogin account session). `securePhrasePending` is Tier B2: the phrase is
- // in memory this session, the ceremony deferred behind a dashboard card.
- private onramp: OnrampConfig | undefined;
- private onrampAttempt: { state: string; provider: string; popup: Window | null } | null = null;
- private onrampRegistration:
- | { kind: "service"; registrationToken: string; provider: string; suggestedLoginName?: string }
- | { kind: "google"; accessToken: string; provider: string }
- | null = null;
- private onrampSession = false;
- /** Which rail manages the credential: an external service or the user's own
- * Google Drive (§CO3.3). Drives the dashboard's custody copy. */
- private onrampKind: "service" | "google" | null = null;
+ // ---- Passkey rail (docs/passkey-login.md) ----
+ // Zero registration, zero servers: a PRF-capable passkey deterministically
+ // derives this site's login name + password (a frozen contract, see
+ // 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.
+ private pendingPasskey: { loginName: string; password: string } | null = null;
+ private passkeySession = false;
private securePhrasePending = false;
-
- // Serverless Google on-ramp (§CO3.3): no third party -- the popup is this
- // site's own page, which relays Google's token fragment back here.
- private onrampGoogle: GoogleOnrampConfig | undefined;
- private googleAttempt: { state: string; popup: Window | null } | null = null;
- private readonly onWindowMessage = (event: MessageEvent): void => {
- if (this.handleGoogleTokenMessage(event)) return;
- const attempt = this.onrampAttempt;
- if (!attempt || !this.onramp) return;
- const result = validateOnrampMessage(
- { origin: event.origin, data: event.data },
- { origin: this.onramp.origin, state: attempt.state }
- );
- if (!result) return;
- this.onrampAttempt = null;
- try {
- attempt.popup?.close();
- } catch {
- // Cross-origin popup handles can refuse; it closes itself regardless.
- }
- if (result.kind === "unlock") {
- // Returning user: the service is simply the password manager -- this is
- // the ordinary password login from here on.
- this.onrampSession = true;
- this.onrampKind = "service";
- void this.attemptLogin(result.loginName, result.password);
- return;
- }
- this.onrampRegistration = {
- kind: "service",
- registrationToken: result.registrationToken,
- provider: attempt.provider,
- suggestedLoginName: result.suggestedLoginName
- };
- this.goto("onramp-create");
- };
-
- /** Serverless Google leg (§CO3.3): the popup (this site's own page) relayed
- * Google's token. Same-origin plus the exact state nonce, or ignored. */
- private handleGoogleTokenMessage(event: MessageEvent): boolean {
- const attempt = this.googleAttempt;
- if (!attempt) return false;
- const result = validateGoogleTokenMessage(
- { origin: event.origin, data: event.data },
- { origin: window.location.origin, state: attempt.state }
- );
- if (!result) return false;
- this.googleAttempt = null;
- try {
- attempt.popup?.close();
- } catch {
- // The popup closes itself regardless.
- }
- if ("error" in result) {
- this.errorMessage =
- result.error === "access_denied"
- ? "Google sign-in was cancelled."
- : `Google sign-in failed (${result.error}).`;
- this.render();
- return true;
- }
- void this.runDriveUnlock(result.accessToken);
- return true;
- }
private sessionWarnings: string[] = [];
private lastSignedEventJson = "";
private exportedNsec = "";
@@ -295,23 +212,6 @@ export class BitLoginAuthElement extends HTMLElement {
const config = readConfigFromElement(this);
this.vaultRelayUrls = config.vaultRelayUrls ?? [];
this.discoveryRelayUrls = config.discoveryRelayUrls ?? [];
- this.onramp = config.onramp;
- this.onrampGoogle = config.onrampGoogle;
- window.addEventListener("message", this.onWindowMessage);
-
- // Serverless-Google callback relay (§CO3.3): when this page IS the OAuth
- // popup, Google put a token in the fragment. Relay it to the opener
- // (same-origin) and close -- the widget in the opener does everything else.
- if (this.onrampGoogle && window.opener) {
- const parsed = parseGoogleCallbackFragment(window.location.hash);
- if (parsed) {
- (window.opener as Window).postMessage(buildGoogleTokenMessage(parsed), window.location.origin);
- history.replaceState(null, "", window.location.pathname + window.location.search);
- window.close();
- // If the browser refuses to close the popup, the widget simply renders
- // its welcome screen here; the opener has what it needs either way.
- }
- }
void this.worker
.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls })
.then(() => this.tryRestoreSession());
@@ -338,7 +238,6 @@ export class BitLoginAuthElement extends HTMLElement {
disconnectedCallback(): void {
if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer);
- window.removeEventListener("message", this.onWindowMessage);
clearActiveSession(this);
this.releaseSigner();
this.worker.terminate();
@@ -394,8 +293,7 @@ export class BitLoginAuthElement extends HTMLElement {
if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect();
this.altSigner = null;
this.session = null;
- this.onrampSession = false;
- this.onrampKind = null;
+ this.passkeySession = false;
this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
@@ -405,8 +303,7 @@ export class BitLoginAuthElement extends HTMLElement {
}
await this.worker.logout();
this.session = null;
- this.onrampSession = false;
- this.onrampKind = null;
+ this.passkeySession = false;
this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
@@ -899,9 +796,7 @@ export class BitLoginAuthElement extends HTMLElement {
case "goto-welcome":
this.pendingRollback = null;
this.pendingExtensionSigner = null;
- this.onrampAttempt = null;
- this.onrampRegistration = null;
- this.googleAttempt = null;
+ this.pendingPasskey = null;
// Backing out of an unfinished bunker connect abandons the worker-side
// attempt -- but never a live session (altSigner) that's merely
// navigating around.
@@ -917,17 +812,20 @@ export class BitLoginAuthElement extends HTMLElement {
this.moreOptionsOpen = !this.moreOptionsOpen;
this.render();
return;
- case "onramp-signin":
- this.startOnramp(target.dataset.provider ?? "");
+ case "goto-passkey":
+ this.pendingPasskey = null;
+ this.errorMessage = undefined;
+ this.goto("passkey");
return;
- case "google-signin":
- this.startGoogleOnramp();
- return;
- case "onramp-new":
+ case "passkey-auth":
+ return this.handlePasskeyAuth();
+ case "passkey-register":
+ return this.handlePasskeyRegisterCeremony();
+ case "passkey-new":
this.importKey = "";
this.importPreviewNpub = "";
- return void this.runOnrampRegistration();
- case "onramp-import":
+ return void this.runPasskeyRegistration();
+ case "passkey-import":
this.importKey = "";
this.importPreviewNpub = "";
this.goto("import-key");
@@ -1123,95 +1021,123 @@ export class BitLoginAuthElement extends HTMLElement {
this.render();
return;
}
- if (this.onrampRegistration) {
- // On-ramp signup chose "use my existing key": same §SF10 wrap, with the
- // service holding the password instead of the user (§CO5).
- void this.runOnrampRegistration();
+ if (this.pendingPasskey) {
+ // Passkey setup chose "use my existing key": same §SF10 wrap, with the
+ // passkey-derived credential in place of a typed password.
+ void this.runPasskeyRegistration();
return;
}
this.loginName = "";
this.goto("create-name");
}
- /** Opens the serverless Google popup for one attempt (§CO3.3). */
- private startGoogleOnramp(): void {
- if (!this.onrampGoogle) return;
- const state = newGoogleOnrampState();
- const url = buildGoogleAuthUrl(this.onrampGoogle, currentRedirectUri(window.location), state);
- const popup = window.open(url, "bitlogin-onramp-google", "popup,width=480,height=640");
- if (!popup) {
- this.errorMessage = "Your browser blocked the sign-in window. Allow pop-ups for this site and try again.";
+ private static readonly PRF_UNSUPPORTED_MESSAGE =
+ "This passkey (or this browser) doesn't support the extension BitLogin needs. Try a different browser or password manager, or use one of the other sign-in methods.";
+
+ /** Runs a WebAuthn ceremony and returns the derived credential, or null
+ * after rendering the appropriate message (cancelled / PRF-unsupported). */
+ private async runPasskeyCeremony(kind: "get" | "create"): Promise<{ loginName: string; password: string } | null> {
+ try {
+ const credential = (kind === "get"
+ ? await navigator.credentials.get(buildPasskeyGetOptions())
+ : await navigator.credentials.create(buildPasskeyCreateOptions("BitLogin"))) as PublicKeyCredential | null;
+ if (!credential) throw new Error("The passkey ceremony returned nothing.");
+ let prf = extractPrfOutput(credential);
+ if (!prf && kind === "create") {
+ // Some authenticators only reveal PRF results during assertion; ask
+ // for one immediately against the passkey that was just created.
+ const assertion = (await navigator.credentials.get(buildPasskeyGetOptions())) as PublicKeyCredential | null;
+ prf = assertion ? extractPrfOutput(assertion) : null;
+ }
+ if (!prf) {
+ this.busy = false;
+ this.errorMessage = BitLoginAuthElement.PRF_UNSUPPORTED_MESSAGE;
+ this.render();
+ return null;
+ }
+ return deriveCredentialFromPrf(prf);
+ } catch (err) {
+ this.busy = false;
+ this.errorMessage =
+ err instanceof Error && err.name === "NotAllowedError"
+ ? "The passkey prompt was cancelled or timed out."
+ : err instanceof Error
+ ? err.message
+ : String(err);
this.render();
- return;
+ return null;
}
- this.googleAttempt = { state, popup };
- this.errorMessage = undefined;
- this.render();
}
- /** Google answered with a Drive token: read the credential from the user's
- * own app data and sign in, or start first-time setup (§CO3.3). */
- private async runDriveUnlock(accessToken: string): Promise {
+ /** "Use my passkey": sign in, or route a passkey with no account here into
+ * first-time setup with the credential it derived. */
+ private async handlePasskeyAuth(): Promise {
+ if (this.busy) return;
this.setBusy(true);
+ const parts = await this.runPasskeyCeremony("get");
+ if (!parts) return;
try {
- const credential = await findDriveCredential(accessToken);
+ const result = await this.worker.login({ loginName: parts.loginName, password: parts.password });
+ this.loginName = parts.loginName;
+ this.session = {
+ publicKey: result.everydayPublicKey,
+ npub: encodeNpub(result.everydayPublicKey),
+ accountId: result.accountId,
+ method: "bitlogin"
+ };
+ this.sessionWarnings = [result.rollbackWarning, result.relayDisagreementWarning].filter((w): w is string => !!w);
this.busy = false;
- if (credential) {
- this.onrampSession = true;
- this.onrampKind = "google";
- await this.attemptLogin(credential.loginName, credential.password);
+ this.passkeySession = true;
+ this.noteSignerClaim(this.claimSigner());
+ this.dispatchLogin();
+ this.flashSuccess("dashboard", "Signed in");
+ } catch (err) {
+ if (err instanceof Error && err.name === "AccountNotFoundError") {
+ // A perfectly good passkey with no account on this site yet.
+ this.pendingPasskey = parts;
+ this.busy = false;
+ this.goto("passkey-create");
+ return;
+ }
+ if (err instanceof Error && err.name === "RollbackDetectedError") {
+ this.pendingRollback = { kind: "login", loginName: parts.loginName, password: parts.password };
+ this.rollbackMessage = err.message;
+ this.busy = false;
+ this.goto("rollback-confirm");
return;
}
- this.onrampRegistration = { kind: "google", accessToken, provider: "google" };
- this.goto("onramp-create");
- } catch (err) {
this.fail(err);
}
}
- /** Opens the on-ramp's OAuth popup for one attempt (§CO5). */
- private startOnramp(provider: string): void {
- if (!this.onramp) return;
- const state = newOnrampState();
- const url = buildOnrampAuthUrl(this.onramp, provider, state, window.location.origin);
- const popup = window.open(url, "bitlogin-onramp", "popup,width=480,height=640");
- if (!popup) {
- this.errorMessage = "Your browser blocked the sign-in window. Allow pop-ups for this site and try again.";
- this.render();
- return;
- }
- this.onrampAttempt = { state, provider, popup };
- this.errorMessage = undefined;
- this.render();
+ /** "Create a new passkey": mint it, then set up (or rejoin) its account. */
+ private async handlePasskeyRegisterCeremony(): Promise {
+ if (this.busy) return;
+ this.setBusy(true);
+ const parts = await this.runPasskeyCeremony("create");
+ if (!parts) return;
+ this.pendingPasskey = parts;
+ this.busy = false;
+ this.goto("passkey-create");
}
- /**
- * New-user registration through the on-ramp (§CO5): standard client-side
- * registration (§15) -- optionally wrapping an imported key (§SF10) -- with
- * the generated password handed to the service for safekeeping afterward.
- * Tier B2 phrase handling: the ceremony is deferred behind the dashboard's
- * "Secure your account" card, EXCEPT when the store call fails -- an account
- * whose credential nobody is holding forces the ceremony immediately.
- */
- private async runOnrampRegistration(): Promise {
- const registration = this.onrampRegistration;
- if (!registration) return;
- if (registration.kind === "service" && !this.onramp) return;
+ /** Registers the account a passkey-derived credential unlocks (fresh
+ * identity, or wrapping an imported key, §SF10). Tier B2 phrase handling:
+ * ceremony deferred behind the dashboard card. */
+ private async runPasskeyRegistration(): Promise {
+ const pending = this.pendingPasskey;
+ if (!pending) return;
this.setBusy(true);
try {
- const suggested =
- registration.kind === "service" ? (registration.suggestedLoginName?.trim().toLowerCase() ?? "") : "";
- const loginName = isValidLoginName(suggested) ? suggested : `nostr-${bytesToHex(randomBytes(4))}`;
- const password = generatePassphrase().secret;
const result = await this.worker.register({
- loginName,
- password,
+ loginName: pending.loginName,
+ password: pending.password,
importKey: this.importKey || undefined
});
this.importKey = "";
this.importPreviewNpub = "";
- this.onrampRegistration = null;
- this.loginName = loginName;
+ this.pendingPasskey = null;
+ this.loginName = pending.loginName;
this.recoveryPhrase = result.recoveryPhrase;
const words = this.recoveryPhrase.split(" ");
const indices = pickRandomIndices(words.length, 3);
@@ -1228,41 +1154,30 @@ export class BitLoginAuthElement extends HTMLElement {
generalRelays: this.vaultRelayUrls,
dmRelays: this.vaultRelayUrls
});
- try {
- if (registration.kind === "google") {
- await storeDriveCredential(registration.accessToken, { loginName, password });
- } else {
- await storeOnrampCredential(this.onramp!, {
- registrationToken: registration.registrationToken,
- loginName,
- password
- });
- }
- } catch (storeErr) {
- // The account is real and live on relays, but nobody is holding its
- // credential -- the phrase ceremony happens NOW (the no-orphan rule).
- this.busy = false;
- this.onrampSession = false;
- this.onrampKind = null;
- this.securePhrasePending = false;
- this.errorMessage = storeErr instanceof Error ? storeErr.message : String(storeErr);
- this.noteSignerClaim(this.claimSigner());
- this.dispatchLogin();
- this.goto("confirm-phrase");
- return;
- }
this.busy = false;
- this.onrampSession = true;
- this.onrampKind = registration.kind;
+ this.passkeySession = true;
this.securePhrasePending = true;
this.noteSignerClaim(this.claimSigner());
this.dispatchLogin();
this.flashSuccess("dashboard", "Account created");
} catch (err) {
+ if (err instanceof Error && err.name === "AccountAlreadyExistsError") {
+ // This passkey already has an account here (an earlier setup finished
+ // after all) -- just sign in with the same derived credential.
+ this.pendingPasskey = null;
+ this.busy = false;
+ void this.handlePasskeySignInWith(pending);
+ return;
+ }
this.fail(err);
}
}
+ private async handlePasskeySignInWith(parts: { loginName: string; password: string }): Promise {
+ this.passkeySession = true;
+ await this.attemptLogin(parts.loginName, parts.password);
+ }
+
private handleCreateNameSubmit(): void {
const name = this.field("loginName").trim().toLowerCase();
if (!isValidLoginName(name)) {
@@ -1342,8 +1257,7 @@ export class BitLoginAuthElement extends HTMLElement {
private async handleLoginSubmit(): Promise {
const loginName = this.field("loginName").trim().toLowerCase();
const password = this.field("password");
- this.onrampSession = false; // typed credentials: the user is their own password manager here
- this.onrampKind = null;
+ this.passkeySession = false; // typed credentials: the user is their own password manager here
await this.attemptLogin(loginName, password);
}
@@ -1735,43 +1649,23 @@ export class BitLoginAuthElement extends HTMLElement {
this.busy
)
: "";
- // Centralized rows exist only when the host configured an on-ramp;
- // the sub-label IS the custody statement (§CO7): who manages access.
- const onrampRows = (this.onramp?.providers ?? [])
- .map((provider) => {
- const label = onrampProviderLabel(provider);
- return `
- `;
- })
- .join("");
- // Serverless Google (§CO3.3): no third party at all -- the sub-label
- // says where the credential actually lives.
- const googleRow = this.onrampGoogle
- ? `
- `
- : "";
- const waitingProvider = this.googleAttempt
- ? "Google"
- : this.onrampAttempt
- ? onrampProviderLabel(this.onrampAttempt.provider)
- : null;
- const onrampWaiting = waitingProvider
- ? `
Finish signing in with ${escapeHtml(waitingProvider)} in the window that opened, then return here.
`
+ // Passkey rail (docs/passkey-login.md): zero site setup, zero servers
+ // -- the sub-label states where the custody actually lives.
+ const passkeyRow = passkeySupported()
+ ? optionRow(
+ "goto-passkey",
+ OPTION_ICONS.passkey,
+ "Continue with a passkey",
+ "Kept in your phone or browser's password manager"
+ )
: "";
const optionsMenu = this.moreOptionsOpen
? `
Use an account you already have
+ ${passkeyRow}
${extensionRow}
${optionRow("goto-bunker", OPTION_ICONS.remote, "Remote signer", "Scan a code with Amber or another signer app")}
${optionRow("goto-import", OPTION_ICONS.importKey, "Import a Nostr key", "Wrap an existing identity in a login name and password")}
- ${googleRow}
- ${onrampRows}
${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
`
: "";
@@ -1784,30 +1678,33 @@ export class BitLoginAuthElement extends HTMLElement {
- ${onrampWaiting}
${optionsMenu}
`;
}
- case "onramp-create": {
- const registration = this.onrampRegistration;
- const custodyLine =
- registration?.kind === "google"
- ? "Your sign-in will be kept in your own Google Drive — no one else holds it."
- : `${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${
- registration ? ` through ${escapeHtml(onrampProviderLabel(registration.provider))}` : ""
- }.`;
+ case "passkey":
+ return `
+ ${this.renderScreenHead("Use a passkey")}
+
A passkey kept in your phone, browser, or security key — usually synced by your Google or Apple account. This site needs no setup and runs no server; the passkey itself unlocks your Nostr account.
+ ${this.renderError()}
+
+
+
First time here? "Use my passkey" also works — if this passkey has no account on this site yet, you'll be taken to set one up.