From 7433b07f2b8a07a82c71d58c3be1b0e2f79c0878 Mon Sep 17 00:00:00 2001 From: J8k3 Date: Sat, 25 Apr 2026 00:00:22 -0400 Subject: [PATCH] Add payment validation and EMV test operations --- AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md | 262 ++++ PAYMENT_RECIPES.md | 94 ++ PAYMENT_SIM_RECIPES.md | 50 + README.md | 8 +- src/core/Operation.mjs | 3 + src/core/config/Categories.json | 1179 +++++++++-------- src/core/config/scripts/generateConfig.mjs | 2 + src/core/lib/CardValidation.mjs | 249 ++++ src/core/lib/EmvCryptogram.mjs | 40 + src/core/lib/PaymentUtils.mjs | 75 ++ src/core/lib/PinBlock.mjs | 249 ++++ src/core/operations/BuildPINBlock.mjs | 66 + src/core/operations/CalculatePaymentKCV.mjs | 144 ++ src/core/operations/DeriveDUKPTKey.mjs | 279 ++++ src/core/operations/DeriveECDHKeyMaterial.mjs | 263 ++++ .../operations/GenerateCardValidationData.mjs | 110 ++ src/core/operations/GenerateEMVARPC.mjs | 69 + src/core/operations/GenerateEMVARQC.mjs | 69 + src/core/operations/ParsePINBlock.mjs | 60 + src/core/operations/ParseTR31KeyBlock.mjs | 129 ++ src/core/operations/ParseTR34B9Envelope.mjs | 137 ++ src/core/operations/TranslatePINBlock.mjs | 83 ++ .../operations/VerifyCardValidationData.mjs | 104 ++ src/web/HTMLIngredient.mjs | 14 +- src/web/HTMLOperation.mjs | 16 +- src/web/Manager.mjs | 1 + src/web/stylesheets/components/_operation.css | 52 + src/web/waiters/RecipeWaiter.mjs | 255 ++++ tests/operations/index.mjs | 1 + tests/operations/tests/Payment.mjs | 280 ++++ 30 files changed, 3759 insertions(+), 584 deletions(-) create mode 100644 AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md create mode 100644 PAYMENT_RECIPES.md create mode 100644 PAYMENT_SIM_RECIPES.md create mode 100644 src/core/lib/CardValidation.mjs create mode 100644 src/core/lib/EmvCryptogram.mjs create mode 100644 src/core/lib/PaymentUtils.mjs create mode 100644 src/core/lib/PinBlock.mjs create mode 100644 src/core/operations/BuildPINBlock.mjs create mode 100644 src/core/operations/CalculatePaymentKCV.mjs create mode 100644 src/core/operations/DeriveDUKPTKey.mjs create mode 100644 src/core/operations/DeriveECDHKeyMaterial.mjs create mode 100644 src/core/operations/GenerateCardValidationData.mjs create mode 100644 src/core/operations/GenerateEMVARPC.mjs create mode 100644 src/core/operations/GenerateEMVARQC.mjs create mode 100644 src/core/operations/ParsePINBlock.mjs create mode 100644 src/core/operations/ParseTR31KeyBlock.mjs create mode 100644 src/core/operations/ParseTR34B9Envelope.mjs create mode 100644 src/core/operations/TranslatePINBlock.mjs create mode 100644 src/core/operations/VerifyCardValidationData.mjs create mode 100644 tests/operations/tests/Payment.mjs diff --git a/AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md b/AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md new file mode 100644 index 00000000..f6f65422 --- /dev/null +++ b/AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md @@ -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 AWS’s 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 AWS’s 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. diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md new file mode 100644 index 00000000..81d72ee2 --- /dev/null +++ b/PAYMENT_RECIPES.md @@ -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` diff --git a/PAYMENT_SIM_RECIPES.md b/PAYMENT_SIM_RECIPES.md new file mode 100644 index 00000000..90fcdac4 --- /dev/null +++ b/PAYMENT_SIM_RECIPES.md @@ -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. diff --git a/README.md b/README.md index 44393f36..dec39514 100755 --- a/README.md +++ b/README.md @@ -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 CyberChef’s 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! diff --git a/src/core/Operation.mjs b/src/core/Operation.mjs index 24739d3f..839a3588 100755 --- a/src/core/Operation.mjs +++ b/src/core/Operation.mjs @@ -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; diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index aac00ca1..47248cf2 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -1,583 +1,600 @@ [ - { - "name": "Favourites", - "ops": [] - }, - { - "name": "Data format", - "ops": [ - "To Hexdump", - "From Hexdump", - "To Hex", - "From Hex", - "To Charcode", - "From Charcode", - "To Decimal", - "From Decimal", - "To Float", - "From Float", - "To Binary", - "From Binary", - "To Octal", - "From Octal", - "To Base32", - "From Base32", - "To Base45", - "From Base45", - "To Base58", - "From Base58", - "To Bech32", - "From Bech32", - "To Base62", - "From Base62", - "To Base64", - "From Base64", - "Show Base64 offsets", - "To Base92", - "From Base92", - "To Base85", - "From Base85", - "To Base", - "From Base", - "To BCD", - "From BCD", - "To HTML Entity", - "From HTML Entity", - "URL Encode", - "URL Decode", - "Escape Unicode Characters", - "Unescape Unicode Characters", - "Normalise Unicode", - "To Quoted Printable", - "From Quoted Printable", - "To Punycode", - "From Punycode", - "AMF Encode", - "AMF Decode", - "To Hex Content", - "From Hex Content", - "PEM to Hex", - "Hex to PEM", - "Parse ASN.1 hex string", - "Change IP format", - "Encode text", - "Decode text", - "Text Encoding Brute Force", - "Swap endianness", - "To MessagePack", - "From MessagePack", - "To Braille", - "From Braille", - "Parse TLV", - "CSV to JSON", - "JSON to CSV", - "Avro to JSON", - "CBOR Encode", - "CBOR Decode", - "YAML to JSON", - "JSON to YAML", - "Caret/M-decode", - "Rison Encode", - "Rison Decode", - "To Modhex", - "From Modhex", - "MIME Decoding" - ] - }, - { - "name": "Encryption / Encoding", - "ops": [ - "AES Encrypt", - "AES Decrypt", - "Blowfish Encrypt", - "Blowfish Decrypt", - "DES Encrypt", - "DES Decrypt", - "Triple DES Encrypt", - "Triple DES Decrypt", - "Fernet Encrypt", - "Fernet Decrypt", - "LS47 Encrypt", - "LS47 Decrypt", - "RC2 Encrypt", - "RC2 Decrypt", - "RC4", - "RC4 Drop", - "ChaCha", - "Salsa20", - "XSalsa20", - "Rabbit", - "SM4 Encrypt", - "SM4 Decrypt", - "GOST Encrypt", - "GOST Decrypt", - "GOST Sign", - "GOST Verify", - "GOST Key Wrap", - "GOST Key Unwrap", - "ROT13", - "ROT13 Brute Force", - "ROT47", - "ROT47 Brute Force", - "ROT8000", - "XOR", - "XOR Brute Force", - "Vigenère Encode", - "Vigenère Decode", - "XXTEA Encrypt", - "XXTEA Decrypt", - "To Morse Code", - "From Morse Code", - "Bacon Cipher Encode", - "Bacon Cipher Decode", - "Bifid Cipher Encode", - "Bifid Cipher Decode", - "Caesar Box Cipher", - "Affine Cipher Encode", - "Affine Cipher Decode", - "A1Z26 Cipher Encode", - "A1Z26 Cipher Decode", - "Rail Fence Cipher Encode", - "Rail Fence Cipher Decode", - "Atbash Cipher", - "CipherSaber2 Encrypt", - "CipherSaber2 Decrypt", - "Cetacean Cipher Encode", - "Cetacean Cipher Decode", - "Substitute", - "Derive PBKDF2 key", - "Derive EVP key", - "Derive HKDF key", - "Bcrypt", - "Scrypt", - "JWT Sign", - "JWT Verify", - "JWT Decode", - "Citrix CTX1 Encode", - "Citrix CTX1 Decode", - "AES Key Wrap", - "AES Key Unwrap", - "Pseudo-Random Number Generator", - "Enigma", - "Bombe", - "Multiple Bombe", - "Typex", - "Lorenz", - "Colossus", - "SIGABA" - ] - }, - { - "name": "Public Key", - "ops": [ - "Parse X.509 certificate", - "Parse X.509 CRL", - "Parse ASN.1 hex string", - "PEM to Hex", - "Hex to PEM", - "Hex to Object Identifier", - "Object Identifier to Hex", - "PEM to JWK", - "JWK to PEM", - "Generate PGP Key Pair", - "PGP Encrypt", - "PGP Decrypt", - "PGP Verify", - "PGP Encrypt and Sign", - "PGP Decrypt and Verify", - "Generate RSA Key Pair", - "RSA Sign", - "RSA Verify", - "RSA Encrypt", - "RSA Decrypt", - "Generate ECDSA Key Pair", - "ECDSA Signature Conversion", - "ECDSA Sign", - "ECDSA Verify", - "Parse SSH Host Key", - "Parse CSR", - "Public Key from Certificate", - "Public Key from Private Key", - "SM2 Encrypt", - "SM2 Decrypt" - ] - }, - { - "name": "Arithmetic / Logic", - "ops": [ - "Set Union", - "Set Intersection", - "Set Difference", - "Symmetric Difference", - "Cartesian Product", - "Power Set", - "XOR", - "XOR Brute Force", - "OR", - "NOT", - "AND", - "ADD", - "SUB", - "Sum", - "Subtract", - "Multiply", - "Divide", - "Mean", - "Median", - "Standard Deviation", - "Bit shift left", - "Bit shift right", - "Rotate left", - "Rotate right", - "ROT13", - "ROT8000" - ] - }, - { - "name": "Networking", - "ops": [ - "HTTP request", - "DNS over HTTPS", - "Strip HTTP headers", - "Dechunk HTTP response", - "Parse User Agent", - "Parse IP range", - "Parse IPv6 address", - "IPv6 Transition Addresses", - "Parse IPv4 header", - "Strip IPv4 header", - "Parse TCP", - "Strip TCP header", - "Parse TLS record", - "Parse UDP", - "Strip UDP header", - "Parse SSH Host Key", - "Parse URI", - "URL Encode", - "URL Decode", - "Protobuf Decode", - "Protobuf Encode", - "VarInt Encode", - "VarInt Decode", - "JA3 Fingerprint", - "JA3S Fingerprint", - "JA4 Fingerprint", - "JA4Server Fingerprint", - "HASSH Client Fingerprint", - "HASSH Server Fingerprint", - "Format MAC addresses", - "Change IP format", - "Group IP addresses", - "Encode NetBIOS Name", - "Decode NetBIOS Name", - "Defang URL", - "Fang URL", - "Defang IP Addresses" - ] - }, - { - "name": "Language", - "ops": [ - "Encode text", - "Decode text", - "Unicode Text Format", - "Remove Diacritics", - "Unescape Unicode Characters", - "Convert to NATO alphabet", - "Convert Leet Speak" - ] - }, - { - "name": "Utils", - "ops": [ - "Diff", - "Remove whitespace", - "Remove null bytes", - "To Upper case", - "To Lower case", - "Swap case", - "Alternating Caps", - "To Case Insensitive Regex", - "From Case Insensitive Regex", - "Add line numbers", - "Remove line numbers", - "Get All Casings", - "To Table", - "Reverse", - "Sort", - "Shuffle", - "Unique", - "Split", - "Filter", - "Head", - "Tail", - "Count occurrences", - "Expand alphabet range", - "Drop bytes", - "Take bytes", - "Pad lines", - "Find / Replace", - "Regular expression", - "Fuzzy Match", - "Offset checker", - "Hamming Distance", - "Levenshtein Distance", - "Convert distance", - "Convert area", - "Convert mass", - "Convert speed", - "Convert data units", - "Convert co-ordinate format", - "Show on map", - "Parse UNIX file permissions", - "Parse ObjectID timestamp", - "Swap endianness", - "Parse colour code", - "Escape string", - "Unescape string", - "Pseudo-Random Number Generator", - "Sleep", - "File Tree", - "Take nth bytes", - "Drop nth bytes" - ] - }, - { - "name": "Date / Time", - "ops": [ - "Parse DateTime", - "Translate DateTime Format", - "From UNIX Timestamp", - "To UNIX Timestamp", - "Windows Filetime to UNIX Timestamp", - "UNIX Timestamp to Windows Filetime", - "DateTime Delta", - "Extract dates", - "Get Time", - "Sleep" - ] - }, - { - "name": "Extractors", - "ops": [ - "Strings", - "Extract IP addresses", - "Extract email addresses", - "Extract MAC addresses", - "Extract URLs", - "Extract domains", - "Extract file paths", - "Extract dates", - "Extract hashes", - "Regular expression", - "XPath expression", - "JPath expression", - "Jsonata Query", - "CSS selector", - "Extract EXIF", - "Extract ID3", - "Extract Files", - "RAKE", - "Template" - ] - }, - { - "name": "Compression", - "ops": [ - "Raw Deflate", - "Raw Inflate", - "Zlib Deflate", - "Zlib Inflate", - "Gzip", - "Gunzip", - "Zip", - "Unzip", - "Bzip2 Decompress", - "Bzip2 Compress", - "Tar", - "Untar", - "LZString Decompress", - "LZString Compress", - "LZMA Decompress", - "LZMA Compress", - "LZ4 Decompress", - "LZ4 Compress", - "LZNT1 Decompress" - ] - }, - { - "name": "Hashing", - "ops": [ - "Analyse hash", - "Generate all checksums", - "Generate all hashes", - "MD2", - "MD4", - "MD5", - "MD6", - "SHA0", - "SHA1", - "SHA2", - "SHA3", - "SM3", - "Keccak", - "Shake", - "RIPEMD", - "HAS-160", - "Whirlpool", - "Snefru", - "BLAKE2b", - "BLAKE2s", - "BLAKE3", - "GOST Hash", - "Streebog", - "SSDEEP", - "CTPH", - "Compare SSDEEP hashes", - "Compare CTPH hashes", - "HMAC", - "CMAC", - "Bcrypt", - "Bcrypt compare", - "Bcrypt parse", - "Argon2", - "Argon2 compare", - "Scrypt", - "NT Hash", - "LM Hash", - "MurmurHash3", - "Fletcher-8 Checksum", - "Fletcher-16 Checksum", - "Fletcher-32 Checksum", - "Fletcher-64 Checksum", - "Adler-32 Checksum", - "Luhn Checksum", - "CRC Checksum", - "TCP/IP Checksum", - "XOR Checksum" - ] - }, - { - "name": "Code tidy", - "ops": [ - "Syntax highlighter", - "Generic Code Beautify", - "JavaScript Parser", - "JavaScript Beautify", - "JavaScript Minify", - "JSON Beautify", - "JSON Minify", - "XML Beautify", - "XML Minify", - "SQL Beautify", - "SQL Minify", - "CSS Beautify", - "CSS Minify", - "XPath expression", - "JPath expression", - "Jq", - "CSS selector", - "PHP Deserialize", - "PHP Serialize", - "Microsoft Script Decoder", - "Strip HTML tags", - "Diff", - "To Snake case", - "To Camel case", - "To Kebab case", - "BSON serialise", - "BSON deserialise", - "To MessagePack", - "From MessagePack", - "Render Markdown" - ] - }, - { - "name": "Forensics", - "ops": [ - "Detect File Type", - "Scan for Embedded Files", - "Extract Files", - "YARA Rules", - "Remove EXIF", - "Extract EXIF", - "Extract RGBA", - "View Bit Plane", - "Randomize Colour Palette", - "Extract LSB", - "ELF Info" - ] - }, - { - "name": "Multimedia", - "ops": [ - "Render Image", - "Play Media", - "Generate Image", - "Optical Character Recognition", - "Remove EXIF", - "Extract EXIF", - "Split Colour Channels", - "Rotate Image", - "Resize Image", - "Blur Image", - "Dither Image", - "Invert Image", - "Flip Image", - "Crop Image", - "Image Brightness / Contrast", - "Image Opacity", - "Image Filter", - "Contain Image", - "Cover Image", - "Image Hue/Saturation/Lightness", - "Sharpen Image", - "Normalise Image", - "Convert Image Format", - "Add Text To Image", - "Hex Density chart", - "Scatter chart", - "Series chart", - "Heatmap chart" - ] - }, - { - "name": "Other", - "ops": [ - "Entropy", - "Frequency distribution", - "Index of Coincidence", - "Chi Square", - "P-list Viewer", - "Disassemble x86", - "Pseudo-Random Number Generator", - "Generate De Bruijn Sequence", - "Generate UUID", - "Analyse UUID", - "Generate TOTP", - "Generate HOTP", - "Generate QR Code", - "Parse QR Code", - "Haversine distance", - "HTML To Text", - "Generate Lorem Ipsum", - "Numberwang", - "XKCD Random Number" - ] - }, - { - "name": "Flow control", - "ops": [ - "Magic", - "Fork", - "Subsection", - "Merge", - "Register", - "Label", - "Jump", - "Conditional Jump", - "Return", - "Comment" - ] - } + { + "name": "Favourites", + "ops": [] + }, + { + "name": "Data format", + "ops": [ + "To Hexdump", + "From Hexdump", + "To Hex", + "From Hex", + "To Charcode", + "From Charcode", + "To Decimal", + "From Decimal", + "To Float", + "From Float", + "To Binary", + "From Binary", + "To Octal", + "From Octal", + "To Base32", + "From Base32", + "To Base45", + "From Base45", + "To Base58", + "From Base58", + "To Bech32", + "From Bech32", + "To Base62", + "From Base62", + "To Base64", + "From Base64", + "Show Base64 offsets", + "To Base92", + "From Base92", + "To Base85", + "From Base85", + "To Base", + "From Base", + "To BCD", + "From BCD", + "To HTML Entity", + "From HTML Entity", + "URL Encode", + "URL Decode", + "Escape Unicode Characters", + "Unescape Unicode Characters", + "Normalise Unicode", + "To Quoted Printable", + "From Quoted Printable", + "To Punycode", + "From Punycode", + "AMF Encode", + "AMF Decode", + "To Hex Content", + "From Hex Content", + "PEM to Hex", + "Hex to PEM", + "Parse ASN.1 hex string", + "Change IP format", + "Encode text", + "Decode text", + "Text Encoding Brute Force", + "Swap endianness", + "To MessagePack", + "From MessagePack", + "To Braille", + "From Braille", + "Parse TLV", + "CSV to JSON", + "JSON to CSV", + "Avro to JSON", + "CBOR Encode", + "CBOR Decode", + "YAML to JSON", + "JSON to YAML", + "Caret/M-decode", + "Rison Encode", + "Rison Decode", + "To Modhex", + "From Modhex", + "MIME Decoding" + ] + }, + { + "name": "Encryption / Encoding", + "ops": [ + "AES Encrypt", + "AES Decrypt", + "Blowfish Encrypt", + "Blowfish Decrypt", + "DES Encrypt", + "DES Decrypt", + "Triple DES Encrypt", + "Triple DES Decrypt", + "Fernet Encrypt", + "Fernet Decrypt", + "LS47 Encrypt", + "LS47 Decrypt", + "RC2 Encrypt", + "RC2 Decrypt", + "RC4", + "RC4 Drop", + "ChaCha", + "Salsa20", + "XSalsa20", + "Rabbit", + "SM4 Encrypt", + "SM4 Decrypt", + "GOST Encrypt", + "GOST Decrypt", + "GOST Sign", + "GOST Verify", + "GOST Key Wrap", + "GOST Key Unwrap", + "ROT13", + "ROT13 Brute Force", + "ROT47", + "ROT47 Brute Force", + "ROT8000", + "XOR", + "XOR Brute Force", + "Vigenère Encode", + "Vigenère Decode", + "XXTEA Encrypt", + "XXTEA Decrypt", + "To Morse Code", + "From Morse Code", + "Bacon Cipher Encode", + "Bacon Cipher Decode", + "Bifid Cipher Encode", + "Bifid Cipher Decode", + "Caesar Box Cipher", + "Affine Cipher Encode", + "Affine Cipher Decode", + "A1Z26 Cipher Encode", + "A1Z26 Cipher Decode", + "Rail Fence Cipher Encode", + "Rail Fence Cipher Decode", + "Atbash Cipher", + "CipherSaber2 Encrypt", + "CipherSaber2 Decrypt", + "Cetacean Cipher Encode", + "Cetacean Cipher Decode", + "Substitute", + "Derive PBKDF2 key", + "Derive EVP key", + "Derive HKDF key", + "Bcrypt", + "Scrypt", + "JWT Sign", + "JWT Verify", + "JWT Decode", + "Citrix CTX1 Encode", + "Citrix CTX1 Decode", + "AES Key Wrap", + "AES Key Unwrap", + "Pseudo-Random Number Generator", + "Enigma", + "Bombe", + "Multiple Bombe", + "Typex", + "Lorenz", + "Colossus", + "SIGABA" + ] + }, + { + "name": "Public Key", + "ops": [ + "Parse X.509 certificate", + "Parse X.509 CRL", + "Parse ASN.1 hex string", + "PEM to Hex", + "Hex to PEM", + "Hex to Object Identifier", + "Object Identifier to Hex", + "PEM to JWK", + "JWK to PEM", + "Generate PGP Key Pair", + "PGP Encrypt", + "PGP Decrypt", + "PGP Verify", + "PGP Encrypt and Sign", + "PGP Decrypt and Verify", + "Generate RSA Key Pair", + "RSA Sign", + "RSA Verify", + "RSA Encrypt", + "RSA Decrypt", + "Generate ECDSA Key Pair", + "ECDSA Signature Conversion", + "ECDSA Sign", + "ECDSA Verify", + "Parse SSH Host Key", + "Parse CSR", + "Public Key from Certificate", + "Public Key from Private Key", + "SM2 Encrypt", + "SM2 Decrypt" + ] + }, + { + "name": "Arithmetic / Logic", + "ops": [ + "Set Union", + "Set Intersection", + "Set Difference", + "Symmetric Difference", + "Cartesian Product", + "Power Set", + "XOR", + "XOR Brute Force", + "OR", + "NOT", + "AND", + "ADD", + "SUB", + "Sum", + "Subtract", + "Multiply", + "Divide", + "Mean", + "Median", + "Standard Deviation", + "Bit shift left", + "Bit shift right", + "Rotate left", + "Rotate right", + "ROT13", + "ROT8000" + ] + }, + { + "name": "Networking", + "ops": [ + "HTTP request", + "DNS over HTTPS", + "Strip HTTP headers", + "Dechunk HTTP response", + "Parse User Agent", + "Parse IP range", + "Parse IPv6 address", + "IPv6 Transition Addresses", + "Parse IPv4 header", + "Strip IPv4 header", + "Parse TCP", + "Strip TCP header", + "Parse TLS record", + "Parse UDP", + "Strip UDP header", + "Parse SSH Host Key", + "Parse URI", + "URL Encode", + "URL Decode", + "Protobuf Decode", + "Protobuf Encode", + "VarInt Encode", + "VarInt Decode", + "JA3 Fingerprint", + "JA3S Fingerprint", + "JA4 Fingerprint", + "JA4Server Fingerprint", + "HASSH Client Fingerprint", + "HASSH Server Fingerprint", + "Format MAC addresses", + "Change IP format", + "Group IP addresses", + "Encode NetBIOS Name", + "Decode NetBIOS Name", + "Defang URL", + "Fang URL", + "Defang IP Addresses" + ] + }, + { + "name": "Language", + "ops": [ + "Encode text", + "Decode text", + "Unicode Text Format", + "Remove Diacritics", + "Unescape Unicode Characters", + "Convert to NATO alphabet", + "Convert Leet Speak" + ] + }, + { + "name": "Utils", + "ops": [ + "Diff", + "Remove whitespace", + "Remove null bytes", + "To Upper case", + "To Lower case", + "Swap case", + "Alternating Caps", + "To Case Insensitive Regex", + "From Case Insensitive Regex", + "Add line numbers", + "Remove line numbers", + "Get All Casings", + "To Table", + "Reverse", + "Sort", + "Shuffle", + "Unique", + "Split", + "Filter", + "Head", + "Tail", + "Count occurrences", + "Expand alphabet range", + "Drop bytes", + "Take bytes", + "Pad lines", + "Find / Replace", + "Regular expression", + "Fuzzy Match", + "Offset checker", + "Hamming Distance", + "Levenshtein Distance", + "Convert distance", + "Convert area", + "Convert mass", + "Convert speed", + "Convert data units", + "Convert co-ordinate format", + "Show on map", + "Parse UNIX file permissions", + "Parse ObjectID timestamp", + "Swap endianness", + "Parse colour code", + "Escape string", + "Unescape string", + "Pseudo-Random Number Generator", + "Sleep", + "File Tree", + "Take nth bytes", + "Drop nth bytes" + ] + }, + { + "name": "Date / Time", + "ops": [ + "Parse DateTime", + "Translate DateTime Format", + "From UNIX Timestamp", + "To UNIX Timestamp", + "Windows Filetime to UNIX Timestamp", + "UNIX Timestamp to Windows Filetime", + "DateTime Delta", + "Extract dates", + "Get Time", + "Sleep" + ] + }, + { + "name": "Extractors", + "ops": [ + "Strings", + "Extract IP addresses", + "Extract email addresses", + "Extract MAC addresses", + "Extract URLs", + "Extract domains", + "Extract file paths", + "Extract dates", + "Extract hashes", + "Regular expression", + "XPath expression", + "JPath expression", + "Jsonata Query", + "CSS selector", + "Extract EXIF", + "Extract ID3", + "Extract Files", + "RAKE", + "Template" + ] + }, + { + "name": "Compression", + "ops": [ + "Raw Deflate", + "Raw Inflate", + "Zlib Deflate", + "Zlib Inflate", + "Gzip", + "Gunzip", + "Zip", + "Unzip", + "Bzip2 Decompress", + "Bzip2 Compress", + "Tar", + "Untar", + "LZString Decompress", + "LZString Compress", + "LZMA Decompress", + "LZMA Compress", + "LZ4 Decompress", + "LZ4 Compress", + "LZNT1 Decompress" + ] + }, + { + "name": "Hashing", + "ops": [ + "Analyse hash", + "Generate all checksums", + "Generate all hashes", + "MD2", + "MD4", + "MD5", + "MD6", + "SHA0", + "SHA1", + "SHA2", + "SHA3", + "SM3", + "Keccak", + "Shake", + "RIPEMD", + "HAS-160", + "Whirlpool", + "Snefru", + "BLAKE2b", + "BLAKE2s", + "BLAKE3", + "GOST Hash", + "Streebog", + "SSDEEP", + "CTPH", + "Compare SSDEEP hashes", + "Compare CTPH hashes", + "HMAC", + "CMAC", + "Bcrypt", + "Bcrypt compare", + "Bcrypt parse", + "Argon2", + "Argon2 compare", + "Scrypt", + "NT Hash", + "LM Hash", + "MurmurHash3", + "Fletcher-8 Checksum", + "Fletcher-16 Checksum", + "Fletcher-32 Checksum", + "Fletcher-64 Checksum", + "Adler-32 Checksum", + "Luhn Checksum", + "CRC Checksum", + "TCP/IP Checksum", + "XOR Checksum" + ] + }, + { + "name": "Code tidy", + "ops": [ + "Syntax highlighter", + "Generic Code Beautify", + "JavaScript Parser", + "JavaScript Beautify", + "JavaScript Minify", + "JSON Beautify", + "JSON Minify", + "XML Beautify", + "XML Minify", + "SQL Beautify", + "SQL Minify", + "CSS Beautify", + "CSS Minify", + "XPath expression", + "JPath expression", + "Jq", + "CSS selector", + "PHP Deserialize", + "PHP Serialize", + "Microsoft Script Decoder", + "Strip HTML tags", + "Diff", + "To Snake case", + "To Camel case", + "To Kebab case", + "BSON serialise", + "BSON deserialise", + "To MessagePack", + "From MessagePack", + "Render Markdown" + ] + }, + { + "name": "Forensics", + "ops": [ + "Detect File Type", + "Scan for Embedded Files", + "Extract Files", + "YARA Rules", + "Remove EXIF", + "Extract EXIF", + "Extract RGBA", + "View Bit Plane", + "Randomize Colour Palette", + "Extract LSB", + "ELF Info" + ] + }, + { + "name": "Multimedia", + "ops": [ + "Render Image", + "Play Media", + "Generate Image", + "Optical Character Recognition", + "Remove EXIF", + "Extract EXIF", + "Split Colour Channels", + "Rotate Image", + "Resize Image", + "Blur Image", + "Dither Image", + "Invert Image", + "Flip Image", + "Crop Image", + "Image Brightness / Contrast", + "Image Opacity", + "Image Filter", + "Contain Image", + "Cover Image", + "Image Hue/Saturation/Lightness", + "Sharpen Image", + "Normalise Image", + "Convert Image Format", + "Add Text To Image", + "Hex Density chart", + "Scatter chart", + "Series chart", + "Heatmap chart" + ] + }, + { + "name": "Other", + "ops": [ + "Entropy", + "Frequency distribution", + "Index of Coincidence", + "Chi Square", + "P-list Viewer", + "Disassemble x86", + "Pseudo-Random Number Generator", + "Generate De Bruijn Sequence", + "Generate UUID", + "Analyse UUID", + "Generate TOTP", + "Generate HOTP", + "Generate QR Code", + "Parse QR Code", + "Haversine distance", + "HTML To Text", + "Generate Lorem Ipsum", + "Numberwang", + "XKCD Random Number" + ] + }, + { + "name": "Payments", + "ops": [ + "Parse TR-31 key block", + "Parse TR-34 B9 envelope", + "Calculate payment KCV", + "Derive ECDH key material", + "Derive DUKPT key", + "Generate card validation data", + "Verify card validation data", + "Generate EMV ARQC", + "Generate EMV ARPC", + "Build PIN block", + "Parse PIN block", + "Translate PIN block" + ] + }, + { + "name": "Flow control", + "ops": [ + "Magic", + "Fork", + "Subsection", + "Merge", + "Register", + "Label", + "Jump", + "Conditional Jump", + "Return", + "Comment" + ] + } ] diff --git a/src/core/config/scripts/generateConfig.mjs b/src/core/config/scripts/generateConfig.mjs index 64c7cb81..29d76957 100644 --- a/src/core/config/scripts/generateConfig.mjs +++ b/src/core/config/scripts/generateConfig.mjs @@ -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, diff --git a/src/core/lib/CardValidation.mjs b/src/core/lib/CardValidation.mjs new file mode 100644 index 00000000..52351f1e --- /dev/null +++ b/src/core/lib/CardValidation.mjs @@ -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, +}; diff --git a/src/core/lib/EmvCryptogram.mjs b/src/core/lib/EmvCryptogram.mjs new file mode 100644 index 00000000..bdf8682a --- /dev/null +++ b/src/core/lib/EmvCryptogram.mjs @@ -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, +}; diff --git a/src/core/lib/PaymentUtils.mjs b/src/core/lib/PaymentUtils.mjs new file mode 100644 index 00000000..91e779b9 --- /dev/null +++ b/src/core/lib/PaymentUtils.mjs @@ -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, +}; diff --git a/src/core/lib/PinBlock.mjs b/src/core/lib/PinBlock.mjs new file mode 100644 index 00000000..dc97dfad --- /dev/null +++ b/src/core/lib/PinBlock.mjs @@ -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, +}; diff --git a/src/core/operations/BuildPINBlock.mjs b/src/core/operations/BuildPINBlock.mjs new file mode 100644 index 00000000..f6a9ac0e --- /dev/null +++ b/src/core/operations/BuildPINBlock.mjs @@ -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.

Input: clear PIN digits.
Arguments: choose the target format, provide the PAN when required, and optionally randomize filler digits for formats 1 and 3.

This operation currently builds clear test PIN blocks for ISO formats 0, 1, and 3."; + this.inlineHelp = "Input: clear PIN digits.
Args: 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 0, 1, and 3 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; diff --git a/src/core/operations/CalculatePaymentKCV.mjs b/src/core/operations/CalculatePaymentKCV.mjs new file mode 100644 index 00000000..cf4e86c0 --- /dev/null +++ b/src/core/operations/CalculatePaymentKCV.mjs @@ -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 Key format.

Use Method to choose the KCV style: TDES, AES-CMAC, AES-ECB, or HMAC.

Input: raw key material such as hex, UTF-8, Latin1, or Base64.
Arguments: select the key format, method, and output length in hex characters.

Returns an uppercase truncated hex KCV value."; + this.inlineHelp = "Input: key material.
Args: 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 Hex 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 6." + } + ]; + } + + /** + * @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; diff --git a/src/core/operations/DeriveDUKPTKey.mjs b/src/core/operations/DeriveDUKPTKey.mjs new file mode 100644 index 00000000..4fd87334 --- /dev/null +++ b/src/core/operations/DeriveDUKPTKey.mjs @@ -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.

Put the 10-byte Key Serial Number in the KSN argument field.

Input: BDK in hex.
Arguments: choose whether to derive the IPEK or the transaction key, provide the KSN, choose the variant, and optionally return JSON.

This operation derives TDES DUKPT keys in software for test and interoperability work."; + this.inlineHelp = "Input: BDK hex.
Args: 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 FFFF9876543210E00008. 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; diff --git a/src/core/operations/DeriveECDHKeyMaterial.mjs b/src/core/operations/DeriveECDHKeyMaterial.mjs new file mode 100644 index 00000000..cd780bee --- /dev/null +++ b/src/core/operations/DeriveECDHKeyMaterial.mjs @@ -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} + */ +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 Peer public key argument field.

Input: private key in PEM or PKCS#8 DER hex. PEM may be BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY when it can be normalized to PKCS#8.
Arguments: choose the curve, peer public key format, optional KDF, optional shared info, output length, and output format.

Use KDF = None to get the raw shared secret."; + this.inlineHelp = "Input: your private key.
Args: 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 BEGIN PRIVATE KEY or a supported BEGIN EC PRIVATE KEY 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 BEGIN PUBLIC KEY 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 None 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 None, 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; diff --git a/src/core/operations/GenerateCardValidationData.mjs b/src/core/operations/GenerateCardValidationData.mjs new file mode 100644 index 00000000..85b78be4 --- /dev/null +++ b/src/core/operations/GenerateCardValidationData.mjs @@ -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.

Input: combined CVK pair as 16-byte or 24-byte hex.
Arguments: select whether you are generating CVV/CVC, CVV2/CVC2, or iCVV, then provide the PAN, expiry components, and service code details.

This implementation is intended for test harnesses and assumes the common CVV decimalization flow used by payment HSM integrations."; + this.inlineHelp = "Input: combined CVK pair hex.
Args: 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 000 and iCVV forces 999." + }, + { + 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 000 and 999." + }, + { + 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 3 and sometimes 4." + }, + { + 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; diff --git a/src/core/operations/GenerateEMVARPC.mjs b/src/core/operations/GenerateEMVARPC.mjs new file mode 100644 index 00000000..cdc1064a --- /dev/null +++ b/src/core/operations/GenerateEMVARPC.mjs @@ -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.

Input: preassembled ARPC input data as hex.
Arguments: provide the issuer session key in hex and choose how many bytes of the CMAC should be returned.

This operation intentionally covers only AES-CMAC-style EMV profiles where the issuer session key and response preimage are already known."; + this.inlineHelp = "Input: preassembled ARPC data as hex.
Args: 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 8 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; diff --git a/src/core/operations/GenerateEMVARQC.mjs b/src/core/operations/GenerateEMVARQC.mjs new file mode 100644 index 00000000..c31fb5bb --- /dev/null +++ b/src/core/operations/GenerateEMVARQC.mjs @@ -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.

Input: preassembled ARQC input data as hex.
Arguments: provide the EMV session key in hex and choose how many bytes of the CMAC should be returned.

This operation intentionally covers only AES-CMAC-style EMV profiles where the session key and preimage are already known."; + this.inlineHelp = "Input: preassembled ARQC data as hex.
Args: 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 8 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; diff --git a/src/core/operations/ParsePINBlock.mjs b/src/core/operations/ParsePINBlock.mjs new file mode 100644 index 00000000..8ae8ec29 --- /dev/null +++ b/src/core/operations/ParsePINBlock.mjs @@ -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.

Input: 8-byte clear PIN block as hex.
Arguments: choose the format and provide the PAN when the format binds to PAN data.

This operation currently parses clear test PIN blocks for ISO formats 0, 1, and 3."; + this.inlineHelp = "Input: clear PIN block hex.
Args: 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; diff --git a/src/core/operations/ParseTR31KeyBlock.mjs b/src/core/operations/ParseTR31KeyBlock.mjs new file mode 100644 index 00000000..69488248 --- /dev/null +++ b/src/core/operations/ParseTR31KeyBlock.mjs @@ -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.

Input: complete TR-31 key block string, with or without spaces. If your source includes a leading R prefix, leave Trim leading R prefix enabled.

This operation parses the fixed header, any optional blocks it can identify, and reports the remaining body."; + this.inlineHelp = "Input: full TR-31 key block text.
Args: leave the prefix trim enabled if the block starts with R."; + 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 R 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; diff --git a/src/core/operations/ParseTR34B9Envelope.mjs b/src/core/operations/ParseTR34B9Envelope.mjs new file mode 100644 index 00000000..09371bb6 --- /dev/null +++ b/src/core/operations/ParseTR34B9Envelope.mjs @@ -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.

Input: complete TR-34 B9 response encoded as hex, including the leading length field.

This operation splits the response into header, response code, authentication data, KCV, envelope data, signature length, signature, and any trailing bytes."; + this.inlineHelp = "Input: full B9 response frame as hex, including the 2-byte length field.
Args: 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; diff --git a/src/core/operations/TranslatePINBlock.mjs b/src/core/operations/TranslatePINBlock.mjs new file mode 100644 index 00000000..96a12d16 --- /dev/null +++ b/src/core/operations/TranslatePINBlock.mjs @@ -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.

Input: 8-byte clear PIN block as hex.
Arguments: 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.

This operation currently translates clear test PIN blocks for ISO formats 0, 1, and 3."; + this.inlineHelp = "Input: source clear PIN block hex.
Args: 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; diff --git a/src/core/operations/VerifyCardValidationData.mjs b/src/core/operations/VerifyCardValidationData.mjs new file mode 100644 index 00000000..121746ac --- /dev/null +++ b/src/core/operations/VerifyCardValidationData.mjs @@ -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.

Input: combined CVK pair as 16-byte or 24-byte hex.
Arguments: select the validation-data profile, provide the PAN and expiry components, then supply the expected validation data.

This operation recomputes the validation value using the same assumptions as the generate operation and reports whether the supplied value matches."; + this.inlineHelp = "Input: combined CVK pair hex.
Args: 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 000 and iCVV forces 999." + }, + { + 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 000 and 999." + }, + { + 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; diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index 7eddb32c..40af5ea8 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -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 ? `
${this.comment}
` : ""; switch (this.type) { case "string": @@ -66,6 +68,7 @@ class HTMLIngredient { value="${this.value}" ${this.disabled ? "disabled" : ""} ${this.maxLength ? `maxlength="${this.maxLength}"` : ""}> + ${commentHtml} `; break; case "shortString": @@ -82,6 +85,7 @@ class HTMLIngredient { value="${this.value}" ${this.disabled ? "disabled" : ""} ${this.maxLength ? `maxlength="${this.maxLength}"` : ""}> + ${commentHtml} `; break; case "toggleString": @@ -107,7 +111,7 @@ class HTMLIngredient { } html += ` - + ${commentHtml} `; break; case "number": @@ -125,6 +129,7 @@ class HTMLIngredient { max="${this.max}" step="${this.step}" ${this.disabled ? "disabled" : ""}> + ${commentHtml} `; break; case "boolean": @@ -141,6 +146,7 @@ class HTMLIngredient { value="${this.name}"> ${this.name} + ${commentHtml} `; break; case "option": @@ -164,6 +170,7 @@ class HTMLIngredient { } } html += ` + ${commentHtml} `; break; case "populateOption": @@ -191,6 +198,7 @@ class HTMLIngredient { } } html += ` + ${commentHtml} `; eventFn = this.type === "populateMultiOption" ? @@ -225,6 +233,7 @@ class HTMLIngredient { } html += ` + ${commentHtml} `; this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this); @@ -256,6 +265,7 @@ class HTMLIngredient { } html += ` + ${commentHtml} `; 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} + ${commentHtml} `; break; case "argSelector": @@ -293,6 +304,7 @@ class HTMLIngredient { `; } html += ` + ${commentHtml} `; this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this); diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs index 30cfd1d9..a655970e 100755 --- a/src/web/HTMLOperation.mjs +++ b/src/web/HTMLOperation.mjs @@ -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 = `
${Utils.escapeHtml(this.name)}
+ let html = `
${Utils.escapeHtml(this.name)}
`; + + if (this.inlineHelp) { + html += `
${this.inlineHelp}
`; + } + + if (this.testDataSamples.length) { + html += `
+ +
`; + } + + html += `
`; for (let i = 0; i < this.ingList.length; i++) { diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index ae972a59..676c9dd3 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -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); diff --git a/src/web/stylesheets/components/_operation.css b/src/web/stylesheets/components/_operation.css index a97fed70..4f5edf66 100755 --- a/src/web/stylesheets/components/_operation.css +++ b/src/web/stylesheets/components/_operation.css @@ -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; diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs index 93ca1182..5267ad43 100755 --- a/src/web/waiters/RecipeWaiter.mjs +++ b/src/web/waiters/RecipeWaiter.mjs @@ -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. * diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 6d5b266f..5e9efb75 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -197,3 +197,4 @@ const logOpsTestReport = logTestReport.bind(null, testStatus); const results = await TestRegister.runTests(); logOpsTestReport(results); })(); +import "./tests/Payment.mjs"; diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs new file mode 100644 index 00000000..7f982c57 --- /dev/null +++ b/tests/operations/tests/Payment.mjs @@ -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"] + } + ] + } +]);