From 1aa54684d8b78525eed633b7aa59c7c4233a90ef Mon Sep 17 00:00:00 2001 From: J8k3 Date: Sat, 16 May 2026 10:18:33 -0400 Subject: [PATCH] Add Thales payShield command parser --- src/core/config/Categories.json | 1 + .../ParseThalesPayShieldCommand.mjs | 337 ++++++++++++++++++ tests/operations/tests/Payment.mjs | 68 ++++ 3 files changed, 406 insertions(+) create mode 100644 src/core/operations/ParseThalesPayShieldCommand.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 93f8b501..cffaf11e 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -614,6 +614,7 @@ "Derive ECDH Key Material", "Calculate Payment KCV", "Generate AS2805 KEK Validation", + "Parse Thales payShield command", "Parse TR-31 key block", "Parse TR-34 B9 envelope", "HMAC", diff --git a/src/core/operations/ParseThalesPayShieldCommand.mjs b/src/core/operations/ParseThalesPayShieldCommand.mjs new file mode 100644 index 00000000..883c4e81 --- /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 = "Parse Thales payShield command"; + this.module = "Payment"; + this.description = "Paste a Thales payShield 10K legacy host command or response into the input field as text.

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 operation parses the visible payShield message syntax, identifies the two-character command/response code, resolves the manual command name when known, and extracts any trailing LMK identifier and message trailer."; + this.inlineHelp = "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/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index a4cfc0b8..f303a3f6 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -24,6 +24,74 @@ pPVK74bCKbeq3gIA5ZN0we6T18GSkTHtCCOG266YyCGTcE2JrnswYk1f8A== -----END PUBLIC KEY-----`; TestRegister.addTests([ + { + name: "Parse Thales payShield command: header, LMK identifier, trailer", + input: "\u0002HEADHE0123456789ABCDEF0011223344556677%00\u0019TAIL\u0003", + expectedOutput: JSON.stringify({ + rawInput: "\u0002HEADHE0123456789ABCDEF0011223344556677%00\u0019TAIL\u0003", + framing: { + stxPresent: true, + etxPresent: true, + endMessageDelimiterPresent: true + }, + normalizedMessage: "HEADHE0123456789ABCDEF0011223344556677%00", + messageHeaderLength: 4, + messageHeader: "HEAD", + commandCode: "HE", + commandCodeType: "request", + commandNames: ["Encrypt Data Block"], + requestCodes: ["HE"], + expectedResponseCodes: ["HF"], + manualPages: [107], + payload: "0123456789ABCDEF0011223344556677", + payloadLength: 32, + lmkIdentifier: "00", + lmkIdentifierDelimiterPresent: true, + tildeDelimiterPresentBeforeLmkIdentifier: false, + messageTrailer: "TAIL", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "Parse Thales payShield command", + args: [4] + } + ] + }, + { + name: "Parse Thales payShield command: trailing tilde before LMK identifier", + input: "MA0123456789ABCDEFHELLO~%12", + expectedOutput: JSON.stringify({ + rawInput: "MA0123456789ABCDEFHELLO~%12", + framing: { + stxPresent: false, + etxPresent: false, + endMessageDelimiterPresent: false + }, + normalizedMessage: "MA0123456789ABCDEFHELLO~%12", + messageHeaderLength: 0, + messageHeader: "", + commandCode: "MA", + commandCodeType: "request", + commandNames: ["Generate a MAC"], + requestCodes: ["MA"], + expectedResponseCodes: ["MB"], + manualPages: [90], + payload: "0123456789ABCDEFHELLO", + payloadLength: 21, + lmkIdentifier: "12", + lmkIdentifierDelimiterPresent: true, + tildeDelimiterPresentBeforeLmkIdentifier: true, + messageTrailer: "", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "Parse Thales payShield command", + args: [0] + } + ] + }, { name: "Parse TR-31 key block: fixed header only", input: "D0016D0AB00E0000",