Zero-knowledge proof (ZKP) of a genuine EMV contactless card signing a verifier challenge
A contactless EMV1 card authenticates itself to a payment terminal with an RSA2 certificate chain (offline data authentication3). zkEMV proves that chain in zero knowledge4, so a verifier learns that a genuine card signed its fresh challenge (nonce) without learning any personally identifiable information (PII) like card number (formally known as primary account number or PAN), expiry or CVV. If the verifier requests, the proof also reveals chosen attributes based on first 6-8 digits of the card (formally known as bank identification number or BIN5), such as whether it is a prepaid card or which country the card is issued in, but never the PAN or the BIN itself. The circuits are written in Noir6 and proved with ProveKit7.
flowchart LR
ca["Scheme CA public key<br/>(per scheme, held by verifier)"] -- signs --> issuer["Issuer PK Certificate (90)<br/>(per issuer, stored on card)"]
issuer -- "issuer key signs" --> icc["ICC PK Certificate (9F46)<br/>(per card, stored on card)"]
icc -- "ICC key signs" --> sdad["Signed Dynamic Application Data (9F4B)<br/>(per tap, from card)"]
nonce["Verifier nonce (9F37)"] -- "covered by" --> sdad
Codes in parentheses are EMV tags8. Each arrow is an RSA signature with public exponent 3 and ISO/IEC 9796-29 message recovery: cubing the signature modulo the signer's key yields the signed data itself (for a certificate, the next public key) together with a SHA-110 hash that the circuit recomputes and compares. For each signature the circuit checks the header, trailer, format, algorithm indicators, key length, exponent and embedded hash; for each certificate it also rebuilds the next key. Across the chain, it checks that the issuer identifier matches the PAN11 prefix and that neither certificate has expired.
- Public inputs: CA modulus, BIN table Merkle root, nonce
9F37, current month (YYMM), scope, attributes to disclose; for Visa, also amount9F02and currency5F2A. - Private inputs: the card's certificates, signature and related data; the card's BIN table range and its Merkle path.
- Output: the disclosed BIN attributes, and a nullifier, the Poseidon212 hash of the scope and the card's ICC public key: the same for every proof of one card in one verifier scope, unlinkable across scopes.
- Not checked: CA key and issuer certificate revocation, contents of the signed static data. The
emvcrate checks CA key expiry, outside the circuit.
data/certificate-authority-public-keys.json holds scheme CA public keys, built from community-maintained lists1314, keeping only keys whose published checksum verifies; not authoritative.
The verifier picks the CA keys it trusts; a production deployment of verifier should trust only the official distribution of CA keys.
The scope sets what a verifier can recognise:
| Scope | Nullifier | For |
|---|---|---|
| Unlinkable | none: the prover picks a random scope | card presence, e.g. anti-bot friction |
| Verifier | the same for a card at one verifier | once per card, e.g. a free trial |
| Event | the same for a card at one verifier's event | once per card per event, e.g. a poll |
Both sides derive the scope from the verifier's origin, the prover from the one it authenticated, so a verifier can't ask for another verifier's scope.
A verifier can also ask for attributes based on card's BIN, from a BIN table it trusts, committed to by the root of a Poseidon2 Binary Merkle tree15 of depth 24, one leaf per range. The circuit reads the first 12 PAN digits from the ICC PK Certificate, which the issuer signs, and checks that they fall in a range of that table. Ranges bound those 12 digits, so a range can stand for a 6- or 8-digit BIN or for a narrower part of one. It reveals only the attributes asked for and zero for the others. When none is asked for, the BIN table is not checked against.
| Attribute | Revealed as |
|---|---|
| Country | Issuer country, ISO 3166-1 numeric16 |
| Card type | Credit, debit or prepaid |
| Brand | Its position in the table's brand list, counting from 1; the table names it, e.g. VISA |
| Commercial | Whether the card is issued to a business entity |
data/bin-table.json is such a table, built from a community-maintained BIN list17; not authoritative.
The verifier picks the table it trusts with pinned root; the prover needs that exact table to get a Merkle path to its BIN range.
| Circuit | Scheme | Key widths in bits (CA / issuer / ICC) | Dynamic signature covers |
|---|---|---|---|
visa_fdda |
Visa fDDA | 1984 / 1408 / 1024 | 9F37 ‖ 9F02 ‖ 5F2A ‖ 9F6918 |
mastercard_dda |
Mastercard DDA | 1984 / 1920 / 1152 | 9F37 |
| Tool | Version | Needed for |
|---|---|---|
| GNU Make19 | any | The recipes below |
| Nargo20 | 1.0.0-beta.26 | Compiling and testing the circuits |
| Rust21 | 1.98.0, minimum 1.90 | The emv crate; rustup installs the version pinned in emv/rust-toolchain.toml |
| Python22 | 3.14 | Regenerating fixtures and circuit inputs |
| black23, mypy24 | As in requirements.txt |
Linting and formatting Python sources |
| cargo-criterion25 | 1.1.0 | Benchmarking Rust library crate |
| provekit-cli26 | 1.0.2 | Printing circuit statistics (make circuit-stats) |
| Docker27, adb28 | any | Benchmarking on an arm64 Android device |
make test-circuits # Noir circuit tests
make test-e2e # compiles the circuits, then proves and verifies each synthetic card tap dataGenerated files are committed. To regenerate them:
python3 circuits/scripts/gen_synthetic.py # rewrites circuits/fixtures/ (deterministic)
python3 circuits/scripts/gen_circuit_inputs.py # verifies each fixture natively, writes each circuit's Prover.toml and src/tests/vectors.nrmake examplesRuns one example per scope on visa_fdda circuit, and one disclosing BIN table attributes on both circuits, with a mock card: a synthetic tap re-signed for each challenge under a test CA.
make circuit-stats
make bench
make bench-androidcircuit-stats prints each circuit's R1CS constraint and witness counts, obtained from ProveKit.
visa_fdda
TOTAL CONSTRAINTS: 64,702 (2^15.98)
TOTAL WITNESSES: 112,016 (2^16.77)
mastercard_dda
TOTAL CONSTRAINTS: 93,054 (2^16.51)
TOTAL WITNESSES: 161,794 (2^17.30)The benchmarks prove and verify each circuit's synthetic card tap data.
When benchmarking on an arm64 Android device, already connected over adb, it cross-compiles the benchmark binary with the Android NDK29 inside Docker (Dockerfile.bench-android).
Keep the device's screen on for the whole run, with the screen off, Android throttles the CPU and the captured timings vary several-fold.
| Environment | Circuit | Prove | Verify | Proof size |
|---|---|---|---|---|
| Ubuntu 26.04 on Intel Core i7-1260P with 16 threads and 15GB RAM | visa_fdda |
621 ms | 81 ms | 613.6 KiB |
| Ubuntu 26.04 on Intel Core i7-1260P with 16 threads and 15GB RAM | mastercard_dda |
782 ms | 83 ms | 636.4 KiB |
| Android 16 on Samsung Galaxy S25 Ultra (Snapdragon 8 Elite) with 8 cores and 12GB RAM | visa_fdda |
660 ms | 85 ms | 616.6 KiB |
| Android 16 on Samsung Galaxy S25 Ultra (Snapdragon 8 Elite) with 8 cores and 12GB RAM | mastercard_dda |
823 ms | 91 ms | 636.2 KiB |
Important
None of the devices were connected to direct power during the benchmark experiments.
Licensed under either of Apache License 2.0 (LICENSE-APACHE) or MIT license (LICENSE-MIT), at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
data/bin-table.json is derived from binlist-data17, licensed under CC BY 4.030, and reformatted into PAN prefix ranges.
data/certificate-authority-public-keys.json is compiled from emv-bertlv13 and EFTlab's list of CA public keys14.
Footnotes
-
EMV. Wikipedia. https://en.wikipedia.org/wiki/EMV ↩
-
RSA cryptosystem. Wikipedia. https://en.wikipedia.org/wiki/RSA_cryptosystem ↩
-
EMV Book 2: Security and Key Management. EMVCo. https://www.emvco.com/specifications/book-2-security-and-key-management/ ↩
-
Zero-knowledge proof. Wikipedia. https://en.wikipedia.org/wiki/Zero-knowledge_proof ↩
-
Bank identification number, the PAN's leading digits that identify the issuer. Wikipedia. https://en.wikipedia.org/wiki/Payment_card_number#Issuer_identification_number_(IIN) ↩
-
Noir. https://noir-lang.org ↩
-
ProveKit. https://docs.provekit.org/ ↩
-
ISO/IEC 9796-2:2010, Digital signature schemes giving message recovery, Part 2: Integer factorization based mechanisms. https://www.iso.org/standard/54788.html ↩
-
SHA-1. Wikipedia. https://en.wikipedia.org/wiki/SHA-1 ↩
-
Payment card number. Wikipedia. https://en.wikipedia.org/wiki/Payment_card_number ↩
-
Grassi, Khovratovich, Schofnegger. Poseidon2: A Faster Version of the Poseidon Hash Function. https://eprint.iacr.org/2023/323 ↩
-
emv-bertlv,
ca-public-keys.txt. binaryfoo. https://github.com/binaryfoo/emv-bertlv/blob/master/src/main/resources/ca-public-keys.txt ↩ ↩2 -
List of CA public keys. EFTlab. https://www.eftlab.co.uk/knowledge-base/list-of-ca-public-keys ↩ ↩2
-
Merkle tree. Wikipedia. https://en.wikipedia.org/wiki/Merkle_tree ↩
-
ISO 3166-1 numeric. Wikipedia. https://en.wikipedia.org/wiki/ISO_3166-1_numeric ↩
-
binlist-data. Techbuddie Solutions. https://github.com/Techbuddie-Solutions/binlist-data ↩ ↩2
-
9F69, Card Authentication Related Data: fDDA version number, card unpredictable number and card transaction qualifiers. EMV Book C-3: Kernel 3 Specification. EMVCo. https://www.emvco.com/specifications/book-c-3-kernel-3-specification/ ↩ -
GNU Make. https://www.gnu.org/software/make/ ↩
-
Nargo, the Noir toolchain. https://noir-lang.org/docs ↩
-
rustup, the Rust toolchain installer. https://rustup.rs ↩
-
Python. https://www.python.org ↩
-
Black, the Python code formatter. https://github.com/psf/black ↩
-
mypy, a static type checker for Python. https://mypy-lang.org ↩
-
cargo-criterion. https://github.com/bheisler/cargo-criterion ↩
-
provekit-cli, ProveKit's command-line tool. https://crates.io/crates/provekit-cli ↩
-
Docker. https://www.docker.com ↩
-
Android Debug Bridge. https://developer.android.com/tools/adb ↩
-
Android NDK. https://developer.android.com/ndk ↩
-
Creative Commons Attribution 4.0 International. https://creativecommons.org/licenses/by/4.0/ ↩