Files
suaveplan/packages/identity/auth/webauthn/src/attestation/apple/apple.test.ts
2026-05-27 23:47:53 +01:00

201 lines
6.9 KiB
TypeScript

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<string, unknown>, authDataBytes: Uint8Array) {
return parseAttestationObject(
encode(
new Map<string, unknown>([
["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<string, unknown>([["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<string, unknown>([["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<string, unknown>([["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<string, unknown>([["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 })
);
});
});