Add EMV Build/Parse ARPC Data operations
- EMV Build ARPC Data: assembles ARPC preimage from named fields; Method 1 (Visa/Amex/Discover: ARQC+ARC, 10 bytes) and Method 2 (Mastercard: ARQC+CSU+optional PAD, 12-20 bytes); outputs hex (chainable into EMV Generate ARPC), JSON, or annotated - EMV Parse ARPC Data: inverse; parses hex preimage back into named fields by method - Shared lib EmvArpc.mjs with build/parse/format functions - 6 new tests in Payment.mjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
10bb87b320
commit
259b9740a7
@ -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",
|
||||
|
||||
167
src/core/lib/EmvArpc.mjs
Normal file
167
src/core/lib/EmvArpc.mjs
Normal file
@ -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,
|
||||
};
|
||||
103
src/core/operations/BuildEMVARPCData.mjs
Normal file
103
src/core/operations/BuildEMVARPCData.mjs
Normal file
@ -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 <b>EMV Generate ARPC</b>. All data comes from arguments — the input field is not used.<br><br><b>Method 1</b> (Visa, Amex, Discover, JCB): <code>ARQC (8 bytes) || ARC (2 bytes)</code> — 10 bytes total.<br><b>Method 2</b> (Mastercard M/Chip): <code>ARQC (8 bytes) || CSU (4 bytes) || ProprietaryAuthData (0–8 bytes)</code> — 12–20 bytes total.<br><br><b>Input:</b> ignored.<br><b>Arguments:</b> method selector plus one field per preimage element. Fields irrelevant to the selected method are ignored.<br><br><b>Chaining:</b> set Output format to <b>Hex</b> and place this operation first in a recipe to supply the preimage directly into <b>EMV Generate ARPC</b>.";
|
||||
this.inlineHelp = "<strong>Args:</strong> select method (1 = Visa/Amex, 2 = Mastercard) and fill the relevant fields. Set format to <strong>Hex</strong> 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;
|
||||
73
src/core/operations/ParseEMVARPCData.mjs
Normal file
73
src/core/operations/ParseEMVARPCData.mjs
Normal file
@ -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 <b>EMV Build ARPC Data</b>.<br><br><b>Method 1</b> (Visa, Amex, Discover, JCB): expects exactly 20 hex chars (10 bytes) — <code>ARQC || ARC</code>.<br><b>Method 2</b> (Mastercard M/Chip): expects 24–40 hex chars (12–20 bytes) — <code>ARQC || CSU || [ProprietaryAuthData]</code>.<br><br><b>Input:</b> preassembled ARPC data as hex.<br><b>Arguments:</b> method selector and output format.";
|
||||
this.inlineHelp = "<strong>Input:</strong> 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;
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user