Secure Transactions: A Comprehensive Guide to EMV Payment Processing in Java
EMV (Europay, Mastercard, and Visa) has become the global standard for secure credit and debit card transactions. Implementing EMV processing in Java requires understanding complex cryptographic protocols, card data structures, and terminal interaction patterns. This guide explores the architecture, implementation, and security considerations for building EMV-capable payment systems in Java.
Understanding EMV Architecture
EMV transactions involve sophisticated communication between the payment card's chip and the terminal. The process follows a well-defined workflow:
graph TD A[Card Insertion] --> B[Application Selection] B --> C[Initiate Application Processing] C --> D[Read Application Data] D --> E[Offline Data Authentication] E --> F[Cardholder Verification] F --> G[Terminal Risk Management] G --> H[Online Authorization] H --> I[Completion] style E fill:#e1f5fe style F fill:#f3e5f5 style H fill:#fff3e0
Core EMV Components and Data Structures
1. EMV Tag-Length-Value (TLV) Framework
public class EMVTLV {
private final String tag;
private final byte[] value;
private final int length;
public EMVTLV(String tag, byte[] value) {
this.tag = tag;
this.value = value;
this.length = value != null ? value.length : 0;
}
// TLV Parser
public static List<EMVTLV> parseTLV(byte[] data) {
List<EMVTLV> tlvList = new ArrayList<>();
int index = 0;
while (index < data.length) {
// Parse tag (1-2 bytes)
String tag = parseTag(data, index);
index += tag.length() / 2; // Each byte = 2 hex chars
// Parse length
int length = parseLength(data, index);
index += getLengthBytes(data, index);
// Extract value
byte[] value = new byte[length];
System.arraycopy(data, index, value, 0, length);
index += length;
tlvList.add(new EMVTLV(tag, value));
}
return tlvList;
}
private static String parseTag(byte[] data, int index) {
int firstByte = data[index] & 0xFF;
// Check for multi-byte tag
if ((firstByte & 0x1F) == 0x1F) {
// Two-byte tag
return String.format("%02X%02X", firstByte, data[index + 1] & 0xFF);
} else {
// Single-byte tag
return String.format("%02X", firstByte);
}
}
private static int parseLength(byte[] data, int index) {
int firstByte = data[index] & 0xFF;
if (firstByte < 0x80) {
return firstByte;
} else if (firstByte == 0x81) {
return data[index + 1] & 0xFF;
} else if (firstByte == 0x82) {
return ((data[index + 1] & 0xFF) << 8) | (data[index + 2] & 0xFF);
}
throw new IllegalArgumentException("Invalid length format");
}
// Getters
public String getTag() { return tag; }
public byte[] getValue() { return value; }
public int getLength() { return length; }
@Override
public String toString() {
return String.format("Tag: %s, Length: %d, Value: %s",
tag, length, bytesToHex(value));
}
private static String bytesToHex(byte[] bytes) {
if (bytes == null) return "null";
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
}
2. EMV Application Data Structures
public class EMVApplication {
private String aid;
private String label;
private int priority;
private Map<String, EMVTLV> records;
public static final String AID_VISA = "A0000000031010";
public static final String AID_MASTERCARD = "A0000000041010";
public static final String AID_AMEX = "A00000002501";
// Common EMV tags
public static final String TAG_PAN = "5A";
public static final String TAG_EXPIRY_DATE = "5F24";
public static final String TAG_TRACK2_EQUIVALENT = "57";
public static final String TAG_APPLICATION_INTERCHANGE_PROFILE = "82";
public static final String TAG_CRYPTOGRAM_INFORMATION_DATA = "9F27";
public static final String TAG_APPLICATION_TRANSACTION_COUNTER = "9F36";
public static final String TAG_ISSUER_APPLICATION_DATA = "9F10";
public EMVApplication(String aid, String label, int priority) {
this.aid = aid;
this.label = label;
this.priority = priority;
this.records = new HashMap<>();
}
public void addRecord(EMVTLV tlv) {
records.put(tlv.getTag(), tlv);
}
public EMVTLV getRecord(String tag) {
return records.get(tag);
}
public String getPAN() {
EMVTLV panRecord = getRecord(TAG_PAN);
if (panRecord != null) {
byte[] panData = panRecord.getValue();
// PAN format: first digit = 0x0A (padding), then PAN digits as BCD
return decodeBCD(panData);
}
return null;
}
public String getExpiryDate() {
EMVTLV expiryRecord = getRecord(TAG_EXPIRY_DATE);
if (expiryRecord != null) {
byte[] expiryData = expiryRecord.getValue();
return String.format("%02X%02X", expiryData[0] & 0xFF, expiryData[1] & 0xFF);
}
return null;
}
private String decodeBCD(byte[] data) {
StringBuilder sb = new StringBuilder();
for (byte b : data) {
int high = (b >> 4) & 0x0F;
int low = b & 0x0F;
if (high != 0x0A) sb.append(high);
if (low != 0x0A) sb.append(low);
}
return sb.toString();
}
}
EMV Transaction Processing Engine
1. Main Transaction Processor
public class EMVTransactionProcessor {
private TerminalConfiguration terminalConfig;
private CryptographicService cryptoService;
private OnlineAuthorizationService authService;
private EMVCardReader cardReader;
public EMVTransactionProcessor(TerminalConfiguration config) {
this.terminalConfig = config;
this.cryptoService = new CryptographicService();
this.authService = new OnlineAuthorizationService();
this.cardReader = new EMVCardReader();
}
public TransactionResult processTransaction(BigDecimal amount, String currency) {
TransactionContext context = new TransactionContext(amount, currency);
try {
// 1. Card Detection and Connection
if (!cardReader.connectToCard()) {
return TransactionResult.error("Card connection failed");
}
// 2. Application Selection
EMVApplication selectedApp = selectApplication();
if (selectedApp == null) {
return TransactionResult.error("No suitable application found");
}
context.setSelectedApplication(selectedApp);
// 3. Initiate Application Processing
if (!initiateApplicationProcessing(selectedApp, context)) {
return TransactionResult.error("Application initialization failed");
}
// 4. Read Application Data
if (!readApplicationData(selectedApp)) {
return TransactionResult.error("Failed to read application data");
}
// 5. Offline Data Authentication
if (!performOfflineDataAuthentication(selectedApp, context)) {
return TransactionResult.error("Offline authentication failed");
}
// 6. Cardholder Verification
CardholderVerificationResult cvr = performCardholderVerification(selectedApp, context);
if (!cvr.isSuccessful()) {
return TransactionResult.error("Cardholder verification failed: " + cvr.getFailureReason());
}
// 7. Terminal Risk Management
TerminalRiskAssessment riskAssessment = performTerminalRiskManagement(selectedApp, context);
// 8. Online Processing Decision
if (riskAssessment.isOnlineRequired()) {
OnlineAuthorizationResult authResult = performOnlineAuthorization(selectedApp, context);
if (!authResult.isApproved()) {
return TransactionResult.error("Online authorization declined: " + authResult.getResponseText());
}
}
// 9. Transaction Completion
completeTransaction(selectedApp, context);
return TransactionResult.success(context.getTransactionId(),
context.getAmount(),
"Transaction approved");
} catch (EMVException e) {
return TransactionResult.error("EMV processing error: " + e.getMessage());
} finally {
cardReader.disconnectFromCard();
}
}
private EMVApplication selectApplication() throws EMVException {
List<EMVApplication> supportedApplications = Arrays.asList(
new EMVApplication(EMVApplication.AID_VISA, "Visa", 1),
new EMVApplication(EMVApplication.AID_MASTERCARD, "Mastercard", 1),
new EMVApplication(EMVApplication.AID_AMEX, "American Express", 2)
);
// Send SELECT command with PSE (Payment System Environment)
byte[] pseResponse = cardReader.sendAPDU(EMVCommands.SELECT_PSE_COMMAND);
List<EMVTLV> pseData = EMVTLV.parseTLV(pseResponse);
// If PSE fails, try direct application selection
for (EMVApplication app : supportedApplications) {
byte[] selectCommand = EMVCommands.buildSelectCommand(app.getAid());
byte[] response = cardReader.sendAPDU(selectCommand);
if (isSuccessResponse(response)) {
// Parse application data from SELECT response
parseSelectResponse(app, response);
return app;
}
}
return null;
}
private boolean initiateApplicationProcessing(EMVApplication app, TransactionContext context)
throws EMVException {
// Build GET PROCESSING OPTIONS command
byte[] gpoCommand = EMVCommands.buildGetProcessingOptions();
byte[] gpoResponse = cardReader.sendAPDU(gpoCommand);
if (!isSuccessResponse(gpoResponse)) {
return false;
}
// Parse AFL (Application File Locator) from response
List<EMVTLV> gpoData = EMVTLV.parseTLV(gpoResponse);
EMVTLV aflTag = findTLVByTag(gpoData, "94"); // AFL tag
if (aflTag != null) {
context.setAFL(aflTag.getValue());
return true;
}
return false;
}
private boolean readApplicationData(EMVApplication app) throws EMVException {
// Read records based on AFL
byte[] afl = app.getRecord("94").getValue();
for (int i = 0; i < afl.length; i += 4) {
byte sfi = (byte) ((afl[i] & 0xFF) >> 3);
byte firstRecord = afl[i + 1];
byte lastRecord = afl[i + 2];
for (byte recordNum = firstRecord; recordNum <= lastRecord; recordNum++) {
byte[] readCommand = EMVCommands.buildReadRecordCommand(sfi, recordNum);
byte[] recordData = cardReader.sendAPDU(readCommand);
if (isSuccessResponse(recordData)) {
// Parse and store record data
List<EMVTLV> recordTLVs = EMVTLV.parseTLV(recordData);
for (EMVTLV tlv : recordTLVs) {
app.addRecord(tlv);
}
}
}
}
return true;
}
}
2. Cryptographic Services for EMV
public class CryptographicService {
private static final String PROVIDER = "BC"; // Bouncy Castle
private KeyStore terminalKeys;
public CryptographicService() {
Security.addProvider(new BouncyCastleProvider());
loadTerminalKeys();
}
public boolean verifyStaticDataAuthentication(EMVApplication app, byte[] signedData) {
try {
// Get Certificate Authority Public Key
PublicKey caPublicKey = getCAPublicKey(app);
// Extract card's public key from certificate
byte[] certificate = app.getRecord("90").getValue(); // Issuer Public Key Certificate
PublicKey cardPublicKey = extractPublicKeyFromCertificate(certificate, caPublicKey);
// Verify signed static application data
byte[] staticData = buildStaticDataForAuthentication(app);
Signature signature = Signature.getInstance("SHA256withRSA", PROVIDER);
signature.initVerify(cardPublicKey);
signature.update(staticData);
return signature.verify(signedData);
} catch (Exception e) {
System.err.println("SDA verification failed: " + e.getMessage());
return false;
}
}
public boolean performDynamicDataAuthentication(EMVApplication app, TransactionContext context) {
try {
// Generate unpredictable number for DDA
byte[] unpredictableNumber = generateUnpredictableNumber();
context.setUnpredictableNumber(unpredictableNumber);
// Send INTERNAL AUTHENTICATE command
byte[] internalAuthCommand = buildInternalAuthenticateCommand(unpredictableNumber);
byte[] internalAuthResponse = context.getCardReader().sendAPDU(internalAuthCommand);
if (!isSuccessResponse(internalAuthResponse)) {
return false;
}
// Parse and verify dynamic signature
List<EMVTLV> authData = EMVTLV.parseTLV(internalAuthResponse);
EMVTLV signedDynamicData = findTLVByTag(authData, "9F4B");
if (signedDynamicData != null) {
return verifyDynamicSignature(app, unpredictableNumber, signedDynamicData.getValue());
}
return false;
} catch (Exception e) {
System.err.println("DDA verification failed: " + e.getMessage());
return false;
}
}
public ARQCVerificationResult verifyARQC(EMVApplication app, byte[] arqc, TransactionContext context) {
try {
// Reconstruct data for ARQC verification
byte[] dataForAuthentication = buildDataForARQCVerification(app, context);
// Get ICC Master Key for ARQC verification
SecretKey iccMasterKey = deriveICCMasterKey(app);
// Verify ARQC using EMV common session key derivation
boolean arqcValid = verifyARQCWithSessionKey(iccMasterKey, dataForAuthentication, arqc);
if (arqcValid) {
// Generate ARPC for online response
byte[] arpc = generateARPC(iccMasterKey, arqc, context.getAuthorizationResponse());
return new ARQCVerificationResult(true, arpc);
}
return ARQCVerificationResult.failed();
} catch (Exception e) {
System.err.println("ARQC verification failed: " + e.getMessage());
return ARQCVerificationResult.failed();
}
}
private byte[] buildDataForARQCVerification(EMVApplication app, TransactionContext context) {
ByteArrayOutputStream dataStream = new ByteArrayOutputStream();
try {
// Add amount (9F02)
dataStream.write(app.getRecord("9F02").getValue());
// Add other transaction data (9F03, 9F1A, 95, 5F2A, 9A, 9C, etc.)
dataStream.write(app.getRecord("9F03").getValue()); // Amount Other
dataStream.write(app.getRecord("9F1A").getValue()); // Terminal Country Code
dataStream.write(app.getRecord("95").getValue()); // Terminal Verification Results
dataStream.write(app.getRecord("5F2A").getValue()); // Transaction Currency Code
dataStream.write(app.getRecord("9A").getValue()); // Transaction Date
dataStream.write(app.getRecord("9C").getValue()); // Transaction Type
// Add unpredictable number if available
if (context.getUnpredictableNumber() != null) {
dataStream.write(context.getUnpredictableNumber());
}
return dataStream.toByteArray();
} catch (IOException e) {
throw new EMVException("Error building ARQC verification data", e);
}
}
}
3. Cardholder Verification Methods (CVM)
public class CardholderVerificationService {
public CardholderVerificationResult performVerification(EMVApplication app, TransactionContext context) {
try {
// Get CVM List from card
EMVTLV cvmListTag = app.getRecord("8E");
if (cvmListTag == null) {
return CardholderVerificationResult.bypassed("No CVM list present");
}
List<CVMRule> cvmRules = parseCVMList(cvmListTag.getValue());
CVMRule applicableRule = selectApplicableCVRule(cvmRules, context);
if (applicableRule == null) {
return CardholderVerificationResult.bypassed("No applicable CVM rule");
}
switch (applicableRule.getMethod()) {
case ONLINE_PIN:
return performOnlinePINVerification(app, context);
case OFFLINE_PIN_PLAINTEXT:
return performOfflinePINVerification(app, context, false);
case OFFLINE_PIN_ENCRYPTED:
return performOfflinePINVerification(app, context, true);
case SIGNATURE:
return performSignatureVerification(context);
case NO_CVM:
return CardholderVerificationResult.bypassed("No CVM required");
default:
return CardholderVerificationResult.failed("Unsupported CVM method");
}
} catch (Exception e) {
return CardholderVerificationResult.failed("CVM processing error: " + e.getMessage());
}
}
private CardholderVerificationResult performOfflinePINVerification(EMVApplication app,
TransactionContext context,
boolean encrypted) {
try {
// Get PIN from terminal (in real scenario, from PIN pad)
String pin = context.getEnteredPIN();
if (pin == null || pin.length() < 4 || pin.length() > 12) {
return CardholderVerificationResult.failed("Invalid PIN format");
}
// Build VERIFY command
byte[] verifyCommand = buildVerifyCommand(pin, encrypted);
byte[] verifyResponse = context.getCardReader().sendAPDU(verifyCommand);
if (isSuccessResponse(verifyResponse)) {
return CardholderVerificationResult.success("Offline PIN verified");
} else {
return CardholderVerificationResult.failed("PIN verification failed");
}
} catch (Exception e) {
return CardholderVerificationResult.failed("PIN verification error: " + e.getMessage());
}
}
private byte[] buildVerifyCommand(String pin, boolean encrypted) {
ByteArrayOutputStream command = new ByteArrayOutputStream();
try {
// CLA, INS, P1, P2
command.write(0x00); // CLA
command.write(0x20); // INS for VERIFY
command.write(0x00); // P1
command.write(encrypted ? 0x80 : 0x00); // P2
// PIN block
byte[] pinBlock = formatPINBlock(pin, encrypted);
command.write(pinBlock.length); // Lc
command.write(pinBlock); // Data
// Le
command.write(0x00); // Expect 0 bytes in response
return command.toByteArray();
} catch (IOException e) {
throw new EMVException("Error building VERIFY command", e);
}
}
}
// CVM Result and Rule classes
class CardholderVerificationResult {
private final boolean successful;
private final String method;
private final String details;
public static CardholderVerificationResult success(String method) {
return new CardholderVerificationResult(true, method, "Verification successful");
}
public static CardholderVerificationResult failed(String reason) {
return new CardholderVerificationResult(false, "FAILED", reason);
}
public static CardholderVerificationResult bypassed(String reason) {
return new CardholderVerificationResult(true, "BYPASSED", reason);
}
// Getters and constructor
}
class CVMRule {
enum Method {
FAIL_CVM_PROCESSING, PLAINTEXT_PIN_VERIFICATION, ENCRYPTED_PIN_ONLINE,
SIGNATURE, NO_CVM, ENCRYPTED_PIN_OFFLINE
}
private Method method;
private Condition condition;
private int order;
// Getters, setters, constructor
}
Online Authorization and Completion
1. Online Processing
public class OnlineAuthorizationService {
private static final String ACQUIRER_URL = "https://acquirer.example.com/authorize";
public OnlineAuthorizationResult authorizeTransaction(EMVApplication app, TransactionContext context) {
try {
// Build authorization request
AuthorizationRequest authRequest = buildAuthorizationRequest(app, context);
// Generate ARQC (if not already present)
if (context.getARQC() == null) {
byte[] arqc = generateARQC(app, context);
context.setARQC(arqc);
authRequest.setARQC(arqc);
}
// Send to acquirer
AuthorizationResponse authResponse = sendAuthorizationRequest(authRequest);
if (authResponse.isApproved()) {
// Generate ARPC and send to card
byte[] arpc = generateARPC(app, context.getARQC(), authResponse);
sendARPCToCard(arpc, context);
}
return new OnlineAuthorizationResult(authResponse.isApproved(),
authResponse.getAuthCode(),
authResponse.getResponseText());
} catch (Exception e) {
return OnlineAuthorizationResult.error("Authorization failed: " + e.getMessage());
}
}
private AuthorizationRequest buildAuthorizationRequest(EMVApplication app, TransactionContext context) {
AuthorizationRequest request = new AuthorizationRequest();
request.setPan(app.getPAN());
request.setAmount(context.getAmount());
request.setCurrency(context.getCurrency());
request.setTransactionId(context.getTransactionId());
request.setTerminalId(terminalConfig.getTerminalId());
request.setMerchantId(terminalConfig.getMerchantId());
// Add EMV-specific data
request.setApplicationCryptogram(context.getARQC());
request.setApplicationTransactionCounter(getATC(app));
request.setUnpredictableNumber(context.getUnpredictableNumber());
request.setTerminalVerificationResults(getTVR(app));
request.setCardholderVerificationMethodResults(getCVR(app));
return request;
}
private void sendARPCToCard(byte[] arpc, TransactionContext context) throws EMVException {
// Build EXTERNAL AUTHENTICATE command
byte[] extAuthCommand = buildExternalAuthenticateCommand(arpc);
byte[] extAuthResponse = context.getCardReader().sendAPDU(extAuthCommand);
if (!isSuccessResponse(extAuthResponse)) {
throw new EMVException("Failed to send ARPC to card");
}
}
}
2. Transaction Completion
public class TransactionCompletionService {
public void completeTransaction(EMVApplication app, TransactionContext context) throws EMVException {
// Generate final application cryptogram (TC)
byte[] tc = generateTransactionCertificate(app, context);
// Update card records if needed
updateCardData(app, context);
// Generate receipt data
ReceiptData receipt = generateReceiptData(app, context, tc);
context.setTransactionCertificate(tc);
context.setReceiptData(receipt);
logTransaction(app, context);
}
private byte[] generateTransactionCertificate(EMVApplication app, TransactionContext context) {
// Build data for TC generation
byte[] tcData = buildDataForTCGeneration(app, context);
// Generate TC using ICC master key
SecretKey iccMasterKey = deriveICCMasterKey(app);
return generateCryptogram(iccMasterKey, tcData, CryptogramType.TC);
}
private ReceiptData generateReceiptData(EMVApplication app, TransactionContext context, byte[] tc) {
ReceiptData receipt = new ReceiptData();
receipt.setMerchantName(terminalConfig.getMerchantName());
receipt.setTerminalId(terminalConfig.getTerminalId());
receipt.setTransactionId(context.getTransactionId());
receipt.setAmount(context.getAmount());
receipt.setCurrency(context.getCurrency());
receipt.setPan(maskPAN(app.getPAN()));
receipt.setAuthorizationCode(context.getAuthCode());
receipt.setTransactionDate(new Date());
receipt.setApplicationCryptogram(tc);
receipt.setApplicationIdentifier(app.getAid());
return receipt;
}
}
Security and Compliance
1. PCI DSS Compliance Utilities
public class PCISecurityUtils {
public static String maskPAN(String pan) {
if (pan == null || pan.length() < 6) return pan;
int firstSix = 6;
int lastFour = 4;
int maskLength = pan.length() - firstSix - lastFour;
if (maskLength <= 0) return pan;
String firstPart = pan.substring(0, firstSix);
String lastPart = pan.substring(pan.length() - lastFour);
String maskedPart = String.join("", Collections.nCopies(maskLength, "*"));
return firstPart + maskedPart + lastPart;
}
public static byte[] encryptSensitiveData(byte[] data, Key encryptionKey) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC");
// Generate IV
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, spec);
byte[] encrypted = cipher.doFinal(data);
// Combine IV and encrypted data
ByteArrayOutputStream output = new ByteArrayOutputStream();
output.write(iv);
output.write(encrypted);
return output.toByteArray();
}
public static boolean validateCardExpiry(String expiryDate) {
if (expiryDate == null || expiryDate.length() != 4) {
return false;
}
try {
int month = Integer.parseInt(expiryDate.substring(0, 2));
int year = Integer.parseInt(expiryDate.substring(2, 4)) + 2000;
if (month < 1 || month > 12) {
return false;
}
YearMonth expiry = YearMonth.of(year, month);
return !expiry.isBefore(YearMonth.now());
} catch (NumberFormatException e) {
return false;
}
}
}
Testing and Simulation
1. EMV Test Card Simulator
public class EMVTestSimulator {
private Map<String, EMVTestCard> testCards;
public EMVTestSimulator() {
loadTestCards();
}
public TransactionResult simulateTransaction(String cardType, BigDecimal amount) {
EMVTestCard testCard = testCards.get(cardType);
if (testCard == null) {
return TransactionResult.error("Unknown test card: " + cardType);
}
EMVTransactionProcessor processor = new EMVTransactionProcessor(createTestConfig());
// Mock card reader that returns test card responses
MockCardReader mockReader = new MockCardReader(testCard);
processor.setCardReader(mockReader);
return processor.processTransaction(amount, "USD");
}
private void loadTestCards() {
testCards = new HashMap<>();
// Visa test card
EMVTestCard visaCard = new EMVTestCard()
.setAid(EMVApplication.AID_VISA)
.setPan("4111111111111111")
.setExpiryDate("2512")
.setPin("1234")
.setArqcKey(Hex.decode("0123456789ABCDEF0123456789ABCDEF"))
.addRecord("9F27", Hex.decode("80")) // Cryptogram Information Data
.addRecord("9F36", Hex.decode("0001")); // ATC
testCards.put("VISA", visaCard);
// Add other test cards...
}
}
Conclusion
EMV payment processing in Java requires careful implementation of:
- TLV Parsing: Handling EMV's complex data structures
- Cryptographic Operations: RSA, 3DES, and AES for security functions
- Card Communication: APDU command/reponse handling
- Authentication Methods: SDA, DDA, and CDA
- Cardholder Verification: PIN and signature processing
- Online Authorization: ARQC/ARPC generation and verification
Key Security Considerations:
- PCI DSS compliance for sensitive data handling
- Secure key management and storage
- Tamper-resistant terminal configuration
- Comprehensive audit logging
- Regular security updates and patch management
Successful EMV implementation requires thorough testing with certified test cards and validation against EMVCo specifications. While complex, a well-architected EMV processing system provides robust, secure payment handling for modern financial applications.