318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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);
|
|
}
|