Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
import { encode } from "@suaveplan/cbor";
import { sha256 } from "@suaveplan/crypto-hash";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildAuthenticatorData,
buildSafetynetJws,
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 { verifyAndroidSafetynetAttestation } from "./android-safetynet.js";
function build(stmt: Map<string, unknown>, authDataBytes: Uint8Array) {
return parseAttestationObject(
encode(
new Map<string, unknown>([
["fmt", "android-safetynet"],
["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("verifyAndroidSafetynetAttestation — malformed statement", () => {
it("rejects missing ver field", { timeout: 5000 }, () => {
const ad = authData();
const stmt = new Map<string, unknown>([
["response", new Uint8Array(8)],
]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash: new Uint8Array(32),
expectedRpIdHash: ad.rpIdHash,
})
).toThrow(WebAuthnError);
});
it("rejects missing response bytes", { timeout: 5000 }, () => {
const ad = authData();
const stmt = new Map<string, unknown>([["ver", "1.0"]]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash: new Uint8Array(32),
expectedRpIdHash: ad.rpIdHash,
})
).toThrow(WebAuthnError);
});
it("rejects when JWS payload is unparseable", { timeout: 5000 }, () => {
const ad = authData();
const stmt = new Map<string, unknown>([
["ver", "1.0"],
["response", new TextEncoder().encode("not.a.jws")],
]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash: new Uint8Array(32),
expectedRpIdHash: ad.rpIdHash,
})
).toThrow(WebAuthnError);
});
it("rejects when compact decode fails (wrong dot count)", {
timeout: 5000,
}, () => {
const ad = authData();
const stmt = new Map<string, unknown>([
["ver", "1.0"],
["response", new TextEncoder().encode("only-one-segment")],
]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash: new Uint8Array(32),
expectedRpIdHash: ad.rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
});
describe("verifyAndroidSafetynetAttestation — real JWS", () => {
/** Build an android-safetynet envelope around a real ES256-signed JWS. */
async function buildEnvelope(opts: {
readonly wrongNonce?: boolean;
readonly noX5cInHeader?: boolean;
readonly nonceClaimType?: "number";
readonly missingPayloadJson?: boolean;
}) {
const ad = authData();
const clientDataHash = sha256(
new TextEncoder().encode("safetynet-test-cdh")
);
const correctNonce = sha256(
new Uint8Array([...ad.bytes, ...clientDataHash])
);
const nonce = opts.wrongNonce
? new Uint8Array(32).fill(0x55)
: correctNonce;
const built = await buildSafetynetJws({ nonce });
let compact = built.compact;
if (opts.noX5cInHeader) {
// Reconstruct compact JWS with the protected header stripped of `x5c`.
const headerB64 = "eyJhbGciOiJFUzI1NiJ9"; // {"alg":"ES256"}
const [, payload, signature] = compact.split(".");
compact = `${headerB64}.${payload ?? ""}.${signature ?? ""}`;
}
if (opts.nonceClaimType === "number") {
// Replace payload with one carrying `nonce` as a number (verifier
// requires string).
const headerB64 = compact.split(".")[0] as string;
const sig = compact.split(".")[2] as string;
const replaced = bytesToB64url(
new TextEncoder().encode(
JSON.stringify({
nonce: 12345,
timestampMs: 1700000000000,
ctsProfileMatch: true,
basicIntegrity: true,
})
)
);
compact = `${headerB64}.${replaced}.${sig}`;
}
if (opts.missingPayloadJson) {
const headerB64 = compact.split(".")[0] as string;
const sig = compact.split(".")[2] as string;
// Base64url-encoded `not-json`.
compact = `${headerB64}.bm90LWpzb24.${sig}`;
}
const responseBytes = new TextEncoder().encode(compact);
const stmt = new Map<string, unknown>([
["ver", "14799000"],
["response", responseBytes],
]);
const parsed = build(stmt, ad.bytes);
return { parsed, clientDataHash, rpIdHash: ad.rpIdHash };
}
it("verifies happy path with real JWS + x5c header", {
timeout: 5000,
}, async () => {
const { parsed, clientDataHash, rpIdHash } = await buildEnvelope({});
const result = await verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: rpIdHash,
});
expect(result.trusted).toBe(true);
expect(result.attestationType).toBe("basic");
expect(result.trustPath.length).toBeGreaterThan(0);
});
it("rejects when nonce does not match SHA-256(authData || clientDataHash)", {
timeout: 5000,
}, async () => {
const { parsed, clientDataHash, rpIdHash } = await buildEnvelope({
wrongNonce: true,
});
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
it("rejects when protected header omits x5c", {
timeout: 5000,
}, async () => {
const { parsed, clientDataHash, rpIdHash } = await buildEnvelope({
noX5cInHeader: true,
});
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
it("rejects when nonce claim is not a string", {
timeout: 5000,
}, async () => {
const { parsed, clientDataHash, rpIdHash } = await buildEnvelope({
nonceClaimType: "number",
});
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
it("rejects when payload segment is not JSON", {
timeout: 5000,
}, async () => {
const { parsed, clientDataHash, rpIdHash } = await buildEnvelope({
missingPayloadJson: true,
});
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
it("rejects when x5c header carries a non-string element", {
timeout: 5000,
}, async () => {
const ad = authData();
const clientDataHash = sha256(
new TextEncoder().encode("safetynet-bad-x5c")
);
const nonce = sha256(new Uint8Array([...ad.bytes, ...clientDataHash]));
// Hand-craft a JWS whose protected header includes a non-string x5c
// entry. Signature segment can be arbitrary url-safe bytes because
// the verifier only structurally parses the JWS here.
const headerObj = { alg: "ES256", x5c: [12345] };
const headerB64 = bytesToB64url(
new TextEncoder().encode(JSON.stringify(headerObj))
);
const payloadB64 = bytesToB64url(
new TextEncoder().encode(
JSON.stringify({
nonce: bytesToBase64(nonce),
timestampMs: 1,
ctsProfileMatch: true,
basicIntegrity: true,
})
)
);
const compact = `${headerB64}.${payloadB64}.AAAA`;
const stmt = new Map<string, unknown>([
["ver", "1.0"],
["response", new TextEncoder().encode(compact)],
]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: ad.rpIdHash,
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.MALFORMED_RESPONSE })
);
});
it("rejects rpIdHash mismatch", { timeout: 5000 }, async () => {
const ad = authData();
const clientDataHash = sha256(
new TextEncoder().encode("safetynet-rpid-mismatch")
);
const nonce = sha256(new Uint8Array([...ad.bytes, ...clientDataHash]));
const built = await buildSafetynetJws({ nonce });
const stmt = new Map<string, unknown>([
["ver", "14799000"],
["response", built.responseBytes],
]);
const parsed = build(stmt, ad.bytes);
expect(() =>
verifyAndroidSafetynetAttestation({
attestationObject: parsed,
clientDataHash,
expectedRpIdHash: new Uint8Array(32).fill(0xff),
})
).toThrow(
expect.objectContaining({ code: WEBAUTHN_CODES.RPID_HASH_MISMATCH })
);
});
});
function bytesToB64url(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}