Replace OAuth on-ramps with passkey sign-in: zero registration, zero servers

Owner decision: static only, and no per-site provider registration --
which every OAuth path requires (and GitHub/Facebook additionally require
a server). Both on-ramp implementations (service handshake, serverless
Google Drive) are removed the day they shipped; centralized-onramps.md
stays on the record as the analysis of why, superseded by
passkey-login.md.

The passkey rail: a WebAuthn PRF-capable passkey (synced for most users
by Google Password Manager or iCloud Keychain) deterministically derives
the login name and password of a standard capsule account -- passkey.ts
holds the derivation behind a FROZEN v1 known-answer contract with the
same standing rule as knownAnswer.test.ts. The ordinary password flows
do everything else; nothing is stored outside the user's authenticator,
and the rail needs no network of its own.

Flow is idempotent by construction: sign-in against a passkey with no
account routes into setup with the derived credential
(AccountNotFoundError); setup against an existing account signs in
(AccountAlreadyExistsError). Fresh identity or §SF10 nsec import at
setup; Tier B2 phrase card retained ('your account stays yours even if
you lose your passkey'); rotation is the graduation step and says so.

Pins updated: passkey row with honest custody label inside the menu,
OAuth actions provably absent, element still network-free. 11 passkey
tests incl. the frozen vectors; menu and screens verified in Chrome.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 12:20:26 -04:00
1 parent be5dd529e9
commit 8079d90cf8
15 files changed
+561 -1059

No files matched your search

+7 -4
View File
@@ -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
+14 -5
View File
@@ -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 <Provider>" rows under the "Use an account
you already have" group (§CO11.1 resolved: that heading covers the whole
+5 -3
View File
@@ -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
+108
View File
@@ -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).
+7 -10
View File
@@ -162,16 +162,13 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one
surface, and routes every signing call to whichever the user picked. You don't need your own method picker,
and your app code doesn't branch by method.</p>
<p>Sites that want a <strong>"Continue with Google"</strong> option can have it with no server anywhere:
set <code>onramp-google-client-id</code> to your own Google OAuth client ID, register this page's exact URL
as a redirect URI, and add <code>https://www.googleapis.com</code> to your CSP's <code>connect-src</code>.
The credential of a real, portable Nostr account is stored in the <em>user's own</em> 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.</p>
<p>For providers without browser-accessible storage (GitHub, Facebook, …) the widget can instead point at an
external OAuth unlock service (<code>onramp-url</code>, <code>onramp-name</code>, <code>onramp-providers</code>;
the service's origin goes in <code>connect-src</code>). Nothing centralized ships unless you configure it.
See <code>docs/centralized-onramps.md</code> in the repo for contracts and threat models.</p>
<p>That includes <strong>"Continue with a passkey"</strong> — 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 <code>docs/passkey-login.md</code> in the repo for the derivation contract and loss/recovery
model.</p>
<p>Two different questions have two different APIs, and conflating them is the classic integration bug:</p>
<p><strong>"Is someone signed in through BitLogin?"</strong> — use <code>window.bitlogin.activeMethod()</code>
(or the richer <code>activeSession()</code>), or listen for <code>bitlogin-login</code>. This answers correctly
+13 -12
View File
@@ -28,18 +28,19 @@ deployment above.
- A `<bitlogin-auth>` 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
("<Service> manages your sign-in") as the sub-label, new users can create
+1 -18
View File
@@ -1,18 +1,7 @@
/** Host-page-configurable options for <bitlogin-auth> (§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
};
}
-160
View File
@@ -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<string, string>)["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/);
});
});
-203
View File
@@ -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<GoogleTokenMessage> | 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<string, string> {
return { authorization: `Bearer ${accessToken}` };
}
async function requireOk(response: Response, doing: string): Promise<Response> {
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<DriveCredential | null> {
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<void> {
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.`
);
}
}
+169 -278
View File
@@ -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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3"/><path d="M21 14v0.01"/><path d="M14 21h0.01"/><path d="M17.5 17.5L21 21"/></svg>`,
importKey: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21"/><path d="M18 12v3.2"/><path d="M14.8 12v2.2"/></svg>`,
recover: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 5v5h5"/><path d="M4.2 14a8 8 0 1 0 1.9-8.3L2.5 10"/></svg>`,
onramp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="10" r="3"/><path d="M6.2 18.5a6.5 6.5 0 0 1 11.6 0"/></svg>`
passkey: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3.5c-3 0-5.5 2.4-5.5 5.4v2.3c0 3.4-.7 5.3-1.6 6.8"/><path d="M12 7.2a2.9 2.9 0 0 0-2.9 2.9v2.1c0 2.6-.5 4.6-1.3 6.3"/><path d="M12 10.5v2.2c0 2.7-.4 4.9-1.1 6.8"/><path d="M15 9.6c.3.7.4 1.5.4 2.4 0 2.9-.3 5.3-.9 7.3"/><path d="M17.5 8.9c.6 3.4.4 7.1-.4 10.2"/></svg>`
} as const;
const CHEVRON_DOWN = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>`;
@@ -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<void> {
/** "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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 `
<button class="option-row" type="button" data-action="onramp-signin" data-provider="${escapeHtml(provider)}">
<span class="option-icon">${OPTION_ICONS.onramp}</span>
<span class="option-text"><span>Continue with ${escapeHtml(label)}</span><span class="option-sub">${escapeHtml(this.onramp!.name)} manages your sign-in</span></span>
</button>`;
})
.join("");
// Serverless Google (§CO3.3): no third party at all -- the sub-label
// says where the credential actually lives.
const googleRow = this.onrampGoogle
? `
<button class="option-row" type="button" data-action="google-signin">
<span class="option-icon">${OPTION_ICONS.onramp}</span>
<span class="option-text"><span>Continue with Google</span><span class="option-sub">Your sign-in key stays in your own Google Drive</span></span>
</button>`
: "";
const waitingProvider = this.googleAttempt
? "Google"
: this.onrampAttempt
? onrampProviderLabel(this.onrampAttempt.provider)
: null;
const onrampWaiting = waitingProvider
? `<div class="notice info">Finish signing in with ${escapeHtml(waitingProvider)} in the window that opened, then return here.</div>`
// 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
? `<div class="option-menu">
<div class="option-group-label">Use an account you already have</div>
${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")}
</div>`
: "";
@@ -1784,30 +1678,33 @@ export class BitLoginAuthElement extends HTMLElement {
<button class="options-toggle ${this.moreOptionsOpen ? "open" : ""}" type="button" data-action="toggle-more" aria-expanded="${this.moreOptionsOpen}">
More sign-in options ${CHEVRON_DOWN}
</button>
${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")}
<p class="sub">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.</p>
${this.renderError()}
<button class="primary" type="button" data-action="passkey-auth" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Waiting for your passkey…' : "Use my passkey"}
</button>
<button class="secondary" type="button" data-action="passkey-register" ${this.busy ? "disabled" : ""}>Create a new passkey</button>
<p class="small">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.</p>
`;
case "passkey-create":
return `
${this.renderScreenHead("Almost there")}
<p class="sub">${custodyLine} Your account itself lives on the open Nostr network — choose how to set up its identity:</p>
<p class="sub">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:</p>
${this.renderError()}
<button class="primary" type="button" data-action="onramp-new" ${this.busy ? "disabled" : ""}>
<button class="primary" type="button" data-action="passkey-new" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Creating your account…' : "Create a fresh identity"}
</button>
<button class="secondary" type="button" data-action="onramp-import" ${this.busy ? "disabled" : ""}>I already have a Nostr key</button>
<button class="secondary" type="button" data-action="passkey-import" ${this.busy ? "disabled" : ""}>I already have a Nostr key</button>
<p class="small">Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.</p>
`;
}
case "extension-confirm":
return `
@@ -2040,23 +1937,19 @@ export class BitLoginAuthElement extends HTMLElement {
<button class="secondary" type="button" data-action="goto-export">Export identity</button>`;
// 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
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even without ${escapeHtml(custodian)}.
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even if you lose your passkey.
<button class="link-inline" type="button" data-action="goto-confirm-phrase">View and save it</button></div>`
: "";
const onrampStanding = this.onrampSession
? this.onrampKind === "google"
? `<p class="small">Signed in with Google — your sign-in key is stored in your own Google Drive; your identity lives on the open Nostr network.</p>`
: `<p class="small">Signed in via ${escapeHtml(this.onramp?.name ?? "a sign-in service")} — they manage your sign-in; your identity lives on the open Nostr network.</p>`
const passkeyStanding = this.passkeySession
? `<p class="small">Signed in with a passkey — it unlocks this account from your device's password manager; your identity lives on the open Nostr network.</p>`
: "";
return `
<h2>Signed in</h2>
${securePhraseCard}
${this.renderWarnings()}
<p class="pubkey">${escapeHtml(this.session?.npub ?? "")}</p>
${onrampStanding}
${passkeyStanding}
${this.renderError()}
<button class="secondary" type="button" data-action="sign-test-event">Sign a test event</button>
${
@@ -2187,10 +2080,8 @@ export class BitLoginAuthElement extends HTMLElement {
return `
<h2>Rotate password</h2>
${
this.onrampSession
? `<div class="notice info">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.</div>`
this.passkeySession
? `<div class="notice info">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.</div>`
: ""
}
<p class="sub">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.</p>
-167
View File
@@ -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/);
});
});
-177
View File
@@ -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<string, string> = {
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<void> {
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.`
);
}
}
+104
View File
@@ -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);
});
});
+119
View File
@@ -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<ArrayBuffer> {
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;
}
+14 -22
View File
@@ -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('`<div class="option-menu">');
const menuEnd = widgetSource.indexOf("</div>`", menuStart);
const menu = widgetSource.slice(menuStart, menuEnd);
expect(menu).toContain("${onrampRows}");
expect(widgetSource).toContain("manages your sign-in</span>");
// 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</div>");
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", () => {