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;

File diff suppressed because it is too large Load Diff

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"]
}
]
}
]);