mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
be5dd529e9
commit
8079d90cf8
15 files changed
+561
-1059
No files matched your search
+7
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user