Security - MAC & PIN Blocks
Lesson, slides, and applied problem sets.
View SlidesLesson
Module 6: Security - Message Authentication & PIN Protection
The Stakes Are Real
At 3:17 AM on a Tuesday in 2014, an attacker in Eastern Europe modified a single byte in an ISO8583 message flowing through a payment switch. The byte was in DE4 (amount). The original transaction was $47.50. The modified transaction was $4,750.00. The message passed validation because the switch didn't verify the MAC.
This module covers the cryptographic mechanisms that prevent such attacks: Message Authentication Codes (MAC) and PIN block encryption. These aren't academic exercises—they're the difference between a secure payment system and a catastrophic breach.
Why Cryptography in Payment Messages?
Payment messages travel through hostile territory. Between a terminal in a coffee shop and an issuing bank, a message might traverse:
Terminal → Merchant Router → Acquirer Gateway → Network Switch →
→ Regional Processor → Issuing Bank
At every hop, the message is:
- Transmitted over networks (potentially intercepted)
- Parsed by software (potentially vulnerable)
- Logged for debugging (potentially exposed)
- Handled by operations staff (potentially malicious)
Two threats dominate:
Threat 1: Message Tampering
Original: 0100...DE4=000000004750... ($47.50)
Tampered: 0100...DE4=000000475000... ($4,750.00)
Without integrity protection, any intermediary can modify amounts, routing, or account numbers.
Threat 2: PIN Exposure
If PINs traveled in cleartext:
DE52 = 1234 ← Attacker now has the PIN
The PIN is the cardholder's secret. If exposed, the entire card is compromised.
ISO8583 addresses these with:
- MAC (Message Authentication Code): Detects tampering
- PIN Blocks: Protects PINs with encryption
Part 1: Message Authentication Codes (MAC)
What a MAC Does
A MAC is a cryptographic checksum. Given a message and a secret key, it produces a fixed-size tag that:
- Proves integrity: If any bit of the message changes, the MAC becomes invalid
- Proves authenticity: Only parties with the key can generate valid MACs
- Is not encryption: The message remains readable; the MAC just validates it
Message: 0100723A...DE2=4532...DE4=000000005000...
Key: 0123456789ABCDEF (shared secret)
↓
MAC Algorithm
↓
MAC: A7 B3 2C 4F 9E 1D 8A 55
The MAC travels with the message in DE64 or DE128. The receiver recalculates the MAC using the same key. If it matches, the message is authentic and unmodified.
DE64 vs DE128: Where MACs Live
ISO8583 defines two MAC fields:
DE64 (Message Authentication Code):
- Position: After all other fields in the message
- Size: 8 bytes (64 bits)
- Calculated over: Fields 1-63
DE128 (Secondary MAC):
- Position: Last field in the message
- Size: 8 bytes (64 bits)
- Calculated over: Fields 1-127 (including DE64)
Why two? Some networks use both for layered security:
- DE64: Calculated by the acquirer, verified by the network
- DE128: Calculated by the network, verified by the issuer
In practice, most systems use only DE64.
The Input to MAC Calculation
The MAC is calculated over specific message data. The exact scope varies by network, but typically:
// Common MAC input construction
func BuildMACInput(msg *Message) []byte {
var input bytes.Buffer
// MTI (4 bytes)
input.WriteString(msg.MTI)
// Primary bitmap (8 bytes)
input.Write(msg.PrimaryBitmap)
// All data elements from DE2 to DE63 (or DE127)
// in order, as they appear in the message
for field := 2; field <= 63; field++ {
if msg.HasField(field) {
input.Write(msg.GetFieldBytes(field))
}
}
return input.Bytes()
}
Critical detail: The MAC is calculated on the wire format of the message—the actual bytes transmitted, not the parsed values. Length prefixes, padding, everything.
ANSI X9.9: Single-Length DES MAC
ANSI X9.9 is the original MAC algorithm for financial messages. It uses single DES (56-bit key) in CBC mode.
The Algorithm (CBC-MAC):
Message: M1 | M2 | M3 | M4 | ... | Mn (split into 8-byte blocks)
Key: K (8 bytes, single DES key)
IV: 00 00 00 00 00 00 00 00 (all zeros)
Step 1: E₁ = DES_Encrypt(K, IV ⊕ M1)
Step 2: E₂ = DES_Encrypt(K, E₁ ⊕ M2)
Step 3: E₃ = DES_Encrypt(K, E₂ ⊕ M3)
...
Step n: MAC = DES_Encrypt(K, Eₙ₋₁ ⊕ Mn)
The final output (MAC) is the result of the last DES encryption.
Visual representation:
M1 M2 M3 Mn
│ │ │ │
▼ ▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│XOR│◄─IV │XOR│ │XOR│ │XOR│
└─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘
│ │ │ │
▼ ▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│DES│ │DES│ │DES│ │DES│
│ K │ │ K │ │ K │ │ K │
└─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘
│ │ │ │
└───────────┴───────────┴───────────┴────► MAC (8 bytes)
E₁ E₂ E₃
Padding the Last Block:
If the message isn't a multiple of 8 bytes, pad it:
func PadForMAC(data []byte) []byte {
blockSize := 8
padding := blockSize - (len(data) % blockSize)
if padding == blockSize {
padding = 0 // Already aligned
}
// ISO/IEC 7816-4 padding: 0x80 followed by 0x00s
padded := make([]byte, len(data)+padding)
copy(padded, data)
if padding > 0 {
padded[len(data)] = 0x80
// Remaining bytes are already 0x00
}
return padded
}
Some networks use different padding (zeros, spaces, or 0x00 throughout). Know your network's specification.
X9.9 in Go:
import (
"crypto/des"
)
func CalculateX99MAC(key, data []byte) ([]byte, error) {
if len(key) != 8 {
return nil, errors.New("X9.9 requires 8-byte key")
}
// Create DES cipher
block, err := des.NewCipher(key)
if err != nil {
return nil, err
}
// Pad data to 8-byte boundary
padded := PadForMAC(data)
// CBC-MAC: chain through all blocks
mac := make([]byte, 8)
for i := 0; i < len(padded); i += 8 {
// XOR with previous result (or IV for first block)
for j := 0; j < 8; j++ {
mac[j] ^= padded[i+j]
}
// Encrypt
block.Encrypt(mac, mac)
}
return mac, nil
}
Why X9.9 Is Obsolete:
Single DES has a 56-bit key. Modern computers can brute-force this in hours. The algorithm is retained only for legacy systems. New implementations must use X9.19 or stronger.
ANSI X9.19: Double-Length 3DES MAC (Retail MAC)
ANSI X9.19 (also called "Retail MAC" or "3DES MAC") uses a 16-byte key for stronger security.
The Algorithm:
Message: M1 | M2 | M3 | ... | Mn (8-byte blocks)
Key: K = K1 || K2 (16 bytes = two 8-byte halves)
Steps 1 to n-1: Same as X9.9, using K1
Intermediate = CBC-MAC with K1
Final step (the twist):
Decrypt intermediate with K2
Encrypt result with K1
MAC = DES_Encrypt(K1, DES_Decrypt(K2, Intermediate))
This is called "DES-MAC with DES3 final":
M1 M2 Mn-1 Mn
│ │ │ │
▼ ▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│XOR│◄─IV │XOR│ │XOR│ │XOR│
└─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘
│ │ │ │
▼ ▼ ▼ ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│DES│ │DES│ │DES│ │DES│
│K1 │ │K1 │ │K1 │ │K1 │
└─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘
│ │ │ │
└───────────┴───────────┘ │
▼
┌─────┐
│DES⁻¹│ (Decrypt)
│ K2 │
└──┬──┘
│
▼
┌─────┐
│ DES │ (Encrypt)
│ K1 │
└──┬──┘
│
▼
MAC (8 bytes)
X9.19 in Go:
func CalculateX919MAC(key, data []byte) ([]byte, error) {
if len(key) != 16 {
return nil, errors.New("X9.19 requires 16-byte key")
}
// Split key into two halves
k1 := key[0:8]
k2 := key[8:16]
// Create ciphers
cipher1, _ := des.NewCipher(k1)
cipher2, _ := des.NewCipher(k2)
// Pad data
padded := PadForMAC(data)
// CBC-MAC with K1 through all blocks
intermediate := make([]byte, 8)
for i := 0; i < len(padded); i += 8 {
for j := 0; j < 8; j++ {
intermediate[j] ^= padded[i+j]
}
cipher1.Encrypt(intermediate, intermediate)
}
// Final 3DES step: Decrypt with K2, Encrypt with K1
cipher2.Decrypt(intermediate, intermediate)
cipher1.Encrypt(intermediate, intermediate)
return intermediate, nil
}
Why the Final DES3 Step?
Without it, an attacker who knows the MAC for message M could construct a valid MAC for M || M' without knowing the key. The final triple-DES step breaks this attack.
MAC Verification in Practice
When a message arrives with a MAC, the receiver:
- Extracts DE64 from the message
- Removes DE64 from the MAC input (you don't MAC the MAC)
- Recalculates the MAC using the shared key
- Compares calculated MAC with received MAC
func VerifyMAC(msg *Message, key []byte) (bool, error) {
// Get the received MAC
receivedMAC, err := hex.DecodeString(msg.GetField(64))
if err != nil {
return false, fmt.Errorf("invalid MAC format: %w", err)
}
// Build MAC input (everything except DE64)
input := BuildMACInputExcluding64(msg)
// Calculate expected MAC
expectedMAC, err := CalculateX919MAC(key, input)
if err != nil {
return false, err
}
// Constant-time comparison (prevents timing attacks)
return subtle.ConstantTimeCompare(receivedMAC, expectedMAC) == 1, nil
}
The Constant-Time Comparison:
Never compare MACs with == or bytes.Equal():
// WRONG: Timing attack vulnerable
if receivedMAC == expectedMAC { ... }
// RIGHT: Constant-time comparison
import "crypto/subtle"
if subtle.ConstantTimeCompare(receivedMAC, expectedMAC) == 1 { ... }
Why? Regular comparison exits early on the first mismatched byte. An attacker can measure response times to determine how many bytes match, eventually guessing the entire MAC.
Fields Included in MAC Calculation
Different networks specify different MAC coverage. Common patterns:
Pattern 1: All fields through DE63
MAC covers: MTI + Primary Bitmap + DE2...DE63
DE64 is the MAC itself, not included in calculation
Pattern 2: Selected critical fields only
MAC covers: MTI + DE2 + DE3 + DE4 + DE11 + DE32 + DE38 + DE39
(PAN, Processing Code, Amount, STAN, Acquirer, Auth Code, Response)
Pattern 3: Full message including secondary bitmap
MAC covers: MTI + Primary Bitmap + Secondary Bitmap + DE2...DE127
DE128 is the MAC
Check your network specification. Getting this wrong means every MAC fails.
type MACSpec struct {
Algorithm string // "X9.9" or "X9.19"
IncludedFields []int // Which DEs to include
IncludeMTI bool
IncludeBitmap bool
Padding string // "ISO7816", "ZEROS", "NONE"
}
// Visa-like specification
var VisaMACSpec = MACSpec{
Algorithm: "X9.19",
IncludeMTI: true,
IncludeBitmap: true,
IncludedFields: []int{2, 3, 4, 6, 10, 11, 12, 14, 22, 23, 24, 26,
32, 35, 37, 38, 39, 41, 42, 43, 49, 51, 52, 53},
Padding: "ISO7816",
}
Part 2: PIN Block Formats
The PIN is the cardholder's secret. It must never travel in cleartext, never be stored, and never be visible to any system except the HSM that validates it.
The PIN Block Concept
A PIN block combines:
- The PIN (4-12 digits, typically 4-6)
- Padding (to create a fixed-size block)
- Sometimes the PAN (for additional obfuscation)
The result is 8 bytes (64 bits) that can be encrypted with DES/3DES.
PIN: 1234
↓
PIN Block Format (e.g., ISO-0)
↓
Clear PIN Block: 04 12 34 FF FF FF FF FF (8 bytes)
↓
3DES Encryption with ZPK
↓
Encrypted PIN Block: A7 3B 2C 4F 9E 1D 8A 55 (8 bytes)
↓
Stored in DE52
DE52: PIN Data
DE52 carries the encrypted PIN block:
DE52 Structure:
- Length: 8 bytes (64 bits), binary field
- Content: Encrypted PIN block
- Encoding: Binary (not hex-ASCII)
The PIN block is always encrypted before being placed in DE52. If you ever see a cleartext PIN block in DE52, something is catastrophically wrong.
DE53: Security Related Control Information
DE53 describes how the PIN is protected:
DE53 Structure (commonly 16 characters):
Position Length Content
1-2 2 PIN Block Format (01=ISO-0, 02=ISO-1, etc.)
3-4 2 PIN Encryption Algorithm (01=DES, 02=3DES)
5-6 2 Key Index (which key encrypted this PIN)
7-16 10 Reserved / Network-specific
Example:
DE53 = "0102010000000000"
││││││
││││└┴─ Reserved
│││└── Key Index 01
││└─── Algorithm 02 (3DES)
└┴──── Format 01 (ISO Format 0)
ISO 9564-1 Format 0 (ISO-0, ANSI PIN Block)
The most common format. Combines PIN with PAN for obfuscation.
Construction:
Step 1: Build PIN field (16 hex digits)
0 L P1 P2 P3 P4 F F F F F F F F F F
│ │ └──────────┴────────────────────────────┘
│ │ PIN digits Padding (0xF)
│ └── Length of PIN (4-12)
└── Format identifier (0)
Step 2: Build PAN field (16 hex digits)
0 0 0 0 N1 N2 N3 N4 N5 N6 N7 N8 N9 N10 N11 N12
└───────┴────────────────────────────────────────┘
Zeros 12 rightmost PAN digits (excluding check digit)
Step 3: XOR them
Clear PIN Block = PIN_field XOR PAN_field
Example:
PIN: 1234
PAN: 4532015112830366
PIN field: 0 4 1 2 3 4 F F F F F F F F F F
(0=format, 4=length, 1234=PIN, F=fill)
PAN field: 0 0 0 0 5 0 1 5 1 1 2 8 3 0 3 6
(rightmost 12 digits of PAN, excluding check digit 6)
4532015112830366
└──────────────┘
30112830515 = actually we take positions 1-12 from right
excluding check: 5015112830366 → 5015112830 36 → 501511283036
Let me recalculate:
PAN: 4 5 3 2 0 1 5 1 1 2 8 3 0 3 6 6
^ check digit
12 rightmost excluding check: 5 3 2 0 1 5 1 1 2 8 3 0 3 6
Wait, that's 14 digits. Let me be precise:
PAN = 4532015112830366 (16 digits, last is check digit)
Remove check digit: 453201511283036 (15 digits)
Take rightmost 12: 201511283036
PAN field: 0 0 0 0 2 0 1 5 1 1 2 8 3 0 3 6
Now XOR:
PIN field: 0 4 1 2 3 4 F F F F F F F F F F
PAN field: 0 0 0 0 2 0 1 5 1 1 2 8 3 0 3 6
─────────────────────────────────
XOR result: 0 4 1 2 1 4 E A E E D 7 C F C 9
Clear PIN Block: 04 12 14 EA EE D7 CF C9
Go Implementation:
type ISO0PINBlock struct{}
func (f ISO0PINBlock) Encode(pin, pan string) ([]byte, error) {
if len(pin) < 4 || len(pin) > 12 {
return nil, errors.New("PIN must be 4-12 digits")
}
// Validate PIN is numeric
for _, c := range pin {
if c < '0' || c > '9' {
return nil, errors.New("PIN must be numeric")
}
}
// Build PIN field: 0 | length | PIN | F-padding
pinField := make([]byte, 8)
pinHex := fmt.Sprintf("0%X%s", len(pin), pin)
for len(pinHex) < 16 {
pinHex += "F"
}
hex.Decode(pinField, []byte(pinHex))
// Build PAN field: 0000 | 12 rightmost digits (excluding check)
panDigits := extractPANDigits(pan)
panHex := "0000" + panDigits
panField := make([]byte, 8)
hex.Decode(panField, []byte(panHex))
// XOR
result := make([]byte, 8)
for i := 0; i < 8; i++ {
result[i] = pinField[i] ^ panField[i]
}
return result, nil
}
func extractPANDigits(pan string) string {
// Remove check digit and take rightmost 12
if len(pan) < 13 {
// Pad with zeros on left
return fmt.Sprintf("%012s", pan[:len(pan)-1])
}
// Take positions [len-13 : len-1] (12 digits, excluding check)
return pan[len(pan)-13 : len(pan)-1]
}
func (f ISO0PINBlock) Decode(block []byte, pan string) (string, error) {
if len(block) != 8 {
return "", errors.New("block must be 8 bytes")
}
// Build PAN field
panDigits := extractPANDigits(pan)
panHex := "0000" + panDigits
panField := make([]byte, 8)
hex.Decode(panField, []byte(panHex))
// XOR to recover PIN field
pinField := make([]byte, 8)
for i := 0; i < 8; i++ {
pinField[i] = block[i] ^ panField[i]
}
// Parse PIN field
pinHex := hex.EncodeToString(pinField)
if pinHex[0] != '0' {
return "", errors.New("invalid format identifier")
}
length, _ := strconv.ParseInt(string(pinHex[1]), 16, 8)
if length < 4 || length > 12 {
return "", fmt.Errorf("invalid PIN length: %d", length)
}
pin := pinHex[2 : 2+length]
return pin, nil
}
Why XOR with PAN?
Without the PAN, all cards with PIN "1234" would have the same PIN block:
No XOR: 04 12 34 FF FF FF FF FF (same for everyone with PIN 1234)
With the XOR, each card has a unique PIN block even with the same PIN:
Card A: 04 12 14 EA EE D7 CF C9 (PIN 1234 on this card)
Card B: 04 12 23 B1 A4 8C 2E 71 (PIN 1234 on different card)
This prevents statistical attacks and makes PIN blocks useless without the corresponding PAN.
ISO 9564-1 Format 1 (ISO-1, Random Fill)
Format 1 doesn't use the PAN. Instead, it uses random padding for uniqueness.
Structure (16 hex digits):
1 L P1 P2 P3 P4 R R R R R R R R R R
│ │ └──────────┴────────────────────────────┘
│ │ PIN digits Random fill
│ └── Length of PIN
└── Format identifier (1)
Advantages:
- Doesn't require PAN at point of PIN entry
- Useful for PIN change operations where PAN isn't available
Disadvantages:
- Less obfuscation (same PIN + random could still be attacked statistically)
- Not commonly used
type ISO1PINBlock struct{}
func (f ISO1PINBlock) Encode(pin string) ([]byte, error) {
if len(pin) < 4 || len(pin) > 12 {
return nil, errors.New("PIN must be 4-12 digits")
}
// Build PIN field with random padding
pinHex := fmt.Sprintf("1%X%s", len(pin), pin)
// Add random hex digits
for len(pinHex) < 16 {
pinHex += fmt.Sprintf("%X", rand.Intn(16))
}
result := make([]byte, 8)
hex.Decode(result, []byte(pinHex))
return result, nil
}
ISO 9564-1 Format 2 (ISO-2, Card-Originated)
Format 2 is used when the PIN is entered directly on the card (chip cards with PIN pad).
Structure (16 hex digits):
2 L P1 P2 P3 P4 F F F F F F F F F F
│ │ └──────────┴────────────────────────────┘
│ │ PIN digits Fill (0xF)
│ └── Length of PIN
└── Format identifier (2)
No XOR with PAN (card already knows the PAN)
Used in EMV chip transactions where the card validates the PIN offline.
ISO 9564-1 Format 3 (ISO-3, ANSI X9.8)
Format 3 is similar to Format 0 but with a different format identifier.
Structure (16 hex digits):
3 L P1 P2 P3 P4 F F F F F F F F F F (PIN field)
0 0 0 0 [12 PAN digits excluding check] (PAN field)
Clear PIN Block = PIN_field XOR PAN_field
The only difference from ISO-0 is the format nibble (3 vs 0).
ISO 9564-1 Format 4 (ISO-4, AES-Based)
Format 4 is the modern format using AES encryption instead of DES/3DES.
Block A (128 bits = 16 bytes):
4 L P1 P2...P12 Padding (0xA)
│ │ └────────────────────────┘
│ │ PIN + Padding
│ └── Length
└── Format (4)
Block B (128 bits):
First 12 PAN digits (excluding check) + 0x00000000
Intermediate = Block_A XOR Block_B
PIN Block = AES_Encrypt(Key, Intermediate)
Format 4 is 16 bytes (128 bits) because AES uses 128-bit blocks. This is incompatible with DE52's traditional 8-byte format, so networks using Format 4 may place it elsewhere.
Part 3: PIN Encryption Keys
The Key Hierarchy
Payment systems use a hierarchy of keys to protect PINs:
┌─────────────────┐
│ LMK │ Local Master Key
│ (HSM internal) │ Never leaves the HSM
└────────┬────────┘
│ Encrypts
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ ZMK │ │ TMK │ │ KTK │
│Zone Master│ │Terminal │ │Key Trans│
│ Key │ │Master Key│ │ Key │
└────┬─────┘ └────┬─────┘ └─────────┘
│ │
│ Encrypts │ Encrypts
▼ ▼
┌──────────┐ ┌──────────┐
│ ZPK │ │ TPK │
│Zone PIN │ │Terminal │
│ Key │ │ PIN Key │
└──────────┘ └──────────┘
LMK (Local Master Key):
- Lives only inside the HSM
- Never exportable
- Encrypts all other keys for storage
ZMK (Zone Master Key):
- Shared between two parties (e.g., acquirer and network)
- Used to encrypt working keys for transmission
- Typically exchanged manually via key ceremonies
ZPK (Zone PIN Key):
- Working key for PIN encryption
- Encrypted under ZMK when transmitted
- Changes periodically (daily, weekly, per-session)
TMK (Terminal Master Key):
- Unique to each terminal
- Loaded during terminal initialization
- Encrypts the TPK
TPK (Terminal PIN Key):
- Encrypts PINs at the terminal
- Changed more frequently than TMK
- Downloaded encrypted under TMK
Key Exchange: How ZPKs Are Distributed
1. Bank A and Bank B perform a key ceremony
- Three components of ZMK are shared in person
- Each party's HSM combines components into ZMK
2. Bank A generates a new ZPK in its HSM
- HSM generates random 16-byte key
- Key never exists in cleartext outside HSM
3. Bank A exports ZPK encrypted under ZMK
Encrypted_ZPK = 3DES_Encrypt(ZMK, ZPK)
4. Bank A sends Encrypted_ZPK to Bank B (via secure channel)
5. Bank B imports ZPK
- HSM decrypts using ZMK
- Stores ZPK encrypted under LMK
6. Both parties now share ZPK for PIN translation
PIN Translation: The Critical Operation
When a PIN travels from terminal to issuer, it's re-encrypted at each hop:
Terminal: Acquirer: Network:
PIN + TPK → Block1 Block1 → Decrypt(TPK) Block2 → Decrypt(ZPK1)
→ Re-encrypt(ZPK1) → Re-encrypt(ZPK2)
→ Block2 → Block3
The PIN is never in cleartext—it's always inside an HSM being "translated" from one key to another.
// Pseudocode for PIN translation (happens inside HSM)
func TranslatePIN(encryptedBlock []byte,
sourceKey, destKey []byte,
sourcePAN, destPAN string) ([]byte, error) {
// Decrypt with source key
clearBlock := DES3Decrypt(sourceKey, encryptedBlock)
// Extract PIN using source PAN
pin, _ := ISO0Decode(clearBlock, sourcePAN)
// Re-encode with destination PAN (if changed)
newBlock, _ := ISO0Encode(pin, destPAN)
// Encrypt with destination key
return DES3Encrypt(destKey, newBlock), nil
}
Key Check Values (KCV)
How do you verify a key was loaded correctly without revealing the key?
Key Check Value = First 3 bytes of 3DES_Encrypt(Key, 0x0000000000000000)
func CalculateKCV(key []byte) ([]byte, error) {
zeros := make([]byte, 8)
cipher, err := des.NewTripleDESCipher(key)
if err != nil {
return nil, err
}
encrypted := make([]byte, 8)
cipher.Encrypt(encrypted, zeros)
return encrypted[:3], nil // First 3 bytes
}
// Example:
// Key: 0123456789ABCDEF FEDCBA9876543210
// KCV: 08D7B4
When exchanging keys, both parties compute and compare KCVs to ensure the key arrived correctly.
Part 4: HSM Integration Concepts
What an HSM Does
An HSM (Hardware Security Module) is a tamper-resistant device that:
- Generates keys using true random number generators
- Stores keys encrypted under the LMK
- Performs cryptographic operations without exposing keys
- Enforces access controls on which operations are allowed
PINs and MACs are never calculated in software—they're calculated inside the HSM.
Common HSM Commands
PE: Generate ZPK
Input: ZMK (encrypted under LMK)
Output: ZPK (encrypted under ZMK), KCV
CA: Translate PIN
Input: Encrypted PIN Block, Source ZPK, Dest ZPK
Output: Re-encrypted PIN Block
MS: Generate MAC
Input: Message data, MAC key
Output: 8-byte MAC
MV: Verify MAC
Input: Message data, MAC key, Received MAC
Output: Valid/Invalid
Why This Matters for Implementers
Even if you're writing payment software, you're not writing crypto:
// WRONG: Calculating MAC in application code
mac := CalculateX919MAC(key, data) // Key is in memory!
// RIGHT: Calling HSM to calculate MAC
mac, err := hsm.GenerateMAC(keyReference, data)
// Key never leaves HSM
Your code interacts with HSM APIs, not with raw keys. The key material exists only inside the HSM.
Part 5: Common Attacks and Mitigations
Attack: Statistical PIN Analysis
If an attacker collects thousands of encrypted PIN blocks:
Block1: A7 3B 2C 4F 9E 1D 8A 55 (PIN 1234?)
Block2: A7 3B 2C 4F 9E 1D 8A 55 (Same block = same PIN?)
Block3: B8 2A 4D 3E 7F 0C 6B 44 (Different PIN)
Mitigation: ISO-0's XOR with PAN ensures unique blocks. Also, key rotation limits exposure.
Attack: Key Exhaustion
If an attacker can:
- Submit PINs and observe encrypted blocks
- Compare blocks to determine key material
This is a chosen-plaintext attack on DES.
Mitigation:
- Limit PIN entry attempts
- Change keys frequently
- Use 3DES or AES
Attack: MAC Forgery (Length Extension)
With some MAC algorithms, knowing MAC(M) allows computing MAC(M || X) without the key.
Mitigation: X9.19's final 3DES step prevents this. Never use raw X9.9 for new systems.
Attack: Timing Side Channel
// Vulnerable:
if bytes.Equal(receivedMAC, computedMAC) { // Early exit on mismatch
return true
}
// Attacker measures: 0.1ms = first byte matches
// 0.2ms = first two bytes match
// Eventually guesses entire MAC
Mitigation: Always use constant-time comparison:
import "crypto/subtle"
if subtle.ConstantTimeCompare(receivedMAC, computedMAC) == 1 {
return true
}
Attack: Key Mismatch Exploitation
If two systems disagree on which key to use:
System A: Encrypts PIN with Key_1
System B: Decrypts with Key_2
Result: Garbage, but might leak information
Mitigation:
- Key identification in DE53
- KCV verification before use
- Monitoring for decryption failures
Part 6: Putting It Together
A Complete MAC-Protected Message
0100 MTI
7234448128E18000 Primary Bitmap
165432101234567890 DE2: PAN (16 digits)
000000 DE3: Processing Code
000000005000 DE4: Amount ($50.00)
1229143045 DE7: Transmission DateTime
123456 DE11: STAN
143045 DE12: Local Time
1229 DE13: Local Date
8765432187654321 DE52: PIN Block (encrypted)
0102010000000000 DE53: Security Control Info
A7B32C4F9E1D8A55 DE64: MAC
A Complete Transaction Flow with Security
Terminal:
1. Customer enters PIN on PINpad
2. PINpad encrypts PIN with TPK → Encrypted PIN Block
3. Terminal builds message with DE52 = Encrypted PIN Block
4. Terminal calculates MAC over message → DE64
5. Terminal sends message
Acquirer:
6. Receives message, verifies MAC with ZPK_terminal
7. If MAC invalid → Reject (DE39 = 96)
8. Translates PIN: TPK → ZPK_network
9. Recalculates MAC with ZPK_network → new DE64
10. Forwards to network
Network:
11. Verifies MAC with ZPK_acquirer
12. Translates PIN: ZPK_acquirer → ZPK_issuer
13. Recalculates MAC → new DE64
14. Forwards to issuer
Issuer:
15. Verifies MAC with ZPK_network
16. Decrypts PIN Block
17. Extracts cleartext PIN (inside HSM)
18. Verifies PIN against stored value (inside HSM)
19. Responds with DE39 = 00 (approved) or 55 (wrong PIN)
Debugging Security Failures
When MACs fail or PINs don't verify:
Check 1: Key Synchronization
Both sides calculate KCV:
Side A: KCV = 08D7B4
Side B: KCV = 08D7B4 ✓ Keys match
If KCVs differ, keys are out of sync.
Check 2: MAC Input Scope
Log the exact bytes being MAC'd:
Side A: MTI + Bitmap + DE2..63 = 0100 7234... (1247 bytes)
Side B: MTI + DE2..63 (no bitmap?) = 0100 1654... (1239 bytes)
If byte counts differ, MAC input is wrong.
Check 3: PIN Block Format
DE53 says: Format 01 (ISO-0)
But terminal sent: Format 03 (ISO-3)
Different formats = garbage PIN after decode.
Check 4: PAN Used in PIN Block
If PAN was masked/truncated before XOR:
Terminal: XOR with 4532XXXXXXXX0366
Issuer: XOR with 4532015112830366
Different PANs = wrong PIN extracted.
Real-World Case Study: The Silent Key Rollover
A processor experienced mysterious PIN failures every morning at 2:00 AM.
The Symptom:
2024-12-29 02:00:01 - PIN verification failed, DE39=55
2024-12-29 02:00:02 - PIN verification failed, DE39=55
2024-12-29 02:00:03 - PIN verification failed, DE39=55
...
2024-12-29 02:05:00 - Systems normal, transactions succeeding
The Investigation:
Key management logs showed:
2024-12-29 01:59:55 - Key rotation initiated
2024-12-29 02:00:00 - New ZPK active on issuer
2024-12-29 02:05:00 - New ZPK active on network
The issuer rolled over to a new ZPK at 02:00:00. The network didn't roll over until 02:05:00. During those 5 minutes:
- Network encrypted PINs with old ZPK
- Issuer tried to decrypt with new ZPK
- Result: Wrong PIN (actually: garbage from decryption)
The Root Cause:
No coordinated key rollover mechanism. Each party rolled over independently.
The Fix:
type KeyState struct {
CurrentKey []byte
CurrentKCV []byte
PreviousKey []byte // Keep for grace period
PreviousKCV []byte
RolloverTime time.Time
GracePeriod time.Duration
}
func (ks *KeyState) GetDecryptKey(receivedKCV []byte) ([]byte, error) {
// Try current key first
if bytes.Equal(receivedKCV, ks.CurrentKCV) {
return ks.CurrentKey, nil
}
// During grace period, try previous key
if time.Since(ks.RolloverTime) < ks.GracePeriod {
if bytes.Equal(receivedKCV, ks.PreviousKCV) {
return ks.PreviousKey, nil
}
}
return nil, errors.New("no matching key for KCV")
}
The Lesson:
Key rollover is a distributed systems problem. Both parties must coordinate, and there must be a grace period where both keys are valid.
Common Pitfalls
1. Using DES Instead of 3DES
// WRONG: Single DES (56-bit, crackable)
block, _ := des.NewCipher(key8bytes)
// RIGHT: Triple DES (112-bit effective)
block, _ := des.NewTripleDESCipher(key16bytes)
2. Wrong Byte Order in Keys
// WRONG: Key as string interpreted differently
key := "0123456789ABCDEF" // 16 ASCII bytes?? or hex??
// RIGHT: Explicit hex decode
keyHex := "0123456789ABCDEF0123456789ABCDEF"
key, _ := hex.DecodeString(keyHex) // 16 bytes
3. MAC Calculated on Parsed Values, Not Wire Bytes
// WRONG: MAC on parsed fields
input := msg.PAN + msg.Amount + msg.STAN // String concatenation?
// RIGHT: MAC on raw wire bytes
input := msg.RawBytes[4:1247] // Exact bytes as transmitted
4. Forgetting to XOR with PAN in ISO-0
// WRONG: Just encoding PIN
block := fmt.Sprintf("0%X%s", len(pin), pin) + "FFFFFFFFFF"
// RIGHT: XOR with PAN field
pinField := buildPINField(pin)
panField := buildPANField(pan)
block := xor(pinField, panField)
5. Logging Sensitive Data
// CATASTROPHICALLY WRONG:
log.Printf("PIN block: %X", pinBlock)
log.Printf("MAC key: %X", macKey)
// RIGHT: Log only metadata
log.Printf("PIN block present: %v, length: %d",
msg.HasField(52), len(msg.GetField(52)))
log.Printf("Using MAC key ID: %s, KCV: %X", keyID, kcv)
Summary
Security in ISO8583 has two pillars:
Message Authentication (MAC):
- Protects message integrity and authenticity
- X9.19 (3DES) is the minimum standard
- Calculated over wire-format bytes
- Lives in DE64 or DE128
PIN Protection:
- ISO Format 0 is most common (XOR with PAN)
- Always encrypted with 3DES or AES
- Keys are hierarchical and managed by HSMs
- PIN never exists in cleartext outside secure hardware
The most common failures are:
- Key synchronization issues
- Wrong MAC input scope
- PIN block format mismatches
- Timing attacks from non-constant comparison
When implementing security:
- Never handle raw keys in application code
- Use HSMs for all crypto operations
- Implement key rollover with grace periods
- Log metadata, never secrets
- Test with real HSMs, not software emulation
What's Next
Apply your knowledge:
- mac-calculator: Calculate X9.19 MACs for ISO8583 messages
- mac-verifier: Verify MACs and handle key rotation
- pin-block-parser: Parse and validate PIN block formats
These problems require understanding the crypto fundamentals covered here. Take your time—security is where rushing causes breaches.
Module Items
MAC Calculator
MAC Verifier
PIN Block Parser
Security - MAC & PIN Blocks Quiz
Test your understanding of message authentication codes, PIN block formats, and payment security fundamentals.