diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index ee36b087..1647e904 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -591,9 +591,11 @@ "Card Validation Data Verify", "DUKPT Derive AES Key", "DUKPT Derive TDES Key", + "EMV Build ARPC Data", "EMV Build ARQC Data", "EMV Generate ARPC", "EMV Generate ARQC", + "EMV Parse ARPC Data", "EMV Parse ARQC Data", "Parse EMV TLV", "EMV Generate MAC", diff --git a/src/core/lib/EmvArpc.mjs b/src/core/lib/EmvArpc.mjs new file mode 100644 index 00000000..207d5176 --- /dev/null +++ b/src/core/lib/EmvArpc.mjs @@ -0,0 +1,167 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * ARPC preimage assembly and parsing for EMV Method 1 and Method 2. + * + * Method 1 (Visa, Amex, Discover, JCB): + * Preimage = ARQC (8 bytes) || ARC (2 bytes) → 10 bytes + * + * Method 2 (Mastercard M/Chip): + * Preimage = ARQC (8 bytes) || CSU (4 bytes) || ProprietaryAuthData (0–8 bytes) → 12–20 bytes + */ + +import OperationError from "../errors/OperationError.mjs"; + +const METHOD1 = "Method 1 (Visa/Amex/Discover)"; +const METHOD2 = "Method 2 (Mastercard)"; +const METHODS = [METHOD1, METHOD2]; + +const METHOD1_FIELDS = [ + { name: "ARQC", bytes: 8, description: "Authorization Request Cryptogram from the card" }, + { name: "ARC", bytes: 2, description: "Authorization Response Code (e.g. Y1=5931, Z1=5A31, 00=3030)" }, +]; + +const METHOD2_FIXED_FIELDS = [ + { name: "ARQC", bytes: 8, description: "Authorization Request Cryptogram from the card" }, + { name: "Card Status Update (CSU)", bytes: 4, description: "Issuer response flags (PIN change/unblock, go-online indicators)" }, +]; + +/** + * @param {string} value + * @param {string} name + * @param {number} bytes + * @returns {string} uppercase hex, validated + */ +function validateHex(value, name, bytes) { + const h = (value || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]*$/.test(h)) + throw new OperationError(`${name}: not valid hex.`); + if (h.length !== bytes * 2) + throw new OperationError(`${name}: expected ${bytes * 2} hex chars (${bytes} bytes), got ${h.length}.`); + return h; +} + +/** + * @param {string} value + * @param {string} name + * @param {number} maxBytes + * @returns {string} uppercase hex, validated (may be empty) + */ +function validateOptionalHex(value, name, maxBytes) { + const h = (value || "").replace(/\s+/g, "").toUpperCase(); + if (h.length === 0) return ""; + if (!/^[0-9A-F]+$/.test(h) || h.length % 2 !== 0) + throw new OperationError(`${name}: not valid hex.`); + if (h.length > maxBytes * 2) + throw new OperationError(`${name}: max ${maxBytes * 2} hex chars (${maxBytes} bytes), got ${h.length}.`); + return h; +} + +/** + * Build Method 1 preimage. + * @param {string} arqcHex + * @param {string} arcHex + * @returns {{ fields: object[], hex: string }} + */ +function buildMethod1(arqcHex, arcHex) { + const arqc = validateHex(arqcHex, "ARQC", 8); + const arc = validateHex(arcHex, "ARC", 2); + const fields = [ + { ...METHOD1_FIELDS[0], value: arqc }, + { ...METHOD1_FIELDS[1], value: arc }, + ]; + return { fields, hex: arqc + arc }; +} + +/** + * Build Method 2 preimage. + * @param {string} arqcHex + * @param {string} csuHex + * @param {string} padHex optional, 0–8 bytes + * @returns {{ fields: object[], hex: string }} + */ +function buildMethod2(arqcHex, csuHex, padHex) { + const arqc = validateHex(arqcHex, "ARQC", 8); + const csu = validateHex(csuHex, "Card Status Update (CSU)", 4); + const pad = validateOptionalHex(padHex, "Proprietary Auth Data", 8); + const fields = [ + { ...METHOD2_FIXED_FIELDS[0], value: arqc }, + { ...METHOD2_FIXED_FIELDS[1], value: csu }, + { name: "Proprietary Auth Data", bytes: pad.length / 2, description: "Optional issuer-specific bytes (0–8)", value: pad }, + ]; + return { fields: pad.length > 0 ? fields : fields.slice(0, 2), hex: arqc + csu + pad }; +} + +/** + * Parse Method 1 hex preimage. + * @param {string} hex + * @returns {{ fields: object[] }} + */ +function parseMethod1(hex) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!h || !/^[0-9A-F]+$/.test(h)) + throw new OperationError("Input is not valid hex."); + if (h.length !== 20) + throw new OperationError(`Method 1 preimage requires 20 hex chars (10 bytes); got ${h.length}.`); + return { + fields: [ + { ...METHOD1_FIELDS[0], value: h.substring(0, 16) }, + { ...METHOD1_FIELDS[1], value: h.substring(16, 20) }, + ] + }; +} + +/** + * Parse Method 2 hex preimage. + * @param {string} hex + * @returns {{ fields: object[] }} + */ +function parseMethod2(hex) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!h || !/^[0-9A-F]+$/.test(h)) + throw new OperationError("Input is not valid hex."); + if (h.length < 24 || h.length > 40 || h.length % 2 !== 0) + throw new OperationError(`Method 2 preimage requires 24–40 hex chars (12–20 bytes); got ${h.length}.`); + const padBytes = (h.length - 24) / 2; + const fields = [ + { ...METHOD2_FIXED_FIELDS[0], value: h.substring(0, 16) }, + { ...METHOD2_FIXED_FIELDS[1], value: h.substring(16, 24) }, + ]; + if (padBytes > 0) + fields.push({ name: "Proprietary Auth Data", bytes: padBytes, description: "Optional issuer-specific bytes (0–8)", value: h.substring(24) }); + return { fields }; +} + +/** + * Format parsed fields as JSON. + * @param {object[]} fields + * @param {string} method + * @returns {string} + */ +function formatJson(fields, method) { + const obj = { method }; + for (const f of fields) obj[f.name] = f.value; + return JSON.stringify(obj, null, 4); +} + +/** + * Format parsed fields as annotated list. + * @param {object[]} fields + * @param {string} method + * @returns {string} + */ +function formatAnnotated(fields, method) { + const header = `ARPC ${method} preimage\n${"─".repeat(50)}`; + const rows = fields.map(f => + `${f.name.padEnd(30)} ${f.value.padEnd(16)} [${f.bytes} byte${f.bytes === 1 ? "" : "s"}]` + ); + return [header, ...rows].join("\n"); +} + +export { + METHODS, METHOD1, METHOD2, + buildMethod1, buildMethod2, + parseMethod1, parseMethod2, + formatJson, formatAnnotated, +}; diff --git a/src/core/operations/BuildEMVARPCData.mjs b/src/core/operations/BuildEMVARPCData.mjs new file mode 100644 index 00000000..0c281941 --- /dev/null +++ b/src/core/operations/BuildEMVARPCData.mjs @@ -0,0 +1,103 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { + METHODS, METHOD1, METHOD2, + buildMethod1, buildMethod2, + formatJson, formatAnnotated, +} from "../lib/EmvArpc.mjs"; + +/** + * EMV Build ARPC Data operation. + */ +class BuildEMVARPCData extends Operation { + + constructor() { + super(); + + this.name = "EMV Build ARPC Data"; + this.module = "Payment"; + this.description = "Assemble the EMV authorization-response preimage from named fields and output it as hex for use with EMV Generate ARPC. All data comes from arguments — the input field is not used.

Method 1 (Visa, Amex, Discover, JCB): ARQC (8 bytes) || ARC (2 bytes) — 10 bytes total.
Method 2 (Mastercard M/Chip): ARQC (8 bytes) || CSU (4 bytes) || ProprietaryAuthData (0–8 bytes) — 12–20 bytes total.

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/ParseEMVARPCData.mjs b/src/core/operations/ParseEMVARPCData.mjs new file mode 100644 index 00000000..ffd5f2c8 --- /dev/null +++ b/src/core/operations/ParseEMVARPCData.mjs @@ -0,0 +1,73 @@ +/** + * @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 { + + 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.

Method 1 (Visa, Amex, Discover, JCB): expects exactly 20 hex chars (10 bytes) — ARQC || ARC.
Method 2 (Mastercard M/Chip): expects 24–40 hex chars (12–20 bytes) — ARQC || CSU || [ProprietaryAuthData].

Input: preassembled ARPC data as hex.
Arguments: method selector and output format."; + this.inlineHelp = "Input: hex ARPC preimage. Select method to control field layout. Inverse of EMV Build ARPC Data."; + this.testDataSamples = [ + { + name: "Method 1 parse (ARQC + ARC)", + input: "A1B2C3D4E5F607085931", + args: [METHOD1, "Annotated"] + }, + { + name: "Method 2 parse (ARQC + CSU)", + input: "A1B2C3D4E5F6070800000000", + args: [METHOD2, "Annotated"] + }, + { + name: "Method 2 parse with Proprietary Auth Data", + input: "A1B2C3D4E5F60708000000 00AABBCCDD", + args: [METHOD2, "JSON"] + }, + ]; + 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 (10 bytes). Method 2: Mastercard M/Chip (12–20 bytes).", + }, + { + name: "Output format", + type: "option", + value: ["Annotated", "JSON"], + comment: "Annotated: one line per field with name, value, and length. JSON: key-value object.", + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [method, fmt] = args; + const { fields } = method === METHOD2 ? parseMethod2(input) : parseMethod1(input); + return fmt === "JSON" ? formatJson(fields, method) : formatAnnotated(fields, method); + } +} + +export default ParseEMVARPCData; diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index c4d635fe..c9e5ed44 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -828,6 +828,74 @@ TestRegister.addTests([ } ] }, + // ── EMV Build / Parse ARPC Data ─────────────────────────────────────────── + // Method 1 (Visa/Amex): ARQC=A1B2C3D4E5F60708, ARC=5931 → 10 bytes + // Method 2 (Mastercard): ARQC=A1B2C3D4E5F60708, CSU=00000000 → 12 bytes + { + name: "EMV Build ARPC Data: Method 1 hex output", + input: "", + expectedOutput: "A1B2C3D4E5F607085931", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }] + }, + { + name: "EMV Build ARPC Data: Method 2 hex output (no PAD)", + input: "", + expectedOutput: "A1B2C3D4E5F6070800000000", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 2 (Mastercard)", "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }] + }, + { + name: "EMV Build ARPC Data: Method 2 hex output (with PAD)", + input: "", + expectedOutput: "A1B2C3D4E5F6070800000000AABBCCDD", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 2 (Mastercard)", "A1B2C3D4E5F60708", "5931", "00000000", "AABBCCDD", "Hex"] + }] + }, + { + name: "EMV Parse ARPC Data: Method 1 JSON", + input: "A1B2C3D4E5F607085931", + expectedOutput: JSON.stringify({ + method: "Method 1 (Visa/Amex/Discover)", + ARQC: "A1B2C3D4E5F60708", + ARC: "5931", + }, null, 4), + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "JSON"] + }] + }, + { + name: "EMV Parse ARPC Data: Method 2 JSON (with PAD)", + input: "A1B2C3D4E5F6070800000000AABBCCDD", + expectedOutput: JSON.stringify({ + method: "Method 2 (Mastercard)", + ARQC: "A1B2C3D4E5F60708", + "Card Status Update (CSU)": "00000000", + "Proprietary Auth Data": "AABBCCDD", + }, null, 4), + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 2 (Mastercard)", "JSON"] + }] + }, + { + name: "EMV Parse ARPC Data: wrong length for Method 1 throws", + input: "A1B2C3D4", + expectedError: true, + expectedOutput: "Error: Method 1 preimage requires 20 hex chars (10 bytes); got 8.", + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "JSON"] + }] + }, + // ── EMV Build / Parse ARQC Data ─────────────────────────────────────────── // CDOL1 sample: Visa $10.00 USD, USA terminal, date 2026-05-21 // 9F02 000000001000 9F03 000000000000 9F1A 0840 95 0000000000