364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|