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 -

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.

+ `; + + case "passkey-create": return ` ${this.renderScreenHead("Almost there")} -

${custodyLine} Your account itself lives on the open Nostr network — choose how to set up its identity:

+

Your passkey is ready — it will unlock this account from now on, with nothing stored anywhere else. Choose how to set up the account's identity:

${this.renderError()} - - +

Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.

`; - } case "extension-confirm": return ` @@ -2040,23 +1937,19 @@ export class BitLoginAuthElement extends HTMLElement { `; // Tier B2 (§CO4): the phrase exists only in this session's memory // until the user claims it -- the one nudge that stays until acted on. - const custodian = - this.onrampKind === "google" ? "your Google account" : (this.onramp?.name ?? "the sign-in service"); const securePhraseCard = this.securePhrasePending - ? `
Secure your account: your recovery phrase is available only during this session. Save it now and your account stays yours even without ${escapeHtml(custodian)}. + ? `
Secure your account: your recovery phrase is available only during this session. Save it now and your account stays yours even if you lose your passkey.
` : ""; - const onrampStanding = this.onrampSession - ? this.onrampKind === "google" - ? `

Signed in with Google — your sign-in key is stored in your own Google Drive; your identity lives on the open Nostr network.

` - : `

Signed in via ${escapeHtml(this.onramp?.name ?? "a sign-in service")} — they manage your sign-in; your identity lives on the open Nostr network.

` + const passkeyStanding = this.passkeySession + ? `

Signed in with a passkey — it unlocks this account from your device's password manager; your identity lives on the open Nostr network.

` : ""; return `

Signed in

${securePhraseCard} ${this.renderWarnings()}

${escapeHtml(this.session?.npub ?? "")}

- ${onrampStanding} + ${passkeyStanding} ${this.renderError()} ${ @@ -2187,10 +2080,8 @@ export class BitLoginAuthElement extends HTMLElement { return `

Rotate password

${ - this.onrampSession - ? `
This takes over from ${ - this.onrampKind === "google" ? "Google sign-in" : "your managed sign-in" - }: the stored copy of your old password stops working, and from then on you sign in with the new password below — you hold it, nobody else. This is the graduation step.
` + this.passkeySession + ? `
This takes over from passkey sign-in: your passkey stops unlocking this account, and from then on you sign in with the new password below — you hold it, nobody else. This is the graduation step.
` : "" }

Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded, and a relay that hasn't processed the deletion may keep serving the old password's capsule until a device that has already seen the new generation refuses it.

diff --git a/packages/widget/src/onramp.test.ts b/packages/widget/src/onramp.test.ts deleted file mode 100644 index ace09b0..0000000 --- a/packages/widget/src/onramp.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - buildOnrampAuthUrl, - newOnrampState, - onrampProviderLabel, - parseOnrampConfig, - storeOnrampCredential, - validateOnrampMessage, - type OnrampConfig -} from "./onramp.js"; - -const CONFIG: OnrampConfig = { - url: "https://onramp.example/bitlogin", - origin: "https://onramp.example", - name: "AcmeSigner", - providers: ["google", "github"] -}; - -describe("parseOnrampConfig", () => { - it("returns undefined when unconfigured, without warning", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(parseOnrampConfig({ url: null, name: null, providers: null })).toBeUndefined(); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); - - it("parses a full configuration and derives the origin", () => { - const config = parseOnrampConfig({ - url: "https://onramp.example/bitlogin", - name: " AcmeSigner ", - providers: "Google, github ," - })!; - expect(config.origin).toBe("https://onramp.example"); - expect(config.name).toBe("AcmeSigner"); - expect(config.providers).toEqual(["google", "github"]); - }); - - it("rejects non-https URLs (except loopback), empty providers, and a missing name -- loudly", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect( - parseOnrampConfig({ url: "http://onramp.example/x", name: "A", providers: "google" }) - ).toBeUndefined(); - expect( - parseOnrampConfig({ url: "https://onramp.example/x", name: "A", providers: " , " }) - ).toBeUndefined(); - expect( - parseOnrampConfig({ url: "https://onramp.example/x", name: " ", providers: "google" }) - ).toBeUndefined(); - expect(parseOnrampConfig({ url: "not a url", name: "A", providers: "google" })).toBeUndefined(); - expect(warn).toHaveBeenCalledTimes(4); - expect( - parseOnrampConfig({ url: "http://localhost:8787/x", name: "Dev", providers: "google" }) - ).toBeDefined(); - warn.mockRestore(); - }); -}); - -describe("auth URL and state", () => { - it("appends provider, state, and page origin to the configured URL", () => { - const url = new URL(buildOnrampAuthUrl(CONFIG, "google", "abc123", "https://site.example")); - expect(url.origin).toBe("https://onramp.example"); - expect(url.pathname).toBe("/bitlogin"); - expect(url.searchParams.get("provider")).toBe("google"); - expect(url.searchParams.get("state")).toBe("abc123"); - expect(url.searchParams.get("origin")).toBe("https://site.example"); - }); - - it("mints distinct high-entropy states", () => { - const a = newOnrampState(); - const b = newOnrampState(); - expect(a).toMatch(/^[0-9a-f]{32}$/u); - expect(a).not.toBe(b); - }); - - it("labels known providers and capitalizes unknown ones", () => { - expect(onrampProviderLabel("google")).toBe("Google"); - expect(onrampProviderLabel("github")).toBe("GitHub"); - expect(onrampProviderLabel("keycloak")).toBe("Keycloak"); - }); -}); - -describe("validateOnrampMessage", () => { - const expected = { origin: CONFIG.origin, state: "state1" }; - const unlockMsg = { - type: "bitlogin:onramp:unlock", - state: "state1", - unlock: { loginName: "user1", password: "pw-pw-pw-pw" } - }; - - it("accepts a well-formed unlock from the exact origin and state", () => { - expect(validateOnrampMessage({ origin: CONFIG.origin, data: unlockMsg }, expected)).toEqual({ - kind: "unlock", - loginName: "user1", - password: "pw-pw-pw-pw" - }); - }); - - it("accepts a well-formed register branch", () => { - const result = validateOnrampMessage( - { - origin: CONFIG.origin, - data: { - type: "bitlogin:onramp:unlock", - state: "state1", - register: { registrationToken: "tok", suggestedLoginName: "sunny-otter" } - } - }, - expected - ); - expect(result).toEqual({ kind: "register", registrationToken: "tok", suggestedLoginName: "sunny-otter" }); - }); - - it("rejects wrong origin, wrong state, wrong type, both branches, and malformed shapes", () => { - expect( - validateOnrampMessage({ origin: "https://evil.example", data: unlockMsg }, expected) - ).toBeNull(); - expect( - validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, state: "other" } }, expected) - ).toBeNull(); - expect( - validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, type: "hello" } }, expected) - ).toBeNull(); - expect( - validateOnrampMessage( - { - origin: CONFIG.origin, - data: { ...unlockMsg, register: { registrationToken: "tok" } } - }, - expected - ) - ).toBeNull(); - expect( - validateOnrampMessage( - { - origin: CONFIG.origin, - data: { type: "bitlogin:onramp:unlock", state: "state1", unlock: { loginName: "u" } } - }, - expected - ) - ).toBeNull(); - expect(validateOnrampMessage({ origin: CONFIG.origin, data: null }, expected)).toBeNull(); - expect(validateOnrampMessage({ origin: CONFIG.origin, data: "string" }, expected)).toBeNull(); - }); -}); - -describe("storeOnrampCredential", () => { - const payload = { registrationToken: "tok", loginName: "user1", password: "pw" }; - - it("POSTs the credential to the configured URL only, and resolves on ok", async () => { - const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200 }); - await storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch); - expect(fetchImpl).toHaveBeenCalledTimes(1); - const [url, init] = fetchImpl.mock.calls[0]!; - expect(url).toBe(CONFIG.url); - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - type: "bitlogin:onramp:store", - ...payload - }); - }); - - it("throws a phrase-preserving error when the service refuses", async () => { - const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 }); - await expect( - storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch) - ).rejects.toThrow(/recovery phrase/); - }); -}); diff --git a/packages/widget/src/onramp.ts b/packages/widget/src/onramp.ts deleted file mode 100644 index 0e40517..0000000 --- a/packages/widget/src/onramp.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** Centralized on-ramp handshake (centralized-onramps.md §CO5) — pure logic. - * - * The widget's side of the "Sign in with Google/GitHub/…" rail. A host opts in - * via element attributes; nothing here activates otherwise. The flow: - * - * 1. The option row opens `authUrl(provider, state)` in a popup. The service - * runs its OAuth dance there. - * 2. The popup posts ONE message back to the opener: - * { type: "bitlogin:onramp:unlock", state, - * unlock: { loginName, password } } // returning user - * or - * { type: "bitlogin:onramp:unlock", state, - * register: { registrationToken, suggestedLoginName? } } // new user - * 3. Returning users go straight into the ordinary password login. New - * users register CLIENT-SIDE (standard §15 flow, optionally wrapping an - * imported key, §SF10), then the widget POSTs the credential to the - * service for safekeeping: { type: "bitlogin:onramp:store", - * registrationToken, loginName, password }. - * - * Trust boundaries enforced here: messages are accepted only from the - * configured service's exact origin with the exact per-attempt state nonce - * (CSPRNG); the store call goes only to the configured URL. Configuring an - * on-ramp is the HOST'S declaration of trust in that service -- the widget's - * job is to make sure nobody else can ride that trust. - */ -import { randomBytes, bytesToHex } from "@bitlogin/core/crypto"; - -export interface OnrampConfig { - /** The service's auth/store endpoint, as configured. */ - url: string; - /** Exact origin messages must come from (derived from url). */ - origin: string; - /** Human name shown in the custody label ("AcmeSigner manages your sign-in"). */ - name: string; - /** Provider ids to render as rows, e.g. ["google", "github"]. */ - providers: string[]; -} - -/** Providers we can label nicely; anything else renders capitalized as-is. */ -const KNOWN_PROVIDER_LABELS: Record = { - google: "Google", - github: "GitHub", - facebook: "Facebook", - apple: "Apple", - microsoft: "Microsoft" -}; - -export function onrampProviderLabel(provider: string): string { - return ( - KNOWN_PROVIDER_LABELS[provider.toLowerCase()] ?? - provider.charAt(0).toUpperCase() + provider.slice(1) - ); -} - -/** - * Parses the element attributes; returns undefined when no on-ramp is - * configured, and undefined WITH a console warning when one is configured - * wrong -- a broken opt-in must fail visibly to the developer, not silently - * ship a half-working row to users. - */ -export function parseOnrampConfig(attrs: { - url: string | null; - name: string | null; - providers: string | null; -}): OnrampConfig | undefined { - if (!attrs.url) return undefined; - let parsed: URL; - try { - parsed = new URL(attrs.url); - } catch { - console.warn("BitLogin: onramp-url is not a valid URL; ignoring the on-ramp configuration."); - return undefined; - } - const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; - if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) { - console.warn("BitLogin: onramp-url must be https (or loopback for development); ignoring the on-ramp configuration."); - return undefined; - } - const providers = (attrs.providers ?? "") - .split(",") - .map((p) => p.trim().toLowerCase()) - .filter(Boolean); - if (providers.length === 0) { - console.warn("BitLogin: onramp-providers is empty; ignoring the on-ramp configuration."); - return undefined; - } - if (!attrs.name?.trim()) { - console.warn("BitLogin: onramp-name is required (it is the custody label users see); ignoring the on-ramp configuration."); - return undefined; - } - return { url: parsed.toString(), origin: parsed.origin, name: attrs.name.trim(), providers }; -} - -/** Per-attempt CSPRNG nonce; the popup must echo it exactly. */ -export function newOnrampState(): string { - return bytesToHex(randomBytes(16)); -} - -export function buildOnrampAuthUrl(config: OnrampConfig, provider: string, state: string, pageOrigin: string): string { - const url = new URL(config.url); - url.searchParams.set("provider", provider); - url.searchParams.set("state", state); - url.searchParams.set("origin", pageOrigin); - return url.toString(); -} - -export type OnrampUnlockResult = - | { kind: "unlock"; loginName: string; password: string } - | { kind: "register"; registrationToken: string; suggestedLoginName?: string }; - -function nonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -/** - * Validates a message event against the configured service and the attempt's - * state nonce. Returns null for anything that doesn't match EXACTLY -- other - * frames post messages all the time, and none of them get to mint a session. - */ -export function validateOnrampMessage( - event: { origin: string; data: unknown }, - expected: { origin: string; state: string } -): OnrampUnlockResult | null { - if (event.origin !== expected.origin) return null; - const data = event.data as - | { type?: unknown; state?: unknown; unlock?: unknown; register?: unknown } - | null - | undefined; - if (!data || typeof data !== "object") return null; - if (data.type !== "bitlogin:onramp:unlock") return null; - if (data.state !== expected.state) return null; - - const unlock = data.unlock as { loginName?: unknown; password?: unknown } | undefined; - const register = data.register as - | { registrationToken?: unknown; suggestedLoginName?: unknown } - | undefined; - // Exactly one branch, fully well-formed. - if (unlock && !register) { - if (!nonEmptyString(unlock.loginName) || !nonEmptyString(unlock.password)) return null; - return { kind: "unlock", loginName: unlock.loginName, password: unlock.password }; - } - if (register && !unlock) { - if (!nonEmptyString(register.registrationToken)) return null; - return { - kind: "register", - registrationToken: register.registrationToken, - suggestedLoginName: nonEmptyString(register.suggestedLoginName) - ? register.suggestedLoginName - : undefined - }; - } - return null; -} - -/** - * Hands a freshly registered credential to the service for safekeeping - * (Architecture B: the service is the password manager). Only ever called - * with the host-configured URL. Throws on any non-OK response -- the caller - * must NOT treat the account as onramp-managed unless the service confirmed - * it stored the credential. - */ -export async function storeOnrampCredential( - config: OnrampConfig, - payload: { registrationToken: string; loginName: string; password: string }, - fetchImpl: typeof fetch = fetch -): Promise { - const response = await fetchImpl(config.url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "bitlogin:onramp:store", ...payload }) - }); - if (!response.ok) { - throw new Error( - `${config.name} could not save your sign-in (HTTP ${response.status}). Your account was created -- do not lose the recovery phrase, and try connecting again later.` - ); - } -} diff --git a/packages/widget/src/passkey.test.ts b/packages/widget/src/passkey.test.ts new file mode 100644 index 0000000..a234615 --- /dev/null +++ b/packages/widget/src/passkey.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + buildPasskeyCreateOptions, + buildPasskeyGetOptions, + deriveCredentialFromPrf, + extractPrfOutput, + PASSKEY_PRF_SALT, + passkeySupported +} from "./passkey.js"; + +describe("deriveCredentialFromPrf — COMPATIBILITY CONTRACT", () => { + // ── Known-answer pins. A failure here is NEVER "update the expected + // value": the same passkey must derive the same account forever, so a + // change orphans every passkey account in existence. Treat exactly like + // a knownAnswer.test.ts failure (see docs/TODO.md standing rule). + it("derives the frozen v1 vectors", () => { + const sevens = new Uint8Array(32).fill(7); + expect(deriveCredentialFromPrf(sevens)).toEqual({ + loginName: "pk-99b383c32c6b", + password: "f4rddm8bDeP8PJB4UldrqF-L13-M-VnEoZIO9P3pD_k" + }); + const counting = new Uint8Array(32); + for (let i = 0; i < 32; i++) counting[i] = i; + expect(deriveCredentialFromPrf(counting)).toEqual({ + loginName: "pk-bc67cb7f4896", + password: "HdoORO04b-emzXVVdbBlDh82XqMYi5468cU4zLy__q0" + }); + }); + + it("the PRF evaluation salt is the frozen v1 constant", () => { + expect(new TextDecoder().decode(PASSKEY_PRF_SALT)).toBe("bitlogin:passkey-credential:v1"); + }); + + it("is deterministic and input-sensitive", () => { + const a = new Uint8Array(32).fill(1); + const b = new Uint8Array(32).fill(2); + expect(deriveCredentialFromPrf(a)).toEqual(deriveCredentialFromPrf(a)); + expect(deriveCredentialFromPrf(a).loginName).not.toBe(deriveCredentialFromPrf(b).loginName); + expect(deriveCredentialFromPrf(a).password).not.toBe(deriveCredentialFromPrf(b).password); + }); + + it("produces a valid login name shape and a full-entropy password", () => { + const parts = deriveCredentialFromPrf(new Uint8Array(32).fill(9)); + expect(parts.loginName).toMatch(/^pk-[0-9a-f]{12}$/u); + expect(parts.password.length).toBeGreaterThanOrEqual(43); // 32 bytes base64url + }); + + it("rejects PRF outputs that are not 32 bytes", () => { + expect(() => deriveCredentialFromPrf(new Uint8Array(31))).toThrow(/32 bytes/); + expect(() => deriveCredentialFromPrf(new Uint8Array(0))).toThrow(/32 bytes/); + }); +}); + +describe("ceremony options", () => { + it("sign-in options: discoverable, user-verified, PRF evaluated with the v1 salt", () => { + const options = buildPasskeyGetOptions().publicKey!; + expect(options.userVerification).toBe("required"); + expect(options.challenge).toBeInstanceOf(Uint8Array); + const prf = (options.extensions as { prf: { eval: { first: Uint8Array } } }).prf; + expect(prf.eval.first).toBe(PASSKEY_PRF_SALT); + expect("allowCredentials" in options).toBe(false); + }); + + it("create options: resident key required, both alg families, opaque random user id", () => { + const a = buildPasskeyCreateOptions("BitLogin").publicKey!; + const b = buildPasskeyCreateOptions("BitLogin").publicKey!; + expect(a.authenticatorSelection).toEqual({ residentKey: "required", userVerification: "required" }); + expect(a.pubKeyCredParams.map((p) => p.alg)).toEqual([-7, -257]); + expect(a.user.id).not.toEqual(b.user.id); + const prf = (a.extensions as { prf: { eval: { first: Uint8Array } } }).prf; + expect(prf.eval.first).toBe(PASSKEY_PRF_SALT); + }); + + it("honors an explicit rpId in both ceremonies", () => { + expect(buildPasskeyGetOptions("site.example").publicKey!.rpId).toBe("site.example"); + expect(buildPasskeyCreateOptions("BitLogin", "site.example").publicKey!.rp.id).toBe("site.example"); + }); +}); + +describe("extractPrfOutput", () => { + const fake = (results: unknown) => + ({ getClientExtensionResults: () => results }) as { + getClientExtensionResults(): AuthenticationExtensionsClientOutputs; + }; + + it("returns the 32-byte PRF result from ArrayBuffer or view", () => { + const bytes = new Uint8Array(32).fill(5); + expect(extractPrfOutput(fake({ prf: { results: { first: bytes.buffer } } }))).toEqual(bytes); + expect(extractPrfOutput(fake({ prf: { results: { first: bytes } } }))).toEqual(bytes); + }); + + it("returns null when PRF is absent or wrong-sized (unsupported authenticator)", () => { + expect(extractPrfOutput(fake({}))).toBeNull(); + expect(extractPrfOutput(fake({ prf: {} }))).toBeNull(); + expect(extractPrfOutput(fake({ prf: { results: { first: new Uint8Array(16) } } }))).toBeNull(); + }); +}); + +describe("passkeySupported", () => { + it("detects the WebAuthn entry point", () => { + expect(passkeySupported({ PublicKeyCredential: function stub() {} })).toBe(true); + expect(passkeySupported({})).toBe(false); + }); +}); diff --git a/packages/widget/src/passkey.ts b/packages/widget/src/passkey.ts new file mode 100644 index 0000000..172816d --- /dev/null +++ b/packages/widget/src/passkey.ts @@ -0,0 +1,119 @@ +/** Passkey sign-in (docs/passkey-login.md) — pure logic. + * + * The zero-registration on-ramp: no OAuth client, no console, no server, no + * stored credential file anywhere. A passkey with the WebAuthn PRF extension + * deterministically derives this site's BitLogin credential client-side; the + * platform (Google Password Manager, iCloud Keychain, a hardware key, …) + * syncs and guards the passkey, and the ordinary password flows do the rest. + * + * ── COMPATIBILITY CONTRACT ────────────────────────────────────────────── + * deriveCredentialFromPrf is one-way and FROZEN: the same PRF output must + * derive the same login name and password forever, or every passkey account + * is orphaned. Treat a failure in passkey.test.ts's known-answer pin exactly + * like a knownAnswer.test.ts failure: the change is account-breaking and + * needs a migration plan or rejection — never "update the expected value". + * ──────────────────────────────────────────────────────────────────────── + */ +import { hkdfExtract, hkdfExpand, bytesToHex, bytesToBase64url, utf8ToBytes, randomBytes } from "@bitlogin/core/crypto"; + +/** PRF evaluation input, domain-separated so no other application evaluating + * the same passkey's PRF can arrive at BitLogin's secret. Fixed forever. */ +export const PASSKEY_PRF_SALT = utf8ToBytes("bitlogin:passkey-credential:v1"); + +const HKDF_SALT = utf8ToBytes("bitlogin:passkey-derive:v1"); +const INFO_LOGIN_NAME = "bitlogin passkey login-name v1"; +const INFO_PASSWORD = "bitlogin passkey password v1"; + +export interface PasskeyCredentialParts { + loginName: string; + password: string; +} + +/** Copies into a plain ArrayBuffer-backed view: WebAuthn's BufferSource typing + * rejects views that could sit on a SharedArrayBuffer. */ +function asBufferSource(bytes: Uint8Array): Uint8Array { + return Uint8Array.from(bytes); +} + +/** + * PRF output (32 bytes from the authenticator) -> login name + password for + * the standard flows. The login name is recognizably passkey-flavored + * ("pk-<12 hex>") so support conversations can tell the rails apart; the + * password carries the full 256 bits. + */ +export function deriveCredentialFromPrf(prfOutput: Uint8Array): PasskeyCredentialParts { + if (prfOutput.length !== 32) { + throw new Error(`Passkey PRF output must be 32 bytes, got ${prfOutput.length}.`); + } + const prk = hkdfExtract(HKDF_SALT, prfOutput); + const loginName = `pk-${bytesToHex(hkdfExpand(prk, INFO_LOGIN_NAME, 6))}`; + const password = bytesToBase64url(hkdfExpand(prk, INFO_PASSWORD, 32)); + return { loginName, password }; +} + +/** Whether this browser can even attempt passkey sign-in. PRF support cannot + * be feature-detected without a ceremony; absence surfaces as a clear error + * at that point instead. */ +export function passkeySupported(w: { PublicKeyCredential?: unknown } = window): boolean { + return typeof w.PublicKeyCredential === "function"; +} + +/** Options for the sign-in ceremony: discoverable credential, user + * verification required, PRF evaluated in the same step. */ +export function buildPasskeyGetOptions(rpId?: string): CredentialRequestOptions { + return { + publicKey: { + // The challenge protects sign-count/liveness in server-verified + // WebAuthn; here the PRF OUTPUT is the secret and the "verification" + // is whether the derived credential opens the capsule. Random anyway. + challenge: asBufferSource(randomBytes(32)), + ...(rpId ? { rpId } : {}), + userVerification: "required", + extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } } as AuthenticationExtensionsClientInputs + } + }; +} + +/** Options for creating the passkey. Discoverable + user-verified, so later + * sign-ins need no username at all. */ +export function buildPasskeyCreateOptions(rpName: string, rpId?: string): CredentialCreationOptions { + return { + publicKey: { + challenge: asBufferSource(randomBytes(32)), + rp: { name: rpName, ...(rpId ? { id: rpId } : {}) }, + user: { + // Opaque handle; the account identity is DERIVED from the PRF, not + // from this. Random so re-creates don't silently overwrite. + id: asBufferSource(randomBytes(16)), + name: rpName, + displayName: rpName + }, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, // ES256 + { type: "public-key", alg: -257 } // RS256 + ], + authenticatorSelection: { + residentKey: "required", + userVerification: "required" + }, + extensions: { prf: { eval: { first: PASSKEY_PRF_SALT } } } as AuthenticationExtensionsClientInputs + } + }; +} + +/** + * Pulls the PRF result out of a completed ceremony. Null when the + * authenticator or browser doesn't do PRF — the caller shows the honest + * "this passkey can't be used here yet" message. + */ +export function extractPrfOutput(credential: { + getClientExtensionResults(): AuthenticationExtensionsClientOutputs; +}): Uint8Array | null { + const extensions = credential.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer | Uint8Array } }; + }; + const first = extensions.prf?.results?.first; + if (!first) return null; + const bytes = first instanceof Uint8Array ? first : new Uint8Array(first); + return bytes.length === 32 ? bytes : null; +} diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index 6aae0c9..f76dd89 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -67,36 +67,28 @@ describe("widget security policy", () => { } }); - it("centralized on-ramp rows carry the custody label and live inside the menu", () => { - // §CO7: the sub-label IS the custody statement, rendered from the - // host-configured service name -- and the rows exist nowhere else. + it("the passkey row lives inside the menu with an honest custody label", () => { + // docs/passkey-login.md: zero registration, zero servers -- and the + // OAuth on-ramps that preceded it stay removed (owner decision + // 2026-08-04: static only, no per-site provider registration). const menuStart = widgetSource.indexOf('`
'); const menuEnd = widgetSource.indexOf("
`", menuStart); const menu = widgetSource.slice(menuStart, menuEnd); - expect(menu).toContain("${onrampRows}"); - expect(widgetSource).toContain("manages your sign-in"); - // One rendering site (the onrampRows template) + one onClick case. - expect(widgetSource.split('"onramp-signin"').length - 1).toBe(2); - // Same for the serverless Google row (§CO3.3), whose sub-label states the - // stronger custody fact: the credential lives in the user's own Drive. - expect(menu).toContain("${googleRow}"); - expect(widgetSource).toContain("Your sign-in key stays in your own Google Drive"); - expect(widgetSource.split('"google-signin"').length - 1).toBe(2); - // The group heading is the owner-chosen wording. + expect(menu).toContain("${passkeyRow}"); + expect(widgetSource).toContain("Kept in your phone or browser's password manager"); expect(menu).toContain(">Use an account you already have
"); + for (const removed of ["onramp-signin", "google-signin", "onramp-url", "googleapis"]) { + expect(widgetSource).not.toContain(removed); + } }); it("keeps all network I/O out of the element", () => { - // The element orchestrates; every network call lives in an auditable - // module (worker RPC, onramp.ts, driveOnramp.ts) where its destination - // is constrained -- Drive tokens and passwords must never ride an - // ad-hoc fetch added to UI code. + // The element orchestrates; network lives behind the worker RPC where + // destinations are constrained. The passkey rail needs NO network of its + // own -- the credential is derived, not fetched. expect(widgetSource).not.toContain("fetch("); - const driveSource = readFileSync(new URL("./driveOnramp.ts", import.meta.url), "utf8"); - // Drive I/O only ever targets the two Google API base constants. - expect(driveSource).toContain('GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3"'); - expect(driveSource.match(/fetchImpl\(`\$\{GOOGLE_DRIVE(?:_UPLOAD)?_API\}/gu)!.length).toBe(3); - expect(driveSource.match(/fetchImpl\(/gu)!.length).toBe(3); + const passkeySource = readFileSync(new URL("./passkey.ts", import.meta.url), "utf8"); + expect(passkeySource).not.toContain("fetch"); }); it("treats the bunker URI as a secret and keeps the element storage-free", () => {