From ffc5fcbf4178c285b7ffb1dd53927437b1db984a Mon Sep 17 00:00:00 2001 From: J8k3 Date: Wed, 20 May 2026 19:04:35 -0400 Subject: [PATCH] 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 --- PAYMENT_RECIPES.md | 7 +- src/core/config/Categories.json | 1 + src/core/lib/PaymentUtils.mjs | 2 +- src/core/operations/CBOREncode.mjs | 72 +++++- .../operations/TranslatePINBlockEncrypted.mjs | 222 ++++++++++++++++++ tests/node/tests/operations.mjs | 2 +- tests/operations/tests/Payment.mjs | 90 ++++++- 7 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 src/core/operations/TranslatePINBlockEncrypted.mjs diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md index 0cc0d653..b082f222 100644 --- a/PAYMENT_RECIPES.md +++ b/PAYMENT_RECIPES.md @@ -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 | diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index e0052a54..2da87783 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -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", diff --git a/src/core/lib/PaymentUtils.mjs b/src/core/lib/PaymentUtils.mjs index 56b542d8..8b124259 100644 --- a/src/core/lib/PaymentUtils.mjs +++ b/src/core/lib/PaymentUtils.mjs @@ -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.`); } diff --git a/src/core/operations/CBOREncode.mjs b/src/core/operations/CBOREncode.mjs index c6e094a9..d302774b 100644 --- a/src/core/operations/CBOREncode.mjs +++ b/src/core/operations/CBOREncode.mjs @@ -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} + */ +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); } } diff --git a/src/core/operations/TranslatePINBlockEncrypted.mjs b/src/core/operations/TranslatePINBlockEncrypted.mjs new file mode 100644 index 00000000..06aa7bf1 --- /dev/null +++ b/src/core/operations/TranslatePINBlockEncrypted.mjs @@ -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.", + "

", + "This is the acquirer's core PIN routing operation.", + " It corresponds to TranslatePinData in AWS Payment Cryptography and to the", + " CA / CC command family on Thales payShield.", + "

", + "Input: encrypted PIN block as hex (8 bytes / 16 hex chars).", + "
", + "Key algorithm: 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).", + "

", + "Supported formats: ISO Format 0, ISO Format 1, ISO Format 3.", + " ISO Format 4 (AES, 16-byte block) is not yet supported.", + "

", + "PCI PIN requirement: 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 = [ + "Input: encrypted PIN block hex.", + "Args: 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; diff --git a/tests/node/tests/operations.mjs b/tests/node/tests/operations.mjs index 3b2bbda6..96735554 100644 --- a/tests/node/tests/operations.mjs +++ b/tests/node/tests/operations.mjs @@ -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); }), diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index ce8f2b7e..77b6fbd6 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -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] + } + ] } ]);