import { encode } from "@suaveplan/cbor"; import { sha256 } from "@suaveplan/crypto-hash"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { APPLE_NONCE_OID, buildAuthenticatorData, buildLeafCertWithEcPublicKey, encodeAppleNonceExtensionValue, FLAG_AT, FLAG_UP, getEcdsaP256TestKey, } from "../../__fixtures__/index.js"; import { WEBAUTHN_CODES, WebAuthnError } from "../../errors/error-codes.js"; import { parseAttestationObject } from "../../parser/attestation-object.js"; import { verifyAppleAttestation } from "./apple.js"; function build(stmt: Map, authDataBytes: Uint8Array) { return parseAttestationObject( encode( new Map([ ["fmt", "apple"], ["attStmt", stmt], ["authData", authDataBytes], ]) ) ); } function authData() { const k = getEcdsaP256TestKey(); return buildAuthenticatorData({ rpId: "example.com", flags: FLAG_UP | FLAG_AT, credentialId: new Uint8Array(8), credentialPublicKey: k.coseKey, }); } describe("verifyAppleAttestation — malformed statement", () => { it("rejects non-array x5c", { timeout: 5000 }, () => { const ad = authData(); const stmt = new Map([["x5c", "not-array"]]); const parsed = build(stmt, ad.bytes); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash: ad.rpIdHash, }) ).toThrow(WebAuthnError); }); it("rejects x5c with non-bytes element", { timeout: 5000 }, () => { const ad = authData(); const stmt = new Map([["x5c", ["not-bytes"]]]); const parsed = build(stmt, ad.bytes); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash: ad.rpIdHash, }) ).toThrow(WebAuthnError); }); it("rejects empty x5c", { timeout: 5000 }, () => { const ad = authData(); const stmt = new Map([["x5c", []]]); const parsed = build(stmt, ad.bytes); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash: new Uint8Array(32), expectedRpIdHash: ad.rpIdHash, }) ).toThrow( expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE, }) ); }); }); describe("verifyAppleAttestation — real chain + nonce extension", () => { /** * Build an Apple anonymous-attestation envelope around a leaf certificate * carrying a real Apple-shape nonce extension. The verifier checks * extension presence, decodes the OCTET STRING, compares against * SHA-256(authData || clientDataHash). */ function buildEnvelope(opts: { readonly missingExt?: boolean; readonly wrongNonce?: boolean; readonly extWithoutOctet?: boolean; }) { const cred = getEcdsaP256TestKey(); const ad = authData(); const clientDataHash = sha256( new TextEncoder().encode("apple-test-cdh") ); const nonce = sha256( new Uint8Array([ ...ad.bytes, ...(opts.wrongNonce ? new Uint8Array(32).fill(0xfe) : clientDataHash), ]) ); const pubUncompressed = new Uint8Array(65); pubUncompressed[0] = 0x04; pubUncompressed.set(cred.x, 1); pubUncompressed.set(cred.y, 33); const extValue = opts.extWithoutOctet ? // Replace OCTET STRING tag (0x04) with INTEGER (0x02) so walkForOctet finds nothing. new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x00]) : encodeAppleNonceExtensionValue(nonce); const exts = opts.missingExt ? [] : [{ oid: APPLE_NONCE_OID, value: extValue }]; const { leafDer } = buildLeafCertWithEcPublicKey({ publicKeyUncompressed: pubUncompressed, extensions: exts, }); const stmt = new Map([["x5c", [leafDer]]]); const parsed = build(stmt, ad.bytes); return { parsed, clientDataHash, rpIdHash: ad.rpIdHash }; } it("verifies real leaf cert with Apple nonce extension", { timeout: 5000, }, () => { const { parsed, clientDataHash, rpIdHash } = buildEnvelope({}); const result = verifyAppleAttestation({ attestationObject: parsed, clientDataHash, expectedRpIdHash: rpIdHash, }); expect(result.trusted).toBe(true); expect(result.attestationType).toBe("anonCA"); expect(result.trustPath).toHaveLength(1); }); it("rejects when nonce extension is missing", { timeout: 5000 }, () => { const { parsed, clientDataHash, rpIdHash } = buildEnvelope({ missingExt: true, }); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash, expectedRpIdHash: rpIdHash, }) ).toThrow( expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE }) ); }); it("rejects when nonce in extension does not match SHA-256(authData || clientDataHash)", { timeout: 5000, }, () => { const { parsed, clientDataHash, rpIdHash } = buildEnvelope({ wrongNonce: true, }); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash, expectedRpIdHash: rpIdHash, }) ).toThrow( expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE }) ); }); it("rejects when extension structure carries no inner OCTET STRING", { timeout: 5000, }, () => { const { parsed, clientDataHash, rpIdHash } = buildEnvelope({ extWithoutOctet: true, }); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash, expectedRpIdHash: rpIdHash, }) ).toThrow(WebAuthnError); }); it("rejects rpIdHash mismatch", { timeout: 5000 }, () => { const { parsed, clientDataHash } = buildEnvelope({}); expect(() => verifyAppleAttestation({ attestationObject: parsed, clientDataHash, expectedRpIdHash: new Uint8Array(32).fill(0xff), }) ).toThrow( expect.objectContaining({ code: WEBAUTHN_CODES.RPID_HASH_MISMATCH }) ); }); });