EMV Protocol: Cryptogram Generation and Offline Authentication
EMV Protocol: Cryptogram Generation and Offline Authentication
EMV (Europay, Mastercard, Visa) is the protocol that governs chip card transactions. When your card’s chip communicates with a terminal, it’s executing a state machine that involves cryptographic authentication, risk assessment, and cryptogram generation — all within the ~500ms window between tap and terminal response.
The diagram above shows the complete EMV transaction flow. Note the clear separation between offline phases (handled entirely between the card and terminal) and the online phase (where the network and issuer are involved). This separation exists because EMV was designed in the 1990s when network connectivity was unreliable — a terminal needed the ability to make authorization decisions locally.
APDU: The Card Communication Protocol
All communication between the terminal and the chip card uses APDU (Application Protocol Data Unit) commands, defined in ISO 7816-4:
from dataclasses import dataclass
@dataclass
class CommandAPDU:
"""
ISO 7816-4 Command APDU structure.
Every command the terminal sends to the card follows this format.
"""
cla: int # Class byte (0x00 for standard EMV)
ins: int # Instruction byte
p1: int # Parameter 1
p2: int # Parameter 2
data: bytes # Command data (optional)
le: int | None # Expected response length (optional)
def serialize(self) -> bytes:
header = bytes([self.cla, self.ins, self.p1, self.p2])
if self.data and self.le is not None:
# Case 4: Command data + expected response
return header + bytes([len(self.data)]) + self.data + bytes([self.le])
elif self.data:
# Case 3: Command data only
return header + bytes([len(self.data)]) + self.data
elif self.le is not None:
# Case 2: Expected response only
return header + bytes([self.le])
else:
# Case 1: No data, no expected response
return header
@dataclass
class ResponseAPDU:
data: bytes
sw1: int # Status word 1
sw2: int # Status word 2
@property
def is_success(self) -> bool:
return self.sw1 == 0x90 and self.sw2 == 0x00
@property
def status_description(self) -> str:
status_codes = {
(0x90, 0x00): "Success",
(0x69, 0x85): "Conditions not satisfied",
(0x6A, 0x82): "Application not found",
(0x6A, 0x81): "Function not supported",
(0x63, 0x00): "Authentication failed",
}
return status_codes.get((self.sw1, self.sw2), f"Unknown: {self.sw1:02X}{self.sw2:02X}")
# EMV APDU Commands
EMV_COMMANDS = {
"SELECT": CommandAPDU(0x00, 0xA4, 0x04, 0x00, b'', 0),
"GET_PROCESSING": CommandAPDU(0x80, 0xA8, 0x00, 0x00, b'', 0),
"READ_RECORD": CommandAPDU(0x00, 0xB2, 0x00, 0x00, b'', 0),
"INTERNAL_AUTH": CommandAPDU(0x00, 0x88, 0x00, 0x00, b'', 0),
"GENERATE_AC": CommandAPDU(0x80, 0xAE, 0x00, 0x00, b'', 0),
"EXTERNAL_AUTH": CommandAPDU(0x00, 0x82, 0x00, 0x00, b'', 0),
}
Phase 1: Application Selection
The terminal first selects which payment application on the card to use. A single chip card can contain multiple applications (Visa credit, Mastercard debit, transit pass):
def select_payment_application(card_reader) -> dict:
"""
EMV application selection using PSE (Payment System Environment)
or direct AID selection.
Step 1: Try PSE — a directory on the card listing all payment apps
Step 2: If PSE fails, try known AIDs directly
"""
# Try PSE first (contact cards)
pse_aid = b'1PAY.SYS.DDF01'
# For contactless: b'2PAY.SYS.DDF01' (PPSE)
select_cmd = CommandAPDU(
cla=0x00, ins=0xA4, p1=0x04, p2=0x00,
data=pse_aid, le=0x00
)
response = card_reader.transmit(select_cmd.serialize())
if response.is_success:
# Parse FCI (File Control Information) to get the AID list
fci = parse_tlv(response.data)
# Tag 84 = DF Name (the AID)
# Tag A5 = FCI Proprietary Template
return fci
# Fallback: try common AIDs directly
KNOWN_AIDS = {
"Visa": bytes.fromhex("A0000000031010"),
"Mastercard": bytes.fromhex("A0000000041010"),
"Amex": bytes.fromhex("A000000025010801"),
"Discover": bytes.fromhex("A0000001523010"),
}
for name, aid in KNOWN_AIDS.items():
select_cmd = CommandAPDU(
cla=0x00, ins=0xA4, p1=0x04, p2=0x00,
data=aid, le=0x00
)
response = card_reader.transmit(select_cmd.serialize())
if response.is_success:
return {"application": name, "aid": aid, "fci": parse_tlv(response.data)}
raise RuntimeError("No supported payment application found on card")
Phase 2: Read Application Data
After selecting the application, the terminal issues GET PROCESSING OPTIONS to initiate the transaction. The card responds with:
- AIP (Application Interchange Profile): Tells the terminal what the card supports (SDA, DDA, CDA, cardholder verification methods)
- AFL (Application File Locator): Lists which files/records the terminal should read
def read_application_data(card_reader, pdol_data: bytes) -> dict:
"""
Initiate application processing and read card data.
PDOL (Processing Options Data Object List) is a list of terminal
data the card requires to initiate processing. The terminal fills
in its values (amount, currency, date, etc.) and sends them.
"""
# GET PROCESSING OPTIONS
gpo_data = b'\x83' + bytes([len(pdol_data)]) + pdol_data
gpo_cmd = CommandAPDU(
cla=0x80, ins=0xA8, p1=0x00, p2=0x00,
data=gpo_data, le=0x00
)
gpo_response = card_reader.transmit(gpo_cmd.serialize())
gpo_parsed = parse_tlv(gpo_response.data)
# Extract AIP and AFL from response
# Tag 77 = Response Message Template Format 2
# Tag 80 = Response Message Template Format 1
aip = gpo_parsed.get("82", b'\x00\x00')
afl = gpo_parsed.get("94", b'')
# Read records specified by AFL
card_data = {}
for i in range(0, len(afl), 4):
sfi = (afl[i] >> 3) & 0x1F
first_record = afl[i + 1]
last_record = afl[i + 2]
num_for_auth = afl[i + 3] # Records used for offline auth
for rec in range(first_record, last_record + 1):
read_cmd = CommandAPDU(
cla=0x00, ins=0xB2, p1=rec,
p2=(sfi << 3) | 0x04, # P2 encodes SFI
data=b'', le=0x00
)
rec_response = card_reader.transmit(read_cmd.serialize())
record_data = parse_tlv(rec_response.data)
card_data.update(record_data)
return card_data
Phase 3: Offline Data Authentication
This is where the terminal verifies that the card is genuine — not a clone. Three mechanisms exist, each with increasing security:
SDA (Static Data Authentication)
The issuer signs a hash of static card data (PAN, expiry, etc.) during card personalization. The terminal verifies this signature using the issuer’s public key (which it validates against the CA’s public key).
Weakness: SDA only proves the card data hasn’t been modified. An attacker can create a perfect clone that replays the same static signature. SDA cards are vulnerable to the “yes card” attack — a modified chip that approves any PIN.
DDA (Dynamic Data Authentication)
The card has its own RSA key pair. During each transaction, the terminal sends an INTERNAL AUTHENTICATE command with a random number, and the card signs it with its private key. This proves the card possesses the private key — a clone without the key cannot produce a valid signature.
CDA (Combined DDA/Application Cryptogram)
CDA combines the DDA dynamic signature with the ARQC generation. The card signs the ARQC cryptogram with its private key, providing both transaction authentication and card authentication in a single operation. This is the most secure mode and is required by most modern payment networks.
def verify_dda(
ca_public_key_modulus: bytes,
ca_public_key_exponent: bytes,
issuer_pk_certificate: bytes,
icc_pk_certificate: bytes,
icc_dynamic_data: bytes,
terminal_unpredictable_number: bytes
) -> bool:
"""
Verify Dynamic Data Authentication (DDA).
Certificate chain:
CA → Issuer Public Key → ICC Public Key → Dynamic Signature
Each certificate is RSA-encrypted data that, when "decrypted"
with the parent's public key, reveals the child's public key
plus a hash for integrity verification.
"""
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Step 1: Recover Issuer Public Key from certificate
# The certificate IS the RSA-encrypted payload
# "Decrypt" with CA public key to recover issuer key
ca_modulus = int.from_bytes(ca_public_key_modulus, 'big')
ca_exponent = int.from_bytes(ca_public_key_exponent, 'big')
# RSA recovery: cert^e mod n
cert_int = int.from_bytes(issuer_pk_certificate, 'big')
recovered = pow(cert_int, ca_exponent, ca_modulus)
recovered_bytes = recovered.to_bytes(len(issuer_pk_certificate), 'big')
# Verify recovered data structure
# Byte 0: 0x6A (header)
# Byte 1: 0x02 (certificate format)
# Bytes 2-5: Issuer identifier
# ... (issuer public key data)
# Last byte: 0xBC (trailer)
if recovered_bytes[0] != 0x6A or recovered_bytes[-1] != 0xBC:
return False
# Step 2: Similarly recover ICC Public Key
# Step 3: Verify dynamic signature
# The card signed: terminal_unpredictable_number + hash_of_dynamic_data
# (Full implementation requires parsing the recovered certificate
# format per EMV Book 2, Table 15)
return True # Simplified — production code verifies each step
Phase 4: ARQC Cryptogram Generation
The Application Request Cryptogram (ARQC) is the chip card’s proof that it authorized this specific transaction. It’s a MAC (Message Authentication Code) computed over transaction-specific data using a key derived from the card’s master key.
import struct
def generate_arqc(
icc_mk: bytes,
atc: int,
amount_authorized: int,
amount_other: int,
terminal_country_code: int,
tvr: bytes,
currency_code: int,
transaction_date: bytes,
transaction_type: int,
unpredictable_number: bytes,
aip: bytes,
cvr: bytes
) -> bytes:
"""
Generate Application Request Cryptogram (ARQC).
The ARQC proves:
1. The card is genuine (only a card with the ICC Master Key can generate this)
2. The transaction data hasn't been tampered with
3. This cryptogram is specific to THIS transaction (via ATC + unpredictable number)
The issuer verifies the ARQC by performing the same computation
using the ICC Master Key it derived from the Issuer Master Key + PAN.
"""
# Step 1: Derive session key from ICC Master Key and ATC
# Session key derivation uses ATC to ensure a unique key per transaction
atc_bytes = struct.pack('>H', atc)
session_key = _derive_session_key(icc_mk, atc_bytes)
# Step 2: Build the ARQC data block
# This is the exact data the card MACs — changing any field
# would produce a different ARQC, which the issuer would reject
arqc_data = bytearray()
arqc_data.extend(struct.pack('>I', amount_authorized)[-6:]) # 6 bytes BCD
arqc_data.extend(struct.pack('>I', amount_other)[-6:])
arqc_data.extend(struct.pack('>H', terminal_country_code))
arqc_data.extend(tvr) # 5 bytes
arqc_data.extend(struct.pack('>H', currency_code))
arqc_data.extend(transaction_date) # 3 bytes YYMMDD
arqc_data.extend(bytes([transaction_type]))
arqc_data.extend(unpredictable_number) # 4 bytes from terminal
arqc_data.extend(aip) # 2 bytes
arqc_data.extend(atc_bytes) # 2 bytes
arqc_data.extend(cvr) # Card verification results
# Step 3: Compute MAC using derived session key
# ISO 9797-1 MAC Algorithm 3 (retail MAC)
arqc = _compute_mac(session_key, bytes(arqc_data))
return arqc
def _derive_session_key(icc_mk: bytes, atc: bytes) -> bytes:
"""
Derive a per-transaction session key using the ATC.
Method: EMV Common Session Key Derivation
Left half: 3DES_encrypt(ICC_MK, ATC || 0xF0 || padding)
Right half: 3DES_encrypt(ICC_MK, ATC || 0x0F || padding)
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
# Build derivation data for left half
left_data = atc + b'\xF0' + b'\x00' * 5
cipher = Cipher(algorithms.TripleDES(icc_mk), modes.ECB(),
backend=default_backend())
enc = cipher.encryptor()
left = enc.update(left_data) + enc.finalize()
# Build derivation data for right half
right_data = atc + b'\x0F' + b'\x00' * 5
cipher = Cipher(algorithms.TripleDES(icc_mk), modes.ECB(),
backend=default_backend())
enc = cipher.encryptor()
right = enc.update(right_data) + enc.finalize()
return left[:8] + right[:8]
def _compute_mac(key: bytes, data: bytes) -> bytes:
"""
ISO 9797-1 MAC Algorithm 3 (Retail MAC).
Process all blocks except the last with single DES (left key half).
Process the last block with full 3DES.
This is faster than full 3DES-CBC-MAC while maintaining security.
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
# Pad data to 8-byte boundary (Method 2: 0x80 then zeros)
padded = data + b'\x80'
while len(padded) % 8 != 0:
padded += b'\x00'
# Single DES CBC with left half of key for all blocks
left_key = key[:8]
iv = b'\x00' * 8
# Process block by block with single DES
current = iv
for i in range(0, len(padded), 8):
block = padded[i:i+8]
xored = bytes(a ^ b for a, b in zip(current, block))
cipher = Cipher(algorithms.TripleDES(key[:8] + key[:8] + key[:8]),
modes.ECB(), backend=default_backend())
enc = cipher.encryptor()
current = (enc.update(xored) + enc.finalize())[:8]
# Final block with full 3DES
cipher = Cipher(algorithms.TripleDES(key), modes.ECB(),
backend=default_backend())
enc = cipher.encryptor()
mac = (enc.update(current) + enc.finalize())[:8]
return mac
The ARQC is unforgeable without the ICC Master Key, transaction-specific (changing even a single cent in the amount produces a completely different cryptogram), and non-replayable (the ATC increments monotonically, and the issuer tracks it — replaying an old ATC is detected immediately).
This is why chip cards are dramatically more secure than magnetic stripe: the stripe’s static data can be copied perfectly, but the chip’s cryptogram cannot be predicted for a future transaction.