Add PIN Block Translate Encrypted; fix CBOR v9 encode; fix EMV MAC tests; fix bcrypt node test

- PIN Block Translate Encrypted: new operation with 5 tests; registered in Payments category
- CBOR v9: fix Encoder streaming/Buffer pool issue; JSDoc on helpers
- EMV Generate MAC: fix empty-input hex parse, stale 3-arg test, missing padding method in verify test
- parseHexBytes: accept empty string as valid 0-byte hex
- bcrypt node test: accept $2a prefix from bcryptjs v2.4.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
J8k3 2026-05-20 19:04:35 -04:00
parent c628207471
commit ffc5fcbf41
7 changed files with 389 additions and 7 deletions

View File

@ -151,7 +151,7 @@ Operations:
- `PIN Data Generate`
- `PIN Data Verify`
> **Note:** Encrypted PIN block translation (decrypt under one incoming zone key, re-encrypt under a different outgoing zone key, as in AWS `TranslatePinData`) is not yet implemented — tracked in issue #4. Use `PIN Block Translate` (section 7) for clear-format-to-format conversion only.
> **Note:** Encrypted PIN block translation is implemented as `PIN Block Translate Encrypted` (section 7). Use `PIN Block Translate` for clear-format-to-format conversion only.
Use this when:
- you want AWS-style PIN-data naming for clear ISO 9564 block flows
@ -170,17 +170,21 @@ Operations:
- `PIN Block Build`
- `PIN Block Parse`
- `PIN Block Translate`
- `PIN Block Translate Encrypted`
Use this when:
- you want the lower-level clear PIN-block tools directly
- `PIN Block Translate Encrypted`: decrypt an encrypted PIN block under an incoming zone key (ZPK/PEK), optionally change format, and re-encrypt under an outgoing zone key — this is the acquirer's core PIN routing operation (issue #17)
Input:
- `PIN Block Build`: clear PIN digits
- `PIN Block Parse`: clear PIN block hex
- `PIN Block Translate`: clear PIN block hex
- `PIN Block Translate Encrypted`: encrypted PIN block hex (8 bytes / 16 hex chars)
Important assumptions:
- current clear-block support is ISO formats `0`, `1`, and `3`
- `PIN Block Translate Encrypted` uses TDES-ECB; accepts 2-key (16-byte) or 3-key (24-byte) keys
## 8) Issuer PIN Verification Helpers
@ -389,6 +393,7 @@ Release guidance: `Publish` = safe with normal guardrails; `Publish with guardra
| `PIN Block Build` | Vendor-aligned | AWS `GeneratePinData`; ISO 9564 | Publish with guardrails |
| `PIN Block Parse` | Vendor-aligned | AWS `VerifyPinData`; ISO 9564 | Publish with guardrails |
| `PIN Block Translate` | Vendor-aligned | AWS `TranslatePinData`; ISO 9564 | Publish with guardrails |
| `PIN Block Translate Encrypted` | Vendor-aligned | AWS `TranslatePinData`; ISO 9564; PCI PIN Req 3-3 | Publish with guardrails |
| `PIN Data Generate` | Vendor-aligned | AWS `GeneratePinData` | Publish with guardrails |
| `PIN Data Verify` | Vendor-aligned | AWS `VerifyPinData` | Publish with guardrails |
| `Payment Calculate KCV` | Verified | NIST SP 800-38B; generic AES/TDES/HMAC primitives | Publish |

View File

@ -611,6 +611,7 @@
"PIN Block Build",
"PIN Block Parse",
"PIN Block Translate",
"PIN Block Translate Encrypted",
"PIN Data Generate",
"PIN Data Verify",
"PIN Generate",

View File

@ -16,7 +16,7 @@ import { toHexFast } from "./Hex.mjs";
*/
function parseHexBytes(input, name, allowedLengths=[]) {
const normalized = (input || "").replace(/\s+/g, "");
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) {
if (!/^[0-9a-fA-F]*$/.test(normalized) || normalized.length % 2 !== 0) {
throw new OperationError(`${name} must be hex.`);
}

View File

@ -7,6 +7,73 @@
import Operation from "../Operation.mjs";
import Cbor from "cbor";
// cbor v9: Encoder.encode/encodeCanonical return only the first byte.
// Pre-sort map keys ourselves and use a custom Map semantic type so the
// encoder writes keys in insertion order without re-sorting internally.
/**
* Returns the byte-length of a CBOR-encoded text string key (header + payload).
* Used to implement RFC 7049 canonical map key ordering.
*
* @param {string} s
* @returns {number}
*/
function cborKeyEncodedLen(s) {
const n = Buffer.byteLength(s, "utf8");
if (n < 24) return 1 + n;
if (n < 0x100) return 2 + n;
if (n < 0x10000) return 3 + n;
return 5 + n;
}
/**
* Recursively converts plain objects to pre-sorted Maps so that the CBOR
* encoder emits keys in canonical (length-first, then lexicographic) order
* without relying on the cbor library's own canonical sort, which is broken
* in cbor v9 for streamed output.
*
* @param {*} val
* @returns {*}
*/
function prepareCBOR(val) {
if (Array.isArray(val)) return val.map(prepareCBOR);
if (val !== null && typeof val === "object" && !(val instanceof Map)) {
const sorted = Object.keys(val).sort((a, b) => {
const la = cborKeyEncodedLen(a), lb = cborKeyEncodedLen(b);
if (la !== lb) return la - lb;
return Buffer.from(a, "utf8").compare(Buffer.from(b, "utf8"));
});
return new Map(sorted.map(k => [k, prepareCBOR(val[k])]));
}
return val;
}
/**
* Encodes a value as canonical CBOR using a streaming Encoder.
* Returns a Promise that resolves to a Buffer containing the full encoding.
*
* @param {*} input
* @returns {Promise<Buffer>}
*/
function cborEncodeCanonical(input) {
return new Promise((resolve, reject) => {
const enc = new Cbor.Encoder({canonical: true});
enc.addSemanticType(Map, (e, m) => {
if (!e._pushInt(m.size, 5)) return false;
for (const [k, v] of m) {
if (!e.pushAny(k) || !e.pushAny(v)) return false;
}
return true;
});
const bufs = [];
enc.on("data", b => bufs.push(b));
enc.on("error", reject);
enc.on("finish", () => resolve(Buffer.concat(bufs)));
enc.pushAny(prepareCBOR(input));
enc.end();
});
}
/**
* CBOR Encode operation
*/
@ -32,8 +99,9 @@ class CBOREncode extends Operation {
* @param {Object[]} args
* @returns {ArrayBuffer}
*/
run(input, args) {
return new Uint8Array(Cbor.encodeCanonical(input)).buffer;
async run(input, args) {
const buf = await cborEncodeCanonical(input);
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
}

View File

@ -0,0 +1,222 @@
/**
* @license Apache-2.0
* @author Jacob Marks [https://jacobmarks.com]
*/
import forge from "node-forge";
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { PIN_BLOCK_FORMATS, buildPinBlock, parsePinBlock } from "../lib/PinBlock.mjs";
// ── Crypto helpers ────────────────────────────────────────────────────────────
/**
* Validates and normalises a TDES key hex string.
* Accepts 16-byte (2-key) or 24-byte (3-key) TDES.
*
* @param {string} hex
* @param {string} label
* @returns {string} normalised uppercase hex, always 24 bytes (48 hex chars)
*/
function normaliseTdesKey(hex, label) {
const h = (hex || "").replace(/\s+/g, "").toUpperCase();
if (!/^[0-9A-F]+$/.test(h)) throw new OperationError(`${label} must be hex.`);
if (h.length === 32) return h + h.slice(0, 16); // expand 2-key to 3-key
if (h.length === 48) return h;
throw new OperationError(`${label} must be 16 bytes (32 hex chars) or 24 bytes (48 hex chars).`);
}
/**
* Converts a hex string to a forge binary string.
*
* @param {string} hex
* @returns {string}
*/
function hexToForgeBin(hex) {
return forge.util.hexToBytes(hex.toLowerCase());
}
/**
* Encrypts one 8-byte block with 3DES-ECB.
*
* @param {string} key48hex 24-byte key as 48 uppercase hex chars
* @param {string} block16hex 8-byte block as 16 uppercase hex chars
* @returns {string} 16 uppercase hex chars
*/
function tdesEcbEncrypt(key48hex, block16hex) {
const cipher = forge.cipher.createCipher("3DES-ECB", hexToForgeBin(key48hex));
cipher.mode.pad = () => true;
cipher.start();
cipher.update(forge.util.createBuffer(hexToForgeBin(block16hex)));
cipher.finish();
return forge.util.bytesToHex(cipher.output.getBytes()).toUpperCase().slice(0, 16);
}
/**
* Decrypts one 8-byte block with 3DES-ECB.
*
* @param {string} key48hex
* @param {string} block16hex
* @returns {string} 16 uppercase hex chars
*/
function tdesEcbDecrypt(key48hex, block16hex) {
const decipher = forge.cipher.createDecipher("3DES-ECB", hexToForgeBin(key48hex));
decipher.mode.pad = () => true;
decipher.start();
decipher.update(forge.util.createBuffer(hexToForgeBin(block16hex)));
decipher.finish();
return forge.util.bytesToHex(decipher.output.getBytes()).toUpperCase().slice(0, 16);
}
// ── Operation ─────────────────────────────────────────────────────────────────
/**
* PIN Block Translate Encrypted operation
*/
class TranslatePINBlockEncrypted extends Operation {
/**
* TranslatePINBlockEncrypted constructor
*/
constructor() {
super();
this.name = "PIN Block Translate Encrypted";
this.module = "Payment";
this.description = [
"Decrypt an encrypted PIN block under an incoming zone key (ZPK / PEK),",
" optionally change the PIN block format, and re-encrypt under an outgoing zone key.",
" The clear PIN is never present in the output — only the re-encrypted block is returned.",
"<br><br>",
"This is the acquirer's core PIN routing operation.",
" It corresponds to <code>TranslatePinData</code> in AWS Payment Cryptography and to the",
" <code>CA</code> / <code>CC</code> command family on Thales payShield.",
"<br><br>",
"<b>Input:</b> encrypted PIN block as hex (8 bytes / 16 hex chars).",
"<br>",
"<b>Key algorithm:</b> TDES — key must be 16 bytes (2-key TDES, 32 hex chars) or",
" 24 bytes (3-key TDES, 48 hex chars).",
" 2-key input is automatically expanded to 3-key (K3 = K1).",
"<br><br>",
"Supported formats: ISO Format 0, ISO Format 1, ISO Format 3.",
" ISO Format 4 (AES, 16-byte block) is not yet supported.",
"<br><br>",
"<b>PCI PIN requirement:</b> the cardholder PAN must not change between incoming and",
" outgoing formats (PCI PIN Security Req 3-3).",
" Supplying a different PAN for the target format is permitted only when the target",
" format does not use PAN binding (Format 1).",
].join("");
this.inlineHelp = [
"<strong>Input:</strong> encrypted PIN block hex.",
"<strong>Args:</strong> incoming ZPK/PEK, incoming format and PAN;",
" outgoing ZPK/PEK, outgoing format and PAN.",
].join(" ");
this.testDataSamples = [
{
name: "TDES ZPK-to-ZPK, same format",
input: "7F381DBF9F6906C4",
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890",
"0123456789ABCDEFFEDCBA9876543210", "ISO Format 0", "5432101234567890", false]
}
];
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Incoming key (TDES hex)",
type: "string",
value: "",
comment: "Zone PIN Key (ZPK) or PIN Encryption Key (PEK) used to decrypt the incoming block. 16 bytes (32 hex) for 2-key TDES or 24 bytes (48 hex) for 3-key TDES."
},
{
name: "Incoming format",
type: "option",
value: PIN_BLOCK_FORMATS,
comment: "ISO 9564 format of the incoming encrypted block."
},
{
name: "Incoming PAN",
type: "string",
value: "",
comment: "Primary account number — required when the incoming format is 0 or 3. The implementation uses the rightmost 12 digits excluding the check digit."
},
{
name: "Outgoing key (TDES hex)",
type: "string",
value: "",
comment: "Zone PIN Key (ZPK) or PIN Encryption Key (PEK) used to encrypt the outgoing block. Same key-length rules as the incoming key."
},
{
name: "Outgoing format",
type: "option",
value: PIN_BLOCK_FORMATS,
comment: "ISO 9564 format of the outgoing encrypted block."
},
{
name: "Outgoing PAN",
type: "string",
value: "",
comment: "Required when the outgoing format is 0 or 3. Per PCI PIN Req 3-3, this must equal the incoming PAN when both formats use PAN binding."
},
{
name: "Output as JSON",
type: "boolean",
value: false,
comment: "When enabled, returns the intermediate values (incoming clear block, outgoing clear block) along with the final encrypted block. Use for debugging only — do not expose clear PIN block values in production."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [inKeyHex, inFormat, inPan, outKeyHex, outFormat, outPan, outputJson] = args;
const encIn = (input || "").replace(/\s+/g, "").toUpperCase();
if (!/^[0-9A-F]{16}$/.test(encIn)) {
throw new OperationError("Encrypted PIN block must be 16 hex characters (8 bytes).");
}
const inKey = normaliseTdesKey(inKeyHex, "Incoming key");
const outKey = normaliseTdesKey(outKeyHex, "Outgoing key");
// Decrypt incoming encrypted block → clear PIN block
const clearIn = tdesEcbDecrypt(inKey, encIn);
// Parse the clear block to recover the PIN
const parsed = parsePinBlock(inFormat, clearIn, inPan);
// Re-encode in the target format
const clearOut = buildPinBlock(outFormat, parsed.pin, outPan, false);
// Re-encrypt under the outgoing key
const encOut = tdesEcbEncrypt(outKey, clearOut);
if (outputJson) {
return JSON.stringify({
incoming: {
format: inFormat,
pan: inPan || null,
encryptedBlockHex: encIn,
clearBlockHex: clearIn,
},
pin: parsed.pin,
outgoing: {
format: outFormat,
pan: outPan || null,
clearBlockHex: clearOut,
encryptedBlockHex: encOut,
},
}, null, 4);
}
return encOut;
}
}
export default TranslatePINBlockEncrypted;

View File

@ -136,7 +136,7 @@ Tiger-128`;
it("Bcrypt", async () => {
const result = await chef.bcrypt("Put a Sock In It");
const strResult = result.toString();
assert.match(strResult, /^\$2b\$10\$[./A-Za-z0-9]{53}$/);
assert.match(strResult, /^\$2[ab]\$10\$[./A-Za-z0-9]{53}$/);
assert.equal(strResult.split("$").length, 4);
}),

View File

@ -956,7 +956,7 @@ TestRegister.addTests([
recipeConfig: [
{
op: "EMV Generate MAC",
args: ["0123456789ABCDEFFEDCBA9876543210", 8, false]
args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false]
}
]
},
@ -1024,7 +1024,7 @@ TestRegister.addTests([
recipeConfig: [
{
op: "EMV Verify MAC",
args: ["0123456789ABCDEFFEDCBA9876543210", "22CB48394DFD1977", true]
args: ["0123456789ABCDEFFEDCBA9876543210", "22CB48394DFD1977", "Method 2", true]
}
]
},
@ -1341,5 +1341,91 @@ TestRegister.addTests([
args: ["00112233445566778899AABBCCDDEEFF", 8, "000102030405060708090A0B0C0D0E0F", true]
}
]
},
// ── PIN Block Translate Encrypted ─────────────────────────────────────────
// Vectors: PIN=1234, PAN=5432101234567890
// clear Format 0 block : 041215FEDCBA9876
// ZPK_IN (2-key TDES) : DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE KCV 06332B
// ZPK_OUT (2-key TDES) : AABBCCDDEEFF00112233445566778899 KCV C4F0A4
// encrypted under ZPK_IN : 7F381DBF9F6906C4
// encrypted under ZPK_OUT : 06C0408B869B2CEB
// AWS Payment Cryptography comparison (translate_pin_data, TR31_P0_PIN_ENCRYPTION_KEY):
// incoming key ARN: arn:aws:payment-cryptography:us-east-1:030716882260:key/yqictqre4fccxmzn
// outgoing key ARN: arn:aws:payment-cryptography:us-east-1:030716882260:key/czgtcqq5cpspwcgk
{
name: "PIN Block Translate Encrypted: same key / same format (round-trip identity)",
input: "7F381DBF9F6906C4",
expectedOutput: "7F381DBF9F6906C4",
recipeConfig: [
{
op: "PIN Block Translate Encrypted",
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890",
"DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", false]
}
]
},
{
name: "PIN Block Translate Encrypted: ZPK-to-ZPK same format",
input: "7F381DBF9F6906C4",
expectedOutput: "06C0408B869B2CEB",
recipeConfig: [
{
op: "PIN Block Translate Encrypted",
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890",
"AABBCCDDEEFF00112233445566778899", "ISO Format 0", "5432101234567890", false]
}
]
},
{
name: "PIN Block Translate Encrypted: ZPK-to-ZPK Format 0 to Format 1",
input: "7F381DBF9F6906C4",
expectedOutput: "CAC0E6065A56F5F3",
recipeConfig: [
{
op: "PIN Block Translate Encrypted",
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890",
"AABBCCDDEEFF00112233445566778899", "ISO Format 1", "", false]
}
]
},
{
name: "PIN Block Translate Encrypted: JSON output mode",
input: "7F381DBF9F6906C4",
expectedOutput: JSON.stringify({
incoming: {
format: "ISO Format 0",
pan: "5432101234567890",
encryptedBlockHex: "7F381DBF9F6906C4",
clearBlockHex: "041215FEDCBA9876"
},
pin: "1234",
outgoing: {
format: "ISO Format 0",
pan: "5432101234567890",
clearBlockHex: "041215FEDCBA9876",
encryptedBlockHex: "06C0408B869B2CEB"
}
}, null, 4),
recipeConfig: [
{
op: "PIN Block Translate Encrypted",
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890",
"AABBCCDDEEFF00112233445566778899", "ISO Format 0", "5432101234567890", true]
}
]
},
{
name: "PIN Block Translate Encrypted: 3-key TDES (48 hex) accepted",
input: "7F381DBF9F6906C4",
expectedOutput: "06C0408B869B2CEB",
recipeConfig: [
{
op: "PIN Block Translate Encrypted",
// 3-key expansion of 2-key keys: K3_IN = K2_IN + K2_IN[0..15], same for OUT
args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEEDDDDEEEEFFFFAAAA", "ISO Format 0", "5432101234567890",
"AABBCCDDEEFF00112233445566778899AABBCCDDEEFF0011", "ISO Format 0", "5432101234567890", false]
}
]
}
]);