Verify PIN [PCI DSS]
This document illustrates the sequence of API calls required to verify card PIN for financial institutions that can store ZPK.
This flow applies to any card type or product. In the example below, we illustrate a card status change as an action that requires PIN verification before proceeding. However, this action could be replaced with any other operation.
Verify PIN [PCI DSS]
Verify PIN Flow (ZPK + 3DES)
This guide explains verifying a PIN using a ZPK (Zone PIN Key) and Triple DES. The flow generates a JWT, optionally looks up the PAN, obtains the ZPK (e.g., from Vault/keystore/config), builds the ISO-9564-0 PIN block, encrypts it with 3DES/ECB/NoPadding, and submits it.
Prerequisites
- Java Environment: Ensure you have a JDK installed (e.g., JDK 11 or higher).
- Dependencies: The code relies on libraries such as Jackson (
com.fasterxml.jackson.databind), Bouncy Castle (org.bouncycastle), SLF4J for logging, and the Java HTTP Client (java.net.http). - Crypto: Uses the JDK’s
javax.crypto(DESede/ECB/NoPadding)—no extra crypto deps needed. - Constants: A
Constantsclass is assumed to contain values likesecuredSvcUrl,financialId,channelId,clientId,clientSecret,zpk, etc. - Network Access: The code makes HTTPS requests to external APIs (e.g.,
https://apiuat.za.network.global/v1/tokenkc/generate).
Overview of the Verify PIN Flow
The flow consists of the following steps:
- Generate a JWT Token: Authenticate with the server to obtain a token for subsequent requests.
- Card Lookup (Optional): If using an external ID (
EXID), retrieve the Primary Account Number (PAN). - Obtain ZPK: From Vault/keystore/config.
- Encrypt the PIN under ZPK: Generate a PIN block and encrypt it using (3DES/ECB/NoPadding) algorithm.
- Verify the PIN: Submit the encrypted PIN to the server.
Here’s the main entry point of the flow:
public void execPinVerifyFlow() {
String jwtToken = TokenUtils.generateToken(); // Step 1
String pan = identifierType.equals("EXID") ? cardLookup(cardId, jwtToken) : cardId; // Step 2
String zpk = getZpkHex(); // Step 3
String pinBlock = PINBlockUtils.generatePinBlock(pin, pan);
String encryptedPinBlock = encryptPinBlockUnderZpk(pinBlock, zpk); // Step 4
pinVerify(cardId, encryptedPinBlock, jwtToken); // Step 5
}
Step-by-Step Explanation
Step 1: Generate a JWT Token
The TokenUtils.generateToken() method authenticates with an external API to obtain a JWT token, which is used for authorization in subsequent requests.
public static String generateToken() throws URISyntaxException {
HttpClient client = HttpClient.newHttpClient();
Map<String, String> parameters = new HashMap<>();
parameters.put("client_id", Constants.clientId);
parameters.put("client_secret", Constants.clientSecret);
parameters.put("grant_type", "client_credentials");
String form = parameters.entrySet()
.stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
URI uri = new URI("https", Constants.basePath, "/v1/tokenkc/generate", null, null);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("NI-apiuat_za_network_global", "qWyQt3D44Upner1T")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Unexpected status code: " + response.statusCode());
}
return JsonPath.parse(response.body()).read("$.access_token");
}
- Purpose: Obtains an OAuth 2.0
access_tokenusing client credentials. - Endpoint:
https://{Constants.basePath}/v1/tokenkc/generate. - Headers: Includes a custom Akamai header for routing/security.
- Body: URL-encoded form data with
client_id,client_secret, andgrant_type. - Output: A JWT token (e.g.,
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...).
Step 2: Card Lookup (Optional)
If the identifierType is "EXID", the data endpoint retrieves the PAN using the provided cardId.
private String cardLookup(String cardId, String jwtToken) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String path = "/api/v1/cards/" + cardId + "/data?fields=pan";
URI uri = new URI("https", Constants.basePath, path, null, null);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.GET()
.header("accept", "application/json")
.header("Authorization", "Bearer " + jwtToken)
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Card lookup failed: " + response.statusCode());
}
// Example response: { "pan": "1234567890123456" }
JsonNode json = new ObjectMapper().readTree(response.body());
return json.get("pan").asText();
}
- Purpose: Maps an external ID (
EXID) to a PAN. - Endpoint:
https://{Constants.basePath}/api/v1/cards/{cardId}/data. - Headers: Includes JWT token, financial/channel IDs, and a unique reference code.
- Output: The decrypted PAN (e.g.,
1234567890123456).
Note: If identifierType is "CONTRACT_NUMBER", the cardId is assumed to be the PAN, and this step is skipped.
Step 3: Obtain ZPK
The getZpkHex method should fetch ZPK that used to encrypt the PIN, How you fetch is environment-specific; stubbed here:
public static String getZpkHex() {
// TODO: Pull from Vault/keystore/KMS or env/config. Must return 32 hex chars (double-length).
return Constants.zpkHex; // e.g., "0123456789ABCDEFFEDCBA9876543210"
}
- Purpose: Retrieves 16 bytes (32 hex chars) key to be used on encryption of pinBlock.
Step 4: Encrypt the PIN
The PIN is encrypted in two stages:
- Generate a PIN Block: Combine the PIN with the PAN using the ISO-0 PIN block format.
- Encrypt the PIN Block: Use the
ZPKobtained from environment.
Generate PIN Block
public static String generatePinBlock(String pin, String cardNumber) throws Exception {
if (pin.length() < 4 || pin.length() > 6) {
throw new Exception("PIN must be 4-6 digits");
}
String pinBlock = String.format("%s%d%s", "0", pin.length(), pin);
while (pinBlock.length() != 16) {
pinBlock += "F";
}
int cardLen = cardNumber.length();
String pan = "0000" + cardNumber.substring(cardLen - 13, cardLen - 1);
return xorHex(pinBlock, pan);
}
- Input: PIN (e.g.,
"4321") and PAN (e.g.,"1234567890123456"). - Process:
- Format the PIN block:
"0+ PIN length + PIN + padding F’s"(e.g.,"041234FFFFFFFFFF"`). - Extract the last 12 digits of the PAN plus 4 leading zeros (e.g.,
"0000567890123456"). - XOR the two hexadecimal strings.
- Format the PIN block:
- Output: A 16-character hex string (e.g.,
"041DABCD12345678").
Encrypt PIN Block
public static String encryptPinBlockUnderZpk(String pinBlockHex, String zpkHex) {
byte[] clearBlock = hexToBytes(pinBlockHex);
byte[] keyBytes = Hex.decodeStrict(prepareDesKey(zpkHex)); // 24 bytes = K1||K2||K1
try {
SecretKey key = new SecretKeySpec(keyBytes, "DESede");
Cipher cipher = Cipher.getInstance("DESede/ECB/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] ct = cipher.doFinal(clearBlock);
return Hex.toHexString(ct);
} catch (Exception e) {
throw new RuntimeException("3DES encryption failed", e);
}
}
// ---- Helpers ----
// ZPK expected as 32 hex chars (16 bytes). Build 24-byte 3DES key = K1||K2||K1.
private String prepareDesKey(String desKey) {
desKey = desKey.length() == 32 ? desKey : desKey.substring(1);
return desKey.substring(0, 16) + desKey.substring(16, 32) + desKey.substring(0, 16);
}
private static byte[] hexToBytes(String hex) {
if (hex.length() % 2 != 0) throw new IllegalArgumentException("Odd hex length.");
int len = hex.length() / 2;
byte[] out = new byte[len];
for (int i = 0; i < len; i++) {
int hi = Character.digit(hex.charAt(2*i), 16);
int lo = Character.digit(hex.charAt(2*i+1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("Invalid hex.");
out[i] = (byte)((hi << 4) + lo);
}
return out;
}
}
- Input: The PIN block and the
ZPK. - Process: (3DES/ECB/NoPadding) encryption with the ZPK.
- Output: An encrypted hex string (e.g.,
"1A2B3C4D...").
Step 5: Verify the PIN
The pinVerify method submits the encrypted PIN to the server.
private void pinVerify(String cardId, String encryptedPinBlockHex, String jwtToken) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String path = "/api/v1/cards/" + cardId + "/verify-pin";
URI uri = new URI("https", Constants.basePath, path, null, null);
String body = "{ \"pinBlock\": \"" + encryptedPinBlockHex + "\" }";
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("accept", "application/json")
.header("content-type", "application/json")
.header("Authorization", "Bearer " + jwtToken)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Verify PIN failed: " + response.statusCode());
}
}
- Purpose: Submits the encrypted PIN to verify it for the card.
- Endpoint:
https://{Constants.basePath}/api/v1/cards/{cardId}/verify-pin. - Body: JSON payload with
pinBlockwhich is the encrypted pinBlock. - Headers: Includes JWT token and other identifiers.
- Output: A successful response (HTTP 200) indicates the PIN is verified and response indicates if valid or not as following
{"valid": true}
Running the Code
To execute the flow, use the main method:
public static void main(String[] args) {
VerifyPinDemo verifyPinDemo = new VerifyPinDemo();
verifyPinDemo.execPinVerifyFlow();
}
- Execution: Instantiates
VerifyPinDemoand runsexecPinVerifyFlow().
Troubleshooting
- HTTP Errors: Check the response status codes and logs for details.
- Certificate Issues: Ensure the PIN certificate is valid and properly Base64-encoded.
- PIN Length: The PIN must be 4-6 digits, or
generatePinBlockwill throw an exception. - Dependencies: Verify all required libraries are included in your project.
Updated 3 months ago