Initial Commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user