Files
bitlogin/packages/widget/src/passkey.test.ts
thePR0M3TH3ANandClaude Fable 5 f0b5f40fd4 Audit response: fix BL-16 through BL-20 and the low findings, same day
BL-16 (high): a remote signer's auth_url is attacker-controlled input that
became a clickable href. sanitizeAuthUrl in core now admits only https
(or loopback http) -- javascript:/data:/blob:/custom schemes are dropped
without consuming the surfacing slot -- and the widget link shows the
hostname. Hostile-scheme tests at both layers.

BL-17: the post-creation PRF fallback assertion is pinned via
allowCredentials to the credential that was just created, so a multi-
passkey account sheet can no longer silently bind an older passkey's
account.

BL-18: queryOnce's timeout path sent CLOSE before settling, unguarded --
a relay dying mid-query stranded the promise and everything queued behind
the worker's serial queue. The send is guarded; settling is unconditional.
MockRelay.close() now terminates live sockets (it previously waited on
them forever), and a suppressEose switch models the silent relay.

BL-19: relays are no longer trusted to honor limits. Buffered queries
enforce filter.limit locally, drop replayed duplicate ids, and cap at
1000 events; a 20-copy replay now yields one event.

BL-20: the .semgrep.yml try/catch fallback pattern never parsed, so the
entropy rules silently did not run. Rewritten in parseable form and
verified under the pinned Semgrep 1.171.0: fires on hostile code, exits
0 on the clean tree.

Lows: NIP-07 and NIP-46 verify returned signed events (signature, pinned
session identity, field equality with the request); Nip46Client.close()
wipes the client and conversation keys (§11.10); passkey labels carry a
short suffix so multiple accounts are distinguishable; postcss bumped to
8.5.25 (GHSA-fxqj-rqcc-2cmp); GitHub Actions pinned to commit SHAs.
Deferred as interop (recorded in TODO): NIP-46 protocol logout and
switch_relays. 12 new adversarial tests; 256 total.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 13:10:20 -04:00

121 lines
5.3 KiB
TypeScript

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");
});
it("pins the assertion to one credential when given (audit BL-17)", () => {
// The post-creation PRF fallback MUST scope discovery to the passkey
// just created; an open sheet could bind an older passkey's account.
const rawId = new Uint8Array(16).fill(3).buffer;
const options = buildPasskeyGetOptions(undefined, rawId).publicKey!;
expect(options.allowCredentials).toEqual([{ type: "public-key", id: rawId }]);
});
it("labels each created passkey distinguishably (audit follow-up)", () => {
const a = buildPasskeyCreateOptions("BitLogin").publicKey!;
const b = buildPasskeyCreateOptions("BitLogin").publicKey!;
expect(a.user.name).toMatch(/^BitLogin \([0-9a-f]{4}\)$/u);
expect(a.user.displayName).toBe(a.user.name);
expect(a.user.name).not.toBe(b.user.name);
});
});
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);
});
});