The one part of 3DS that is fully public: the signing
Unlike CAVV generation, EMV 3DS message signing is a real, documented mechanism: the ACS signs its ARes as a compact JWS (RFC 7515), and the 3DS Server verifies it with the ACS's own public key. That's ordinary ECDSA over a JSON payload — something Web Crypto genuinely supports — so this tool, unlike the other three Payments tools, needs no from-scratch cipher at all.
const acsKeyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);The same generateKey call the ECDSA tool uses elsewhere on this site — there's nothing payments-specific about the cryptography here, only about what gets signed.
Building the compact JWS by hand
A compact JWS is just base64url(header), a dot, base64url(payload), a dot, and base64url(signature), with the signature computed over the ASCII bytes of the first two segments joined by that dot. There's no library hiding this — the tool builds the string directly and signs it with crypto.subtle.sign.
const header = { alg: "ES256", typ: "JOSE" };
const headerB64 = bufToBase64Url(utf8ToBuf(JSON.stringify(header)));
const payloadB64 = bufToBase64Url(utf8ToBuf(JSON.stringify(aresObject)));
const signingInput = `${headerB64}.${payloadB64}`;
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
acsKeyPair.privateKey,
utf8ToBuf(signingInput),
);
const jws = `${signingInput}.${bufToBase64Url(sig)}`;Web Crypto's ECDSA sign() already returns the raw (r ‖ s) signature format JOSE/JWS expects — no DER-to-raw conversion step needed, unlike some other ECDSA ecosystems.
The risk decision this tool simplifies
"ACS: evaluate & sign ARes" uses one deliberately crude rule — amount ≥ 50 triggers a challenge — to stand in for what a real issuer's risk engine actually weighs: device fingerprint, cardholder history, merchant risk indicators, and dozens of other signals the EMV 3DS spec leaves entirely up to the issuer. The transStatus value this tool sets (Y for frictionless, C for challenge) is the real field from the real spec; only the logic that picks it is simplified.
Tamper, then verify
The tamper button edits the decoded ARes payload — flipping transStatus from C to Y, as an attacker trying to silently approve a transaction that should have required a step-up challenge would want to — and re-assembles a JWS using the original signature, which no longer matches the new payload. Verify recomputes the signature check independently and shows exactly where it fails.
const valid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
acsPublicKey,
signatureBytes,
utf8ToBuf(`${headerB64}.${payloadB64}`),
);Same pattern as every other signature scheme on this site: the verifier recomputes over what it actually received and compares, it never decrypts the signature back into anything.