Files
bitlogin/packages/widget/src/securityPolicy.test.ts
T
thePR0M3TH3ANandClaude Fable 5 8079d90cf8 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]>
2026-08-04 12:20:26 -04:00

177 lines
7.8 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const widgetSource = readFileSync(
new URL("./element.ts", import.meta.url),
"utf8",
);
const readme = readFileSync(
new URL("../../../README.md", import.meta.url),
"utf8",
);
const vercel = JSON.parse(
readFileSync(new URL("../../../vercel.json", import.meta.url), "utf8"),
) as {
headers: Array<{
source: string;
headers: Array<{ key: string; value: string }>;
}>;
};
const demoPages = ["index.html", "docs.html", "account.html"].map((name) =>
readFileSync(new URL(`../../demo/public/${name}`, import.meta.url), "utf8"),
);
describe("widget security policy", () => {
it("does not expose manual-password creation, recovery, or rotation controls", () => {
expect(widgetSource).not.toContain("toggle-manual-password");
expect(widgetSource).not.toContain('name="manualPassword"');
expect(widgetSource).not.toContain("checkManualPassword");
expect(widgetSource).not.toContain("entropyBits");
});
it("keeps username/password primary; the extension method is a secondary affordance", () => {
// Owner decision 2026-08-04 (login-methods.md §LM9.1): alternative methods must
// not displace the password path. The welcome screen's primary sign-in button has
// to appear before the extension option in the rendered template.
const passwordSignIn = widgetSource.indexOf('data-action="goto-login">Sign in</button>');
const optionsMenuSlot = widgetSource.indexOf("${optionsMenu}");
expect(passwordSignIn).toBeGreaterThan(-1);
expect(optionsMenuSlot).toBeGreaterThan(-1);
expect(passwordSignIn).toBeLessThan(optionsMenuSlot);
// The extension row renders inside the collapsed menu, not free-standing.
expect(widgetSource).toContain('"extension-signin",');
});
it("keeps every alternative method inside the collapsed options menu", () => {
// Same owner decision as the pin above, applied to all of NIP-46, key
// import, and recovery: each renders as a row of the option-menu that the
// "${optionsMenu}" slot (already pinned after the primary sign-in button)
// interpolates -- never as a free-standing button beside the primaries.
const menuStart = widgetSource.indexOf('`<div class="option-menu">');
const menuEnd = widgetSource.indexOf("</div>`", menuStart);
expect(menuStart).toBeGreaterThan(-1);
expect(menuEnd).toBeGreaterThan(menuStart);
const menu = widgetSource.slice(menuStart, menuEnd);
// Expected occurrences: the onClick case + the menu row, plus sanctioned
// contextual renderings AWAY from the welcome screen -- the login screen's
// "Forgot password?" link (goto-recover) and the bunker screen's own
// "Generate a new code" retry (goto-bunker).
for (const [action, expected] of [
["goto-bunker", 3],
["goto-import", 2],
["goto-recover", 3],
] as const) {
expect(menu).toContain(`optionRow("${action}"`);
const occurrences = widgetSource.split(`"${action}"`).length - 1;
expect(occurrences).toBe(expected);
}
});
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("${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; 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 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", () => {
// A bunker:// URI carries a connection token; it gets a password input
// (no shoulder-surfing, no autofill heuristics), and the element itself
// must never touch web storage -- alternative-method sessions are
// memory-only by design (§LM5, §LM7), and the ONLY persistence in the
// widget lives behind the worker's session cache.
expect(widgetSource).toContain('type="password" name="bunkerUri"');
expect(widgetSource).not.toContain("localStorage");
expect(widgetSource).not.toContain("sessionStorage");
});
it("extension sessions never claim window.nostr and never persist", () => {
// §LM4: NIP-07 mode delegates to the extension's provider; fighting it for the
// window.nostr slot is reserved for BitLogin-account sessions. There must be no
// claimSigner() call in the extension confirm path, and no storage write.
const confirmBody = widgetSource.slice(
widgetSource.indexOf("private handleExtensionConfirm"),
// Ends where the NIP-46 handlers begin -- those claim the slot
// legitimately (§LM5: the routed provider IS that session's backend).
widgetSource.indexOf("private async startNostrconnect"),
);
expect(confirmBody.length).toBeGreaterThan(0);
expect(confirmBody).not.toContain("claimSigner");
expect(confirmBody).not.toContain("localStorage");
expect(confirmBody).not.toContain("sessionCache");
});
it("renders vault integrity warnings on the management screen", () => {
expect(widgetSource).toContain("buildVaultIntegrityWarnings(listed)");
expect(widgetSource).toContain("this.vaultIntegrityWarnings.map");
});
it("documents only same-origin self-hosting, never a remote demo embed", () => {
expect(readme).toContain("same-origin");
expect(readme).toContain("self-host");
expect(readme).not.toContain(
'src="https://bitlogin.network/vendor/bitlogin/bitlogin.js"',
);
});
it("pins the deployment CSP and baseline browser headers", () => {
const headers = new Map(
vercel.headers
.flatMap((entry) => entry.headers)
.map((header) => [header.key, header.value]),
);
expect(headers.get("Content-Security-Policy")).toContain(
"default-src 'none'",
);
expect(headers.get("Content-Security-Policy")).toContain(
"frame-ancestors 'none'",
);
// Argon2id (hash-wasm) compiles a WASM module inside the crypto worker;
// script-src without 'wasm-unsafe-eval' blocks WebAssembly.compile and
// breaks every password flow on the deployed site. The keyword permits
// WASM compilation ONLY -- plain 'unsafe-eval' (JS eval) must stay out.
expect(headers.get("Content-Security-Policy")).toContain(
"script-src 'self' 'wasm-unsafe-eval'",
);
expect(headers.get("Content-Security-Policy")).not.toContain(
" 'unsafe-eval'",
);
for (const required of [
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Resource-Policy",
"X-Frame-Options",
]) {
expect(headers.has(required), `${required} must be configured`).toBe(
true,
);
}
});
it("keeps demo pages free of inline scripts and remote font dependencies", () => {
for (const page of demoPages) {
expect(page).not.toMatch(/<script(?![^>]*\bsrc=)[^>]*>/iu);
expect(page).not.toMatch(/fonts\.(?:googleapis|gstatic)\.com/iu);
}
});
});