Input: ignored. Arguments: method selector plus one field per preimage element. Fields irrelevant to the selected method are ignored.
Chaining: set Output format to Hex and place this operation first in a recipe to supply the preimage directly into EMV Generate ARPC.";
+ this.inlineHelp = "Args: select method (1 = Visa/Amex, 2 = Mastercard) and fill the relevant fields. Set format to Hex to chain into EMV Generate ARPC.";
+ this.testDataSamples = [
+ {
+ name: "Method 1 (Visa/Amex) — hex output",
+ input: "",
+ args: [METHOD1, "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"]
+ },
+ {
+ name: "Method 2 (Mastercard) — hex output",
+ input: "",
+ args: [METHOD2, "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"]
+ },
+ {
+ name: "Method 2 with Proprietary Auth Data — annotated",
+ input: "",
+ args: [METHOD2, "A1B2C3D4E5F60708", "5931", "00000000", "AABBCCDD", "Annotated"]
+ },
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "ARPC method",
+ type: "option",
+ value: METHODS,
+ comment: "Method 1: Visa, Amex, Discover, JCB. Method 2: Mastercard M/Chip.",
+ },
+ {
+ name: "ARQC (hex, 8 bytes)",
+ type: "string",
+ value: "",
+ comment: "Authorization Request Cryptogram — output of EMV Generate ARQC.",
+ },
+ {
+ name: "ARC (hex, 2 bytes) — Method 1",
+ type: "string",
+ value: "3030",
+ comment: "Authorization Response Code. Common values: 3030=00, 5931=Y1 (approve), 5933=Y3, 5A31=Z1 (decline). Used only for Method 1.",
+ },
+ {
+ name: "Card Status Update / CSU (hex, 4 bytes) — Method 2",
+ type: "string",
+ value: "00000000",
+ comment: "Issuer response flags for PIN change/unblock and go-online. Used only for Method 2.",
+ },
+ {
+ name: "Proprietary Auth Data (hex, 0–8 bytes) — Method 2",
+ type: "string",
+ value: "",
+ comment: "Optional scheme-specific data appended after CSU. Leave empty if not used. Used only for Method 2.",
+ },
+ {
+ name: "Output format",
+ type: "option",
+ value: ["Hex", "JSON", "Annotated"],
+ comment: "Hex: flat hex for piping into EMV Generate ARPC. JSON/Annotated: human-readable inspection.",
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input ignored
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [method, arqc, arc, csu, pad, fmt] = args;
+
+ const { fields, hex } = method === METHOD2 ?
+ buildMethod2(arqc, csu, pad) :
+ buildMethod1(arqc, arc);
+
+ if (fmt === "JSON") return formatJson(fields, method);
+ if (fmt === "Annotated") return formatAnnotated(fields, method);
+ return hex;
+ }
+}
+
+export default BuildEMVARPCData;
diff --git a/src/core/operations/BuildEMVARQCData.mjs b/src/core/operations/BuildEMVARQCData.mjs
new file mode 100644
index 0000000000..90812e5f1f
--- /dev/null
+++ b/src/core/operations/BuildEMVARQCData.mjs
@@ -0,0 +1,104 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { buildCdol1, formatHex, formatJson, formatAnnotatedTlv } from "../lib/EmvCdol.mjs";
+
+/**
+ * EMV Build ARQC Data operation.
+ */
+class BuildEMVARQCData extends Operation {
+
+ /** @inheritdoc */
+ constructor() {
+ super();
+
+ this.name = "EMV Build ARQC Data";
+ this.module = "Payment";
+ this.description = "Assemble the 10 standard EMVCo CDOL1 fields into the preassembled ARQC input data block used as input to EMV Generate ARQC and EMV Verify ARQC. All data comes from arguments — the input field is not used.
Input: ignored. Arguments: one hex field per CDOL1 element plus an output format selector.
Network coverage: the 10-field, 33-byte layout is identical across Visa, Mastercard, Amex, Discover, JCB, and UnionPay acquirer flows. Network differences (Visa/Amex Option A vs Mastercard Option B session-key derivation) occur upstream in key derivation and do not affect the CDOL1 data block structure.
Chaining: set Output format to Hex and place this operation first in a recipe to supply the preimage directly into EMV Generate ARQC without using the input field.";
+ this.inlineHelp = "Args: one hex field per CDOL1 element. Set format to Hex to chain into EMV Generate ARQC.";
+ this.testDataSamples = [
+ {
+ name: "Standard CDOL1 — hex output (Visa $10.00 USD, USA terminal)",
+ input: "",
+ args: [
+ "000000001000",
+ "000000000000",
+ "0840",
+ "0000000000",
+ "0840",
+ "260521",
+ "00",
+ "A1B2C3D4",
+ "5900",
+ "0001",
+ "Hex",
+ ]
+ },
+ {
+ name: "Standard CDOL1 — annotated TLV",
+ input: "",
+ args: [
+ "000000001000",
+ "000000000000",
+ "0840",
+ "0000000000",
+ "0840",
+ "260521",
+ "00",
+ "A1B2C3D4",
+ "5900",
+ "0001",
+ "Annotated TLV",
+ ]
+ },
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Amount Authorised (9F02)", type: "string", value: "000000001000", comment: "6-byte BCD minor-unit amount, e.g. 000000001000 = $10.00" },
+ { name: "Amount Other (9F03)", type: "string", value: "000000000000", comment: "6-byte BCD cashback amount; 000000000000 if none" },
+ { name: "Terminal Country Code (9F1A)", type: "string", value: "0840", comment: "ISO 3166-1 numeric, e.g. 0840 = USA" },
+ { name: "TVR (95)", type: "string", value: "0000000000", comment: "5-byte Terminal Verification Results" },
+ { name: "Transaction Currency Code (5F2A)", type: "string", value: "0840", comment: "ISO 4217 numeric, e.g. 0840 = USD" },
+ { name: "Transaction Date (9A)", type: "string", value: "260521", comment: "3-byte YYMMDD, e.g. 260521 = 2026-05-21" },
+ { name: "Transaction Type (9C)", type: "string", value: "00", comment: "1-byte EMV type: 00 = Purchase, 01 = Cash, 09 = Cashback" },
+ { name: "Unpredictable Number (9F37)", type: "string", value: "00000000", comment: "4-byte terminal random; use a real random value in production flows" },
+ { name: "AIP (82)", type: "string", value: "5900", comment: "2-byte Application Interchange Profile" },
+ { name: "ATC (9F36)", type: "string", value: "0001", comment: "2-byte Application Transaction Counter" },
+ {
+ name: "Output format",
+ type: "option",
+ value: ["Hex", "JSON", "Annotated TLV"],
+ comment: "Hex: flat hex suitable for piping into EMV Generate ARQC. JSON/Annotated TLV: human-readable inspection.",
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input ignored
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [
+ amountAuth, amountOther, countryCode, tvr, currencyCode,
+ txDate, txType, unpredictable, aip, atc,
+ fmt,
+ ] = args;
+
+ const parsed = buildCdol1([
+ amountAuth, amountOther, countryCode, tvr, currencyCode,
+ txDate, txType, unpredictable, aip, atc,
+ ]);
+
+ if (fmt === "JSON") return formatJson(parsed);
+ if (fmt === "Annotated TLV") return formatAnnotatedTlv(parsed);
+ return formatHex(parsed);
+ }
+}
+
+export default BuildEMVARQCData;
diff --git a/src/core/operations/BuildEMVPINChangeScriptData.mjs b/src/core/operations/BuildEMVPINChangeScriptData.mjs
new file mode 100644
index 0000000000..6db4e0cbf5
--- /dev/null
+++ b/src/core/operations/BuildEMVPINChangeScriptData.mjs
@@ -0,0 +1,60 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { PIN_CHANGE_MODES, buildPinChangeHeader, formatAnnotatedPinChangeHeader } from "../lib/EmvScript.mjs";
+
+/**
+ * Build EMV PIN Change Script Data operation.
+ */
+class BuildEMVPINChangeScriptData extends Operation {
+ /**
+ * BuildEMVPINChangeScriptData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Build PIN Change Script Data";
+ this.module = "Payment";
+ this.description = "Assembles the 5-byte CHANGE REFERENCE DATA (INS=24) command header for a PIN-change issuer script. Use this as the first step in a recipe — the hex output feeds into the EMV Generate MAC (PIN Change) input field, which appends the encrypted PIN block before computing the MAC.
Output:CLA 24 P1 P2 Lc (5 bytes). The Lc field must cover all data bytes that follow in the final APDU: typically 8 bytes for the encrypted PIN block plus 8 bytes for the MAC = 0x10.
P1: 00 = change requires current PIN verification; 01 = change without verification. P2: PIN reference — 80 is the global PIN reference used by most EMV cards.
Security: Software emulation for testing only.";
+ this.inlineHelp = "Output: 5-byte CHANGE REFERENCE DATA header. Feed into EMV Generate MAC (PIN Change) as input.";
+ this.testDataSamples = [
+ {
+ name: "PIN change header sample",
+ input: "",
+ args: ["84", "Change with current PIN verification", "80", "10", "Hex"]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "CLA (hex)", type: "string", value: "84", comment: "Class byte. 84 = secure messaging with key from current DF (standard for issuer scripts)." },
+ { name: "Change mode (P1)", type: "option", value: PIN_CHANGE_MODES, comment: "P1=00: change requires verification with the current PIN. P1=01: change without current PIN verification." },
+ { name: "PIN reference (P2, hex)", type: "string", value: "80", comment: "PIN reference data qualifier. 80 = global PIN reference (most EMV cards). Check card spec for other values." },
+ { name: "Lc (hex)", type: "string", value: "10", comment: "Total data length in the final APDU. Default 10 (hex) = 16 bytes: 8-byte encrypted PIN block + 8-byte MAC." },
+ { name: "Output format", type: "option", value: ["Hex", "JSON", "Annotated"], comment: "Hex: header ready to chain. JSON: named fields. Annotated: field-by-field breakdown." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [claHex, changeMode, p2Hex, lcHex, outputFormat] = args;
+ const f = buildPinChangeHeader(claHex, changeMode, p2Hex, lcHex);
+ if (outputFormat === "JSON") {
+ return JSON.stringify({ cla: f.cla, ins: f.ins, p1: f.p1, p2: f.p2, lc: f.lc, header: f.header }, null, 4);
+ }
+ if (outputFormat === "Annotated") {
+ return formatAnnotatedPinChangeHeader(f);
+ }
+ return f.header;
+ }
+}
+
+export default BuildEMVPINChangeScriptData;
diff --git a/src/core/operations/BuildEMVScriptData.mjs b/src/core/operations/BuildEMVScriptData.mjs
new file mode 100644
index 0000000000..fe0dbaac28
--- /dev/null
+++ b/src/core/operations/BuildEMVScriptData.mjs
@@ -0,0 +1,61 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { SCRIPT_COMMANDS, buildScriptApdu, formatAnnotatedApdu } from "../lib/EmvScript.mjs";
+
+/**
+ * Build EMV Script Data operation.
+ */
+class BuildEMVScriptData extends Operation {
+ /**
+ * BuildEMVScriptData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Build Script Data";
+ this.module = "Payment";
+ this.description = "Assembles an issuer-script command APDU from named fields. Use this as the first step in a recipe chain — the hex output feeds directly into the EMV Generate MAC input field.
Output:CLA | INS | P1 | P2 | Lc | Data — Lc is computed automatically from the data length.
Common INS values: DA=PUT DATA, DB=PUT DATA (ODD), DC=UPDATE RECORD, D6=WRITE BINARY, 26=DISABLE VERIFICATION, 28=ENABLE VERIFICATION, 82=EXTERNAL AUTHENTICATE.
Security: Software emulation for testing only.";
+ this.inlineHelp = "Output: CLA INS P1 P2 Lc Data APDU hex. Feed into EMV Generate MAC as input.";
+ this.testDataSamples = [
+ {
+ name: "PUT DATA sample",
+ input: "",
+ args: ["84", "PUT DATA", "00", "42", "0102030405060708090A", "Hex"]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "CLA (hex)", type: "string", value: "84", comment: "Class byte. 84 = secure messaging with key from current DF (standard for issuer scripts)." },
+ { name: "Command", type: "option", value: SCRIPT_COMMANDS, comment: "Selects the INS byte. Common issuer script commands: PUT DATA (DA/DB), UPDATE RECORD (DC), WRITE BINARY (D6)." },
+ { name: "P1 (hex)", type: "string", value: "00", comment: "Parameter 1. Meaning depends on command: record number for UPDATE RECORD, data reference for PUT DATA." },
+ { name: "P2 (hex)", type: "string", value: "00", comment: "Parameter 2. Meaning depends on command: SFI+record selector for UPDATE RECORD, data object tag low byte for PUT DATA." },
+ { name: "Data (hex)", type: "string", value: "", comment: "Command data payload. Lc is computed automatically from the length." },
+ { name: "Output format", type: "option", value: ["Hex", "JSON", "Annotated"], comment: "Hex: APDU ready to chain. JSON: named fields. Annotated: field-by-field breakdown." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [claHex, commandName, p1Hex, p2Hex, dataHex, outputFormat] = args;
+ const f = buildScriptApdu(claHex, commandName, p1Hex, p2Hex, dataHex);
+ if (outputFormat === "JSON") {
+ return JSON.stringify({ cla: f.cla, ins: f.ins, p1: f.p1, p2: f.p2, lc: f.lc, data: f.data, apdu: f.apdu }, null, 4);
+ }
+ if (outputFormat === "Annotated") {
+ return formatAnnotatedApdu(f);
+ }
+ return f.apdu;
+ }
+}
+
+export default BuildEMVScriptData;
diff --git a/src/core/operations/BuildPINBlock.mjs b/src/core/operations/BuildPINBlock.mjs
new file mode 100644
index 0000000000..95d515b674
--- /dev/null
+++ b/src/core/operations/BuildPINBlock.mjs
@@ -0,0 +1,67 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { PIN_BLOCK_FORMATS, buildPinBlock } from "../lib/PinBlock.mjs";
+
+/**
+ * Build PIN block operation
+ */
+class BuildPINBlock extends Operation {
+
+ /**
+ * BuildPINBlock constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Block Build";
+ this.module = "Payment";
+ this.description = "Paste the clear PIN into the input field and choose the ISO 9564 clear PIN block format to build.
Input: clear PIN digits. Arguments: choose the target format, provide the PAN when required, and optionally randomize filler digits for formats 1 and 3.
This operation currently builds clear test PIN blocks for ISO formats 0, 1, and 3.";
+ this.inlineHelp = "Input: clear PIN digits. Args: choose the format, add the PAN for formats 0 and 3, then decide whether format 1 or 3 filler digits should be randomized.";
+ this.testDataSamples = [
+ {
+ name: "Random ISO Format 0 sample",
+ input: "__RANDOM_PIN_4__",
+ args: ["ISO Format 0", "__RANDOM_PAN_16__", false]
+ }
+ ];
+ this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Format",
+ type: "option",
+ value: PIN_BLOCK_FORMATS,
+ comment: "Choose the clear ISO 9564 block format to build. Assumption: only formats 0, 1, and 3 are implemented."
+ },
+ {
+ name: "Primary account number",
+ type: "string",
+ value: "",
+ comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
+ },
+ {
+ name: "Randomize fill digits",
+ type: "boolean",
+ value: false,
+ comment: "Affects only formats 1 and 3. When disabled, filler is deterministic so test vectors stay stable."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [format, pan, randomizeFill] = args;
+ return buildPinBlock(format, input, pan, randomizeFill);
+ }
+}
+
+export default BuildPINBlock;
diff --git a/src/core/operations/CBOREncode.mjs b/src/core/operations/CBOREncode.mjs
index c6e094a9ae..d302774bfc 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/CalculatePaymentKCV.mjs b/src/core/operations/CalculatePaymentKCV.mjs
new file mode 100644
index 0000000000..f890e3f0d6
--- /dev/null
+++ b/src/core/operations/CalculatePaymentKCV.mjs
@@ -0,0 +1,152 @@
+/**
+ * @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 Utils from "../Utils.mjs";
+import CMAC from "./CMAC.mjs";
+
+/**
+ * Calculate payment KCV operation
+ */
+class CalculatePaymentKCV extends Operation {
+
+ /**
+ * CalculatePaymentKCV constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Payment Calculate KCV";
+ this.module = "Payment";
+ this.description = "Paste the key into the input field and choose how that key is encoded using Key format.
Use Method to choose the KCV style: TDES, AES-CMAC, AES-ECB, or HMAC.
Input: raw key material such as hex, UTF-8, Latin1, or Base64. Arguments: select the key format, method, and output length in hex characters.
Returns an uppercase truncated hex KCV value.";
+ this.inlineHelp = "Input: key material. Args: tell the op how the key is encoded, choose the KCV method, then set the output length.";
+ this.testDataSamples = [
+ {
+ name: "Random AES-CMAC sample",
+ input: "__RANDOM_AES_128_HEX__",
+ args: ["Hex", "AES-CMAC (Empty)", 6]
+ },
+ {
+ name: "Generate key then compute KCV",
+ recipeConfig: [
+ { op: "Key Generate", args: ["AES-128 (16 bytes)", 16, false, false] },
+ { op: "Payment Calculate KCV", args: ["Hex", "AES-CMAC (Empty)", 6] }
+ ]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Key format",
+ "type": "option",
+ "value": ["Hex", "UTF8", "Latin1", "Base64"],
+ "comment": "How the input field should be decoded before KCV calculation. Use Hex for payment keys entered as hexadecimal characters."
+ },
+ {
+ "name": "Method",
+ "type": "option",
+ "value": ["TDES-ECB (Zeros)", "AES-CMAC (Empty)", "AES-CMAC (Zeros)", "AES-CMAC (Ones)", "AES-ECB (Zeros)", "HMAC SHA-224", "HMAC SHA-256", "HMAC SHA-384", "HMAC SHA-512"],
+ "comment": "Assumption: TDES expects a 16-byte or 24-byte key, AES expects 16/24/32 bytes, and the method name states the exact data block used for the KCV."
+ },
+ {
+ "name": "Output hex chars",
+ "type": "number",
+ "value": 6,
+ "comment": "Number of uppercase hex characters returned from the left side of the calculated value. Common payment KCV length is 6."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [keyFormat, method, outputHexChars] = args;
+ const truncLength = Math.max(1, Number(outputHexChars) || 6);
+ const keyBytes = Utils.convertToByteString(input || "", keyFormat);
+
+ if (!keyBytes.length) {
+ throw new OperationError("No key material was provided.");
+ }
+
+ let hexOut;
+
+ switch (method) {
+ case "TDES-ECB (Zeros)": {
+ if (keyBytes.length !== 16 && keyBytes.length !== 24) {
+ throw new OperationError("TDES key must be 16 or 24 bytes.");
+ }
+ const key = keyBytes.length === 16 ? keyBytes + keyBytes.substring(0, 8) : keyBytes;
+ const cipher = forge.cipher.createCipher("3DES-ECB", key);
+ cipher.start();
+ cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00"));
+ cipher.finish();
+ hexOut = cipher.output.toHex().toUpperCase();
+ break;
+ }
+ case "AES-CMAC (Empty)":
+ case "AES-CMAC (Zeros)":
+ case "AES-CMAC (Ones)": {
+ if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
+ throw new OperationError("AES key must be 16, 24, or 32 bytes.");
+ }
+ const cmacOp = new CMAC();
+ let data;
+ if (method === "AES-CMAC (Empty)") {
+ data = new Uint8Array(0).buffer;
+ } else if (method === "AES-CMAC (Zeros)") {
+ data = new Uint8Array(16).buffer;
+ } else {
+ data = Uint8Array.from(new Array(16).fill(0xFF)).buffer;
+ }
+ hexOut = cmacOp.run(data, [{ string: keyBytes, option: "Latin1" }, "AES"]).toUpperCase();
+ break;
+ }
+ case "AES-ECB (Zeros)": {
+ if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
+ throw new OperationError("AES key must be 16, 24, or 32 bytes.");
+ }
+ const cipher = forge.cipher.createCipher("AES-ECB", keyBytes);
+ cipher.mode.pad = function() {
+ return true;
+ };
+ cipher.start();
+ cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"));
+ cipher.finish();
+ hexOut = cipher.output.toHex().toUpperCase();
+ break;
+ }
+ case "HMAC SHA-224":
+ case "HMAC SHA-256":
+ case "HMAC SHA-384":
+ case "HMAC SHA-512": {
+ const algorithmMap = {
+ "HMAC SHA-224": "sha224",
+ "HMAC SHA-256": "sha256",
+ "HMAC SHA-384": "sha384",
+ "HMAC SHA-512": "sha512"
+ };
+ const hmac = forge.hmac.create();
+ hmac.start(algorithmMap[method], keyBytes);
+ hmac.update("");
+ hexOut = hmac.digest().toHex().toUpperCase();
+ break;
+ }
+ default:
+ throw new OperationError("Unsupported method.");
+ }
+
+ return hexOut.substring(0, truncLength);
+ }
+
+}
+
+export default CalculatePaymentKCV;
diff --git a/src/core/operations/DecryptPaymentData.mjs b/src/core/operations/DecryptPaymentData.mjs
new file mode 100644
index 0000000000..5346333207
--- /dev/null
+++ b/src/core/operations/DecryptPaymentData.mjs
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, decryptPaymentData } from "../lib/PaymentDataCipher.mjs";
+
+/**
+ * Decrypt payment data operation.
+ */
+class DecryptPaymentData extends Operation {
+ /**
+ * DecryptPaymentData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Payment Decrypt Data";
+ this.module = "Payment";
+ this.description = "Paste ciphertext into the input field as hex and decrypt it using a payment-facing cipher wrapper.
Input: ciphertext hex. Arguments: choose the cipher profile, provide a direct key or BDK, add IV where needed, and provide KSN plus DUKPT variant when using a DUKPT profile.";
+ this.inlineHelp = "Input: ciphertext hex. Args: choose AES, TDES, or DUKPT-wrapped TDES, then provide key, IV, and optional KSN context.";
+ this.testDataSamples = [
+ {
+ name: "AES CBC sample",
+ input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B",
+ args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Cipher profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "Select the payment-facing decryption profile. DUKPT profiles derive a session key first, then run TDES decryption." },
+ { name: "Key / BDK", type: "string", value: "", comment: "Provide the clear AES/TDES key for static profiles, or the clear BDK for DUKPT profiles." },
+ { name: "IV (hex)", type: "string", value: "", comment: "Initialization vector as hex. Leave blank for ECB. Use 16 bytes for AES CBC/CTR and 8 bytes for TDES CBC." },
+ { name: "KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT profiles. Provide the full 10-byte KSN as hex." },
+ { name: "DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT profiles. Use Data for the current data-key masking behavior in this fork." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the effective cipher context and plaintext." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [profile, keyHex, ivHex, ksn, dukptVariant, outputJson] = args;
+ const result = decryptPaymentData(input, profile, keyHex, ivHex, ksn, dukptVariant);
+ return outputJson ? JSON.stringify(result, null, 4) : result.plaintextHex;
+ }
+}
+
+export default DecryptPaymentData;
diff --git a/src/core/operations/DeriveDUKPTAESKey.mjs b/src/core/operations/DeriveDUKPTAESKey.mjs
new file mode 100644
index 0000000000..8d798df9f1
--- /dev/null
+++ b/src/core/operations/DeriveDUKPTAESKey.mjs
@@ -0,0 +1,335 @@
+/**
+ * @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": 0x8001, // BDK → device Initial Key (X9.24-3 §6.3.1)
+ Intermediate: 0x8000, // internal binary-tree node (X9.24-3 §6.3.2)
+ "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
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/**
+ * Parses a hex string into a Uint8Array, validating format and byte length.
+ *
+ * @param {string} hex
+ * @param {number} expectedBytes
+ * @param {string} name
+ * @returns {Uint8Array}
+ */
+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;
+}
+
+/**
+ * Converts a Uint8Array to a byte string for use with node-forge.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function toByteString(bytes) {
+ return Array.from(bytes, b => String.fromCharCode(b)).join("");
+}
+
+/**
+ * Converts a Uint8Array to an uppercase hex string.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function hex(bytes) {
+ return toHexFast(bytes).toUpperCase();
+}
+
+// ── AES-128 ECB single-block encrypt ─────────────────────────────────────────
+
+/**
+ * Encrypts a single 16-byte block using AES-128-ECB.
+ * This is the primitive used by X9.24-3 for all key derivation steps.
+ *
+ * @param {Uint8Array} key16
+ * @param {Uint8Array} block16
+ * @returns {Uint8Array}
+ */
+function aesEncryptBlock(key16, block16) {
+ const cipher = forge.cipher.createCipher("AES-ECB", toByteString(key16));
+ cipher.start();
+ cipher.update(forge.util.createBuffer(toByteString(block16)));
+ cipher.finish();
+ return Uint8Array.from(cipher.output.getBytes(), c => c.charCodeAt(0)).slice(0, 16);
+}
+
+// ── X9.24-3 AES-128 DUKPT derivation ─────────────────────────────────────────
+
+/**
+ * Builds the 16-byte IK derivation data block (ANSI X9.24-3-2017 §6.3.1).
+ *
+ * Layout (IK derivation only — uses full 8-byte IKI, no counter field):
+ * [0] version = 0x01
+ * [1] key size class = 0x01 (AES-128)
+ * [2-3] key usage = 0x8001 (IK Derivation)
+ * [4-5] algorithm = 0x0002 (AES-128)
+ * [6-7] key length = 0x0080 (128 bits)
+ * [8-15] IKI (full 8 bytes)
+ *
+ * @param {Uint8Array} iki8
+ * @returns {Uint8Array}
+ */
+function ikDerivationData(iki8) {
+ const d = new Uint8Array(16);
+ d[0] = 0x01; d[1] = 0x01;
+ d[2] = (KEY_USAGE["IK Derivation"] >> 8) & 0xFF;
+ d[3] = KEY_USAGE["IK Derivation"] & 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);
+ return d;
+}
+
+/**
+ * Builds the 16-byte derivation data block for intermediate-node and working-key
+ * derivation (ANSI X9.24-3-2017 §6.3.2 / §6.3.3).
+ *
+ * Layout:
+ * [0] version = 0x01
+ * [1] key size class = 0x01 (AES-128)
+ * [2-3] key usage indicator
+ * [4-5] algorithm = 0x0002 (AES-128)
+ * [6-7] key length = 0x0080 (128 bits)
+ * [8-11] last 4 bytes of IKI (IKI[4..7])
+ * [12-15] counter register (4 bytes, big-endian)
+ *
+ * @param {number} usage
+ * @param {Uint8Array} iki8
+ * @param {number} counterReg
+ * @returns {Uint8Array}
+ */
+function derivationData(usage, iki8, counterReg) {
+ const d = new Uint8Array(16);
+ d[0] = 0x01; 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;
+ // last 4 bytes of 8-byte IKI
+ d[8] = iki8[4]; d[9] = iki8[5]; d[10] = iki8[6]; d[11] = iki8[7];
+ d[12] = (counterReg >>> 24) & 0xFF;
+ d[13] = (counterReg >>> 16) & 0xFF;
+ d[14] = (counterReg >>> 8) & 0xFF;
+ d[15] = counterReg & 0xFF;
+ return d;
+}
+
+/**
+ * Derives the Initial Key (IK) from a BDK and IKI using AES-ECB (X9.24-3 §6.3.1).
+ *
+ * @param {Uint8Array} bdk16
+ * @param {Uint8Array} iki8
+ * @returns {Uint8Array}
+ */
+function deriveIK(bdk16, iki8) {
+ return aesEncryptBlock(bdk16, ikDerivationData(iki8));
+}
+
+/**
+ * Binary-tree traversal from IK to the leaf transaction key (X9.24-3 §6.3.2).
+ * Traverses all 32 counter bits from MSB to LSB, deriving one intermediate key
+ * per set bit using AES-ECB.
+ *
+ * @param {Uint8Array} ik16
+ * @param {Uint8Array} iki8
+ * @param {number} counter
+ * @returns {Uint8Array}
+ */
+function deriveTransactionKey(ik16, iki8, counter) {
+ if (counter === 0) throw new OperationError(
+ "Counter 0 is reserved — no transactions have occurred yet."
+ );
+ let key = Uint8Array.from(ik16);
+ let reg = 0;
+ for (let bit = 31; bit >= 0; bit--) {
+ if ((counter >>> bit) & 1) {
+ reg = (reg | (1 << bit)) >>> 0;
+ key = aesEncryptBlock(key, derivationData(KEY_USAGE.Intermediate, iki8, reg));
+ }
+ }
+ return key;
+}
+
+/**
+ * Derives a purpose-specific working key from the transaction key (X9.24-3 §6.3.3).
+ *
+ * @param {Uint8Array} txKey16
+ * @param {Uint8Array} iki8
+ * @param {number} counter
+ * @param {string} purposeName
+ * @returns {Uint8Array}
+ */
+function deriveWorkingKey(txKey16, iki8, counter, purposeName) {
+ return aesEncryptBlock(txKey16, derivationData(KEY_USAGE[purposeName], iki8, counter));
+}
+
+// ── Operation class ───────────────────────────────────────────────────────────
+
+/**
+ * Derive DUKPT AES Key operation.
+ */
+class DeriveDUKPTAESKey extends Operation {
+
+ /**
+ * DeriveDUKPTAESKey constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "DUKPT Derive AES Key";
+ this.module = "Payment";
+ this.description = [
+ "Derives AES DUKPT working keys per ANSI X9.24-3 (AES-128). All derivation steps use AES-ECB.",
+ "
",
+ "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, 16 bytes — working keys):",
+ "
",
+ "IK derivation uses a separate 16-byte block with full 8-byte IKI at [8-15] and usage 0x8001.",
+ "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.",
+ "
Put the 10-byte Key Serial Number in the KSN argument field.
Input: BDK in hex. Arguments: choose whether to derive the IPEK or the transaction key, provide the KSN, choose the variant, and optionally return JSON.
This operation derives TDES DUKPT keys (ANSI X9.24 Part 1) in software for test and interoperability work. It uses a 16-byte BDK and a 10-byte KSN. For AES DUKPT (ANSI X9.24 Part 3), which uses a 12-byte KSN and AES keys, use the DUKPT Derive AES Key operation.";
+ this.inlineHelp = "Input: BDK hex. Args: add the KSN, choose IPEK or transaction-key derivation, then optionally apply a variant.";
+ this.testDataSamples = [
+ {
+ name: "Known transaction key vector",
+ input: "__RANDOM_TDES_16_HEX__",
+ args: ["Derive Session Key", "FFFF9876543210E00008", "None", false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Derived_unique_key_per_transaction";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Mode",
+ "type": "option",
+ "value": ["Derive IPEK", "Derive Session Key"],
+ "comment": "Choose whether the output should be the IPEK or the derived transaction/session key. Assumption: this implementation follows TDES DUKPT (ANSI X9.24 Part 1), not AES DUKPT (ANSI X9.24 Part 3)."
+ },
+ {
+ "name": "KSN (hex, 10 bytes)",
+ "type": "string",
+ "value": "",
+ "comment": "Provide the full 10-byte KSN as 20 hex characters, for example FFFF9876543210E00008. Spaces are allowed. Note: AES DUKPT uses a 12-byte KSN — this operation only accepts 10-byte TDES DUKPT KSNs."
+ },
+ {
+ "name": "Session key variant",
+ "type": "option",
+ "value": ["None", "PIN", "MAC Request", "MAC Response", "Data"],
+ "comment": "Applied only when deriving the session key. Assumption: variants are implemented as simple XOR masks over the derived base key."
+ },
+ {
+ "name": "Output as JSON",
+ "type": "boolean",
+ "value": false,
+ "comment": "When enabled, returns the intermediate values along with the final key so the derivation can be inspected."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [mode, ksnHex, variant, outputJson] = args;
+ const bdk = parseHex(input, 16, "BDK");
+ const ksn = parseHex(ksnHex, 10, "KSN");
+
+ const ipek = deriveIpek(bdk, ksn);
+ const ipekHex = toHexFast(ipek).toUpperCase();
+
+ const ksnHexOut = toHexFast(ksn).toUpperCase();
+
+ if (mode === "Derive IPEK") {
+ if (outputJson) {
+ return JSON.stringify({ mode, ksn: ksnHexOut, bdk: toHexFast(bdk).toUpperCase(), ipek: ipekHex }, null, 4);
+ }
+ return ipekHex;
+ }
+
+ const sessionBase = deriveSessionBaseKey(ipek, ksn);
+ const session = xorBytes(sessionBase, VARIANT_MASKS[variant]);
+ const sessionHex = toHexFast(session).toUpperCase();
+
+ if (outputJson) {
+ return JSON.stringify({
+ mode,
+ ksn: ksnHexOut,
+ bdk: toHexFast(bdk).toUpperCase(),
+ ipek: ipekHex,
+ sessionBase: toHexFast(sessionBase).toUpperCase(),
+ variant,
+ sessionKey: sessionHex
+ }, null, 4);
+ }
+
+ return sessionHex;
+ }
+
+}
+
+export default DeriveDUKPTKey;
diff --git a/src/core/operations/DeriveECDHKeyMaterial.mjs b/src/core/operations/DeriveECDHKeyMaterial.mjs
new file mode 100644
index 0000000000..6566fbb348
--- /dev/null
+++ b/src/core/operations/DeriveECDHKeyMaterial.mjs
@@ -0,0 +1,282 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import r from "jsrsasign";
+import { fromBase64, toBase64 } from "../lib/Base64.mjs";
+import { toHexFast } from "../lib/Hex.mjs";
+
+/**
+ * Parses a PEM or hex-encoded DER key into bytes.
+ *
+ * @param {string} input
+ * @param {string} format
+ * @param {string} pemLabel
+ * @returns {Uint8Array}
+ */
+function parsePemOrHex(input, format, pemLabel) {
+ const value = (input || "").trim();
+ if (!value.length) throw new OperationError("Missing key input.");
+
+ if (format === "PEM") {
+ const normalized = value
+ .replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "")
+ .replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "")
+ .replace(/\s+/g, "");
+ return new Uint8Array(fromBase64(normalized, undefined, "byteArray"));
+ }
+
+ const hex = value.replace(/\s+/g, "");
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0)
+ throw new OperationError("Expected hex input.");
+
+ const out = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < out.length; i++)
+ out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+ return out;
+}
+
+/**
+ * Normalizes PEM private keys to PKCS#8 DER for WebCrypto import.
+ * Accepts PKCS#8 PEM, SEC1 EC PEM (BEGIN EC PRIVATE KEY), or raw hex.
+ *
+ * @param {string} input
+ * @returns {Uint8Array}
+ */
+function parsePrivateKey(input) {
+ const value = (input || "").trim();
+ if (!value.length) throw new OperationError("Missing key input.");
+
+ if (!value.includes("-----BEGIN"))
+ return parsePemOrHex(value, "HEX", "PRIVATE KEY");
+
+ if (value.includes("-----BEGIN PRIVATE KEY-----"))
+ return parsePemOrHex(value, "PEM", "PRIVATE KEY");
+
+ try {
+ const key = r.KEYUTIL.getKey(value);
+ const pkcs8Pem = r.KEYUTIL.getPEM(key, "PKCS8PRV");
+ return parsePemOrHex(pkcs8Pem, "PEM", "PRIVATE KEY");
+ } catch (err) {
+ throw new OperationError(`Unsupported private key format: ${err}`);
+ }
+}
+
+/**
+ * Concatenates byte arrays.
+ *
+ * @param {Uint8Array[]} parts
+ * @returns {Uint8Array}
+ */
+function concatBytes(parts) {
+ const total = parts.reduce((sum, p) => sum + p.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const p of parts) {
+ out.set(p, offset);
+ offset += p.length;
+ }
+ return out;
+}
+
+/**
+ * Derives output keying material using NIST SP 800-56A Concat KDF.
+ *
+ * @param {Uint8Array} rawSecret
+ * @param {Uint8Array} sharedInfo
+ * @param {string} hashAlg "SHA-256" or "SHA-512"
+ * @param {number} outputLen
+ * @returns {Promise}
+ */
+async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
+ let counter = 1;
+ const chunks = [];
+ let generated = 0;
+
+ while (generated < outputLen) {
+ const ctr = new Uint8Array([
+ (counter >>> 24) & 0xff,
+ (counter >>> 16) & 0xff,
+ (counter >>> 8) & 0xff,
+ counter & 0xff,
+ ]);
+ const data = concatBytes([ctr, rawSecret, sharedInfo]);
+ const digest = new Uint8Array(await crypto.subtle.digest(hashAlg, data));
+ chunks.push(digest);
+ generated += digest.length;
+ counter += 1;
+ }
+
+ return concatBytes(chunks).slice(0, outputLen);
+}
+
+/**
+ * Derive ECDH Key Material operation.
+ */
+class DeriveECDHKeyMaterial extends Operation {
+
+ /**
+ * DeriveECDHKeyMaterial constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Derive ECDH Key Material";
+ this.module = "Ciphers";
+ this.description = [
+ "Paste your EC private key into the input field and provide the peer's public key as an argument.",
+ "
",
+ "Input: private key in PEM (BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY)",
+ " or as PKCS#8 DER hex.",
+ " Arguments: curve, peer public key, optional KDF (NIST SP 800-56A Concat KDF),",
+ " shared info, output length, and output format.",
+ "
",
+ "Use KDF = None to obtain the raw shared secret (the x-coordinate of the shared EC point).",
+ " The output length argument is ignored in None mode.",
+ ].join("");
+ this.inlineHelp = "Input: your EC private key (PEM or PKCS8 DER hex). " +
+ "Args: pick the curve, paste the peer public key, then choose raw secret or KDF output.";
+
+ this.testDataSamples = [
+ {
+ name: "P-256 raw shared secret",
+ input: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4HBsMvgcOvEQBrYJ\ndEXulke/dh5vYiOvfI41AToqfbWhRANCAAQgZgScW2pSpRRTOADLPL5D+8TF6xXx\nx9GDOE8V1xYj7arujDYH5935uCdVxXa84lUEw35+afHuh0bDmBDxolmx\n-----END PRIVATE KEY-----",
+ args: ["PEM", "P-256", "PEM",
+ "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa+FXJzzko0OZ9DcOaXpLzAkSt7bE\nXXVKQqYfsmuelH6QgH86dMR04/bvnhl4bF7YKbMWDlPRHs9haSeR/PhFNg==\n-----END PUBLIC KEY-----",
+ "None", 32, "", "Hex"],
+ },
+ ];
+
+ this.infoURL = "https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Private key format",
+ "type": "option",
+ "value": ["PEM", "Hex (PKCS8 DER)"],
+ "comment": "PEM may be BEGIN PRIVATE KEY (PKCS#8) or BEGIN EC PRIVATE KEY (SEC1, auto-converted).",
+ },
+ {
+ "name": "Curve",
+ "type": "option",
+ "value": ["P-256", "P-384", "P-521"],
+ "comment": "Must match the actual curve of both keys. The op does not auto-detect the curve.",
+ },
+ {
+ "name": "Peer public key format",
+ "type": "option",
+ "value": ["PEM", "Hex (SPKI DER)"],
+ "comment": "PEM should be an SPKI BEGIN PUBLIC KEY block.",
+ },
+ {
+ "name": "Peer public key",
+ "type": "text",
+ "value": "-----BEGIN PUBLIC KEY-----",
+ "comment": "Paste the full peer public key here.",
+ },
+ {
+ "name": "KDF",
+ "type": "option",
+ "value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"],
+ "comment": "None returns the raw shared secret. Concat KDF follows NIST SP 800-56A §5.8.1.",
+ },
+ {
+ "name": "Output length (bytes)",
+ "type": "number",
+ "value": 32,
+ "comment": "Used only with KDF modes. Ignored when KDF is None.",
+ },
+ {
+ "name": "Shared info (hex)",
+ "type": "string",
+ "value": "",
+ "comment": "Optional KDF shared info as hex. Leave blank if not used.",
+ },
+ {
+ "name": "Output format",
+ "type": "option",
+ "value": ["Hex", "Base64"],
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ async run(input, args) {
+ const [
+ privateFmt,
+ curve,
+ publicFmt,
+ peerPublicKey,
+ kdf,
+ outLenArg,
+ sharedInfoHex,
+ outputFormat,
+ ] = args;
+
+ if (!globalThis.crypto || !globalThis.crypto.subtle)
+ throw new OperationError("WebCrypto is not available in this runtime.");
+
+ const privateDer = privateFmt === "PEM" ?
+ parsePrivateKey(input) :
+ parsePemOrHex(input, "HEX", "PRIVATE KEY");
+
+ const publicDer = parsePemOrHex(
+ peerPublicKey,
+ publicFmt === "PEM" ? "PEM" : "HEX",
+ "PUBLIC KEY"
+ );
+
+ const outLen = Math.max(1, Number(outLenArg) || 32);
+
+ const sharedInfoHexNorm = (sharedInfoHex || "").replace(/\s+/g, "");
+ if (sharedInfoHexNorm.length % 2 !== 0 ||
+ (sharedInfoHexNorm.length > 0 && !/^[0-9a-fA-F]+$/.test(sharedInfoHexNorm)))
+ throw new OperationError("Shared info must be hex.");
+
+ const sharedInfo = sharedInfoHexNorm.length ?
+ new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) :
+ new Uint8Array();
+
+ const privateKey = await crypto.subtle.importKey(
+ "pkcs8", privateDer,
+ { name: "ECDH", namedCurve: curve },
+ false, ["deriveBits"]
+ );
+
+ const publicKey = await crypto.subtle.importKey(
+ "spki", publicDer,
+ { name: "ECDH", namedCurve: curve },
+ false, []
+ );
+
+ // P-521 has a 521-bit field; deriveBits requires a multiple of 8,
+ // so request 528 bits (66 bytes) and WebCrypto returns the full x-coordinate.
+ const curveBits = curve === "P-256" ? 256 : curve === "P-384" ? 384 : 528;
+ const rawSecret = new Uint8Array(
+ await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, curveBits)
+ );
+
+ let out;
+ if (kdf === "Concat KDF SHA-256") {
+ out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen);
+ } else if (kdf === "Concat KDF SHA-512") {
+ out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen);
+ } else {
+ // None: return the full raw shared secret; output length arg is ignored.
+ out = rawSecret;
+ }
+
+ return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase();
+ }
+
+}
+
+export default DeriveECDHKeyMaterial;
diff --git a/src/core/operations/EncryptPaymentData.mjs b/src/core/operations/EncryptPaymentData.mjs
new file mode 100644
index 0000000000..2f8bb14133
--- /dev/null
+++ b/src/core/operations/EncryptPaymentData.mjs
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, encryptPaymentData } from "../lib/PaymentDataCipher.mjs";
+
+/**
+ * Encrypt payment data operation.
+ */
+class EncryptPaymentData extends Operation {
+ /**
+ * EncryptPaymentData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Payment Encrypt Data";
+ this.module = "Payment";
+ this.description = "Paste plaintext into the input field as hex and encrypt it using a payment-facing cipher wrapper.
Input: plaintext hex. Arguments: choose the cipher profile, provide a direct key or BDK, add IV where needed, and provide KSN plus DUKPT variant when using a DUKPT profile.";
+ this.inlineHelp = "Input: plaintext hex. Args: choose AES, TDES, or DUKPT-wrapped TDES, then provide key, IV, and optional KSN context.";
+ this.testDataSamples = [
+ {
+ name: "AES CBC sample",
+ input: "00112233445566778899AABBCCDDEEFF",
+ args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Cipher profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "Select the payment-facing encryption profile. DUKPT profiles derive a session key first, then run TDES encryption." },
+ { name: "Key / BDK", type: "string", value: "", comment: "Provide the clear AES/TDES key for static profiles, or the clear BDK for DUKPT profiles." },
+ { name: "IV (hex)", type: "string", value: "", comment: "Initialization vector as hex. Leave blank for ECB. Use 16 bytes for AES CBC/CTR and 8 bytes for TDES CBC." },
+ { name: "KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT profiles. Provide the full 10-byte KSN as hex." },
+ { name: "DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT profiles. Use Data for the current data-key masking behavior in this fork." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the effective cipher context and ciphertext." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [profile, keyHex, ivHex, ksn, dukptVariant, outputJson] = args;
+ const result = encryptPaymentData(input, profile, keyHex, ivHex, ksn, dukptVariant);
+ return outputJson ? JSON.stringify(result, null, 4) : result.ciphertextHex;
+ }
+}
+
+export default EncryptPaymentData;
diff --git a/src/core/operations/GenerateAS2805KEKValidation.mjs b/src/core/operations/GenerateAS2805KEKValidation.mjs
new file mode 100644
index 0000000000..e9c8d4d285
--- /dev/null
+++ b/src/core/operations/GenerateAS2805KEKValidation.mjs
@@ -0,0 +1,109 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import CalculatePaymentKCV from "./CalculatePaymentKCV.mjs";
+import { bytesToHex, parseHexBytes } from "../lib/PaymentUtils.mjs";
+
+/**
+ * Returns cryptographically random bytes when available.
+ *
+ * @param {number} length
+ * @returns {Uint8Array}
+ */
+function randomBytes(length) {
+ const out = new Uint8Array(length);
+ if (globalThis.crypto && globalThis.crypto.getRandomValues) {
+ globalThis.crypto.getRandomValues(out);
+ return out;
+ }
+
+ for (let i = 0; i < out.length; i++) {
+ out[i] = Math.floor(Math.random() * 256);
+ }
+ return out;
+}
+
+/**
+ * Inverts all bytes.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {Uint8Array}
+ */
+function invertBytes(bytes) {
+ return Uint8Array.from(bytes, byte => byte ^ 0xFF);
+}
+
+/**
+ * Generate AS2805 KEK validation operation.
+ */
+class GenerateAS2805KEKValidation extends Operation {
+ /**
+ * GenerateAS2805KEKValidation constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "AS2805 Generate KEK Validation";
+ this.module = "Payment";
+ this.description = "Paste the clear sending KEK into the input field as hex and generate an AS2805 KEK validation request or response.
Input: clear KEK as 16-byte or 24-byte hex. Arguments: choose request or response mode, select the random-key length, choose the variant mask label, and optionally provide the incoming RandomKeySend value.
Validation: Emulation helper. This software implementation returns RandomKeyReceive as the bytewise inverse of RandomKeySend, which is useful for lab testing but does not claim exact HSM-side AS2805 node-initialization behavior.
Security: Clear KEKs in the recipe are test-use only.";
+ this.inlineHelp = "Input: clear KEK hex. Args: choose request or response mode and provide RandomKeySend for response mode. Validation: explicit emulation, not certified AS2805 behavior.";
+ this.testDataSamples = [
+ {
+ name: "AS2805 request sample",
+ input: "__RANDOM_TDES_16_HEX__",
+ args: ["KekValidationRequest", "TDES_2KEY", "VARIANT_MASK_82", "", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/AS2805";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Validation type", type: "option", value: ["KekValidationRequest", "KekValidationResponse"], comment: "Request mode creates a fresh RandomKeySend. Response mode derives RandomKeyReceive from the supplied RandomKeySend." },
+ { name: "Derive key algorithm", type: "option", value: ["TDES_2KEY", "TDES_3KEY"], comment: "Controls whether RandomKeySend / RandomKeyReceive are 16 bytes or 24 bytes long." },
+ { name: "RandomKeySend variant mask", type: "option", value: ["VARIANT_MASK_82", "VARIANT_MASK_82C0"], comment: "Variant mask label used during AS2805 KEK validation. This emulation reports the selected label but does not model HSM-side key custody." },
+ { name: "RandomKeySend (response only)", type: "string", value: "", comment: "Required only in response mode. Provide the incoming RandomKeySend hex value from the partner node." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the KEK KCV and both RandomKeySend / RandomKeyReceive values." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [validationType, deriveKeyAlgorithm, randomKeySendVariantMask, randomKeySendHex, outputJson] = args;
+ const kek = parseHexBytes(input, "KEK", deriveKeyAlgorithm === "TDES_2KEY" ? [16] : [24]);
+ const randomKeyLength = deriveKeyAlgorithm === "TDES_2KEY" ? 16 : 24;
+
+ let randomKeySend;
+ if (validationType === "KekValidationRequest") {
+ randomKeySend = randomBytes(randomKeyLength);
+ } else {
+ if (!randomKeySendHex) {
+ throw new OperationError("RandomKeySend is required for KEK validation response mode.");
+ }
+ randomKeySend = parseHexBytes(randomKeySendHex, "RandomKeySend", [randomKeyLength]);
+ }
+
+ const randomKeyReceive = invertBytes(randomKeySend);
+ const kcv = new CalculatePaymentKCV().run(bytesToHex(kek), ["Hex", "TDES-ECB (Zeros)", 6]);
+
+ const result = {
+ validationType,
+ deriveKeyAlgorithm,
+ randomKeySendVariantMask,
+ keyCheckValue: kcv,
+ randomKeySend: bytesToHex(randomKeySend),
+ randomKeyReceive: bytesToHex(randomKeyReceive)
+ };
+
+ return outputJson ? JSON.stringify(result, null, 4) : result.randomKeyReceive;
+ }
+}
+
+export default GenerateAS2805KEKValidation;
diff --git a/src/core/operations/GenerateCardValidationData.mjs b/src/core/operations/GenerateCardValidationData.mjs
new file mode 100644
index 0000000000..a4b7751df8
--- /dev/null
+++ b/src/core/operations/GenerateCardValidationData.mjs
@@ -0,0 +1,118 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { CVV_PROFILES, generateCardValidationData } from "../lib/CardValidation.mjs";
+
+/**
+ * Generate card validation data operation.
+ */
+class GenerateCardValidationData extends Operation {
+
+ /**
+ * GenerateCardValidationData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Card Validation Data Generate";
+ this.module = "Payment";
+ this.description = "Paste the combined CVK pair into the input field as hex and generate a card-verification value for software testing.
Input: combined CVK pair as 16-byte or 24-byte hex. Arguments: select whether you are generating CVV/CVC, CVV2/CVC2, or iCVV, then provide the PAN, expiry components, and service code details.
Profile behaviour: CVV2/CVC2 forces service code 000 regardless of the supplied service code. iCVV forces service code 999. CVV/CVC uses the supplied service code directly.
This implementation is intended for test harnesses and assumes the common CVV decimalization flow used by payment HSM integrations.";
+ this.inlineHelp = "Input: combined CVK pair hex. Args: choose the validation-data profile, then provide PAN, expiry, and service-code inputs.";
+ this.testDataSamples = [
+ {
+ name: "Known CVV2 test sample",
+ input: "0123456789ABCDEFFEDCBA9876543210",
+ args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false]
+ },
+ {
+ name: "Generated CVK → CVV2",
+ recipeConfig: [
+ { op: "Key Generate", args: ["AES-128 (16 bytes)", 16, false, false] },
+ { op: "Card Validation Data Generate", args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false] }
+ ]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Card_security_code";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Validation data type",
+ type: "option",
+ value: CVV_PROFILES,
+ comment: "Choose whether the output should behave like CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code 000 and iCVV forces 999."
+ },
+ {
+ name: "Primary account number",
+ type: "string",
+ value: "",
+ comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
+ },
+ {
+ name: "Expiry month (MM)",
+ type: "shortString",
+ value: "",
+ comment: "Two-digit month component used when assembling the expiry date."
+ },
+ {
+ name: "Expiry year (YY)",
+ type: "shortString",
+ value: "",
+ comment: "Two-digit year component used when assembling the expiry date."
+ },
+ {
+ name: "Expiry layout",
+ type: "option",
+ value: ["YYMM", "MMYY"],
+ defaultIndex: 1,
+ comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
+ },
+ {
+ name: "Service code",
+ type: "shortString",
+ value: "101",
+ comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force 000 and 999."
+ },
+ {
+ name: "Output digits",
+ type: "number",
+ value: 3,
+ min: 1,
+ max: 5,
+ comment: "How many digits of validation data to return. Common card-security-code lengths are 3 and sometimes 4."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false,
+ comment: "When enabled, returns the assembled input, intermediate hex, and decimalized value along with the final output."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, outputDigits, outputJson] = args;
+ const result = generateCardValidationData(
+ input,
+ pan,
+ expiryMonth,
+ expiryYear,
+ expiryLayout,
+ serviceCode,
+ profile,
+ outputDigits
+ );
+
+ return outputJson ? JSON.stringify(result, null, 4) : result.validationData;
+ }
+}
+
+export default GenerateCardValidationData;
diff --git a/src/core/operations/GenerateEMVARPC.mjs b/src/core/operations/GenerateEMVARPC.mjs
new file mode 100644
index 0000000000..65d2a59330
--- /dev/null
+++ b/src/core/operations/GenerateEMVARPC.mjs
@@ -0,0 +1,70 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
+
+/**
+ * Generate EMV ARPC operation.
+ */
+class GenerateEMVARPC extends Operation {
+
+ /**
+ * GenerateEMVARPC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Generate ARPC";
+ this.module = "Payment";
+ this.description = "Paste the already-assembled EMV authorization-response input into the input field as hex and generate an AES-CMAC-based ARPC.
Input: preassembled ARPC input data as hex. Arguments: provide the issuer session key in hex and choose how many bytes of the CMAC should be returned.
Validation: Partially verified. This intentionally covers only supplied-key AES-CMAC-style EMV response profiles and does not derive issuer session keys or assemble response fields for you.
Session key derivation: The issuer session key for ARPC generation is typically derived from the same issuer master key used for ARQC verification, using the same ATC-based derivation. The ARPC input data is assembled from the ARQC value and the Authorization Response Code (ARC). This operation expects both the session key and the preimage to be assembled before calling it.
Security: Clear session keys are test-use only.";
+ this.inlineHelp = "Input: preassembled ARPC data as hex. Args: provide the issuer AES session key and choose the truncated cryptogram length. Validation: supplied-key AES-CMAC response profile only.";
+ this.testDataSamples = [
+ {
+ name: "AES-CMAC ARPC sample",
+ input: "11223344556677889900AABBCCDDEEFF",
+ args: ["00112233445566778899AABBCCDDEEFF", 8, false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Issuer session key (hex)",
+ type: "string",
+ value: "",
+ comment: "Provide the already-derived issuer session key as hex. Assumption: this op does not derive EMV issuer session keys."
+ },
+ {
+ name: "Cryptogram bytes",
+ type: "number",
+ value: 8,
+ min: 1,
+ max: 16,
+ comment: "Number of leftmost CMAC bytes to return. Common ARPC length is 8 bytes."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false,
+ comment: "When enabled, returns the full AES-CMAC and the truncated ARPC value."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [issuerSessionKeyHex, cryptogramBytes, outputJson] = args;
+ const result = generateEmvAesCmacCryptogram(input, issuerSessionKeyHex, cryptogramBytes);
+ return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
+ }
+}
+
+export default GenerateEMVARPC;
diff --git a/src/core/operations/GenerateEMVARQC.mjs b/src/core/operations/GenerateEMVARQC.mjs
new file mode 100644
index 0000000000..cb36d907b2
--- /dev/null
+++ b/src/core/operations/GenerateEMVARQC.mjs
@@ -0,0 +1,70 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
+
+/**
+ * Generate EMV ARQC operation.
+ */
+class GenerateEMVARQC extends Operation {
+
+ /**
+ * GenerateEMVARQC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Generate ARQC";
+ this.module = "Payment";
+ this.description = "Paste the already-assembled EMV authorization-request input into the input field as hex and generate an AES-CMAC-based ARQC.
Input: preassembled ARQC input data as hex. Arguments: provide the EMV session key in hex and choose how many bytes of the CMAC should be returned.
Validation: Partially verified. This intentionally covers only supplied-key AES-CMAC-style EMV profiles and does not derive EMV session keys or assemble CDOL data for you.
Session key derivation: In a full EMV flow the session key is derived from the issuer master key using the Application Transaction Counter (ATC) and PAN sequence number. Visa and Amex use EMV Common Session Key Derivation (sometimes called Option A); Mastercard uses a different derivation (Option B). This operation expects you to supply the already-derived session key — use a separate key-derivation step before calling this operation if you need to reproduce a full end-to-end flow.
Security: Clear session keys are test-use only.";
+ this.inlineHelp = "Input: preassembled ARQC data as hex. Args: provide the AES session key and choose the truncated cryptogram length. Validation: supplied-key AES-CMAC profile only.";
+ this.testDataSamples = [
+ {
+ name: "AES-CMAC ARQC sample",
+ input: "000102030405060708090A0B0C0D0E0F",
+ args: ["00112233445566778899AABBCCDDEEFF", 8, false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Session key (hex)",
+ type: "string",
+ value: "",
+ comment: "Provide the already-derived EMV session key as hex. Assumption: this op does not derive EMV session keys."
+ },
+ {
+ name: "Cryptogram bytes",
+ type: "number",
+ value: 8,
+ min: 1,
+ max: 16,
+ comment: "Number of leftmost CMAC bytes to return. Common ARQC length is 8 bytes."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false,
+ comment: "When enabled, returns the full AES-CMAC and the truncated ARQC value."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sessionKeyHex, cryptogramBytes, outputJson] = args;
+ const result = generateEmvAesCmacCryptogram(input, sessionKeyHex, cryptogramBytes);
+ return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
+ }
+}
+
+export default GenerateEMVARQC;
diff --git a/src/core/operations/GenerateEMVMAC.mjs b/src/core/operations/GenerateEMVMAC.mjs
new file mode 100644
index 0000000000..fb3c508ccc
--- /dev/null
+++ b/src/core/operations/GenerateEMVMAC.mjs
@@ -0,0 +1,53 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateEmvMac } from "../lib/EmvMac.mjs";
+
+/**
+ * Generate EMV MAC operation.
+ */
+class GenerateEMVMAC extends Operation {
+ /**
+ * GenerateEMVMAC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Generate MAC";
+ this.module = "Payment";
+ this.description = "Paste the issuer-script or EMV command payload into the input field as hex and generate an EMV MAC.
Input: message data as hex. Arguments: provide the already-derived EMV session integrity key and choose how many leftmost MAC bytes to return.
Validation: Partially verified. This implements a retail-MAC style EMV helper with a supplied session key, not full EMV session derivation or brand-specific issuer processing.
Key context: In a full issuer implementation, the session integrity key used here corresponds to the secure-messaging integrity key (distinct from the confidentiality key used to encrypt data and the PIN encryption key used for PIN blocks). This operation accepts any key you supply and does not enforce that separation.
Security: Clear session keys in the recipe are test-use only.";
+ this.inlineHelp = "Input: issuer-script message data as hex. Args: provide the derived EMV session integrity key. Validation: supplied-key EMV MAC helper, not full EMV derivation.";
+ this.testDataSamples = [
+ {
+ name: "EMV MAC sample",
+ input: "8424000008999E57FD0F47CACE0007",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV integrity session key in hex. This op does not derive EMV keys for you." },
+ { name: "Padding method", type: "option", value: ["Method 2", "Method 1"], comment: "Method 2 appends 0x80 then zero-pads to block boundary (ISO 7816-4; standard for EMV issuer scripts). Method 1 zero-pads to block boundary only." },
+ { name: "Output bytes", type: "number", value: 8, min: 1, max: 8, comment: "Number of leftmost MAC bytes to return. EMV issuer scripts commonly use 8 bytes." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the issuer-script input and full retail-MAC details." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sessionKeyHex, paddingMethod, outputBytes, outputJson] = args;
+ const result = generateEmvMac(input, sessionKeyHex, outputBytes, paddingMethod);
+ return outputJson ? JSON.stringify(result, null, 4) : result.macHex;
+ }
+}
+
+export default GenerateEMVMAC;
diff --git a/src/core/operations/GenerateEMVMACForPINChange.mjs b/src/core/operations/GenerateEMVMACForPINChange.mjs
new file mode 100644
index 0000000000..714a3b86c8
--- /dev/null
+++ b/src/core/operations/GenerateEMVMACForPINChange.mjs
@@ -0,0 +1,53 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateEmvPinChangeMac } from "../lib/EmvMac.mjs";
+
+/**
+ * Generate EMV MAC for PIN change operation.
+ */
+class GenerateEMVMACForPINChange extends Operation {
+ /**
+ * GenerateEMVMACForPINChange constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Generate MAC (PIN Change)";
+ this.module = "Payment";
+ this.description = "Paste the issuer-script APDU command into the input field as hex and generate the MAC for an offline EMV PIN-change script.
Input: issuer-script message data as hex. Arguments: provide the already-encrypted target PIN block in hex and the already-derived EMV session integrity key.
Validation: Test helper. The new PIN block must already be encrypted, and this op appends it to the supplied message before applying the same supplied-key EMV MAC profile used elsewhere in this fork.
Key context: In a full issuer implementation, a PIN-change script involves three distinct keys: a secure-messaging integrity key (for the MAC), a secure-messaging confidentiality key (for encrypting the script data), and a PIN encryption key (for the new PIN block). This operation accepts a single session integrity key and a pre-encrypted PIN block — it does not model the full three-key separation.
Security: Test-only issuer-script assembly with clear session keys in the recipe.";
+ this.inlineHelp = "Input: issuer-script APDU message as hex. Args: provide the encrypted target PIN block and derived EMV integrity key. Validation: test helper for PIN-change script MAC assembly.";
+ this.testDataSamples = [
+ {
+ name: "EMV PIN change MAC sample",
+ input: "00A4040008A000000004101080D80500000001010A04000000000000",
+ args: ["67FB27C75580EFE7", "0123456789ABCDEFFEDCBA9876543210", 8, false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "New encrypted PIN block (hex)", type: "string", value: "", comment: "Provide the already-encrypted new PIN block that will be appended to the issuer-script message." },
+ { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV session integrity key in hex. This operation does not derive EMV keys or encrypt the PIN block for you." },
+ { name: "Output bytes", type: "number", value: 8, min: 1, max: 8, comment: "Number of leftmost MAC bytes to return. EMV issuer scripts commonly use 8 bytes." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the composed issuer-script message and the computed MAC." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [encryptedPinBlockHex, sessionKeyHex, outputBytes, outputJson] = args;
+ const result = generateEmvPinChangeMac(input, encryptedPinBlockHex, sessionKeyHex, outputBytes);
+ return outputJson ? JSON.stringify(result, null, 4) : result.macHex;
+ }
+}
+
+export default GenerateEMVMACForPINChange;
diff --git a/src/core/operations/GenerateIBM3624PINOffset.mjs b/src/core/operations/GenerateIBM3624PINOffset.mjs
new file mode 100644
index 0000000000..d75d0e81f0
--- /dev/null
+++ b/src/core/operations/GenerateIBM3624PINOffset.mjs
@@ -0,0 +1,54 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateIbm3624PinOffset } from "../lib/PaymentPinVerification.mjs";
+
+/**
+ * Generate IBM 3624 PIN offset operation.
+ */
+class GenerateIBM3624PINOffset extends Operation {
+ /**
+ * GenerateIBM3624PINOffset constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN IBM 3624 Offset Generate";
+ this.module = "Payment";
+ this.description = "Paste the clear PIN into the input field and generate the IBM 3624 offset used by issuer-side PIN verification.
Input: clear PIN digits. Arguments: provide the clear PVK in hex, decimalization table, validation data, and pad character.
Validation: Partially verified. This is a clear-key software implementation of the IBM 3624 PIN offset scheme rather than HSM-certified behavior.
Security: Clear PIN and PVK material are test-use only.";
+ this.inlineHelp = "Input: clear PIN digits. Args: provide PVK, decimalization table, validation data, and pad character. Validation: clear-key IBM 3624 helper.";
+ this.testDataSamples = [
+ {
+ name: "IBM 3624 offset sample",
+ input: "__RANDOM_PIN_4__",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/IBM_3624";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear IBM 3624 PVK as 16-byte or 24-byte hex." },
+ { name: "Decimalization table", type: "string", value: "0123456789012345", comment: "Sixteen decimal digits used to map hex nibbles to decimal digits." },
+ { name: "PIN validation data", type: "string", value: "", comment: "Issuer validation data, typically PAN-derived digits, 4 to 16 digits." },
+ { name: "Pad character", type: "shortString", value: "F", comment: "Single hex nibble used to right-pad validation data to 16 nibbles." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the intermediate natural PIN and validation-block details." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [pvkHex, decimalizationTable, pinValidationData, padCharacter, outputJson] = args;
+ const result = generateIbm3624PinOffset(pvkHex, decimalizationTable, pinValidationData, padCharacter, input);
+ return outputJson ? JSON.stringify(result, null, 4) : result.pinOffset;
+ }
+}
+
+export default GenerateIBM3624PINOffset;
diff --git a/src/core/operations/GenerateKey.mjs b/src/core/operations/GenerateKey.mjs
new file mode 100644
index 0000000000..d3a58a4615
--- /dev/null
+++ b/src/core/operations/GenerateKey.mjs
@@ -0,0 +1,207 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import forge from "node-forge";
+
+// ── Key / IV specs ────────────────────────────────────────────────────────────
+
+const KEY_SPECS = {
+ "AES-128 (16 bytes)": { bytes: 16, algorithm: "A", type: "key", pciOk: true },
+ "AES-192 (24 bytes)": { bytes: 24, algorithm: "A", type: "key", pciOk: true },
+ "AES-256 (32 bytes)": { bytes: 32, algorithm: "A", type: "key", pciOk: true },
+ "TDES Double-length (16 bytes)": { bytes: 16, algorithm: "T", type: "key", pciOk: false,
+ warn: "TDES prohibited for new PIN keys since 1 January 2023 (PCI PIN Req 2-2)" },
+ "TDES Triple-length (24 bytes)": { bytes: 24, algorithm: "T", type: "key", pciOk: false,
+ warn: "TDES prohibited for new PIN keys since 1 January 2023 (PCI PIN Req 2-2)" },
+ "AES IV / Nonce (16 bytes)": { bytes: 16, algorithm: "A", type: "iv", pciOk: true },
+ "Custom random bytes (specify below)": { bytes: null, algorithm: null, type: "custom", pciOk: true },
+};
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/**
+ * Generates n cryptographically random bytes using WebCrypto or node-forge.
+ *
+ * @param {number} n
+ * @returns {Uint8Array}
+ */
+function randomBytes(n) {
+ const buf = new Uint8Array(n);
+ if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.getRandomValues) {
+ globalThis.crypto.getRandomValues(buf);
+ } else {
+ const raw = forge.random.getBytesSync(n);
+ for (let i = 0; i < n; i++) buf[i] = raw.charCodeAt(i);
+ }
+ return buf;
+}
+
+/**
+ * Converts a Uint8Array to an uppercase hex string.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function toHex(bytes) {
+ return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join("");
+}
+
+/**
+ * Converts a Uint8Array to a byte string for use with node-forge.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function toByteStr(bytes) {
+ return Array.from(bytes, b => String.fromCharCode(b)).join("");
+}
+
+/**
+ * Left-shifts a byte array by one bit.
+ *
+ * @param {Uint8Array} a
+ * @returns {Uint8Array}
+ */
+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;
+}
+
+/**
+ * Computes the AES CMAC KCV: CMAC(key, zero-block), first 3 bytes.
+ * Uses the PCI PIN-required method, not the legacy ECB-zeros method.
+ *
+ * @param {Uint8Array} key
+ * @returns {string}
+ */
+function aesCmacKcv(key) {
+ const k = key.slice(0, 16);
+ const RB = new Uint8Array(16); RB[15] = 0x87;
+ const cipher = forge.cipher.createCipher("AES-ECB", toByteStr(k));
+
+ const ecb = block => {
+ cipher.start();
+ cipher.update(forge.util.createBuffer(toByteStr(block)));
+ cipher.finish();
+ return Uint8Array.from(cipher.output.getBytes(), c => c.charCodeAt(0)).slice(0, 16);
+ };
+
+ const L = ecb(new Uint8Array(16));
+ const K1 = shiftLeft1(L);
+ if (L[0] & 0x80) for (let i = 0; i < 16; i++) K1[i] ^= RB[i];
+
+ // Single full block (16 zero bytes) — complete block uses K1
+ const finalBlock = new Uint8Array(16);
+ for (let i = 0; i < 16; i++) finalBlock[i] = K1[i]; // 0x00 XOR K1[i]
+
+ return toHex(ecb(finalBlock).slice(0, 3));
+}
+
+// ── Operation ─────────────────────────────────────────────────────────────────
+
+/**
+ * Generate random payment key or IV.
+ */
+class GenerateKey extends Operation {
+
+ /**
+ * GenerateKey constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Key Generate";
+ this.module = "Payment";
+ this.description = [
+ "Generates a cryptographically random payment key, IV, or custom-length byte string.",
+ "
",
+ "Supported types: AES-128/192/256, TDES double/triple-length,",
+ " AES IV/Nonce (16 bytes), or a custom length for any other use.",
+ "
",
+ "For AES keys, optionally computes a CMAC KCV (3-byte, AES-CMAC of a zero block),",
+ " which is the PCI-required check value method — not the legacy ECB-zeros method.",
+ "
",
+ "Important: Keys generated in the browser are suitable for testing only.",
+ " For production, keys must be generated in an HSM or other FIPS 140-2+ approved device.",
+ ].join("");
+
+ this.inlineHelp = "Select a key type; output is hex. Use JSON output for KCV and metadata.";
+
+ this.testDataSamples = [
+ { name: "AES-128 key", input: "", args: ["AES-128 (16 bytes)", 16, true, true] },
+ ];
+
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Key / material type",
+ type: "option",
+ value: Object.keys(KEY_SPECS),
+ },
+ {
+ name: "Custom length (bytes)",
+ type: "number",
+ value: 16,
+ min: 1,
+ max: 256,
+ },
+ {
+ name: "Compute AES CMAC KCV",
+ type: "boolean",
+ value: true,
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false,
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [keyType, customLength, computeKcv, outputJson] = args;
+
+ const spec = KEY_SPECS[keyType];
+ if (!spec) throw new OperationError("Unknown key / material type.");
+
+ const byteCount = spec.type === "custom" ? Math.max(1, Math.min(256, customLength)) : spec.bytes;
+ const material = randomBytes(byteCount);
+ const hex = toHex(material);
+
+ if (!outputJson) return hex;
+
+ const out = {
+ type: keyType,
+ lengthBytes: byteCount,
+ lengthBits: byteCount * 8,
+ hex,
+ };
+
+ if (spec.algorithm) out.algorithm = spec.algorithm === "A" ? "AES" : "TDES";
+ if (spec.warn) out.warning = spec.warn;
+
+ if (computeKcv && spec.algorithm === "A" && byteCount >= 16) {
+ out.kcv = aesCmacKcv(material);
+ out.kcvMethod = "AES-CMAC of 16 zero bytes, first 3 bytes (PCI PIN compliant)";
+ }
+
+ out.note = "For testing only — production keys must be generated in an approved HSM.";
+
+ return JSON.stringify(out, null, 4);
+ }
+}
+
+export default GenerateKey;
diff --git a/src/core/operations/GeneratePIN.mjs b/src/core/operations/GeneratePIN.mjs
new file mode 100644
index 0000000000..afe656b0e1
--- /dev/null
+++ b/src/core/operations/GeneratePIN.mjs
@@ -0,0 +1,125 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import { buildPinBlock } from "../lib/PinBlock.mjs";
+
+const PIN_OUTPUT_MODES = [
+ "PIN digits",
+ "ISO Format 0 clear PIN block",
+ "ISO Format 1 clear PIN block",
+ "ISO Format 3 clear PIN block",
+];
+
+// Maps output mode label → PIN_BLOCK_FORMATS string used by buildPinBlock
+const OUTPUT_TO_FORMAT = {
+ "ISO Format 0 clear PIN block": "ISO Format 0",
+ "ISO Format 1 clear PIN block": "ISO Format 1",
+ "ISO Format 3 clear PIN block": "ISO Format 3",
+};
+
+/**
+ * Generate PIN operation.
+ */
+class GeneratePIN extends Operation {
+ /**
+ * GeneratePIN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Generate";
+ this.module = "Payment";
+ this.description = "Generate a cryptographically random cardholder PIN and optionally encode it as a clear ISO 9564 PIN block for use in test recipes.
Input: ignored. Arguments: choose the PIN length, the output mode, and (for block modes) the PAN.
The PIN digits are drawn using crypto.getRandomValues with rejection sampling to guarantee uniform distribution across 0–9.
Block output modes produce the clear (unencrypted) PIN block directly; these are test artifacts and must not be treated as production PIN blocks.
Security: Test data only. Do not use generated PINs or clear PIN blocks in production systems.";
+ this.inlineHelp = "Input: ignored. Args: PIN length, output mode, and PAN for block formats. Validation: uniform random digits via crypto.getRandomValues; clear ISO 9564 block formats 0, 1, and 3.";
+ this.testDataSamples = [
+ {
+ name: "4-digit PIN, digits only",
+ input: "",
+ args: [4, "PIN digits", ""]
+ },
+ {
+ name: "4-digit PIN, Format 0 block",
+ input: "",
+ args: [4, "ISO Format 0 clear PIN block", "5432101234567890"]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Personal_identification_number";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "PIN length",
+ type: "number",
+ value: 4,
+ min: 4,
+ max: 12,
+ comment: "Number of PIN digits to generate. Most cardholder PINs are 4 digits."
+ },
+ {
+ name: "Output",
+ type: "option",
+ value: PIN_OUTPUT_MODES,
+ comment: "PIN digits only, or a clear ISO 9564 PIN block. Block modes require the PAN argument."
+ },
+ {
+ name: "PAN (for block formats)",
+ type: "string",
+ value: "",
+ comment: "Required for ISO Format 0 and Format 3 block output. Ignored for PIN digits and ISO Format 1."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [length, outputMode, pan] = args;
+
+ if (!Number.isInteger(length) || length < 4 || length > 12) {
+ throw new OperationError("PIN length must be between 4 and 12.");
+ }
+
+ const pin = generateRandomPin(length);
+
+ if (outputMode === "PIN digits") return pin;
+
+ const format = OUTPUT_TO_FORMAT[outputMode];
+ return buildPinBlock(format, pin, pan, true);
+ }
+}
+
+/**
+ * Generates a single random decimal digit using rejection sampling.
+ * Rejects values >= 250 to ensure uniform distribution across 0–9
+ * (250 = 25 × 10, so bytes 0–249 map to exactly 25 values per digit).
+ *
+ * @returns {number}
+ */
+function randomDecimalDigit() {
+ const buf = new Uint8Array(1);
+ let b;
+ do {
+ globalThis.crypto.getRandomValues(buf);
+ b = buf[0];
+ } while (b >= 250);
+ return b % 10;
+}
+
+/**
+ * Generates a random PIN of the given length.
+ *
+ * @param {number} length
+ * @returns {string}
+ */
+function generateRandomPin(length) {
+ return Array.from({ length }, () => randomDecimalDigit()).join("");
+}
+
+export default GeneratePIN;
diff --git a/src/core/operations/GeneratePaymentMAC.mjs b/src/core/operations/GeneratePaymentMAC.mjs
new file mode 100644
index 0000000000..3842612f9d
--- /dev/null
+++ b/src/core/operations/GeneratePaymentMAC.mjs
@@ -0,0 +1,100 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { ISO9797_PADDING_METHODS, PAYMENT_MAC_METHODS, generatePaymentMac } from "../lib/PaymentMac.mjs";
+
+/**
+ * Generate payment MAC operation.
+ */
+class GeneratePaymentMAC extends Operation {
+
+ /**
+ * GeneratePaymentMAC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "MAC Generate";
+ this.module = "Payment";
+ this.description = "Paste the message data into the input field and generate a payment-oriented MAC using one payment-facing operation.
Input: message data in the selected input format. Arguments: choose the MAC method, provide either a direct key or a DUKPT BDK, optionally provide a KSN for DUKPT methods, choose the ISO9797 padding rule when applicable, and choose the truncation length.
Validation: Mixed. HMAC/CMAC rely on established primitives. ISO9797 / AS2805 and DUKPT modes are software-emulation helpers that need to be interpreted in the scope called out by each method and key context.
Security: Uses clear key material in the recipe. Do not paste production keys into shared or untrusted environments.";
+ this.inlineHelp = "Input: message data. Args: choose the payment MAC method, then provide either a direct key or a DUKPT BDK plus KSN. Validation: primitive-backed for HMAC/CMAC; broader payment semantics are profile-specific.";
+ this.testDataSamples = [
+ {
+ name: "Static AES-CMAC sample",
+ input: "1122334455667788",
+ args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", 8, false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Input format",
+ type: "option",
+ value: ["Hex", "UTF8", "Latin1", "Base64"],
+ comment: "How to decode the input field before MAC generation. Use Hex for payment test vectors expressed as hex."
+ },
+ {
+ name: "MAC method",
+ type: "option",
+ value: PAYMENT_MAC_METHODS,
+ comment: "Static-key HMAC and CMAC modes reuse the existing generic primitives. ISO9797 and AS2805 modes apply TDES-based payment MAC logic. DUKPT modes derive a TDES session key first. Note: ISO 9797-1 Algorithm 1 and Algorithm 3 are legacy MAC profiles — prefer AES-CMAC for new implementations."
+ },
+ {
+ name: "Key / BDK",
+ type: "string",
+ value: "",
+ comment: "Provide the direct MAC key for HMAC or CMAC methods, or the clear BDK for DUKPT methods."
+ },
+ {
+ name: "Key format",
+ type: "option",
+ value: ["Hex", "UTF8", "Latin1", "Base64"],
+ comment: "How to decode the key input. Assumption: DUKPT BDK input must be Hex."
+ },
+ {
+ name: "KSN (DUKPT only)",
+ type: "string",
+ value: "",
+ comment: "Required only for DUKPT MAC methods. Provide the full 10-byte KSN as 20 hex characters."
+ },
+ {
+ name: "ISO9797 padding",
+ type: "option",
+ value: ISO9797_PADDING_METHODS,
+ comment: "Used only for ISO9797 and AS2805 MAC methods. Method 1 pads with zero bytes to the next block. Method 2 appends 80 then zeros."
+ },
+ {
+ name: "Output bytes",
+ type: "number",
+ value: 8,
+ min: 1,
+ max: 64,
+ comment: "Number of leftmost MAC bytes to return. Leave at 8 for common payment truncation lengths."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false,
+ comment: "When enabled, returns the full MAC, truncation details, and key-context metadata."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [inputFormat, method, keyValue, keyFormat, ksn, paddingMethod, outputBytes, outputJson] = args;
+ const result = generatePaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, outputBytes, paddingMethod);
+ return outputJson ? JSON.stringify(result, null, 4) : result.macHex;
+ }
+}
+
+export default GeneratePaymentMAC;
diff --git a/src/core/operations/GeneratePaymentPINData.mjs b/src/core/operations/GeneratePaymentPINData.mjs
new file mode 100644
index 0000000000..df594ce0b0
--- /dev/null
+++ b/src/core/operations/GeneratePaymentPINData.mjs
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import BuildPINBlock from "./BuildPINBlock.mjs";
+
+/**
+ * Generate payment PIN data operation.
+ */
+class GeneratePaymentPINData extends Operation {
+ /**
+ * GeneratePaymentPINData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Data Generate";
+ this.module = "Payment";
+ this.description = "Paste the clear PIN into the input field and generate clear PIN-block test data.
Input: clear PIN digits. Arguments: choose the PIN-block format, provide the PAN when required, and optionally return structured JSON.
Validation: Partially verified. This wrapper currently covers clear ISO 9564 formats 0, 1, and 3 only.
Security: Clear PIN handling is test-use only.";
+ this.inlineHelp = "Input: clear PIN digits. Args: choose the block format and provide the PAN for PAN-bound formats. Validation: clear ISO formats 0, 1, and 3 only.";
+ this.testDataSamples = [
+ {
+ name: "Format 0 sample",
+ input: "__RANDOM_PIN_4__",
+ args: ["ISO Format 0", "5432101234567890", false, false]
+ }
+ ];
+ this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Format", type: "option", value: ["ISO Format 0", "ISO Format 1", "ISO Format 3"], comment: "Clear ISO 9564 format to generate. This wrapper currently supports only formats 0, 1, and 3." },
+ { name: "Primary account number", type: "string", value: "", comment: "Required for formats 0 and 3. Enter digits only." },
+ { name: "Randomize fill digits", type: "boolean", value: false, comment: "Affects only formats 1 and 3. Leave disabled for repeatable vectors." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the clear PIN block plus the source context." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [format, pan, randomizeFill, outputJson] = args;
+ const builder = new BuildPINBlock();
+ const pinBlockHex = builder.run(input, [format, pan, randomizeFill]);
+ const result = { format, pan, pinBlockHex };
+ return outputJson ? JSON.stringify(result, null, 4) : pinBlockHex;
+ }
+}
+
+export default GeneratePaymentPINData;
diff --git a/src/core/operations/GenerateTestPAN.mjs b/src/core/operations/GenerateTestPAN.mjs
new file mode 100644
index 0000000000..6534916e57
--- /dev/null
+++ b/src/core/operations/GenerateTestPAN.mjs
@@ -0,0 +1,81 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { PAN_BRANDS, MASTERCARD_SERIES, generateTestPan } from "../lib/Pan.mjs";
+
+/**
+ * Generate test PAN operation.
+ */
+class GenerateTestPAN extends Operation {
+ /**
+ * GenerateTestPAN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PAN Generate";
+ this.module = "Payment";
+ this.description = "Generate a brand-valid payment card number for test workflows.
Input: ignored. Arguments: choose the payment network, decide whether to use a curated sample or a locally generated brand-valid PAN, and choose the target length when the network supports multiple lengths.
Validation: Partially verified. Network classification and Luhn behavior are based on public numbering rules. Some curated samples are from public vendor docs, while generated samples are local deterministic test values rather than network-certified sandbox cards.
Security: Test data only. Do not treat generated PANs as live accounts.";
+ this.inlineHelp = "Input: ignored. Args: choose the network, sample mode, and target length. Validation: public numbering rules + Luhn; not all curated samples are network-published official test cards.";
+ this.testDataSamples = [
+ {
+ name: "Visa curated sample",
+ input: "",
+ args: ["Visa", "Curated sample", 16, "Any", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Network",
+ type: "option",
+ value: PAN_BRANDS,
+ comment: "Choose the payment network whose public numbering rules should be applied."
+ },
+ {
+ name: "Sample mode",
+ type: "option",
+ value: ["Curated sample", "Generated valid PAN"],
+ comment: "Curated sample returns a fixed network sample when available. Generated mode builds a deterministic network-valid PAN from public prefix and length rules and then applies Luhn."
+ },
+ {
+ name: "Target length",
+ type: "number",
+ value: 16,
+ min: 13,
+ max: 19,
+ comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length."
+ },
+ {
+ name: "Mastercard series",
+ type: "option",
+ value: MASTERCARD_SERIES,
+ comment: "Applies only when Network is Mastercard in generated mode. '5-series' restricts to the 51–55 range. '2-series' restricts to 2221–2720. 'Any' picks randomly between both ranges."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: true,
+ comment: "When enabled, returns the PAN plus the detected network, IIN, Luhn status, and source note."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [brand, mode, length, mastercardSeries, outputJson] = args;
+ const result = generateTestPan(brand, mode, length, mastercardSeries);
+ return outputJson ? JSON.stringify(result, null, 4) : result.pan;
+ }
+}
+
+export default GenerateTestPAN;
diff --git a/src/core/operations/GenerateVISAPVV.mjs b/src/core/operations/GenerateVISAPVV.mjs
new file mode 100644
index 0000000000..0b998ef4d0
--- /dev/null
+++ b/src/core/operations/GenerateVISAPVV.mjs
@@ -0,0 +1,53 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateVisaPvv } from "../lib/PaymentPinVerification.mjs";
+
+/**
+ * Generate VISA PVV operation.
+ */
+class GenerateVISAPVV extends Operation {
+ /**
+ * GenerateVISAPVV constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "VISA PVV Generate";
+ this.module = "Payment";
+ this.description = "Paste the clear PIN into the input field and generate a VISA PIN Verification Value (PVV).
Input: clear PIN digits. Arguments: provide the clear PVK in hex, PAN, and PVKI.
Validation: Partially verified. This is a clear-key software implementation of the common VISA PVV assembly pattern, not an HSM-certified PVV service.
Security: Clear PIN and PVK material are test-use only.";
+ this.inlineHelp = "Input: clear PIN digits. Args: provide PVK, PAN, and PVKI. Validation: clear-key VISA PVV helper.";
+ this.testDataSamples = [
+ {
+ name: "VISA PVV sample",
+ input: "__RANDOM_PIN_4__",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear VISA PVK as 16-byte or 24-byte hex." },
+ { name: "Primary account number", type: "string", value: "", comment: "Provide the PAN as digits only. The standard PVV input uses the rightmost 11 digits before the check digit." },
+ { name: "PVKI", type: "number", value: 1, min: 0, max: 6, comment: "PIN verification key index from 0 through 6." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the assembled PVV input and intermediate encrypted block." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [pvkHex, pan, pvki, outputJson] = args;
+ const result = generateVisaPvv(pvkHex, pan, pvki, input);
+ return outputJson ? JSON.stringify(result, null, 4) : result.pvv;
+ }
+}
+
+export default GenerateVISAPVV;
diff --git a/src/core/operations/KeyComponentCombine.mjs b/src/core/operations/KeyComponentCombine.mjs
new file mode 100644
index 0000000000..a59d851046
--- /dev/null
+++ b/src/core/operations/KeyComponentCombine.mjs
@@ -0,0 +1,103 @@
+/**
+ * @author Jacob Marks [jacob.marks@jacobmarks.com]
+ * @copyright Jacob Marks 2026
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+
+/**
+ * Key Component Combine operation
+ */
+class KeyComponentCombine extends Operation {
+
+ /**
+ * KeyComponentCombine constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Key Component Combine";
+ this.module = "Payment";
+ this.description = "Combines XOR key components into the original key. Each component is XOR'd together to reconstruct the key. Accepts 2–8 components.
Input: one hex component per line, or JSON output from Key Component Split. Plain hex output chains directly into wrap and encryption operations.";
+ this.infoURL = "";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false
+ }
+ ];
+ this.testDataSamples = [{
+ input: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\nFEDCBA98765432100123456789ABCDEF",
+ args: [false]
+ }];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [outputJson] = args;
+
+ const trimmed = input.trim();
+ if (!trimmed) throw new Error("Input is empty.");
+
+ let hexComponents;
+ if (trimmed.startsWith("{")) {
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (e) {
+ throw new Error("Invalid JSON input.");
+ }
+ if (!Array.isArray(parsed.components) || parsed.components.length === 0) {
+ throw new Error("JSON input must contain a non-empty 'components' array.");
+ }
+ hexComponents = parsed.components;
+ } else {
+ hexComponents = trimmed.split("\n")
+ .map(l => l.trim().toUpperCase().replace(/\s+/g, ""))
+ .filter(l => l.length > 0);
+ }
+
+ if (hexComponents.length < 2) throw new Error("At least 2 components are required.");
+ if (hexComponents.length > 8) throw new Error("Maximum 8 components are supported.");
+
+ for (const hex of hexComponents) {
+ if (!/^[0-9A-F]+$/.test(hex) || hex.length % 2 !== 0) {
+ throw new Error(`Invalid hex component: ${hex.slice(0, 16)}${hex.length > 16 ? "…" : ""}`);
+ }
+ }
+
+ const byteLen = hexComponents[0].length / 2;
+ if (hexComponents.some(h => h.length / 2 !== byteLen)) {
+ throw new Error("All components must be the same length.");
+ }
+
+ const result = new Uint8Array(byteLen);
+ for (const hex of hexComponents) {
+ for (let i = 0; i < byteLen; i++) {
+ result[i] ^= parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ }
+ }
+
+ const keyHex = Array.from(result, b => b.toString(16).padStart(2, "0").toUpperCase()).join("");
+
+ if (!outputJson) return keyHex;
+
+ return JSON.stringify({
+ algorithm: "XOR",
+ keyLengthBits: byteLen * 8,
+ componentCount: hexComponents.length,
+ keyHex
+ }, null, 4);
+ }
+
+}
+
+export default KeyComponentCombine;
diff --git a/src/core/operations/KeyComponentSplit.mjs b/src/core/operations/KeyComponentSplit.mjs
new file mode 100644
index 0000000000..f5b7a75507
--- /dev/null
+++ b/src/core/operations/KeyComponentSplit.mjs
@@ -0,0 +1,128 @@
+/**
+ * @author Jacob Marks [jacob.marks@jacobmarks.com]
+ * @copyright Jacob Marks 2026
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+
+/**
+ * Returns cryptographically random bytes.
+ *
+ * @param {number} n
+ * @returns {Uint8Array}
+ */
+function randomBytes(n) {
+ const buf = new Uint8Array(n);
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
+ crypto.getRandomValues(buf);
+ } else {
+ for (let i = 0; i < n; i++) buf[i] = Math.floor(Math.random() * 256);
+ }
+ return buf;
+}
+
+/**
+ * Converts a Uint8Array to an uppercase hex string.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function toHex(bytes) {
+ return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join("");
+}
+
+/**
+ * Parses a hex string to a Uint8Array.
+ *
+ * @param {string} hex
+ * @returns {Uint8Array}
+ */
+function hexToBytes(hex) {
+ const out = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < out.length; i++) {
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+ }
+ return out;
+}
+
+/**
+ * Key Component Split operation
+ */
+class KeyComponentSplit extends Operation {
+
+ /**
+ * KeyComponentSplit constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Key Component Split";
+ this.module = "Payment";
+ this.description = "Splits a symmetric key into N XOR components for key ceremony use. N-1 components are generated randomly; the final component is derived so that XOR of all N components equals the original key. Accepts 2–8 components. Recombine with Key Component Combine.
Output is one component per line (hex). Use JSON output to include component count and key length metadata.";
+ this.infoURL = "";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Number of components",
+ type: "number",
+ value: 3
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: false
+ }
+ ];
+ this.testDataSamples = [{
+ input: "0123456789ABCDEFFEDCBA9876543210",
+ args: [3, false]
+ }];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [numComponents, outputJson] = args;
+
+ const keyHex = input.trim().toUpperCase().replace(/\s+/g, "");
+ if (keyHex.length === 0) throw new Error("Input key is empty.");
+ if (!/^[0-9A-F]+$/.test(keyHex) || keyHex.length % 2 !== 0) {
+ throw new Error("Input must be a valid even-length hex string.");
+ }
+
+ const n = Math.round(numComponents);
+ if (n < 2 || n > 8) throw new Error("Number of components must be between 2 and 8.");
+
+ const keyBytes = hexToBytes(keyHex);
+ const len = keyBytes.length;
+
+ // Generate N-1 random components; last = key XOR all others
+ const components = [];
+ for (let i = 0; i < n - 1; i++) components.push(randomBytes(len));
+
+ const last = new Uint8Array(keyBytes);
+ for (const c of components) {
+ for (let i = 0; i < len; i++) last[i] ^= c[i];
+ }
+ components.push(last);
+
+ const hexComponents = components.map(toHex);
+
+ if (!outputJson) return hexComponents.join("\n");
+
+ return JSON.stringify({
+ algorithm: "XOR",
+ keyLengthBits: len * 8,
+ componentCount: n,
+ components: hexComponents
+ }, null, 4);
+ }
+
+}
+
+export default KeyComponentSplit;
diff --git a/src/core/operations/ParseEMVARPCData.mjs b/src/core/operations/ParseEMVARPCData.mjs
new file mode 100644
index 0000000000..6172b0cf1b
--- /dev/null
+++ b/src/core/operations/ParseEMVARPCData.mjs
@@ -0,0 +1,74 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import {
+ METHODS, METHOD1, METHOD2,
+ parseMethod1, parseMethod2,
+ formatJson, formatAnnotated,
+} from "../lib/EmvArpc.mjs";
+
+/**
+ * EMV Parse ARPC Data operation.
+ */
+class ParseEMVARPCData extends Operation {
+
+ /** @inheritdoc */
+ constructor() {
+ super();
+
+ this.name = "EMV Parse ARPC Data";
+ this.module = "Payment";
+ this.description = "Parse a preassembled EMV authorization-response preimage and display each field by name. Inverse of EMV Build ARPC Data.
Input: preassembled ARQC data as hex (66 hex chars / 33 bytes). Arguments: output format.
Network coverage: the 10-field layout is identical across Visa, Mastercard, Amex, Discover, JCB, and UnionPay acquirer flows. Use this as the inverse of EMV Build ARQC Data.";
+ this.inlineHelp = "Input: 33-byte CDOL1 hex block. Inverse of EMV Build ARQC Data.";
+ this.testDataSamples = [
+ {
+ name: "Standard CDOL1 parse — annotated TLV",
+ input: "000000001000000000000000084000000000000840260521 00A1B2C3D459000001",
+ args: ["Annotated TLV"]
+ },
+ {
+ name: "Standard CDOL1 parse — JSON",
+ input: "000000001000000000000000084000000000000840260521 00A1B2C3D459000001",
+ args: ["JSON"]
+ },
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Output format",
+ type: "option",
+ value: ["Annotated TLV", "JSON"],
+ comment: "Annotated TLV: one line per field with tag, length, value, and name. JSON: key-value object.",
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [fmt] = args;
+ const parsed = parseCdol1(input);
+ return fmt === "JSON" ? formatJson(parsed) : formatAnnotatedTlv(parsed);
+ }
+}
+
+export default ParseEMVARQCData;
diff --git a/src/core/operations/ParseEMVTLV.mjs b/src/core/operations/ParseEMVTLV.mjs
new file mode 100644
index 0000000000..3038737bd2
--- /dev/null
+++ b/src/core/operations/ParseEMVTLV.mjs
@@ -0,0 +1,73 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { parseEmvTlv, EMV_TAG_DICTIONARY } from "../lib/EmvTlv.mjs";
+
+/**
+ * EMV Parse TLV operation.
+ */
+class ParseEMVTLV extends Operation {
+
+ /** @inheritdoc */
+ constructor() {
+ super();
+
+ this.name = "EMV Parse TLV";
+ this.module = "Payment";
+ this.description = "Parse hex-encoded BER-TLV data (e.g., DE 55 field, ICC response, terminal data, ARQC preimage in TLV form) and annotate each tag using the built-in EMV tag dictionary.
Input: hex-encoded BER-TLV data. Output: JSON tree. Each record includes the tag hex value, name from the EMV tag dictionary, source (ICC / Terminal / Host / Both), value format, length, value in hex, and — for constructed tags — a children array with the recursively parsed inner TLVs.
Tag dictionary: covers EMV Books 1–4, EMVCo contactless Book C, and common Nexo/acquirer tags (~90 entries). Unknown tags are decoded structurally but marked with name Unknown.
Constructed tags: tags with the constructed bit set (e.g., 70, 77, 6F, A5, BF0C) are recursively parsed into child arrays.
Note: indefinite-length BER encoding is not supported; this covers the definite short- and long-form lengths used by all standard EMV cards.";
+ this.inlineHelp = "Input: hex-encoded BER-TLV (DE 55, ICC response, GPO reply, etc.). Outputs annotated JSON with EMV tag names and nested children.";
+ this.testDataSamples = [
+ {
+ name: "GPO response (Format 2): AIP=5900 + AFL",
+ input: "770A82025900940408010401",
+ args: [false]
+ },
+ {
+ name: "DE 55 fragment: ARQC cryptogram tags",
+ input: "9F2608A1B2C3D4E5F607089F2701809F360200019F10120110A0000F040000000000000000000000FF",
+ args: [false]
+ },
+ {
+ name: "Tag dictionary listing",
+ input: "",
+ args: [true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Show tag dictionary only",
+ type: "boolean",
+ value: false,
+ comment: "When enabled, ignores input and prints the full EMV tag dictionary as JSON.",
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [dictionaryMode] = args;
+
+ if (dictionaryMode) {
+ const dict = {};
+ for (const [tag, meta] of Object.entries(EMV_TAG_DICTIONARY)) {
+ dict[tag] = { name: meta.name, constructed: meta.constructed, source: meta.source, format: meta.format, class: meta.class };
+ }
+ return JSON.stringify(dict, null, 4);
+ }
+
+ const parsed = parseEmvTlv(input);
+ return JSON.stringify(parsed, null, 4);
+ }
+}
+
+export default ParseEMVTLV;
diff --git a/src/core/operations/ParseFuturexExcryptCommand.mjs b/src/core/operations/ParseFuturexExcryptCommand.mjs
new file mode 100644
index 0000000000..5941001f5a
--- /dev/null
+++ b/src/core/operations/ParseFuturexExcryptCommand.mjs
@@ -0,0 +1,177 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+
+const COMMANDS = {
+ CAAV: "Calculate Account holder Authentication Value",
+ DAPT: "Decrypt Apple Pay Token",
+ DCDK: "Decrypt Cardholder Data Using DUKPT",
+ DGPT: "Decrypt Google Pay Token",
+ DRKI: "Identification Request",
+ DRKK: "Key Request",
+ DRKV: "Key Verification Request",
+ DSPT: "Decrypt Samsung Pay Token",
+ ECDK: "Encrypt Cardholder Data Using DUKPT",
+ EMPT: "Translate PIN Block for EMV Personalization",
+ EMVA: "Verify ARQC and optionally generate ARPC",
+ EMVG: "Generate Master Key",
+ EMVK: "Derive Key from Vendor Master Key and Derivation Data",
+ EMVM: "Generate or Verify MAC",
+ EMVP: "PIN Change",
+ EMVR: "EMV RSA Private Key or Component Translation to Encryption Under a Personalization Key",
+ EMVS: "Translate an ICC Master Key to Encryption Under a Personalization Key",
+ EMVT: "EMV Translate Sensitive Data",
+ GCAV: "Generate CAVV",
+ GCIV: "Generate a CVC3 IV",
+ GCSC: "Generate American Express CSC Value",
+ GCVC: "Generate CVC and CVC2",
+ GCVV: "Generate CVV or CVC Value",
+ GDAC: "Generate a Data Authentication Code",
+ GDCV: "Generate dCVV/CVC3",
+ GDDC: "Generate Discover dynamic CVV",
+ GEMC: "Generate EMV ICC Certificate",
+ GEMQ: "Generate EMV Issuer CSR",
+ GHMC: "Generate HCE Mobile Cryptogram",
+ GHMD: "Generate HCE Magstripe Verification Value",
+ GHMK: "Generate HCE Mobile Keys",
+ GHPB: "Generate HMAC and PBKDF2 Obfuscated Value",
+ GIDN: "Generate an ICC dynamic number",
+ GMAC: "Generate Message Authentication Code",
+ GNOF: "Generate New Offset",
+ GOFC: "Generate Offset of Clear PIN",
+ GOFF: "Generate PIN offset value",
+ GOPC: "Generate Offset and EMV PIN Change",
+ GPIN: "Generate PIN",
+ GPMC: "General Purpose Symmetric MAC",
+ GVDC: "Generate dynamic CVV",
+ HMAC: "Generate MAC Hash",
+ OFPC: "Perform EMV PIN Change Using Offset",
+ ONGQ: "Translate PAN Encrypted Under an Asymmetric Key Pair to a Different Trusted Public Key",
+ PEDK: "Key Request",
+ RKHM: "Generate or Verify HMAC",
+ RPIN: "PIN Change and Optional PIN Verification",
+ SSAD: "Sign Static Authentication Data with Issuer Private Key",
+ TCDK: "Translate Cardholder Data Using DUKPT",
+ TDKD: "Translate Cardholder Data Using DUKPT and Symmetric Keys",
+ TKDR: "Translate DUKPT Data to RSA with Specific Output Data",
+ TPCP: "Translate Encrypted PIN Coordinates to a PEK for Generate New Map Collection",
+ TPDD: "Allow an encrypted ANSI PIN block to be translated",
+ TPIN: "Translate PIN blocks",
+ TRPN: "Translate PIN from RSA to Symmetric PIN Block",
+ TSPN: "Translate PIN from PIN block to RSA encryption",
+ VAAV: "Verify Account Holder Authentication Value",
+ VCAC: "Verify EMV Mastercard CAP Token",
+ VCAV: "Verify Cardholder Authentication Verification Value",
+ VCSC: "Verify American Express CSC Value",
+ VCVC: "Verify CVC and CVC2",
+ VCVV: "Verify CVV",
+ VDAC: "Verify a Data Authentication Code",
+ VDCV: "Verify CVC3",
+ VDDC: "Verify dynamic CVC value",
+ VEMI: "Verify an EMV Issuer Certificate",
+ VHMC: "Verify HCE Mobile Cryptogram",
+ VHMD: "Verify HCE Magstripe Verification Value",
+ VIDN: "Verify an ICC dynamic number",
+ VMAC: "Verify Message Authentication Code",
+ VMAP: "Verify MAC and PIN",
+ VPIN: "Verify PIN",
+ VVDC: "Verify a dynamic CVV",
+ WPIN: "Weak PIN checking",
+ XPIN: "PIN translation"
+};
+
+/**
+ * Parses an Excrypt field into tag/value components.
+ *
+ * @param {string} field
+ * @returns {{raw: string, tag: string, value: string}}
+ */
+function parseField(field) {
+ const tag = field.substring(0, Math.min(2, field.length)).toUpperCase();
+ return {
+ raw: field,
+ tag,
+ value: field.substring(tag.length)
+ };
+}
+
+/**
+ * Parse Futurex Excrypt Command operation.
+ */
+class ParseFuturexExcryptCommand extends Operation {
+
+ /**
+ * ParseFuturexExcryptCommand constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "HSM Parse Futurex Command";
+ this.module = "Payment";
+ this.description = "Paste a Futurex Excrypt command or response into the input field as text.
Scope: This operation performs syntax parsing only. It splits the bracketed message into tag/value fields and resolves the command code to a name when known. It does not interpret, validate, or execute the command — field values, key material, and transaction semantics are not checked.
General syntax: Excrypt messages are enclosed by opening and closing delimiters, typically [ and ]. Inside the message, fields are semicolon-delimited. Each field is a tag/value pair, for example AOECHO where AO is the tag and ECHO is the value. The command code is commonly carried in the AO field.
Input: raw Excrypt message text.";
+ this.inlineHelp = "Scope: syntax parser only — fields are split and labelled but not validated or executed. Syntax:[tagvalue;tagvalue;...] where fields are separated by semicolons and tags are typically two characters such as AO. Input: raw Futurex Excrypt message text.";
+ this.testDataSamples = [
+ {
+ name: "Excrypt command sample",
+ input: "[AOGMAC;FS6;RV0011223344556677;]"
+ }
+ ];
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [];
+ }
+
+ /**
+ * @param {string} input
+ * @returns {string}
+ */
+ run(input) {
+ const rawInput = (input || "").replace(/\r?\n/g, "");
+ if (!rawInput.length) {
+ throw new OperationError("No input.");
+ }
+
+ const openingDelimiterPresent = rawInput.startsWith("[");
+ const closingDelimiterPresent = rawInput.endsWith("]");
+ const body = rawInput.replace(/^\[/, "").replace(/\]$/, "");
+ const rawFields = body.split(";").filter(field => field.length > 0);
+
+ if (!rawFields.length) {
+ throw new OperationError("No Excrypt fields found.");
+ }
+
+ const fields = rawFields.map(parseField);
+ const commandField = fields.find(field => field.tag === "AO") || fields[0];
+ const commandCode = commandField.value.toUpperCase();
+ const commandName = COMMANDS[commandCode] || null;
+ const notes = [];
+
+ if (!openingDelimiterPresent || !closingDelimiterPresent) {
+ notes.push("Message is missing one or both expected Excrypt outer delimiters.");
+ }
+
+ if (!commandName) {
+ notes.push("Command code was not found in the Futurex payment integration guide lookup.");
+ }
+
+ return JSON.stringify({
+ rawInput,
+ openingDelimiterPresent,
+ closingDelimiterPresent,
+ body,
+ rawFields,
+ fields,
+ commandFieldTag: commandField.tag,
+ commandCode,
+ commandName,
+ fieldCount: fields.length,
+ notes
+ }, null, 4);
+ }
+}
+
+export default ParseFuturexExcryptCommand;
diff --git a/src/core/operations/ParsePAN.mjs b/src/core/operations/ParsePAN.mjs
new file mode 100644
index 0000000000..c89a58574a
--- /dev/null
+++ b/src/core/operations/ParsePAN.mjs
@@ -0,0 +1,45 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { parsePan } from "../lib/Pan.mjs";
+
+/**
+ * Parse PAN operation.
+ */
+class ParsePAN extends Operation {
+ /**
+ * ParsePAN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PAN Parse";
+ this.module = "Payment";
+ this.description = "Paste a payment card number into the input field and classify it by public network rules.
Input: PAN digits. Arguments: none.
Validation: Verified for Luhn behavior and public range matching used in this fork. Classification is limited to the implemented Visa, Mastercard, American Express, and Discover ranges.
Security: PANs may still be sensitive. Use test data wherever possible.";
+ this.inlineHelp = "Input: PAN digits only. Args: none. Validation: public range matching + Luhn.";
+ this.testDataSamples = [
+ {
+ name: "Discover sample",
+ input: "6011000991543426",
+ args: []
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [];
+ }
+
+ /**
+ * @param {string} input
+ * @returns {string}
+ */
+ run(input) {
+ return JSON.stringify(parsePan(input), null, 4);
+ }
+}
+
+export default ParsePAN;
diff --git a/src/core/operations/ParsePINBlock.mjs b/src/core/operations/ParsePINBlock.mjs
new file mode 100644
index 0000000000..30fffe7465
--- /dev/null
+++ b/src/core/operations/ParsePINBlock.mjs
@@ -0,0 +1,61 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { PIN_BLOCK_FORMATS, parsePinBlock } from "../lib/PinBlock.mjs";
+
+/**
+ * Parse PIN block operation
+ */
+class ParsePINBlock extends Operation {
+
+ /**
+ * ParsePINBlock constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Block Parse";
+ this.module = "Payment";
+ this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and decode it into its component fields.
Input: 8-byte clear PIN block as hex. Arguments: choose the format and provide the PAN when the format binds to PAN data.
This operation currently parses clear test PIN blocks for ISO formats 0, 1, and 3.";
+ this.inlineHelp = "Input: clear PIN block hex. Args: choose the format and provide the PAN for formats 0 and 3 so the block can be decoded.";
+ this.testDataSamples = [
+ {
+ name: "Known ISO Format 0 vector",
+ input: "041215FEDCBA9876",
+ args: ["ISO Format 0", "5432101234567890"]
+ }
+ ];
+ this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Format",
+ type: "option",
+ value: PIN_BLOCK_FORMATS,
+ comment: "Choose the format you expect the input block to decode as. The parser validates the format nibble after PAN unmasking."
+ },
+ {
+ name: "Primary account number",
+ type: "string",
+ value: "",
+ comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [format, pan] = args;
+ return JSON.stringify(parsePinBlock(format, input, pan), null, 4);
+ }
+}
+
+export default ParsePINBlock;
diff --git a/src/core/operations/ParseTR31KeyBlock.mjs b/src/core/operations/ParseTR31KeyBlock.mjs
new file mode 100644
index 0000000000..18c78239a8
--- /dev/null
+++ b/src/core/operations/ParseTR31KeyBlock.mjs
@@ -0,0 +1,292 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+
+// ── X9.143 (TR-31) lookup tables ─────────────────────────────────────────────
+
+const VERSION_IDS = {
+ A: "ANSI X9.24-1 (2009) — DEA, no MAC authentication (deprecated, insecure)",
+ B: "ANSI X9.24-1 (2009) — TDEA, Key Derivation Binding Method",
+ C: "ANSI X9.24-1 (2009) — TDEA, Key Variant Binding Method",
+ D: "ANSI X9.24-2 (2017) — AES, Key Derivation Binding Method (current PCI standard)",
+ R: "AS 2805.6.1 — Australian Standard extension",
+};
+
+const KEY_USAGE_CODES = {
+ B0: "BDK — Base Derivation Key (DUKPT)",
+ B1: "Initial DUKPT Key (IK)",
+ B2: "Base Derivation Key, version 2",
+ C0: "CVK — Card Verification Key",
+ D0: "Symmetric Data Encryption Key (DEK)",
+ D1: "Asymmetric Data Encryption Key",
+ D2: "Data Decryption Key",
+ E0: "EMV Issuer Master Key — Application Cryptogram",
+ E1: "EMV Issuer Master Key — Secure Messaging Confidentiality",
+ E2: "EMV Issuer Master Key — Secure Messaging Integrity",
+ E3: "EMV Issuer Master Key — Data Authentication Code",
+ E4: "EMV Issuer Master Key — Dynamic Number",
+ E5: "EMV Issuer Master Key — Card Personalization",
+ E6: "EMV Issuer Master Key — Session Key (DEA)",
+ I0: "Initialization Value (IV) — Encryption",
+ I1: "Initialization Value (IV) — MACs",
+ K0: "Key Encryption or Wrapping (KEK)",
+ K1: "TR-34 Asymmetric RSA Key for Key Wrapping",
+ K2: "TR-31 Key Block Protection Key (KBPK)",
+ K3: "DUKPT Key (Derived Unique Key Per Transaction)",
+ M0: "ISO 16609 MAC — Algorithm 1 (3DEA)",
+ M1: "ISO 9797-1 MAC — Algorithm 1",
+ M2: "ISO 9797-1 MAC — Algorithm 2",
+ M3: "ISO 9797-1 MAC — Algorithm 3",
+ M4: "ISO 9797-1 MAC — Algorithm 4",
+ M5: "ISO 9797-1 MAC — Algorithm 5",
+ M6: "ISO 9797-1 MAC — Algorithm 6 (CMAC; PCI default for AES)",
+ M7: "HMAC",
+ M8: "ISO 9797-1 MAC — Algorithm 3 Padded",
+ P0: "PIN Encryption",
+ S0: "Asymmetric Key Pair for Digital Signature",
+ S1: "Asymmetric Key Pair — CA Certificate",
+ S2: "Asymmetric Key Pair — Non-X9.24",
+ V0: "PIN Verification Key (PVK)",
+ V1: "PIN Verification Key — IBM 3624 PIN Offset Method",
+ V2: "PIN Verification Key — Visa PVV",
+ V3: "PIN Verification Key — PIN Change",
+ V4: "PIN Verification Key — Other",
+};
+
+const ALGORITHMS = {
+ A: "AES",
+ D: "DEA (Single DES) — PROHIBITED for new keys",
+ E: "Elliptic Curve",
+ H: "HMAC",
+ R: "RSA",
+ S: "DSA",
+ T: "Triple DEA (3DES / TDEA)",
+ "0": "Not applicable",
+};
+
+const MODES_OF_USE = {
+ B: "Both Encrypt and Decrypt / Both Generate and Verify",
+ C: "Combined MAC Generate and Verify",
+ D: "Decrypt only",
+ E: "Encrypt only",
+ G: "MAC Generate only",
+ N: "No restrictions / Not applicable",
+ S: "Secure Messaging (Sign/Verify)",
+ T: "Both Sign and Decrypt (asymmetric)",
+ V: "MAC Verify only",
+ X: "Key Derivation only",
+ Y: "Derivation Data (e.g. session keys)",
+};
+
+const EXPORTABILITY = {
+ E: "Exportable — can be wrapped under a KEK in a trusted key block",
+ N: "Non-exportable",
+ S: "Sensitive — exportable only to certain authorised systems",
+};
+
+const OPTIONAL_BLOCK_IDS = {
+ AL: "Algorithm — algorithm override for non-standard usage",
+ AT: "Asymmetric key type",
+ BI: "Key block identifier",
+ CT: "Certificate type",
+ DA: "Derivations allowed",
+ DD: "Derivation data",
+ HM: "Hash algorithm for HMAC",
+ IK: "Initial Key Identifier (AES DUKPT)",
+ IS: "Issuer identification",
+ KC: "Key check value — AES CMAC",
+ KP: "Key parity / KCV",
+ KS: "KSN Descriptor (DUKPT)",
+ LB: "Label",
+ PB: "Padding block",
+ TS: "Time stamp",
+ WP: "Wrapping key padding algorithm",
+};
+
+// ── Operation ─────────────────────────────────────────────────────────────────
+
+/**
+ * Parse TR-31 Key Block operation.
+ */
+class ParseTR31KeyBlock extends Operation {
+
+ /**
+ * ParseTR31KeyBlock constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "TR-31 Parse Key Block";
+ this.module = "Payment";
+ this.description = [
+ "Parses a TR-31 (ANSI X9.143) key block and decodes every header field into a human-readable description.",
+ "
",
+ "Input: Complete TR-31 key block string, with or without spaces.",
+ " Enable Trim leading R prefix if the block begins with a transport R.",
+ "
",
+ "The 16-character fixed header layout: V LLLL UU A M KK X CC RR",
+ " ",
+ "V=version, L=block length, U=key usage, A=algorithm,",
+ " M=mode of use, K=key version, X=exportability,",
+ " C=optional block count, R=reserved.",
+ "
",
+ "Version D (AES Key Derivation) is the current PCI PIN standard.",
+ " Versions A/B/C use TDEA or lack MAC authentication — flag for migration.",
+ "
",
+ "Input: Complete TR-34 message frame encoded as hex, including the 2-byte length prefix.",
+ "
",
+ "TR-34 defines a family of messages (B0–B9) for transporting symmetric keys using RSA.",
+ " This operation auto-detects the message type and labels each field accordingly.",
+ " The Envelope Data section is a CMS EnvelopedData (ASN.1 SEQUENCE) —",
+ " the outer tag and length are shown; full CMS parsing is not performed here.",
+ "
",
+ "Key transport flow: KDH (Key Distribution Host) wraps a symmetric key under the",
+ " KRD's RSA public key and signs the result. KRD verifies the signature, then decrypts.",
+ "
",
+ "References: ANS X9.143, ANSI TR-34, PCI PIN v3.1 Req 18-4.",
+ ].join("");
+
+ this.inlineHelp = "Input: full TR-34 message frame as hex, including the 2-byte length prefix.";
+
+ this.testDataSamples = [
+ {
+ name: "Synthetic B9 parser sample",
+ input: "001730303030423930303100112233300030303034AABBCCDD",
+ args: [],
+ },
+ ];
+
+ this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [];
+ }
+
+ /**
+ * @param {string} input
+ * @returns {string}
+ */
+ run(input) {
+ const hex = (input || "").replace(/\s+/g, "");
+
+ if (!hex.length) throw new OperationError("No input.");
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
+ throw new OperationError("Input must be hex.");
+
+ const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
+ if (bytes.length < 12) throw new OperationError("Input too short for a TR-34 frame.");
+
+ const notes = [];
+
+ // ── Outer frame ─────────────────────────────────────────────────────
+ const declaredLength = (bytes[0] << 8) | bytes[1];
+ let offset = 2;
+
+ const header = String.fromCharCode(...bytes.slice(offset, offset + 4)); offset += 4;
+ const responseType = String.fromCharCode(...bytes.slice(offset, offset + 2)); offset += 2;
+ const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2)); offset += 2;
+
+ const msgDesc = TR34_MESSAGE_TYPES[responseType] || "Unknown message type";
+ const errDesc = TR34_ERROR_CODES[errorCode] || "Unknown error code";
+
+ if (errorCode !== "00") {
+ notes.push(`Non-zero error code: ${errorCode} — ${errDesc}`);
+ }
+
+ // ── Authentication data (ASN.1 variable length) ──────────────────────
+ const authLenMeta = parseAsnLength(bytes, offset);
+ const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength;
+ const authData = bytes.slice(offset, offset + authTotalLen);
+ const authAsn = peekAsnSequence(authData);
+ offset += authTotalLen;
+
+ // ── KCV (3 bytes) ────────────────────────────────────────────────────
+ const kcv = bytes.slice(offset, offset + 3); offset += 3;
+
+ // ── Envelope data (CMS EnvelopedData, ASN.1 variable length) ─────────
+ const envLenMeta = parseAsnLength(bytes, offset);
+ const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength;
+ const envelopeData = bytes.slice(offset, offset + envTotalLen);
+ const envAsn = peekAsnSequence(envelopeData);
+ offset += envTotalLen;
+
+ // ── Signature length (4-byte ASCII decimal) ──────────────────────────
+ const sigLenAscii = String.fromCharCode(...bytes.slice(offset, offset + 4)); offset += 4;
+ const sigLength = parseInt(sigLenAscii, 10);
+ const signature = Number.isFinite(sigLength) ? bytes.slice(offset, offset + sigLength) : new Uint8Array();
+ if (Number.isFinite(sigLength)) offset += sigLength;
+
+ // ── Build output ─────────────────────────────────────────────────────
+ const out = {
+ declaredLength,
+ actualLengthExcludingLengthField: bytes.length - 2,
+ header,
+ messageType: responseType,
+ messageDescription: msgDesc,
+ errorCode,
+ errorDescription: errDesc,
+ authData: {
+ hex: hexStr(authData),
+ byteCount: authData.length,
+ asnOuter: authAsn,
+ },
+ kcvHex: hexStr(kcv),
+ envelopeData: {
+ hex: hexStr(envelopeData),
+ byteCount: envelopeData.length,
+ description: "CMS EnvelopedData — wrapped symmetric key (decrypt with KRD private RSA key)",
+ asnOuter: envAsn,
+ },
+ signatureLengthAscii: sigLenAscii,
+ signatureLength: Number.isFinite(sigLength) ? sigLength : null,
+ signatureHex: hexStr(signature),
+ trailingHex: hexStr(bytes.slice(offset)),
+ notes,
+ };
+
+ if (out.declaredLength !== bytes.length - 2) {
+ out.notes.push(
+ `Declared length ${out.declaredLength} does not match ` +
+ `actual payload length ${bytes.length - 2}.`
+ );
+ }
+
+ return JSON.stringify(out, null, 4);
+ }
+}
+
+export default ParseTR34B9Envelope;
diff --git a/src/core/operations/ParseThalesPayShieldCommand.mjs b/src/core/operations/ParseThalesPayShieldCommand.mjs
new file mode 100644
index 0000000000..38a3eb23c9
--- /dev/null
+++ b/src/core/operations/ParseThalesPayShieldCommand.mjs
@@ -0,0 +1,337 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+
+const STX = "\x02";
+const ETX = "\x03";
+const END_MESSAGE_DELIMITER = "\x19";
+
+const REQUEST_COMMANDS = {
+ AA: { responseCodes: ["AB"], names: ["Translate a TMK, TPK or PVK"], manualPages: [49] },
+ AC: { responseCodes: ["AD"], names: ["Translate a TAK"], manualPages: [54] },
+ AE: { responseCodes: ["AF"], names: ["Translate a TMK, TPK or PVK from LMK to Another TMK, TPK or PVK"], manualPages: [22] },
+ AG: { responseCodes: ["AH"], names: ["Translate a TAK from LMK to TMK Encryption"], manualPages: [24] },
+ AI: { responseCodes: ["AJ"], names: ["Encrypt Data Block with SEED algorithm"], manualPages: [161] },
+ AK: { responseCodes: ["AL"], names: ["Decrypt Data Block with SEED algorithm"], manualPages: [163] },
+ AM: { responseCodes: ["AN"], names: ["Translate Data Block with SEED algorithm"], manualPages: [165] },
+ AO: { responseCodes: ["AP"], names: ["Generate Round Key from SEED Key"], manualPages: [167] },
+ AS: { responseCodes: ["AT"], names: ["Generate a CVK Pair"], manualPages: [26] },
+ AU: { responseCodes: ["AV"], names: ["Translate a CVK Pair from LMK to ZMK Encryption"], manualPages: [45] },
+ AW: { responseCodes: ["AX"], names: ["Translate a CVK Pair from ZMK to LMK Encryption"], manualPages: [47] },
+ AY: { responseCodes: ["AZ"], names: ["Translate a CVK Pair from Old LMK to New LMK Encryption"], manualPages: [44] },
+ BI: { responseCodes: ["BJ"], names: ["Generate a BDK"], manualPages: [80] },
+ CI: { responseCodes: ["CJ"], names: ["Translate a PIN from BDK to ZPK Encryption (DUKPT)"], manualPages: [110] },
+ CK: { responseCodes: ["CL"], names: ["Verify a PIN Using the IBM Offset Method (DUKPT)"], manualPages: [112] },
+ CM: { responseCodes: ["CN"], names: ["Verify a PIN Using the ABA PVV Method (DUKPT)"], manualPages: [115] },
+ CO: { responseCodes: ["CP"], names: ["Verify a PIN Using the Diebold Method (DUKPT)"], manualPages: [117] },
+ CQ: { responseCodes: ["CR"], names: ["Verify a PIN Using the Encrypted PIN Method (DUKPT)"], manualPages: [119] },
+ DI: { responseCodes: ["DJ"], names: ["Generate and Export a KML"], manualPages: [85] },
+ DK: { responseCodes: ["DL"], names: ["Import a KML"], manualPages: [87] },
+ DM: { responseCodes: ["DN"], names: ["Verify Load Signature S1 and Generate Load Signature S2"], manualPages: [128] },
+ DO: { responseCodes: ["DP"], names: ["Verify Load Completion Signature S3"], manualPages: [130] },
+ DQ: { responseCodes: ["DR"], names: ["Verify Unload Signature S1 and Generate Unload Signature S2"], manualPages: [131] },
+ DS: { responseCodes: ["DT"], names: ["Verify Unload Completion Signature S3"], manualPages: [133] },
+ DW: { responseCodes: ["DX"], names: ["Translate a BDK from ZMK to LMK Encryption"], manualPages: [81] },
+ DY: { responseCodes: ["DZ"], names: ["Translate a BDK from LMK to ZMK Encryption"], manualPages: [83] },
+ FA: { responseCodes: ["FB"], names: ["Translate a ZPK from ZMK to LMK Encryption"], manualPages: [70] },
+ FC: { responseCodes: ["FD"], names: ["Translate a TMK, TPK or PVK from ZMK to LMK Encryption"], manualPages: [52] },
+ FE: { responseCodes: ["FF"], names: ["Translate a TMK, TPK or PVK from LMK to ZMK Encryption"], manualPages: [50] },
+ FG: { responseCodes: ["FH"], names: ["Generate a Pair of PVKs"], manualPages: [29] },
+ FI: { responseCodes: ["FJ"], names: ["Generate ZEK/ZAK"], manualPages: [32] },
+ FK: { responseCodes: ["FL"], names: ["Translate a ZEK/ZAK from ZMK to LMK Encryption"], manualPages: [65] },
+ FM: { responseCodes: ["FN"], names: ["Translate a ZEK/ZAK from LMK to ZMK Encryption"], manualPages: [63] },
+ FO: { responseCodes: ["FP"], names: ["Generate a Watchword Key"], manualPages: [31] },
+ FQ: { responseCodes: ["FR"], names: ["Translate a Watchword Key from LMK to ZMK Encryption"], manualPages: [59] },
+ FS: { responseCodes: ["FT"], names: ["Translate a Watchword Key from ZMK to LMK Encryption"], manualPages: [61] },
+ FU: { responseCodes: ["FV"], names: ["Verify a Watchword Response"], manualPages: [135] },
+ G2: { responseCodes: ["G3"], names: ["Verify an Interchange PIN using the comparison method with SEED encryption algorithm"], manualPages: [155] },
+ G4: { responseCodes: ["G5"], names: ["Verify a Terminal PIN using the comparison method with SEED encryption algorithm"], manualPages: [156] },
+ G6: { responseCodes: ["G7"], names: ["Translate a PIN from one ZPK to another ZPK with SEED encryption algorithm"], manualPages: [157] },
+ G8: { responseCodes: ["G9"], names: ["Translate a PIN from TPK to ZPK with SEED encryption algorithm"], manualPages: [159] },
+ GC: { responseCodes: ["GD"], names: ["Translate a ZPK from LMK to ZMK Encryption"], manualPages: [68] },
+ GE: { responseCodes: ["GF"], names: ["Translate a ZMK"], manualPages: [72] },
+ GG: { responseCodes: ["GH"], names: ["Form a ZMK from Three ZMK Components"], manualPages: [36] },
+ GY: { responseCodes: ["GZ"], names: ["Form a ZMK from 2 to 9 ZMK Components"], manualPages: [38] },
+ HA: { responseCodes: ["HB"], names: ["Generate a TAK"], manualPages: [20] },
+ HC: { responseCodes: ["HD"], names: ["Generate a TMK, TPK or PVK"], manualPages: [19] },
+ HE: { responseCodes: ["HF"], names: ["Encrypt Data Block"], manualPages: [107] },
+ HG: { responseCodes: ["HH"], names: ["Decrypt Data Block"], manualPages: [108] },
+ IA: { responseCodes: ["IB"], names: ["Generate a ZPK"], manualPages: [34] },
+ JS: { responseCodes: ["JT"], names: ["ARQC Verification and/or ARPC Generation (UnionPay)"], manualPages: [122] },
+ JU: { responseCodes: ["JV"], names: ["Generate Secure Message with Integrity and optional Confidentiality (UnionPay)"], manualPages: [124] },
+ KA: { responseCodes: ["KB"], names: ["Generate a Key Check Value (Not Double-Length ZMK)"], manualPages: [73] },
+ KC: { responseCodes: ["KD"], names: ["Translate a ZPK"], manualPages: [67] },
+ LK: { responseCodes: ["LL"], names: ["Generate a Decimal MAC"], manualPages: [136] },
+ LM: { responseCodes: ["LN"], names: ["Verify a Decimal MAC"], manualPages: [137] },
+ MA: { responseCodes: ["MB"], names: ["Generate a MAC"], manualPages: [90] },
+ MC: { responseCodes: ["MD"], names: ["Verify a MAC"], manualPages: [91] },
+ ME: { responseCodes: ["MF"], names: ["Verify and Translate a MAC"], manualPages: [92] },
+ MG: { responseCodes: ["MH"], names: ["Translate a TAK from LMK to ZMK Encryption"], manualPages: [55] },
+ MI: { responseCodes: ["MJ"], names: ["Translate a TAK from ZMK to LMK Encryption"], manualPages: [57] },
+ MK: { responseCodes: ["ML"], names: ["Generate a Binary MAC"], manualPages: [98] },
+ MM: { responseCodes: ["MN"], names: ["Verify a Binary MAC"], manualPages: [99] },
+ MO: { responseCodes: ["MP"], names: ["Verify and Translate a Binary MAC"], manualPages: [100] },
+ MQ: { responseCodes: ["MR"], names: ["Generate MAC (MAB) for Large Message"], manualPages: [94] },
+ MS: { responseCodes: ["MT"], names: ["Generate MAC (MAB) using ANSI X9.19 Method for a Large Message"], manualPages: [96] },
+ MU: { responseCodes: ["MV"], names: ["Generate a MAC on a Binary Message"], manualPages: [102] },
+ MW: { responseCodes: ["MX"], names: ["Verify a MAC on a Binary Message"], manualPages: [104] },
+ OC: { responseCodes: ["OD", "OZ"], names: ["Generate and Print a ZMK Component"], manualPages: [40] },
+ OE: { responseCodes: ["OF", "OZ"], names: ["Generate and Print a TMK, TPK or PVK"], manualPages: [27] },
+ R2: { responseCodes: ["R3"], names: ["Export Electronic Purse Card Key Set"], manualPages: [207] },
+ RY: { responseCodes: ["RZ"], names: ["Generate a CSCK", "Export a CSCK", "Import a CSCK"], manualPages: [75, 76, 78] },
+ T0: { responseCodes: ["T1"], names: ["Unlinked Load Transaction Request"], manualPages: [184] },
+ T2: { responseCodes: ["T3"], names: ["Release RLSAM"], manualPages: [186] },
+ T4: { responseCodes: ["T5"], names: ["Release R2LSAM"], manualPages: [187] },
+ T6: { responseCodes: ["T7"], names: ["Verify RCEP"], manualPages: [188] },
+ TA: { responseCodes: ["TB", "TZ"], names: ["Print TMK Mailer"], manualPages: [42] },
+ U0: { responseCodes: ["U1"], names: ["Decrypt R1 and validate the MACLSAM"], manualPages: [169] },
+ U2: { responseCodes: ["U3"], names: ["Compute HCEP"], manualPages: [171] },
+ U4: { responseCodes: ["U5"], names: ["Validate the S1 MAC (Load and Unload)"], manualPages: [172] },
+ U6: { responseCodes: ["U7"], names: ["Validate the S1 MAC (Currency Exchange)"], manualPages: [174] },
+ U8: { responseCodes: ["U9"], names: ["Generate the S2 MAC (Linked load, declined unlinked load, unload)"], manualPages: [176] },
+ V0: { responseCodes: ["V1"], names: ["Generate the S2 MAC (Currency Exchange)"], manualPages: [177] },
+ V2: { responseCodes: ["V3"], names: ["Generate the S2 MAC (Approved Unlinked Load)"], manualPages: [178] },
+ V4: { responseCodes: ["V5"], names: ["Validate the S3 MAC (Currency Exchange transactions)"], manualPages: [179] },
+ V6: { responseCodes: ["V7"], names: ["Validate the S3 MAC (Load or Unload transactions)"], manualPages: [181] },
+ V8: { responseCodes: ["V9"], names: ["Validate the H2LSAM"], manualPages: [183] },
+ W0: { responseCodes: ["W1"], names: ["Validate S6 MAC"], manualPages: [189] },
+ W2: { responseCodes: ["W3"], names: ["Validate S6' MAC"], manualPages: [190] },
+ W4: { responseCodes: ["W5"], names: ["Validate S6'' MAC"], manualPages: [191] },
+ W6: { responseCodes: ["W7"], names: ["Validate S5',DLT MAC"], manualPages: [192] },
+ W8: { responseCodes: ["W9"], names: ["Validate S5',ISS MAC"], manualPages: [193] },
+ X0: { responseCodes: ["X1"], names: ["Validate the S4 MAC (Old Terminals)"], manualPages: [194] },
+ X2: { responseCodes: ["X3"], names: ["Validate the S4 MAC (New Terminals)"], manualPages: [195] },
+ X4: { responseCodes: ["X5"], names: ["Validate the S5 MAC (Old Terminals)"], manualPages: [196] },
+ X6: { responseCodes: ["X7"], names: ["Validate the S5' MAC (MAC of the PSAM for a Transaction) (New Terminals)"], manualPages: [197] },
+ X8: { responseCodes: ["X9"], names: ["Validate the S5 Variant MAC (MAC of the PSAM for an Issuer Total) (New Terminals)"], manualPages: [199] },
+ XK: { responseCodes: ["XL"], names: ["Verify PIN Block from Internet and Verify MAC"], manualPages: [140] },
+ XM: { responseCodes: ["XN"], names: ["Verify PIN Block from Internet, Verify MAC & Return New Encrypted PIN"], manualPages: [142] },
+ XO: { responseCodes: ["XP"], names: ["Verify MAC"], manualPages: [144] },
+ XQ: { responseCodes: ["XR"], names: ["Generate MAC"], manualPages: [146] },
+ XS: { responseCodes: ["XT"], names: ["Translate PIN Block from Internet, Verify MAC and Optionally Generate a MAC"], manualPages: [148] },
+ XU: { responseCodes: ["XV"], names: ["Decrypt Data"], manualPages: [150] },
+ XW: { responseCodes: ["XX"], names: ["Encrypt Data"], manualPages: [152] },
+ Y0: { responseCodes: ["Y1"], names: ["Create the Acknowledgement MAC (Old Terminals)"], manualPages: [201] },
+ Y2: { responseCodes: ["Y3"], names: ["Create the Acknowledgement MAC (New Terminals)"], manualPages: [202] },
+ Y4: { responseCodes: ["Y5"], names: ["Create the Update MAC"], manualPages: [203] },
+ Y6: { responseCodes: ["Y7"], names: ["Validate the SADMIN MAC (Administrative MAC of the PSAM)"], manualPages: [204] },
+ Y8: { responseCodes: ["Y9"], names: ["Create the Merchant Acquirer MAC"], manualPages: [205] },
+ Z0: { responseCodes: ["Z1"], names: ["Validate the Card Issuer MAC"], manualPages: [206] },
+};
+
+const RESPONSE_COMMANDS = Object.entries(REQUEST_COMMANDS).reduce((responses, [requestCode, details]) => {
+ details.responseCodes.forEach((responseCode) => {
+ if (!responses[responseCode]) {
+ responses[responseCode] = {
+ requestCodes: [],
+ names: [],
+ manualPages: []
+ };
+ }
+
+ responses[responseCode].requestCodes.push(requestCode);
+ details.names.forEach((name) => {
+ if (!responses[responseCode].names.includes(name)) {
+ responses[responseCode].names.push(name);
+ }
+ });
+ details.manualPages.forEach((page) => {
+ if (!responses[responseCode].manualPages.includes(page)) {
+ responses[responseCode].manualPages.push(page);
+ }
+ });
+ });
+ return responses;
+}, {});
+
+/**
+ * Parses transport framing and trailer fields from a payShield message.
+ *
+ * @param {string} input
+ * @returns {{message: string, framing: object, messageTrailer: string}}
+ */
+function parseTransport(input) {
+ let message = input;
+ const framing = {
+ stxPresent: false,
+ etxPresent: false,
+ endMessageDelimiterPresent: false
+ };
+
+ if (message.startsWith(STX)) {
+ framing.stxPresent = true;
+ message = message.substring(1);
+ }
+
+ if (message.endsWith(ETX)) {
+ framing.etxPresent = true;
+ message = message.substring(0, message.length - 1);
+ }
+
+ let messageTrailer = "";
+ const endMessageIndex = message.lastIndexOf(END_MESSAGE_DELIMITER);
+ if (endMessageIndex !== -1) {
+ framing.endMessageDelimiterPresent = true;
+ messageTrailer = message.substring(endMessageIndex + 1);
+ message = message.substring(0, endMessageIndex);
+ }
+
+ return { message, framing, messageTrailer };
+}
+
+/**
+ * Resolves request/response metadata for a command code.
+ *
+ * @param {string} commandCode
+ * @returns {{commandCodeType: string, commandNames: string[], expectedResponseCodes: string[], requestCodes: string[], manualPages: number[]}}
+ */
+function resolveCommandMetadata(commandCode) {
+ if (REQUEST_COMMANDS[commandCode]) {
+ return {
+ commandCodeType: "request",
+ commandNames: REQUEST_COMMANDS[commandCode].names,
+ expectedResponseCodes: REQUEST_COMMANDS[commandCode].responseCodes,
+ requestCodes: [commandCode],
+ manualPages: REQUEST_COMMANDS[commandCode].manualPages
+ };
+ }
+
+ if (RESPONSE_COMMANDS[commandCode]) {
+ return {
+ commandCodeType: "response",
+ commandNames: RESPONSE_COMMANDS[commandCode].names,
+ expectedResponseCodes: [commandCode],
+ requestCodes: RESPONSE_COMMANDS[commandCode].requestCodes,
+ manualPages: RESPONSE_COMMANDS[commandCode].manualPages
+ };
+ }
+
+ return {
+ commandCodeType: "unknown",
+ commandNames: [],
+ expectedResponseCodes: [],
+ requestCodes: [],
+ manualPages: []
+ };
+}
+
+/**
+ * Parses an optional trailing LMK identifier segment from a command payload.
+ *
+ * @param {string} payload
+ * @returns {{payload: string, lmkIdentifier: string|null, lmkIdentifierDelimiterPresent: boolean, tildeDelimiterPresent: boolean}}
+ */
+function parseTrailingLmkIdentifier(payload) {
+ const match = payload.match(/^(.*?)(~)?%([0-9]{2})$/);
+ if (!match) {
+ return {
+ payload,
+ lmkIdentifier: null,
+ lmkIdentifierDelimiterPresent: false,
+ tildeDelimiterPresent: false
+ };
+ }
+
+ return {
+ payload: match[1],
+ lmkIdentifier: match[3],
+ lmkIdentifierDelimiterPresent: true,
+ tildeDelimiterPresent: Boolean(match[2])
+ };
+}
+
+/**
+ * Parse Thales payShield host command operation.
+ */
+class ParseThalesPayShieldCommand extends Operation {
+
+ /**
+ * ParseThalesPayShieldCommand constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "HSM Parse Thales Command";
+ this.module = "Payment";
+ this.description = "Paste a Thales payShield 10K legacy host command or response into the input field as text.
Scope: This operation performs syntax parsing only. It identifies framing delimiters, splits the header, command code, payload, LMK suffix, and message trailer into labelled fields. It does not interpret, validate, or execute the command payload — field values, key material, and transaction semantics are not checked.
General syntax: optional STX, then m header characters, then a 2-character command or response code, then the command payload, then optionally an LMK suffix such as %nn or ~%nn, then optionally X'19' and a message trailer, then optional ETX.
Input: raw command text, optionally including STX/ETX framing, message header, X'19' end-message delimiter, and message trailer. Arguments: provide the configured message-header length.";
+ this.inlineHelp = "Scope: syntax parser only — field values are split and labelled but not validated or executed. Syntax:[STX][header m][code 2][payload][~][%nn][EM trailer-delimiter][trailer][ETX]. Input: raw payShield command or response text. Args: set the message-header length configured on the HSM link.";
+ this.testDataSamples = [
+ {
+ name: "Encrypt Data Block with header and trailer",
+ input: "\u0002HEADHE0123456789ABCDEF0011223344556677%00\u0019TAIL\u0003",
+ args: [4]
+ }
+ ];
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Message header length",
+ type: "number",
+ value: 0,
+ min: 0,
+ max: 64,
+ comment: "Number of characters at the start of the message that should be treated as the transport header (m A in the manual)."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [messageHeaderLength] = args;
+ const rawInput = input || "";
+ const notes = [];
+
+ if (!rawInput.length) {
+ throw new OperationError("No input.");
+ }
+
+ const { message, framing, messageTrailer } = parseTransport(rawInput.replace(/\r?\n/g, ""));
+ if (message.length < messageHeaderLength + 2) {
+ throw new OperationError("Input is too short for the configured message header length plus command code.");
+ }
+
+ const messageHeader = message.substring(0, messageHeaderLength);
+ const commandCode = message.substring(messageHeaderLength, messageHeaderLength + 2).toUpperCase();
+ const payloadWithSuffixes = message.substring(messageHeaderLength + 2);
+ const lmk = parseTrailingLmkIdentifier(payloadWithSuffixes);
+ const metadata = resolveCommandMetadata(commandCode);
+
+ if (metadata.commandCodeType === "unknown") {
+ notes.push("Command code was not found in the payShield 10K Legacy Host Commands manual lookup.");
+ }
+
+ const result = {
+ rawInput,
+ framing,
+ normalizedMessage: message,
+ messageHeaderLength,
+ messageHeader,
+ commandCode,
+ commandCodeType: metadata.commandCodeType,
+ commandNames: metadata.commandNames,
+ requestCodes: metadata.requestCodes,
+ expectedResponseCodes: metadata.expectedResponseCodes,
+ manualPages: metadata.manualPages,
+ payload: lmk.payload,
+ payloadLength: lmk.payload.length,
+ lmkIdentifier: lmk.lmkIdentifier,
+ lmkIdentifierDelimiterPresent: lmk.lmkIdentifierDelimiterPresent,
+ tildeDelimiterPresentBeforeLmkIdentifier: lmk.tildeDelimiterPresent,
+ messageTrailer,
+ notes
+ };
+
+ return JSON.stringify(result, null, 4);
+ }
+}
+
+export default ParseThalesPayShieldCommand;
diff --git a/src/core/operations/ReEncryptPaymentData.mjs b/src/core/operations/ReEncryptPaymentData.mjs
new file mode 100644
index 0000000000..08a99b30e8
--- /dev/null
+++ b/src/core/operations/ReEncryptPaymentData.mjs
@@ -0,0 +1,71 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, reEncryptPaymentData } from "../lib/PaymentDataCipher.mjs";
+
+/**
+ * Re-encrypt payment data operation.
+ */
+class ReEncryptPaymentData extends Operation {
+ /**
+ * ReEncryptPaymentData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Payment Re-Encrypt Data";
+ this.module = "Payment";
+ this.description = "Paste ciphertext into the input field as hex, decrypt it under the source key context, then re-encrypt it under the target key context.
Input: source ciphertext hex. Arguments: choose source and target profiles, provide the corresponding key or BDK material, add IVs, and supply KSN plus DUKPT variant when using DUKPT profiles.";
+ this.inlineHelp = "Input: source ciphertext hex. Args: define the source decrypt context, then the target encrypt context.";
+ this.testDataSamples = [
+ {
+ name: "AES CBC to TDES CBC sample",
+ input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B",
+ args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", "TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "1234567890ABCDEF", "", "Data", false]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Source profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "How to decrypt the input ciphertext." },
+ { name: "Source key / BDK", type: "string", value: "", comment: "Source clear AES/TDES key or DUKPT BDK." },
+ { name: "Source IV (hex)", type: "string", value: "", comment: "Source IV as hex. Leave blank for ECB." },
+ { name: "Source KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT source profiles." },
+ { name: "Source DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT source profiles." },
+ { name: "Target profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "How to encrypt the recovered plaintext." },
+ { name: "Target key / BDK", type: "string", value: "", comment: "Target clear AES/TDES key or DUKPT BDK." },
+ { name: "Target IV (hex)", type: "string", value: "", comment: "Target IV as hex. Leave blank for ECB." },
+ { name: "Target KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT target profiles." },
+ { name: "Target DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT target profiles." },
+ { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns both the source decrypt and target encrypt contexts." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sourceProfile, sourceKeyHex, sourceIvHex, sourceKsn, sourceDukptVariant, targetProfile, targetKeyHex, targetIvHex, targetKsn, targetDukptVariant, outputJson] = args;
+ const result = reEncryptPaymentData(input, {
+ sourceProfile,
+ sourceKeyHex,
+ sourceIvHex,
+ sourceKsn,
+ sourceDukptVariant,
+ targetProfile,
+ targetKeyHex,
+ targetIvHex,
+ targetKsn,
+ targetDukptVariant,
+ });
+ return outputJson ? JSON.stringify(result, null, 4) : result.target.ciphertextHex;
+ }
+}
+
+export default ReEncryptPaymentData;
diff --git a/src/core/operations/TranslatePINBlock.mjs b/src/core/operations/TranslatePINBlock.mjs
new file mode 100644
index 0000000000..0b2decf4ae
--- /dev/null
+++ b/src/core/operations/TranslatePINBlock.mjs
@@ -0,0 +1,84 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { PIN_BLOCK_FORMATS, translatePinBlock } from "../lib/PinBlock.mjs";
+
+/**
+ * Translate PIN block operation
+ */
+class TranslatePINBlock extends Operation {
+
+ /**
+ * TranslatePINBlock constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Block Translate";
+ this.module = "Payment";
+ this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and translate it between supported clear block formats.
Input: 8-byte clear PIN block as hex. Arguments: choose the source and target formats, provide source and target PAN values when required, and optionally randomize target filler digits for formats 1 and 3.
This operation currently translates clear test PIN blocks for ISO formats 0, 1, and 3.
Important: PIN translation must not change the cardholder PAN. Translating a PIN block from one PAN to a different PAN is prohibited by PCI PIN security requirements. Always supply the same PAN for both source and target when the formats require it.";
+ this.inlineHelp = "Input: source clear PIN block hex. Args: choose source and target formats, then provide the source and target PAN values where the formats require them.";
+ this.testDataSamples = [
+ {
+ name: "ISO Format 0 to 1 translation",
+ input: "041215FEDCBA9876",
+ args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false]
+ }
+ ];
+ this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Source format",
+ type: "option",
+ value: PIN_BLOCK_FORMATS,
+ comment: "How the input block should be decoded before translation."
+ },
+ {
+ name: "Source PAN",
+ type: "string",
+ value: "",
+ comment: "Required when the source format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
+ },
+ {
+ name: "Target format",
+ type: "option",
+ value: PIN_BLOCK_FORMATS,
+ defaultIndex: 1,
+ comment: "The clear PIN block format to emit after decoding the source block."
+ },
+ {
+ name: "Target PAN",
+ type: "string",
+ value: "",
+ comment: "Required when the target format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit. The target PAN must match the source PAN — translating a PIN block to a different PAN is prohibited by PCI PIN security requirements."
+ },
+ {
+ name: "Randomize target fill digits",
+ type: "boolean",
+ value: false,
+ comment: "Affects only target formats 1 and 3. Leave disabled if you want repeatable vectors."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill] = args;
+ return JSON.stringify(
+ translatePinBlock(input, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill),
+ null,
+ 4
+ );
+ }
+}
+
+export default TranslatePINBlock;
diff --git a/src/core/operations/TranslatePINBlockEncrypted.mjs b/src/core/operations/TranslatePINBlockEncrypted.mjs
new file mode 100644
index 0000000000..06aa7bf13e
--- /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.",
+ "
",
+ "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/src/core/operations/VerifyCardValidationData.mjs b/src/core/operations/VerifyCardValidationData.mjs
new file mode 100644
index 0000000000..b4a0384d82
--- /dev/null
+++ b/src/core/operations/VerifyCardValidationData.mjs
@@ -0,0 +1,105 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { CVV_PROFILES, verifyCardValidationData } from "../lib/CardValidation.mjs";
+
+/**
+ * Verify card validation data operation.
+ */
+class VerifyCardValidationData extends Operation {
+
+ /**
+ * VerifyCardValidationData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "Card Validation Data Verify";
+ this.module = "Payment";
+ this.description = "Paste the combined CVK pair into the input field as hex and verify a CVV/CVC-style value for software testing.
Input: combined CVK pair as 16-byte or 24-byte hex. Arguments: select the validation-data profile, provide the PAN and expiry components, then supply the expected validation data.
Profile behaviour: CVV2/CVC2 forces service code 000 and iCVV forces 999 — the service code arg is ignored for those profiles.
This operation recomputes the validation value using the same assumptions as the generate operation and reports whether the supplied value matches.";
+ this.inlineHelp = "Input: combined CVK pair hex. Args: provide PAN, expiry, service-code context, and the validation data to check.";
+ this.testDataSamples = [
+ {
+ name: "Known CVV2 verification sample",
+ input: "0123456789ABCDEFFEDCBA9876543210",
+ args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Card_security_code";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Validation data type",
+ type: "option",
+ value: CVV_PROFILES,
+ comment: "Choose whether the supplied value should be interpreted as CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code 000 and iCVV forces 999."
+ },
+ {
+ name: "Primary account number",
+ type: "string",
+ value: "",
+ comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
+ },
+ {
+ name: "Expiry month (MM)",
+ type: "shortString",
+ value: "",
+ comment: "Two-digit month component used when assembling the expiry date."
+ },
+ {
+ name: "Expiry year (YY)",
+ type: "shortString",
+ value: "",
+ comment: "Two-digit year component used when assembling the expiry date."
+ },
+ {
+ name: "Expiry layout",
+ type: "option",
+ value: ["YYMM", "MMYY"],
+ defaultIndex: 1,
+ comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
+ },
+ {
+ name: "Service code",
+ type: "shortString",
+ value: "101",
+ comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force 000 and 999."
+ },
+ {
+ name: "Expected value",
+ type: "shortString",
+ value: "",
+ comment: "Validation data to compare against, using 1 to 5 decimal digits."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, expectedValue] = args;
+ return JSON.stringify(
+ verifyCardValidationData(
+ input,
+ pan,
+ expiryMonth,
+ expiryYear,
+ expiryLayout,
+ serviceCode,
+ profile,
+ expectedValue
+ ),
+ null,
+ 4
+ );
+ }
+}
+
+export default VerifyCardValidationData;
diff --git a/src/core/operations/VerifyEMVARQC.mjs b/src/core/operations/VerifyEMVARQC.mjs
new file mode 100644
index 0000000000..774895e830
--- /dev/null
+++ b/src/core/operations/VerifyEMVARQC.mjs
@@ -0,0 +1,59 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
+
+/**
+ * Verify EMV ARQC operation.
+ */
+class VerifyEMVARQC extends Operation {
+ /**
+ * VerifyEMVARQC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Verify ARQC";
+ this.module = "Payment";
+ this.description = "Paste the stored ARQC into the input field and verify it against an AES-CMAC recomputed from the preimage data.
Input: stored ARQC cryptogram as hex (typically 8 bytes = 16 hex chars). Arguments: provide the EMV session key, cryptogram length, the preassembled ARQC input data, and choose output format.
This operation recomputes the ARQC from the supplied preimage and key, then compares it to the input ARQC. Use this directly after EMV Generate ARQC in a recipe — the ARQC output flows naturally into this input.
Validation: Partially verified. This checks the same supplied-key AES-CMAC EMV profile as generation and does not claim full scheme-level ARQC validation semantics.
Session key derivation: In a full EMV flow the session key is derived from the issuer master key using the Application Transaction Counter (ATC) and PAN sequence number. Visa and Amex use EMV Common Session Key Derivation (Option A); Mastercard uses a different derivation (Option B). This operation expects you to supply the already-derived session key.
Security: Clear session keys are test-use only.";
+ this.inlineHelp = "Input: stored ARQC cryptogram as hex. Args: provide the AES session key, preimage data, and cryptogram length. Validation: same supplied-key EMV profile as generation.";
+ this.testDataSamples = [
+ {
+ name: "AES-CMAC ARQC verification sample",
+ input: "C1F732B52FB20CAA",
+ args: ["00112233445566778899AABBCCDDEEFF", 8, "000102030405060708090A0B0C0D0E0F", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Session key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV session key as hex. This wrapper does not derive EMV session keys." },
+ { name: "Cryptogram bytes", type: "number", value: 8, min: 1, max: 16, comment: "Number of leftmost CMAC bytes to compare." },
+ { name: "Preimage data (hex)", type: "string", value: "", comment: "Preassembled ARQC input data as hex — the same data used by EMV Generate ARQC to produce the ARQC." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed ARQC and validity result." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sessionKeyHex, cryptogramBytes, preimage, outputJson] = args;
+ const generated = generateEmvAesCmacCryptogram(preimage, sessionKeyHex, cryptogramBytes);
+ const normalizedInput = (input || "").replace(/\s+/g, "").toUpperCase();
+ const result = {
+ ...generated,
+ expectedArqcHex: normalizedInput,
+ valid: generated.cryptogramHex === normalizedInput
+ };
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyEMVARQC;
diff --git a/src/core/operations/VerifyEMVMAC.mjs b/src/core/operations/VerifyEMVMAC.mjs
new file mode 100644
index 0000000000..da479ab0e6
--- /dev/null
+++ b/src/core/operations/VerifyEMVMAC.mjs
@@ -0,0 +1,53 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { verifyEmvMac } from "../lib/EmvMac.mjs";
+
+/**
+ * Verify EMV MAC operation.
+ */
+class VerifyEMVMAC extends Operation {
+ /**
+ * VerifyEMVMAC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "EMV Verify MAC";
+ this.module = "Payment";
+ this.description = "Paste the issuer-script or EMV command payload into the input field as hex and verify an EMV MAC.
Input: message data as hex. Arguments: provide the already-derived EMV session integrity key and the expected MAC as hex.
Validation: Partially verified. This checks the same supplied-key EMV MAC profile as the generate operation and does not claim full issuer-host or scheme-specific EMV verification semantics.
Key context: In a full issuer implementation, the session integrity key used here corresponds to the secure-messaging integrity key (distinct from the confidentiality key used to encrypt data and the PIN encryption key used for PIN blocks). This operation accepts any key you supply and does not enforce that separation.
Security: Clear session keys in the recipe are test-use only.";
+ this.inlineHelp = "Input: issuer-script message data as hex. Args: provide the derived EMV session integrity key and expected MAC. Validation: same supplied-key EMV profile as generation.";
+ this.testDataSamples = [
+ {
+ name: "EMV MAC verification sample",
+ input: "8424000008999E57FD0F47CACE0007",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "22CB48394DFD1977", "Method 2", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/EMV";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV integrity session key in hex. This op does not derive EMV keys for you." },
+ { name: "Expected MAC (hex)", type: "string", value: "", comment: "Issuer-script MAC to compare against, expressed as even-length hex." },
+ { name: "Padding method", type: "option", value: ["Method 2", "Method 1"], comment: "Must match the method used during generation. Method 2 appends 0x80 then zero-pads (ISO 7816-4; standard for EMV issuer scripts). Method 1 zero-pads only." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed MAC and validity result." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [sessionKeyHex, expectedMac, paddingMethod, outputJson] = args;
+ const result = verifyEmvMac(input, sessionKeyHex, expectedMac, paddingMethod);
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyEMVMAC;
diff --git a/src/core/operations/VerifyIBM3624PIN.mjs b/src/core/operations/VerifyIBM3624PIN.mjs
new file mode 100644
index 0000000000..a135b13c2c
--- /dev/null
+++ b/src/core/operations/VerifyIBM3624PIN.mjs
@@ -0,0 +1,55 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { verifyIbm3624Pin } from "../lib/PaymentPinVerification.mjs";
+
+/**
+ * Verify IBM 3624 PIN operation.
+ */
+class VerifyIBM3624PIN extends Operation {
+ /**
+ * VerifyIBM3624PIN constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN IBM 3624 Verify";
+ this.module = "Payment";
+ this.description = "Paste the stored PIN offset into the input field and verify it against a clear PIN.
Input: stored IBM 3624 PIN offset (4 to 12 decimal digits). Arguments: provide the clear PVK in hex, decimalization table, validation data, pad character, and the clear PIN to verify.
This operation re-derives the offset from the supplied PIN and keying material and compares it to the input offset. Use this directly after PIN IBM 3624 Offset Generate in a recipe — the offset output flows naturally into this input.
Validation: Partially verified. This is the verification pair for the same clear-key IBM 3624 helper logic used by generation.
Security: Clear PIN and PVK material are test-use only.";
+ this.inlineHelp = "Input: stored IBM 3624 PIN offset. Args: provide PVK, decimalization table, validation data, pad character, and the clear PIN to verify. Validation: clear-key IBM 3624 verification helper.";
+ this.testDataSamples = [
+ {
+ name: "IBM 3624 verify sample",
+ input: "3207",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", "1234", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/IBM_3624";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear IBM 3624 PVK as 16-byte or 24-byte hex." },
+ { name: "Decimalization table", type: "string", value: "0123456789012345", comment: "Sixteen decimal digits used to map hex nibbles to decimal digits." },
+ { name: "PIN validation data", type: "string", value: "", comment: "Issuer validation data, typically PAN-derived digits, 4 to 16 digits." },
+ { name: "Pad character", type: "shortString", value: "F", comment: "Single hex nibble used to right-pad validation data to 16 nibbles." },
+ { name: "Clear PIN", type: "string", value: "", comment: "The PIN to verify. The operation re-derives the offset from this PIN and compares it to the input offset." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed offset and validity result." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [pvkHex, decimalizationTable, pinValidationData, padCharacter, pin, outputJson] = args;
+ const result = verifyIbm3624Pin(pvkHex, decimalizationTable, pinValidationData, padCharacter, input, pin);
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyIBM3624PIN;
diff --git a/src/core/operations/VerifyPaymentMAC.mjs b/src/core/operations/VerifyPaymentMAC.mjs
new file mode 100644
index 0000000000..62e4f5ed3e
--- /dev/null
+++ b/src/core/operations/VerifyPaymentMAC.mjs
@@ -0,0 +1,98 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { ISO9797_PADDING_METHODS, PAYMENT_MAC_METHODS, verifyPaymentMac } from "../lib/PaymentMac.mjs";
+
+/**
+ * Verify payment MAC operation.
+ */
+class VerifyPaymentMAC extends Operation {
+
+ /**
+ * VerifyPaymentMAC constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "MAC Verify";
+ this.module = "Payment";
+ this.description = "Paste the message data into the input field and verify a payment-oriented MAC using one payment-facing operation.
Input: message data in the selected input format. Arguments: choose the MAC method, provide either a direct key or a DUKPT BDK, add the KSN for DUKPT methods, choose the ISO9797 padding rule when applicable, and supply the expected MAC as hex.
Validation: Uses the same implementation paths and assumptions as the generate operation. Treat ISO9797, AS2805, DUKPT, and EMV-adjacent usage as profile-specific software verification rather than HSM certification.
Security: Uses clear key material in the recipe.";
+ this.inlineHelp = "Input: message data. Args: choose the payment MAC method, provide the key context, then paste the expected MAC. Validation: same assumptions as generation.";
+ this.testDataSamples = [
+ {
+ name: "Static AES-CMAC verification sample",
+ input: "1122334455667788",
+ args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", "339AF1AD1650E908", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Input format",
+ type: "option",
+ value: ["Hex", "UTF8", "Latin1", "Base64"],
+ comment: "How to decode the input field before MAC verification."
+ },
+ {
+ name: "MAC method",
+ type: "option",
+ value: PAYMENT_MAC_METHODS,
+ comment: "Static-key HMAC and CMAC modes reuse the existing generic primitives. ISO9797 and AS2805 modes apply TDES-based payment MAC logic. DUKPT modes derive a TDES session key first. Note: ISO 9797-1 Algorithm 1 and Algorithm 3 are legacy MAC profiles — prefer AES-CMAC for new implementations."
+ },
+ {
+ name: "Key / BDK",
+ type: "string",
+ value: "",
+ comment: "Provide the direct MAC key for HMAC or CMAC methods, or the clear BDK for DUKPT methods."
+ },
+ {
+ name: "Key format",
+ type: "option",
+ value: ["Hex", "UTF8", "Latin1", "Base64"],
+ comment: "How to decode the key input. Assumption: DUKPT BDK input must be Hex."
+ },
+ {
+ name: "KSN (DUKPT only)",
+ type: "string",
+ value: "",
+ comment: "Required only for DUKPT MAC methods. Provide the full 10-byte KSN as 20 hex characters."
+ },
+ {
+ name: "ISO9797 padding",
+ type: "option",
+ value: ISO9797_PADDING_METHODS,
+ comment: "Used only for ISO9797 and AS2805 MAC methods. Keep this aligned with the sender."
+ },
+ {
+ name: "Expected MAC (hex)",
+ type: "string",
+ value: "",
+ comment: "MAC value to compare against, expressed as even-length hex."
+ },
+ {
+ name: "Output as JSON",
+ type: "boolean",
+ value: true,
+ comment: "When enabled, returns the recomputed MAC, comparison target, and validity result."
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [inputFormat, method, keyValue, keyFormat, ksn, paddingMethod, expectedMac, outputJson] = args;
+ const result = verifyPaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, expectedMac, paddingMethod);
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyPaymentMAC;
diff --git a/src/core/operations/VerifyPaymentPINData.mjs b/src/core/operations/VerifyPaymentPINData.mjs
new file mode 100644
index 0000000000..6ea384fa36
--- /dev/null
+++ b/src/core/operations/VerifyPaymentPINData.mjs
@@ -0,0 +1,59 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import ParsePINBlock from "./ParsePINBlock.mjs";
+
+/**
+ * Verify payment PIN data operation.
+ */
+class VerifyPaymentPINData extends Operation {
+ /**
+ * VerifyPaymentPINData constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "PIN Data Verify";
+ this.module = "Payment";
+ this.description = "Paste a clear PIN block into the input field as hex and verify it against an expected PIN.
Input: clear PIN block hex. Arguments: choose the format, provide the PAN when required, supply the expected clear PIN, and optionally return structured JSON.
Validation: Partially verified. This wrapper currently covers clear ISO 9564 formats 0, 1, and 3 only.
Security: Clear PIN handling is test-use only.";
+ this.inlineHelp = "Input: clear PIN block hex. Args: define the PIN-block format, PAN context, expected PIN, and output format. Validation: clear ISO formats 0, 1, and 3 only.";
+ this.testDataSamples = [
+ {
+ name: "Format 0 verification sample",
+ input: "041215FEDCBA9876",
+ args: ["ISO Format 0", "5432101234567890", "1234", true]
+ }
+ ];
+ this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "Format", type: "option", value: ["ISO Format 0", "ISO Format 1", "ISO Format 3"], comment: "How to decode the input PIN block." },
+ { name: "Primary account number", type: "string", value: "", comment: "Required for formats 0 and 3." },
+ { name: "Expected PIN", type: "string", value: "", comment: "Clear PIN digits to compare against." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the parsed PIN block and validity result." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [format, pan, expectedPin, outputJson] = args;
+ const parser = new ParsePINBlock();
+ const parsed = JSON.parse(parser.run(input, [format, pan]));
+ const result = {
+ ...parsed,
+ expectedPin,
+ valid: parsed.pin === String(expectedPin || "")
+ };
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyPaymentPINData;
diff --git a/src/core/operations/VerifyVISAPVV.mjs b/src/core/operations/VerifyVISAPVV.mjs
new file mode 100644
index 0000000000..2fd381d623
--- /dev/null
+++ b/src/core/operations/VerifyVISAPVV.mjs
@@ -0,0 +1,54 @@
+/**
+ * @license Apache-2.0
+ * @author Jacob Marks [https://jacobmarks.com]
+ */
+
+import Operation from "../Operation.mjs";
+import { verifyVisaPvv } from "../lib/PaymentPinVerification.mjs";
+
+/**
+ * Verify VISA PVV operation.
+ */
+class VerifyVISAPVV extends Operation {
+ /**
+ * VerifyVISAPVV constructor.
+ */
+ constructor() {
+ super();
+
+ this.name = "VISA PVV Verify";
+ this.module = "Payment";
+ this.description = "Paste the stored PVV into the input field and verify it against a clear PIN.
Input: stored PVV (4 decimal digits). Arguments: provide the clear PVK in hex, PAN, PVKI, and the clear PIN to verify.
This operation re-derives the PVV from the supplied PIN and keying material and compares it to the input PVV. Use this directly after VISA PVV Generate in a recipe — the PVV output flows naturally into this input.
Validation: Partially verified. This is the verification pair for the same clear-key VISA PVV helper logic used by generation.
Security: Clear PIN and PVK material are test-use only.";
+ this.inlineHelp = "Input: stored PVV (4 decimal digits). Args: provide PVK, PAN, PVKI, and the clear PIN to verify. Validation: clear-key VISA PVV verification helper.";
+ this.testDataSamples = [
+ {
+ name: "VISA PVV verify sample",
+ input: "6776",
+ args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, "1234", true]
+ }
+ ];
+ this.infoURL = "https://en.wikipedia.org/wiki/ISO_9564";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear VISA PVK as 16-byte or 24-byte hex." },
+ { name: "Primary account number", type: "string", value: "", comment: "Provide the PAN as digits only. The standard PVV input uses the rightmost 11 digits before the check digit." },
+ { name: "PVKI", type: "number", value: 1, min: 0, max: 6, comment: "PIN verification key index from 0 through 6." },
+ { name: "Clear PIN", type: "string", value: "", comment: "The PIN to verify. The operation re-derives the PVV from this PIN and compares it to the input PVV." },
+ { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the assembled PVV input and validity result." },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [pvkHex, pan, pvki, pin, outputJson] = args;
+ const result = verifyVisaPvv(pvkHex, pan, pvki, pin, input);
+ return outputJson ? JSON.stringify(result, null, 4) : String(result.valid);
+ }
+}
+
+export default VerifyVISAPVV;
diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs
index 91cbed891d..d09ed060f9 100755
--- a/src/web/HTMLIngredient.mjs
+++ b/src/web/HTMLIngredient.mjs
@@ -27,6 +27,7 @@ class HTMLIngredient {
this.value = config.value;
this.disabled = config.disabled || false;
this.hint = config.hint || false;
+ this.comment = config.comment || "";
this.rows = config.rows || false;
this.target = config.target;
this.defaultIndex = config.defaultIndex || 0;
@@ -49,6 +50,7 @@ class HTMLIngredient {
toHtml() {
let html = "",
i, m, eventFn;
+ const commentHtml = this.comment ? `