From 9207d79356afb41c28e9de12db71dfcf9f070df5 Mon Sep 17 00:00:00 2001
From: Jacob Marks <100745682+J8k3@users.noreply.github.com>
Date: Sat, 16 May 2026 22:37:36 -0400
Subject: [PATCH] Add Derive DUKPT AES Key operation (ANSI X9.24-3 AES-128)
---
src/core/operations/DeriveDUKPTAESKey.mjs | 303 ++++++++++++++++++++++
1 file changed, 303 insertions(+)
create mode 100644 src/core/operations/DeriveDUKPTAESKey.mjs
diff --git a/src/core/operations/DeriveDUKPTAESKey.mjs b/src/core/operations/DeriveDUKPTAESKey.mjs
new file mode 100644
index 00000000..458ff3cf
--- /dev/null
+++ b/src/core/operations/DeriveDUKPTAESKey.mjs
@@ -0,0 +1,303 @@
+/**
+ * @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 { toHexFast } from "../lib/Hex.mjs";
+
+// ── X9.24-3 key usage indicators (bytes 2-3 of derivation data) ───────────────
+
+const KEY_USAGE = {
+ "IK Derivation": 0x8000, // BDK → device Initial Key
+ "Intermediate": 0x0000, // internal binary-tree node (not user-visible)
+ "PIN Encryption": 0x1000,
+ "MAC Generation": 0x2000, // sender / request direction
+ "MAC Verification": 0x2001, // receiver / response direction
+ "MAC Both Ways": 0x2002,
+ "Data Encryption": 0x3000,
+ "Data Decryption": 0x3001,
+ "Data Both Ways": 0x3002,
+};
+
+// AES-128 wire constants
+const ALGO_CODE = 0x0002; // AES-128 algorithm identifier
+const KEY_LEN_VAL = 0x0080; // 128 bits
+
+// CMAC Rb constant for 128-bit block (RFC 4493)
+const RB = new Uint8Array(16);
+RB[15] = 0x87;
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function parseHex(hex, expectedBytes, name) {
+ const h = (hex || "").replace(/\s+/g, "");
+ if (!/^[0-9a-fA-F]+$/.test(h) || h.length % 2 !== 0)
+ throw new OperationError(`${name} must be a hex string.`);
+ const bytes = new Uint8Array(h.length / 2);
+ for (let i = 0; i < bytes.length; i++)
+ bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
+ if (expectedBytes && bytes.length !== expectedBytes)
+ throw new OperationError(`${name} must be ${expectedBytes} bytes (got ${bytes.length}).`);
+ return bytes;
+}
+
+function xor(a, b) {
+ const out = new Uint8Array(a.length);
+ for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i];
+ return out;
+}
+
+function shiftLeft1(a) {
+ const out = new Uint8Array(a.length);
+ for (let i = 0; i < a.length - 1; i++)
+ out[i] = ((a[i] << 1) | (a[i + 1] >> 7)) & 0xFF;
+ out[a.length - 1] = (a[a.length - 1] << 1) & 0xFF;
+ return out;
+}
+
+function toByteString(bytes) {
+ return Array.from(bytes, b => String.fromCharCode(b)).join("");
+}
+
+function hex(bytes) {
+ return toHexFast(bytes).toUpperCase();
+}
+
+// ── AES-128 ECB single-block encrypt ─────────────────────────────────────────
+// Reuses the forge cipher object across calls (same pattern as CMAC.mjs).
+
+function makeEcbCipher(key16) {
+ return forge.cipher.createCipher("AES-ECB", toByteString(key16));
+}
+
+function ecbBlock(cipher, block16) {
+ cipher.start();
+ cipher.update(forge.util.createBuffer(toByteString(block16)));
+ cipher.finish();
+ return Uint8Array.from(cipher.output.getBytes(), c => c.charCodeAt(0)).slice(0, 16);
+}
+
+// ── AES-CMAC (RFC 4493) ───────────────────────────────────────────────────────
+
+function aesCmac(key16, message) {
+ const cipher = makeEcbCipher(key16);
+
+ // Subkey generation
+ const L = ecbBlock(cipher, new Uint8Array(16));
+ const K1 = shiftLeft1(L);
+ if (L[0] & 0x80) for (let i = 0; i < 16; i++) K1[i] ^= RB[i];
+ const K2 = shiftLeft1(K1);
+ if (K1[0] & 0x80) for (let i = 0; i < 16; i++) K2[i] ^= RB[i];
+
+ const n = Math.max(1, Math.ceil(message.length / 16));
+ const flag = message.length > 0 && message.length % 16 === 0;
+
+ // Prepare final block
+ const lastRaw = message.slice((n - 1) * 16);
+ const lastBlock = new Uint8Array(16);
+ lastBlock.set(lastRaw);
+ if (!flag) lastBlock[lastRaw.length] = 0x80; // ISO/IEC 7816-4 padding
+ const lastXored = xor(lastBlock, flag ? K1 : K2);
+
+ // CBC-MAC chain
+ let X = new Uint8Array(16);
+ for (let i = 0; i < n - 1; i++)
+ X = ecbBlock(cipher, xor(X, message.slice(i * 16, (i + 1) * 16)));
+ return ecbBlock(cipher, xor(X, lastXored));
+}
+
+// ── X9.24-3 AES-128 DUKPT derivation ─────────────────────────────────────────
+
+/**
+ * Builds the 20-byte derivation data block (ANSI X9.24-3-2017).
+ *
+ * Layout:
+ * [0-1] version = 0x0001
+ * [2-3] key usage indicator
+ * [4-5] algorithm = 0x0002 (AES-128)
+ * [6-7] key length = 0x0080 (128 bits)
+ * [8-15] IKI (8 bytes, from KSN bytes 0-7)
+ * [16-19] counter register (4 bytes)
+ */
+function derivationData(usage, iki8, counterReg) {
+ const d = new Uint8Array(20);
+ d[0] = 0x00; d[1] = 0x01;
+ d[2] = (usage >> 8) & 0xFF; d[3] = usage & 0xFF;
+ d[4] = (ALGO_CODE >> 8) & 0xFF; d[5] = ALGO_CODE & 0xFF;
+ d[6] = (KEY_LEN_VAL >> 8) & 0xFF; d[7] = KEY_LEN_VAL & 0xFF;
+ d.set(iki8, 8);
+ d[16] = (counterReg >>> 24) & 0xFF;
+ d[17] = (counterReg >>> 16) & 0xFF;
+ d[18] = (counterReg >>> 8) & 0xFF;
+ d[19] = counterReg & 0xFF;
+ return d;
+}
+
+/** BDK + IKI → Initial Key loaded into the terminal. */
+function deriveIK(bdk16, iki8) {
+ return aesCmac(bdk16, derivationData(KEY_USAGE["IK Derivation"], iki8, 0));
+}
+
+/**
+ * Binary-tree traversal from IK to the leaf transaction key.
+ * Uses the 21 usable counter bits (bits 20-0 of the 4-byte counter field).
+ */
+function deriveTransactionKey(ik16, iki8, counter) {
+ const usable = counter & 0x1FFFFF;
+ if (usable === 0) throw new OperationError(
+ "Counter 0 is reserved — no transactions have occurred yet."
+ );
+ if (usable === 0x1FFFFF) throw new OperationError(
+ "Counter 0x1FFFFF indicates key exhaustion — this terminal needs a new IK."
+ );
+ let key = Uint8Array.from(ik16);
+ let reg = 0;
+ for (let bit = 20; bit >= 0; bit--) {
+ if (usable & (1 << bit)) {
+ reg |= (1 << bit);
+ key = aesCmac(key, derivationData(KEY_USAGE["Intermediate"], iki8, reg));
+ }
+ }
+ return key;
+}
+
+/** Transaction key + purpose → purpose-specific working key. */
+function deriveWorkingKey(txKey16, iki8, counter, purposeName) {
+ return aesCmac(txKey16, derivationData(KEY_USAGE[purposeName], iki8, counter & 0x1FFFFF));
+}
+
+// ── Operation class ───────────────────────────────────────────────────────────
+
+/**
+ * Derive DUKPT AES Key operation.
+ */
+class DeriveDUKPTAESKey extends Operation {
+
+ constructor() {
+ super();
+
+ this.name = "Derive DUKPT AES Key";
+ this.module = "Payment";
+ this.description = [
+ "Derives AES DUKPT working keys per ANSI X9.24-3 (AES-128).",
+ "
",
+ "Input: 16-byte BDK as hex, or the 16-byte Initial Key (IK) if you already have it.",
+ "
",
+ "The KSN is 12 bytes: 8-byte Initial Key Identifier (IKI) + 4-byte transaction counter.",
+ "Only the low 21 bits of the counter are used for derivation (max 2,097,151 transactions per IK).",
+ "
",
+ "Derivation data format (X9.24-3, 20 bytes):",
+ "
", + "[0-1] version = 0x0001\n", + "[2-3] key usage indicator\n", + "[4-5] algorithm = 0x0002 (AES-128)\n", + "[6-7] key length = 0x0080 (128 bits)\n", + "[8-15] IKI (8 bytes from KSN)\n", + "[16-19] counter register (4 bytes)\n", + "", + "Key usage codes: PIN Encryption=0x1000, MAC Generation=0x2000, ", + "MAC Verification=0x2001, MAC Both Ways=0x2002, ", + "Data Encryption=0x3000, Data Decryption=0x3001, Data Both Ways=0x3002.", + "