Add payment validation and EMV test operations

This commit is contained in:
J8k3 2026-04-25 00:00:22 -04:00
parent 89800059f5
commit 7433b07f2b
30 changed files with 3759 additions and 584 deletions

View File

@ -0,0 +1,262 @@
# AWS Payment Cryptography Recipe Coverage
This guide maps AWS Payment Cryptography Data Plane operations to CyberChef recipe starters.
Intent:
- This fork is not a certified HSM.
- It is intended to emulate HSM-style payment cryptography behavior in software for development, QA, regression, interoperability, and integration testing.
- The goal of this guide is therefore twofold:
1. document what can already be emulated with the current operation set
2. identify which AWS Payment Cryptography use cases should be added next to improve test-harness coverage
Source baseline:
- AWS Payment Cryptography Data Plane API Reference: https://docs.aws.amazon.com/payment-cryptography/latest/DataAPIReference/Welcome.html
- AWS Data Plane actions list: https://docs.aws.amazon.com/payment-cryptography/latest/DataAPIReference/API_Operations.html
Coverage legend:
- `Direct`: CyberChef can reproduce the core cryptographic shape of the AWS operation.
- `Partial`: CyberChef can help with preimage assembly, derivation, or one stage of the flow, but not the full AWS behavior.
- `Not yet implemented`: This is a valid testing/emulation target for the fork, but the required payment primitives are not implemented yet.
## Coverage Summary
| AWS operation | Coverage | Notes |
| --- | --- | --- |
| `EncryptData` | `Direct` / `Partial` | Direct for AES, TDES, RSA. Partial for DUKPT and EMV-derived encryption. |
| `DecryptData` | `Direct` / `Partial` | Direct for AES, TDES, RSA. Partial for DUKPT and EMV-derived decryption. |
| `ReEncryptData` | `Direct` / `Partial` | Direct for plain decrypt-then-encrypt workflows. Partial for DUKPT re-encryption. |
| `GenerateMac` | `Direct` / `Partial` | Direct for HMAC and CMAC. Partial for DUKPT MAC and EMV MAC flows. |
| `VerifyMac` | `Direct` / `Partial` | Direct by recomputing and comparing HMAC/CMAC. Partial for DUKPT MAC and EMV MAC flows. |
| `VerifyAuthRequestCryptogram` | `Partial` | Usable for AES-CMAC ARQC/ARPC-style checking when session key and preimage are already known. Dedicated ARQC and ARPC generators now exist for that constrained profile. |
| `TranslateKeyMaterial` | `Partial` | Useful for ECDH derivation and TR-31 inspection, not full HSM-side rewrap semantics. |
| `GenerateCardValidationData` | `Direct` | Direct for software CVV/CVV2/iCVV generation when the combined CVK pair is provided as clear hex. |
| `VerifyCardValidationData` | `Direct` | Direct for software CVV/CVV2/iCVV verification using the same clear-CVK assumptions as generation. |
| `GeneratePinData` | `Partial` | Clear PIN-block build coverage now exists for ISO formats 0, 1, and 3. PVV, IBM3624, and encrypted-generation paths are still missing. |
| `TranslatePinData` | `Partial` | Clear PIN-block parse and translate coverage now exists for ISO formats 0, 1, and 3. Encrypted PEK/BDK/ECDH translation is still missing. |
| `VerifyPinData` | `Partial` | Clear PIN-block decoding exists, but PVV / IBM3624 verification behavior is still missing. |
| `GenerateMacEmvPinChange` | `Not yet implemented` | Requires issuer-script PIN-change building blocks. |
| `GenerateAs2805KekValidation` | `Not yet implemented` | Requires AS2805-specific KEK-validation primitives. |
## Direct Recipe Starters
## 1) AWS `EncryptData`: AES / TDES / RSA
Operations:
- `AES Encrypt` or `Triple DES Encrypt` or `RSA Encrypt`
Suggested use:
- Paste the AWS `PlainText` hexBinary value into the input field.
- Set the operation input mode to `Hex` and output mode to `Hex`.
- Paste the key into the key argument using the correct format selector.
- Match the AWS algorithm and mode manually in the chosen CyberChef operation.
Notes:
- AWS documents `EncryptData` as supporting symmetric `TDES` and `AES`, asymmetric `RSA`, and derived `DUKPT` or `EMV` schemes.
- This starter directly covers only the non-derived AES, TDES, and RSA cases.
## 2) AWS `DecryptData`: AES / TDES / RSA
Operations:
- `AES Decrypt` or `Triple DES Decrypt` or `RSA Decrypt`
Suggested use:
- Paste the AWS `CipherText` hexBinary value into the input field.
- Set the operation input mode to `Hex` and output mode to `Hex` or `Raw`.
- Paste the key into the key argument using the correct format selector.
- Match the AWS algorithm and mode manually in the chosen CyberChef operation.
## 3) AWS `ReEncryptData`: Symmetric Rewrap
Operations:
- `AES Decrypt` or `Triple DES Decrypt`
- `AES Encrypt` or `Triple DES Encrypt`
Suggested use:
- Paste the incoming ciphertext into the input field as hex.
- First decrypt with the incoming key and mode.
- Then encrypt with the outgoing key and mode.
Notes:
- This covers the software-visible decrypt-then-encrypt pattern.
- It does not model AWS wrapped-key handling or HSM-side key custody.
## 4) AWS `GenerateMac`: HMAC
Operations:
- `From Hex`
- `HMAC`
- `Take bytes`
Suggested use:
- Paste the AWS `MessageData` hexBinary value into the input field.
- Run `From Hex`.
- Run `HMAC` with the appropriate key and hash function.
- If AWS truncates the MAC, use `Take bytes` to keep the leftmost bytes that match `MacLength`.
## 5) AWS `GenerateMac`: CMAC
Operations:
- `From Hex`
- `CMAC`
- `Take bytes`
Suggested use:
- Paste the AWS `MessageData` hexBinary value into the input field.
- Run `From Hex`.
- Run `CMAC` with `Encryption algorithm` set to `AES` or `Triple DES`.
- Use `Take bytes` to match the requested `MacLength` if truncation is required.
## 6) AWS `VerifyMac`: Recompute And Compare
Operations:
- `From Hex`
- `HMAC` or `CMAC`
- `Take bytes`
Suggested use:
- Recompute the MAC using the same starter as `GenerateMac`.
- Compare the result to the AWS `Mac` value manually or with a follow-on comparison recipe.
## 7) AWS `GenerateCardValidationData`: CVV / CVV2 / iCVV
Operations:
- `Generate card validation data`
Suggested use:
- Paste the clear combined CVK pair into the input field as hex.
- Choose the profile that matches the AWS card-validation mode you want to emulate.
- Provide the PAN, expiry, and service-code context in the argument fields.
Notes:
- This directly covers software generation of CVV/CVV2/iCVV-style values.
- Assumption: CVV2 forces service code `000` and iCVV forces `999`.
## 8) AWS `VerifyCardValidationData`: CVV / CVV2 / iCVV
Operations:
- `Verify card validation data`
Suggested use:
- Use the same card context as generation, then supply the incoming value in the `Expected value` argument.
- The operation recomputes the value and returns structured verification output.
Notes:
- This is intended for software parity and regression checks.
- It does not emulate AWS key custody or HSM-side audit semantics.
## Partial Recipe Starters
## 9) AWS `EncryptData` / `DecryptData`: DUKPT-Derived Symmetric Flows
Operations:
- `Derive DUKPT key`
- `AES Encrypt` or `AES Decrypt` or `Triple DES Encrypt` or `Triple DES Decrypt`
Suggested use:
- Derive the transaction key from BDK and KSN first.
- Feed the derived key into the cipher operation that matches your target algorithm.
Notes:
- This is useful for offline vector work.
- It does not claim one-to-one parity with every AWS DUKPT encryption attribute combination.
## 10) AWS `GenerateMac` / `VerifyMac`: DUKPT MAC
Operations:
- `Derive DUKPT key`
- `From Hex`
- `CMAC` or `HMAC`
- `Take bytes`
Suggested use:
- Derive the transaction key from BDK and KSN.
- Convert `MessageData` from hex and generate the MAC using the derived key.
Notes:
- Treat this as a lab starter, not proof of parity with AWSs full DUKPT MAC union attributes.
## 11) AWS `VerifyAuthRequestCryptogram`: EMV ARQC Check
Operations:
- `Generate EMV ARQC`
Suggested use:
- Paste the already-assembled EMV authorization-request preimage into the input field as hex.
- Provide the already-derived AES session key and cryptogram length.
- Compare the result to the incoming ARQC.
Notes:
- This is only practical when the session key and exact preimage assembly are already known.
- It is a good fit for AES-CMAC-based profiles, not a full generic EMV verifier.
## 12) AWS `TranslateKeyMaterial`: ECDH And Wrapped-Key Inspection
Operations:
- `Derive ECDH key material`
- `Parse TR-31 key block`
- `Parse TR-34 B9 envelope`
Suggested use:
- Use `Derive ECDH key material` to reproduce the shared-secret or KDF stage.
- Use the TR-31 or TR-34 parsers to inspect the wrapped key containers involved in the exchange.
Notes:
- This helps with interoperability debugging.
- It does not recreate AWSs HSM-side translate-and-rewrap behavior.
## 13) AWS `GenerateMac`: EMV MAC Preimage Review
Operations:
- `From Hex`
- `CMAC`
- `Take bytes`
Suggested use:
- Use this to validate assembled EMV message blocks and truncation behavior when you already know the scheme profile and session key.
Notes:
- AWS documents `GenerateMac` as supporting EMV MAC.
- This fork does not yet have a dedicated EMV MAC operation, so this remains a profile-specific starter rather than a generic implementation.
## 14) AWS `GeneratePinData`: Clear PIN Block Build
Operations:
- `Build PIN block`
Suggested use:
- Paste the clear PIN into the input field.
- Choose ISO format 0, 1, or 3.
- Provide the PAN when the selected format requires it.
Notes:
- This is useful for software test harnesses that need deterministic clear PIN-block construction before encryption.
- It does not yet implement PVV generation, IBM 3624 offsets, or encrypted AWS response semantics.
## 15) AWS `TranslatePinData`: Clear PIN Block Translation
Operations:
- `Translate PIN block`
Suggested use:
- Paste the source clear PIN block into the input field as hex.
- Choose the source and target formats.
- Provide source and target PAN values where required.
Notes:
- This is a software emulation helper for test-vector work.
- It does not yet emulate encrypted HSM-bound translation between PEK, BDK, or ECDH-derived keys.
## 16) AWS `VerifyPinData`: Clear PIN Block Inspection
Operations:
- `Parse PIN block`
Suggested use:
- Paste the clear PIN block into the input field as hex.
- Decode the PIN-block structure and compare the recovered PIN to your expected test data.
Notes:
- This is only structural verification today.
- It does not yet implement VISA PVV or IBM 3624 verification logic.
## Not Yet Implemented
These AWS operations are still valid emulation targets, but do not yet have recipe-equivalent support in this fork:
- `GenerateMacEmvPinChange`
- `GenerateAs2805KekValidation`
Why:
- They depend on PVV/IBM3624/issuer-script/AS2805-specific payment primitives that are not implemented here.
## Good Next Additions
If you want closer AWS coverage, the highest-value missing operations are:
1. PIN block encode/decode for ISO 9564 formats 0, 1, 3, and 4.
2. IBM 3624 and VISA PVV generation and verification.
3. Dedicated EMV MAC and profile-specific EMV session-derivation helpers.
4. Clear-to-encrypted and encrypted-to-encrypted PIN translation flows.
5. TR-31 unwrap and rewrap helpers for dynamic-key workflows.

94
PAYMENT_RECIPES.md Normal file
View File

@ -0,0 +1,94 @@
# Payment Recipe Starters
These recipe starters are designed for software-only inspection, validation, and prototyping workflows.
For AWS-specific mappings, see `AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md`.
## 1) TR-31 Header Parse
Operations:
- `Parse TR-31 key block`
## 2) TR-34 B9 Envelope Split
Operations:
- `Parse TR-34 B9 envelope`
## 3) KCV Validation
Operations:
- `Calculate payment KCV`
## 4) ECDH Key Agreement (Software)
Operations:
- `Derive ECDH key material`
Suggested use:
- Import a local private key and peer public key.
- Derive raw shared secret or run Concat KDF (`SHA-256` or `SHA-512`) with shared-info.
## 5) DUKPT Derivation (Software)
Operations:
- `Derive DUKPT key`
Suggested use:
- Derive IPEK from BDK + KSN.
- Derive base session key and apply a variant mask (`PIN`, `MAC Request`, `MAC Response`, `Data`).
## 6) PIN Block Build / Parse / Translate
Operations:
- `Build PIN block`
- `Parse PIN block`
- `Translate PIN block`
Suggested use:
- Build clear ISO 9564 format 0, 1, or 3 PIN blocks from a PIN and PAN.
- Parse clear test PIN blocks back into PIN, PIN field, PAN field, and filler details.
- Translate clear test PIN blocks between supported formats before feeding them into cipher steps.
Scope note:
- This starter currently covers clear software test blocks for ISO formats 0, 1, and 3.
- It does not yet generate PVV, IBM 3624 offsets, or encrypted PEK/BDK translation flows by itself.
## 7) Card Validation Data (CVV / CVV2 / iCVV)
Operations:
- `Generate card validation data`
- `Verify card validation data`
Suggested use:
- Paste the combined CVK pair into the input field as 16-byte or 24-byte hex.
- Choose whether you want CVV/CVC, CVV2/CVC2, or iCVV behavior.
- Provide the PAN, expiry month/year, and service-code context in the argument fields.
Scope note:
- This implementation is intended for software test harnesses.
- CVV2 forces service code `000` and iCVV forces `999`.
- It does not try to emulate scheme-specific dCVV, token CVV, or issuer-host formatting differences beyond the common decimalization flow.
## 8) EMV ARQC Generation (AES-CMAC Profile)
Operations:
- `Generate EMV ARQC`
Suggested use:
- Paste the already-assembled ARQC input block into the input field as hex.
- Provide the already-derived AES session key in the argument field.
- Choose how many leftmost CMAC bytes to keep as the final cryptogram.
Scope note:
- This operation is intentionally limited to AES-CMAC-style EMV profiles.
- It does not derive EMV session keys or assemble CDOL/tag data for you.
## 9) EMV ARPC Generation (AES-CMAC Response Profile)
Operations:
- `Generate EMV ARPC`
Suggested use:
- Paste the already-assembled ARPC response input block into the input field as hex.
- Provide the already-derived issuer AES session key in the argument field.
- Choose how many leftmost CMAC bytes to keep as the final cryptogram.
Scope note:
- This operation is intentionally limited to AES-CMAC response profiles where the issuer session key and exact preimage are already known.
- Legacy 3DES EMV ARQC/ARPC flows are not covered.
## 10) Combined Message Triage
Operations:
- `Parse TR-34 B9 envelope`
- `Parse ASN.1 hex string`

50
PAYMENT_SIM_RECIPES.md Normal file
View File

@ -0,0 +1,50 @@
# Payment Simulation Recipe Candidates
This list targets software-only development and testing environments.
## Frame And Transport Simulation
1. Length-prefix builder/parser pairs for command and response replay.
2. Status code mutation recipes (success/error branch testing).
3. Header-length fuzzing recipes for parser hardening.
## TR-31 Simulation
1. Header mutation recipes (usage, mode, exportability, optional block counts).
2. Optional-block truncation and malformed-length negative tests.
3. Prefix-normalization recipes (`R` prefix handling).
## TR-34 Simulation
1. Envelope section split/rebuild recipes.
2. ASN.1 length corruption tests.
3. Signature-length mismatch recipes.
## KCV And Key Lifecycle Simulation
1. KCV cross-check recipes across TDES, AES-CMAC, and HMAC methods.
2. Variant-mask simulation for derived key classes.
3. Deterministic fixed-vector recipes for regression checks.
## ECDH Simulation
1. Static keypair handshake vectors.
2. Shared-info permutations in Concat KDF.
3. Curve mismatch and malformed key negative tests.
## DUKPT Simulation
1. IPEK derivation from known BDK/KSN vectors.
2. Counter progression replay across KSN ranges.
3. Variant-mask output sets for transaction classes.
## EMV/Scheme-Level Candidate Recipes
1. ARQC generation checks for AES-CMAC profiles with fixed session keys and known CDOL payloads.
2. ARPC generation checks for AES-CMAC response profiles with explicit ARC/CSU/proprietary-data assembly.
3. Tag concatenation and canonical ordering checks.
4. Session derivation input normalization checks.
5. Cryptogram preimage assembly validation recipes.
6. PAN parser and network classifier recipes for Visa (`4`, typically 13/16/19 digits), Mastercard (`51`-`55`, `2221`-`2720`, 16 digits), American Express (`34`, `37`, 15 digits), and Discover (`6011`, `644`-`649`, `65`, and `622126`-`622925`, typically 16-19 digits), including Luhn validation and issuer-range explanation.
## AWS Payment Cryptography Candidate Recipes
1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA.
2. `ReEncryptData` parity vectors for decrypt-then-encrypt workflows.
3. `GenerateMac` and `VerifyMac` parity vectors for HMAC and CMAC.
4. `VerifyAuthRequestCryptogram` preimage-validation recipes for AES-CMAC EMV profiles.
5. DUKPT derivation-plus-cipher recipes for AWS derived-key lab testing.
6. ECDH and TR-31 inspection recipes for `TranslateKeyMaterial` interoperability debugging.
7. Gap-tracking recipes for unsupported AWS flows: PVV, IBM3624, encrypted PIN block translation, issuer-script PIN change, and AS2805 KEK validation.

View File

@ -19,6 +19,8 @@ This fork extends **CyberChef** with a focused set of payment cryptography opera
### Scope
The extensions are designed to help inspect, parse, validate, and construct common payment-industry cryptographic structures without requiring access to live HSMs or production systems.
They are also intended to support software emulation of common HSM-style payment workflows for development, QA, interoperability, and integration testing.
Initial focus areas include:
- TR-31 key block parsing and encoding
- Key metadata inspection and structural validation
@ -34,7 +36,7 @@ Future extensions may include:
These extensions are not intended to:
- Facilitate fraud, card data misuse, or PIN compromise
- Replace certified HSMs or production cryptographic controls
- Automate end-to-end payment authorization workflows
- Claim certification, tamper-resistance, or compliance equivalence with production HSM deployments
All operations are designed to be explicit, inspectable, and composable, consistent with CyberChefs philosophy.
@ -45,6 +47,10 @@ src/core/operations/payment-crypto/
They appear in the CyberChef UI under the **Payment Cryptography** category.
Recipe starter docs:
- [PAYMENT_RECIPES.md](PAYMENT_RECIPES.md)
- [AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md](AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md)
## Live demo
CyberChef is still under active development. As a result, it shouldn't be considered a finished product. There is still testing and bug fixing to do, new features to be added and additional documentation to write. Please contribute!

View File

@ -30,6 +30,8 @@ class Operation {
this.name = "";
this.module = "";
this.description = "";
this.inlineHelp = "";
this.testDataSamples = [];
this.infoURL = null;
}
@ -180,6 +182,7 @@ class Operation {
if (ing.toggleValues) conf.toggleValues = ing.toggleValues;
if (ing.hint) conf.hint = ing.hint;
if (ing.comment) conf.comment = ing.comment;
if (ing.rows) conf.rows = ing.rows;
if (ing.disabled) conf.disabled = ing.disabled;
if (ing.target) conf.target = ing.target;

View File

@ -565,6 +565,23 @@
"XKCD Random Number"
]
},
{
"name": "Payments",
"ops": [
"Parse TR-31 key block",
"Parse TR-34 B9 envelope",
"Calculate payment KCV",
"Derive ECDH key material",
"Derive DUKPT key",
"Generate card validation data",
"Verify card validation data",
"Generate EMV ARQC",
"Generate EMV ARPC",
"Build PIN block",
"Parse PIN block",
"Translate PIN block"
]
},
{
"name": "Flow control",
"ops": [

View File

@ -37,6 +37,8 @@ for (const opObj in Ops) {
operationConfig[op.name] = {
module: op.module,
description: op.description,
inlineHelp: op.inlineHelp,
testDataSamples: op.testDataSamples,
infoURL: op.infoURL,
inputType: op.inputType,
outputType: op.presentType,

View File

@ -0,0 +1,249 @@
/**
* @license Apache-2.0
*/
import forge from "node-forge";
import OperationError from "../errors/OperationError.mjs";
import { bytesToHex, parseHexBytes, toByteString } from "./PaymentUtils.mjs";
const CVV_PROFILES = [
"CVV / CVC (use service code arg)",
"CVV2 / CVC2 (force 000)",
"iCVV (force 999)",
];
/**
* Validates card data inputs.
*
* @param {string} pan
* @param {string} expiryMonth
* @param {string} expiryYear
* @param {string} serviceCode
*/
function validateCardData(pan, expiryMonth, expiryYear, serviceCode) {
if (!/^\d{13,19}$/.test((pan || "").replace(/\s+/g, ""))) {
throw new OperationError("PAN must be 13 to 19 digits.");
}
if (!/^\d{2}$/.test((expiryMonth || "").replace(/\s+/g, ""))) {
throw new OperationError("Expiry month must be 2 digits.");
}
if (!/^\d{2}$/.test((expiryYear || "").replace(/\s+/g, ""))) {
throw new OperationError("Expiry year must be 2 digits.");
}
if (!/^\d{3}$/.test((serviceCode || "").replace(/\s+/g, ""))) {
throw new OperationError("Service code must be 3 digits.");
}
}
/**
* Resolves the service code based on the selected validation-data profile.
*
* @param {string} profile
* @param {string} serviceCode
* @returns {string}
*/
function resolveServiceCode(profile, serviceCode) {
switch (profile) {
case "CVV2 / CVC2 (force 000)":
return "000";
case "iCVV (force 999)":
return "999";
default:
return (serviceCode || "").replace(/\s+/g, "");
}
}
/**
* Encrypts one 8-byte block with DES ECB.
*
* @param {Uint8Array} key8
* @param {Uint8Array} block8
* @returns {Uint8Array}
*/
function encryptDesEcb(key8, block8) {
const cipher = forge.cipher.createCipher("DES-ECB", toByteString(key8));
cipher.mode.pad = function() {
return true;
};
cipher.start();
cipher.update(forge.util.createBuffer(toByteString(block8)));
cipher.finish();
return Uint8Array.from(cipher.output.getBytes().split("").map(ch => ch.charCodeAt(0))).slice(0, 8);
}
/**
* Encrypts one 8-byte block with 3DES ECB.
*
* @param {Uint8Array} key
* @param {Uint8Array} block8
* @returns {Uint8Array}
*/
function encryptTdesEcb(key, block8) {
const normalizedKey = key.length === 16 ? Uint8Array.from([...key, ...key.slice(0, 8)]) : key;
const cipher = forge.cipher.createCipher("3DES-ECB", toByteString(normalizedKey));
cipher.mode.pad = function() {
return true;
};
cipher.start();
cipher.update(forge.util.createBuffer(toByteString(block8)));
cipher.finish();
return Uint8Array.from(cipher.output.getBytes().split("").map(ch => ch.charCodeAt(0))).slice(0, 8);
}
/**
* XORs two byte arrays.
*
* @param {Uint8Array} left
* @param {Uint8Array} right
* @returns {Uint8Array}
*/
function xorBytes(left, right) {
const out = new Uint8Array(left.length);
for (let i = 0; i < left.length; i++) {
out[i] = left[i] ^ right[i];
}
return out;
}
/**
* Converts a decimal digit string into BCD bytes.
*
* @param {string} digits
* @returns {Uint8Array}
*/
function digitsToBcdBytes(digits) {
const out = new Uint8Array(digits.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = (parseInt(digits.charAt(i * 2), 10) << 4) | parseInt(digits.charAt(i * 2 + 1), 10);
}
return out;
}
/**
* Decimalizes a CVV result hex string using the common numeric-first extraction rule.
*
* @param {string} hex
* @param {number} digitCount
* @returns {string}
*/
function decimalizeCvvHex(hex, digitCount) {
let out = "";
for (const ch of hex) {
if (/\d/.test(ch)) {
out += ch;
if (out.length >= digitCount) return out.substring(0, digitCount);
}
}
for (const ch of hex) {
if (/[A-F]/.test(ch)) {
out += String(ch.charCodeAt(0) - "A".charCodeAt(0));
if (out.length >= digitCount) return out.substring(0, digitCount);
}
}
return out.substring(0, digitCount);
}
/**
* Generates card validation data such as CVV, CVV2, or iCVV.
*
* @param {string} cvkHex
* @param {string} pan
* @param {string} expiryMonth
* @param {string} expiryYear
* @param {string} expiryLayout
* @param {string} serviceCode
* @param {string} profile
* @param {number} digitCount
* @returns {Object}
*/
function generateCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, digitCount) {
const normalizedPan = (pan || "").replace(/\s+/g, "");
const normalizedMonth = (expiryMonth || "").replace(/\s+/g, "");
const normalizedYear = (expiryYear || "").replace(/\s+/g, "");
const resolvedServiceCode = resolveServiceCode(profile, serviceCode);
validateCardData(normalizedPan, normalizedMonth, normalizedYear, resolvedServiceCode);
const normalizedDigitCount = Math.max(1, Math.min(5, Number(digitCount) || 3));
const cvk = parseHexBytes(cvkHex, "CVK pair", [16, 24]);
const keyA = cvk.slice(0, 8);
const expiry = expiryLayout === "MMYY" ?
`${normalizedMonth}${normalizedYear}` :
`${normalizedYear}${normalizedMonth}`;
const dataDigits = `${normalizedPan}${expiry}${resolvedServiceCode}`.padEnd(32, "0").substring(0, 32);
const leftBlock = digitsToBcdBytes(dataDigits.substring(0, 16));
const rightBlock = digitsToBcdBytes(dataDigits.substring(16, 32));
const step1 = encryptDesEcb(keyA, leftBlock);
const step2 = xorBytes(step1, rightBlock);
const resultBytes = encryptTdesEcb(cvk, step2);
const resultHex = bytesToHex(resultBytes);
const decimalized = decimalizeCvvHex(resultHex, 5);
return {
profile,
pan: normalizedPan,
expiry,
expiryLayout,
serviceCode: resolvedServiceCode,
digitCount: normalizedDigitCount,
inputDigits: dataDigits,
resultHex,
decimalized,
validationData: decimalized.substring(0, normalizedDigitCount)
};
}
/**
* Verifies card validation data.
*
* @param {string} cvkHex
* @param {string} pan
* @param {string} expiryMonth
* @param {string} expiryYear
* @param {string} expiryLayout
* @param {string} serviceCode
* @param {string} profile
* @param {string} expectedValue
* @returns {Object}
*/
function verifyCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, expectedValue) {
const normalizedExpected = (expectedValue || "").replace(/\s+/g, "");
if (!/^\d{1,5}$/.test(normalizedExpected)) {
throw new OperationError("Expected validation data must be 1 to 5 decimal digits.");
}
const generated = generateCardValidationData(
cvkHex,
pan,
expiryMonth,
expiryYear,
expiryLayout,
serviceCode,
profile,
normalizedExpected.length
);
return {
...generated,
expectedValue: normalizedExpected,
valid: generated.validationData === normalizedExpected
};
}
export {
CVV_PROFILES,
generateCardValidationData,
verifyCardValidationData,
};

View File

@ -0,0 +1,40 @@
/**
* @license Apache-2.0
*/
import CMAC from "../operations/CMAC.mjs";
import OperationError from "../errors/OperationError.mjs";
import { parseHexBuffer } from "./PaymentUtils.mjs";
/**
* Generates an EMV AES-CMAC cryptogram and truncates it.
*
* @param {string} inputHex
* @param {string} keyHex
* @param {number} outputBytes
* @returns {Object}
*/
function generateEmvAesCmacCryptogram(inputHex, keyHex, outputBytes) {
const inputBuffer = parseHexBuffer(inputHex, "Input data");
const normalizedKey = (keyHex || "").replace(/\s+/g, "");
if (!/^[0-9a-fA-F]+$/.test(normalizedKey) || normalizedKey.length % 2 !== 0) {
throw new OperationError("Session key must be hex.");
}
const normalizedOutputBytes = Math.max(1, Math.min(16, Number(outputBytes) || 8));
const cmac = new CMAC();
const fullMacHex = cmac.run(inputBuffer, [{ string: normalizedKey, option: "Hex" }, "AES"]).toUpperCase();
const cryptogramHex = fullMacHex.substring(0, normalizedOutputBytes * 2);
return {
inputHex: (inputHex || "").replace(/\s+/g, "").toUpperCase(),
outputBytes: normalizedOutputBytes,
fullMacHex,
cryptogramHex
};
}
export {
generateEmvAesCmacCryptogram,
};

View File

@ -0,0 +1,75 @@
/**
* @license Apache-2.0
*/
import OperationError from "../errors/OperationError.mjs";
import { toHexFast } from "./Hex.mjs";
/**
* Parses hex into bytes.
*
* @param {string} input
* @param {string} name
* @param {number[]} [allowedLengths]
* @returns {Uint8Array}
*/
function parseHexBytes(input, name, allowedLengths=[]) {
const normalized = (input || "").replace(/\s+/g, "");
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) {
throw new OperationError(`${name} must be hex.`);
}
const out = new Uint8Array(normalized.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(normalized.substring(i * 2, i * 2 + 2), 16);
}
if (allowedLengths.length && !allowedLengths.includes(out.length)) {
throw new OperationError(`${name} must be ${allowedLengths.join(" or ")} bytes.`);
}
return out;
}
/**
* Converts bytes to uppercase hex.
*
* @param {Uint8Array} bytes
* @returns {string}
*/
function bytesToHex(bytes) {
return toHexFast(bytes).toUpperCase();
}
/**
* Converts bytes to a forge-compatible byte string.
*
* @param {Uint8Array} bytes
* @returns {string}
*/
function toByteString(bytes) {
return Array.from(bytes, byte => String.fromCharCode(byte)).join("");
}
/**
* Converts hex to an ArrayBuffer.
*
* @param {string} input
* @param {string} name
* @returns {ArrayBuffer}
*/
function parseHexBuffer(input, name) {
const bytes = parseHexBytes(input, name);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
export {
bytesToHex,
parseHexBuffer,
parseHexBytes,
toByteString,
};

249
src/core/lib/PinBlock.mjs Normal file
View File

@ -0,0 +1,249 @@
/**
* @license Apache-2.0
*/
import OperationError from "../errors/OperationError.mjs";
import { toHexFast } from "./Hex.mjs";
const PIN_BLOCK_FORMATS = ["ISO Format 0", "ISO Format 1", "ISO Format 3"];
/**
* Returns a random nibble in the given inclusive range.
*
* @param {number} min
* @param {number} max
* @returns {number}
*/
function randomNibble(min, max) {
const range = max - min + 1;
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
const buf = new Uint8Array(1);
globalThis.crypto.getRandomValues(buf);
return min + (buf[0] % range);
}
return min + Math.floor(Math.random() * range);
}
/**
* Converts a hex string into nibble values.
*
* @param {string} hex
* @returns {number[]}
*/
function hexToNibbles(hex) {
return hex.toUpperCase().split("").map(ch => parseInt(ch, 16));
}
/**
* Converts nibble values into a byte array.
*
* @param {number[]} nibbles
* @returns {Uint8Array}
*/
function nibblesToBytes(nibbles) {
const out = new Uint8Array(nibbles.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = (nibbles[i * 2] << 4) | nibbles[i * 2 + 1];
}
return out;
}
/**
* XORs two nibble arrays.
*
* @param {number[]} a
* @param {number[]} b
* @returns {number[]}
*/
function xorNibbles(a, b) {
return a.map((value, index) => value ^ b[index]);
}
/**
* Normalizes and validates a PIN.
*
* @param {string} pin
* @returns {string}
*/
function normalizePin(pin) {
const normalized = (pin || "").replace(/\s+/g, "");
if (!/^\d{4,12}$/.test(normalized)) {
throw new OperationError("PIN must be 4 to 12 digits.");
}
return normalized;
}
/**
* Normalizes and validates a PAN.
*
* @param {string} pan
* @returns {string}
*/
function normalizePan(pan) {
const normalized = (pan || "").replace(/\s+/g, "");
if (!/^\d{12,19}$/.test(normalized)) {
throw new OperationError("PAN must be 12 to 19 digits.");
}
return normalized;
}
/**
* Parses an 8-byte PIN block hex string.
*
* @param {string} blockHex
* @returns {string}
*/
function normalizeBlockHex(blockHex) {
const normalized = (blockHex || "").replace(/\s+/g, "").toUpperCase();
if (!/^[0-9A-F]{16}$/.test(normalized)) {
throw new OperationError("PIN block must be 16 hex characters (8 bytes).");
}
return normalized;
}
/**
* Builds the PIN field for a clear PIN block.
*
* @param {string} format
* @param {string} pin
* @param {boolean} randomizeFill
* @returns {number[]}
*/
function buildPinField(format, pin, randomizeFill) {
const formatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3;
const pinNibbles = pin.split("").map(digit => parseInt(digit, 10));
const out = [formatNibble, pin.length, ...pinNibbles];
while (out.length < 16) {
if (format === "ISO Format 0") {
out.push(0xF);
} else if (format === "ISO Format 1") {
out.push(randomizeFill ? randomNibble(0x0, 0xF) : 0xF);
} else {
out.push(randomizeFill ? randomNibble(0xA, 0xF) : 0xA);
}
}
return out;
}
/**
* Builds the PAN field for PAN-bound PIN block formats.
*
* @param {string} pan
* @returns {number[]}
*/
function buildPanField(pan) {
const normalizedPan = normalizePan(pan);
const pan12 = normalizedPan.slice(0, -1).slice(-12).padStart(12, "0");
return hexToNibbles(`0000${pan12}`);
}
/**
* Builds a clear PIN block.
*
* @param {string} format
* @param {string} pin
* @param {string} pan
* @param {boolean} randomizeFill
* @returns {string}
*/
function buildPinBlock(format, pin, pan, randomizeFill) {
if (!PIN_BLOCK_FORMATS.includes(format)) {
throw new OperationError("Unsupported PIN block format.");
}
const normalizedPin = normalizePin(pin);
const pinField = buildPinField(format, normalizedPin, randomizeFill);
if (format === "ISO Format 1") {
return toHexFast(nibblesToBytes(pinField)).toUpperCase();
}
const panField = buildPanField(pan);
return toHexFast(nibblesToBytes(xorNibbles(pinField, panField))).toUpperCase();
}
/**
* Parses a clear PIN block.
*
* @param {string} format
* @param {string} blockHex
* @param {string} pan
* @returns {Object}
*/
function parsePinBlock(format, blockHex, pan) {
if (!PIN_BLOCK_FORMATS.includes(format)) {
throw new OperationError("Unsupported PIN block format.");
}
const normalizedBlock = normalizeBlockHex(blockHex);
const clearField = format === "ISO Format 1" ?
hexToNibbles(normalizedBlock) :
xorNibbles(hexToNibbles(normalizedBlock), buildPanField(pan));
const formatNibble = clearField[0];
const expectedFormatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3;
if (formatNibble !== expectedFormatNibble) {
throw new OperationError(`PIN block does not decode as ${format}.`);
}
const pinLength = clearField[1];
if (pinLength < 4 || pinLength > 12) {
throw new OperationError("Decoded PIN length is invalid.");
}
const pinDigits = clearField.slice(2, 2 + pinLength);
if (pinDigits.some(nibble => nibble < 0x0 || nibble > 0x9)) {
throw new OperationError("Decoded PIN contains non-decimal digits.");
}
const fillDigits = clearField.slice(2 + pinLength);
if (format === "ISO Format 0" && fillDigits.some(nibble => nibble !== 0xF)) {
throw new OperationError("Format 0 filler must be 0xF.");
}
if (format === "ISO Format 3" && fillDigits.some(nibble => nibble < 0xA || nibble > 0xF)) {
throw new OperationError("Format 3 filler must be in the range 0xA to 0xF.");
}
return {
format,
pin: pinDigits.join(""),
pinLength,
pinFieldHex: toHexFast(nibblesToBytes(clearField)).toUpperCase(),
panFieldHex: format === "ISO Format 1" ? null : toHexFast(nibblesToBytes(buildPanField(pan))).toUpperCase(),
blockHex: normalizedBlock,
fillDigitsHex: fillDigits.map(nibble => nibble.toString(16).toUpperCase()).join("")
};
}
/**
* Translates a clear PIN block between formats.
*
* @param {string} blockHex
* @param {string} sourceFormat
* @param {string} sourcePan
* @param {string} targetFormat
* @param {string} targetPan
* @param {boolean} randomizeFill
* @returns {Object}
*/
function translatePinBlock(blockHex, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill) {
const parsed = parsePinBlock(sourceFormat, blockHex, sourcePan);
return {
source: parsed,
target: {
format: targetFormat,
blockHex: buildPinBlock(targetFormat, parsed.pin, targetPan, randomizeFill)
}
};
}
export {
PIN_BLOCK_FORMATS,
buildPinBlock,
parsePinBlock,
translatePinBlock,
};

View File

@ -0,0 +1,66 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { PIN_BLOCK_FORMATS, buildPinBlock } from "../lib/PinBlock.mjs";
/**
* Build PIN block operation
*/
class BuildPINBlock extends Operation {
/**
* BuildPINBlock constructor
*/
constructor() {
super();
this.name = "Build PIN block";
this.module = "Payment";
this.description = "Paste the clear PIN into the input field and choose the ISO 9564 clear PIN block format to build.<br><br><b>Input:</b> clear PIN digits.<br><b>Arguments:</b> choose the target format, provide the PAN when required, and optionally randomize filler digits for formats 1 and 3.<br><br>This operation currently builds clear test PIN blocks for ISO formats 0, 1, and 3.";
this.inlineHelp = "<strong>Input:</strong> clear PIN digits.<br><strong>Args:</strong> choose the format, add the PAN for formats 0 and 3, then decide whether format 1 or 3 filler digits should be randomized.";
this.testDataSamples = [
{
name: "Random ISO Format 0 sample",
input: "__RANDOM_PIN_4__",
args: ["ISO Format 0", "__RANDOM_PAN_16__", false]
}
];
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Format",
type: "option",
value: PIN_BLOCK_FORMATS,
comment: "Choose the clear ISO 9564 block format to build. Assumption: only formats <code>0</code>, <code>1</code>, and <code>3</code> are implemented."
},
{
name: "Primary account number",
type: "string",
value: "",
comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
},
{
name: "Randomize fill digits",
type: "boolean",
value: false,
comment: "Affects only formats 1 and 3. When disabled, filler is deterministic so test vectors stay stable."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [format, pan, randomizeFill] = args;
return buildPinBlock(format, input, pan, randomizeFill);
}
}
export default BuildPINBlock;

View File

@ -0,0 +1,144 @@
/**
* @license Apache-2.0
*/
import forge from "node-forge";
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs";
import CMAC from "./CMAC.mjs";
/**
* Calculate payment KCV operation
*/
class CalculatePaymentKCV extends Operation {
/**
* CalculatePaymentKCV constructor
*/
constructor() {
super();
this.name = "Calculate payment KCV";
this.module = "Payment";
this.description = "Paste the key into the input field and choose how that key is encoded using <b>Key format</b>.<br><br>Use <b>Method</b> to choose the KCV style: TDES, AES-CMAC, AES-ECB, or HMAC.<br><br><b>Input:</b> raw key material such as hex, UTF-8, Latin1, or Base64.<br><b>Arguments:</b> select the key format, method, and output length in hex characters.<br><br>Returns an uppercase truncated hex KCV value.";
this.inlineHelp = "<strong>Input:</strong> key material.<br><strong>Args:</strong> tell the op how the key is encoded, choose the KCV method, then set the output length.";
this.testDataSamples = [
{
name: "Random AES-CMAC sample",
input: "__RANDOM_AES_128_HEX__",
args: ["Hex", "AES-CMAC (Empty)", 6]
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
"name": "Key format",
"type": "option",
"value": ["Hex", "UTF8", "Latin1", "Base64"],
"comment": "How the input field should be decoded before KCV calculation. Use <code>Hex</code> for payment keys entered as hexadecimal characters."
},
{
"name": "Method",
"type": "option",
"value": ["TDES-ECB (Zeros)", "AES-CMAC (Empty)", "AES-CMAC (Zeros)", "AES-CMAC (Ones)", "AES-ECB (Zeros)", "HMAC SHA-224", "HMAC SHA-256", "HMAC SHA-384", "HMAC SHA-512"],
"comment": "Assumption: TDES expects a 16-byte or 24-byte key, AES expects 16/24/32 bytes, and the method name states the exact data block used for the KCV."
},
{
"name": "Output hex chars",
"type": "number",
"value": 6,
"comment": "Number of uppercase hex characters returned from the left side of the calculated value. Common payment KCV length is <code>6</code>."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [keyFormat, method, outputHexChars] = args;
const truncLength = Math.max(1, Number(outputHexChars) || 6);
const keyBytes = Utils.convertToByteString(input || "", keyFormat);
if (!keyBytes.length) {
throw new OperationError("No key material was provided.");
}
let hexOut;
switch (method) {
case "TDES-ECB (Zeros)": {
if (keyBytes.length !== 16 && keyBytes.length !== 24) {
throw new OperationError("TDES key must be 16 or 24 bytes.");
}
const key = keyBytes.length === 16 ? keyBytes + keyBytes.substring(0, 8) : keyBytes;
const cipher = forge.cipher.createCipher("3DES-ECB", key);
cipher.start();
cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00"));
cipher.finish();
hexOut = cipher.output.toHex().toUpperCase();
break;
}
case "AES-CMAC (Empty)":
case "AES-CMAC (Zeros)":
case "AES-CMAC (Ones)": {
if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
throw new OperationError("AES key must be 16, 24, or 32 bytes.");
}
const cmacOp = new CMAC();
let data;
if (method === "AES-CMAC (Empty)") {
data = new Uint8Array(0).buffer;
} else if (method === "AES-CMAC (Zeros)") {
data = new Uint8Array(16).buffer;
} else {
data = Uint8Array.from(new Array(16).fill(0xFF)).buffer;
}
hexOut = cmacOp.run(data, [{ string: keyBytes, option: "Latin1" }, "AES"]).toUpperCase();
break;
}
case "AES-ECB (Zeros)": {
if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
throw new OperationError("AES key must be 16, 24, or 32 bytes.");
}
const cipher = forge.cipher.createCipher("AES-ECB", keyBytes);
cipher.mode.pad = function() {
return true;
};
cipher.start();
cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"));
cipher.finish();
hexOut = cipher.output.toHex().toUpperCase();
break;
}
case "HMAC SHA-224":
case "HMAC SHA-256":
case "HMAC SHA-384":
case "HMAC SHA-512": {
const algorithmMap = {
"HMAC SHA-224": forge.md.sha512.sha224.create(),
"HMAC SHA-256": "sha256",
"HMAC SHA-384": "sha384",
"HMAC SHA-512": "sha512"
};
const hmac = forge.hmac.create();
hmac.start(algorithmMap[method], keyBytes);
hmac.update("");
hexOut = hmac.digest().toHex().toUpperCase();
break;
}
default:
throw new OperationError("Unsupported method.");
}
return hexOut.substring(0, truncLength);
}
}
export default CalculatePaymentKCV;

View File

@ -0,0 +1,279 @@
/**
* @license Apache-2.0
*/
import forge from "node-forge";
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { toHexFast } from "../lib/Hex.mjs";
const DUKPT_KEY_MASK = Uint8Array.from([0xC0, 0xC0, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x00]);
const VARIANT_MASKS = {
"None": Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
"PIN": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]),
"MAC Request": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00]),
"MAC Response": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
"Data": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00]),
};
/**
* Parses a fixed-length hex string into bytes.
*
* @param {string} input
* @param {number} expectedLen
* @param {string} name
* @returns {Uint8Array}
*/
function parseHex(input, expectedLen, name) {
const hex = (input || "").replace(/\s+/g, "");
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
throw new OperationError(`${name} must be hex.`);
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
if (expectedLen && out.length !== expectedLen) {
throw new OperationError(`${name} must be ${expectedLen} bytes.`);
}
return out;
}
/**
* XORs two equally sized byte arrays.
*
* @param {Uint8Array} a
* @param {Uint8Array} b
* @returns {Uint8Array}
*/
function xorBytes(a, b) {
const out = new Uint8Array(a.length);
for (let i = 0; i < a.length; i++) {
out[i] = a[i] ^ b[i];
}
return out;
}
/**
* Converts bytes to a forge-compatible binary string.
*
* @param {Uint8Array} bytes
* @returns {string}
*/
function toByteString(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i++) {
s += String.fromCharCode(bytes[i]);
}
return s;
}
/**
* Encrypts one 8-byte block with 2-key TDES in ECB mode.
*
* @param {Uint8Array} key16
* @param {Uint8Array} block8
* @returns {Uint8Array}
*/
function encryptBlock3DesEcb(key16, block8) {
const key24 = toByteString(Uint8Array.from([...key16, ...key16.slice(0, 8)]));
const cipher = forge.cipher.createCipher("3DES-ECB", key24);
cipher.mode.pad = function() {
return true;
};
cipher.start();
cipher.update(forge.util.createBuffer(toByteString(block8)));
cipher.finish();
const out = cipher.output.getBytes();
return Uint8Array.from(out.split("").map(c => c.charCodeAt(0))).slice(0, 8);
}
/**
* Encrypts one 8-byte block with DES in ECB mode.
*
* @param {Uint8Array} key8
* @param {Uint8Array} block8
* @returns {Uint8Array}
*/
function encryptBlockDesEcb(key8, block8) {
const cipher = forge.cipher.createCipher("DES-ECB", toByteString(key8));
cipher.mode.pad = function() {
return true;
};
cipher.start();
cipher.update(forge.util.createBuffer(toByteString(block8)));
cipher.finish();
const out = cipher.output.getBytes();
return Uint8Array.from(out.split("").map(c => c.charCodeAt(0))).slice(0, 8);
}
/**
* Derives the DUKPT IPEK from a BDK and KSN.
*
* @param {Uint8Array} bdk
* @param {Uint8Array} ksn
* @returns {Uint8Array}
*/
function deriveIpek(bdk, ksn) {
const ksnReg = Uint8Array.from(ksn);
ksnReg[7] &= 0xE0;
ksnReg[8] = 0x00;
ksnReg[9] = 0x00;
const data = ksnReg.slice(0, 8);
const left = encryptBlock3DesEcb(bdk, data);
const right = encryptBlock3DesEcb(xorBytes(bdk, DUKPT_KEY_MASK), data);
return Uint8Array.from([...left, ...right]);
}
/**
* Runs the ANSI X9.24 non-reversible key generation step.
*
* @param {Uint8Array} key
* @param {Uint8Array} ksnReg
* @returns {Uint8Array}
*/
function nonReversibleKeyGen(key, ksnReg) {
const reg8 = ksnReg.slice(2, 10);
const keyL = key.slice(0, 8);
const keyR = key.slice(8, 16);
const msgR = xorBytes(keyR, reg8);
const desR = encryptBlockDesEcb(keyL, msgR);
const right = xorBytes(desR, keyR);
const masked = xorBytes(key, DUKPT_KEY_MASK);
const mKeyL = masked.slice(0, 8);
const mKeyR = masked.slice(8, 16);
const msgL = xorBytes(mKeyR, reg8);
const desL = encryptBlockDesEcb(mKeyL, msgL);
const left = xorBytes(desL, mKeyR);
return Uint8Array.from([...left, ...right]);
}
/**
* Derives the base session key for the current transaction counter.
*
* @param {Uint8Array} ipek
* @param {Uint8Array} ksn
* @returns {Uint8Array}
*/
function deriveSessionBaseKey(ipek, ksn) {
const ksnReg = Uint8Array.from(ksn);
ksnReg[7] &= 0xE0;
ksnReg[8] = 0x00;
ksnReg[9] = 0x00;
const counter = ((ksn[7] & 0x1F) << 16) | (ksn[8] << 8) | ksn[9];
let curKey = Uint8Array.from(ipek);
for (let shift = 20; shift >= 0; shift--) {
const bit = 1 << shift;
if ((counter & bit) !== 0) {
ksnReg[7] = (ksnReg[7] & 0xE0) | (((counter & 0x1F0000) >> 16) & 0x1F);
ksnReg[8] = (counter >> 8) & 0xFF;
ksnReg[9] = counter & 0xFF;
curKey = nonReversibleKeyGen(curKey, ksnReg);
}
}
return curKey;
}
/**
* Derive DUKPT key operation
*/
class DeriveDUKPTKey extends Operation {
/**
* DeriveDUKPTKey constructor
*/
constructor() {
super();
this.name = "Derive DUKPT key";
this.module = "Payment";
this.description = "Paste the Base Derivation Key (BDK) into the input field as a 16-byte hex value.<br><br>Put the 10-byte Key Serial Number in the <b>KSN</b> argument field.<br><br><b>Input:</b> BDK in hex.<br><b>Arguments:</b> choose whether to derive the IPEK or the transaction key, provide the KSN, choose the variant, and optionally return JSON.<br><br>This operation derives TDES DUKPT keys in software for test and interoperability work.";
this.inlineHelp = "<strong>Input:</strong> BDK hex.<br><strong>Args:</strong> add the KSN, choose IPEK or transaction-key derivation, then optionally apply a variant.";
this.testDataSamples = [
{
name: "Known transaction key vector",
input: "0123456789ABCDEFFEDCBA9876543210",
args: ["Derive Session Key", "FFFF9876543210E00008", "None", false]
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Derived_unique_key_per_transaction";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
"name": "Mode",
"type": "option",
"value": ["Derive IPEK", "Derive Session Key"],
"comment": "Choose whether the output should be the IPEK or the derived transaction/session key. Assumption: this implementation follows TDES DUKPT, not AES DUKPT."
},
{
"name": "KSN (hex, 10 bytes)",
"type": "string",
"value": "",
"comment": "Provide the full 10-byte KSN as 20 hex characters, for example <code>FFFF9876543210E00008</code>. Spaces are allowed."
},
{
"name": "Session key variant",
"type": "option",
"value": ["None", "PIN", "MAC Request", "MAC Response", "Data"],
"comment": "Applied only when deriving the session key. Assumption: variants are implemented as simple XOR masks over the derived base key."
},
{
"name": "Output as JSON",
"type": "boolean",
"value": false,
"comment": "When enabled, returns the intermediate values along with the final key so the derivation can be inspected."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [mode, ksnHex, variant, outputJson] = args;
const bdk = parseHex(input, 16, "BDK");
const ksn = parseHex(ksnHex, 10, "KSN");
const ipek = deriveIpek(bdk, ksn);
const ipekHex = toHexFast(ipek).toUpperCase();
if (mode === "Derive IPEK") {
if (outputJson) {
return JSON.stringify({ mode, ipek: ipekHex }, null, 4);
}
return ipekHex;
}
const sessionBase = deriveSessionBaseKey(ipek, ksn);
const session = xorBytes(sessionBase, VARIANT_MASKS[variant]);
const sessionHex = toHexFast(session).toUpperCase();
if (outputJson) {
return JSON.stringify({
mode,
ipek: ipekHex,
sessionBase: toHexFast(sessionBase).toUpperCase(),
variant,
sessionKey: sessionHex
}, null, 4);
}
return sessionHex;
}
}
export default DeriveDUKPTKey;

View File

@ -0,0 +1,263 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import r from "jsrsasign";
import { fromBase64, toBase64 } from "../lib/Base64.mjs";
import { toHexFast } from "../lib/Hex.mjs";
/**
* Parses a PEM or hex-encoded DER key into bytes.
*
* @param {string} input
* @param {string} format
* @param {string} pemLabel
* @returns {Uint8Array}
*/
function parsePemOrHex(input, format, pemLabel) {
const value = (input || "").trim();
if (!value.length) throw new OperationError("Missing key input.");
if (format === "PEM") {
const normalized = value
.replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "")
.replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "")
.replace(/\s+/g, "");
return new Uint8Array(fromBase64(normalized, undefined, "byteArray"));
}
const hex = value.replace(/\s+/g, "");
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
throw new OperationError("Expected hex input.");
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
/**
* Normalizes PEM private keys to PKCS#8 DER for WebCrypto import.
*
* @param {string} input
* @returns {Uint8Array}
*/
function parsePrivateKey(input) {
const value = (input || "").trim();
if (!value.length) throw new OperationError("Missing key input.");
if (!value.includes("-----BEGIN")) {
return parsePemOrHex(value, "HEX", "PRIVATE KEY");
}
if (value.includes("-----BEGIN PRIVATE KEY-----")) {
return parsePemOrHex(value, "PEM", "PRIVATE KEY");
}
try {
const key = r.KEYUTIL.getKey(value);
const pkcs8Pem = r.KEYUTIL.getPEM(key, "PKCS8PRV");
return parsePemOrHex(pkcs8Pem, "PEM", "PRIVATE KEY");
} catch (err) {
throw new OperationError(`Unsupported private key format: ${err}`);
}
}
/**
* Concatenates byte arrays.
*
* @param {Uint8Array[]} parts
* @returns {Uint8Array}
*/
function concatBytes(parts) {
const total = parts.reduce((sum, p) => sum + p.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
/**
* Derives output keying material using a simple Concat KDF.
*
* @param {Uint8Array} rawSecret
* @param {Uint8Array} sharedInfo
* @param {string} hashAlg
* @param {number} outputLen
* @returns {Promise<Uint8Array>}
*/
async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
const digestName = hashAlg === "SHA-256" ? "SHA-256" : "SHA-512";
let counter = 1;
const chunks = [];
let generated = 0;
while (generated < outputLen) {
const ctr = new Uint8Array([
(counter >>> 24) & 0xff,
(counter >>> 16) & 0xff,
(counter >>> 8) & 0xff,
counter & 0xff,
]);
const data = concatBytes([ctr, rawSecret, sharedInfo]);
const digest = new Uint8Array(await crypto.subtle.digest(digestName, data));
chunks.push(digest);
generated += digest.length;
counter += 1;
}
return concatBytes(chunks).slice(0, outputLen);
}
/**
* Derive ECDH key material operation
*/
class DeriveECDHKeyMaterial extends Operation {
/**
* DeriveECDHKeyMaterial constructor
*/
constructor() {
super();
this.name = "Derive ECDH key material";
this.module = "Payment";
this.description = "Paste your private key into the input field and paste the peer public key into the <b>Peer public key</b> argument field.<br><br><b>Input:</b> private key in PEM or PKCS#8 DER hex. PEM may be <code>BEGIN PRIVATE KEY</code> or <code>BEGIN EC PRIVATE KEY</code> when it can be normalized to PKCS#8.<br><b>Arguments:</b> choose the curve, peer public key format, optional KDF, optional shared info, output length, and output format.<br><br>Use <b>KDF = None</b> to get the raw shared secret.";
this.inlineHelp = "<strong>Input:</strong> your private key.<br><strong>Args:</strong> pick the curve, paste the peer public key, then choose raw shared secret or KDF output.";
this.testDataSamples = [
{
name: "Known P-256 PEM vector",
input: "__ECDH_TEST_PRIVATE_KEY__",
args: ["PEM", "P-256", "PEM", "__ECDH_TEST_PEER_PUBLIC_KEY__", "None", 32, "", "Hex"]
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
"name": "Private key format",
"type": "option",
"value": ["PEM", "Hex (PKCS8 DER)"],
"comment": "Input field format for your private key. PEM may be <code>BEGIN PRIVATE KEY</code> or a supported <code>BEGIN EC PRIVATE KEY</code> that can be normalized to PKCS#8."
},
{
"name": "Curve",
"type": "option",
"value": ["P-256", "P-384", "P-521"],
"comment": "Must match the actual curve of both keys. The op does not auto-detect or translate between curves."
},
{
"name": "Peer public key format",
"type": "option",
"value": ["PEM", "Hex (SPKI DER)"],
"comment": "Format of the peer public key argument. PEM should be an SPKI <code>BEGIN PUBLIC KEY</code> block."
},
{
"name": "Peer public key",
"type": "text",
"value": "-----BEGIN PUBLIC KEY-----",
"comment": "Paste the full peer public key here. For PEM input, include the begin/end lines."
},
{
"name": "KDF",
"type": "option",
"value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"],
"comment": "Use <code>None</code> to return the raw shared secret. The KDF options use a simple Concat KDF over the shared secret plus optional shared info."
},
{
"name": "Output length (bytes)",
"type": "number",
"value": 32,
"comment": "Used only with KDF modes. For <code>None</code>, the raw shared secret length is determined by the curve."
},
{
"name": "Shared info (hex)",
"type": "string",
"value": "",
"comment": "Optional KDF shared info as hex. Leave blank if your test profile does not include shared info."
},
{
"name": "Output format",
"type": "option",
"value": ["Hex", "Base64"],
"comment": "Controls how the raw shared secret or KDF output is displayed."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
async run(input, args) {
const [
privateFmt,
curve,
publicFmt,
peerPublicKey,
kdf,
outLenArg,
sharedInfoHex,
outputFormat
] = args;
if (!globalThis.crypto || !globalThis.crypto.subtle) {
throw new OperationError("WebCrypto is not available in this runtime.");
}
const privateDer = privateFmt === "PEM" ? parsePrivateKey(input) : parsePemOrHex(input, "HEX", "PRIVATE KEY");
const publicDer = parsePemOrHex(peerPublicKey, publicFmt === "PEM" ? "PEM" : "HEX", "PUBLIC KEY");
const outLen = Math.max(1, Number(outLenArg) || 32);
const sharedInfoHexNorm = (sharedInfoHex || "").replace(/\s+/g, "");
if (sharedInfoHexNorm.length % 2 !== 0 || (sharedInfoHexNorm.length > 0 && !/^[0-9a-fA-F]+$/.test(sharedInfoHexNorm))) {
throw new OperationError("Shared info must be hex.");
}
const sharedInfo = sharedInfoHexNorm.length ?
new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) :
new Uint8Array();
const privateKey = await crypto.subtle.importKey(
"pkcs8",
privateDer,
{ name: "ECDH", namedCurve: curve },
false,
["deriveBits"]
);
const publicKey = await crypto.subtle.importKey(
"spki",
publicDer,
{ name: "ECDH", namedCurve: curve },
false,
[]
);
const curveBits = curve === "P-256" ? 256 : curve === "P-384" ? 384 : 528;
const rawSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, curveBits));
let out = rawSecret;
if (kdf === "Concat KDF SHA-256") {
out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen);
} else if (kdf === "Concat KDF SHA-512") {
out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen);
} else {
out = rawSecret.slice(0, outLen);
}
return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase();
}
}
export default DeriveECDHKeyMaterial;

View File

@ -0,0 +1,110 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { CVV_PROFILES, generateCardValidationData } from "../lib/CardValidation.mjs";
/**
* Generate card validation data operation.
*/
class GenerateCardValidationData extends Operation {
/**
* GenerateCardValidationData constructor.
*/
constructor() {
super();
this.name = "Generate card validation data";
this.module = "Payment";
this.description = "Paste the combined CVK pair into the input field as hex and generate a card-verification value for software testing.<br><br><b>Input:</b> combined CVK pair as 16-byte or 24-byte hex.<br><b>Arguments:</b> select whether you are generating CVV/CVC, CVV2/CVC2, or iCVV, then provide the PAN, expiry components, and service code details.<br><br>This implementation is intended for test harnesses and assumes the common CVV decimalization flow used by payment HSM integrations.";
this.inlineHelp = "<strong>Input:</strong> combined CVK pair hex.<br><strong>Args:</strong> choose the validation-data profile, then provide PAN, expiry, and service-code inputs.";
this.testDataSamples = [
{
name: "Known CVV2 test sample",
input: "0123456789ABCDEFFEDCBA9876543210",
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false]
}
];
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/generate-card-data.html";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Validation data type",
type: "option",
value: CVV_PROFILES,
comment: "Choose whether the output should behave like CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code <code>000</code> and iCVV forces <code>999</code>."
},
{
name: "Primary account number",
type: "string",
value: "",
comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
},
{
name: "Expiry month (MM)",
type: "shortString",
value: "",
comment: "Two-digit month component used when assembling the expiry date."
},
{
name: "Expiry year (YY)",
type: "shortString",
value: "",
comment: "Two-digit year component used when assembling the expiry date."
},
{
name: "Expiry layout",
type: "option",
value: ["YYMM", "MMYY"],
defaultIndex: 1,
comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
},
{
name: "Service code",
type: "shortString",
value: "101",
comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force <code>000</code> and <code>999</code>."
},
{
name: "Output digits",
type: "number",
value: 3,
min: 1,
max: 5,
comment: "How many digits of validation data to return. Common card-security-code lengths are <code>3</code> and sometimes <code>4</code>."
},
{
name: "Output as JSON",
type: "boolean",
value: false,
comment: "When enabled, returns the assembled input, intermediate hex, and decimalized value along with the final output."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, outputDigits, outputJson] = args;
const result = generateCardValidationData(
input,
pan,
expiryMonth,
expiryYear,
expiryLayout,
serviceCode,
profile,
outputDigits
);
return outputJson ? JSON.stringify(result, null, 4) : result.validationData;
}
}
export default GenerateCardValidationData;

View File

@ -0,0 +1,69 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
/**
* Generate EMV ARPC operation.
*/
class GenerateEMVARPC extends Operation {
/**
* GenerateEMVARPC constructor.
*/
constructor() {
super();
this.name = "Generate EMV ARPC";
this.module = "Payment";
this.description = "Paste the already-assembled EMV authorization-response input into the input field as hex and generate an AES-CMAC-based ARPC.<br><br><b>Input:</b> preassembled ARPC input data as hex.<br><b>Arguments:</b> provide the issuer session key in hex and choose how many bytes of the CMAC should be returned.<br><br>This operation intentionally covers only AES-CMAC-style EMV profiles where the issuer session key and response preimage are already known.";
this.inlineHelp = "<strong>Input:</strong> preassembled ARPC data as hex.<br><strong>Args:</strong> provide the issuer AES session key and choose the truncated cryptogram length.";
this.testDataSamples = [
{
name: "AES-CMAC ARPC sample",
input: "11223344556677889900AABBCCDDEEFF",
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
}
];
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/crypto-ops-carddata.html";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Issuer session key (hex)",
type: "string",
value: "",
comment: "Provide the already-derived issuer session key as hex. Assumption: this op does not derive EMV issuer session keys."
},
{
name: "Cryptogram bytes",
type: "number",
value: 8,
min: 1,
max: 16,
comment: "Number of leftmost CMAC bytes to return. Common ARPC length is <code>8</code> bytes."
},
{
name: "Output as JSON",
type: "boolean",
value: false,
comment: "When enabled, returns the full AES-CMAC and the truncated ARPC value."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [issuerSessionKeyHex, cryptogramBytes, outputJson] = args;
const result = generateEmvAesCmacCryptogram(input, issuerSessionKeyHex, cryptogramBytes);
return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
}
}
export default GenerateEMVARPC;

View File

@ -0,0 +1,69 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
/**
* Generate EMV ARQC operation.
*/
class GenerateEMVARQC extends Operation {
/**
* GenerateEMVARQC constructor.
*/
constructor() {
super();
this.name = "Generate EMV ARQC";
this.module = "Payment";
this.description = "Paste the already-assembled EMV authorization-request input into the input field as hex and generate an AES-CMAC-based ARQC.<br><br><b>Input:</b> preassembled ARQC input data as hex.<br><b>Arguments:</b> provide the EMV session key in hex and choose how many bytes of the CMAC should be returned.<br><br>This operation intentionally covers only AES-CMAC-style EMV profiles where the session key and preimage are already known.";
this.inlineHelp = "<strong>Input:</strong> preassembled ARQC data as hex.<br><strong>Args:</strong> provide the AES session key and choose the truncated cryptogram length.";
this.testDataSamples = [
{
name: "AES-CMAC ARQC sample",
input: "000102030405060708090A0B0C0D0E0F",
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
}
];
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/crypto-ops-carddata.html";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Session key (hex)",
type: "string",
value: "",
comment: "Provide the already-derived EMV session key as hex. Assumption: this op does not derive EMV session keys."
},
{
name: "Cryptogram bytes",
type: "number",
value: 8,
min: 1,
max: 16,
comment: "Number of leftmost CMAC bytes to return. Common ARQC length is <code>8</code> bytes."
},
{
name: "Output as JSON",
type: "boolean",
value: false,
comment: "When enabled, returns the full AES-CMAC and the truncated ARQC value."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [sessionKeyHex, cryptogramBytes, outputJson] = args;
const result = generateEmvAesCmacCryptogram(input, sessionKeyHex, cryptogramBytes);
return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
}
}
export default GenerateEMVARQC;

View File

@ -0,0 +1,60 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { PIN_BLOCK_FORMATS, parsePinBlock } from "../lib/PinBlock.mjs";
/**
* Parse PIN block operation
*/
class ParsePINBlock extends Operation {
/**
* ParsePINBlock constructor
*/
constructor() {
super();
this.name = "Parse PIN block";
this.module = "Payment";
this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and decode it into its component fields.<br><br><b>Input:</b> 8-byte clear PIN block as hex.<br><b>Arguments:</b> choose the format and provide the PAN when the format binds to PAN data.<br><br>This operation currently parses clear test PIN blocks for ISO formats 0, 1, and 3.";
this.inlineHelp = "<strong>Input:</strong> clear PIN block hex.<br><strong>Args:</strong> choose the format and provide the PAN for formats 0 and 3 so the block can be decoded.";
this.testDataSamples = [
{
name: "Known ISO Format 0 vector",
input: "041215FEDCBA9876",
args: ["ISO Format 0", "5432101234567890"]
}
];
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Format",
type: "option",
value: PIN_BLOCK_FORMATS,
comment: "Choose the format you expect the input block to decode as. The parser validates the format nibble after PAN unmasking."
},
{
name: "Primary account number",
type: "string",
value: "",
comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [format, pan] = args;
return JSON.stringify(parsePinBlock(format, input, pan), null, 4);
}
}
export default ParsePINBlock;

View File

@ -0,0 +1,129 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
/**
* Parse TR-31 key block header operation
*/
class ParseTR31KeyBlock extends Operation {
/**
* ParseTR31KeyBlock constructor
*/
constructor() {
super();
this.name = "Parse TR-31 key block";
this.module = "Payment";
this.description = "Paste the full TR-31 key block into the input field as text or hex characters.<br><br><b>Input:</b> complete TR-31 key block string, with or without spaces. If your source includes a leading <code>R</code> prefix, leave <b>Trim leading R prefix</b> enabled.<br><br>This operation parses the fixed header, any optional blocks it can identify, and reports the remaining body.";
this.inlineHelp = "<strong>Input:</strong> full TR-31 key block text.<br><strong>Args:</strong> leave the prefix trim enabled if the block starts with <code>R</code>.";
this.testDataSamples = [
{
name: "Fixed-header parser sample",
input: "D0016D0AB00E0000",
args: [true]
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
"name": "Trim leading R prefix",
"type": "boolean",
"value": true,
"comment": "Enable this if your source begins with an <code>R</code> transport prefix before the TR-31 block. The parser otherwise expects the block to start at the version byte."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [trimLeadingR] = args;
let keyBlock = (input || "").replace(/\s+/g, "").toUpperCase();
const notes = [];
if (!keyBlock.length) {
throw new OperationError("No input.");
}
if (trimLeadingR && keyBlock.startsWith("R")) {
keyBlock = keyBlock.substring(1);
notes.push("Removed leading R prefix.");
}
if (keyBlock.length < 16) {
throw new OperationError("Input too short for TR-31 header.");
}
const fixedHeader = keyBlock.substring(0, 16);
const declaredBlockLength = parseInt(keyBlock.substring(1, 5), 10);
const optionalBlocksDeclared = parseInt(keyBlock.substring(12, 14), 10);
let offset = 16;
let optionalBlocksParsed = 0;
const optionalBlocks = [];
while (optionalBlocksParsed < optionalBlocksDeclared && offset + 4 <= keyBlock.length) {
const blockId = keyBlock.substring(offset, offset + 2);
const blockLength = parseInt(keyBlock.substring(offset + 2, offset + 4), 10);
if (!Number.isFinite(blockLength) || blockLength < 4) {
notes.push(`Stopped optional block parsing due to invalid block length at offset ${offset}.`);
break;
}
if (offset + blockLength > keyBlock.length) {
notes.push(`Stopped optional block parsing due to truncated block at offset ${offset}.`);
break;
}
optionalBlocks.push({
"id": blockId,
"length": blockLength,
"value": keyBlock.substring(offset + 4, offset + blockLength)
});
optionalBlocksParsed += 1;
offset += blockLength;
}
const result = {
"raw": keyBlock,
"fixedHeader": {
"raw": fixedHeader,
"versionId": keyBlock.substring(0, 1),
"declaredBlockLength": Number.isFinite(declaredBlockLength) ? declaredBlockLength : null,
"keyUsage": keyBlock.substring(5, 7),
"algorithm": keyBlock.substring(7, 8),
"modeOfUse": keyBlock.substring(8, 9),
"keyVersionNumber": keyBlock.substring(9, 11),
"exportability": keyBlock.substring(11, 12),
"optionalBlocksDeclared": Number.isFinite(optionalBlocksDeclared) ? optionalBlocksDeclared : null,
"reserved": keyBlock.substring(14, 16)
},
"optionalBlocks": optionalBlocks,
"bodyOffset": offset,
"remainingBody": keyBlock.substring(offset),
"notes": notes
};
if (result.fixedHeader.declaredBlockLength !== null && result.fixedHeader.declaredBlockLength !== keyBlock.length) {
result.notes.push(`Declared block length ${result.fixedHeader.declaredBlockLength} does not match actual length ${keyBlock.length}.`);
}
if (result.fixedHeader.optionalBlocksDeclared !== null && result.fixedHeader.optionalBlocksDeclared !== optionalBlocks.length) {
result.notes.push(`Declared optional blocks ${result.fixedHeader.optionalBlocksDeclared} but parsed ${optionalBlocks.length}.`);
}
return JSON.stringify(result, null, 4);
}
}
export default ParseTR31KeyBlock;

View File

@ -0,0 +1,137 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
/**
* Parses an ASN.1 TLV length field at the given offset.
*
* @param {Uint8Array} bytes
* @param {number} offset
* @returns {{headerLength: number, valueLength: number}}
*/
function parseAsnLength(bytes, offset) {
if (offset + 2 > bytes.length) {
throw new OperationError("Insufficient ASN.1 data.");
}
const first = bytes[offset + 1];
if ((first & 0x80) === 0) {
return { headerLength: 2, valueLength: first };
}
const lengthOfLength = first & 0x7f;
if (offset + 2 + lengthOfLength > bytes.length) {
throw new OperationError("Invalid ASN.1 length field.");
}
let valueLength = 0;
for (let i = 0; i < lengthOfLength; i++) {
valueLength = (valueLength << 8) | bytes[offset + 2 + i];
}
return { headerLength: 2 + lengthOfLength, valueLength };
}
/**
* Parse TR-34 B9 envelope operation
*/
class ParseTR34B9Envelope extends Operation {
/**
* ParseTR34B9Envelope constructor
*/
constructor() {
super();
this.name = "Parse TR-34 B9 envelope";
this.module = "Payment";
this.description = "Paste the full B9 response frame into the input field as hex.<br><br><b>Input:</b> complete TR-34 B9 response encoded as hex, including the leading length field.<br><br>This operation splits the response into header, response code, authentication data, KCV, envelope data, signature length, signature, and any trailing bytes.";
this.inlineHelp = "<strong>Input:</strong> full B9 response frame as hex, including the 2-byte length field.<br><strong>Args:</strong> none.";
this.testDataSamples = [
{
name: "Synthetic B9 parser sample",
input: "001730303030423930303100112233300030303034AABBCCDD",
args: []
}
];
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
this.inputType = "string";
this.outputType = "string";
this.args = [];
}
/**
* @param {string} input
* @returns {string}
*/
run(input) {
const hex = (input || "").replace(/\s+/g, "");
if (!hex.length) {
throw new OperationError("No input.");
}
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
throw new OperationError("Input must be hex.");
}
const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
if (bytes.length < 12) {
throw new OperationError("Input too short.");
}
const declaredLength = (bytes[0] << 8) | bytes[1];
let offset = 2;
const header = String.fromCharCode(...bytes.slice(offset, offset + 4));
offset += 4;
const responseType = String.fromCharCode(...bytes.slice(offset, offset + 2));
offset += 2;
const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2));
offset += 2;
const authLenMeta = parseAsnLength(bytes, offset);
const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength;
const authData = bytes.slice(offset, offset + authTotalLen);
offset += authTotalLen;
const kcv = bytes.slice(offset, offset + 3);
offset += 3;
const envLenMeta = parseAsnLength(bytes, offset);
const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength;
const envelopeData = bytes.slice(offset, offset + envTotalLen);
offset += envTotalLen;
const signatureLengthAscii = String.fromCharCode(...bytes.slice(offset, offset + 4));
offset += 4;
const signatureLength = parseInt(signatureLengthAscii, 10);
const signature = Number.isFinite(signatureLength) ? bytes.slice(offset, offset + signatureLength) : new Uint8Array();
if (Number.isFinite(signatureLength)) {
offset += signatureLength;
}
const out = {
declaredLength,
actualLengthExcludingLengthField: bytes.length - 2,
header,
responseType,
errorCode,
authDataHex: Buffer.from(authData).toString("hex").toUpperCase(),
kcvHex: Buffer.from(kcv).toString("hex").toUpperCase(),
envelopeDataHex: Buffer.from(envelopeData).toString("hex").toUpperCase(),
signatureLengthAscii,
signatureLength: Number.isFinite(signatureLength) ? signatureLength : null,
signatureHex: Buffer.from(signature).toString("hex").toUpperCase(),
trailingHex: Buffer.from(bytes.slice(offset)).toString("hex").toUpperCase()
};
return JSON.stringify(out, null, 4);
}
}
export default ParseTR34B9Envelope;

View File

@ -0,0 +1,83 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { PIN_BLOCK_FORMATS, translatePinBlock } from "../lib/PinBlock.mjs";
/**
* Translate PIN block operation
*/
class TranslatePINBlock extends Operation {
/**
* TranslatePINBlock constructor
*/
constructor() {
super();
this.name = "Translate PIN block";
this.module = "Payment";
this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and translate it between supported clear block formats.<br><br><b>Input:</b> 8-byte clear PIN block as hex.<br><b>Arguments:</b> choose the source and target formats, provide source and target PAN values when required, and optionally randomize target filler digits for formats 1 and 3.<br><br>This operation currently translates clear test PIN blocks for ISO formats 0, 1, and 3.";
this.inlineHelp = "<strong>Input:</strong> source clear PIN block hex.<br><strong>Args:</strong> choose source and target formats, then provide the source and target PAN values where the formats require them.";
this.testDataSamples = [
{
name: "ISO Format 0 to 1 translation",
input: "041215FEDCBA9876",
args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false]
}
];
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Source format",
type: "option",
value: PIN_BLOCK_FORMATS,
comment: "How the input block should be decoded before translation."
},
{
name: "Source PAN",
type: "string",
value: "",
comment: "Required when the source format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
},
{
name: "Target format",
type: "option",
value: PIN_BLOCK_FORMATS,
defaultIndex: 1,
comment: "The clear PIN block format to emit after decoding the source block."
},
{
name: "Target PAN",
type: "string",
value: "",
comment: "Required when the target format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
},
{
name: "Randomize target fill digits",
type: "boolean",
value: false,
comment: "Affects only target formats 1 and 3. Leave disabled if you want repeatable vectors."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill] = args;
return JSON.stringify(
translatePinBlock(input, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill),
null,
4
);
}
}
export default TranslatePINBlock;

View File

@ -0,0 +1,104 @@
/**
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import { CVV_PROFILES, verifyCardValidationData } from "../lib/CardValidation.mjs";
/**
* Verify card validation data operation.
*/
class VerifyCardValidationData extends Operation {
/**
* VerifyCardValidationData constructor.
*/
constructor() {
super();
this.name = "Verify card validation data";
this.module = "Payment";
this.description = "Paste the combined CVK pair into the input field as hex and verify a CVV/CVC-style value for software testing.<br><br><b>Input:</b> combined CVK pair as 16-byte or 24-byte hex.<br><b>Arguments:</b> select the validation-data profile, provide the PAN and expiry components, then supply the expected validation data.<br><br>This operation recomputes the validation value using the same assumptions as the generate operation and reports whether the supplied value matches.";
this.inlineHelp = "<strong>Input:</strong> combined CVK pair hex.<br><strong>Args:</strong> provide PAN, expiry, service-code context, and the validation data to check.";
this.testDataSamples = [
{
name: "Known CVV2 verification sample",
input: "0123456789ABCDEFFEDCBA9876543210",
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"]
}
];
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/verify-card-data.html";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Validation data type",
type: "option",
value: CVV_PROFILES,
comment: "Choose whether the supplied value should be interpreted as CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code <code>000</code> and iCVV forces <code>999</code>."
},
{
name: "Primary account number",
type: "string",
value: "",
comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
},
{
name: "Expiry month (MM)",
type: "shortString",
value: "",
comment: "Two-digit month component used when assembling the expiry date."
},
{
name: "Expiry year (YY)",
type: "shortString",
value: "",
comment: "Two-digit year component used when assembling the expiry date."
},
{
name: "Expiry layout",
type: "option",
value: ["YYMM", "MMYY"],
defaultIndex: 1,
comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
},
{
name: "Service code",
type: "shortString",
value: "101",
comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force <code>000</code> and <code>999</code>."
},
{
name: "Expected value",
type: "shortString",
value: "",
comment: "Validation data to compare against, using 1 to 5 decimal digits."
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, expectedValue] = args;
return JSON.stringify(
verifyCardValidationData(
input,
pan,
expiryMonth,
expiryYear,
expiryLayout,
serviceCode,
profile,
expectedValue
),
null,
4
);
}
}
export default VerifyCardValidationData;

View File

@ -27,6 +27,7 @@ class HTMLIngredient {
this.value = config.value;
this.disabled = config.disabled || false;
this.hint = config.hint || false;
this.comment = config.comment || "";
this.rows = config.rows || false;
this.target = config.target;
this.defaultIndex = config.defaultIndex || 0;
@ -49,6 +50,7 @@ class HTMLIngredient {
toHtml() {
let html = "",
i, m, eventFn;
const commentHtml = this.comment ? `<div class="arg-comment">${this.comment}</div>` : "";
switch (this.type) {
case "string":
@ -66,6 +68,7 @@ class HTMLIngredient {
value="${this.value}"
${this.disabled ? "disabled" : ""}
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
${commentHtml}
</div>`;
break;
case "shortString":
@ -82,6 +85,7 @@ class HTMLIngredient {
value="${this.value}"
${this.disabled ? "disabled" : ""}
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
${commentHtml}
</div>`;
break;
case "toggleString":
@ -107,7 +111,7 @@ class HTMLIngredient {
}
html += `</div>
</div>
${commentHtml}
</div>`;
break;
case "number":
@ -125,6 +129,7 @@ class HTMLIngredient {
max="${this.max}"
step="${this.step}"
${this.disabled ? "disabled" : ""}>
${commentHtml}
</div>`;
break;
case "boolean":
@ -141,6 +146,7 @@ class HTMLIngredient {
value="${this.name}"> ${this.name}
</label>
</div>
${commentHtml}
</div>`;
break;
case "option":
@ -164,6 +170,7 @@ class HTMLIngredient {
}
}
html += `</select>
${commentHtml}
</div>`;
break;
case "populateOption":
@ -191,6 +198,7 @@ class HTMLIngredient {
}
}
html += `</select>
${commentHtml}
</div>`;
eventFn = this.type === "populateMultiOption" ?
@ -225,6 +233,7 @@ class HTMLIngredient {
}
html += `</div>
</div>
${commentHtml}
</div>`;
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
@ -256,6 +265,7 @@ class HTMLIngredient {
}
html += `</div>
</div>
${commentHtml}
</div>`;
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
@ -272,6 +282,7 @@ class HTMLIngredient {
arg-name="${this.name}"
rows="${this.rows ? this.rows : 3}"
${this.disabled ? "disabled" : ""}>${this.value}</textarea>
${commentHtml}
</div>`;
break;
case "argSelector":
@ -293,6 +304,7 @@ class HTMLIngredient {
</option>`;
}
html += `</select>
${commentHtml}
</div>`;
this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this);

View File

@ -28,6 +28,8 @@ class HTMLOperation {
this.name = name;
this.description = config.description;
this.inlineHelp = config.inlineHelp || "";
this.testDataSamples = config.testDataSamples || [];
this.infoURL = config.infoURL;
this.manualBake = config.manualBake || false;
this.config = config;
@ -74,7 +76,19 @@ class HTMLOperation {
* @returns {string}
*/
toFullHtml() {
let html = `<div class="op-title">${Utils.escapeHtml(this.name)}</div>
let html = `<div class="op-title">${Utils.escapeHtml(this.name)}</div>`;
if (this.inlineHelp) {
html += `<div class="op-inline-help">${this.inlineHelp}</div>`;
}
if (this.testDataSamples.length) {
html += `<div class="op-test-data">
<button type="button" class="btn btn-sm btn-secondary populate-test-data">Populate test data</button>
</div>`;
}
html += `
<div class="ingredients">`;
for (let i = 0; i < this.ingList.length; i++) {

View File

@ -159,6 +159,7 @@ class Manager {
this.addDynamicListener(".hide-args-icon", "click", this.recipe.hideArgsClick, this.recipe);
this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe);
this.addDynamicListener(".populate-test-data", "click", this.recipe.populateTestDataClick, this.recipe);
this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe);
this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe);
this.addDynamicListener("#rec-list .dropdown-menu.toggle-dropdown a", "click", this.recipe.dropdownToggleClick, this.recipe);

View File

@ -26,6 +26,58 @@
font-weight: var(--op-title-font-weight);
}
.op-inline-help {
margin-top: 8px;
padding: 8px 10px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.18);
font-size: 0.9em;
line-height: 1.35;
}
.op-inline-help strong {
font-weight: 700;
}
.op-test-data {
margin-top: 8px;
}
.populate-test-data {
display: inline-block;
padding: 7px 12px;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 6px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.14));
color: #fff;
font-size: 0.85em;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.01em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.populate-test-data:hover,
.populate-test-data:focus {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.2));
border-color: rgba(255, 255, 255, 0.65);
color: #fff;
}
.arg-comment {
margin-top: 6px;
color: rgba(255, 255, 255, 0.88);
font-size: 0.82em;
line-height: 1.4;
}
.arg-comment code {
color: inherit;
background: rgba(255, 255, 255, 0.12);
padding: 1px 4px;
border-radius: 3px;
}
.ingredients {
display: flex;
flex-flow: row wrap;

View File

@ -11,6 +11,18 @@ import {escapeControlChars} from "../utils/editorUtils.mjs";
import DOMPurify from "dompurify";
const ECDH_TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVPecKErSPjan5fSz
f+jsKPKthv3Ao5N0IxkbatQNw16hRANCAARhg779GdYIpH0QnY66FmGX1nMFyybu
sjExdXFN15BBa1+zh1Cf7Cr484KJ8Mh2ga/Qs8qKk/8VbWSj0SbLb6Os
-----END PRIVATE KEY-----`;
const ECDH_TEST_PEER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZWOfvFUyA5ITdtEUar7aAz308Llr
pPVK74bCKbeq3gIA5ZN0we6T18GSkTHtCCOG266YyCGTcE2JrnswYk1f8A==
-----END PUBLIC KEY-----`;
/**
* Waiter to handle events related to the recipe.
*/
@ -481,6 +493,249 @@ class RecipeWaiter {
}
/**
* Populates the operation card and input pane with a built-in test sample.
*
* @fires Manager#statechange
* @param {Event} e
*/
populateTestDataClick(e) {
e.preventDefault();
e.stopPropagation();
const button = e.target.closest(".populate-test-data");
const op = e.target.closest("li.operation");
if (!button || !op) return;
const opName = op.querySelector(".op-title").textContent;
const opConfig = this.app.operations[opName];
const samples = opConfig?.testDataSamples || [];
if (!samples.length) {
return;
}
const sampleIndex = Number(button.dataset.sampleIndex || 0) % samples.length;
const sample = this.resolveTestDataSample(samples[sampleIndex]);
button.dataset.sampleIndex = String((sampleIndex + 1) % samples.length);
if (sample.recipeConfig) {
this.app.setRecipeConfig(sample.recipeConfig);
} else {
this.populateRecipeOperationArgs(op, sample.args || []);
}
if (typeof sample.input === "string") {
this.app.setInput(sample.input);
}
window.dispatchEvent(this.manager.statechange);
}
/**
* Populates a recipe operation's arguments from a resolved sample.
*
* @param {HTMLElement} op
* @param {Array} args
*/
populateRecipeOperationArgs(op, args) {
const ingEls = op.querySelectorAll(".arg");
for (let i = 0; i < ingEls.length; i++) {
if (args[i] === undefined) continue;
if (ingEls[i].getAttribute("type") === "checkbox") {
ingEls[i].checked = Boolean(args[i]);
} else if (ingEls[i].classList.contains("toggle-string")) {
ingEls[i].value = args[i].string;
ingEls[i].parentNode.parentNode.querySelector("button").innerHTML =
Utils.escapeHtml(args[i].option);
} else {
ingEls[i].value = args[i];
}
}
this.triggerArgEvents(op);
}
/**
* Resolves placeholders inside a test-data sample.
*
* @param {Object} sample
* @returns {Object}
*/
resolveTestDataSample(sample) {
return {
input: this.resolveTestDataValue(sample.input),
args: this.resolveTestDataValue(sample.args || []),
recipeConfig: this.resolveTestDataValue(sample.recipeConfig)
};
}
/**
* Recursively resolves test-data placeholders.
*
* @param {*} value
* @returns {*}
*/
resolveTestDataValue(value) {
if (typeof value === "string") {
return this.resolveTestDataPlaceholder(value);
}
if (Array.isArray(value)) {
return value.map(item => this.resolveTestDataValue(item));
}
if (value && typeof value === "object") {
const resolved = {};
for (const [key, nestedValue] of Object.entries(value)) {
resolved[key] = this.resolveTestDataValue(nestedValue);
}
return resolved;
}
return value;
}
/**
* Resolves a single placeholder string into generated or canned test data.
*
* @param {string} value
* @returns {string}
*/
resolveTestDataPlaceholder(value) {
switch (value) {
case "__RANDOM_AES_128_HEX__":
return this.randomHex(16);
case "__RANDOM_TDES_16_HEX__":
return this.randomHex(16);
case "__RANDOM_PIN_4__":
return this.randomDigits(4, true);
case "__RANDOM_PAN_16__":
return this.randomPan(16);
case "__RANDOM_KSN__":
return this.randomKsn();
case "__ECDH_TEST_PRIVATE_KEY__":
return ECDH_TEST_PRIVATE_KEY;
case "__ECDH_TEST_PEER_PUBLIC_KEY__":
return ECDH_TEST_PEER_PUBLIC_KEY;
default:
return value;
}
}
/**
* Generates uppercase random hex.
*
* @param {number} byteLength
* @returns {string}
*/
randomHex(byteLength) {
const bytes = new Uint8Array(byteLength);
this.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("").toUpperCase();
}
/**
* Generates a random numeric string.
*
* @param {number} length
* @param {boolean} firstNonZero
* @returns {string}
*/
randomDigits(length, firstNonZero=false) {
const bytes = new Uint8Array(length);
this.getRandomValues(bytes);
let out = "";
for (let i = 0; i < length; i++) {
let digit = bytes[i] % 10;
if (i === 0 && firstNonZero && digit === 0) digit = 1;
out += String(digit);
}
return out;
}
/**
* Generates a valid Luhn PAN with a Mastercard-style prefix.
*
* @param {number} length
* @returns {string}
*/
randomPan(length=16) {
const prefix = "543210";
const bodyLength = Math.max(prefix.length + 1, length) - 1;
let body = prefix;
if (body.length < bodyLength) {
body += this.randomDigits(bodyLength - body.length);
}
body = body.substring(0, bodyLength);
let sum = 0;
const parity = body.length % 2;
for (let i = 0; i < body.length; i++) {
let digit = parseInt(body.charAt(i), 10);
if (i % 2 === parity) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
}
const checkDigit = (10 - (sum % 10)) % 10;
return body + String(checkDigit);
}
/**
* Generates a DUKPT-style 10-byte KSN hex string with a random 21-bit counter.
*
* @returns {string}
*/
randomKsn() {
const bytes = new Uint8Array(10);
this.getRandomValues(bytes);
bytes[0] = 0xFF;
bytes[1] = 0xFF;
bytes[2] = 0x98;
bytes[3] = 0x76;
bytes[4] = 0x54;
bytes[5] = 0x32;
bytes[6] = 0x10;
bytes[7] = (bytes[7] & 0x1F) | 0xE0;
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("").toUpperCase();
}
/**
* Fills a byte array with random data.
*
* @param {Uint8Array} bytes
* @returns {Uint8Array}
*/
getRandomValues(bytes) {
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
return globalThis.crypto.getRandomValues(bytes);
}
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
return bytes;
}
/**
* Triggers various change events for operation arguments that have just been initialised.
*

View File

@ -197,3 +197,4 @@ const logOpsTestReport = logTestReport.bind(null, testStatus);
const results = await TestRegister.runTests();
logOpsTestReport(results);
})();
import "./tests/Payment.mjs";

View File

@ -0,0 +1,280 @@
/**
* Payment operation tests.
*
* @license Apache-2.0
*/
import TestRegister from "../../lib/TestRegister.mjs";
const ecdhPrivateKey = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVPecKErSPjan5fSz
f+jsKPKthv3Ao5N0IxkbatQNw16hRANCAARhg779GdYIpH0QnY66FmGX1nMFyybu
sjExdXFN15BBa1+zh1Cf7Cr484KJ8Mh2ga/Qs8qKk/8VbWSj0SbLb6Os
-----END PRIVATE KEY-----`;
const ecdhPrivateKeySec1 = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIFT3nChK0j42p+X0s3/o7CjyrYb9wKOTdCMZG2rUDcNeoAoGCCqGSM49
AwEHoUQDQgAEYYO+/RnWCKR9EJ2OuhZhl9ZzBcsm7rIxMXVxTdeQQWtfs4dQn+wq
+POCifDIdoGv0LPKipP/FW1ko9Emy2+jrA==
-----END EC PRIVATE KEY-----`;
const ecdhPeerPublicKey = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZWOfvFUyA5ITdtEUar7aAz308Llr
pPVK74bCKbeq3gIA5ZN0we6T18GSkTHtCCOG266YyCGTcE2JrnswYk1f8A==
-----END PUBLIC KEY-----`;
TestRegister.addTests([
{
name: "Parse TR-31 key block: fixed header only",
input: "D0016D0AB00E0000",
expectedOutput: JSON.stringify({
raw: "D0016D0AB00E0000",
fixedHeader: {
raw: "D0016D0AB00E0000",
versionId: "D",
declaredBlockLength: 16,
keyUsage: "D0",
algorithm: "A",
modeOfUse: "B",
keyVersionNumber: "00",
exportability: "E",
optionalBlocksDeclared: 0,
reserved: "00"
},
optionalBlocks: [],
bodyOffset: 16,
remainingBody: "",
notes: []
}, null, 4),
recipeConfig: [
{
op: "Parse TR-31 key block",
args: [true]
}
]
},
{
name: "Parse TR-34 B9 envelope: split sections",
input: "001730303030423930303100112233300030303034AABBCCDD",
expectedOutput: JSON.stringify({
declaredLength: 23,
actualLengthExcludingLengthField: 23,
header: "0000",
responseType: "B9",
errorCode: "00",
authDataHex: "3100",
kcvHex: "112233",
envelopeDataHex: "3000",
signatureLengthAscii: "0004",
signatureLength: 4,
signatureHex: "AABBCCDD",
trailingHex: ""
}, null, 4),
recipeConfig: [
{
op: "Parse TR-34 B9 envelope",
args: []
}
]
},
{
name: "Calculate payment KCV: HMAC SHA-256",
input: "00112233445566778899AABBCCDDEEFF",
expectedOutput: "E8A065",
recipeConfig: [
{
op: "Calculate payment KCV",
args: ["Hex", "HMAC SHA-256", 6]
}
]
},
{
name: "Calculate payment KCV: AES-CMAC empty",
input: "00112233445566778899AABBCCDDEEFF",
expectedOutput: "917737",
recipeConfig: [
{
op: "Calculate payment KCV",
args: ["Hex", "AES-CMAC (Empty)", 6]
}
]
},
{
name: "Calculate payment KCV: AES-CMAC zeros",
input: "00112233445566778899AABBCCDDEEFF",
expectedOutput: "53E107",
recipeConfig: [
{
op: "Calculate payment KCV",
args: ["Hex", "AES-CMAC (Zeros)", 6]
}
]
},
{
name: "Calculate payment KCV: AES-CMAC ones",
input: "00112233445566778899AABBCCDDEEFF",
expectedOutput: "7B3046",
recipeConfig: [
{
op: "Calculate payment KCV",
args: ["Hex", "AES-CMAC (Ones)", 6]
}
]
},
{
name: "Calculate payment KCV: AES-ECB zeros",
input: "00112233445566778899AABBCCDDEEFF",
expectedOutput: "FDE4FB",
recipeConfig: [
{
op: "Calculate payment KCV",
args: ["Hex", "AES-ECB (Zeros)", 6]
}
]
},
{
name: "Derive DUKPT key: known IPEK vector",
input: "0123456789ABCDEFFEDCBA9876543210",
expectedOutput: "6AC292FAA1315B4D858AB3A3D7D5933A",
recipeConfig: [
{
op: "Derive DUKPT key",
args: ["Derive IPEK", "FFFF9876543210E00008", "None", false]
}
]
},
{
name: "Build PIN block: ISO Format 0",
input: "1234",
expectedOutput: "041215FEDCBA9876",
recipeConfig: [
{
op: "Build PIN block",
args: ["ISO Format 0", "5432101234567890", false]
}
]
},
{
name: "Parse PIN block: ISO Format 0",
input: "041215FEDCBA9876",
expectedOutput: JSON.stringify({
format: "ISO Format 0",
pin: "1234",
pinLength: 4,
pinFieldHex: "041234FFFFFFFFFF",
panFieldHex: "0000210123456789",
blockHex: "041215FEDCBA9876",
fillDigitsHex: "FFFFFFFFFF"
}, null, 4),
recipeConfig: [
{
op: "Parse PIN block",
args: ["ISO Format 0", "5432101234567890"]
}
]
},
{
name: "Translate PIN block: ISO Format 0 to ISO Format 1",
input: "041215FEDCBA9876",
expectedOutput: JSON.stringify({
source: {
format: "ISO Format 0",
pin: "1234",
pinLength: 4,
pinFieldHex: "041234FFFFFFFFFF",
panFieldHex: "0000210123456789",
blockHex: "041215FEDCBA9876",
fillDigitsHex: "FFFFFFFFFF"
},
target: {
format: "ISO Format 1",
blockHex: "141234FFFFFFFFFF"
}
}, null, 4),
recipeConfig: [
{
op: "Translate PIN block",
args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false]
}
]
},
{
name: "Generate card validation data: known CVV2 sample",
input: "0123456789ABCDEFFEDCBA9876543210",
expectedOutput: "221",
recipeConfig: [
{
op: "Generate card validation data",
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false]
}
]
},
{
name: "Verify card validation data: known CVV2 sample",
input: "0123456789ABCDEFFEDCBA9876543210",
expectedOutput: JSON.stringify({
profile: "CVV2 / CVC2 (force 000)",
pan: "4123456789012345",
expiry: "0225",
expiryLayout: "MMYY",
serviceCode: "000",
digitCount: 3,
inputDigits: "41234567890123450225000000000000",
resultHex: "D2D21E5FA3030D91",
decimalized: "22153",
validationData: "221",
expectedValue: "221",
valid: true
}, null, 4),
recipeConfig: [
{
op: "Verify card validation data",
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"]
}
]
},
{
name: "Generate EMV ARQC: AES-CMAC profile",
input: "000102030405060708090A0B0C0D0E0F",
expectedOutput: "C1F732B52FB20CAA",
recipeConfig: [
{
op: "Generate EMV ARQC",
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
}
]
},
{
name: "Generate EMV ARPC: AES-CMAC profile",
input: "11223344556677889900AABBCCDDEEFF",
expectedOutput: "312442B1A4D64F94",
recipeConfig: [
{
op: "Generate EMV ARPC",
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
}
]
},
{
name: "Derive ECDH key material: raw shared secret",
input: ecdhPrivateKey,
expectedOutput: "4BE993A2D1BD25C7B5A625EDEBE48D022557ACA445C60EE403ECE9BA38A41CFE",
recipeConfig: [
{
op: "Derive ECDH key material",
args: ["PEM", "P-256", "PEM", ecdhPeerPublicKey, "None", 32, "", "Hex"]
}
]
},
{
name: "Derive ECDH key material: SEC1 EC private key PEM",
input: ecdhPrivateKeySec1,
expectedOutput: "4BE993A2D1BD25C7B5A625EDEBE48D022557ACA445C60EE403ECE9BA38A41CFE",
recipeConfig: [
{
op: "Derive ECDH key material",
args: ["PEM", "P-256", "PEM", ecdhPeerPublicKey, "None", 32, "", "Hex"]
}
]
}
]);