feat: release authentication laboratories 0.3.0
This commit is contained in:
@@ -48,14 +48,46 @@ test("computes an RFC OCRA vector and decodes client data", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("shared origin exposes inspection but not live credential creation", async ({
|
||||
test("localhost development enables live credential creation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
||||
await page.getByRole("button", { name: "Live ceremony" }).click();
|
||||
await expect(page.getByText("Inspect only")).toBeVisible();
|
||||
await expect(page.getByText("Live enabled")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create test credential" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("exposes the advanced OTP and WebAuthn laboratories", async ({ page }) => {
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await page.getByRole("button", { name: "Resync & rotation" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "HOTP resynchronization" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Collection comparison" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Import & migration" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Scan with camera" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Decrypt PSKC" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
||||
await page.getByRole("button", { name: "Extensions & traces" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Extension configuration" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Ceremony traces" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Attestation verifier" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Trust root and historical policy" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { utf8ToBytes } from "../../src/crypto/encoding";
|
||||
import {
|
||||
compareCollectionSnapshots,
|
||||
createCollectionSnapshot,
|
||||
parseCollectionSnapshot,
|
||||
} from "../../src/otp/collection";
|
||||
import type { OtpProfile } from "../../src/otp/profile";
|
||||
|
||||
function profile(account: string, secret: string): OtpProfile {
|
||||
return {
|
||||
kind: "totp",
|
||||
issuer: "Example",
|
||||
account,
|
||||
secret: utf8ToBytes(secret),
|
||||
algorithm: "SHA-1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
counter: 0n,
|
||||
extensions: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("OTP collection snapshots", () => {
|
||||
it("exports fingerprints without secrets", async () => {
|
||||
const snapshot = await createCollectionSnapshot([
|
||||
profile("alice", "top-secret-value"),
|
||||
]);
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
expect(serialized).not.toContain("top-secret-value");
|
||||
expect(parseCollectionSnapshot(serialized)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("detects renames, parameter changes and secret reuse", async () => {
|
||||
const before = await createCollectionSnapshot([
|
||||
profile("alice", "secret-a"),
|
||||
profile("bob", "secret-b"),
|
||||
]);
|
||||
const bob = profile("bob", "secret-b");
|
||||
bob.digits = 8;
|
||||
const after = await createCollectionSnapshot([
|
||||
profile("alice-renamed", "secret-a"),
|
||||
bob,
|
||||
profile("carol", "secret-b"),
|
||||
]);
|
||||
const result = compareCollectionSnapshots(before, after);
|
||||
expect(result.changes.map(({ kind }) => kind)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"renamed",
|
||||
"parameters-changed",
|
||||
"added",
|
||||
"secret-reused",
|
||||
]),
|
||||
);
|
||||
expect(result.rotationPlan[0]?.kind).toBe("secret-reused");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
exportGoogleMigration,
|
||||
exportCsv,
|
||||
importCsv,
|
||||
importGoogleMigrationBatch,
|
||||
@@ -115,4 +116,27 @@ describe("OTP migrations", () => {
|
||||
importGoogleMigrationBatch([googlePart(0), googlePart(1, 2, 99)]),
|
||||
).toThrow(/different batches/iu);
|
||||
});
|
||||
|
||||
it("exports and reassembles Google migration batches", () => {
|
||||
const portable = { ...profile, period: 30 };
|
||||
const exported = exportGoogleMigration(
|
||||
[portable, { ...portable, account: "bob" }],
|
||||
1,
|
||||
);
|
||||
expect(exported.uris).toHaveLength(2);
|
||||
const imported = importGoogleMigrationBatch(exported.uris);
|
||||
expect(imported.profiles.map(({ account }) => account)).toEqual([
|
||||
profile.account,
|
||||
"bob",
|
||||
]);
|
||||
expect(imported.profiles[0]).toMatchObject({
|
||||
algorithm: "SHA-256",
|
||||
digits: 8,
|
||||
period: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses lossy Google exports", () => {
|
||||
expect(() => exportGoogleMigration([profile])).toThrow(/period or epoch/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { utf8ToBytes } from "../../src/crypto/encoding";
|
||||
import {
|
||||
hotp,
|
||||
resynchronizeHotp,
|
||||
totp,
|
||||
totpCounter,
|
||||
verifyHotp,
|
||||
@@ -41,6 +42,33 @@ describe("HOTP", () => {
|
||||
});
|
||||
expect(match).toMatchObject({ counter: 4n, delta: 3 });
|
||||
});
|
||||
|
||||
it("resynchronizes with one or two bounded consecutive codes", async () => {
|
||||
const secret = utf8ToBytes("12345678901234567890");
|
||||
await expect(
|
||||
resynchronizeHotp({
|
||||
firstCode: "338314",
|
||||
secondCode: "254676",
|
||||
secret,
|
||||
counter: 1n,
|
||||
lookAhead: 10,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
matchedCounter: 4n,
|
||||
nextCounter: 6n,
|
||||
distance: 3,
|
||||
confidence: "consecutive-codes",
|
||||
});
|
||||
await expect(
|
||||
resynchronizeHotp({
|
||||
firstCode: "338314",
|
||||
secondCode: "000000",
|
||||
secret,
|
||||
counter: 1n,
|
||||
lookAhead: 10,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOTP", () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { utf8ToBytes } from "../../src/crypto/encoding";
|
||||
import { exportEncryptedPskc, importEncryptedPskc } from "../../src/otp/pskc";
|
||||
import type { OtpProfile } from "../../src/otp/profile";
|
||||
|
||||
const profiles: OtpProfile[] = [
|
||||
{
|
||||
kind: "totp",
|
||||
account: "alice@example.test",
|
||||
issuer: "Example & Sons",
|
||||
secret: utf8ToBytes("12345678901234567890"),
|
||||
algorithm: "SHA-256",
|
||||
digits: 8,
|
||||
period: 45,
|
||||
epoch: 10,
|
||||
counter: 0n,
|
||||
extensions: new Map(),
|
||||
},
|
||||
{
|
||||
kind: "hotp",
|
||||
account: "hardware token",
|
||||
issuer: "Example",
|
||||
secret: utf8ToBytes("abcdefghijklmnopqrst"),
|
||||
algorithm: "SHA-1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
counter: 1042n,
|
||||
extensions: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
describe("encrypted PSKC", () => {
|
||||
it("round-trips authenticated password-encrypted collections", async () => {
|
||||
const exported = await exportEncryptedPskc(
|
||||
profiles,
|
||||
"correct horse battery staple",
|
||||
{ iterations: 100_000 },
|
||||
);
|
||||
expect(exported).toContain("PBKDF2-params");
|
||||
expect(exported).toContain(
|
||||
'Algorithm="http://www.rsasecurity.com/rsalabs/pkcs/schemas/pkcs-5v2-0#pbkdf2"',
|
||||
);
|
||||
expect(exported).toContain("<pskc:MACKey><xenc:EncryptionMethod");
|
||||
expect(exported).toContain('<pskc:EncryptedValue Id="ED-1">');
|
||||
expect(exported).not.toContain("12345678901234567890");
|
||||
const result = await importEncryptedPskc(
|
||||
exported,
|
||||
"correct horse battery staple",
|
||||
);
|
||||
expect(result.profiles).toHaveLength(2);
|
||||
expect(result.profiles[0]).toMatchObject({
|
||||
kind: "totp",
|
||||
account: "alice@example.test",
|
||||
issuer: "Example & Sons",
|
||||
algorithm: "SHA-256",
|
||||
digits: 8,
|
||||
period: 45,
|
||||
epoch: 10,
|
||||
});
|
||||
expect(result.profiles[1]).toMatchObject({
|
||||
kind: "hotp",
|
||||
counter: 1042n,
|
||||
});
|
||||
expect([...result.profiles[0]!.secret]).toEqual([...profiles[0]!.secret]);
|
||||
});
|
||||
|
||||
it("rejects wrong passwords and unauthenticated changes", async () => {
|
||||
const exported = await exportEncryptedPskc(
|
||||
profiles.slice(0, 1),
|
||||
"correct horse battery staple",
|
||||
{ iterations: 100_000 },
|
||||
);
|
||||
await expect(
|
||||
importEncryptedPskc(exported, "incorrect password"),
|
||||
).rejects.toThrow(/incorrect|damaged/u);
|
||||
const tampered = exported.replace(
|
||||
/<pskc:ValueMAC>([^<])/u,
|
||||
(_match, first: string) => `<pskc:ValueMAC>${first === "A" ? "B" : "A"}`,
|
||||
);
|
||||
await expect(
|
||||
importEncryptedPskc(tampered, "correct horse battery staple"),
|
||||
).rejects.toThrow(/ValueMAC/u);
|
||||
});
|
||||
|
||||
it("rejects XML entity declarations", async () => {
|
||||
await expect(
|
||||
importEncryptedPskc(
|
||||
'<!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><x/>',
|
||||
"password",
|
||||
),
|
||||
).rejects.toThrow(/entity/u);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
importAegisBackup,
|
||||
importEncryptedAegisBackup,
|
||||
importAndOtpBackup,
|
||||
importFreeOtpBackup,
|
||||
importTwoFasBackup,
|
||||
} from "../../src/otp/vendor-backups";
|
||||
import { scrypt } from "@noble/hashes/scrypt.js";
|
||||
import {
|
||||
bytesToArrayBuffer,
|
||||
bytesToHex,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
|
||||
const secret = "JBSWY3DPEHPK3PXP";
|
||||
|
||||
async function encryptGcm(
|
||||
keyBytes: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
bytesToArrayBuffer(keyBytes),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
const output = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: bytesToArrayBuffer(nonce) },
|
||||
key,
|
||||
bytesToArrayBuffer(plaintext),
|
||||
),
|
||||
);
|
||||
return { ciphertext: output.slice(0, -16), tag: output.slice(-16) };
|
||||
}
|
||||
|
||||
function base64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
describe("vendor OTP backup importers", () => {
|
||||
it("imports Aegis plaintext entries and skips non-portable token types", () => {
|
||||
const result = importAegisBackup(
|
||||
@@ -90,4 +123,67 @@ describe("vendor OTP backup importers", () => {
|
||||
importAegisBackup(JSON.stringify({ db: "ciphertext", header: {} })),
|
||||
).toThrow(/encrypted/iu);
|
||||
});
|
||||
|
||||
it("decrypts authenticated Aegis password vaults locally", async () => {
|
||||
const password = "correct horse battery staple";
|
||||
const salt = Uint8Array.from({ length: 16 }, (_, index) => index + 1);
|
||||
const masterKey = Uint8Array.from(
|
||||
{ length: 32 },
|
||||
(_, index) => 255 - index,
|
||||
);
|
||||
const wrappingKey = scrypt(password, salt, {
|
||||
N: 16,
|
||||
r: 8,
|
||||
p: 1,
|
||||
dkLen: 32,
|
||||
maxmem: 1024 * 1024,
|
||||
});
|
||||
const slotNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 20);
|
||||
const dbNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 40);
|
||||
const wrapped = await encryptGcm(wrappingKey, masterKey, slotNonce);
|
||||
const database = utf8ToBytes(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
type: "totp",
|
||||
name: "alice",
|
||||
issuer: "Example",
|
||||
info: { secret, algo: "SHA1", digits: 6, period: 30 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const encrypted = await encryptGcm(masterKey, database, dbNonce);
|
||||
const vault = JSON.stringify({
|
||||
version: 1,
|
||||
header: {
|
||||
slots: [
|
||||
{
|
||||
type: 1,
|
||||
n: 16,
|
||||
r: 8,
|
||||
p: 1,
|
||||
salt: bytesToHex(salt),
|
||||
key: bytesToHex(wrapped.ciphertext),
|
||||
key_params: {
|
||||
nonce: bytesToHex(slotNonce),
|
||||
tag: bytesToHex(wrapped.tag),
|
||||
},
|
||||
},
|
||||
],
|
||||
params: { nonce: bytesToHex(dbNonce), tag: bytesToHex(encrypted.tag) },
|
||||
},
|
||||
db: base64(encrypted.ciphertext),
|
||||
});
|
||||
await expect(
|
||||
importEncryptedAegisBackup(vault, password),
|
||||
).resolves.toMatchObject({
|
||||
profiles: [
|
||||
expect.objectContaining({ account: "alice", issuer: "Example" }),
|
||||
],
|
||||
});
|
||||
await expect(importEncryptedAegisBackup(vault, "wrong")).rejects.toThrow(
|
||||
/incorrect/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { scanQrFromCamera } from "../../src/qr/camera";
|
||||
|
||||
describe("opt-in camera QR scanning", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("requests only video and stops every track after a match", async () => {
|
||||
const stop = vi.fn();
|
||||
const getUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop }],
|
||||
});
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal(
|
||||
"BarcodeDetector",
|
||||
class {
|
||||
detect = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ rawValue: "otpauth://totp/Test" }]);
|
||||
},
|
||||
);
|
||||
const video = document.createElement("video");
|
||||
vi.spyOn(video, "play").mockResolvedValue();
|
||||
vi.spyOn(video, "pause").mockImplementation(() => undefined);
|
||||
Object.defineProperty(video, "readyState", {
|
||||
configurable: true,
|
||||
value: HTMLMediaElement.HAVE_CURRENT_DATA,
|
||||
});
|
||||
await expect(
|
||||
scanQrFromCamera(video, new AbortController().signal),
|
||||
).resolves.toBe("otpauth://totp/Test");
|
||||
expect(getUserMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ audio: false, video: expect.any(Object) }),
|
||||
);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(video.srcObject).toBeNull();
|
||||
});
|
||||
|
||||
it("does not request permission after prior cancellation", async () => {
|
||||
const getUserMedia = vi.fn();
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
scanQrFromCamera(document.createElement("video"), controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
authenticationExtensions,
|
||||
extensionOutputToJson,
|
||||
registrationExtensions,
|
||||
} from "../../src/webauthn/extensions";
|
||||
|
||||
describe("WebAuthn extension inputs", () => {
|
||||
it("builds registration inputs without silently changing binary values", () => {
|
||||
const result = registrationExtensions({
|
||||
credProps: true,
|
||||
appidExclude: "https://legacy.example.test/app-id.json",
|
||||
prf: { enabled: true, first: "base64url:AQID", second: "label" },
|
||||
largeBlob: { registrationSupport: "required" },
|
||||
}) as AuthenticationExtensionsClientInputs & { appidExclude: string };
|
||||
expect(result.credProps).toBe(true);
|
||||
expect(result.appidExclude).toBe("https://legacy.example.test/app-id.json");
|
||||
expect(new Uint8Array(result.prf!.eval!.first as ArrayBuffer)).toEqual(
|
||||
Uint8Array.of(1, 2, 3),
|
||||
);
|
||||
expect(result.largeBlob).toEqual({ support: "required" });
|
||||
});
|
||||
|
||||
it("rejects invalid AppID and conflicting largeBlob operations", () => {
|
||||
expect(() =>
|
||||
registrationExtensions({ appidExclude: "http://unsafe" }),
|
||||
).toThrow(/HTTPS/u);
|
||||
expect(() =>
|
||||
authenticationExtensions({
|
||||
largeBlob: { read: true, write: "payload" },
|
||||
}),
|
||||
).toThrow(/cannot be requested together/u);
|
||||
});
|
||||
|
||||
it("serializes extension buffers for inspection", () => {
|
||||
expect(extensionOutputToJson({ result: Uint8Array.of(1, 2, 3) })).toEqual({
|
||||
result: { base64url: "AQID", bytes: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { liveLabAvailability } from "../../src/webauthn/live";
|
||||
|
||||
describe("live WebAuthn origin isolation", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("rejects the shared Portal origin", () => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
expect(
|
||||
liveLabAvailability({
|
||||
hostname: "toolbox.add-ideas.de",
|
||||
origin: "https://toolbox.add-ideas.de",
|
||||
}),
|
||||
).toMatchObject({ available: false, rpId: "toolbox.add-ideas.de" });
|
||||
});
|
||||
|
||||
it("accepts only the dedicated production host and local development", () => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
expect(
|
||||
liveLabAvailability({
|
||||
hostname: "auth.toolbox.add-ideas.de",
|
||||
origin: "https://auth.toolbox.add-ideas.de",
|
||||
}).available,
|
||||
).toBe(true);
|
||||
expect(
|
||||
liveLabAvailability({
|
||||
hostname: "127.0.0.1",
|
||||
origin: "http://127.0.0.1:4173",
|
||||
}).available,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a secure context on every host", () => {
|
||||
vi.stubGlobal("isSecureContext", false);
|
||||
expect(
|
||||
liveLabAvailability({
|
||||
hostname: "auth.toolbox.add-ideas.de",
|
||||
origin: "http://auth.toolbox.add-ideas.de",
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
reason: "WebAuthn requires a secure context.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareTraces,
|
||||
parseTrace,
|
||||
redactTrace,
|
||||
type AuthenticationTrace,
|
||||
} from "../../src/webauthn/trace";
|
||||
|
||||
function trace(): AuthenticationTrace {
|
||||
return {
|
||||
schema: "de.add-ideas.auth-tools.webauthn-trace",
|
||||
version: 1,
|
||||
kind: "authentication",
|
||||
recordedAt: "2026-08-19T12:00:00.000Z",
|
||||
request: {
|
||||
user: { id: "user-id", name: "Alice", displayName: "Alice Example" },
|
||||
extensions: { prf: true },
|
||||
},
|
||||
expectations: {
|
||||
challenge: "AQID",
|
||||
origin: "https://auth.example.test",
|
||||
rpId: "auth.example.test",
|
||||
},
|
||||
credentialPublicKey: { "1": 2, "3": -7, "-1": 1 },
|
||||
response: {
|
||||
id: "credential-id",
|
||||
rawId: "credential-id",
|
||||
clientDataJSON: "e30",
|
||||
authenticatorData: "AQID",
|
||||
signature: "BAUG",
|
||||
userHandle: "user-handle",
|
||||
clientExtensionResults: { prf: { enabled: true } },
|
||||
},
|
||||
privacy: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebAuthn ceremony traces", () => {
|
||||
it("parses the bounded versioned schema", () => {
|
||||
expect(parseTrace(JSON.stringify(trace()))).toMatchObject({
|
||||
kind: "authentication",
|
||||
version: 1,
|
||||
});
|
||||
expect(() => parseTrace('{"version":2}')).toThrow(/schema/u);
|
||||
let nested: Record<string, unknown> = {};
|
||||
for (let depth = 0; depth < 70; depth += 1) nested = { nested };
|
||||
expect(() =>
|
||||
parseTrace(JSON.stringify({ ...trace(), request: nested })),
|
||||
).toThrow(/depth limit/u);
|
||||
});
|
||||
|
||||
it("redacts labels and top-level identifiers while preserving evidence", async () => {
|
||||
const redacted = await redactTrace(trace());
|
||||
expect(redacted.response.id).toMatch(/^sha256:/u);
|
||||
expect(redacted.response.clientDataJSON).toBe("e30");
|
||||
expect(
|
||||
redacted.kind === "authentication" && redacted.response.userHandle,
|
||||
).toBe("[redacted]");
|
||||
expect((redacted.request.user as Record<string, string>).displayName).toBe(
|
||||
"[redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
it("compares nested trace state", () => {
|
||||
const right = trace();
|
||||
right.expectations.rpId = "other.example.test";
|
||||
right.response.clientExtensionResults = { prf: { enabled: false } };
|
||||
expect(compareTraces(trace(), right).map(({ path }) => path)).toEqual([
|
||||
"expectations.rpId",
|
||||
"response.clientExtensionResults.prf.enabled",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FidoMetadataSnapshot } from "../../src/webauthn/metadata";
|
||||
import {
|
||||
DEFAULT_ATTESTATION_TRUST_POLICY,
|
||||
evaluateMetadataPolicy,
|
||||
} from "../../src/webauthn/trust-policy";
|
||||
|
||||
const AAGUID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
function snapshot(): FidoMetadataSnapshot {
|
||||
return {
|
||||
sequenceNumber: 42,
|
||||
nextUpdate: "2027-01-01",
|
||||
entries: [
|
||||
{
|
||||
aaguid: AAGUID,
|
||||
statusReports: [
|
||||
{ status: "FIDO_CERTIFIED", effectiveDate: "2025-01-01" },
|
||||
{
|
||||
status: "USER_VERIFICATION_BYPASS",
|
||||
effectiveDate: "2026-04-01",
|
||||
authenticatorVersion: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
jwtAlgorithm: "RS256",
|
||||
signerCertificates: 1,
|
||||
signerCertificateChain: ["AA"],
|
||||
signatureVerified: true,
|
||||
trustEstablished: false,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("attestation metadata policy", () => {
|
||||
it("evaluates historical effective dates", () => {
|
||||
const result = evaluateMetadataPolicy({
|
||||
snapshot: snapshot(),
|
||||
aaguid: AAGUID,
|
||||
asOf: "2026-03-01",
|
||||
trustEstablished: true,
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.activeStatusReports.map(({ status }) => status)).toEqual([
|
||||
"FIDO_CERTIFIED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks severe statuses for the selected firmware", () => {
|
||||
const result = evaluateMetadataPolicy({
|
||||
snapshot: snapshot(),
|
||||
aaguid: AAGUID,
|
||||
asOf: "2026-08-19",
|
||||
authenticatorVersion: 7,
|
||||
trustEstablished: true,
|
||||
});
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "Metadata status: USER_VERIFICATION_BYPASS",
|
||||
status: "fail",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows transparent policy overrides", () => {
|
||||
const result = evaluateMetadataPolicy({
|
||||
snapshot: { ...snapshot(), signatureVerified: false },
|
||||
aaguid: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
asOf: "2028-01-01",
|
||||
policy: {
|
||||
...DEFAULT_ATTESTATION_TRUST_POLICY,
|
||||
requireValidBlobSignature: false,
|
||||
requirePinnedTrustRoot: false,
|
||||
requireCurrentSnapshot: false,
|
||||
requireMetadataEntry: false,
|
||||
},
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user