diff --git a/docs/centralized-onramps.md b/docs/centralized-onramps.md index 2a6f772..3537987 100644 --- a/docs/centralized-onramps.md +++ b/docs/centralized-onramps.md @@ -1,7 +1,10 @@ # Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody -**Document version:** 0.2 -**Status:** The **widget side of §CO5 is implemented** (2026-08-04): +**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): `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 @@ -90,6 +93,49 @@ NIP-46 support (§LM5) is the whole client. compatibility answer for services that already exist. Not the recommended destination. +## CO3.3 Architecture C — provider storage: no server anywhere (implemented) + +**Owner decision (2026-08-04): static only is the way.** Architecture B still +assumes *someone* runs the unlock service. For providers that expose +user-owned storage to pure browser clients, even that party disappears: the +credential of a standard capsule account lives in **the user's own cloud +storage**, readable only through the host's OAuth client ID. Google +authenticates the user; Google stores the user's own secret; the static +widget does everything else. Nobody else exists to trust, pay, or lose. + +Implemented for Google (`onramp-google-client-id` attribute): + +1. The "Continue with Google" row opens a popup on Google's OAuth endpoint — + plain implicit grant, `response_type=token`, no external script, so a + strict `script-src 'self'` CSP survives. Scope is `drive.appdata` only: + no identity claims, no email, minimum disclosure. +2. The registered redirect URI is **the widget page itself** — no callback + artifact to deploy. The widget instance in the popup recognizes the token + fragment, relays it to the opener (same-origin, exact state nonce), + scrubs the URL, and closes. +3. The opener reads `bitlogin-credential-v1.json` from the Drive app-data + folder: present → ordinary password login (§16); absent → first-time + setup (fresh identity or §SF10 import), client-side registration, then + the credential is uploaded. An upload failure triggers the no-orphan + rule: the phrase ceremony runs immediately. + +Threat model: Google-account compromise = password compromise, exactly the +spec's §14 analysis; there is no second party to compromise. The app-data +file is plaintext by necessity — any encryption key would have to come from +the same OAuth gate it would be protecting against. Deployment notes: the +host registers its own OAuth client ID and the widget page's exact URL, and +adds `https://www.googleapis.com` to `connect-src`. Known dependency: the +implicit grant is legacy at Google; if it is ever sunset, the fallback is +Google Identity Services, which requires relaxing the CSP for one external +script — recorded here so that trade arrives as a decision, not a surprise. +The same pattern extends to Dropbox and Microsoft OneDrive (PKCE browser +flows with app-scoped storage); GitHub and Facebook offer no equivalent and +remain Architecture A/B territory. + +Password rotation while Google-managed is the graduation step (CO6 step 2): +the Drive copy simply goes stale and dead, and the rotate screen says so +before the user commits. + ## CO3.2 Architecture B — OAuth as the password manager (recommended) The insight: a BitLogin account requires a high-entropy password, but the diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index f260ade..7020066 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -162,12 +162,16 @@ 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.

+

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.

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 94c9858..386cc4b 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -28,10 +28,18 @@ 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 +- 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 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 e2a502c..ae5a749 100644 --- a/packages/widget/src/config.ts +++ b/packages/widget/src/config.ts @@ -1,13 +1,18 @@ /** 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 (centralized-onramps.md §CO5). Absent unless the - * host explicitly configures one -- BitLogin ships no centralized - * dependency by default. */ + /** 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,6 +25,7 @@ export function readConfigFromElement(el: HTMLElement): BitLoginConfig { url: el.getAttribute("onramp-url"), name: el.getAttribute("onramp-name"), providers: el.getAttribute("onramp-providers") - }) + }), + onrampGoogle: parseGoogleOnrampConfig(el.getAttribute("onramp-google-client-id")) }; } diff --git a/packages/widget/src/driveOnramp.test.ts b/packages/widget/src/driveOnramp.test.ts new file mode 100644 index 0000000..5c0ba5e --- /dev/null +++ b/packages/widget/src/driveOnramp.test.ts @@ -0,0 +1,160 @@ +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 new file mode 100644 index 0000000..0f90a28 --- /dev/null +++ b/packages/widget/src/driveOnramp.ts @@ -0,0 +1,203 @@ +/** 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 0cd5462..3e202ee 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -28,6 +28,17 @@ import { 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"; type Screen = @@ -133,10 +144,22 @@ export class BitLoginAuthElement extends HTMLElement { // 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 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; 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( @@ -154,16 +177,46 @@ export class BitLoginAuthElement extends HTMLElement { // 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 = ""; @@ -243,7 +296,22 @@ export class BitLoginAuthElement extends HTMLElement { 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()); @@ -327,6 +395,7 @@ export class BitLoginAuthElement extends HTMLElement { this.altSigner = null; this.session = null; this.onrampSession = false; + this.onrampKind = null; this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); @@ -337,6 +406,7 @@ export class BitLoginAuthElement extends HTMLElement { await this.worker.logout(); this.session = null; this.onrampSession = false; + this.onrampKind = null; this.securePhrasePending = false; clearActiveSession(this); this.releaseSigner(); @@ -831,6 +901,7 @@ export class BitLoginAuthElement extends HTMLElement { this.pendingExtensionSigner = null; this.onrampAttempt = null; this.onrampRegistration = null; + this.googleAttempt = null; // Backing out of an unfinished bunker connect abandons the worker-side // attempt -- but never a live session (altSigner) that's merely // navigating around. @@ -849,6 +920,9 @@ export class BitLoginAuthElement extends HTMLElement { case "onramp-signin": this.startOnramp(target.dataset.provider ?? ""); return; + case "google-signin": + this.startGoogleOnramp(); + return; case "onramp-new": this.importKey = ""; this.importPreviewNpub = ""; @@ -1059,6 +1133,42 @@ export class BitLoginAuthElement extends HTMLElement { 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."; + this.render(); + return; + } + 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 { + this.setBusy(true); + try { + const credential = await findDriveCredential(accessToken); + this.busy = false; + if (credential) { + this.onrampSession = true; + this.onrampKind = "google"; + await this.attemptLogin(credential.loginName, credential.password); + 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; @@ -1085,11 +1195,12 @@ export class BitLoginAuthElement extends HTMLElement { */ private async runOnrampRegistration(): Promise { const registration = this.onrampRegistration; - const onramp = this.onramp; - if (!registration || !onramp) return; + if (!registration) return; + if (registration.kind === "service" && !this.onramp) return; this.setBusy(true); try { - const suggested = registration.suggestedLoginName?.trim().toLowerCase() ?? ""; + 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({ @@ -1118,16 +1229,21 @@ export class BitLoginAuthElement extends HTMLElement { dmRelays: this.vaultRelayUrls }); try { - await storeOnrampCredential(onramp, { - registrationToken: registration.registrationToken, - loginName, - password - }); + 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 the service is not - // holding its credential -- the phrase ceremony happens NOW. + // 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()); @@ -1137,6 +1253,7 @@ export class BitLoginAuthElement extends HTMLElement { } this.busy = false; this.onrampSession = true; + this.onrampKind = registration.kind; this.securePhrasePending = true; this.noteSignerClaim(this.claimSigner()); this.dispatchLogin(); @@ -1226,6 +1343,7 @@ export class BitLoginAuthElement extends HTMLElement { 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; await this.attemptLogin(loginName, password); } @@ -1629,8 +1747,22 @@ export class BitLoginAuthElement extends HTMLElement { `; }) .join(""); - const onrampWaiting = this.onrampAttempt - ? `

Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.
` + // 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.
` : ""; const optionsMenu = this.moreOptionsOpen ? `
@@ -1638,6 +1770,7 @@ export class BitLoginAuthElement extends HTMLElement { ${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")}
` @@ -1657,14 +1790,16 @@ export class BitLoginAuthElement extends HTMLElement { } case "onramp-create": { - const providerLabel = this.onrampRegistration - ? onrampProviderLabel(this.onrampRegistration.provider) - : ""; + 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))}` : "" + }.`; 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:

+

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

${this.renderError()} `; // 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(this.onramp?.name ?? "the sign-in service")}. + ? `
Secure your account: your recovery phrase is available only during this session. Save it now and your account stays yours even without ${escapeHtml(custodian)}.
` : ""; 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.

` + ? 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.

` : ""; return `

Signed in

@@ -2047,6 +2186,13 @@ export class BitLoginAuthElement extends HTMLElement { case "change-password": 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.
` + : "" + }

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.

${escapeHtml(this.changePasswordNewCredential)}
diff --git a/packages/widget/src/securityPolicy.test.ts b/packages/widget/src/securityPolicy.test.ts index c8baef2..6aae0c9 100644 --- a/packages/widget/src/securityPolicy.test.ts +++ b/packages/widget/src/securityPolicy.test.ts @@ -77,10 +77,28 @@ describe("widget security policy", () => { 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(">Use an account you already have
"); }); + 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. + 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); + }); + 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