Add payment validation and EMV test operations
This commit is contained in:
parent
89800059f5
commit
7433b07f2b
262
AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md
Normal file
262
AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# AWS Payment Cryptography Recipe Coverage
|
||||||
|
|
||||||
|
This guide maps AWS Payment Cryptography Data Plane operations to CyberChef recipe starters.
|
||||||
|
|
||||||
|
Intent:
|
||||||
|
- This fork is not a certified HSM.
|
||||||
|
- It is intended to emulate HSM-style payment cryptography behavior in software for development, QA, regression, interoperability, and integration testing.
|
||||||
|
- The goal of this guide is therefore twofold:
|
||||||
|
1. document what can already be emulated with the current operation set
|
||||||
|
2. identify which AWS Payment Cryptography use cases should be added next to improve test-harness coverage
|
||||||
|
|
||||||
|
Source baseline:
|
||||||
|
- AWS Payment Cryptography Data Plane API Reference: https://docs.aws.amazon.com/payment-cryptography/latest/DataAPIReference/Welcome.html
|
||||||
|
- AWS Data Plane actions list: https://docs.aws.amazon.com/payment-cryptography/latest/DataAPIReference/API_Operations.html
|
||||||
|
|
||||||
|
Coverage legend:
|
||||||
|
- `Direct`: CyberChef can reproduce the core cryptographic shape of the AWS operation.
|
||||||
|
- `Partial`: CyberChef can help with preimage assembly, derivation, or one stage of the flow, but not the full AWS behavior.
|
||||||
|
- `Not yet implemented`: This is a valid testing/emulation target for the fork, but the required payment primitives are not implemented yet.
|
||||||
|
|
||||||
|
## Coverage Summary
|
||||||
|
|
||||||
|
| AWS operation | Coverage | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `EncryptData` | `Direct` / `Partial` | Direct for AES, TDES, RSA. Partial for DUKPT and EMV-derived encryption. |
|
||||||
|
| `DecryptData` | `Direct` / `Partial` | Direct for AES, TDES, RSA. Partial for DUKPT and EMV-derived decryption. |
|
||||||
|
| `ReEncryptData` | `Direct` / `Partial` | Direct for plain decrypt-then-encrypt workflows. Partial for DUKPT re-encryption. |
|
||||||
|
| `GenerateMac` | `Direct` / `Partial` | Direct for HMAC and CMAC. Partial for DUKPT MAC and EMV MAC flows. |
|
||||||
|
| `VerifyMac` | `Direct` / `Partial` | Direct by recomputing and comparing HMAC/CMAC. Partial for DUKPT MAC and EMV MAC flows. |
|
||||||
|
| `VerifyAuthRequestCryptogram` | `Partial` | Usable for AES-CMAC ARQC/ARPC-style checking when session key and preimage are already known. Dedicated ARQC and ARPC generators now exist for that constrained profile. |
|
||||||
|
| `TranslateKeyMaterial` | `Partial` | Useful for ECDH derivation and TR-31 inspection, not full HSM-side rewrap semantics. |
|
||||||
|
| `GenerateCardValidationData` | `Direct` | Direct for software CVV/CVV2/iCVV generation when the combined CVK pair is provided as clear hex. |
|
||||||
|
| `VerifyCardValidationData` | `Direct` | Direct for software CVV/CVV2/iCVV verification using the same clear-CVK assumptions as generation. |
|
||||||
|
| `GeneratePinData` | `Partial` | Clear PIN-block build coverage now exists for ISO formats 0, 1, and 3. PVV, IBM3624, and encrypted-generation paths are still missing. |
|
||||||
|
| `TranslatePinData` | `Partial` | Clear PIN-block parse and translate coverage now exists for ISO formats 0, 1, and 3. Encrypted PEK/BDK/ECDH translation is still missing. |
|
||||||
|
| `VerifyPinData` | `Partial` | Clear PIN-block decoding exists, but PVV / IBM3624 verification behavior is still missing. |
|
||||||
|
| `GenerateMacEmvPinChange` | `Not yet implemented` | Requires issuer-script PIN-change building blocks. |
|
||||||
|
| `GenerateAs2805KekValidation` | `Not yet implemented` | Requires AS2805-specific KEK-validation primitives. |
|
||||||
|
|
||||||
|
## Direct Recipe Starters
|
||||||
|
|
||||||
|
## 1) AWS `EncryptData`: AES / TDES / RSA
|
||||||
|
Operations:
|
||||||
|
- `AES Encrypt` or `Triple DES Encrypt` or `RSA Encrypt`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the AWS `PlainText` hexBinary value into the input field.
|
||||||
|
- Set the operation input mode to `Hex` and output mode to `Hex`.
|
||||||
|
- Paste the key into the key argument using the correct format selector.
|
||||||
|
- Match the AWS algorithm and mode manually in the chosen CyberChef operation.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- AWS documents `EncryptData` as supporting symmetric `TDES` and `AES`, asymmetric `RSA`, and derived `DUKPT` or `EMV` schemes.
|
||||||
|
- This starter directly covers only the non-derived AES, TDES, and RSA cases.
|
||||||
|
|
||||||
|
## 2) AWS `DecryptData`: AES / TDES / RSA
|
||||||
|
Operations:
|
||||||
|
- `AES Decrypt` or `Triple DES Decrypt` or `RSA Decrypt`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the AWS `CipherText` hexBinary value into the input field.
|
||||||
|
- Set the operation input mode to `Hex` and output mode to `Hex` or `Raw`.
|
||||||
|
- Paste the key into the key argument using the correct format selector.
|
||||||
|
- Match the AWS algorithm and mode manually in the chosen CyberChef operation.
|
||||||
|
|
||||||
|
## 3) AWS `ReEncryptData`: Symmetric Rewrap
|
||||||
|
Operations:
|
||||||
|
- `AES Decrypt` or `Triple DES Decrypt`
|
||||||
|
- `AES Encrypt` or `Triple DES Encrypt`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the incoming ciphertext into the input field as hex.
|
||||||
|
- First decrypt with the incoming key and mode.
|
||||||
|
- Then encrypt with the outgoing key and mode.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This covers the software-visible decrypt-then-encrypt pattern.
|
||||||
|
- It does not model AWS wrapped-key handling or HSM-side key custody.
|
||||||
|
|
||||||
|
## 4) AWS `GenerateMac`: HMAC
|
||||||
|
Operations:
|
||||||
|
- `From Hex`
|
||||||
|
- `HMAC`
|
||||||
|
- `Take bytes`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the AWS `MessageData` hexBinary value into the input field.
|
||||||
|
- Run `From Hex`.
|
||||||
|
- Run `HMAC` with the appropriate key and hash function.
|
||||||
|
- If AWS truncates the MAC, use `Take bytes` to keep the leftmost bytes that match `MacLength`.
|
||||||
|
|
||||||
|
## 5) AWS `GenerateMac`: CMAC
|
||||||
|
Operations:
|
||||||
|
- `From Hex`
|
||||||
|
- `CMAC`
|
||||||
|
- `Take bytes`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the AWS `MessageData` hexBinary value into the input field.
|
||||||
|
- Run `From Hex`.
|
||||||
|
- Run `CMAC` with `Encryption algorithm` set to `AES` or `Triple DES`.
|
||||||
|
- Use `Take bytes` to match the requested `MacLength` if truncation is required.
|
||||||
|
|
||||||
|
## 6) AWS `VerifyMac`: Recompute And Compare
|
||||||
|
Operations:
|
||||||
|
- `From Hex`
|
||||||
|
- `HMAC` or `CMAC`
|
||||||
|
- `Take bytes`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Recompute the MAC using the same starter as `GenerateMac`.
|
||||||
|
- Compare the result to the AWS `Mac` value manually or with a follow-on comparison recipe.
|
||||||
|
|
||||||
|
## 7) AWS `GenerateCardValidationData`: CVV / CVV2 / iCVV
|
||||||
|
Operations:
|
||||||
|
- `Generate card validation data`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the clear combined CVK pair into the input field as hex.
|
||||||
|
- Choose the profile that matches the AWS card-validation mode you want to emulate.
|
||||||
|
- Provide the PAN, expiry, and service-code context in the argument fields.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This directly covers software generation of CVV/CVV2/iCVV-style values.
|
||||||
|
- Assumption: CVV2 forces service code `000` and iCVV forces `999`.
|
||||||
|
|
||||||
|
## 8) AWS `VerifyCardValidationData`: CVV / CVV2 / iCVV
|
||||||
|
Operations:
|
||||||
|
- `Verify card validation data`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Use the same card context as generation, then supply the incoming value in the `Expected value` argument.
|
||||||
|
- The operation recomputes the value and returns structured verification output.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This is intended for software parity and regression checks.
|
||||||
|
- It does not emulate AWS key custody or HSM-side audit semantics.
|
||||||
|
|
||||||
|
## Partial Recipe Starters
|
||||||
|
|
||||||
|
## 9) AWS `EncryptData` / `DecryptData`: DUKPT-Derived Symmetric Flows
|
||||||
|
Operations:
|
||||||
|
- `Derive DUKPT key`
|
||||||
|
- `AES Encrypt` or `AES Decrypt` or `Triple DES Encrypt` or `Triple DES Decrypt`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Derive the transaction key from BDK and KSN first.
|
||||||
|
- Feed the derived key into the cipher operation that matches your target algorithm.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This is useful for offline vector work.
|
||||||
|
- It does not claim one-to-one parity with every AWS DUKPT encryption attribute combination.
|
||||||
|
|
||||||
|
## 10) AWS `GenerateMac` / `VerifyMac`: DUKPT MAC
|
||||||
|
Operations:
|
||||||
|
- `Derive DUKPT key`
|
||||||
|
- `From Hex`
|
||||||
|
- `CMAC` or `HMAC`
|
||||||
|
- `Take bytes`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Derive the transaction key from BDK and KSN.
|
||||||
|
- Convert `MessageData` from hex and generate the MAC using the derived key.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Treat this as a lab starter, not proof of parity with 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.
|
||||||
94
PAYMENT_RECIPES.md
Normal file
94
PAYMENT_RECIPES.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Payment Recipe Starters
|
||||||
|
|
||||||
|
These recipe starters are designed for software-only inspection, validation, and prototyping workflows.
|
||||||
|
|
||||||
|
For AWS-specific mappings, see `AWS_PAYMENT_CRYPTOGRAPHY_RECIPES.md`.
|
||||||
|
|
||||||
|
## 1) TR-31 Header Parse
|
||||||
|
Operations:
|
||||||
|
- `Parse TR-31 key block`
|
||||||
|
|
||||||
|
## 2) TR-34 B9 Envelope Split
|
||||||
|
Operations:
|
||||||
|
- `Parse TR-34 B9 envelope`
|
||||||
|
|
||||||
|
## 3) KCV Validation
|
||||||
|
Operations:
|
||||||
|
- `Calculate payment KCV`
|
||||||
|
|
||||||
|
## 4) ECDH Key Agreement (Software)
|
||||||
|
Operations:
|
||||||
|
- `Derive ECDH key material`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Import a local private key and peer public key.
|
||||||
|
- Derive raw shared secret or run Concat KDF (`SHA-256` or `SHA-512`) with shared-info.
|
||||||
|
|
||||||
|
## 5) DUKPT Derivation (Software)
|
||||||
|
Operations:
|
||||||
|
- `Derive DUKPT key`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Derive IPEK from BDK + KSN.
|
||||||
|
- Derive base session key and apply a variant mask (`PIN`, `MAC Request`, `MAC Response`, `Data`).
|
||||||
|
|
||||||
|
## 6) PIN Block Build / Parse / Translate
|
||||||
|
Operations:
|
||||||
|
- `Build PIN block`
|
||||||
|
- `Parse PIN block`
|
||||||
|
- `Translate PIN block`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Build clear ISO 9564 format 0, 1, or 3 PIN blocks from a PIN and PAN.
|
||||||
|
- Parse clear test PIN blocks back into PIN, PIN field, PAN field, and filler details.
|
||||||
|
- Translate clear test PIN blocks between supported formats before feeding them into cipher steps.
|
||||||
|
|
||||||
|
Scope note:
|
||||||
|
- This starter currently covers clear software test blocks for ISO formats 0, 1, and 3.
|
||||||
|
- It does not yet generate PVV, IBM 3624 offsets, or encrypted PEK/BDK translation flows by itself.
|
||||||
|
|
||||||
|
## 7) Card Validation Data (CVV / CVV2 / iCVV)
|
||||||
|
Operations:
|
||||||
|
- `Generate card validation data`
|
||||||
|
- `Verify card validation data`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the combined CVK pair into the input field as 16-byte or 24-byte hex.
|
||||||
|
- Choose whether you want CVV/CVC, CVV2/CVC2, or iCVV behavior.
|
||||||
|
- Provide the PAN, expiry month/year, and service-code context in the argument fields.
|
||||||
|
|
||||||
|
Scope note:
|
||||||
|
- This implementation is intended for software test harnesses.
|
||||||
|
- CVV2 forces service code `000` and iCVV forces `999`.
|
||||||
|
- It does not try to emulate scheme-specific dCVV, token CVV, or issuer-host formatting differences beyond the common decimalization flow.
|
||||||
|
|
||||||
|
## 8) EMV ARQC Generation (AES-CMAC Profile)
|
||||||
|
Operations:
|
||||||
|
- `Generate EMV ARQC`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the already-assembled ARQC input block into the input field as hex.
|
||||||
|
- Provide the already-derived AES session key in the argument field.
|
||||||
|
- Choose how many leftmost CMAC bytes to keep as the final cryptogram.
|
||||||
|
|
||||||
|
Scope note:
|
||||||
|
- This operation is intentionally limited to AES-CMAC-style EMV profiles.
|
||||||
|
- It does not derive EMV session keys or assemble CDOL/tag data for you.
|
||||||
|
|
||||||
|
## 9) EMV ARPC Generation (AES-CMAC Response Profile)
|
||||||
|
Operations:
|
||||||
|
- `Generate EMV ARPC`
|
||||||
|
|
||||||
|
Suggested use:
|
||||||
|
- Paste the already-assembled ARPC response input block into the input field as hex.
|
||||||
|
- Provide the already-derived issuer AES session key in the argument field.
|
||||||
|
- Choose how many leftmost CMAC bytes to keep as the final cryptogram.
|
||||||
|
|
||||||
|
Scope note:
|
||||||
|
- This operation is intentionally limited to AES-CMAC response profiles where the issuer session key and exact preimage are already known.
|
||||||
|
- Legacy 3DES EMV ARQC/ARPC flows are not covered.
|
||||||
|
|
||||||
|
## 10) Combined Message Triage
|
||||||
|
Operations:
|
||||||
|
- `Parse TR-34 B9 envelope`
|
||||||
|
- `Parse ASN.1 hex string`
|
||||||
50
PAYMENT_SIM_RECIPES.md
Normal file
50
PAYMENT_SIM_RECIPES.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Payment Simulation Recipe Candidates
|
||||||
|
|
||||||
|
This list targets software-only development and testing environments.
|
||||||
|
|
||||||
|
## Frame And Transport Simulation
|
||||||
|
1. Length-prefix builder/parser pairs for command and response replay.
|
||||||
|
2. Status code mutation recipes (success/error branch testing).
|
||||||
|
3. Header-length fuzzing recipes for parser hardening.
|
||||||
|
|
||||||
|
## TR-31 Simulation
|
||||||
|
1. Header mutation recipes (usage, mode, exportability, optional block counts).
|
||||||
|
2. Optional-block truncation and malformed-length negative tests.
|
||||||
|
3. Prefix-normalization recipes (`R` prefix handling).
|
||||||
|
|
||||||
|
## TR-34 Simulation
|
||||||
|
1. Envelope section split/rebuild recipes.
|
||||||
|
2. ASN.1 length corruption tests.
|
||||||
|
3. Signature-length mismatch recipes.
|
||||||
|
|
||||||
|
## KCV And Key Lifecycle Simulation
|
||||||
|
1. KCV cross-check recipes across TDES, AES-CMAC, and HMAC methods.
|
||||||
|
2. Variant-mask simulation for derived key classes.
|
||||||
|
3. Deterministic fixed-vector recipes for regression checks.
|
||||||
|
|
||||||
|
## ECDH Simulation
|
||||||
|
1. Static keypair handshake vectors.
|
||||||
|
2. Shared-info permutations in Concat KDF.
|
||||||
|
3. Curve mismatch and malformed key negative tests.
|
||||||
|
|
||||||
|
## DUKPT Simulation
|
||||||
|
1. IPEK derivation from known BDK/KSN vectors.
|
||||||
|
2. Counter progression replay across KSN ranges.
|
||||||
|
3. Variant-mask output sets for transaction classes.
|
||||||
|
|
||||||
|
## EMV/Scheme-Level Candidate Recipes
|
||||||
|
1. ARQC generation checks for AES-CMAC profiles with fixed session keys and known CDOL payloads.
|
||||||
|
2. ARPC generation checks for AES-CMAC response profiles with explicit ARC/CSU/proprietary-data assembly.
|
||||||
|
3. Tag concatenation and canonical ordering checks.
|
||||||
|
4. Session derivation input normalization checks.
|
||||||
|
5. Cryptogram preimage assembly validation recipes.
|
||||||
|
6. PAN parser and network classifier recipes for Visa (`4`, typically 13/16/19 digits), Mastercard (`51`-`55`, `2221`-`2720`, 16 digits), American Express (`34`, `37`, 15 digits), and Discover (`6011`, `644`-`649`, `65`, and `622126`-`622925`, typically 16-19 digits), including Luhn validation and issuer-range explanation.
|
||||||
|
|
||||||
|
## AWS Payment Cryptography Candidate Recipes
|
||||||
|
1. `EncryptData` and `DecryptData` parity vectors for AES, TDES, and RSA.
|
||||||
|
2. `ReEncryptData` parity vectors for decrypt-then-encrypt workflows.
|
||||||
|
3. `GenerateMac` and `VerifyMac` parity vectors for HMAC and CMAC.
|
||||||
|
4. `VerifyAuthRequestCryptogram` preimage-validation recipes for AES-CMAC EMV profiles.
|
||||||
|
5. DUKPT derivation-plus-cipher recipes for AWS derived-key lab testing.
|
||||||
|
6. ECDH and TR-31 inspection recipes for `TranslateKeyMaterial` interoperability debugging.
|
||||||
|
7. Gap-tracking recipes for unsupported AWS flows: PVV, IBM3624, encrypted PIN block translation, issuer-script PIN change, and AS2805 KEK validation.
|
||||||
@ -19,6 +19,8 @@ This fork extends **CyberChef** with a focused set of payment cryptography opera
|
|||||||
### Scope
|
### 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.
|
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:
|
Initial focus areas include:
|
||||||
- TR-31 key block parsing and encoding
|
- TR-31 key block parsing and encoding
|
||||||
- Key metadata inspection and structural validation
|
- Key metadata inspection and structural validation
|
||||||
@ -34,7 +36,7 @@ Future extensions may include:
|
|||||||
These extensions are not intended to:
|
These extensions are not intended to:
|
||||||
- Facilitate fraud, card data misuse, or PIN compromise
|
- Facilitate fraud, card data misuse, or PIN compromise
|
||||||
- Replace certified HSMs or production cryptographic controls
|
- 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.
|
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.
|
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
|
## 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!
|
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!
|
||||||
|
|||||||
@ -30,6 +30,8 @@ class Operation {
|
|||||||
this.name = "";
|
this.name = "";
|
||||||
this.module = "";
|
this.module = "";
|
||||||
this.description = "";
|
this.description = "";
|
||||||
|
this.inlineHelp = "";
|
||||||
|
this.testDataSamples = [];
|
||||||
this.infoURL = null;
|
this.infoURL = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +182,7 @@ class Operation {
|
|||||||
|
|
||||||
if (ing.toggleValues) conf.toggleValues = ing.toggleValues;
|
if (ing.toggleValues) conf.toggleValues = ing.toggleValues;
|
||||||
if (ing.hint) conf.hint = ing.hint;
|
if (ing.hint) conf.hint = ing.hint;
|
||||||
|
if (ing.comment) conf.comment = ing.comment;
|
||||||
if (ing.rows) conf.rows = ing.rows;
|
if (ing.rows) conf.rows = ing.rows;
|
||||||
if (ing.disabled) conf.disabled = ing.disabled;
|
if (ing.disabled) conf.disabled = ing.disabled;
|
||||||
if (ing.target) conf.target = ing.target;
|
if (ing.target) conf.target = ing.target;
|
||||||
|
|||||||
@ -565,6 +565,23 @@
|
|||||||
"XKCD Random Number"
|
"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",
|
"name": "Flow control",
|
||||||
"ops": [
|
"ops": [
|
||||||
|
|||||||
@ -37,6 +37,8 @@ for (const opObj in Ops) {
|
|||||||
operationConfig[op.name] = {
|
operationConfig[op.name] = {
|
||||||
module: op.module,
|
module: op.module,
|
||||||
description: op.description,
|
description: op.description,
|
||||||
|
inlineHelp: op.inlineHelp,
|
||||||
|
testDataSamples: op.testDataSamples,
|
||||||
infoURL: op.infoURL,
|
infoURL: op.infoURL,
|
||||||
inputType: op.inputType,
|
inputType: op.inputType,
|
||||||
outputType: op.presentType,
|
outputType: op.presentType,
|
||||||
|
|||||||
249
src/core/lib/CardValidation.mjs
Normal file
249
src/core/lib/CardValidation.mjs
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import forge from "node-forge";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import { bytesToHex, parseHexBytes, toByteString } from "./PaymentUtils.mjs";
|
||||||
|
|
||||||
|
const CVV_PROFILES = [
|
||||||
|
"CVV / CVC (use service code arg)",
|
||||||
|
"CVV2 / CVC2 (force 000)",
|
||||||
|
"iCVV (force 999)",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates card data inputs.
|
||||||
|
*
|
||||||
|
* @param {string} pan
|
||||||
|
* @param {string} expiryMonth
|
||||||
|
* @param {string} expiryYear
|
||||||
|
* @param {string} serviceCode
|
||||||
|
*/
|
||||||
|
function validateCardData(pan, expiryMonth, expiryYear, serviceCode) {
|
||||||
|
if (!/^\d{13,19}$/.test((pan || "").replace(/\s+/g, ""))) {
|
||||||
|
throw new OperationError("PAN must be 13 to 19 digits.");
|
||||||
|
}
|
||||||
|
if (!/^\d{2}$/.test((expiryMonth || "").replace(/\s+/g, ""))) {
|
||||||
|
throw new OperationError("Expiry month must be 2 digits.");
|
||||||
|
}
|
||||||
|
if (!/^\d{2}$/.test((expiryYear || "").replace(/\s+/g, ""))) {
|
||||||
|
throw new OperationError("Expiry year must be 2 digits.");
|
||||||
|
}
|
||||||
|
if (!/^\d{3}$/.test((serviceCode || "").replace(/\s+/g, ""))) {
|
||||||
|
throw new OperationError("Service code must be 3 digits.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the service code based on the selected validation-data profile.
|
||||||
|
*
|
||||||
|
* @param {string} profile
|
||||||
|
* @param {string} serviceCode
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveServiceCode(profile, serviceCode) {
|
||||||
|
switch (profile) {
|
||||||
|
case "CVV2 / CVC2 (force 000)":
|
||||||
|
return "000";
|
||||||
|
case "iCVV (force 999)":
|
||||||
|
return "999";
|
||||||
|
default:
|
||||||
|
return (serviceCode || "").replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts one 8-byte block with DES ECB.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} key8
|
||||||
|
* @param {Uint8Array} block8
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function encryptDesEcb(key8, block8) {
|
||||||
|
const cipher = forge.cipher.createCipher("DES-ECB", toByteString(key8));
|
||||||
|
cipher.mode.pad = function() {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer(toByteString(block8)));
|
||||||
|
cipher.finish();
|
||||||
|
return Uint8Array.from(cipher.output.getBytes().split("").map(ch => ch.charCodeAt(0))).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts one 8-byte block with 3DES ECB.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} key
|
||||||
|
* @param {Uint8Array} block8
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function encryptTdesEcb(key, block8) {
|
||||||
|
const normalizedKey = key.length === 16 ? Uint8Array.from([...key, ...key.slice(0, 8)]) : key;
|
||||||
|
const cipher = forge.cipher.createCipher("3DES-ECB", toByteString(normalizedKey));
|
||||||
|
cipher.mode.pad = function() {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer(toByteString(block8)));
|
||||||
|
cipher.finish();
|
||||||
|
return Uint8Array.from(cipher.output.getBytes().split("").map(ch => ch.charCodeAt(0))).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XORs two byte arrays.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} left
|
||||||
|
* @param {Uint8Array} right
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function xorBytes(left, right) {
|
||||||
|
const out = new Uint8Array(left.length);
|
||||||
|
for (let i = 0; i < left.length; i++) {
|
||||||
|
out[i] = left[i] ^ right[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a decimal digit string into BCD bytes.
|
||||||
|
*
|
||||||
|
* @param {string} digits
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function digitsToBcdBytes(digits) {
|
||||||
|
const out = new Uint8Array(digits.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = (parseInt(digits.charAt(i * 2), 10) << 4) | parseInt(digits.charAt(i * 2 + 1), 10);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimalizes a CVV result hex string using the common numeric-first extraction rule.
|
||||||
|
*
|
||||||
|
* @param {string} hex
|
||||||
|
* @param {number} digitCount
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function decimalizeCvvHex(hex, digitCount) {
|
||||||
|
let out = "";
|
||||||
|
|
||||||
|
for (const ch of hex) {
|
||||||
|
if (/\d/.test(ch)) {
|
||||||
|
out += ch;
|
||||||
|
if (out.length >= digitCount) return out.substring(0, digitCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ch of hex) {
|
||||||
|
if (/[A-F]/.test(ch)) {
|
||||||
|
out += String(ch.charCodeAt(0) - "A".charCodeAt(0));
|
||||||
|
if (out.length >= digitCount) return out.substring(0, digitCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.substring(0, digitCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates card validation data such as CVV, CVV2, or iCVV.
|
||||||
|
*
|
||||||
|
* @param {string} cvkHex
|
||||||
|
* @param {string} pan
|
||||||
|
* @param {string} expiryMonth
|
||||||
|
* @param {string} expiryYear
|
||||||
|
* @param {string} expiryLayout
|
||||||
|
* @param {string} serviceCode
|
||||||
|
* @param {string} profile
|
||||||
|
* @param {number} digitCount
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function generateCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, digitCount) {
|
||||||
|
const normalizedPan = (pan || "").replace(/\s+/g, "");
|
||||||
|
const normalizedMonth = (expiryMonth || "").replace(/\s+/g, "");
|
||||||
|
const normalizedYear = (expiryYear || "").replace(/\s+/g, "");
|
||||||
|
const resolvedServiceCode = resolveServiceCode(profile, serviceCode);
|
||||||
|
|
||||||
|
validateCardData(normalizedPan, normalizedMonth, normalizedYear, resolvedServiceCode);
|
||||||
|
|
||||||
|
const normalizedDigitCount = Math.max(1, Math.min(5, Number(digitCount) || 3));
|
||||||
|
const cvk = parseHexBytes(cvkHex, "CVK pair", [16, 24]);
|
||||||
|
const keyA = cvk.slice(0, 8);
|
||||||
|
const expiry = expiryLayout === "MMYY" ?
|
||||||
|
`${normalizedMonth}${normalizedYear}` :
|
||||||
|
`${normalizedYear}${normalizedMonth}`;
|
||||||
|
const dataDigits = `${normalizedPan}${expiry}${resolvedServiceCode}`.padEnd(32, "0").substring(0, 32);
|
||||||
|
const leftBlock = digitsToBcdBytes(dataDigits.substring(0, 16));
|
||||||
|
const rightBlock = digitsToBcdBytes(dataDigits.substring(16, 32));
|
||||||
|
const step1 = encryptDesEcb(keyA, leftBlock);
|
||||||
|
const step2 = xorBytes(step1, rightBlock);
|
||||||
|
const resultBytes = encryptTdesEcb(cvk, step2);
|
||||||
|
const resultHex = bytesToHex(resultBytes);
|
||||||
|
const decimalized = decimalizeCvvHex(resultHex, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
pan: normalizedPan,
|
||||||
|
expiry,
|
||||||
|
expiryLayout,
|
||||||
|
serviceCode: resolvedServiceCode,
|
||||||
|
digitCount: normalizedDigitCount,
|
||||||
|
inputDigits: dataDigits,
|
||||||
|
resultHex,
|
||||||
|
decimalized,
|
||||||
|
validationData: decimalized.substring(0, normalizedDigitCount)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies card validation data.
|
||||||
|
*
|
||||||
|
* @param {string} cvkHex
|
||||||
|
* @param {string} pan
|
||||||
|
* @param {string} expiryMonth
|
||||||
|
* @param {string} expiryYear
|
||||||
|
* @param {string} expiryLayout
|
||||||
|
* @param {string} serviceCode
|
||||||
|
* @param {string} profile
|
||||||
|
* @param {string} expectedValue
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function verifyCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, expectedValue) {
|
||||||
|
const normalizedExpected = (expectedValue || "").replace(/\s+/g, "");
|
||||||
|
if (!/^\d{1,5}$/.test(normalizedExpected)) {
|
||||||
|
throw new OperationError("Expected validation data must be 1 to 5 decimal digits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = generateCardValidationData(
|
||||||
|
cvkHex,
|
||||||
|
pan,
|
||||||
|
expiryMonth,
|
||||||
|
expiryYear,
|
||||||
|
expiryLayout,
|
||||||
|
serviceCode,
|
||||||
|
profile,
|
||||||
|
normalizedExpected.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...generated,
|
||||||
|
expectedValue: normalizedExpected,
|
||||||
|
valid: generated.validationData === normalizedExpected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
CVV_PROFILES,
|
||||||
|
generateCardValidationData,
|
||||||
|
verifyCardValidationData,
|
||||||
|
};
|
||||||
40
src/core/lib/EmvCryptogram.mjs
Normal file
40
src/core/lib/EmvCryptogram.mjs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CMAC from "../operations/CMAC.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import { parseHexBuffer } from "./PaymentUtils.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an EMV AES-CMAC cryptogram and truncates it.
|
||||||
|
*
|
||||||
|
* @param {string} inputHex
|
||||||
|
* @param {string} keyHex
|
||||||
|
* @param {number} outputBytes
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function generateEmvAesCmacCryptogram(inputHex, keyHex, outputBytes) {
|
||||||
|
const inputBuffer = parseHexBuffer(inputHex, "Input data");
|
||||||
|
const normalizedKey = (keyHex || "").replace(/\s+/g, "");
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(normalizedKey) || normalizedKey.length % 2 !== 0) {
|
||||||
|
throw new OperationError("Session key must be hex.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOutputBytes = Math.max(1, Math.min(16, Number(outputBytes) || 8));
|
||||||
|
const cmac = new CMAC();
|
||||||
|
const fullMacHex = cmac.run(inputBuffer, [{ string: normalizedKey, option: "Hex" }, "AES"]).toUpperCase();
|
||||||
|
const cryptogramHex = fullMacHex.substring(0, normalizedOutputBytes * 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputHex: (inputHex || "").replace(/\s+/g, "").toUpperCase(),
|
||||||
|
outputBytes: normalizedOutputBytes,
|
||||||
|
fullMacHex,
|
||||||
|
cryptogramHex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateEmvAesCmacCryptogram,
|
||||||
|
};
|
||||||
75
src/core/lib/PaymentUtils.mjs
Normal file
75
src/core/lib/PaymentUtils.mjs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import { toHexFast } from "./Hex.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses hex into bytes.
|
||||||
|
*
|
||||||
|
* @param {string} input
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number[]} [allowedLengths]
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function parseHexBytes(input, name, allowedLengths=[]) {
|
||||||
|
const normalized = (input || "").replace(/\s+/g, "");
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) {
|
||||||
|
throw new OperationError(`${name} must be hex.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Uint8Array(normalized.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = parseInt(normalized.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedLengths.length && !allowedLengths.includes(out.length)) {
|
||||||
|
throw new OperationError(`${name} must be ${allowedLengths.join(" or ")} bytes.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts bytes to uppercase hex.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function bytesToHex(bytes) {
|
||||||
|
return toHexFast(bytes).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts bytes to a forge-compatible byte string.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toByteString(bytes) {
|
||||||
|
return Array.from(bytes, byte => String.fromCharCode(byte)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts hex to an ArrayBuffer.
|
||||||
|
*
|
||||||
|
* @param {string} input
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {ArrayBuffer}
|
||||||
|
*/
|
||||||
|
function parseHexBuffer(input, name) {
|
||||||
|
const bytes = parseHexBytes(input, name);
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
bytesToHex,
|
||||||
|
parseHexBuffer,
|
||||||
|
parseHexBytes,
|
||||||
|
toByteString,
|
||||||
|
};
|
||||||
249
src/core/lib/PinBlock.mjs
Normal file
249
src/core/lib/PinBlock.mjs
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import { toHexFast } from "./Hex.mjs";
|
||||||
|
|
||||||
|
const PIN_BLOCK_FORMATS = ["ISO Format 0", "ISO Format 1", "ISO Format 3"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random nibble in the given inclusive range.
|
||||||
|
*
|
||||||
|
* @param {number} min
|
||||||
|
* @param {number} max
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function randomNibble(min, max) {
|
||||||
|
const range = max - min + 1;
|
||||||
|
|
||||||
|
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
|
||||||
|
const buf = new Uint8Array(1);
|
||||||
|
globalThis.crypto.getRandomValues(buf);
|
||||||
|
return min + (buf[0] % range);
|
||||||
|
}
|
||||||
|
|
||||||
|
return min + Math.floor(Math.random() * range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a hex string into nibble values.
|
||||||
|
*
|
||||||
|
* @param {string} hex
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function hexToNibbles(hex) {
|
||||||
|
return hex.toUpperCase().split("").map(ch => parseInt(ch, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts nibble values into a byte array.
|
||||||
|
*
|
||||||
|
* @param {number[]} nibbles
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function nibblesToBytes(nibbles) {
|
||||||
|
const out = new Uint8Array(nibbles.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = (nibbles[i * 2] << 4) | nibbles[i * 2 + 1];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XORs two nibble arrays.
|
||||||
|
*
|
||||||
|
* @param {number[]} a
|
||||||
|
* @param {number[]} b
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function xorNibbles(a, b) {
|
||||||
|
return a.map((value, index) => value ^ b[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes and validates a PIN.
|
||||||
|
*
|
||||||
|
* @param {string} pin
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalizePin(pin) {
|
||||||
|
const normalized = (pin || "").replace(/\s+/g, "");
|
||||||
|
if (!/^\d{4,12}$/.test(normalized)) {
|
||||||
|
throw new OperationError("PIN must be 4 to 12 digits.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes and validates a PAN.
|
||||||
|
*
|
||||||
|
* @param {string} pan
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalizePan(pan) {
|
||||||
|
const normalized = (pan || "").replace(/\s+/g, "");
|
||||||
|
if (!/^\d{12,19}$/.test(normalized)) {
|
||||||
|
throw new OperationError("PAN must be 12 to 19 digits.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an 8-byte PIN block hex string.
|
||||||
|
*
|
||||||
|
* @param {string} blockHex
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalizeBlockHex(blockHex) {
|
||||||
|
const normalized = (blockHex || "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
if (!/^[0-9A-F]{16}$/.test(normalized)) {
|
||||||
|
throw new OperationError("PIN block must be 16 hex characters (8 bytes).");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the PIN field for a clear PIN block.
|
||||||
|
*
|
||||||
|
* @param {string} format
|
||||||
|
* @param {string} pin
|
||||||
|
* @param {boolean} randomizeFill
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function buildPinField(format, pin, randomizeFill) {
|
||||||
|
const formatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3;
|
||||||
|
const pinNibbles = pin.split("").map(digit => parseInt(digit, 10));
|
||||||
|
const out = [formatNibble, pin.length, ...pinNibbles];
|
||||||
|
|
||||||
|
while (out.length < 16) {
|
||||||
|
if (format === "ISO Format 0") {
|
||||||
|
out.push(0xF);
|
||||||
|
} else if (format === "ISO Format 1") {
|
||||||
|
out.push(randomizeFill ? randomNibble(0x0, 0xF) : 0xF);
|
||||||
|
} else {
|
||||||
|
out.push(randomizeFill ? randomNibble(0xA, 0xF) : 0xA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the PAN field for PAN-bound PIN block formats.
|
||||||
|
*
|
||||||
|
* @param {string} pan
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function buildPanField(pan) {
|
||||||
|
const normalizedPan = normalizePan(pan);
|
||||||
|
const pan12 = normalizedPan.slice(0, -1).slice(-12).padStart(12, "0");
|
||||||
|
return hexToNibbles(`0000${pan12}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a clear PIN block.
|
||||||
|
*
|
||||||
|
* @param {string} format
|
||||||
|
* @param {string} pin
|
||||||
|
* @param {string} pan
|
||||||
|
* @param {boolean} randomizeFill
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildPinBlock(format, pin, pan, randomizeFill) {
|
||||||
|
if (!PIN_BLOCK_FORMATS.includes(format)) {
|
||||||
|
throw new OperationError("Unsupported PIN block format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPin = normalizePin(pin);
|
||||||
|
const pinField = buildPinField(format, normalizedPin, randomizeFill);
|
||||||
|
|
||||||
|
if (format === "ISO Format 1") {
|
||||||
|
return toHexFast(nibblesToBytes(pinField)).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const panField = buildPanField(pan);
|
||||||
|
return toHexFast(nibblesToBytes(xorNibbles(pinField, panField))).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a clear PIN block.
|
||||||
|
*
|
||||||
|
* @param {string} format
|
||||||
|
* @param {string} blockHex
|
||||||
|
* @param {string} pan
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function parsePinBlock(format, blockHex, pan) {
|
||||||
|
if (!PIN_BLOCK_FORMATS.includes(format)) {
|
||||||
|
throw new OperationError("Unsupported PIN block format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBlock = normalizeBlockHex(blockHex);
|
||||||
|
const clearField = format === "ISO Format 1" ?
|
||||||
|
hexToNibbles(normalizedBlock) :
|
||||||
|
xorNibbles(hexToNibbles(normalizedBlock), buildPanField(pan));
|
||||||
|
|
||||||
|
const formatNibble = clearField[0];
|
||||||
|
const expectedFormatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3;
|
||||||
|
if (formatNibble !== expectedFormatNibble) {
|
||||||
|
throw new OperationError(`PIN block does not decode as ${format}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinLength = clearField[1];
|
||||||
|
if (pinLength < 4 || pinLength > 12) {
|
||||||
|
throw new OperationError("Decoded PIN length is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinDigits = clearField.slice(2, 2 + pinLength);
|
||||||
|
if (pinDigits.some(nibble => nibble < 0x0 || nibble > 0x9)) {
|
||||||
|
throw new OperationError("Decoded PIN contains non-decimal digits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillDigits = clearField.slice(2 + pinLength);
|
||||||
|
if (format === "ISO Format 0" && fillDigits.some(nibble => nibble !== 0xF)) {
|
||||||
|
throw new OperationError("Format 0 filler must be 0xF.");
|
||||||
|
}
|
||||||
|
if (format === "ISO Format 3" && fillDigits.some(nibble => nibble < 0xA || nibble > 0xF)) {
|
||||||
|
throw new OperationError("Format 3 filler must be in the range 0xA to 0xF.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
pin: pinDigits.join(""),
|
||||||
|
pinLength,
|
||||||
|
pinFieldHex: toHexFast(nibblesToBytes(clearField)).toUpperCase(),
|
||||||
|
panFieldHex: format === "ISO Format 1" ? null : toHexFast(nibblesToBytes(buildPanField(pan))).toUpperCase(),
|
||||||
|
blockHex: normalizedBlock,
|
||||||
|
fillDigitsHex: fillDigits.map(nibble => nibble.toString(16).toUpperCase()).join("")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a clear PIN block between formats.
|
||||||
|
*
|
||||||
|
* @param {string} blockHex
|
||||||
|
* @param {string} sourceFormat
|
||||||
|
* @param {string} sourcePan
|
||||||
|
* @param {string} targetFormat
|
||||||
|
* @param {string} targetPan
|
||||||
|
* @param {boolean} randomizeFill
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function translatePinBlock(blockHex, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill) {
|
||||||
|
const parsed = parsePinBlock(sourceFormat, blockHex, sourcePan);
|
||||||
|
return {
|
||||||
|
source: parsed,
|
||||||
|
target: {
|
||||||
|
format: targetFormat,
|
||||||
|
blockHex: buildPinBlock(targetFormat, parsed.pin, targetPan, randomizeFill)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
PIN_BLOCK_FORMATS,
|
||||||
|
buildPinBlock,
|
||||||
|
parsePinBlock,
|
||||||
|
translatePinBlock,
|
||||||
|
};
|
||||||
66
src/core/operations/BuildPINBlock.mjs
Normal file
66
src/core/operations/BuildPINBlock.mjs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { PIN_BLOCK_FORMATS, buildPinBlock } from "../lib/PinBlock.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build PIN block operation
|
||||||
|
*/
|
||||||
|
class BuildPINBlock extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BuildPINBlock constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Build PIN block";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the clear PIN into the input field and choose the ISO 9564 clear PIN block format to build.<br><br><b>Input:</b> clear PIN digits.<br><b>Arguments:</b> choose the target format, provide the PAN when required, and optionally randomize filler digits for formats 1 and 3.<br><br>This operation currently builds clear test PIN blocks for ISO formats 0, 1, and 3.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> clear PIN digits.<br><strong>Args:</strong> choose the format, add the PAN for formats 0 and 3, then decide whether format 1 or 3 filler digits should be randomized.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Random ISO Format 0 sample",
|
||||||
|
input: "__RANDOM_PIN_4__",
|
||||||
|
args: ["ISO Format 0", "__RANDOM_PAN_16__", false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Format",
|
||||||
|
type: "option",
|
||||||
|
value: PIN_BLOCK_FORMATS,
|
||||||
|
comment: "Choose the clear ISO 9564 block format to build. Assumption: only formats <code>0</code>, <code>1</code>, and <code>3</code> are implemented."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary account number",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Randomize fill digits",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
comment: "Affects only formats 1 and 3. When disabled, filler is deterministic so test vectors stay stable."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [format, pan, randomizeFill] = args;
|
||||||
|
return buildPinBlock(format, input, pan, randomizeFill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuildPINBlock;
|
||||||
144
src/core/operations/CalculatePaymentKCV.mjs
Normal file
144
src/core/operations/CalculatePaymentKCV.mjs
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import forge from "node-forge";
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import Utils from "../Utils.mjs";
|
||||||
|
import CMAC from "./CMAC.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate payment KCV operation
|
||||||
|
*/
|
||||||
|
class CalculatePaymentKCV extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalculatePaymentKCV constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Calculate payment KCV";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the key into the input field and choose how that key is encoded using <b>Key format</b>.<br><br>Use <b>Method</b> to choose the KCV style: TDES, AES-CMAC, AES-ECB, or HMAC.<br><br><b>Input:</b> raw key material such as hex, UTF-8, Latin1, or Base64.<br><b>Arguments:</b> select the key format, method, and output length in hex characters.<br><br>Returns an uppercase truncated hex KCV value.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> key material.<br><strong>Args:</strong> tell the op how the key is encoded, choose the KCV method, then set the output length.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Random AES-CMAC sample",
|
||||||
|
input: "__RANDOM_AES_128_HEX__",
|
||||||
|
args: ["Hex", "AES-CMAC (Empty)", 6]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
"name": "Key format",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["Hex", "UTF8", "Latin1", "Base64"],
|
||||||
|
"comment": "How the input field should be decoded before KCV calculation. Use <code>Hex</code> for payment keys entered as hexadecimal characters."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Method",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["TDES-ECB (Zeros)", "AES-CMAC (Empty)", "AES-CMAC (Zeros)", "AES-CMAC (Ones)", "AES-ECB (Zeros)", "HMAC SHA-224", "HMAC SHA-256", "HMAC SHA-384", "HMAC SHA-512"],
|
||||||
|
"comment": "Assumption: TDES expects a 16-byte or 24-byte key, AES expects 16/24/32 bytes, and the method name states the exact data block used for the KCV."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Output hex chars",
|
||||||
|
"type": "number",
|
||||||
|
"value": 6,
|
||||||
|
"comment": "Number of uppercase hex characters returned from the left side of the calculated value. Common payment KCV length is <code>6</code>."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [keyFormat, method, outputHexChars] = args;
|
||||||
|
const truncLength = Math.max(1, Number(outputHexChars) || 6);
|
||||||
|
const keyBytes = Utils.convertToByteString(input || "", keyFormat);
|
||||||
|
|
||||||
|
if (!keyBytes.length) {
|
||||||
|
throw new OperationError("No key material was provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let hexOut;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "TDES-ECB (Zeros)": {
|
||||||
|
if (keyBytes.length !== 16 && keyBytes.length !== 24) {
|
||||||
|
throw new OperationError("TDES key must be 16 or 24 bytes.");
|
||||||
|
}
|
||||||
|
const key = keyBytes.length === 16 ? keyBytes + keyBytes.substring(0, 8) : keyBytes;
|
||||||
|
const cipher = forge.cipher.createCipher("3DES-ECB", key);
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00"));
|
||||||
|
cipher.finish();
|
||||||
|
hexOut = cipher.output.toHex().toUpperCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "AES-CMAC (Empty)":
|
||||||
|
case "AES-CMAC (Zeros)":
|
||||||
|
case "AES-CMAC (Ones)": {
|
||||||
|
if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
|
||||||
|
throw new OperationError("AES key must be 16, 24, or 32 bytes.");
|
||||||
|
}
|
||||||
|
const cmacOp = new CMAC();
|
||||||
|
let data;
|
||||||
|
if (method === "AES-CMAC (Empty)") {
|
||||||
|
data = new Uint8Array(0).buffer;
|
||||||
|
} else if (method === "AES-CMAC (Zeros)") {
|
||||||
|
data = new Uint8Array(16).buffer;
|
||||||
|
} else {
|
||||||
|
data = Uint8Array.from(new Array(16).fill(0xFF)).buffer;
|
||||||
|
}
|
||||||
|
hexOut = cmacOp.run(data, [{ string: keyBytes, option: "Latin1" }, "AES"]).toUpperCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "AES-ECB (Zeros)": {
|
||||||
|
if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
|
||||||
|
throw new OperationError("AES key must be 16, 24, or 32 bytes.");
|
||||||
|
}
|
||||||
|
const cipher = forge.cipher.createCipher("AES-ECB", keyBytes);
|
||||||
|
cipher.mode.pad = function() {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"));
|
||||||
|
cipher.finish();
|
||||||
|
hexOut = cipher.output.toHex().toUpperCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "HMAC SHA-224":
|
||||||
|
case "HMAC SHA-256":
|
||||||
|
case "HMAC SHA-384":
|
||||||
|
case "HMAC SHA-512": {
|
||||||
|
const algorithmMap = {
|
||||||
|
"HMAC SHA-224": forge.md.sha512.sha224.create(),
|
||||||
|
"HMAC SHA-256": "sha256",
|
||||||
|
"HMAC SHA-384": "sha384",
|
||||||
|
"HMAC SHA-512": "sha512"
|
||||||
|
};
|
||||||
|
const hmac = forge.hmac.create();
|
||||||
|
hmac.start(algorithmMap[method], keyBytes);
|
||||||
|
hmac.update("");
|
||||||
|
hexOut = hmac.digest().toHex().toUpperCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new OperationError("Unsupported method.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexOut.substring(0, truncLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalculatePaymentKCV;
|
||||||
279
src/core/operations/DeriveDUKPTKey.mjs
Normal file
279
src/core/operations/DeriveDUKPTKey.mjs
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import forge from "node-forge";
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import { toHexFast } from "../lib/Hex.mjs";
|
||||||
|
|
||||||
|
const DUKPT_KEY_MASK = Uint8Array.from([0xC0, 0xC0, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xC0, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
const VARIANT_MASKS = {
|
||||||
|
"None": Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||||
|
"PIN": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]),
|
||||||
|
"MAC Request": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00]),
|
||||||
|
"MAC Response": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
|
||||||
|
"Data": Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a fixed-length hex string into bytes.
|
||||||
|
*
|
||||||
|
* @param {string} input
|
||||||
|
* @param {number} expectedLen
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function parseHex(input, expectedLen, name) {
|
||||||
|
const hex = (input || "").replace(/\s+/g, "");
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
|
||||||
|
throw new OperationError(`${name} must be hex.`);
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
if (expectedLen && out.length !== expectedLen) {
|
||||||
|
throw new OperationError(`${name} must be ${expectedLen} bytes.`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XORs two equally sized byte arrays.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} a
|
||||||
|
* @param {Uint8Array} b
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function xorBytes(a, b) {
|
||||||
|
const out = new Uint8Array(a.length);
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
out[i] = a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts bytes to a forge-compatible binary string.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toByteString(bytes) {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
s += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts one 8-byte block with 2-key TDES in ECB mode.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} key16
|
||||||
|
* @param {Uint8Array} block8
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function encryptBlock3DesEcb(key16, block8) {
|
||||||
|
const key24 = toByteString(Uint8Array.from([...key16, ...key16.slice(0, 8)]));
|
||||||
|
const cipher = forge.cipher.createCipher("3DES-ECB", key24);
|
||||||
|
cipher.mode.pad = function() {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer(toByteString(block8)));
|
||||||
|
cipher.finish();
|
||||||
|
const out = cipher.output.getBytes();
|
||||||
|
return Uint8Array.from(out.split("").map(c => c.charCodeAt(0))).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts one 8-byte block with DES in ECB mode.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} key8
|
||||||
|
* @param {Uint8Array} block8
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function encryptBlockDesEcb(key8, block8) {
|
||||||
|
const cipher = forge.cipher.createCipher("DES-ECB", toByteString(key8));
|
||||||
|
cipher.mode.pad = function() {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
cipher.start();
|
||||||
|
cipher.update(forge.util.createBuffer(toByteString(block8)));
|
||||||
|
cipher.finish();
|
||||||
|
const out = cipher.output.getBytes();
|
||||||
|
return Uint8Array.from(out.split("").map(c => c.charCodeAt(0))).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the DUKPT IPEK from a BDK and KSN.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} bdk
|
||||||
|
* @param {Uint8Array} ksn
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function deriveIpek(bdk, ksn) {
|
||||||
|
const ksnReg = Uint8Array.from(ksn);
|
||||||
|
ksnReg[7] &= 0xE0;
|
||||||
|
ksnReg[8] = 0x00;
|
||||||
|
ksnReg[9] = 0x00;
|
||||||
|
const data = ksnReg.slice(0, 8);
|
||||||
|
|
||||||
|
const left = encryptBlock3DesEcb(bdk, data);
|
||||||
|
const right = encryptBlock3DesEcb(xorBytes(bdk, DUKPT_KEY_MASK), data);
|
||||||
|
|
||||||
|
return Uint8Array.from([...left, ...right]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the ANSI X9.24 non-reversible key generation step.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} key
|
||||||
|
* @param {Uint8Array} ksnReg
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function nonReversibleKeyGen(key, ksnReg) {
|
||||||
|
const reg8 = ksnReg.slice(2, 10);
|
||||||
|
|
||||||
|
const keyL = key.slice(0, 8);
|
||||||
|
const keyR = key.slice(8, 16);
|
||||||
|
|
||||||
|
const msgR = xorBytes(keyR, reg8);
|
||||||
|
const desR = encryptBlockDesEcb(keyL, msgR);
|
||||||
|
const right = xorBytes(desR, keyR);
|
||||||
|
|
||||||
|
const masked = xorBytes(key, DUKPT_KEY_MASK);
|
||||||
|
const mKeyL = masked.slice(0, 8);
|
||||||
|
const mKeyR = masked.slice(8, 16);
|
||||||
|
|
||||||
|
const msgL = xorBytes(mKeyR, reg8);
|
||||||
|
const desL = encryptBlockDesEcb(mKeyL, msgL);
|
||||||
|
const left = xorBytes(desL, mKeyR);
|
||||||
|
|
||||||
|
return Uint8Array.from([...left, ...right]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the base session key for the current transaction counter.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} ipek
|
||||||
|
* @param {Uint8Array} ksn
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function deriveSessionBaseKey(ipek, ksn) {
|
||||||
|
const ksnReg = Uint8Array.from(ksn);
|
||||||
|
ksnReg[7] &= 0xE0;
|
||||||
|
ksnReg[8] = 0x00;
|
||||||
|
ksnReg[9] = 0x00;
|
||||||
|
|
||||||
|
const counter = ((ksn[7] & 0x1F) << 16) | (ksn[8] << 8) | ksn[9];
|
||||||
|
let curKey = Uint8Array.from(ipek);
|
||||||
|
|
||||||
|
for (let shift = 20; shift >= 0; shift--) {
|
||||||
|
const bit = 1 << shift;
|
||||||
|
if ((counter & bit) !== 0) {
|
||||||
|
ksnReg[7] = (ksnReg[7] & 0xE0) | (((counter & 0x1F0000) >> 16) & 0x1F);
|
||||||
|
ksnReg[8] = (counter >> 8) & 0xFF;
|
||||||
|
ksnReg[9] = counter & 0xFF;
|
||||||
|
curKey = nonReversibleKeyGen(curKey, ksnReg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return curKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive DUKPT key operation
|
||||||
|
*/
|
||||||
|
class DeriveDUKPTKey extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeriveDUKPTKey constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Derive DUKPT key";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the Base Derivation Key (BDK) into the input field as a 16-byte hex value.<br><br>Put the 10-byte Key Serial Number in the <b>KSN</b> argument field.<br><br><b>Input:</b> BDK in hex.<br><b>Arguments:</b> choose whether to derive the IPEK or the transaction key, provide the KSN, choose the variant, and optionally return JSON.<br><br>This operation derives TDES DUKPT keys in software for test and interoperability work.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> BDK hex.<br><strong>Args:</strong> add the KSN, choose IPEK or transaction-key derivation, then optionally apply a variant.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Known transaction key vector",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
args: ["Derive Session Key", "FFFF9876543210E00008", "None", false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://en.wikipedia.org/wiki/Derived_unique_key_per_transaction";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
"name": "Mode",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["Derive IPEK", "Derive Session Key"],
|
||||||
|
"comment": "Choose whether the output should be the IPEK or the derived transaction/session key. Assumption: this implementation follows TDES DUKPT, not AES DUKPT."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSN (hex, 10 bytes)",
|
||||||
|
"type": "string",
|
||||||
|
"value": "",
|
||||||
|
"comment": "Provide the full 10-byte KSN as 20 hex characters, for example <code>FFFF9876543210E00008</code>. Spaces are allowed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Session key variant",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["None", "PIN", "MAC Request", "MAC Response", "Data"],
|
||||||
|
"comment": "Applied only when deriving the session key. Assumption: variants are implemented as simple XOR masks over the derived base key."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Output as JSON",
|
||||||
|
"type": "boolean",
|
||||||
|
"value": false,
|
||||||
|
"comment": "When enabled, returns the intermediate values along with the final key so the derivation can be inspected."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [mode, ksnHex, variant, outputJson] = args;
|
||||||
|
const bdk = parseHex(input, 16, "BDK");
|
||||||
|
const ksn = parseHex(ksnHex, 10, "KSN");
|
||||||
|
|
||||||
|
const ipek = deriveIpek(bdk, ksn);
|
||||||
|
const ipekHex = toHexFast(ipek).toUpperCase();
|
||||||
|
|
||||||
|
if (mode === "Derive IPEK") {
|
||||||
|
if (outputJson) {
|
||||||
|
return JSON.stringify({ mode, ipek: ipekHex }, null, 4);
|
||||||
|
}
|
||||||
|
return ipekHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionBase = deriveSessionBaseKey(ipek, ksn);
|
||||||
|
const session = xorBytes(sessionBase, VARIANT_MASKS[variant]);
|
||||||
|
const sessionHex = toHexFast(session).toUpperCase();
|
||||||
|
|
||||||
|
if (outputJson) {
|
||||||
|
return JSON.stringify({
|
||||||
|
mode,
|
||||||
|
ipek: ipekHex,
|
||||||
|
sessionBase: toHexFast(sessionBase).toUpperCase(),
|
||||||
|
variant,
|
||||||
|
sessionKey: sessionHex
|
||||||
|
}, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeriveDUKPTKey;
|
||||||
263
src/core/operations/DeriveECDHKeyMaterial.mjs
Normal file
263
src/core/operations/DeriveECDHKeyMaterial.mjs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
import r from "jsrsasign";
|
||||||
|
import { fromBase64, toBase64 } from "../lib/Base64.mjs";
|
||||||
|
import { toHexFast } from "../lib/Hex.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a PEM or hex-encoded DER key into bytes.
|
||||||
|
*
|
||||||
|
* @param {string} input
|
||||||
|
* @param {string} format
|
||||||
|
* @param {string} pemLabel
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function parsePemOrHex(input, format, pemLabel) {
|
||||||
|
const value = (input || "").trim();
|
||||||
|
if (!value.length) throw new OperationError("Missing key input.");
|
||||||
|
|
||||||
|
if (format === "PEM") {
|
||||||
|
const normalized = value
|
||||||
|
.replace(new RegExp(`-----BEGIN ${pemLabel}-----`, "g"), "")
|
||||||
|
.replace(new RegExp(`-----END ${pemLabel}-----`, "g"), "")
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
|
||||||
|
return new Uint8Array(fromBase64(normalized, undefined, "byteArray"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = value.replace(/\s+/g, "");
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
|
||||||
|
throw new OperationError("Expected hex input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes PEM private keys to PKCS#8 DER for WebCrypto import.
|
||||||
|
*
|
||||||
|
* @param {string} input
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function parsePrivateKey(input) {
|
||||||
|
const value = (input || "").trim();
|
||||||
|
if (!value.length) throw new OperationError("Missing key input.");
|
||||||
|
|
||||||
|
if (!value.includes("-----BEGIN")) {
|
||||||
|
return parsePemOrHex(value, "HEX", "PRIVATE KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes("-----BEGIN PRIVATE KEY-----")) {
|
||||||
|
return parsePemOrHex(value, "PEM", "PRIVATE KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = r.KEYUTIL.getKey(value);
|
||||||
|
const pkcs8Pem = r.KEYUTIL.getPEM(key, "PKCS8PRV");
|
||||||
|
return parsePemOrHex(pkcs8Pem, "PEM", "PRIVATE KEY");
|
||||||
|
} catch (err) {
|
||||||
|
throw new OperationError(`Unsupported private key format: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenates byte arrays.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array[]} parts
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
function concatBytes(parts) {
|
||||||
|
const total = parts.reduce((sum, p) => sum + p.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
out.set(p, offset);
|
||||||
|
offset += p.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives output keying material using a simple Concat KDF.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} rawSecret
|
||||||
|
* @param {Uint8Array} sharedInfo
|
||||||
|
* @param {string} hashAlg
|
||||||
|
* @param {number} outputLen
|
||||||
|
* @returns {Promise<Uint8Array>}
|
||||||
|
*/
|
||||||
|
async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) {
|
||||||
|
const digestName = hashAlg === "SHA-256" ? "SHA-256" : "SHA-512";
|
||||||
|
let counter = 1;
|
||||||
|
const chunks = [];
|
||||||
|
let generated = 0;
|
||||||
|
|
||||||
|
while (generated < outputLen) {
|
||||||
|
const ctr = new Uint8Array([
|
||||||
|
(counter >>> 24) & 0xff,
|
||||||
|
(counter >>> 16) & 0xff,
|
||||||
|
(counter >>> 8) & 0xff,
|
||||||
|
counter & 0xff,
|
||||||
|
]);
|
||||||
|
const data = concatBytes([ctr, rawSecret, sharedInfo]);
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest(digestName, data));
|
||||||
|
chunks.push(digest);
|
||||||
|
generated += digest.length;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return concatBytes(chunks).slice(0, outputLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive ECDH key material operation
|
||||||
|
*/
|
||||||
|
class DeriveECDHKeyMaterial extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeriveECDHKeyMaterial constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Derive ECDH key material";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste your private key into the input field and paste the peer public key into the <b>Peer public key</b> argument field.<br><br><b>Input:</b> private key in PEM or PKCS#8 DER hex. PEM may be <code>BEGIN PRIVATE KEY</code> or <code>BEGIN EC PRIVATE KEY</code> when it can be normalized to PKCS#8.<br><b>Arguments:</b> choose the curve, peer public key format, optional KDF, optional shared info, output length, and output format.<br><br>Use <b>KDF = None</b> to get the raw shared secret.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> your private key.<br><strong>Args:</strong> pick the curve, paste the peer public key, then choose raw shared secret or KDF output.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Known P-256 PEM vector",
|
||||||
|
input: "__ECDH_TEST_PRIVATE_KEY__",
|
||||||
|
args: ["PEM", "P-256", "PEM", "__ECDH_TEST_PEER_PUBLIC_KEY__", "None", 32, "", "Hex"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
"name": "Private key format",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["PEM", "Hex (PKCS8 DER)"],
|
||||||
|
"comment": "Input field format for your private key. PEM may be <code>BEGIN PRIVATE KEY</code> or a supported <code>BEGIN EC PRIVATE KEY</code> that can be normalized to PKCS#8."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Curve",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["P-256", "P-384", "P-521"],
|
||||||
|
"comment": "Must match the actual curve of both keys. The op does not auto-detect or translate between curves."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Peer public key format",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["PEM", "Hex (SPKI DER)"],
|
||||||
|
"comment": "Format of the peer public key argument. PEM should be an SPKI <code>BEGIN PUBLIC KEY</code> block."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Peer public key",
|
||||||
|
"type": "text",
|
||||||
|
"value": "-----BEGIN PUBLIC KEY-----",
|
||||||
|
"comment": "Paste the full peer public key here. For PEM input, include the begin/end lines."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KDF",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"],
|
||||||
|
"comment": "Use <code>None</code> to return the raw shared secret. The KDF options use a simple Concat KDF over the shared secret plus optional shared info."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Output length (bytes)",
|
||||||
|
"type": "number",
|
||||||
|
"value": 32,
|
||||||
|
"comment": "Used only with KDF modes. For <code>None</code>, the raw shared secret length is determined by the curve."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shared info (hex)",
|
||||||
|
"type": "string",
|
||||||
|
"value": "",
|
||||||
|
"comment": "Optional KDF shared info as hex. Leave blank if your test profile does not include shared info."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Output format",
|
||||||
|
"type": "option",
|
||||||
|
"value": ["Hex", "Base64"],
|
||||||
|
"comment": "Controls how the raw shared secret or KDF output is displayed."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
async run(input, args) {
|
||||||
|
const [
|
||||||
|
privateFmt,
|
||||||
|
curve,
|
||||||
|
publicFmt,
|
||||||
|
peerPublicKey,
|
||||||
|
kdf,
|
||||||
|
outLenArg,
|
||||||
|
sharedInfoHex,
|
||||||
|
outputFormat
|
||||||
|
] = args;
|
||||||
|
|
||||||
|
if (!globalThis.crypto || !globalThis.crypto.subtle) {
|
||||||
|
throw new OperationError("WebCrypto is not available in this runtime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateDer = privateFmt === "PEM" ? parsePrivateKey(input) : parsePemOrHex(input, "HEX", "PRIVATE KEY");
|
||||||
|
const publicDer = parsePemOrHex(peerPublicKey, publicFmt === "PEM" ? "PEM" : "HEX", "PUBLIC KEY");
|
||||||
|
const outLen = Math.max(1, Number(outLenArg) || 32);
|
||||||
|
|
||||||
|
const sharedInfoHexNorm = (sharedInfoHex || "").replace(/\s+/g, "");
|
||||||
|
if (sharedInfoHexNorm.length % 2 !== 0 || (sharedInfoHexNorm.length > 0 && !/^[0-9a-fA-F]+$/.test(sharedInfoHexNorm))) {
|
||||||
|
throw new OperationError("Shared info must be hex.");
|
||||||
|
}
|
||||||
|
const sharedInfo = sharedInfoHexNorm.length ?
|
||||||
|
new Uint8Array(sharedInfoHexNorm.match(/.{2}/g).map(h => parseInt(h, 16))) :
|
||||||
|
new Uint8Array();
|
||||||
|
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
"pkcs8",
|
||||||
|
privateDer,
|
||||||
|
{ name: "ECDH", namedCurve: curve },
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
publicDer,
|
||||||
|
{ name: "ECDH", namedCurve: curve },
|
||||||
|
false,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const curveBits = curve === "P-256" ? 256 : curve === "P-384" ? 384 : 528;
|
||||||
|
const rawSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, curveBits));
|
||||||
|
|
||||||
|
let out = rawSecret;
|
||||||
|
if (kdf === "Concat KDF SHA-256") {
|
||||||
|
out = await concatKdf(rawSecret, sharedInfo, "SHA-256", outLen);
|
||||||
|
} else if (kdf === "Concat KDF SHA-512") {
|
||||||
|
out = await concatKdf(rawSecret, sharedInfo, "SHA-512", outLen);
|
||||||
|
} else {
|
||||||
|
out = rawSecret.slice(0, outLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeriveECDHKeyMaterial;
|
||||||
110
src/core/operations/GenerateCardValidationData.mjs
Normal file
110
src/core/operations/GenerateCardValidationData.mjs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { CVV_PROFILES, generateCardValidationData } from "../lib/CardValidation.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate card validation data operation.
|
||||||
|
*/
|
||||||
|
class GenerateCardValidationData extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenerateCardValidationData constructor.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Generate card validation data";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the combined CVK pair into the input field as hex and generate a card-verification value for software testing.<br><br><b>Input:</b> combined CVK pair as 16-byte or 24-byte hex.<br><b>Arguments:</b> select whether you are generating CVV/CVC, CVV2/CVC2, or iCVV, then provide the PAN, expiry components, and service code details.<br><br>This implementation is intended for test harnesses and assumes the common CVV decimalization flow used by payment HSM integrations.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> combined CVK pair hex.<br><strong>Args:</strong> choose the validation-data profile, then provide PAN, expiry, and service-code inputs.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Known CVV2 test sample",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/generate-card-data.html";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Validation data type",
|
||||||
|
type: "option",
|
||||||
|
value: CVV_PROFILES,
|
||||||
|
comment: "Choose whether the output should behave like CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code <code>000</code> and iCVV forces <code>999</code>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary account number",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry month (MM)",
|
||||||
|
type: "shortString",
|
||||||
|
value: "",
|
||||||
|
comment: "Two-digit month component used when assembling the expiry date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry year (YY)",
|
||||||
|
type: "shortString",
|
||||||
|
value: "",
|
||||||
|
comment: "Two-digit year component used when assembling the expiry date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry layout",
|
||||||
|
type: "option",
|
||||||
|
value: ["YYMM", "MMYY"],
|
||||||
|
defaultIndex: 1,
|
||||||
|
comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service code",
|
||||||
|
type: "shortString",
|
||||||
|
value: "101",
|
||||||
|
comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force <code>000</code> and <code>999</code>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Output digits",
|
||||||
|
type: "number",
|
||||||
|
value: 3,
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
comment: "How many digits of validation data to return. Common card-security-code lengths are <code>3</code> and sometimes <code>4</code>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Output as JSON",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
comment: "When enabled, returns the assembled input, intermediate hex, and decimalized value along with the final output."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, outputDigits, outputJson] = args;
|
||||||
|
const result = generateCardValidationData(
|
||||||
|
input,
|
||||||
|
pan,
|
||||||
|
expiryMonth,
|
||||||
|
expiryYear,
|
||||||
|
expiryLayout,
|
||||||
|
serviceCode,
|
||||||
|
profile,
|
||||||
|
outputDigits
|
||||||
|
);
|
||||||
|
|
||||||
|
return outputJson ? JSON.stringify(result, null, 4) : result.validationData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateCardValidationData;
|
||||||
69
src/core/operations/GenerateEMVARPC.mjs
Normal file
69
src/core/operations/GenerateEMVARPC.mjs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate EMV ARPC operation.
|
||||||
|
*/
|
||||||
|
class GenerateEMVARPC extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenerateEMVARPC constructor.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Generate EMV ARPC";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the already-assembled EMV authorization-response input into the input field as hex and generate an AES-CMAC-based ARPC.<br><br><b>Input:</b> preassembled ARPC input data as hex.<br><b>Arguments:</b> provide the issuer session key in hex and choose how many bytes of the CMAC should be returned.<br><br>This operation intentionally covers only AES-CMAC-style EMV profiles where the issuer session key and response preimage are already known.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> preassembled ARPC data as hex.<br><strong>Args:</strong> provide the issuer AES session key and choose the truncated cryptogram length.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "AES-CMAC ARPC sample",
|
||||||
|
input: "11223344556677889900AABBCCDDEEFF",
|
||||||
|
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/crypto-ops-carddata.html";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Issuer session key (hex)",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Provide the already-derived issuer session key as hex. Assumption: this op does not derive EMV issuer session keys."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cryptogram bytes",
|
||||||
|
type: "number",
|
||||||
|
value: 8,
|
||||||
|
min: 1,
|
||||||
|
max: 16,
|
||||||
|
comment: "Number of leftmost CMAC bytes to return. Common ARPC length is <code>8</code> bytes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Output as JSON",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
comment: "When enabled, returns the full AES-CMAC and the truncated ARPC value."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [issuerSessionKeyHex, cryptogramBytes, outputJson] = args;
|
||||||
|
const result = generateEmvAesCmacCryptogram(input, issuerSessionKeyHex, cryptogramBytes);
|
||||||
|
return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateEMVARPC;
|
||||||
69
src/core/operations/GenerateEMVARQC.mjs
Normal file
69
src/core/operations/GenerateEMVARQC.mjs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate EMV ARQC operation.
|
||||||
|
*/
|
||||||
|
class GenerateEMVARQC extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GenerateEMVARQC constructor.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Generate EMV ARQC";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the already-assembled EMV authorization-request input into the input field as hex and generate an AES-CMAC-based ARQC.<br><br><b>Input:</b> preassembled ARQC input data as hex.<br><b>Arguments:</b> provide the EMV session key in hex and choose how many bytes of the CMAC should be returned.<br><br>This operation intentionally covers only AES-CMAC-style EMV profiles where the session key and preimage are already known.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> preassembled ARQC data as hex.<br><strong>Args:</strong> provide the AES session key and choose the truncated cryptogram length.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "AES-CMAC ARQC sample",
|
||||||
|
input: "000102030405060708090A0B0C0D0E0F",
|
||||||
|
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/crypto-ops-carddata.html";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Session key (hex)",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Provide the already-derived EMV session key as hex. Assumption: this op does not derive EMV session keys."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cryptogram bytes",
|
||||||
|
type: "number",
|
||||||
|
value: 8,
|
||||||
|
min: 1,
|
||||||
|
max: 16,
|
||||||
|
comment: "Number of leftmost CMAC bytes to return. Common ARQC length is <code>8</code> bytes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Output as JSON",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
comment: "When enabled, returns the full AES-CMAC and the truncated ARQC value."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [sessionKeyHex, cryptogramBytes, outputJson] = args;
|
||||||
|
const result = generateEmvAesCmacCryptogram(input, sessionKeyHex, cryptogramBytes);
|
||||||
|
return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerateEMVARQC;
|
||||||
60
src/core/operations/ParsePINBlock.mjs
Normal file
60
src/core/operations/ParsePINBlock.mjs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { PIN_BLOCK_FORMATS, parsePinBlock } from "../lib/PinBlock.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse PIN block operation
|
||||||
|
*/
|
||||||
|
class ParsePINBlock extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParsePINBlock constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Parse PIN block";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and decode it into its component fields.<br><br><b>Input:</b> 8-byte clear PIN block as hex.<br><b>Arguments:</b> choose the format and provide the PAN when the format binds to PAN data.<br><br>This operation currently parses clear test PIN blocks for ISO formats 0, 1, and 3.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> clear PIN block hex.<br><strong>Args:</strong> choose the format and provide the PAN for formats 0 and 3 so the block can be decoded.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Known ISO Format 0 vector",
|
||||||
|
input: "041215FEDCBA9876",
|
||||||
|
args: ["ISO Format 0", "5432101234567890"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Format",
|
||||||
|
type: "option",
|
||||||
|
value: PIN_BLOCK_FORMATS,
|
||||||
|
comment: "Choose the format you expect the input block to decode as. The parser validates the format nibble after PAN unmasking."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary account number",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [format, pan] = args;
|
||||||
|
return JSON.stringify(parsePinBlock(format, input, pan), null, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParsePINBlock;
|
||||||
129
src/core/operations/ParseTR31KeyBlock.mjs
Normal file
129
src/core/operations/ParseTR31KeyBlock.mjs
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TR-31 key block header operation
|
||||||
|
*/
|
||||||
|
class ParseTR31KeyBlock extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseTR31KeyBlock constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Parse TR-31 key block";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the full TR-31 key block into the input field as text or hex characters.<br><br><b>Input:</b> complete TR-31 key block string, with or without spaces. If your source includes a leading <code>R</code> prefix, leave <b>Trim leading R prefix</b> enabled.<br><br>This operation parses the fixed header, any optional blocks it can identify, and reports the remaining body.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> full TR-31 key block text.<br><strong>Args:</strong> leave the prefix trim enabled if the block starts with <code>R</code>.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Fixed-header parser sample",
|
||||||
|
input: "D0016D0AB00E0000",
|
||||||
|
args: [true]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
"name": "Trim leading R prefix",
|
||||||
|
"type": "boolean",
|
||||||
|
"value": true,
|
||||||
|
"comment": "Enable this if your source begins with an <code>R</code> transport prefix before the TR-31 block. The parser otherwise expects the block to start at the version byte."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [trimLeadingR] = args;
|
||||||
|
let keyBlock = (input || "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
const notes = [];
|
||||||
|
|
||||||
|
if (!keyBlock.length) {
|
||||||
|
throw new OperationError("No input.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimLeadingR && keyBlock.startsWith("R")) {
|
||||||
|
keyBlock = keyBlock.substring(1);
|
||||||
|
notes.push("Removed leading R prefix.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyBlock.length < 16) {
|
||||||
|
throw new OperationError("Input too short for TR-31 header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedHeader = keyBlock.substring(0, 16);
|
||||||
|
const declaredBlockLength = parseInt(keyBlock.substring(1, 5), 10);
|
||||||
|
const optionalBlocksDeclared = parseInt(keyBlock.substring(12, 14), 10);
|
||||||
|
let offset = 16;
|
||||||
|
let optionalBlocksParsed = 0;
|
||||||
|
const optionalBlocks = [];
|
||||||
|
|
||||||
|
while (optionalBlocksParsed < optionalBlocksDeclared && offset + 4 <= keyBlock.length) {
|
||||||
|
const blockId = keyBlock.substring(offset, offset + 2);
|
||||||
|
const blockLength = parseInt(keyBlock.substring(offset + 2, offset + 4), 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(blockLength) || blockLength < 4) {
|
||||||
|
notes.push(`Stopped optional block parsing due to invalid block length at offset ${offset}.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + blockLength > keyBlock.length) {
|
||||||
|
notes.push(`Stopped optional block parsing due to truncated block at offset ${offset}.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
optionalBlocks.push({
|
||||||
|
"id": blockId,
|
||||||
|
"length": blockLength,
|
||||||
|
"value": keyBlock.substring(offset + 4, offset + blockLength)
|
||||||
|
});
|
||||||
|
optionalBlocksParsed += 1;
|
||||||
|
offset += blockLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
"raw": keyBlock,
|
||||||
|
"fixedHeader": {
|
||||||
|
"raw": fixedHeader,
|
||||||
|
"versionId": keyBlock.substring(0, 1),
|
||||||
|
"declaredBlockLength": Number.isFinite(declaredBlockLength) ? declaredBlockLength : null,
|
||||||
|
"keyUsage": keyBlock.substring(5, 7),
|
||||||
|
"algorithm": keyBlock.substring(7, 8),
|
||||||
|
"modeOfUse": keyBlock.substring(8, 9),
|
||||||
|
"keyVersionNumber": keyBlock.substring(9, 11),
|
||||||
|
"exportability": keyBlock.substring(11, 12),
|
||||||
|
"optionalBlocksDeclared": Number.isFinite(optionalBlocksDeclared) ? optionalBlocksDeclared : null,
|
||||||
|
"reserved": keyBlock.substring(14, 16)
|
||||||
|
},
|
||||||
|
"optionalBlocks": optionalBlocks,
|
||||||
|
"bodyOffset": offset,
|
||||||
|
"remainingBody": keyBlock.substring(offset),
|
||||||
|
"notes": notes
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.fixedHeader.declaredBlockLength !== null && result.fixedHeader.declaredBlockLength !== keyBlock.length) {
|
||||||
|
result.notes.push(`Declared block length ${result.fixedHeader.declaredBlockLength} does not match actual length ${keyBlock.length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.fixedHeader.optionalBlocksDeclared !== null && result.fixedHeader.optionalBlocksDeclared !== optionalBlocks.length) {
|
||||||
|
result.notes.push(`Declared optional blocks ${result.fixedHeader.optionalBlocksDeclared} but parsed ${optionalBlocks.length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(result, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParseTR31KeyBlock;
|
||||||
137
src/core/operations/ParseTR34B9Envelope.mjs
Normal file
137
src/core/operations/ParseTR34B9Envelope.mjs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an ASN.1 TLV length field at the given offset.
|
||||||
|
*
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @param {number} offset
|
||||||
|
* @returns {{headerLength: number, valueLength: number}}
|
||||||
|
*/
|
||||||
|
function parseAsnLength(bytes, offset) {
|
||||||
|
if (offset + 2 > bytes.length) {
|
||||||
|
throw new OperationError("Insufficient ASN.1 data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = bytes[offset + 1];
|
||||||
|
if ((first & 0x80) === 0) {
|
||||||
|
return { headerLength: 2, valueLength: first };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthOfLength = first & 0x7f;
|
||||||
|
if (offset + 2 + lengthOfLength > bytes.length) {
|
||||||
|
throw new OperationError("Invalid ASN.1 length field.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueLength = 0;
|
||||||
|
for (let i = 0; i < lengthOfLength; i++) {
|
||||||
|
valueLength = (valueLength << 8) | bytes[offset + 2 + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headerLength: 2 + lengthOfLength, valueLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TR-34 B9 envelope operation
|
||||||
|
*/
|
||||||
|
class ParseTR34B9Envelope extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseTR34B9Envelope constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Parse TR-34 B9 envelope";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the full B9 response frame into the input field as hex.<br><br><b>Input:</b> complete TR-34 B9 response encoded as hex, including the leading length field.<br><br>This operation splits the response into header, response code, authentication data, KCV, envelope data, signature length, signature, and any trailing bytes.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> full B9 response frame as hex, including the 2-byte length field.<br><strong>Args:</strong> none.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Synthetic B9 parser sample",
|
||||||
|
input: "001730303030423930303100112233300030303034AABBCCDD",
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://en.wikipedia.org/wiki/Key_block";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input) {
|
||||||
|
const hex = (input || "").replace(/\s+/g, "");
|
||||||
|
if (!hex.length) {
|
||||||
|
throw new OperationError("No input.");
|
||||||
|
}
|
||||||
|
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
||||||
|
throw new OperationError("Input must be hex.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(hex.match(/.{2}/g).map(h => parseInt(h, 16)));
|
||||||
|
if (bytes.length < 12) {
|
||||||
|
throw new OperationError("Input too short.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredLength = (bytes[0] << 8) | bytes[1];
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
const header = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
const responseType = String.fromCharCode(...bytes.slice(offset, offset + 2));
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
const errorCode = String.fromCharCode(...bytes.slice(offset, offset + 2));
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
const authLenMeta = parseAsnLength(bytes, offset);
|
||||||
|
const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength;
|
||||||
|
const authData = bytes.slice(offset, offset + authTotalLen);
|
||||||
|
offset += authTotalLen;
|
||||||
|
|
||||||
|
const kcv = bytes.slice(offset, offset + 3);
|
||||||
|
offset += 3;
|
||||||
|
|
||||||
|
const envLenMeta = parseAsnLength(bytes, offset);
|
||||||
|
const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength;
|
||||||
|
const envelopeData = bytes.slice(offset, offset + envTotalLen);
|
||||||
|
offset += envTotalLen;
|
||||||
|
|
||||||
|
const signatureLengthAscii = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
||||||
|
offset += 4;
|
||||||
|
const signatureLength = parseInt(signatureLengthAscii, 10);
|
||||||
|
const signature = Number.isFinite(signatureLength) ? bytes.slice(offset, offset + signatureLength) : new Uint8Array();
|
||||||
|
if (Number.isFinite(signatureLength)) {
|
||||||
|
offset += signatureLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
declaredLength,
|
||||||
|
actualLengthExcludingLengthField: bytes.length - 2,
|
||||||
|
header,
|
||||||
|
responseType,
|
||||||
|
errorCode,
|
||||||
|
authDataHex: Buffer.from(authData).toString("hex").toUpperCase(),
|
||||||
|
kcvHex: Buffer.from(kcv).toString("hex").toUpperCase(),
|
||||||
|
envelopeDataHex: Buffer.from(envelopeData).toString("hex").toUpperCase(),
|
||||||
|
signatureLengthAscii,
|
||||||
|
signatureLength: Number.isFinite(signatureLength) ? signatureLength : null,
|
||||||
|
signatureHex: Buffer.from(signature).toString("hex").toUpperCase(),
|
||||||
|
trailingHex: Buffer.from(bytes.slice(offset)).toString("hex").toUpperCase()
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(out, null, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParseTR34B9Envelope;
|
||||||
83
src/core/operations/TranslatePINBlock.mjs
Normal file
83
src/core/operations/TranslatePINBlock.mjs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { PIN_BLOCK_FORMATS, translatePinBlock } from "../lib/PinBlock.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate PIN block operation
|
||||||
|
*/
|
||||||
|
class TranslatePINBlock extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TranslatePINBlock constructor
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Translate PIN block";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and translate it between supported clear block formats.<br><br><b>Input:</b> 8-byte clear PIN block as hex.<br><b>Arguments:</b> choose the source and target formats, provide source and target PAN values when required, and optionally randomize target filler digits for formats 1 and 3.<br><br>This operation currently translates clear test PIN blocks for ISO formats 0, 1, and 3.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> source clear PIN block hex.<br><strong>Args:</strong> choose source and target formats, then provide the source and target PAN values where the formats require them.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "ISO Format 0 to 1 translation",
|
||||||
|
input: "041215FEDCBA9876",
|
||||||
|
args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://wikipedia.org/wiki/ISO_9564";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Source format",
|
||||||
|
type: "option",
|
||||||
|
value: PIN_BLOCK_FORMATS,
|
||||||
|
comment: "How the input block should be decoded before translation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Source PAN",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Required when the source format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Target format",
|
||||||
|
type: "option",
|
||||||
|
value: PIN_BLOCK_FORMATS,
|
||||||
|
defaultIndex: 1,
|
||||||
|
comment: "The clear PIN block format to emit after decoding the source block."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Target PAN",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Required when the target format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Randomize target fill digits",
|
||||||
|
type: "boolean",
|
||||||
|
value: false,
|
||||||
|
comment: "Affects only target formats 1 and 3. Leave disabled if you want repeatable vectors."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill] = args;
|
||||||
|
return JSON.stringify(
|
||||||
|
translatePinBlock(input, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill),
|
||||||
|
null,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TranslatePINBlock;
|
||||||
104
src/core/operations/VerifyCardValidationData.mjs
Normal file
104
src/core/operations/VerifyCardValidationData.mjs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Operation from "../Operation.mjs";
|
||||||
|
import { CVV_PROFILES, verifyCardValidationData } from "../lib/CardValidation.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify card validation data operation.
|
||||||
|
*/
|
||||||
|
class VerifyCardValidationData extends Operation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VerifyCardValidationData constructor.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.name = "Verify card validation data";
|
||||||
|
this.module = "Payment";
|
||||||
|
this.description = "Paste the combined CVK pair into the input field as hex and verify a CVV/CVC-style value for software testing.<br><br><b>Input:</b> combined CVK pair as 16-byte or 24-byte hex.<br><b>Arguments:</b> select the validation-data profile, provide the PAN and expiry components, then supply the expected validation data.<br><br>This operation recomputes the validation value using the same assumptions as the generate operation and reports whether the supplied value matches.";
|
||||||
|
this.inlineHelp = "<strong>Input:</strong> combined CVK pair hex.<br><strong>Args:</strong> provide PAN, expiry, service-code context, and the validation data to check.";
|
||||||
|
this.testDataSamples = [
|
||||||
|
{
|
||||||
|
name: "Known CVV2 verification sample",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
this.infoURL = "https://docs.aws.amazon.com/payment-cryptography/latest/userguide/verify-card-data.html";
|
||||||
|
this.inputType = "string";
|
||||||
|
this.outputType = "string";
|
||||||
|
this.args = [
|
||||||
|
{
|
||||||
|
name: "Validation data type",
|
||||||
|
type: "option",
|
||||||
|
value: CVV_PROFILES,
|
||||||
|
comment: "Choose whether the supplied value should be interpreted as CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code <code>000</code> and iCVV forces <code>999</code>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary account number",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
comment: "Provide the PAN as 13 to 19 decimal digits with no separators."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry month (MM)",
|
||||||
|
type: "shortString",
|
||||||
|
value: "",
|
||||||
|
comment: "Two-digit month component used when assembling the expiry date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry year (YY)",
|
||||||
|
type: "shortString",
|
||||||
|
value: "",
|
||||||
|
comment: "Two-digit year component used when assembling the expiry date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expiry layout",
|
||||||
|
type: "option",
|
||||||
|
value: ["YYMM", "MMYY"],
|
||||||
|
defaultIndex: 1,
|
||||||
|
comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service code",
|
||||||
|
type: "shortString",
|
||||||
|
value: "101",
|
||||||
|
comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force <code>000</code> and <code>999</code>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Expected value",
|
||||||
|
type: "shortString",
|
||||||
|
value: "",
|
||||||
|
comment: "Validation data to compare against, using 1 to 5 decimal digits."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
* @param {Object[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
run(input, args) {
|
||||||
|
const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, expectedValue] = args;
|
||||||
|
return JSON.stringify(
|
||||||
|
verifyCardValidationData(
|
||||||
|
input,
|
||||||
|
pan,
|
||||||
|
expiryMonth,
|
||||||
|
expiryYear,
|
||||||
|
expiryLayout,
|
||||||
|
serviceCode,
|
||||||
|
profile,
|
||||||
|
expectedValue
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerifyCardValidationData;
|
||||||
@ -27,6 +27,7 @@ class HTMLIngredient {
|
|||||||
this.value = config.value;
|
this.value = config.value;
|
||||||
this.disabled = config.disabled || false;
|
this.disabled = config.disabled || false;
|
||||||
this.hint = config.hint || false;
|
this.hint = config.hint || false;
|
||||||
|
this.comment = config.comment || "";
|
||||||
this.rows = config.rows || false;
|
this.rows = config.rows || false;
|
||||||
this.target = config.target;
|
this.target = config.target;
|
||||||
this.defaultIndex = config.defaultIndex || 0;
|
this.defaultIndex = config.defaultIndex || 0;
|
||||||
@ -49,6 +50,7 @@ class HTMLIngredient {
|
|||||||
toHtml() {
|
toHtml() {
|
||||||
let html = "",
|
let html = "",
|
||||||
i, m, eventFn;
|
i, m, eventFn;
|
||||||
|
const commentHtml = this.comment ? `<div class="arg-comment">${this.comment}</div>` : "";
|
||||||
|
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
case "string":
|
case "string":
|
||||||
@ -66,6 +68,7 @@ class HTMLIngredient {
|
|||||||
value="${this.value}"
|
value="${this.value}"
|
||||||
${this.disabled ? "disabled" : ""}
|
${this.disabled ? "disabled" : ""}
|
||||||
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
|
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "shortString":
|
case "shortString":
|
||||||
@ -82,6 +85,7 @@ class HTMLIngredient {
|
|||||||
value="${this.value}"
|
value="${this.value}"
|
||||||
${this.disabled ? "disabled" : ""}
|
${this.disabled ? "disabled" : ""}
|
||||||
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
|
${this.maxLength ? `maxlength="${this.maxLength}"` : ""}>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "toggleString":
|
case "toggleString":
|
||||||
@ -107,7 +111,7 @@ class HTMLIngredient {
|
|||||||
}
|
}
|
||||||
html += `</div>
|
html += `</div>
|
||||||
</div>
|
</div>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "number":
|
case "number":
|
||||||
@ -125,6 +129,7 @@ class HTMLIngredient {
|
|||||||
max="${this.max}"
|
max="${this.max}"
|
||||||
step="${this.step}"
|
step="${this.step}"
|
||||||
${this.disabled ? "disabled" : ""}>
|
${this.disabled ? "disabled" : ""}>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "boolean":
|
case "boolean":
|
||||||
@ -141,6 +146,7 @@ class HTMLIngredient {
|
|||||||
value="${this.name}"> ${this.name}
|
value="${this.name}"> ${this.name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "option":
|
case "option":
|
||||||
@ -164,6 +170,7 @@ class HTMLIngredient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += `</select>
|
html += `</select>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "populateOption":
|
case "populateOption":
|
||||||
@ -191,6 +198,7 @@ class HTMLIngredient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += `</select>
|
html += `</select>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
eventFn = this.type === "populateMultiOption" ?
|
eventFn = this.type === "populateMultiOption" ?
|
||||||
@ -225,6 +233,7 @@ class HTMLIngredient {
|
|||||||
}
|
}
|
||||||
html += `</div>
|
html += `</div>
|
||||||
</div>
|
</div>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
|
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
|
||||||
@ -256,6 +265,7 @@ class HTMLIngredient {
|
|||||||
}
|
}
|
||||||
html += `</div>
|
html += `</div>
|
||||||
</div>
|
</div>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
|
this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this);
|
||||||
@ -272,6 +282,7 @@ class HTMLIngredient {
|
|||||||
arg-name="${this.name}"
|
arg-name="${this.name}"
|
||||||
rows="${this.rows ? this.rows : 3}"
|
rows="${this.rows ? this.rows : 3}"
|
||||||
${this.disabled ? "disabled" : ""}>${this.value}</textarea>
|
${this.disabled ? "disabled" : ""}>${this.value}</textarea>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "argSelector":
|
case "argSelector":
|
||||||
@ -293,6 +304,7 @@ class HTMLIngredient {
|
|||||||
</option>`;
|
</option>`;
|
||||||
}
|
}
|
||||||
html += `</select>
|
html += `</select>
|
||||||
|
${commentHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this);
|
this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this);
|
||||||
|
|||||||
@ -28,6 +28,8 @@ class HTMLOperation {
|
|||||||
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.description = config.description;
|
this.description = config.description;
|
||||||
|
this.inlineHelp = config.inlineHelp || "";
|
||||||
|
this.testDataSamples = config.testDataSamples || [];
|
||||||
this.infoURL = config.infoURL;
|
this.infoURL = config.infoURL;
|
||||||
this.manualBake = config.manualBake || false;
|
this.manualBake = config.manualBake || false;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -74,7 +76,19 @@ class HTMLOperation {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
toFullHtml() {
|
toFullHtml() {
|
||||||
let html = `<div class="op-title">${Utils.escapeHtml(this.name)}</div>
|
let html = `<div class="op-title">${Utils.escapeHtml(this.name)}</div>`;
|
||||||
|
|
||||||
|
if (this.inlineHelp) {
|
||||||
|
html += `<div class="op-inline-help">${this.inlineHelp}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.testDataSamples.length) {
|
||||||
|
html += `<div class="op-test-data">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary populate-test-data">Populate test data</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
<div class="ingredients">`;
|
<div class="ingredients">`;
|
||||||
|
|
||||||
for (let i = 0; i < this.ingList.length; i++) {
|
for (let i = 0; i < this.ingList.length; i++) {
|
||||||
|
|||||||
@ -159,6 +159,7 @@ class Manager {
|
|||||||
this.addDynamicListener(".hide-args-icon", "click", this.recipe.hideArgsClick, this.recipe);
|
this.addDynamicListener(".hide-args-icon", "click", this.recipe.hideArgsClick, this.recipe);
|
||||||
this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
|
this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
|
||||||
this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, 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", "dblclick", this.recipe.operationDblclick, this.recipe);
|
||||||
this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, 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);
|
this.addDynamicListener("#rec-list .dropdown-menu.toggle-dropdown a", "click", this.recipe.dropdownToggleClick, this.recipe);
|
||||||
|
|||||||
@ -26,6 +26,58 @@
|
|||||||
font-weight: var(--op-title-font-weight);
|
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 {
|
.ingredients {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
|
|||||||
@ -11,6 +11,18 @@ import {escapeControlChars} from "../utils/editorUtils.mjs";
|
|||||||
import DOMPurify from "dompurify";
|
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.
|
* 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.
|
* Triggers various change events for operation arguments that have just been initialised.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -197,3 +197,4 @@ const logOpsTestReport = logTestReport.bind(null, testStatus);
|
|||||||
const results = await TestRegister.runTests();
|
const results = await TestRegister.runTests();
|
||||||
logOpsTestReport(results);
|
logOpsTestReport(results);
|
||||||
})();
|
})();
|
||||||
|
import "./tests/Payment.mjs";
|
||||||
|
|||||||
280
tests/operations/tests/Payment.mjs
Normal file
280
tests/operations/tests/Payment.mjs
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Payment operation tests.
|
||||||
|
*
|
||||||
|
* @license Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import TestRegister from "../../lib/TestRegister.mjs";
|
||||||
|
|
||||||
|
const ecdhPrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVPecKErSPjan5fSz
|
||||||
|
f+jsKPKthv3Ao5N0IxkbatQNw16hRANCAARhg779GdYIpH0QnY66FmGX1nMFyybu
|
||||||
|
sjExdXFN15BBa1+zh1Cf7Cr484KJ8Mh2ga/Qs8qKk/8VbWSj0SbLb6Os
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
const ecdhPrivateKeySec1 = `-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIFT3nChK0j42p+X0s3/o7CjyrYb9wKOTdCMZG2rUDcNeoAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEYYO+/RnWCKR9EJ2OuhZhl9ZzBcsm7rIxMXVxTdeQQWtfs4dQn+wq
|
||||||
|
+POCifDIdoGv0LPKipP/FW1ko9Emy2+jrA==
|
||||||
|
-----END EC PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
const ecdhPeerPublicKey = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZWOfvFUyA5ITdtEUar7aAz308Llr
|
||||||
|
pPVK74bCKbeq3gIA5ZN0we6T18GSkTHtCCOG266YyCGTcE2JrnswYk1f8A==
|
||||||
|
-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
|
TestRegister.addTests([
|
||||||
|
{
|
||||||
|
name: "Parse TR-31 key block: fixed header only",
|
||||||
|
input: "D0016D0AB00E0000",
|
||||||
|
expectedOutput: JSON.stringify({
|
||||||
|
raw: "D0016D0AB00E0000",
|
||||||
|
fixedHeader: {
|
||||||
|
raw: "D0016D0AB00E0000",
|
||||||
|
versionId: "D",
|
||||||
|
declaredBlockLength: 16,
|
||||||
|
keyUsage: "D0",
|
||||||
|
algorithm: "A",
|
||||||
|
modeOfUse: "B",
|
||||||
|
keyVersionNumber: "00",
|
||||||
|
exportability: "E",
|
||||||
|
optionalBlocksDeclared: 0,
|
||||||
|
reserved: "00"
|
||||||
|
},
|
||||||
|
optionalBlocks: [],
|
||||||
|
bodyOffset: 16,
|
||||||
|
remainingBody: "",
|
||||||
|
notes: []
|
||||||
|
}, null, 4),
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Parse TR-31 key block",
|
||||||
|
args: [true]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Parse TR-34 B9 envelope: split sections",
|
||||||
|
input: "001730303030423930303100112233300030303034AABBCCDD",
|
||||||
|
expectedOutput: JSON.stringify({
|
||||||
|
declaredLength: 23,
|
||||||
|
actualLengthExcludingLengthField: 23,
|
||||||
|
header: "0000",
|
||||||
|
responseType: "B9",
|
||||||
|
errorCode: "00",
|
||||||
|
authDataHex: "3100",
|
||||||
|
kcvHex: "112233",
|
||||||
|
envelopeDataHex: "3000",
|
||||||
|
signatureLengthAscii: "0004",
|
||||||
|
signatureLength: 4,
|
||||||
|
signatureHex: "AABBCCDD",
|
||||||
|
trailingHex: ""
|
||||||
|
}, null, 4),
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Parse TR-34 B9 envelope",
|
||||||
|
args: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Calculate payment KCV: HMAC SHA-256",
|
||||||
|
input: "00112233445566778899AABBCCDDEEFF",
|
||||||
|
expectedOutput: "E8A065",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Calculate payment KCV",
|
||||||
|
args: ["Hex", "HMAC SHA-256", 6]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Calculate payment KCV: AES-CMAC empty",
|
||||||
|
input: "00112233445566778899AABBCCDDEEFF",
|
||||||
|
expectedOutput: "917737",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Calculate payment KCV",
|
||||||
|
args: ["Hex", "AES-CMAC (Empty)", 6]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Calculate payment KCV: AES-CMAC zeros",
|
||||||
|
input: "00112233445566778899AABBCCDDEEFF",
|
||||||
|
expectedOutput: "53E107",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Calculate payment KCV",
|
||||||
|
args: ["Hex", "AES-CMAC (Zeros)", 6]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Calculate payment KCV: AES-CMAC ones",
|
||||||
|
input: "00112233445566778899AABBCCDDEEFF",
|
||||||
|
expectedOutput: "7B3046",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Calculate payment KCV",
|
||||||
|
args: ["Hex", "AES-CMAC (Ones)", 6]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Calculate payment KCV: AES-ECB zeros",
|
||||||
|
input: "00112233445566778899AABBCCDDEEFF",
|
||||||
|
expectedOutput: "FDE4FB",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Calculate payment KCV",
|
||||||
|
args: ["Hex", "AES-ECB (Zeros)", 6]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Derive DUKPT key: known IPEK vector",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
expectedOutput: "6AC292FAA1315B4D858AB3A3D7D5933A",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Derive DUKPT key",
|
||||||
|
args: ["Derive IPEK", "FFFF9876543210E00008", "None", false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Build PIN block: ISO Format 0",
|
||||||
|
input: "1234",
|
||||||
|
expectedOutput: "041215FEDCBA9876",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Build PIN block",
|
||||||
|
args: ["ISO Format 0", "5432101234567890", false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Parse PIN block: ISO Format 0",
|
||||||
|
input: "041215FEDCBA9876",
|
||||||
|
expectedOutput: JSON.stringify({
|
||||||
|
format: "ISO Format 0",
|
||||||
|
pin: "1234",
|
||||||
|
pinLength: 4,
|
||||||
|
pinFieldHex: "041234FFFFFFFFFF",
|
||||||
|
panFieldHex: "0000210123456789",
|
||||||
|
blockHex: "041215FEDCBA9876",
|
||||||
|
fillDigitsHex: "FFFFFFFFFF"
|
||||||
|
}, null, 4),
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Parse PIN block",
|
||||||
|
args: ["ISO Format 0", "5432101234567890"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Translate PIN block: ISO Format 0 to ISO Format 1",
|
||||||
|
input: "041215FEDCBA9876",
|
||||||
|
expectedOutput: JSON.stringify({
|
||||||
|
source: {
|
||||||
|
format: "ISO Format 0",
|
||||||
|
pin: "1234",
|
||||||
|
pinLength: 4,
|
||||||
|
pinFieldHex: "041234FFFFFFFFFF",
|
||||||
|
panFieldHex: "0000210123456789",
|
||||||
|
blockHex: "041215FEDCBA9876",
|
||||||
|
fillDigitsHex: "FFFFFFFFFF"
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
format: "ISO Format 1",
|
||||||
|
blockHex: "141234FFFFFFFFFF"
|
||||||
|
}
|
||||||
|
}, null, 4),
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Translate PIN block",
|
||||||
|
args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Generate card validation data: known CVV2 sample",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
expectedOutput: "221",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Generate card validation data",
|
||||||
|
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verify card validation data: known CVV2 sample",
|
||||||
|
input: "0123456789ABCDEFFEDCBA9876543210",
|
||||||
|
expectedOutput: JSON.stringify({
|
||||||
|
profile: "CVV2 / CVC2 (force 000)",
|
||||||
|
pan: "4123456789012345",
|
||||||
|
expiry: "0225",
|
||||||
|
expiryLayout: "MMYY",
|
||||||
|
serviceCode: "000",
|
||||||
|
digitCount: 3,
|
||||||
|
inputDigits: "41234567890123450225000000000000",
|
||||||
|
resultHex: "D2D21E5FA3030D91",
|
||||||
|
decimalized: "22153",
|
||||||
|
validationData: "221",
|
||||||
|
expectedValue: "221",
|
||||||
|
valid: true
|
||||||
|
}, null, 4),
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Verify card validation data",
|
||||||
|
args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Generate EMV ARQC: AES-CMAC profile",
|
||||||
|
input: "000102030405060708090A0B0C0D0E0F",
|
||||||
|
expectedOutput: "C1F732B52FB20CAA",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Generate EMV ARQC",
|
||||||
|
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Generate EMV ARPC: AES-CMAC profile",
|
||||||
|
input: "11223344556677889900AABBCCDDEEFF",
|
||||||
|
expectedOutput: "312442B1A4D64F94",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Generate EMV ARPC",
|
||||||
|
args: ["00112233445566778899AABBCCDDEEFF", 8, false]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Derive ECDH key material: raw shared secret",
|
||||||
|
input: ecdhPrivateKey,
|
||||||
|
expectedOutput: "4BE993A2D1BD25C7B5A625EDEBE48D022557ACA445C60EE403ECE9BA38A41CFE",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Derive ECDH key material",
|
||||||
|
args: ["PEM", "P-256", "PEM", ecdhPeerPublicKey, "None", 32, "", "Hex"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Derive ECDH key material: SEC1 EC private key PEM",
|
||||||
|
input: ecdhPrivateKeySec1,
|
||||||
|
expectedOutput: "4BE993A2D1BD25C7B5A625EDEBE48D022557ACA445C60EE403ECE9BA38A41CFE",
|
||||||
|
recipeConfig: [
|
||||||
|
{
|
||||||
|
op: "Derive ECDH key material",
|
||||||
|
args: ["PEM", "P-256", "PEM", ecdhPeerPublicKey, "None", 32, "", "Hex"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
Loading…
x
Reference in New Issue
Block a user