import { encode } from "@suaveplan/cbor"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { buildAuthenticatorData, buildClientDataJSON, buildGenericEnvelope, buildPackedSelfEd25519, buildPackedSelfEs256, FLAG_AT, FLAG_UP, getEcdsaP256TestKey, getEd25519TestKey, } from "../../__fixtures__/index.js"; import { WEBAUTHN_CODES, type WebAuthnError, } from "../../errors/error-codes.js"; import { parseAttestationObject } from "../../parser/attestation-object.js"; import { verifyPackedAttestation } from "./packed.js"; const origin = "https://example.com"; const challenge = new Uint8Array(32).fill(0xaa); function setup() { const key = getEcdsaP256TestKey(); const authData = buildAuthenticatorData({ rpId: "example.com", flags: FLAG_UP | FLAG_AT, credentialId: new Uint8Array(16).fill(0xcc), credentialPublicKey: key.coseKey, }); const clientData = buildClientDataJSON({ type: "webauthn.create", challenge, origin, }); return { key, authData, clientData }; } describe("verifyPackedAttestation (self path)", () => { it("accepts an ES256 self-attestation good vector", { timeout: 5000, }, async () => { const { key, authData, clientData } = setup(); const env = buildPackedSelfEs256({ authData: authData.bytes, clientDataHash: clientData.hash, key, }); const parsed = parseAttestationObject(env); const out = await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); expect(out.attestationType).toBe("self"); expect(out.trustPath).toEqual([]); }); it("accepts an EdDSA self-attestation good vector", { timeout: 5000, }, async () => { const ed = getEd25519TestKey(); const authData = buildAuthenticatorData({ rpId: "example.com", flags: FLAG_UP | FLAG_AT, credentialId: new Uint8Array(16).fill(0xdd), credentialPublicKey: ed.coseKey, }); const clientData = buildClientDataJSON({ type: "webauthn.create", challenge, origin, }); const env = buildPackedSelfEd25519({ authData: authData.bytes, clientDataHash: clientData.hash, key: ed, }); const parsed = parseAttestationObject(env); const out = await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); expect(out.attestationType).toBe("self"); }); it("rejects self-attestation with tampered signature", { timeout: 5000, }, async () => { const { key, authData, clientData } = setup(); const env = buildPackedSelfEs256({ authData: authData.bytes, clientDataHash: clientData.hash, key, tamperSig: true, }); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.SIGNATURE_INVALID ); return; } throw new Error("expected throw"); }); it("rejects self-attestation when rpIdHash mismatches", { timeout: 5000, }, async () => { const { key, authData, clientData } = setup(); const env = buildPackedSelfEs256({ authData: authData.bytes, clientDataHash: clientData.hash, key, }); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: new Uint8Array(32).fill(0xee), }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.RPID_HASH_MISMATCH ); return; } throw new Error("expected throw"); }); it("rejects self-attestation when statement alg disagrees with credential alg", { timeout: 5000, }, async () => { const { key, authData, clientData } = setup(); // Build envelope with alg=-8 but credential is ES256 (-7). const env = buildPackedSelfEs256({ authData: authData.bytes, clientDataHash: clientData.hash, key, alg: -8, }); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.MALFORMED_RESPONSE ); return; } throw new Error("expected throw"); }); it("rejects when credential public key missing", { timeout: 5000, }, async () => { // Authenticator data without AT flag — credentialPublicKey absent. const authData = buildAuthenticatorData({ rpId: "example.com", flags: FLAG_UP, }); const clientData = buildClientDataJSON({ type: "webauthn.create", challenge, origin, }); const attStmt = new Map([ ["alg", -7], ["sig", new Uint8Array(64)], ]); const env = buildGenericEnvelope("packed", attStmt, authData.bytes); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.MALFORMED_RESPONSE ); return; } throw new Error("expected throw"); }); }); describe("verifyPackedAttestation (full path with x5c)", () => { it("accepts and surfaces trustPath on basic attestation", { timeout: 5000, }, async () => { const { key, authData, clientData } = setup(); // Build packed envelope with non-empty x5c — the verifier surfaces // the chain but does not validate it (chain anchoring is caller's // responsibility per the source comment). const attStmt = new Map(); attStmt.set("alg", -7); attStmt.set("sig", new Uint8Array(64)); attStmt.set("x5c", [new Uint8Array(32).fill(7)]); const env = encode( new Map([ ["fmt", "packed"], ["attStmt", attStmt], ["authData", authData.bytes], ]) ); const parsed = parseAttestationObject(env); const out = await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); expect(out.attestationType).toBe("basic"); expect(out.trustPath.length).toBe(1); void key; }); it("rejects when x5c array empty", { timeout: 5000 }, async () => { const { authData, clientData } = setup(); const attStmt = new Map(); attStmt.set("alg", -7); attStmt.set("sig", new Uint8Array(64)); attStmt.set("x5c", []); const env = encode( new Map([ ["fmt", "packed"], ["attStmt", attStmt], ["authData", authData.bytes], ]) ); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: authData.rpIdHash, }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.MALFORMED_RESPONSE ); return; } throw new Error("expected throw"); }); it("rejects rpIdHash mismatch on full attestation", { timeout: 5000, }, async () => { const { authData, clientData } = setup(); const attStmt = new Map(); attStmt.set("alg", -7); attStmt.set("sig", new Uint8Array(64)); attStmt.set("x5c", [new Uint8Array(32)]); const env = encode( new Map([ ["fmt", "packed"], ["attStmt", attStmt], ["authData", authData.bytes], ]) ); const parsed = parseAttestationObject(env); try { await verifyPackedAttestation({ attestationObject: parsed, clientDataHash: clientData.hash, expectedRpIdHash: new Uint8Array(32).fill(0xee), }); } catch (err) { expect((err as WebAuthnError).code).toBe( WEBAUTHN_CODES.RPID_HASH_MISMATCH ); return; } throw new Error("expected throw"); }); }); describe("verifyPackedAttestation (malformed statement)", () => { function buildBadStmt(stmt: Map) { const { authData } = setup(); const env = encode( new Map([ ["fmt", "packed"], ["attStmt", stmt], ["authData", authData.bytes], ]) ); return { env, expectedRpIdHash: authData.rpIdHash }; } it("rejects missing alg", { timeout: 5000 }, async () => { const stmt = new Map([["sig", new Uint8Array(64)]]); const { env, expectedRpIdHash } = buildBadStmt(stmt); const parsed = parseAttestationObject(env); await expect( verifyPackedAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash, }) ).rejects.toThrow(); }); it("rejects missing sig", { timeout: 5000 }, async () => { const stmt = new Map([["alg", -7]]); const { env, expectedRpIdHash } = buildBadStmt(stmt); const parsed = parseAttestationObject(env); await expect( verifyPackedAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash, }) ).rejects.toThrow(); }); it("rejects non-array x5c", { timeout: 5000 }, async () => { const stmt = new Map([ ["alg", -7], ["sig", new Uint8Array(64)], ["x5c", "not-array"], ]); const { env, expectedRpIdHash } = buildBadStmt(stmt); const parsed = parseAttestationObject(env); await expect( verifyPackedAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash, }) ).rejects.toThrow(); }); it("rejects x5c containing a non-bytes element", { timeout: 5000, }, async () => { const stmt = new Map([ ["alg", -7], ["sig", new Uint8Array(64)], ["x5c", ["not-bytes"]], ]); const { env, expectedRpIdHash } = buildBadStmt(stmt); const parsed = parseAttestationObject(env); await expect( verifyPackedAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash, }) ).rejects.toThrow(); }); });