diff --git a/docs/centralized-onramps.md b/docs/centralized-onramps.md index df5501a..2a6f772 100644 --- a/docs/centralized-onramps.md +++ b/docs/centralized-onramps.md @@ -1,8 +1,27 @@ # Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody -**Document version:** 0.1 (design proposal) -**Status:** Proposal only — nothing here is implemented, and the reference -services it describes live outside this repository by definition. Depends on +**Document version:** 0.2 +**Status:** The **widget side of §CO5 is implemented** (2026-08-04): +`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 +existing-account group — extension, remote signer, key import, and +centralized rails — with the custody statement carried by each row's +sub-label), client-side registration with optional §SF10 import, and the +Tier B2 "Secure your account" dashboard card. Reference services live +outside this repository by definition and do not yet exist. + +**Handshake contract as implemented (v1):** the widget opens +`onramp-url?provider=&state=&origin=` in a popup; +the popup posts to its opener exactly one +`{ type: "bitlogin:onramp:unlock", state, unlock: { loginName, password } }` +(returning user) or `{ …, register: { registrationToken, +suggestedLoginName? } }` (new user), accepted only from the configured +origin with the exact state nonce. For new users the widget registers +client-side, then `POST onramp-url` with +`{ type: "bitlogin:onramp:store", registrationToken, loginName, password }`; +a non-OK response forces the phrase ceremony immediately instead of +deferring it. Hosts must add the service origin to their CSP `connect-src`. Depends on `docs/spec.md`, `docs/login-methods.md` (the one-login-surface model), and `docs/second-factor.md` §SF10 (imported identities, implemented), which turns out to be the graduation mechanism. @@ -256,6 +275,8 @@ A service a BitLogin deployment points at must: `{ provider, service, tier }`. 3. Whether Tier B2's first-session phrase window should survive a reload within some time budget, or end strictly with the worker. -4. Whether the option-menu group heading "Centralized options" is the right - label, or something warmer ("Use an account you already have") with the - custody statement carried entirely by the sub-label. +4. **Resolved 2026-08-04 (owner decision):** the group heading is "Use an + account you already have," covering every existing-account path — Nostr + extension, remote signer, key import, and centralized rails alike — with + the custody statement carried entirely by each centralized row's + sub-label (" manages your sign-in"). diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index cf8207c..f260ade 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -162,6 +162,12 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined +

Sites that want a "Continue with Google/GitHub" on-ramp can additionally point the widget at an OAuth + unlock service (onramp-url, onramp-name, onramp-providers attributes — + nothing centralized ships unless you configure it, and your CSP's connect-src must include the + service's origin). The service manages the password of a real, portable Nostr account; users can bring an + existing key at signup and are nudged toward claiming their recovery phrase over time. See + docs/centralized-onramps.md in the repo for the handshake contract and threat 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 e7f4a27..94c9858 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -28,6 +28,17 @@ 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. +- Optional centralized on-ramps ("Continue with Google/GitHub/..."): 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 + 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 + a fresh identity or import an existing nsec, and the dashboard nudges + toward claiming the recovery phrase ("Secure your account"). Hosts must + add the service origin to CSP connect-src. Contract and threat model: + docs/centralized-onramps.md. - One login surface, several methods: if the visitor's browser has a NIP-07 signer extension, the welcome screen offers "Use your Nostr extension instead" below the primary username/password path, and every visitor gets diff --git a/packages/widget/src/config.ts b/packages/widget/src/config.ts index 5a5197f..e2a502c 100644 --- a/packages/widget/src/config.ts +++ b/packages/widget/src/config.ts @@ -1,7 +1,13 @@ /** Host-page-configurable options for (§19.1, §19.6). */ +import { parseOnrampConfig, type OnrampConfig } from "./onramp.js"; + export interface BitLoginConfig { vaultRelayUrls?: string[]; discoveryRelayUrls?: string[]; + /** Centralized on-ramp (centralized-onramps.md §CO5). Absent unless the + * host explicitly configures one -- BitLogin ships no centralized + * dependency by default. */ + onramp?: OnrampConfig; } export function readConfigFromElement(el: HTMLElement): BitLoginConfig { @@ -9,6 +15,11 @@ 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 + 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") + }) }; } diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 6dee4e0..0cd5462 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -20,12 +20,22 @@ import { Nip46Signer } from "./signers/nip46.js"; import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js"; import { renderQrSvg } from "./qr.js"; import { setActiveSession, clearActiveSession } from "./globalSession.js"; +import { + buildOnrampAuthUrl, + newOnrampState, + onrampProviderLabel, + storeOnrampCredential, + validateOnrampMessage, + type OnrampConfig +} from "./onramp.js"; +import { randomBytes, bytesToHex } from "@bitlogin/core/crypto"; type Screen = | "welcome" | "extension-confirm" | "bunker-connect" | "bunker-confirm" + | "onramp-create" | "import-key" | "create-name" | "create-credential" @@ -54,7 +64,8 @@ const OPTION_ICONS = { extension: ``, remote: ``, importKey: ``, - recover: `` + recover: ``, + onramp: `` } as const; const CHEVRON_DOWN = ``; @@ -112,6 +123,47 @@ export class BitLoginAuthElement extends HTMLElement { private bunkerConnectUri = ""; 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: { registrationToken: string; provider: string; suggestedLoginName?: string } | null = null; + private onrampSession = false; + private securePhrasePending = false; + private readonly onWindowMessage = (event: MessageEvent): void => { + 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; + void this.attemptLogin(result.loginName, result.password); + return; + } + this.onrampRegistration = { + registrationToken: result.registrationToken, + provider: attempt.provider, + suggestedLoginName: result.suggestedLoginName + }; + this.goto("onramp-create"); + }; private sessionWarnings: string[] = []; private lastSignedEventJson = ""; private exportedNsec = ""; @@ -190,6 +242,8 @@ export class BitLoginAuthElement extends HTMLElement { const config = readConfigFromElement(this); this.vaultRelayUrls = config.vaultRelayUrls ?? []; this.discoveryRelayUrls = config.discoveryRelayUrls ?? []; + this.onramp = config.onramp; + window.addEventListener("message", this.onWindowMessage); void this.worker .configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls }) .then(() => this.tryRestoreSession()); @@ -216,6 +270,7 @@ 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(); @@ -271,6 +326,8 @@ export class BitLoginAuthElement extends HTMLElement { if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect(); this.altSigner = null; this.session = null; + this.onrampSession = false; + this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); @@ -279,6 +336,8 @@ export class BitLoginAuthElement extends HTMLElement { } await this.worker.logout(); this.session = null; + this.onrampSession = false; + this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); @@ -770,6 +829,8 @@ export class BitLoginAuthElement extends HTMLElement { case "goto-welcome": this.pendingRollback = null; this.pendingExtensionSigner = null; + this.onrampAttempt = null; + this.onrampRegistration = null; // Backing out of an unfinished bunker connect abandons the worker-side // attempt -- but never a live session (altSigner) that's merely // navigating around. @@ -785,6 +846,18 @@ export class BitLoginAuthElement extends HTMLElement { this.moreOptionsOpen = !this.moreOptionsOpen; this.render(); return; + case "onramp-signin": + this.startOnramp(target.dataset.provider ?? ""); + return; + case "onramp-new": + this.importKey = ""; + this.importPreviewNpub = ""; + return void this.runOnrampRegistration(); + case "onramp-import": + this.importKey = ""; + this.importPreviewNpub = ""; + this.goto("import-key"); + return; case "extension-signin": return this.handleExtensionSignIn(); case "extension-continue": @@ -976,10 +1049,103 @@ 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(); + return; + } this.loginName = ""; this.goto("create-name"); } + /** 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(); + } + + /** + * 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; + const onramp = this.onramp; + if (!registration || !onramp) return; + this.setBusy(true); + try { + const suggested = 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, + importKey: this.importKey || undefined + }); + this.importKey = ""; + this.importPreviewNpub = ""; + this.onrampRegistration = null; + this.loginName = loginName; + this.recoveryPhrase = result.recoveryPhrase; + const words = this.recoveryPhrase.split(" "); + const indices = pickRandomIndices(words.length, 3); + this.confirmSlots = indices.map((index) => ({ index, value: "" })); + this.session = { + publicKey: result.everydayPublicKey, + npub: encodeNpub(result.everydayPublicKey), + accountId: result.accountId, + method: "bitlogin" + }; + this.sessionWarnings = []; + void this.worker.publishProfileAndRelayLists({ + name: this.loginName, + generalRelays: this.vaultRelayUrls, + dmRelays: this.vaultRelayUrls + }); + try { + await storeOnrampCredential(onramp, { + registrationToken: registration.registrationToken, + loginName, + password + }); + } catch (storeErr) { + // The account is real and live on relays, but the service is not + // holding its credential -- the phrase ceremony happens NOW. + this.busy = false; + this.onrampSession = false; + 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.securePhrasePending = true; + this.noteSignerClaim(this.claimSigner()); + this.dispatchLogin(); + this.flashSuccess("dashboard", "Account created"); + } catch (err) { + this.fail(err); + } + } + private handleCreateNameSubmit(): void { const name = this.field("loginName").trim().toLowerCase(); if (!isValidLoginName(name)) { @@ -1042,6 +1208,14 @@ export class BitLoginAuthElement extends HTMLElement { return; } } + if (this.securePhrasePending) { + // Tier B2 deferred ceremony (§CO4): the session already exists and + // bitlogin-login already fired at registration -- this just retires + // the "Secure your account" card. + this.securePhrasePending = false; + this.flashSuccess("dashboard", "Recovery phrase secured"); + return; + } this.sessionWarnings = []; this.noteSignerClaim(this.claimSigner()); this.dispatchLogin(); @@ -1051,6 +1225,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 await this.attemptLogin(loginName, password); } @@ -1442,11 +1617,28 @@ 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(""); + const onrampWaiting = this.onrampAttempt + ? `

Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.
` + : ""; const optionsMenu = this.moreOptionsOpen ? `
+
Use an account you already have
${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")} + ${onrampRows} ${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
` : ""; @@ -1459,10 +1651,29 @@ export class BitLoginAuthElement extends HTMLElement { + ${onrampWaiting} ${optionsMenu} `; } + case "onramp-create": { + const providerLabel = this.onrampRegistration + ? onrampProviderLabel(this.onrampRegistration.provider) + : ""; + return ` + ${this.renderScreenHead("Almost there")} +

${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${ + providerLabel ? ` through ${escapeHtml(providerLabel)}` : "" + }. Your account itself lives on the open Nostr network — choose how to set up its 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 ` ${this.renderScreenHead("Sign in with your extension")} @@ -1692,10 +1903,21 @@ 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 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(this.onramp?.name ?? "the sign-in service")}. +
` + : ""; + const onrampStanding = this.onrampSession + ? `

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

` + : ""; return `

Signed in

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

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

+ ${onrampStanding} ${this.renderError()} ${ diff --git a/packages/widget/src/onramp.test.ts b/packages/widget/src/onramp.test.ts new file mode 100644 index 0000000..ace09b0 --- /dev/null +++ b/packages/widget/src/onramp.test.ts @@ -0,0 +1,167 @@ +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 new file mode 100644 index 0000000..0e40517 --- /dev/null +++ b/packages/widget/src/onramp.ts @@ -0,0 +1,177 @@ +/** 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/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index c36e1fc..c8baef2 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -67,6 +67,20 @@ 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. + 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); + // The group heading is the owner-chosen wording. + expect(menu).toContain(">Use an account you already have"); + }); + it("treats the bunker URI as a secret and keeps the element storage-free", () => { // A bunker:// URI carries a connection token; it gets a password input // (no shoulder-surfing, no autofill heuristics), and the element itself diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index eb4fab1..dfe1558 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -267,6 +267,26 @@ button:focus-visible { outline: 2px solid var(--bl-accent); outline-offset: 2px; margin-top: 4px; overflow: hidden; } +.option-group-label { + padding: 10px 14px 6px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--bl-muted); +} +.option-group-label + .option-row { border-top: none; } +.notice .link-inline { + width: auto; + margin: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font-size: inherit; + font-weight: 600; + text-decoration: underline; +} .option-row { display: flex; align-items: center;