Change PIN [PCI DSS]
This document illustrates the sequence of API calls required to change card PIN for financial institutions that can store ZPK.
This flow applies to any card type or product.
Change PIN [PCI DSS]
Change PIN Flow (ZPK + 3DES)
This guide explains setting 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 Change 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.
- Change the PINs: Submit the encrypted PINs to the server.
Here’s the main entry point of the flow:
public void execPinChangeFlow() {
String jwtToken = TokenUtils.generateToken(); // Step 1
String pan = identifierType.equals("EXID") ? cardLookup(cardId, jwtToken) : cardId; // Step 2
String zpk = getZpkHex(); // Step 3
String oldPinBlock = PINBlockUtils.generatePinBlock(oldPin, pan);
String newPinBlock = PINBlockUtils.generatePinBlock(newPin, pan);
String encryptedOldPinBlock = encryptPinBlockUnderZpk(oldPinBlock, zpk); // Step 4
String encryptedNewPinBlock = encryptPinBlockUnderZpk(newPinBlock, zpk);
pinChange(cardId, encryptedOldPinBlock, encryptedNewPinBlock, 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 PINs
Generate two ISO-0 PIN blocks using the same PAN, Each 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 public key from the PIN certificate.
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: Change the PIN
The pinChange method submits the encrypted PIN to the server.
private void pinChange(String cardId, String encryptedOldPinBlock, String encryptedNewPinBlock, String jwtToken) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String path = "/api/v1/cards/" + cardId + "/change-pin";
URI uri = new URI("https", Constants.basePath, path, null, null);
String body = "{ \"pinBlock\": \"" + encryptedNewPinBlock + "\"currentPinBlock\": \"" + encryptedOldPinBlock + "\"}";
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("accept", "*/*")
.header("accept-language", "en-US,en;q=0.9")
.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("Change PIN failed: " + response.statusCode());
}
}
- Purpose: Submits the encrypted PINs to change it for the card.
- Endpoint:
https://{Constants.basePath}/api/v1/cards/{cardId}/change-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 change.
Running the Code
To execute the flow, use the main method:
public static void main(String[] args) {
ChangePinDemo changePinDemo = new ChangePinDemo();
changePinDemo.execPinChangeFlow();
}
- Execution: Instantiates
ChangePinDemoand runsexecPinChangeFlow().
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