Initial Commit
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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<string, unknown>([
|
||||
["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<string, unknown>();
|
||||
attStmt.set("alg", -7);
|
||||
attStmt.set("sig", new Uint8Array(64));
|
||||
attStmt.set("x5c", [new Uint8Array(32).fill(7)]);
|
||||
const env = encode(
|
||||
new Map<string, unknown>([
|
||||
["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<string, unknown>();
|
||||
attStmt.set("alg", -7);
|
||||
attStmt.set("sig", new Uint8Array(64));
|
||||
attStmt.set("x5c", []);
|
||||
const env = encode(
|
||||
new Map<string, unknown>([
|
||||
["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<string, unknown>();
|
||||
attStmt.set("alg", -7);
|
||||
attStmt.set("sig", new Uint8Array(64));
|
||||
attStmt.set("x5c", [new Uint8Array(32)]);
|
||||
const env = encode(
|
||||
new Map<string, unknown>([
|
||||
["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<string, unknown>) {
|
||||
const { authData } = setup();
|
||||
const env = encode(
|
||||
new Map<string, unknown>([
|
||||
["fmt", "packed"],
|
||||
["attStmt", stmt],
|
||||
["authData", authData.bytes],
|
||||
])
|
||||
);
|
||||
return { env, expectedRpIdHash: authData.rpIdHash };
|
||||
}
|
||||
it("rejects missing alg", { timeout: 5000 }, async () => {
|
||||
const stmt = new Map<string, unknown>([["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<string, unknown>([["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<string, unknown>([
|
||||
["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<string, unknown>([
|
||||
["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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user