diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index b5d5fea8..ee36b087 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -591,8 +591,11 @@ "Card Validation Data Verify", "DUKPT Derive AES Key", "DUKPT Derive TDES Key", + "EMV Build ARQC Data", "EMV Generate ARPC", "EMV Generate ARQC", + "EMV Parse ARQC Data", + "Parse EMV TLV", "EMV Generate MAC", "EMV Generate MAC (PIN Change)", "EMV Verify ARQC", diff --git a/src/core/lib/EmvCdol.mjs b/src/core/lib/EmvCdol.mjs new file mode 100644 index 00000000..e50e06a1 --- /dev/null +++ b/src/core/lib/EmvCdol.mjs @@ -0,0 +1,118 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; + +/** + * Standard EMVCo CDOL1 field template. + * + * This 10-field, 33-byte layout covers Visa, Mastercard, Amex, Discover, + * JCB, and UnionPay acquirer flows. Network differences (Option A vs + * Option B session-key derivation) affect key derivation upstream, not + * the structure of the CDOL1 data block itself. + */ +const CDOL1_FIELDS = [ + { tag: "9F02", name: "Amount Authorised", bytes: 6 }, + { tag: "9F03", name: "Amount Other", bytes: 6 }, + { tag: "9F1A", name: "Terminal Country Code", bytes: 2 }, + { tag: "95", name: "TVR", bytes: 5 }, + { tag: "5F2A", name: "Transaction Currency Code", bytes: 2 }, + { tag: "9A", name: "Transaction Date", bytes: 3 }, + { tag: "9C", name: "Transaction Type", bytes: 1 }, + { tag: "9F37", name: "Unpredictable Number", bytes: 4 }, + { tag: "82", name: "AIP", bytes: 2 }, + { tag: "9F36", name: "ATC", bytes: 2 }, +]; + +const CDOL1_TOTAL_BYTES = CDOL1_FIELDS.reduce((sum, f) => sum + f.bytes, 0); // 33 + +/** + * @param {string} value + * @param {string} name + * @param {number} bytes + * @returns {string} uppercase hex, validated + */ +function validateFieldHex(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[]} values — one hex string per CDOL1 field, in template order + * @returns {{ tag: string, name: string, bytes: number, value: string }[]} + */ +function buildCdol1(values) { + if (values.length !== CDOL1_FIELDS.length) + throw new OperationError(`Expected ${CDOL1_FIELDS.length} field values, got ${values.length}.`); + return CDOL1_FIELDS.map((f, i) => ({ + ...f, + value: validateFieldHex(values[i], f.name, f.bytes), + })); +} + +/** + * @param {string} hex — flat 66-char (33-byte) CDOL1 preimage + * @returns {{ tag: string, name: string, bytes: number, value: string }[]} + */ +function parseCdol1(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 < CDOL1_TOTAL_BYTES * 2) + throw new OperationError( + `Standard CDOL1 requires ${CDOL1_TOTAL_BYTES * 2} hex chars (${CDOL1_TOTAL_BYTES} bytes); got ${h.length}.` + ); + let offset = 0; + return CDOL1_FIELDS.map(f => { + const value = h.substring(offset, offset + f.bytes * 2); + offset += f.bytes * 2; + return { ...f, value }; + }); +} + +/** + * @param {{ value: string }[]} parsed + * @returns {string} flat uppercase hex + */ +function formatHex(parsed) { + return parsed.map(f => f.value).join(""); +} + +/** + * @param {{ tag: string, name: string, value: string }[]} parsed + * @returns {string} pretty-printed JSON + */ +function formatJson(parsed) { + const obj = {}; + for (const f of parsed) obj[`${f.name} (${f.tag})`] = f.value; + return JSON.stringify(obj, null, 4); +} + +/** + * @param {{ tag: string, name: string, bytes: number, value: string }[]} parsed + * @returns {string} annotated TLV lines: TAG LEN VALUE [name] + */ +function formatAnnotatedTlv(parsed) { + return parsed + .map(f => { + const lenHex = f.bytes.toString(16).padStart(2, "0").toUpperCase(); + return `${f.tag.padEnd(6)} ${lenHex} ${f.value.padEnd(12)} [${f.name}]`; + }) + .join("\n"); +} + +export { + CDOL1_FIELDS, + CDOL1_TOTAL_BYTES, + buildCdol1, + parseCdol1, + formatHex, + formatJson, + formatAnnotatedTlv, +}; diff --git a/src/core/lib/EmvTlv.mjs b/src/core/lib/EmvTlv.mjs new file mode 100644 index 00000000..addbe480 --- /dev/null +++ b/src/core/lib/EmvTlv.mjs @@ -0,0 +1,163 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * BER-TLV parser for EMV data. Handles: + * - 1- and 2-byte tags (short-form and long-form tags up to 2 bytes) + * - Short-form and long-form lengths (up to 3 length bytes) + * - Recursive constructed-tag parsing + * - EMV tag dictionary enrichment (name, source, format, class) + */ + +import OperationError from "../errors/OperationError.mjs"; +import EMV_TAG_DICTIONARY from "./EmvTlvDictionary.mjs"; + +/** + * Parse a hex string into a Uint8Array of bytes. + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + const h = hex.replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]*$/.test(h) || h.length % 2 !== 0) + throw new OperationError("Input is not valid hex (odd length or non-hex chars)."); + const bytes = new Uint8Array(h.length / 2); + for (let i = 0; i < bytes.length; i++) + bytes[i] = parseInt(h.substring(i * 2, i * 2 + 2), 16); + return bytes; +} + +/** + * Determine tag class name from the high two bits of the first tag byte. + * @param {number} firstByte + * @returns {string} + */ +function tagClassName(firstByte) { + switch ((firstByte & 0xC0) >> 6) { + case 0: return "Universal"; + case 1: return "Application"; + case 2: return "Context-Specific"; + case 3: return "Private"; + } +} + +/** + * Read one BER-TLV record starting at `offset` in `bytes`. + * Returns { tag, tagHex, rawBytes, constructed, class, length, valueBytes, offset: nextOffset }. + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {object} + */ +function readTlv(bytes, offset) { + if (offset >= bytes.length) + throw new OperationError(`Unexpected end of data at offset ${offset}.`); + + const firstByte = bytes[offset]; + const isConstructed = !!(firstByte & 0x20); + const cls = tagClassName(firstByte); + + // Tag: 1 or 2 bytes + let tagHex; + if ((firstByte & 0x1F) === 0x1F) { + // Long-form tag: second byte follows + if (offset + 1 >= bytes.length) + throw new OperationError(`Truncated long-form tag at offset ${offset}.`); + tagHex = bytes[offset].toString(16).padStart(2, "0").toUpperCase() + + bytes[offset + 1].toString(16).padStart(2, "0").toUpperCase(); + offset += 2; + } else { + tagHex = firstByte.toString(16).padStart(2, "0").toUpperCase(); + offset += 1; + } + + // Length + if (offset >= bytes.length) + throw new OperationError(`Missing length byte for tag ${tagHex}.`); + + const lenByte = bytes[offset++]; + let length; + if (lenByte === 0x80) { + throw new OperationError(`Indefinite-length form is not supported (tag ${tagHex}).`); + } else if (lenByte > 0x80) { + const numLenBytes = lenByte & 0x7F; + if (numLenBytes > 3) + throw new OperationError(`Length encoding too long (${numLenBytes} bytes) for tag ${tagHex}.`); + if (offset + numLenBytes > bytes.length) + throw new OperationError(`Truncated length field for tag ${tagHex}.`); + length = 0; + for (let i = 0; i < numLenBytes; i++) + length = (length << 8) | bytes[offset++]; + } else { + length = lenByte; + } + + if (offset + length > bytes.length) + throw new OperationError(`Value of tag ${tagHex} extends past end of data (need ${length} bytes at offset ${offset}, have ${bytes.length - offset}).`); + + const valueBytes = bytes.slice(offset, offset + length); + offset += length; + + return { tagHex, isConstructed, class: cls, length, valueBytes, nextOffset: offset }; +} + +/** + * Recursively parse all BER-TLV records in `bytes[start..end]`. + * @param {Uint8Array} bytes + * @param {number} start + * @param {number} end + * @param {number} depth + * @returns {object[]} + */ +function parseTlvSequence(bytes, start, end, depth) { + const records = []; + let offset = start; + while (offset < end) { + // Skip 0x00 padding bytes (common in EMV records) + if (bytes[offset] === 0x00) { offset++; continue; } + + const tlv = readTlv(bytes, offset); + offset = tlv.nextOffset; + + const valueHex = Array.from(tlv.valueBytes) + .map(b => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + + const dict = EMV_TAG_DICTIONARY[tlv.tagHex] || null; + + const record = { + tag: tlv.tagHex, + name: dict ? dict.name : "Unknown", + constructed: tlv.isConstructed, + class: dict ? dict.class : tlv.class, + source: dict ? dict.source : null, + format: dict ? dict.format : null, + length: tlv.length, + valueHex, + }; + + if (tlv.isConstructed && tlv.length > 0) { + try { + record.children = parseTlvSequence(tlv.valueBytes, 0, tlv.valueBytes.length, depth + 1); + } catch (_) { + record.children = []; + record.parseWarning = "Could not parse constructed value as BER-TLV."; + } + } + + records.push(record); + } + return records; +} + +/** + * Entry point: parse a hex-encoded EMV TLV blob. + * @param {string} hex + * @returns {object[]} parsed TLV records + */ +function parseEmvTlv(hex) { + const bytes = hexToBytes(hex); + if (bytes.length === 0) throw new OperationError("Input is empty."); + return parseTlvSequence(bytes, 0, bytes.length, 0); +} + +export { parseEmvTlv, EMV_TAG_DICTIONARY }; diff --git a/src/core/lib/EmvTlvDictionary.mjs b/src/core/lib/EmvTlvDictionary.mjs new file mode 100644 index 00000000..f80370b6 --- /dev/null +++ b/src/core/lib/EmvTlvDictionary.mjs @@ -0,0 +1,167 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * EMV tag dictionary covering EMV Books 1-4, EMVCo contactless, Nexo, and + * common acquirer/terminal tags. Each entry carries metadata used by the + * Parse EMV TLV operation. + * + * Sources: EMV Book 1 §A; EMV Book 3 §A; Nexo FAST 3.x; ISO 8583 DE 55 common tags. + */ + +/** + * Tag source abbreviations: + * "ICC" — generated or maintained by the card + * "T" — generated or maintained by the terminal + * "Both" — may originate from either + * "Host" — generated or maintained by the issuer host + */ + +/** + * Value format codes (EMV Book 3, Annex A): + * "a" — alphabetic + * "an" — alphanumeric + * "ans" — alphanumeric special + * "b" — binary + * "cn" — compressed numeric (BCD) + * "n" — numeric (BCD) + * "var" — variable / scheme-specific + */ + +const EMV_TAG_DICTIONARY = { + // ── File Control Information ─────────────────────────────────────────────── + "6F": { name: "File Control Information (FCI) Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "A5": { name: "FCI Proprietary Template", constructed: true, source: "ICC", format: "b", class: "Context-Specific" }, + "BF0C":{ name: "FCI Issuer Discretionary Data", constructed: true, source: "ICC", format: "b", class: "Private" }, + + // ── Record / Response Templates ──────────────────────────────────────────── + "70": { name: "Record Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "71": { name: "Issuer Script Template 1", constructed: true, source: "Host", format: "b", class: "Application" }, + "72": { name: "Issuer Script Template 2", constructed: true, source: "Host", format: "b", class: "Application" }, + "77": { name: "Response Message Template Format 2", constructed: true, source: "ICC", format: "b", class: "Application" }, + "80": { name: "Response Message Template Format 1", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "83": { name: "Command Template", constructed: false, source: "T", format: "b", class: "Context-Specific" }, + + // ── Application Labels / Identifiers ─────────────────────────────────────── + "4F": { name: "Application Identifier (AID)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "50": { name: "Application Label", constructed: false, source: "ICC", format: "an", class: "Application" }, + "84": { name: "Dedicated File (DF) Name", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "87": { name: "Application Priority Indicator", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9D": { name: "Directory Definition File (DDF) Name", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F06": { name: "Application Identifier (AID) — Terminal", constructed: false, source: "T", format: "b", class: "Application" }, + "9F11": { name: "Issuer Code Table Index", constructed: false, source: "ICC", format: "n", class: "Application" }, + "9F12": { name: "Application Preferred Name", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "9F38": { name: "Processing Options Data Object List (PDOL)",constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4D": { name: "Log Entry", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Card / Cardholder Data ───────────────────────────────────────────────── + "5A": { name: "Application PAN", constructed: false, source: "ICC", format: "cn", class: "Application" }, + "56": { name: "Track 1 Equivalent Data", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "57": { name: "Track 2 Equivalent Data", constructed: false, source: "ICC", format: "b", class: "Application" }, + "5F20": { name: "Cardholder Name", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "5F24": { name: "Application Expiry Date (YYMMDD)", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F25": { name: "Application Effective Date (YYMMDD)", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F28": { name: "Issuer Country Code", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F2D": { name: "Language Preference", constructed: false, source: "ICC", format: "an", class: "Application" }, + "5F30": { name: "Service Code", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F34": { name: "Application PAN Sequence Number", constructed: false, source: "ICC", format: "n", class: "Application" }, + + // ── Transaction Amount / Currency ────────────────────────────────────────── + "5F2A": { name: "Transaction Currency Code", constructed: false, source: "T", format: "n", class: "Application" }, + "5F36": { name: "Transaction Currency Exponent", constructed: false, source: "T", format: "n", class: "Application" }, + "9F02": { name: "Amount, Authorised", constructed: false, source: "T", format: "n", class: "Application" }, + "9F03": { name: "Amount, Other", constructed: false, source: "T", format: "n", class: "Application" }, + "9F04": { name: "Amount, Other (Binary)", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Transaction Identification ───────────────────────────────────────────── + "9A": { name: "Transaction Date", constructed: false, source: "T", format: "n", class: "Application" }, + "9C": { name: "Transaction Type", constructed: false, source: "T", format: "n", class: "Application" }, + "9F21": { name: "Transaction Time", constructed: false, source: "T", format: "n", class: "Application" }, + "9F37": { name: "Unpredictable Number", constructed: false, source: "T", format: "b", class: "Application" }, + "9F41": { name: "Transaction Sequence Counter", constructed: false, source: "T", format: "n", class: "Application" }, + "9F7C": { name: "Merchant Custom Data", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Terminal Data ────────────────────────────────────────────────────────── + "9F1A": { name: "Terminal Country Code", constructed: false, source: "T", format: "n", class: "Application" }, + "9F33": { name: "Terminal Capabilities", constructed: false, source: "T", format: "b", class: "Application" }, + "9F35": { name: "Terminal Type", constructed: false, source: "T", format: "n", class: "Application" }, + "9F40": { name: "Additional Terminal Capabilities", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1B": { name: "Terminal Floor Limit", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1C": { name: "Terminal Identification", constructed: false, source: "T", format: "an", class: "Application" }, + "9F1D": { name: "Terminal Risk Management Data", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1E": { name: "Interface Device (IFD) Serial Number", constructed: false, source: "T", format: "an", class: "Application" }, + "9F15": { name: "Merchant Category Code", constructed: false, source: "T", format: "n", class: "Application" }, + "9F16": { name: "Merchant Identifier", constructed: false, source: "T", format: "ans", class: "Application" }, + + // ── Cryptographic Data ───────────────────────────────────────────────────── + "82": { name: "Application Interchange Profile (AIP)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F26": { name: "Application Cryptogram (ARQC/TC/AAC)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F27": { name: "Cryptogram Information Data (CID)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F36": { name: "Application Transaction Counter (ATC)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F10": { name: "Issuer Application Data (IAD)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4B": { name: "Signed Dynamic Application Data", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4C": { name: "ICC Dynamic Number", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F45": { name: "Data Authentication Code", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4A": { name: "Static Data Authentication Tag List", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Risk Management ──────────────────────────────────────────────────────── + "95": { name: "Terminal Verification Results (TVR)", constructed: false, source: "T", format: "b", class: "Application" }, + "9B": { name: "Transaction Status Information (TSI)", constructed: false, source: "Both", format: "b", class: "Application" }, + "9F0D": { name: "Issuer Action Code — Default", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F0E": { name: "Issuer Action Code — Denial", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F0F": { name: "Issuer Action Code — Online", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F07": { name: "Application Usage Control", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── CDOL / Script ────────────────────────────────────────────────────────── + "8C": { name: "Card Risk Management Data Object List 1 (CDOL1)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "8D": { name: "Card Risk Management Data Object List 2 (CDOL2)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "86": { name: "Issuer Script Command", constructed: false, source: "Host", format: "b", class: "Context-Specific" }, + "9F18": { name: "Issuer Script Identifier", constructed: false, source: "Host", format: "b", class: "Application" }, + + // ── CVM ──────────────────────────────────────────────────────────────────── + "8E": { name: "Cardholder Verification Method (CVM) List", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F34": { name: "CVM Results", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Short File Identifier / AFL ─────────────────────────────────────────── + "88": { name: "Short File Identifier (SFI)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "94": { name: "Application File Locator (AFL)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "8F": { name: "Certification Authority Public Key Index", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + + // ── Issuer / Online Auth ─────────────────────────────────────────────────── + "89": { name: "Authorization Code", constructed: false, source: "Host", format: "an", class: "Context-Specific" }, + "8A": { name: "Authorization Response Code", constructed: false, source: "Host", format: "an", class: "Context-Specific" }, + "91": { name: "Issuer Authentication Data", constructed: false, source: "Host", format: "b", class: "Context-Specific" }, + "9F08": { name: "Application Version Number — ICC", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F09": { name: "Application Version Number — Terminal", constructed: false, source: "T", format: "b", class: "Application" }, + "9F0B": { name: "Cardholder Name Extended", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "9F0C": { name: "Issuer Country Code (alpha2)", constructed: false, source: "ICC", format: "a", class: "Application" }, + "9F13": { name: "Last Online ATC Register", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F14": { name: "Lower Consecutive Offline Limit", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F23": { name: "Upper Consecutive Offline Limit", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F17": { name: "PIN Try Counter", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Public Key Data ──────────────────────────────────────────────────────── + "90": { name: "Issuer Public Key Certificate", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "92": { name: "Issuer Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "93": { name: "Signed Static Application Data", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F2D": { name: "ICC PIN Encipherment Public Key Certificate",constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F2E": { name: "ICC PIN Encipherment Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F2F": { name: "ICC PIN Encipherment Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F32": { name: "Issuer Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F46": { name: "ICC Public Key Certificate", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F47": { name: "ICC Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F48": { name: "ICC Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F49": { name: "Dynamic Data Authentication Data Object List (DDOL)", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Contactless (EMVCo Book C / MSD) ────────────────────────────────────── + "9F6D": { name: "Mag-Stripe Application Version Number — Reader", constructed: false, source: "T", format: "b", class: "Application" }, + "9F6E": { name: "Third Party Data", constructed: false, source: "T", format: "b", class: "Application" }, + "9F7D": { name: "Application Capabilities Information", constructed: false, source: "ICC", format: "b", class: "Application" }, + "DF8117": { name: "Card Data Input Capability", constructed: false, source: "T", format: "b", class: "Private" }, + + // ── Directory ────────────────────────────────────────────────────────────── + "61": { name: "Application Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "73": { name: "Directory Discretionary Template", constructed: true, source: "ICC", format: "b", class: "Application" }, +}; + +export default EMV_TAG_DICTIONARY; diff --git a/src/core/operations/BuildEMVARQCData.mjs b/src/core/operations/BuildEMVARQCData.mjs new file mode 100644 index 00000000..d2eface6 --- /dev/null +++ b/src/core/operations/BuildEMVARQCData.mjs @@ -0,0 +1,103 @@ +/** + * @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 { + + 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/ParseEMVARQCData.mjs b/src/core/operations/ParseEMVARQCData.mjs new file mode 100644 index 00000000..7a4accda --- /dev/null +++ b/src/core/operations/ParseEMVARQCData.mjs @@ -0,0 +1,58 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { parseCdol1, formatJson, formatAnnotatedTlv } from "../lib/EmvCdol.mjs"; + +/** + * EMV Parse ARQC Data operation. + */ +class ParseEMVARQCData extends Operation { + + constructor() { + super(); + + this.name = "EMV Parse ARQC Data"; + this.module = "Payment"; + this.description = "Parse a preassembled EMV ARQC input data block (standard 10-field CDOL1, 33 bytes) and display each field by name and tag.

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 00000000..4342a0f1 --- /dev/null +++ b/src/core/operations/ParseEMVTLV.mjs @@ -0,0 +1,72 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { parseEmvTlv, EMV_TAG_DICTIONARY } from "../lib/EmvTlv.mjs"; + +/** + * Parse EMV TLV operation. + */ +class ParseEMVTLV extends Operation { + + constructor() { + super(); + + this.name = "Parse EMV 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/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index de8dee46..c4d635fe 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -828,6 +828,135 @@ TestRegister.addTests([ } ] }, + // ── 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 + // 5F2A 0840 9A 260521 9C 00 9F37 A1B2C3D4 82 5900 9F36 0001 + // Assembled hex (33 bytes / 66 chars): + // 00000000100000000000000008400000000000084026052100A1B2C3D459000001 + { + name: "EMV Build ARQC Data: hex output", + input: "", + expectedOutput: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Hex"] + } + ] + }, + { + name: "EMV Build ARQC Data: JSON output", + input: "", + expectedOutput: JSON.stringify({ + "Amount Authorised (9F02)": "000000001000", + "Amount Other (9F03)": "000000000000", + "Terminal Country Code (9F1A)": "0840", + "TVR (95)": "0000000000", + "Transaction Currency Code (5F2A)": "0840", + "Transaction Date (9A)": "260521", + "Transaction Type (9C)": "00", + "Unpredictable Number (9F37)": "A1B2C3D4", + "AIP (82)": "5900", + "ATC (9F36)": "0001", + }, null, 4), + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "JSON"] + } + ] + }, + { + name: "EMV Build ARQC Data: annotated TLV output", + input: "", + expectedOutput: [ + "9F02 06 000000001000 [Amount Authorised]", + "9F03 06 000000000000 [Amount Other]", + "9F1A 02 0840 [Terminal Country Code]", + "95 05 0000000000 [TVR]", + "5F2A 02 0840 [Transaction Currency Code]", + "9A 03 260521 [Transaction Date]", + "9C 01 00 [Transaction Type]", + "9F37 04 A1B2C3D4 [Unpredictable Number]", + "82 02 5900 [AIP]", + "9F36 02 0001 [ATC]", + ].join("\n"), + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Annotated TLV"] + } + ] + }, + { + name: "EMV Parse ARQC Data: annotated TLV", + input: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + expectedOutput: [ + "9F02 06 000000001000 [Amount Authorised]", + "9F03 06 000000000000 [Amount Other]", + "9F1A 02 0840 [Terminal Country Code]", + "95 05 0000000000 [TVR]", + "5F2A 02 0840 [Transaction Currency Code]", + "9A 03 260521 [Transaction Date]", + "9C 01 00 [Transaction Type]", + "9F37 04 A1B2C3D4 [Unpredictable Number]", + "82 02 5900 [AIP]", + "9F36 02 0001 [ATC]", + ].join("\n"), + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["Annotated TLV"] + } + ] + }, + { + name: "EMV Parse ARQC Data: JSON", + input: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + expectedOutput: JSON.stringify({ + "Amount Authorised (9F02)": "000000001000", + "Amount Other (9F03)": "000000000000", + "Terminal Country Code (9F1A)": "0840", + "TVR (95)": "0000000000", + "Transaction Currency Code (5F2A)": "0840", + "Transaction Date (9A)": "260521", + "Transaction Type (9C)": "00", + "Unpredictable Number (9F37)": "A1B2C3D4", + "AIP (82)": "5900", + "ATC (9F36)": "0001", + }, null, 4), + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["JSON"] + } + ] + }, + { + name: "EMV Build ARQC Data: bad field length throws", + input: "", + expectedError: true, + expectedOutput: "Error: Amount Authorised: expected 12 hex chars (6 bytes), got 4.", + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["0001", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Hex"] + } + ] + }, + { + name: "EMV Parse ARQC Data: too-short input throws", + input: "000000001000", + expectedError: true, + expectedOutput: "Error: Standard CDOL1 requires 66 hex chars (33 bytes); got 12.", + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["JSON"] + } + ] + }, { name: "Payment Encrypt Data: AES CBC", input: "00112233445566778899AABBCCDDEEFF", @@ -1343,6 +1472,53 @@ TestRegister.addTests([ ] }, + // ── Parse EMV TLV ───────────────────────────────────────────────────────── + { + name: "Parse EMV TLV: GPO Format 2 (constructed 77 > AIP + AFL)", + input: "770A82025900940408010401", + expectedOutput: JSON.stringify([ + { + tag: "77", name: "Response Message Template Format 2", + constructed: true, class: "Application", source: "ICC", format: "b", + length: 10, valueHex: "82025900940408010401", + children: [ + { tag: "82", name: "Application Interchange Profile (AIP)", constructed: false, class: "Context-Specific", source: "ICC", format: "b", length: 2, valueHex: "5900" }, + { tag: "94", name: "Application File Locator (AFL)", constructed: false, class: "Context-Specific", source: "ICC", format: "b", length: 4, valueHex: "08010401" }, + ], + }, + ], null, 4), + recipeConfig: [{ op: "Parse EMV TLV", args: [false] }] + }, + { + name: "Parse EMV TLV: primitive tags (ARQC / CID / ATC)", + input: "9F2608A1B2C3D4E5F607089F2701809F360200 01", + expectedOutput: JSON.stringify([ + { tag: "9F26", name: "Application Cryptogram (ARQC/TC/AAC)", constructed: false, class: "Application", source: "ICC", format: "b", length: 8, valueHex: "A1B2C3D4E5F60708" }, + { tag: "9F27", name: "Cryptogram Information Data (CID)", constructed: false, class: "Application", source: "ICC", format: "b", length: 1, valueHex: "80" }, + { tag: "9F36", name: "Application Transaction Counter (ATC)",constructed: false, class: "Application", source: "ICC", format: "b", length: 2, valueHex: "0001" }, + ], null, 4), + recipeConfig: [{ op: "Parse EMV TLV", args: [false] }] + }, + { + name: "Parse EMV TLV: unknown tag decoded structurally", + input: "FF0203AABBCC", + expectedMatch: /"name":\s*"Unknown"/, + recipeConfig: [{ op: "Parse EMV TLV", args: [false] }] + }, + { + name: "Parse EMV TLV: dictionary mode returns tag index", + input: "", + expectedMatch: /"9F26":/, + recipeConfig: [{ op: "Parse EMV TLV", args: [true] }] + }, + { + name: "Parse EMV TLV: bad hex throws", + input: "GG", + expectedError: true, + expectedOutput: "Error: Input is not valid hex (odd length or non-hex chars).", + recipeConfig: [{ op: "Parse EMV TLV", args: [false] }] + }, + // ── PIN Block Translate Encrypted ───────────────────────────────────────── // Vectors: PIN=1234, PAN=5432101234567890 // clear Format 0 block : 041215FEDCBA9876