201 lines
6.9 KiB
TypeScript
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 })
|
|
);
|
|
});
|
|
});
|