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, authDataBytes: Uint8Array) { return parseAttestationObject( encode( new Map([ ["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([ ["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([["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([ ["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([ ["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([ ["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([ ["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([ ["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); }