diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 0000000000..d04894ecad --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,85 @@ +name: Build and Deploy +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set node version + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + + - name: Install + run: | + npm ci + npm run setheapsize + + - name: Lint + run: npx grunt lint + + - name: Unit Tests + run: | + npm test + npm run testnodeconsumer + + - name: Production Build + if: success() + run: npx grunt prod + + - name: Upload Build Artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: zipped-build + path: build/prod/*.zip + retention-days: 1 + + - name: Configure AWS credentials + if: success() + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Unzip build + if: success() + run: unzip build/prod/*.zip -d build/unpacked + + - name: Install brotli + if: success() + run: sudo apt-get install -y brotli + + - name: Decompress build + if: success() + run: | + find build/unpacked -name "*.gz" -exec sh -c 'gunzip -f "$1"' _ {} \; + find build/unpacked -name "*.br" -exec sh -c 'brotli -d -f "$1"' _ {} \; + + - name: Sync to S3 + if: success() + run: | + aws s3 sync build/unpacked/ s3://${{ secrets.S3_BUCKET_NAME }} \ + --delete \ + --cache-control "max-age=86400" + + - name: Invalidate CloudFront + if: success() + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" diff --git a/.github/workflows/sync_upstream.yml b/.github/workflows/sync_upstream.yml new file mode 100644 index 0000000000..37af5b4432 --- /dev/null +++ b/.github/workflows/sync_upstream.yml @@ -0,0 +1,25 @@ +name: Sync Upstream +on: + schedule: + - cron: '0 6 * * 1' # weekly, Monday 6am UTC + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT_TOKEN }} + fetch-depth: 0 + + - name: Sync upstream + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git remote add upstream https://github.com/gchq/CyberChef.git + git fetch upstream + git merge upstream/master + git checkout HEAD -- .github/workflows/ + git commit --amend --no-edit + git push origin master diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..0b3188f9ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# Repo Working Notes + +## Test And Debugging Baseline + +- Use Docker/Linux for installs, builds, and tests by default. +- Treat the CI environment as the source of truth: + - Ubuntu/Linux + - Node 24 + - `npm ci` + - `npm test` +- **Before committing any JS/MJS change, run the full lint suite in Docker:** + ``` + npx grunt eslint + ``` + This runs all five targets: `eslint:configs`, `eslint:core`, `eslint:web`, `eslint:node`, `eslint:tests`. Running only `eslint:core` misses errors in test files and other targets. Do not push without a clean lint run. +- Dev server with auto-rebuild: `npm start` (port 8080). Production build: `npm run build` (output in `build/prod/`). If the production build OOMs, set `NODE_OPTIONS=--max_old_space_size=2048`. +- **Do not run `npm run build` or `npm start` on Windows.** The local Node version is not guaranteed to match CI and webpack builds will silently fail or produce wrong output. Build verification belongs in Docker/Linux CI only. +- Do not spend time fixing Windows-only runtime or dependency issues unless explicitly requested. +- Do not commit repo changes whose only purpose is to make local Windows execution work. +- If a failure appears only in the local Windows shell, do not treat it as a code regression until it reproduces in Docker/Linux. +- When Docker is unavailable, restore Docker availability first rather than switching to Windows-specific debugging. + +## Session Start + +- At the start of a session, sync with `origin/master` before doing substantive work. +- Preferred command: `git pull --rebase origin master` +- Only do this automatically when the worktree is clean. If local changes are already present, inspect before rebasing. + +## Code Style + +Follow `CONTRIBUTING.md` coding conventions: 4-space indentation, CamelCase class identifiers, camelCase function/variable names, UNDERSCORE_UPPER_CASE constants, UTF-8 source encoding, UNIX line endings, all files end with a newline. + +## Commit Scope + +- Keep commits small and reviewable by default. +- Prefer one commit per logical change — a single coherent unit a reviewer can evaluate independently. +- Group related changes (e.g., a new feature + its test + the knowledge-base entry it required) into one commit when they can't be evaluated independently. +- Prefer squash or amend for iterative follow-ups — if a second commit only fixes or extends the immediately preceding one, squash rather than leaving noise in the log. +- Do not split a change just to make it look smaller; split when a reviewer would genuinely benefit from evaluating the pieces independently. +- When CI flags a lint or test failure after a push, fix locally and **amend or squash into the failing commit** (using `git push --force-with-lease`) rather than adding a new fix commit on top. + +## APC Cross-Reference (Standing Instruction) + +Whenever a payment cryptography question arises — algorithm behavior, key types, format support, operation design, test vector validation — **first query the APC-agent MCP tools** to check what AWS Payment Cryptography exposes for that operation. Use ToolSearch to load the relevant tool before calling it. + +If the data is not available via APC-agent (endpoint doesn't exist, key-mode constraint, API gap), **treat that as a documented gap** — file a GitHub issue at `J8k3/CyberChef` capturing the operation, what was tried, and what's needed to close it. Do not guess APC behavior from training data; use the live tools. + +**Knowledge contribution (reciprocal):** When this session surfaces new payment domain knowledge — a PCI rule, an algorithm edge case, an APC API constraint, an HSM command mapping — write it back into the MCP server in the same session: `payment-knowledge-base.md` for domain facts, `hsm_analysis.py` for HSM commands, `compliance.py` for enforcement rules. Do not defer. The two repos are a knowledge loop: CyberChef proves behavior in tests; the MCP server codifies it for LLM consumption. + +This check is for internal development and validation only. APC must never appear in CyberChef UI text (operation names, descriptions, inline help, arg labels). + +## Security Constraint + +**Never mention AWS, APC, or AWS Payment Cryptography in any CyberChef UI-facing text.** This includes operation names, descriptions, arg labels, inline help text, and output strings. Internal code comments and test file comments are fine. + +## ESLint + +- Continuation lines inside `args: [` must be aligned to **23 spaces** +- All module-level functions require JSDoc (`jsdoc/require-jsdoc`). Constructors must have their own JSDoc — either `/** @inheritdoc */` or a named comment block. The class-level JSDoc does not satisfy this. +- No unused imports +- No inline single-line blocks: `try { x; } catch` or `if (x) { y; }` — statement and closing brace must each be on their own line (`brace-style` rule) +- Ternary `?` and `:` must be at the **end** of the line, not the start (`operator-linebreak` rule). Write `condition ?\n a :\n b` not `condition\n ? a\n : b`. + +## Payment Operation Maintenance + +After completing any substantive payment operation work, ask: *"Did I learn anything in this session that isn't captured in AGENTS.md?"* If yes, add it before committing. + +**Before committing any new or changed payment operation, verify all of the following are in the same commit:** +- `PAYMENT_RECIPES.md` Operation Registry updated (add/rename/remove the op name) +- `J8k3/CyberChef-Payments` README updated if the op appears in the recipe catalog or validation table +- APC-agent queried for the relevant endpoint and any gap documented +- Tests passing + +Do not commit the operation first and defer docs or APC check to a follow-up. If the user has to ask whether the docs were updated, the process was not followed. + +`PAYMENT_RECIPES.md` is the **developer reference** for this repo: naming conventions, operation registry, and raw APC test vectors. The user-facing recipe catalog, chaining patterns, and validation status live in [J8k3/CyberChef-Payments](https://github.com/J8k3/CyberChef-Payments). Do not duplicate catalog content in `PAYMENT_RECIPES.md`. + +When adding, renaming, or removing a payment operation: + +1. **Update `PAYMENT_RECIPES.md` Operation Registry** — add, rename, or remove the operation name from the relevant domain group. Remove or mark deprecated any operations that are replaced. +2. **Follow the naming convention** — all payment operation display names use Title Case. Acronyms (DUKPT, AES, EMV, MAC, PAN, TR-31, TR-34, KCV) stay upper-case. Brand names keep their canonical form (`payShield`). Pattern: `[Domain Prefix] [Verb] [Qualifier]` — the domain/protocol prefix comes first so operations sort and scan by topic in the UI list. Example: `EMV Verify MAC`, `DUKPT Derive TDES Key`, `PIN Block Parse`. When a vendor name is a sub-specifier of a PIN method, embed it after the PIN domain prefix: `PIN IBM 3624 Offset Generate`, `PIN IBM 3624 Verify`. See the Naming Convention section in `PAYMENT_RECIPES.md`. **Also update the `J8k3/CyberChef-Payments` README** if the op appears in the recipe catalog. +3. **Only operations written for this fork belong in the Payments category** — do not add upstream CyberChef ops (AES Encrypt, HMAC, CMAC, Triple DES Encrypt, AES Key Wrap, etc.) even as convenience shortcuts. If an op wasn't authored here, it stays in its own upstream category only. +4. **Keep `this.name` and file name consistent** — the CyberChef UI shows `this.name`; the file name is the class name in PascalCase. Both should reflect the same intent. +5. **Do not rename `this.name` without updating `PAYMENT_RECIPES.md`** — stale names in the doc are confusing and break recipe search. +6. **Review and update `this.description`, `this.inlineHelp`, and `this.testDataSamples`** whenever changing a recipe — operation descriptions, inline help text, and sample args must stay consistent with the current arg list and behavior. A renamed arg, added arg, or changed default silently breaks the tooltip if the description still references the old shape. +7. **Regenerate the build config after any add, rename, or delete** — three files are gitignored and auto-generated; editing `this.name` or `Categories.json` alone is not enough: + - `src/core/operations/index.mjs` — full op list; built by `generateOpsIndex.mjs` + - `src/core/config/modules/Payment.mjs` — maps `this.name` → constructor for the Payment module chunk; built by `generateConfig.mjs` + - `src/core/config/OperationConfig.json` — op metadata for the UI + Run from the project root after any op change: + ``` + node src/core/config/scripts/generateOpsIndex.mjs && node src/core/config/scripts/generateConfig.mjs + ``` + Or `npx grunt dev` / `npx grunt prod`, which runs both steps automatically. CI runs them on every build. **Symptom of a stale registry:** `TypeError: f[e.module][e.name] is not a constructor` at runtime. + **Grunt alias:** if using grunt tasks directly, the correct task is `npx grunt exec:generateConfig`. `npx grunt exec:generateNodeIndex` is a *different* task — it only regenerates the Node API wrapper (`src/node/index.mjs`) and does NOT update `OperationConfig.json` or `modules/Payment.mjs`. + diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md new file mode 100644 index 0000000000..74490d0d55 --- /dev/null +++ b/PAYMENT_RECIPES.md @@ -0,0 +1,167 @@ +# Payment Operations Reference + +Owner: +- Jacob Marks, `https://jacobmarks.com` +- Fork home: `https://github.com/J8k3/CyberChef` + +**User-facing workflow catalog, screenshots, and recipe links:** [J8k3/CyberChef-Payments](https://github.com/J8k3/CyberChef-Payments) + +This file is the **developer reference** for the implementation repo. It covers naming conventions, the operation registry, and raw APC comparison test data. Do not duplicate recipe catalog content here — maintain it in CyberChef-Payments to avoid divergence. + +--- + +## Naming Convention + +All payment operation display names follow **Title Case** throughout. Acronyms (DUKPT, AES, EMV, MAC, PAN, PVV, KCV, ARQC, ARPC, TR-31, TR-34) are always upper-case. Brand names retain their canonical capitalisation (`payShield`). + +Pattern: `[Domain Prefix] [Verb] [Qualifier]` +- Domain prefixes: EMV, DUKPT, PIN Block, PIN Data, PIN IBM 3624, PAN, Card Validation Data, VISA PVV, AS2805, HSM, Payment, MAC, Key, TR-31, TR-34 +- Verbs: Generate, Verify, Parse, Build, Translate, Derive, Calculate, Encrypt, Decrypt, Re-Encrypt +- The prefix comes first so operations sort and scan by topic in the UI list +- Only operations authored in this fork belong in the Payments category — do not add upstream CyberChef ops + +## UI Arrangement + +The `Payments` category is sorted alphabetically. The domain-prefix naming convention means related operations naturally cluster together in the list (all EMV ops together, all PIN Block ops together, etc.). + +--- + +## Operation Registry + +Operations in the **Payments** category, grouped by domain. Update this list when adding, renaming, or removing an operation. + +**Encrypt / Decrypt** +- `Payment Encrypt Data` +- `Payment Decrypt Data` +- `Payment Re-Encrypt Data` + +**MAC** +- `MAC Generate` +- `MAC Verify` + +**EMV** +- `EMV Build ARQC Data` +- `EMV Parse ARQC Data` +- `EMV Generate ARQC` +- `EMV Verify ARQC` +- `EMV Build ARPC Data` +- `EMV Parse ARPC Data` +- `EMV Generate ARPC` +- `EMV Build Script Data` +- `EMV Build PIN Change Script Data` +- `EMV Generate MAC` +- `EMV Verify MAC` +- `EMV Generate MAC (PIN Change)` +- `EMV Parse TLV` + +**Card Validation** +- `Card Validation Data Generate` +- `Card Validation Data Verify` +- `PAN Generate` +- `PAN Parse` + +**PIN** +- `PIN Block Build` +- `PIN Block Parse` +- `PIN Block Translate` +- `PIN Block Translate Encrypted` +- `PIN Data Generate` +- `PIN Data Verify` +- `PIN IBM 3624 Offset Generate` +- `PIN IBM 3624 Verify` +- `VISA PVV Generate` +- `VISA PVV Verify` + +**DUKPT** +- `DUKPT Derive TDES Key` +- `DUKPT Derive AES Key` + +**Key Management** +- `Key Generate` +- `Key Component Split` +- `Key Component Combine` +- `Payment Calculate KCV` +- `Derive ECDH Key Material` +- `AS2805 Generate KEK Validation` + +**Key Containers / HSM** +- `TR-31 Parse Key Block` +- `TR-34 Parse Key Transport` +- `HSM Parse Thales Command` +- `HSM Parse Futurex Command` + +--- + +## APC Comparison Testing + +Performed 2026-05-19. HSM-style operations compared against AWS Payment Cryptography (APC) where APC exposed comparable behavior, using fixed test vectors imported as APC managed keys. + +### Test Vectors + +| Name | Hex | +|---|---| +| `tdes_bdk` | `0123456789ABCDEFFEDCBA9876543210` | +| `aes_bdk` | `FEDCBA98765432100123456789ABCDEF` | +| `tdes_dek1` | `0101010101010101FEFEFEFEFEFEFEFE` | +| `tdes_dek2` | `FEFEFEFEFEFEFEFE0101010101010101` | +| `tdes_m3` | `1111111111111111AAAAAAAAAAAAAAAA` | +| `aes_m6` | `000102030405060708090A0B0C0D0E0F` | +| `visa_pvk` | `AAAABBBBCCCCDDDDEEEEFFFFAAAABBBB` | +| `ibm3624_pvk` | `BBBBCCCCDDDDEEEEFFFFAAAABBBBCCCC` | +| `cvk` | `CCCCDDDDEEEEFFFFAAAABBBBCCCCDDDD` | +| `emv_e0` | `101112131415161718191A1B1C1D1E1F` | +| `tdes_pek` | `DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE` | +| plaintext | `0102030405060708` | +| mac\_msg | `0102030405060708090A0B0C` | +| pan\_cvv | `4123456789012345` | +| pan\_pvv | `5432101234567890` | +| service\_code | `101` | +| pin | `1234` | +| ksn\_tdes | `FFFF9876543210E00001` | +| ksn\_aes | `123456789012345600000001` | +| atc | `0001` | + +### Results + +| Operation | CyberChef Output | APC Output | Status | Notes | +|---|---|---|---|---| +| Payment Encrypt Data (TDES ECB) | `B064B6C2571C65D5` | `B064B6C2571C65D5` | ✅ MATCH | | +| Payment Decrypt Data (TDES ECB) | `0102030405060708` | `0102030405060708` | ✅ MATCH | | +| Payment Re-Encrypt Data | — | API error | ❌ BLOCKED | D0 keys with `NoRestrictions` rejected by APC `re_encrypt_data` — key-mode constraint, not a CyberChef bug | +| MAC Generate (ISO 9797-3, Method 1) | `D8749ECF9A6C6932` | `D8749ECF9A6C6932` | ✅ MATCH | | +| MAC Verify (ISO 9797-3) | — | PASS | ✅ | | +| MAC Generate (AES-CMAC) | `E330EE80C0D43370` | `E330EE80C0D43370` | ✅ MATCH | | +| MAC Verify (AES-CMAC) | — | PASS | ✅ | | +| EMV Generate MAC | `BEB0A99CA833D7C8` (Method 2) | `1C36D79CE0F2F832` | ⚠️ METHOD DEPENDENT | Default (Method 2) does not match. Select Method 1 in the padding method arg for output that aligns with systems using zero-pad. | +| EMV Generate MAC (PIN Change) | `3D9E060686858CC0` | N/A | ⚠️ N/A | APC has no direct equivalent endpoint | +| Card Validation Data Generate (CVV) | `703` | `703` | ✅ MATCH | | +| Card Validation Data Generate (CVV2) | `111` | `111` | ✅ MATCH | | +| Card Validation Data Verify | — | PASS | ✅ | | +| VISA PVV Generate | `5596` (visa_pvk) / `6776` (test key) | `5596` / `6776` (verify path) | ✅ MATCH | APC `generate_pin_data` blocked by compliance warning; cross-validated via `verify_pin_data` for both keys | +| VISA PVV Verify | — | PASS | ✅ | Both `visa_pvk` (KCV AAAABBBB…) and test key `0123456789ABCDEF…` (KCV 08D7B4) confirmed via APC `verify_pin_data` | +| PIN IBM 3624 Offset Generate | `0324` | `0324` (verify path) | ✅ MATCH | Cross-validated via APC `verify_pin_data` | +| PIN IBM 3624 Verify | — | PASS | ✅ | | +| EMV Generate ARQC | `8C8E19CED4DBBF59` | AES-128 rejected | ❌ BLOCKED | APC `verify_auth_request_cryptogram` requires AES-256 E0 key; AES-128 rejected. CyberChef implementation (AES-CMAC, Option A session-key derivation) is correct | +| DUKPT Derive TDES Key | IPEK `6AC292FAA1315B4D858AB3A3D7D5933A` | N/A | ✅ VERIFIED | Matches published ANSI X9.24-1 test vector | +| DUKPT Derive AES Key | IK/working keys derived | N/A | ✅ VERIFIED | Verified against ANSI X9.24-3 §6.3 official test vectors; APC does not expose derived intermediate keys for direct comparison | +| DUKPT TDES Encrypt (Payment Encrypt Data) | `92A5157E4607D1B0` | `124F7A32F3F84187` | ❌ VARIANT MISMATCH | CyberChef follows ANSI X9.24-1 "Data" variant (bytes 5+13 XOR `0xFF`); APC uses an undocumented internal variant for data encryption | +| DUKPT TDES MAC (MAC Generate) | `AF59E7E8A06F01B2` | `AF59E7E8A06F01B2` | ✅ MATCH | APC `DukptKeyVariant=REQUEST` aligns with CyberChef "MAC Request" | + +### Key Findings + +- **APC `mac_length` is in nibbles (hex digits), not bytes** — pass `16` to get an 8-byte MAC. +- **EMV Generate MAC padding method** — `EMV Generate MAC` defaults to Method 2 (ISO 7816-4; standard for EMV issuer scripts). Select Method 1 (zero pad) when the receiving system requires it. Both generate and verify must use the same method. +- **DUKPT TDES data encryption variant** — CyberChef follows ANSI X9.24-1 standard (bytes 5 and 13 XOR `0xFF` for the "Data" variant). APC applies a different undocumented internal variant. DUKPT MAC operations align correctly (both use MAC Request / `REQUEST` variant). +- **EMV ARQC requires AES-256 on APC** — `verify_auth_request_cryptogram` rejects AES-128 E0 keys. If testing ARQC against APC, an AES-256 E0 master key is required. CyberChef's AES-CMAC + Option A session-key derivation is standard-compliant. +- **Re-encrypt key mode constraint** — D0 keys imported into APC with `NoRestrictions: true` are blocked by `re_encrypt_data`. This is an APC API constraint, not a CyberChef limitation. + +--- + +### References + +- AWS Payment Cryptography Data Plane API: https://docs.aws.amazon.com/payment-cryptography/latest/DataAPIReference/Welcome.html +- AWS MAC overview: https://docs.aws.amazon.com/payment-cryptography/latest/userguide/crypto-ops-mac.html +- NIST SP 800-38B CMAC: https://csrc.nist.gov/pubs/sp/800/38/b/upd1/final +- RFC 3394 AES Key Wrap: https://www.rfc-editor.org/rfc/rfc3394 +- Discover public test-card page: https://www.discoverglobalnetwork.com/resources/businesses/check-your-card-reader/ +- Mastercard AVS test scenarios: https://static.developer.mastercard.com/content/mastercard-send-avs/uploads/avs-test-case-scenario-v4.pdf diff --git a/README.md b/README.md index 6da7d3ec6c..1281719fa8 100755 --- a/README.md +++ b/README.md @@ -1,33 +1,73 @@ -# CyberChef +# CyberChef - Payments -[![](https://github.com/gchq/CyberChef/workflows/Master%20Build,%20Test%20&%20Deploy/badge.svg)](https://github.com/gchq/CyberChef/actions?query=workflow%3A%22Master+Build%2C+Test+%26+Deploy%22) -[![npm](https://img.shields.io/npm/v/cyberchef.svg)](https://www.npmjs.com/package/cyberchef) +This fork extends CyberChef with workflow-oriented payment cryptography tooling for engineering, debugging, interoperability, development, QA, and standards exploration — including for systems built for regulated payment environments. The upstream CyberChef is merged weekly. + +[![](https://github.com/J8k3/CyberChef/workflows/Build%20and%20Deploy/badge.svg)](https://github.com/gchq/CyberChef/actions?query=workflow%3A%22Master+Build%2C+Test+%26+Deploy%22) [![](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/gchq/CyberChef/blob/master/LICENSE) -[![Gitter](https://badges.gitter.im/gchq/CyberChef.svg)](https://gitter.im/gchq/CyberChef?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +**[cyberchef.jacobmarks.com][1]** — live demo + +- Full workflow library with screenshots and recipe catalog: **[J8k3/CyberChef-Payments](https://github.com/J8k3/CyberChef-Payments)** +- Payment domain knowledge: **[J8k3/Payments](https://github.com/J8k3/Payments)** + +--- + +## What this fork adds + +All payment operations appear in the CyberChef UI under the **Payments** category. Source: `src/core/operations/`. -#### *The Cyber Swiss Army Knife* +- Pipeline-inspectable workflow tooling for EMV, PIN, DUKPT, MAC, key management, and HSM command parsing +- Operations composable with CyberChef’s full recipe model — chain, breakpoint, share as URL +- Local-first: all computation runs in the browser, no cloud account or HSM needed +- Weekly upstream sync with [gchq/CyberChef](https://github.com/gchq/CyberChef) -CyberChef is a simple, intuitive web app for carrying out all manner of "cyber" operations within a web browser. These operations include simple encoding like XOR and Base64, more complex encryption like AES, DES and Blowfish, creating binary and hexdumps, compression and decompression of data, calculating hashes and checksums, IPv6 and X.509 parsing, changing character encodings, and much more. +## Scope -The tool is designed to enable both technical and non-technical analysts to manipulate data in complex ways without having to deal with complex tools or algorithms. It was conceived, designed, built and incrementally improved by an analyst in their 10% innovation time over several years. +Current payment operation coverage: -## Live demo +- EMV ARQC/ARPC generation and verification; issuer-script MAC and PIN change +- PIN block build, parse, and encrypted translation between zone keys (ISO 9564 formats 0, 1, 3) +- DUKPT TDES key derivation (ANSI X9.24-1, 10-byte KSN, IPEK-based) +- DUKPT AES key derivation (ANSI X9.24-3, 12-byte KSN, IK-based, AES-128) +- MAC: AES-CMAC, TDES-CMAC, HMAC, ISO 9797-1, AS2805, DUKPT variants +- Card validation data: CVV/CVC, CVV2/CVC2, iCVV; IBM 3624 PIN offset; VISA PVV +- PAN generation and parsing across major card networks +- Key management: generation, KCV, component split/combine, ECDH, TR-31/TR-34 parsing +- HSM command parsing: Thales payShield and Futurex Excrypt transport-syntax triage -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! +## Validation + +These extensions emulate payment HSM-style workflows and may not model every vendor-specific edge case. Validation focuses on standards alignment, known vectors, and comparison with AWS Payment Cryptography behavior where comparable APIs are available. Cryptographic operations in CyberChef should not be relied upon to provide security in any situation. No guarantee is offered for their correctness. -[A live demo can be found here][1] - have fun! +## Non-goals + +- Not a certified HSM or PCI-scoped control +- Not a replacement for production cryptographic infrastructure +- Not intended for use with production keys, real PANs, or live PIN blocks + +## Representative recipes + +A small selection — for the full workflow library with walkthroughs, screenshots, and cross-validation notes, see **[J8k3/CyberChef-Payments](https://github.com/J8k3/CyberChef-Payments)**. + + - [EMV: generate ARQC][p05] + - [EMV: generate ARPC issuer response][p07] + - [DUKPT TDES: derive IPEK from BDK][p12] + - [PIN Block Translate Encrypted: re-key between ZPKs][p25] + - [Card validation: generate CVV2][p22] + - [HSM: parse Thales payShield command][p16] + - [TR-31: parse and inspect key block][p15] -## Running Locally with Docker +[A live demo can be found at cyberchef.jacobmarks.com][1] + +## Developing/Running Locally with Docker **Prerequisites** - [Docker](https://www.docker.com/products/docker-desktop/) - Docker Desktop must be open and running on your machine - #### Option 1: Build the Docker Image Yourself 1. Build the docker image @@ -40,18 +80,6 @@ docker run -it -p 8080:8080 cyberchef ``` 3. Navigate to `http://localhost:8080` in your browser -#### Option 2: Use the pre-built Docker Image - -If you prefer to skip the build process, you can use the pre-built image - -```bash -docker run -it -p 8080:8080 ghcr.io/gchq/cyberchef:latest -``` - -Just like before, navigate to `http://localhost:8080` in your browser. - -This image is built and published through our [GitHub Workflows](.github/workflows/releases.yml) - ## How it works There are four main areas in CyberChef: @@ -105,7 +133,7 @@ You can use as many operations as you like in simple or complex ways. Some examp ## Deep linking By manipulating CyberChef's URL hash, you can change the initial settings with which the page opens. -The format is `https://gchq.github.io/CyberChef/#recipe=Operation()&input=...` +The format is `https://cyberchef.jacobmarks.com/#recipe=Operation()&input=...` Supported arguments are `recipe`, `input` (encoded in Base64), and `theme`. @@ -133,20 +161,44 @@ An installation walkthrough, how-to guides for adding new operations and themes, - Submit a pull request. If you are doing this for the first time, you will be prompted to sign the [GCHQ Contributor Licence Agreement](https://cla-assistant.io/gchq/CyberChef) via the CLA assistant on the pull request. This will also ask whether you are happy for GCHQ to contact you about a token of thanks for your contribution, or about job opportunities at GCHQ. -## Licencing +## Licensing CyberChef is released under the [Apache 2.0 Licence](https://www.apache.org/licenses/LICENSE-2.0) and is covered by [Crown Copyright](https://www.nationalarchives.gov.uk/information-management/re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/). - [1]: https://gchq.github.io/CyberChef - [2]: https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true)&input=VTI4Z2JHOXVaeUJoYm1RZ2RHaGhibXR6SUdadmNpQmhiR3dnZEdobElHWnBjMmd1 - [3]: https://gchq.github.io/CyberChef/#recipe=Translate_DateTime_Format('Standard%20date%20and%20time','DD/MM/YYYY%20HH:mm:ss','UTC','dddd%20Do%20MMMM%20YYYY%20HH:mm:ss%20Z%20z','Australia/Queensland')&input=MTUvMDYvMjAxNSAyMDo0NTowMA - [4]: https://gchq.github.io/CyberChef/#recipe=Parse_IPv6_address()&input=MjAwMTowMDAwOjQxMzY6ZTM3ODo4MDAwOjYzYmY6M2ZmZjpmZGQy - [5]: https://gchq.github.io/CyberChef/#recipe=From_Hexdump()Gunzip()&input=MDAwMDAwMDAgIDFmIDhiIDA4IDAwIDEyIGJjIGYzIDU3IDAwIGZmIDBkIGM3IGMxIDA5IDAwIDIwICB8Li4uLi6881cu/y7HwS4uIHwKMDAwMDAwMTAgIDA4IDA1IGQwIDU1IGZlIDA0IDJkIGQzIDA0IDFmIGNhIDhjIDQ0IDIxIDViIGZmICB8Li7QVf4uLdMuLsouRCFb/3wKMDAwMDAwMjAgIDYwIGM3IGQ3IDAzIDE2IGJlIDQwIDFmIDc4IDRhIDNmIDA5IDg5IDBiIDlhIDdkICB8YMfXLi6%2BQC54Sj8uLi4ufXwKMDAwMDAwMzAgIDRlIGM4IDRlIDZkIDA1IDFlIDAxIDhiIDRjIDI0IDAwIDAwIDAwICAgICAgICAgICB8TshObS4uLi5MJC4uLnw - [6]: https://gchq.github.io/CyberChef/#recipe=RC4(%7B'option':'UTF8','string':'secret'%7D,'Hex','Hex')Disassemble_x86('64','Full%20x86%20architecture',16,0,true,true)&input=MjFkZGQyNTQwMTYwZWU2NWZlMDc3NzEwM2YyYTM5ZmJlNWJjYjZhYTBhYWJkNDE0ZjkwYzZjYWY1MzEyNzU0YWY3NzRiNzZiM2JiY2QxOTNjYjNkZGZkYmM1YTI2NTMzYTY4NmI1OWI4ZmVkNGQzODBkNDc0NDIwMWFlYzIwNDA1MDcxMzhlMmZlMmIzOTUwNDQ2ZGIzMWQyYmM2MjliZTRkM2YyZWIwMDQzYzI5M2Q3YTVkMjk2MmMwMGZlNmRhMzAwNzJkOGM1YTZiNGZlN2Q4NTlhMDQwZWVhZjI5OTczMzYzMDJmNWEwZWMxOQ - [7]: https://gchq.github.io/CyberChef/#recipe=Fork('%5C%5Cn','%5C%5Cn',false)From_UNIX_Timestamp('Seconds%20(s)')&input=OTc4MzQ2ODAwCjEwMTI2NTEyMDAKMTA0NjY5NjQwMAoxMDgxMDg3MjAwCjExMTUzMDUyMDAKMTE0OTYwOTYwMA - [8]: https://gchq.github.io/CyberChef/#recipe=Fork('%5C%5Cn','%5C%5Cn',false)Conditional_Jump('1',false,'base64',10)To_Hex('Space')Return()Label('base64')To_Base64('A-Za-z0-9%2B/%3D')&input=U29tZSBkYXRhIHdpdGggYSAxIGluIGl0ClNvbWUgZGF0YSB3aXRoIGEgMiBpbiBpdA - [9]: https://gchq.github.io/CyberChef/#recipe=Register('key%3D(%5B%5C%5Cda-f%5D*)',true,false)Find_/_Replace(%7B'option':'Regex','string':'.*data%3D(.*)'%7D,'$1',true,false,true)RC4(%7B'option':'Hex','string':'$R0'%7D,'Hex','Latin1')&input=aHR0cDovL21hbHdhcmV6LmJpei9iZWFjb24ucGhwP2tleT0wZTkzMmE1YyZkYXRhPThkYjdkNWViZTM4NjYzYTU0ZWNiYjMzNGUzZGIxMQ - [10]: https://gchq.github.io/CyberChef/#recipe=Register('(.%7B32%7D)',true,false)Drop_bytes(0,32,false)AES_Decrypt(%7B'option':'Hex','string':'1748e7179bd56570d51fa4ba287cc3e5'%7D,%7B'option':'Hex','string':'$R0'%7D,'CTR','Hex','Raw',%7B'option':'Hex','string':''%7D)&input=NTFlMjAxZDQ2MzY5OGVmNWY3MTdmNzFmNWI0NzEyYWYyMGJlNjc0YjNiZmY1M2QzODU0NjM5NmVlNjFkYWFjNDkwOGUzMTljYTNmY2Y3MDg5YmZiNmIzOGVhOTllNzgxZDI2ZTU3N2JhOWRkNmYzMTFhMzk0MjBiODk3OGU5MzAxNGIwNDJkNDQ3MjZjYWVkZjU0MzZlYWY2NTI0MjljMGRmOTRiNTIxNjc2YzdjMmNlODEyMDk3YzI3NzI3M2M3YzcyY2Q4OWFlYzhkOWZiNGEyNzU4NmNjZjZhYTBhZWUyMjRjMzRiYTNiZmRmN2FlYjFkZGQ0Nzc2MjJiOTFlNzJjOWU3MDlhYjYwZjhkYWY3MzFlYzBjYzg1Y2UwZjc0NmZmMTU1NGE1YTNlYzI5MWNhNDBmOWU2MjlhODcyNTkyZDk4OGZkZDgzNDUzNGFiYTc5YzFhZDE2NzY3NjlhN2MwMTBiZjA0NzM5ZWNkYjY1ZDk1MzAyMzcxZDYyOWQ5ZTM3ZTdiNGEzNjFkYTQ2OGYxZWQ1MzU4OTIyZDJlYTc1MmRkMTFjMzY2ZjMwMTdiMTRhYTAxMWQyYWYwM2M0NGY5NTU3OTA5OGExNWUzY2Y5YjQ0ODZmOGZmZTljMjM5ZjM0ZGU3MTUxZjZjYTY1MDBmZTRiODUwYzNmMWMwMmU4MDFjYWYzYTI0NDY0NjE0ZTQyODAxNjE1YjhmZmFhMDdhYzgyNTE0OTNmZmRhN2RlNWRkZjMzNjg4ODBjMmI5NWIwMzBmNDFmOGYxNTA2NmFkZDA3MWE2NmNmNjBlNWY0NmYzYTIzMGQzOTdiNjUyOTYzYTIxYTUzZg - [11]: https://gchq.github.io/CyberChef/#recipe=XOR(%7B'option':'Hex','string':'3a'%7D,'Standard',false)To_Hexdump(16,false,false)&input=VGhlIGFuc3dlciB0byB0aGUgdWx0aW1hdGUgcXVlc3Rpb24gb2YgbGlmZSwgdGhlIFVuaXZlcnNlLCBhbmQgZXZlcnl0aGluZyBpcyA0Mi4 - [12]: https://gchq.github.io/CyberChef/#recipe=Magic(3,false,false)&input=V1VhZ3dzaWFlNm1QOGdOdENDTFVGcENwQ0IyNlJtQkRvREQ4UGFjZEFtekF6QlZqa0syUXN0RlhhS2hwQzZpVVM3UkhxWHJKdEZpc29SU2dvSjR3aGptMWFybTg2NHFhTnE0UmNmVW1MSHJjc0FhWmM1VFhDWWlmTmRnUzgzZ0RlZWpHWDQ2Z2FpTXl1QlY2RXNrSHQxc2NnSjg4eDJ0TlNvdFFEd2JHWTFtbUNvYjJBUkdGdkNLWU5xaU45aXBNcTFaVTFtZ2tkYk51R2NiNzZhUnRZV2hDR1VjOGc5M1VKdWRoYjhodHNoZVpud1RwZ3FoeDgzU1ZKU1pYTVhVakpUMnptcEM3dVhXdHVtcW9rYmRTaTg4WXRrV0RBYzFUb291aDJvSDRENGRkbU5LSldVRHBNd21uZ1VtSzE0eHdtb21jY1BRRTloTTE3MkFQblNxd3hkS1ExNzJSa2NBc3lzbm1qNWdHdFJtVk5OaDJzMzU5d3I2bVMyUVJQ + [1]: https://cyberchef.jacobmarks.com + [2]: https://cyberchef.jacobmarks.com/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true)&input=VTI4Z2JHOXVaeUJoYm1RZ2RHaGhibXR6SUdadmNpQmhiR3dnZEdobElHWnBjMmd1 + [3]: https://cyberchef.jacobmarks.com/#recipe=Translate_DateTime_Format('Standard%20date%20and%20time','DD/MM/YYYY%20HH:mm:ss','UTC','dddd%20Do%20MMMM%20YYYY%20HH:mm:ss%20Z%20z','Australia/Queensland')&input=MTUvMDYvMjAxNSAyMDo0NTowMA + [4]: https://cyberchef.jacobmarks.com/#recipe=Parse_IPv6_address()&input=MjAwMTowMDAwOjQxMzY6ZTM3ODo4MDAwOjYzYmY6M2ZmZjpmZGQy + [5]: https://cyberchef.jacobmarks.com/#recipe=From_Hexdump()Gunzip()&input=MDAwMDAwMDAgIDFmIDhiIDA4IDAwIDEyIGJjIGYzIDU3IDAwIGZmIDBkIGM3IGMxIDA5IDAwIDIwICB8Li4uLi6881cu/y7HwS4uIHwKMDAwMDAwMTAgIDA4IDA1IGQwIDU1IGZlIDA0IDJkIGQzIDA0IDFmIGNhIDhjIDQ0IDIxIDViIGZmICB8Li7QVf4uLdMuLsouRCFb/3wKMDAwMDAwMjAgIDYwIGM3IGQ3IDAzIDE2IGJlIDQwIDFmIDc4IDRhIDNmIDA5IDg5IDBiIDlhIDdkICB8YMfXLi6%2BQC54Sj8uLi4ufXwKMDAwMDAwMzAgIDRlIGM4IDRlIDZkIDA1IDFlIDAxIDhiIDRjIDI0IDAwIDAwIDAwICAgICAgICAgICB8TshObS4uLi5MJC4uLnw + [6]: https://cyberchef.jacobmarks.com/#recipe=RC4(%7B'option':'UTF8','string':'secret'%7D,'Hex','Hex')Disassemble_x86('64','Full%20x86%20architecture',16,0,true,true)&input=MjFkZGQyNTQwMTYwZWU2NWZlMDc3NzEwM2YyYTM5ZmJlNWJjYjZhYTBhYWJkNDE0ZjkwYzZjYWY1MzEyNzU0YWY3NzRiNzZiM2JiY2QxOTNjYjNkZGZkYmM1YTI2NTMzYTY4NmI1OWI4ZmVkNGQzODBkNDc0NDIwMWFlYzIwNDA1MDcxMzhlMmZlMmIzOTUwNDQ2ZGIzMWQyYmM2MjliZTRkM2YyZWIwMDQzYzI5M2Q3YTVkMjk2MmMwMGZlNmRhMzAwNzJkOGM1YTZiNGZlN2Q4NTlhMDQwZWVhZjI5OTczMzYzMDJmNWEwZWMxOQ + [7]: https://cyberchef.jacobmarks.com/#recipe=Fork('%5C%5Cn','%5C%5Cn',false)From_UNIX_Timestamp('Seconds%20(s)')&input=OTc4MzQ2ODAwCjEwMTI2NTEyMDAKMTA0NjY5NjQwMAoxMDgxMDg3MjAwCjExMTUzMDUyMDAKMTE0OTYwOTYwMA + [8]: https://cyberchef.jacobmarks.com/#recipe=Fork('%5C%5Cn','%5C%5Cn',false)Conditional_Jump('1',false,'base64',10)To_Hex('Space')Return()Label('base64')To_Base64('A-Za-z0-9%2B/%3D')&input=U29tZSBkYXRhIHdpdGggYSAxIGluIGl0ClNvbWUgZGF0YSB3aXRoIGEgMiBpbiBpdA + [9]: https://cyberchef.jacobmarks.com/#recipe=Register('key%3D(%5B%5C%5Cda-f%5D*)',true,false)Find_/_Replace(%7B'option':'Regex','string':'.*data%3D(.*)'%7D,'$1',true,false,true)RC4(%7B'option':'Hex','string':'$R0'%7D,'Hex','Latin1')&input=aHR0cDovL21hbHdhcmV6LmJpei9iZWFjb24ucGhwP2tleT0wZTkzMmE1YyZkYXRhPThkYjdkNWViZTM4NjYzYTU0ZWNiYjMzNGUzZGIxMQ + [10]: https://cyberchef.jacobmarks.com/#recipe=Register('(.%7B32%7D)',true,false)Drop_bytes(0,32,false)AES_Decrypt(%7B'option':'Hex','string':'1748e7179bd56570d51fa4ba287cc3e5'%7D,%7B'option':'Hex','string':'$R0'%7D,'CTR','Hex','Raw',%7B'option':'Hex','string':''%7D)&input=NTFlMjAxZDQ2MzY5OGVmNWY3MTdmNzFmNWI0NzEyYWYyMGJlNjc0YjNiZmY1M2QzODU0NjM5NmVlNjFkYWFjNDkwOGUzMTljYTNmY2Y3MDg5YmZiNmIzOGVhOTllNzgxZDI2ZTU3N2JhOWRkNmYzMTFhMzk0MjBiODk3OGU5MzAxNGIwNDJkNDQ3MjZjYWVkZjU0MzZlYWY2NTI0MjljMGRmOTRiNTIxNjc2YzdjMmNlODEyMDk3YzI3NzI3M2M3YzcyY2Q4OWFlYzhkOWZiNGEyNzU4NmNjZjZhYTBhZWUyMjRjMzRiYTNiZmRmN2FlYjFkZGQ0Nzc2MjJiOTFlNzJjOWU3MDlhYjYwZjhkYWY3MzFlYzBjYzg1Y2UwZjc0NmZmMTU1NGE1YTNlYzI5MWNhNDBmOWU2MjlhODcyNTkyZDk4OGZkZDgzNDUzNGFiYTc5YzFhZDE2NzY3NjlhN2MwMTBiZjA0NzM5ZWNkYjY1ZDk1MzAyMzcxZDYyOWQ5ZTM3ZTdiNGEzNjFkYTQ2OGYxZWQ1MzU4OTIyZDJlYTc1MmRkMTFjMzY2ZjMwMTdiMTRhYTAxMWQyYWYwM2M0NGY5NTU3OTA5OGExNWUzY2Y5YjQ0ODZmOGZmZTljMjM5ZjM0ZGU3MTUxZjZjYTY1MDBmZTRiODUwYzNmMWMwMmU4MDFjYWYzYTI0NDY0NjE0ZTQyODAxNjE1YjhmZmFhMDdhYzgyNTE0OTNmZmRhN2RlNWRkZjMzNjg4ODBjMmI5NWIwMzBmNDFmOGYxNTA2NmFkZDA3MWE2NmNmNjBlNWY0NmYzYTIzMGQzOTdiNjUyOTYzYTIxYTUzZg + [11]: https://cyberchef.jacobmarks.com/#recipe=XOR(%7B'option':'Hex','string':'3a'%7D,'Standard',false)To_Hexdump(16,false,false)&input=VGhlIGFuc3dlciB0byB0aGUgdWx0aW1hdGUgcXVlc3Rpb24gb2YgbGlmZSwgdGhlIFVuaXZlcnNlLCBhbmQgZXZlcnl0aGluZyBpcyA0Mi4 + [12]: https://cyberchef.jacobmarks.com/#recipe=Magic(3,false,false)&input=V1VhZ3dzaWFlNm1QOGdOdENDTFVGcENwQ0IyNlJtQkRvREQ4UGFjZEFtekF6QlZqa0syUXN0RlhhS2hwQzZpVVM3UkhxWHJKdEZpc29SU2dvSjR3aGptMWFybTg2NHFhTnE0UmNmVW1MSHJjc0FhWmM1VFhDWWlmTmRnUzgzZ0RlZWpHWDQ2Z2FpTXl1QlY2RXNrSHQxc2NnSjg4eDJ0TlNvdFFEd2JHWTFtbUNvYjJBUkdGdkNLWU5xaU45aXBNcTFaVTFtZ2tkYk51R2NiNzZhUnRZV2hDR1VjOGc5M1VKdWRoYjhodHNoZVpud1RwZ3FoeDgzU1ZKU1pYTVhVakpUMnptcEM3dVhXdHVtcW9rYmRTaTg4WXRrV0RBYzFUb291aDJvSDRENGRkbU5LSldVRHBNd21uZ1VtSzE0eHdtb21jY1BRRTloTTE3MkFQblNxd3hkS1ExNzJSa2NBc3lzbm1qNWdHdFJtVk5OaDJzMzU5d3I2bVMyUVJQ + [p01]: https://cyberchef.jacobmarks.com/#recipe=PIN_Generate(4,'PIN%20digits','')VISA_PVV_Generate('0123456789ABCDEFFEDCBA9876543210','5432101234567890',1,true) + [p02]: https://cyberchef.jacobmarks.com/#recipe=PIN_Generate(4,'PIN%20digits','')VISA_PVV_Generate('0123456789ABCDEFFEDCBA9876543210','5432101234567890',1,false)VISA_PVV_Verify('0123456789ABCDEFFEDCBA9876543210','5432101234567890',1,'1234',true) + [p03]: https://cyberchef.jacobmarks.com/#recipe=PIN_Generate(4,'PIN%20digits','')PIN_IBM_3624_Offset_Generate('0123456789ABCDEFFEDCBA9876543210','0123456789012345','5432101234567890','F',false) + [p04]: https://cyberchef.jacobmarks.com/#recipe=PIN_Generate(4,'PIN%20digits','')PIN_IBM_3624_Offset_Generate('0123456789ABCDEFFEDCBA9876543210','0123456789012345','5432101234567890','F',false)PIN_IBM_3624_Verify('0123456789ABCDEFFEDCBA9876543210','0123456789012345','5432101234567890','F','1234',true) + [p05]: https://cyberchef.jacobmarks.com/#recipe=EMV_Build_ARQC_Data('000000001000','000000000000','0840','0000000000','0840','260521','00','00000000','5900','0001','Hex')EMV_Generate_ARQC('00112233445566778899AABBCCDDEEFF',8,false) + [p06]: https://cyberchef.jacobmarks.com/#recipe=EMV_Build_ARQC_Data('000000001000','000000000000','0840','0000000000','0840','260521','00','A1B2C3D4','5900','0001','Hex')EMV_Generate_ARQC('00112233445566778899AABBCCDDEEFF',8,false)EMV_Verify_ARQC('00112233445566778899AABBCCDDEEFF',8,'000102030405060708090A0B0C0D0E0F',true) + [p07]: https://cyberchef.jacobmarks.com/#recipe=EMV_Build_ARPC_Data('Method%201%20(Visa/Amex/Discover)','A1B2C3D4E5F60708','5931','00000000','','Hex')EMV_Generate_ARPC('00112233445566778899AABBCCDDEEFF',8,false) + [p08]: https://cyberchef.jacobmarks.com/#recipe=EMV_Build_Script_Data('84','PUT%20DATA','00','42','0102030405060708090A','Hex')EMV_Generate_MAC('0123456789ABCDEFFEDCBA9876543210','Method%202',8,false) + [p09]: https://cyberchef.jacobmarks.com/#recipe=EMV_Verify_MAC('0123456789ABCDEFFEDCBA9876543210','22CB48394DFD1977','Method%202',true)&input=ODQyNDAwMDAwODk5OUU1N0ZEMEY0N0NBQ0UwMDA3 + [p10]: https://cyberchef.jacobmarks.com/#recipe=MAC_Generate('Hex','AES-CMAC','00112233445566778899AABBCCDDEEFF','Hex','','Method%201',8,false)&input=MTEyMjMzNDQ1NTY2Nzc4OA + [p11]: https://cyberchef.jacobmarks.com/#recipe=MAC_Verify('Hex','AES-CMAC','00112233445566778899AABBCCDDEEFF','Hex','','Method%201','339AF1AD1650E908',true)&input=MTEyMjMzNDQ1NTY2Nzc4OA + [p12]: https://cyberchef.jacobmarks.com/#recipe=Key_Generate('TDES%20Double-length%20(16%20bytes)',16,true,false)DUKPT_Derive_TDES_Key('Derive%20IPEK','FFFF9876543210E00008','None',false) + [p13]: https://cyberchef.jacobmarks.com/#recipe=Key_Generate('TDES%20Double-length%20(16%20bytes)',16,true,false)DUKPT_Derive_TDES_Key('Derive%20Session%20Key','FFFF9876543210E00008','PIN',false) + [p14]: https://cyberchef.jacobmarks.com/#recipe=PIN_Generate(4,'PIN%20digits','')PIN_Block_Build('ISO%20Format%200','5432101234567890',false)PIN_Block_Parse('ISO%20Format%200','5432101234567890') + [p15]: https://cyberchef.jacobmarks.com/#recipe=TR-31_Parse_Key_Block(false)&input=RDAxMTJQMEFFMDBFMDAwMDEwRUY5OTkwQzgwMkMzRUM3REEwNEM2OUFENjhBNzFCMjM4ODBEQzZDQTY0QjY0Q0UyRTVGMUE0RDA5NTJBM0E + [p16]: https://cyberchef.jacobmarks.com/#recipe=HSM_Parse_Thales_Command()&input=SEVBREhFMDEyMzQ1Njc4OUFCQ0RFRjAwMTEyMjMzNDQ1NTY2NzclMDBUQUlM + [p17]: https://cyberchef.jacobmarks.com/#recipe=HSM_Parse_Futurex_Command()&input=W0FPR01BQztGUzY7UlYwMDExMjIzMzQ0NTU2Njc3O10 + [p18]: https://cyberchef.jacobmarks.com/#recipe=Key_Generate('AES-128%20(16%20bytes)',16,false,false)Payment_Calculate_KCV('Hex','AES-CMAC%20(Empty)',6) + [p20]: https://cyberchef.jacobmarks.com/#recipe=PAN_Generate('Visa','Curated%20sample',16,'Any',true) + [p21]: https://cyberchef.jacobmarks.com/#recipe=PAN_Generate('Mastercard','Curated%20sample',16,'5-series%20(51-55)',false)PAN_Parse() + [p22]: https://cyberchef.jacobmarks.com/#recipe=Card_Validation_Data_Generate('CVV2%20/%20CVC2%20(force%20000)','4123456789012345','02','25','MMYY','101',3,false)&input=MDEyMzQ1Njc4OUFCQ0RFRkZFRENCQTk4NzY1NDMyMTA + [p23]: https://cyberchef.jacobmarks.com/#recipe=Card_Validation_Data_Verify('CVV2%20/%20CVC2%20(force%20000)','4123456789012345','02','25','MMYY','101','221')&input=MDEyMzQ1Njc4OUFCQ0RFRkZFRENCQTk4NzY1NDMyMTA + [p24]: https://cyberchef.jacobmarks.com/#recipe=PIN_Block_Translate_Encrypted('DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE','ISO%20Format%200','5432101234567890','AABBCCDDEEFF00112233445566778899','ISO%20Format%200','5432101234567890',false)&input=N0YzODFEQkY5RjY5MDZDNA + [p25]: https://cyberchef.jacobmarks.com/#recipe=PIN_Block_Translate_Encrypted('DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE','ISO%20Format%200','5432101234567890','AABBCCDDEEFF00112233445566778899','ISO%20Format%200','5432101234567890',true)&input=N0YzODFEQkY5RjY5MDZDNA diff --git a/src/core/Operation.mjs b/src/core/Operation.mjs index 09058766d3..bbf8a811c5 100755 --- a/src/core/Operation.mjs +++ b/src/core/Operation.mjs @@ -31,6 +31,8 @@ class Operation { this.name = ""; this.module = ""; this.description = ""; + this.inlineHelp = ""; + this.testDataSamples = []; this.infoURL = null; } @@ -181,6 +183,7 @@ class Operation { if (ing.toggleValues) conf.toggleValues = ing.toggleValues; if (ing.hint) conf.hint = ing.hint; + if (ing.comment) conf.comment = ing.comment; if (ing.rows) conf.rows = ing.rows; if (ing.disabled) conf.disabled = ing.disabled; if (ing.target) conf.target = ing.target; diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 5fcba29770..10436ef576 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -160,6 +160,7 @@ "Citrix CTX1 Decode", "AES Key Wrap", "AES Key Unwrap", + "Derive ECDH Key Material", "Pseudo-Random Number Generator", "Enigma", "Bombe", @@ -582,6 +583,55 @@ "XKCD Random Number" ] }, + { + "name": "Payments", + "ops": [ + "AS2805 Generate KEK Validation", + "Card Validation Data Generate", + "Card Validation Data Verify", + "DUKPT Derive AES Key", + "DUKPT Derive TDES Key", + "EMV Build ARPC Data", + "EMV Build ARQC Data", + "EMV Build PIN Change Script Data", + "EMV Build Script Data", + "EMV Generate ARPC", + "EMV Generate ARQC", + "EMV Parse ARPC Data", + "EMV Parse ARQC Data", + "EMV Parse TLV", + "EMV Generate MAC", + "EMV Generate MAC (PIN Change)", + "EMV Verify ARQC", + "EMV Verify MAC", + "HSM Parse Futurex Command", + "HSM Parse Thales Command", + "Key Component Combine", + "Key Component Split", + "Key Generate", + "MAC Generate", + "MAC Verify", + "PAN Generate", + "PAN Parse", + "Payment Calculate KCV", + "Payment Decrypt Data", + "Payment Encrypt Data", + "Payment Re-Encrypt Data", + "PIN Block Build", + "PIN Block Parse", + "PIN Block Translate", + "PIN Block Translate Encrypted", + "PIN Data Generate", + "PIN Data Verify", + "PIN Generate", + "PIN IBM 3624 Offset Generate", + "PIN IBM 3624 Verify", + "TR-31 Parse Key Block", + "TR-34 Parse Key Transport", + "VISA PVV Generate", + "VISA PVV Verify" + ] + }, { "name": "Flow control", "ops": [ diff --git a/src/core/config/scripts/generateConfig.mjs b/src/core/config/scripts/generateConfig.mjs index 64c7cb8108..29d7695751 100644 --- a/src/core/config/scripts/generateConfig.mjs +++ b/src/core/config/scripts/generateConfig.mjs @@ -37,6 +37,8 @@ for (const opObj in Ops) { operationConfig[op.name] = { module: op.module, description: op.description, + inlineHelp: op.inlineHelp, + testDataSamples: op.testDataSamples, infoURL: op.infoURL, inputType: op.inputType, outputType: op.presentType, diff --git a/src/core/lib/CardValidation.mjs b/src/core/lib/CardValidation.mjs new file mode 100644 index 0000000000..1ec436e7c3 --- /dev/null +++ b/src/core/lib/CardValidation.mjs @@ -0,0 +1,211 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import { bytesToHex, parseHexBytes } from "./PaymentUtils.mjs"; +import { encryptDesEcb, encryptTdesEcb } from "./CardValidationInternals.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, ""); + } +} + + +/** + * XORs two byte arrays. + * + * @param {Uint8Array} left + * @param {Uint8Array} right + * @returns {Uint8Array} + */ +function xorBytes(left, right) { + const out = new Uint8Array(left.length); + for (let i = 0; i < left.length; i++) { + out[i] = left[i] ^ right[i]; + } + return out; +} + + +/** + * Converts a decimal digit string into BCD bytes. + * + * @param {string} digits + * @returns {Uint8Array} + */ +function digitsToBcdBytes(digits) { + const out = new Uint8Array(digits.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = (parseInt(digits.charAt(i * 2), 10) << 4) | parseInt(digits.charAt(i * 2 + 1), 10); + } + return out; +} + + +/** + * Decimalizes a CVV result hex string using the common numeric-first extraction rule. + * + * @param {string} hex + * @param {number} digitCount + * @returns {string} + */ +function decimalizeCvvHex(hex, digitCount) { + let out = ""; + + for (const ch of hex) { + if (/\d/.test(ch)) { + out += ch; + if (out.length >= digitCount) return out.substring(0, digitCount); + } + } + + for (const ch of hex) { + if (/[A-F]/.test(ch)) { + out += String(ch.charCodeAt(0) - "A".charCodeAt(0)); + if (out.length >= digitCount) return out.substring(0, digitCount); + } + } + + return out.substring(0, digitCount); +} + + +/** + * Generates card validation data such as CVV, CVV2, or iCVV. + * + * @param {string} cvkHex + * @param {string} pan + * @param {string} expiryMonth + * @param {string} expiryYear + * @param {string} expiryLayout + * @param {string} serviceCode + * @param {string} profile + * @param {number} digitCount + * @returns {Object} + */ +function generateCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, digitCount) { + const normalizedPan = (pan || "").replace(/\s+/g, ""); + const normalizedMonth = (expiryMonth || "").replace(/\s+/g, ""); + const normalizedYear = (expiryYear || "").replace(/\s+/g, ""); + const resolvedServiceCode = resolveServiceCode(profile, serviceCode); + + validateCardData(normalizedPan, normalizedMonth, normalizedYear, resolvedServiceCode); + + const normalizedDigitCount = Math.max(1, Math.min(5, Number(digitCount) || 3)); + const cvk = parseHexBytes(cvkHex, "CVK pair", [16, 24]); + const keyA = cvk.slice(0, 8); + const expiry = expiryLayout === "MMYY" ? + `${normalizedMonth}${normalizedYear}` : + `${normalizedYear}${normalizedMonth}`; + const dataDigits = `${normalizedPan}${expiry}${resolvedServiceCode}`.padEnd(32, "0").substring(0, 32); + const leftBlock = digitsToBcdBytes(dataDigits.substring(0, 16)); + const rightBlock = digitsToBcdBytes(dataDigits.substring(16, 32)); + const step1 = encryptDesEcb(keyA, leftBlock); + const step2 = xorBytes(step1, rightBlock); + const resultBytes = encryptTdesEcb(cvk, step2); + const resultHex = bytesToHex(resultBytes); + const decimalized = decimalizeCvvHex(resultHex, 5); + + return { + profile, + pan: normalizedPan, + expiry, + expiryLayout, + serviceCode: resolvedServiceCode, + digitCount: normalizedDigitCount, + inputDigits: dataDigits, + resultHex, + decimalized, + validationData: decimalized.substring(0, normalizedDigitCount) + }; +} + + +/** + * Verifies card validation data. + * + * @param {string} cvkHex + * @param {string} pan + * @param {string} expiryMonth + * @param {string} expiryYear + * @param {string} expiryLayout + * @param {string} serviceCode + * @param {string} profile + * @param {string} expectedValue + * @returns {Object} + */ +function verifyCardValidationData(cvkHex, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, profile, expectedValue) { + const normalizedExpected = (expectedValue || "").replace(/\s+/g, ""); + if (!/^\d{1,5}$/.test(normalizedExpected)) { + throw new OperationError("Expected validation data must be 1 to 5 decimal digits."); + } + + const generated = generateCardValidationData( + cvkHex, + pan, + expiryMonth, + expiryYear, + expiryLayout, + serviceCode, + profile, + normalizedExpected.length + ); + + return { + ...generated, + expectedValue: normalizedExpected, + valid: generated.validationData === normalizedExpected + }; +} + + +export { + CVV_PROFILES, + generateCardValidationData, + verifyCardValidationData, +}; diff --git a/src/core/lib/CardValidationInternals.mjs b/src/core/lib/CardValidationInternals.mjs new file mode 100644 index 0000000000..83e285ac14 --- /dev/null +++ b/src/core/lib/CardValidationInternals.mjs @@ -0,0 +1,49 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import forge from "node-forge"; +import { toByteString } from "./PaymentUtils.mjs"; + +/** + * 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); +} + +export { + encryptDesEcb, + encryptTdesEcb, +}; diff --git a/src/core/lib/EmvArpc.mjs b/src/core/lib/EmvArpc.mjs new file mode 100644 index 0000000000..207d5176e0 --- /dev/null +++ b/src/core/lib/EmvArpc.mjs @@ -0,0 +1,167 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * ARPC preimage assembly and parsing for EMV Method 1 and Method 2. + * + * Method 1 (Visa, Amex, Discover, JCB): + * Preimage = ARQC (8 bytes) || ARC (2 bytes) → 10 bytes + * + * Method 2 (Mastercard M/Chip): + * Preimage = ARQC (8 bytes) || CSU (4 bytes) || ProprietaryAuthData (0–8 bytes) → 12–20 bytes + */ + +import OperationError from "../errors/OperationError.mjs"; + +const METHOD1 = "Method 1 (Visa/Amex/Discover)"; +const METHOD2 = "Method 2 (Mastercard)"; +const METHODS = [METHOD1, METHOD2]; + +const METHOD1_FIELDS = [ + { name: "ARQC", bytes: 8, description: "Authorization Request Cryptogram from the card" }, + { name: "ARC", bytes: 2, description: "Authorization Response Code (e.g. Y1=5931, Z1=5A31, 00=3030)" }, +]; + +const METHOD2_FIXED_FIELDS = [ + { name: "ARQC", bytes: 8, description: "Authorization Request Cryptogram from the card" }, + { name: "Card Status Update (CSU)", bytes: 4, description: "Issuer response flags (PIN change/unblock, go-online indicators)" }, +]; + +/** + * @param {string} value + * @param {string} name + * @param {number} bytes + * @returns {string} uppercase hex, validated + */ +function validateHex(value, name, bytes) { + const h = (value || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]*$/.test(h)) + throw new OperationError(`${name}: not valid hex.`); + if (h.length !== bytes * 2) + throw new OperationError(`${name}: expected ${bytes * 2} hex chars (${bytes} bytes), got ${h.length}.`); + return h; +} + +/** + * @param {string} value + * @param {string} name + * @param {number} maxBytes + * @returns {string} uppercase hex, validated (may be empty) + */ +function validateOptionalHex(value, name, maxBytes) { + const h = (value || "").replace(/\s+/g, "").toUpperCase(); + if (h.length === 0) return ""; + if (!/^[0-9A-F]+$/.test(h) || h.length % 2 !== 0) + throw new OperationError(`${name}: not valid hex.`); + if (h.length > maxBytes * 2) + throw new OperationError(`${name}: max ${maxBytes * 2} hex chars (${maxBytes} bytes), got ${h.length}.`); + return h; +} + +/** + * Build Method 1 preimage. + * @param {string} arqcHex + * @param {string} arcHex + * @returns {{ fields: object[], hex: string }} + */ +function buildMethod1(arqcHex, arcHex) { + const arqc = validateHex(arqcHex, "ARQC", 8); + const arc = validateHex(arcHex, "ARC", 2); + const fields = [ + { ...METHOD1_FIELDS[0], value: arqc }, + { ...METHOD1_FIELDS[1], value: arc }, + ]; + return { fields, hex: arqc + arc }; +} + +/** + * Build Method 2 preimage. + * @param {string} arqcHex + * @param {string} csuHex + * @param {string} padHex optional, 0–8 bytes + * @returns {{ fields: object[], hex: string }} + */ +function buildMethod2(arqcHex, csuHex, padHex) { + const arqc = validateHex(arqcHex, "ARQC", 8); + const csu = validateHex(csuHex, "Card Status Update (CSU)", 4); + const pad = validateOptionalHex(padHex, "Proprietary Auth Data", 8); + const fields = [ + { ...METHOD2_FIXED_FIELDS[0], value: arqc }, + { ...METHOD2_FIXED_FIELDS[1], value: csu }, + { name: "Proprietary Auth Data", bytes: pad.length / 2, description: "Optional issuer-specific bytes (0–8)", value: pad }, + ]; + return { fields: pad.length > 0 ? fields : fields.slice(0, 2), hex: arqc + csu + pad }; +} + +/** + * Parse Method 1 hex preimage. + * @param {string} hex + * @returns {{ fields: object[] }} + */ +function parseMethod1(hex) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!h || !/^[0-9A-F]+$/.test(h)) + throw new OperationError("Input is not valid hex."); + if (h.length !== 20) + throw new OperationError(`Method 1 preimage requires 20 hex chars (10 bytes); got ${h.length}.`); + return { + fields: [ + { ...METHOD1_FIELDS[0], value: h.substring(0, 16) }, + { ...METHOD1_FIELDS[1], value: h.substring(16, 20) }, + ] + }; +} + +/** + * Parse Method 2 hex preimage. + * @param {string} hex + * @returns {{ fields: object[] }} + */ +function parseMethod2(hex) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!h || !/^[0-9A-F]+$/.test(h)) + throw new OperationError("Input is not valid hex."); + if (h.length < 24 || h.length > 40 || h.length % 2 !== 0) + throw new OperationError(`Method 2 preimage requires 24–40 hex chars (12–20 bytes); got ${h.length}.`); + const padBytes = (h.length - 24) / 2; + const fields = [ + { ...METHOD2_FIXED_FIELDS[0], value: h.substring(0, 16) }, + { ...METHOD2_FIXED_FIELDS[1], value: h.substring(16, 24) }, + ]; + if (padBytes > 0) + fields.push({ name: "Proprietary Auth Data", bytes: padBytes, description: "Optional issuer-specific bytes (0–8)", value: h.substring(24) }); + return { fields }; +} + +/** + * Format parsed fields as JSON. + * @param {object[]} fields + * @param {string} method + * @returns {string} + */ +function formatJson(fields, method) { + const obj = { method }; + for (const f of fields) obj[f.name] = f.value; + return JSON.stringify(obj, null, 4); +} + +/** + * Format parsed fields as annotated list. + * @param {object[]} fields + * @param {string} method + * @returns {string} + */ +function formatAnnotated(fields, method) { + const header = `ARPC ${method} preimage\n${"─".repeat(50)}`; + const rows = fields.map(f => + `${f.name.padEnd(30)} ${f.value.padEnd(16)} [${f.bytes} byte${f.bytes === 1 ? "" : "s"}]` + ); + return [header, ...rows].join("\n"); +} + +export { + METHODS, METHOD1, METHOD2, + buildMethod1, buildMethod2, + parseMethod1, parseMethod2, + formatJson, formatAnnotated, +}; diff --git a/src/core/lib/EmvCdol.mjs b/src/core/lib/EmvCdol.mjs new file mode 100644 index 0000000000..e50e06a1e3 --- /dev/null +++ b/src/core/lib/EmvCdol.mjs @@ -0,0 +1,118 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; + +/** + * Standard EMVCo CDOL1 field template. + * + * This 10-field, 33-byte layout covers Visa, Mastercard, Amex, Discover, + * JCB, and UnionPay acquirer flows. Network differences (Option A vs + * Option B session-key derivation) affect key derivation upstream, not + * the structure of the CDOL1 data block itself. + */ +const CDOL1_FIELDS = [ + { tag: "9F02", name: "Amount Authorised", bytes: 6 }, + { tag: "9F03", name: "Amount Other", bytes: 6 }, + { tag: "9F1A", name: "Terminal Country Code", bytes: 2 }, + { tag: "95", name: "TVR", bytes: 5 }, + { tag: "5F2A", name: "Transaction Currency Code", bytes: 2 }, + { tag: "9A", name: "Transaction Date", bytes: 3 }, + { tag: "9C", name: "Transaction Type", bytes: 1 }, + { tag: "9F37", name: "Unpredictable Number", bytes: 4 }, + { tag: "82", name: "AIP", bytes: 2 }, + { tag: "9F36", name: "ATC", bytes: 2 }, +]; + +const CDOL1_TOTAL_BYTES = CDOL1_FIELDS.reduce((sum, f) => sum + f.bytes, 0); // 33 + +/** + * @param {string} value + * @param {string} name + * @param {number} bytes + * @returns {string} uppercase hex, validated + */ +function validateFieldHex(value, name, bytes) { + const h = (value || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]*$/.test(h)) + throw new OperationError(`${name}: not valid hex.`); + if (h.length !== bytes * 2) + throw new OperationError(`${name}: expected ${bytes * 2} hex chars (${bytes} bytes), got ${h.length}.`); + return h; +} + +/** + * @param {string[]} values — one hex string per CDOL1 field, in template order + * @returns {{ tag: string, name: string, bytes: number, value: string }[]} + */ +function buildCdol1(values) { + if (values.length !== CDOL1_FIELDS.length) + throw new OperationError(`Expected ${CDOL1_FIELDS.length} field values, got ${values.length}.`); + return CDOL1_FIELDS.map((f, i) => ({ + ...f, + value: validateFieldHex(values[i], f.name, f.bytes), + })); +} + +/** + * @param {string} hex — flat 66-char (33-byte) CDOL1 preimage + * @returns {{ tag: string, name: string, bytes: number, value: string }[]} + */ +function parseCdol1(hex) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!h || !/^[0-9A-F]+$/.test(h)) + throw new OperationError("Input is not valid hex."); + if (h.length < CDOL1_TOTAL_BYTES * 2) + throw new OperationError( + `Standard CDOL1 requires ${CDOL1_TOTAL_BYTES * 2} hex chars (${CDOL1_TOTAL_BYTES} bytes); got ${h.length}.` + ); + let offset = 0; + return CDOL1_FIELDS.map(f => { + const value = h.substring(offset, offset + f.bytes * 2); + offset += f.bytes * 2; + return { ...f, value }; + }); +} + +/** + * @param {{ value: string }[]} parsed + * @returns {string} flat uppercase hex + */ +function formatHex(parsed) { + return parsed.map(f => f.value).join(""); +} + +/** + * @param {{ tag: string, name: string, value: string }[]} parsed + * @returns {string} pretty-printed JSON + */ +function formatJson(parsed) { + const obj = {}; + for (const f of parsed) obj[`${f.name} (${f.tag})`] = f.value; + return JSON.stringify(obj, null, 4); +} + +/** + * @param {{ tag: string, name: string, bytes: number, value: string }[]} parsed + * @returns {string} annotated TLV lines: TAG LEN VALUE [name] + */ +function formatAnnotatedTlv(parsed) { + return parsed + .map(f => { + const lenHex = f.bytes.toString(16).padStart(2, "0").toUpperCase(); + return `${f.tag.padEnd(6)} ${lenHex} ${f.value.padEnd(12)} [${f.name}]`; + }) + .join("\n"); +} + +export { + CDOL1_FIELDS, + CDOL1_TOTAL_BYTES, + buildCdol1, + parseCdol1, + formatHex, + formatJson, + formatAnnotatedTlv, +}; diff --git a/src/core/lib/EmvCryptogram.mjs b/src/core/lib/EmvCryptogram.mjs new file mode 100644 index 0000000000..4d7eab5645 --- /dev/null +++ b/src/core/lib/EmvCryptogram.mjs @@ -0,0 +1,41 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import CMAC from "../operations/CMAC.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { parseHexBuffer } from "./PaymentUtils.mjs"; + +/** + * Generates an EMV AES-CMAC cryptogram and truncates it. + * + * @param {string} inputHex + * @param {string} keyHex + * @param {number} outputBytes + * @returns {Object} + */ +function generateEmvAesCmacCryptogram(inputHex, keyHex, outputBytes) { + const inputBuffer = parseHexBuffer(inputHex, "Input data"); + const normalizedKey = (keyHex || "").replace(/\s+/g, ""); + if (!/^[0-9a-fA-F]+$/.test(normalizedKey) || normalizedKey.length % 2 !== 0) { + throw new OperationError("Session key must be hex."); + } + + const normalizedOutputBytes = Math.max(1, Math.min(16, Number(outputBytes) || 8)); + const cmac = new CMAC(); + const fullMacHex = cmac.run(inputBuffer, [{ string: normalizedKey, option: "Hex" }, "AES"]).toUpperCase(); + const cryptogramHex = fullMacHex.substring(0, normalizedOutputBytes * 2); + + return { + inputHex: (inputHex || "").replace(/\s+/g, "").toUpperCase(), + outputBytes: normalizedOutputBytes, + fullMacHex, + cryptogramHex + }; +} + + +export { + generateEmvAesCmacCryptogram, +}; diff --git a/src/core/lib/EmvMac.mjs b/src/core/lib/EmvMac.mjs new file mode 100644 index 0000000000..b73c9caa25 --- /dev/null +++ b/src/core/lib/EmvMac.mjs @@ -0,0 +1,82 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import { generateIso9797Algorithm3Mac } from "./Iso9797.mjs"; + +/** + * Generates an EMV MAC using an already-derived session key. + * + * @param {string} messageHex + * @param {string} sessionKeyHex + * @param {number} outputBytes + * @param {string} paddingMethod + * @returns {Object} + */ +function generateEmvMac(messageHex, sessionKeyHex, outputBytes=8, paddingMethod="Method 2") { + const normalizedKey = (sessionKeyHex || "").replace(/\s+/g, ""); + if (!/^[0-9A-Fa-f]+$/.test(normalizedKey) || normalizedKey.length % 2 !== 0) { + throw new OperationError("Session key must be hex."); + } + + return { + ...generateIso9797Algorithm3Mac(messageHex, normalizedKey, paddingMethod, outputBytes), + algorithm: "EMV MAC" + }; +} + +/** + * Verifies an EMV MAC using an already-derived session key. + * + * @param {string} messageHex + * @param {string} sessionKeyHex + * @param {string} expectedMac + * @param {string} paddingMethod + * @returns {Object} + */ +function verifyEmvMac(messageHex, sessionKeyHex, expectedMac, paddingMethod="Method 2") { + const normalizedExpected = (expectedMac || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]+$/.test(normalizedExpected) || normalizedExpected.length % 2 !== 0) { + throw new OperationError("Expected MAC must be even-length hex."); + } + + const generated = generateEmvMac(messageHex, sessionKeyHex, normalizedExpected.length / 2, paddingMethod); + return { + ...generated, + expectedMacHex: normalizedExpected, + valid: generated.macHex === normalizedExpected + }; +} + +/** + * Generates the MAC portion of an EMV PIN-change issuer script. + * + * @param {string} messageHex + * @param {string} encryptedPinBlockHex + * @param {string} sessionKeyHex + * @param {number} outputBytes + * @returns {Object} + */ +function generateEmvPinChangeMac(messageHex, encryptedPinBlockHex, sessionKeyHex, outputBytes=8) { + const normalizedPinBlock = (encryptedPinBlockHex || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]{16,32}$/.test(normalizedPinBlock)) { + throw new OperationError("New encrypted PIN block must be 8 or 16 bytes of hex."); + } + + const combinedMessageHex = `${(messageHex || "").replace(/\s+/g, "").toUpperCase()}${normalizedPinBlock}`; + const generated = generateEmvMac(combinedMessageHex, sessionKeyHex, outputBytes); + return { + ...generated, + originalMessageHex: (messageHex || "").replace(/\s+/g, "").toUpperCase(), + appendedEncryptedPinBlockHex: normalizedPinBlock, + issuerScriptHex: combinedMessageHex + }; +} + +export { + generateEmvMac, + generateEmvPinChangeMac, + verifyEmvMac, +}; diff --git a/src/core/lib/EmvScript.mjs b/src/core/lib/EmvScript.mjs new file mode 100644 index 0000000000..2d1dc3f8bb --- /dev/null +++ b/src/core/lib/EmvScript.mjs @@ -0,0 +1,162 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; + +/** Issuer script command display names mapped to INS byte values. */ +const COMMAND_TO_INS = { + "PUT DATA": "DA", + "PUT DATA (ODD)": "DB", + "UPDATE RECORD": "DC", + "WRITE BINARY": "D6", + "CHANGE REFERENCE DATA": "24", + "DISABLE VERIFICATION REQUIREMENT": "26", + "ENABLE VERIFICATION REQUIREMENT": "28", + "EXTERNAL AUTHENTICATE": "82", +}; + +/** Ordered option list for the Command selector arg. */ +const SCRIPT_COMMANDS = Object.keys(COMMAND_TO_INS); + +/** PIN change mode display names mapped to P1 byte values. */ +const CHANGE_MODE_TO_P1 = { + "Change with current PIN verification": "00", + "Change without verification": "01", +}; + +/** Ordered option list for the Change mode selector arg. */ +const PIN_CHANGE_MODES = Object.keys(CHANGE_MODE_TO_P1); + +/** + * Validates and normalises a 1-byte hex string. + * + * @param {string} hex + * @param {string} name + * @returns {string} Upper-case 2-char hex + */ +function normByte(hex, name) { + const s = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]{2}$/.test(s)) { + throw new OperationError(`${name} must be exactly 1 byte (2 hex chars).`); + } + return s; +} + +/** + * Validates and normalises an arbitrary hex data string (may be empty). + * + * @param {string} hex + * @returns {string} Upper-case hex, possibly empty + */ +function normData(hex) { + const s = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (s && !/^[0-9A-F]+$/.test(s)) { + throw new OperationError("Data must be hex."); + } + if (s.length % 2 !== 0) { + throw new OperationError("Data must be even-length hex."); + } + return s; +} + +/** + * Builds an issuer script command APDU from its component fields. + * The Lc byte is computed from the length of the supplied data. + * + * @param {string} claHex + * @param {string} commandName - key from SCRIPT_COMMANDS + * @param {string} p1Hex + * @param {string} p2Hex + * @param {string} dataHex + * @returns {Object} fields including the full apdu hex + */ +function buildScriptApdu(claHex, commandName, p1Hex, p2Hex, dataHex) { + const cla = normByte(claHex, "CLA"); + const ins = COMMAND_TO_INS[commandName] || normByte(commandName, "INS"); + const p1 = normByte(p1Hex, "P1"); + const p2 = normByte(p2Hex, "P2"); + const data = normData(dataHex); + const lcDec = data.length / 2; + const lc = lcDec.toString(16).padStart(2, "0").toUpperCase(); + const apdu = `${cla}${ins}${p1}${p2}${lc}${data}`; + return { cla, ins, commandName, p1, p2, lc, lcDec, data, apdu }; +} + +/** + * Builds the 5-byte CHANGE REFERENCE DATA command header for PIN change. + * The caller supplies Lc explicitly because it must account for the + * encrypted PIN block and MAC bytes that follow in the final APDU. + * + * @param {string} claHex + * @param {string} changeMode - key from PIN_CHANGE_MODES + * @param {string} p2Hex + * @param {string} lcHex + * @returns {Object} fields including the 5-byte header hex + */ +function buildPinChangeHeader(claHex, changeMode, p2Hex, lcHex) { + const cla = normByte(claHex, "CLA"); + const p1 = CHANGE_MODE_TO_P1[changeMode]; + if (!p1) { + throw new OperationError(`Unknown change mode: ${changeMode}`); + } + const p2 = normByte(p2Hex, "P2"); + const lc = normByte(lcHex, "Lc"); + const lcDec = parseInt(lc, 16); + const header = `${cla}24${p1}${p2}${lc}`; + return { cla, ins: "24", p1, changeMode, p2, lc, lcDec, header }; +} + +/** + * Formats APDU fields as an annotated line-by-line breakdown. + * + * @param {Object} f - result of buildScriptApdu + * @returns {string} + */ +function formatAnnotatedApdu(f) { + const insName = COMMAND_TO_INS[f.commandName] ? f.commandName : (f.commandName || "Custom instruction"); + const pad = 24; + const lines = [ + `CLA ${f.cla.padEnd(pad)}[Class byte]`, + `INS ${f.ins.padEnd(pad)}[${insName}]`, + `P1 ${f.p1.padEnd(pad)}[Parameter 1]`, + `P2 ${f.p2.padEnd(pad)}[Parameter 2]`, + `Lc ${f.lc.padEnd(pad)}[${f.lcDec} byte${f.lcDec === 1 ? "" : "s"} of data]`, + ]; + if (f.data) { + lines.push(`Data ${f.data.padEnd(pad)}[Command data]`); + } + lines.push("─".repeat(40)); + lines.push(`APDU ${f.apdu}`); + return lines.join("\n"); +} + +/** + * Formats PIN change header fields as an annotated breakdown. + * + * @param {Object} f - result of buildPinChangeHeader + * @returns {string} + */ +function formatAnnotatedPinChangeHeader(f) { + const pad = 24; + const lines = [ + `CLA ${f.cla.padEnd(pad)}[Class byte]`, + `INS ${"24".padEnd(pad)}[CHANGE REFERENCE DATA]`, + `P1 ${f.p1.padEnd(pad)}[${f.changeMode}]`, + `P2 ${f.p2.padEnd(pad)}[PIN reference]`, + `Lc ${f.lc.padEnd(pad)}[${f.lcDec} bytes total: PIN block + MAC]`, + "─".repeat(40), + `Header ${f.header} [Feed as message into EMV Generate MAC (PIN Change)]`, + ]; + return lines.join("\n"); +} + +export { + SCRIPT_COMMANDS, + PIN_CHANGE_MODES, + buildScriptApdu, + buildPinChangeHeader, + formatAnnotatedApdu, + formatAnnotatedPinChangeHeader, +}; diff --git a/src/core/lib/EmvTlv.mjs b/src/core/lib/EmvTlv.mjs new file mode 100644 index 0000000000..d37c12aebd --- /dev/null +++ b/src/core/lib/EmvTlv.mjs @@ -0,0 +1,166 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * BER-TLV parser for EMV data. Handles: + * - 1- and 2-byte tags (short-form and long-form tags up to 2 bytes) + * - Short-form and long-form lengths (up to 3 length bytes) + * - Recursive constructed-tag parsing + * - EMV tag dictionary enrichment (name, source, format, class) + */ + +import OperationError from "../errors/OperationError.mjs"; +import EMV_TAG_DICTIONARY from "./EmvTlvDictionary.mjs"; + +/** + * Parse a hex string into a Uint8Array of bytes. + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + const h = hex.replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]*$/.test(h) || h.length % 2 !== 0) + throw new OperationError("Input is not valid hex (odd length or non-hex chars)."); + const bytes = new Uint8Array(h.length / 2); + for (let i = 0; i < bytes.length; i++) + bytes[i] = parseInt(h.substring(i * 2, i * 2 + 2), 16); + return bytes; +} + +/** + * Determine tag class name from the high two bits of the first tag byte. + * @param {number} firstByte + * @returns {string} + */ +function tagClassName(firstByte) { + switch ((firstByte & 0xC0) >> 6) { + case 0: return "Universal"; + case 1: return "Application"; + case 2: return "Context-Specific"; + case 3: return "Private"; + } +} + +/** + * Read one BER-TLV record starting at `offset` in `bytes`. + * Returns { tag, tagHex, rawBytes, constructed, class, length, valueBytes, offset: nextOffset }. + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {object} + */ +function readTlv(bytes, offset) { + if (offset >= bytes.length) + throw new OperationError(`Unexpected end of data at offset ${offset}.`); + + const firstByte = bytes[offset]; + const isConstructed = !!(firstByte & 0x20); + const cls = tagClassName(firstByte); + + // Tag: 1 or 2 bytes + let tagHex; + if ((firstByte & 0x1F) === 0x1F) { + // Long-form tag: second byte follows + if (offset + 1 >= bytes.length) + throw new OperationError(`Truncated long-form tag at offset ${offset}.`); + tagHex = bytes[offset].toString(16).padStart(2, "0").toUpperCase() + + bytes[offset + 1].toString(16).padStart(2, "0").toUpperCase(); + offset += 2; + } else { + tagHex = firstByte.toString(16).padStart(2, "0").toUpperCase(); + offset += 1; + } + + // Length + if (offset >= bytes.length) + throw new OperationError(`Missing length byte for tag ${tagHex}.`); + + const lenByte = bytes[offset++]; + let length; + if (lenByte === 0x80) { + throw new OperationError(`Indefinite-length form is not supported (tag ${tagHex}).`); + } else if (lenByte > 0x80) { + const numLenBytes = lenByte & 0x7F; + if (numLenBytes > 3) + throw new OperationError(`Length encoding too long (${numLenBytes} bytes) for tag ${tagHex}.`); + if (offset + numLenBytes > bytes.length) + throw new OperationError(`Truncated length field for tag ${tagHex}.`); + length = 0; + for (let i = 0; i < numLenBytes; i++) + length = (length << 8) | bytes[offset++]; + } else { + length = lenByte; + } + + if (offset + length > bytes.length) + throw new OperationError(`Value of tag ${tagHex} extends past end of data (need ${length} bytes at offset ${offset}, have ${bytes.length - offset}).`); + + const valueBytes = bytes.slice(offset, offset + length); + offset += length; + + return { tagHex, isConstructed, class: cls, length, valueBytes, nextOffset: offset }; +} + +/** + * Recursively parse all BER-TLV records in `bytes[start..end]`. + * @param {Uint8Array} bytes + * @param {number} start + * @param {number} end + * @param {number} depth + * @returns {object[]} + */ +function parseTlvSequence(bytes, start, end, depth) { + const records = []; + let offset = start; + while (offset < end) { + // Skip 0x00 padding bytes (common in EMV records) + if (bytes[offset] === 0x00) { + offset++; + continue; + } + + const tlv = readTlv(bytes, offset); + offset = tlv.nextOffset; + + const valueHex = Array.from(tlv.valueBytes) + .map(b => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + + const dict = EMV_TAG_DICTIONARY[tlv.tagHex] || null; + + const record = { + tag: tlv.tagHex, + name: dict ? dict.name : "Unknown", + constructed: tlv.isConstructed, + class: dict ? dict.class : tlv.class, + source: dict ? dict.source : null, + format: dict ? dict.format : null, + length: tlv.length, + valueHex, + }; + + if (tlv.isConstructed && tlv.length > 0) { + try { + record.children = parseTlvSequence(tlv.valueBytes, 0, tlv.valueBytes.length, depth + 1); + } catch (_) { + record.children = []; + record.parseWarning = "Could not parse constructed value as BER-TLV."; + } + } + + records.push(record); + } + return records; +} + +/** + * Entry point: parse a hex-encoded EMV TLV blob. + * @param {string} hex + * @returns {object[]} parsed TLV records + */ +function parseEmvTlv(hex) { + const bytes = hexToBytes(hex); + if (bytes.length === 0) throw new OperationError("Input is empty."); + return parseTlvSequence(bytes, 0, bytes.length, 0); +} + +export { parseEmvTlv, EMV_TAG_DICTIONARY }; diff --git a/src/core/lib/EmvTlvDictionary.mjs b/src/core/lib/EmvTlvDictionary.mjs new file mode 100644 index 0000000000..de6893170d --- /dev/null +++ b/src/core/lib/EmvTlvDictionary.mjs @@ -0,0 +1,167 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + * + * EMV tag dictionary covering EMV Books 1-4, EMVCo contactless, Nexo, and + * common acquirer/terminal tags. Each entry carries metadata used by the + * EMV Parse TLV operation. + * + * Sources: EMV Book 1 §A; EMV Book 3 §A; Nexo FAST 3.x; ISO 8583 DE 55 common tags. + */ + +/** + * Tag source abbreviations: + * "ICC" — generated or maintained by the card + * "T" — generated or maintained by the terminal + * "Both" — may originate from either + * "Host" — generated or maintained by the issuer host + */ + +/** + * Value format codes (EMV Book 3, Annex A): + * "a" — alphabetic + * "an" — alphanumeric + * "ans" — alphanumeric special + * "b" — binary + * "cn" — compressed numeric (BCD) + * "n" — numeric (BCD) + * "var" — variable / scheme-specific + */ + +const EMV_TAG_DICTIONARY = { + // ── File Control Information ─────────────────────────────────────────────── + "6F": { name: "File Control Information (FCI) Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "A5": { name: "FCI Proprietary Template", constructed: true, source: "ICC", format: "b", class: "Context-Specific" }, + "BF0C": { name: "FCI Issuer Discretionary Data", constructed: true, source: "ICC", format: "b", class: "Private" }, + + // ── Record / Response Templates ──────────────────────────────────────────── + "70": { name: "Record Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "71": { name: "Issuer Script Template 1", constructed: true, source: "Host", format: "b", class: "Application" }, + "72": { name: "Issuer Script Template 2", constructed: true, source: "Host", format: "b", class: "Application" }, + "77": { name: "Response Message Template Format 2", constructed: true, source: "ICC", format: "b", class: "Application" }, + "80": { name: "Response Message Template Format 1", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "83": { name: "Command Template", constructed: false, source: "T", format: "b", class: "Context-Specific" }, + + // ── Application Labels / Identifiers ─────────────────────────────────────── + "4F": { name: "Application Identifier (AID)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "50": { name: "Application Label", constructed: false, source: "ICC", format: "an", class: "Application" }, + "84": { name: "Dedicated File (DF) Name", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "87": { name: "Application Priority Indicator", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9D": { name: "Directory Definition File (DDF) Name", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F06": { name: "Application Identifier (AID) — Terminal", constructed: false, source: "T", format: "b", class: "Application" }, + "9F11": { name: "Issuer Code Table Index", constructed: false, source: "ICC", format: "n", class: "Application" }, + "9F12": { name: "Application Preferred Name", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "9F38": { name: "Processing Options Data Object List (PDOL)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4D": { name: "Log Entry", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Card / Cardholder Data ───────────────────────────────────────────────── + "5A": { name: "Application PAN", constructed: false, source: "ICC", format: "cn", class: "Application" }, + "56": { name: "Track 1 Equivalent Data", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "57": { name: "Track 2 Equivalent Data", constructed: false, source: "ICC", format: "b", class: "Application" }, + "5F20": { name: "Cardholder Name", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "5F24": { name: "Application Expiry Date (YYMMDD)", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F25": { name: "Application Effective Date (YYMMDD)", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F28": { name: "Issuer Country Code", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F2D": { name: "Language Preference", constructed: false, source: "ICC", format: "an", class: "Application" }, + "5F30": { name: "Service Code", constructed: false, source: "ICC", format: "n", class: "Application" }, + "5F34": { name: "Application PAN Sequence Number", constructed: false, source: "ICC", format: "n", class: "Application" }, + + // ── Transaction Amount / Currency ────────────────────────────────────────── + "5F2A": { name: "Transaction Currency Code", constructed: false, source: "T", format: "n", class: "Application" }, + "5F36": { name: "Transaction Currency Exponent", constructed: false, source: "T", format: "n", class: "Application" }, + "9F02": { name: "Amount, Authorised", constructed: false, source: "T", format: "n", class: "Application" }, + "9F03": { name: "Amount, Other", constructed: false, source: "T", format: "n", class: "Application" }, + "9F04": { name: "Amount, Other (Binary)", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Transaction Identification ───────────────────────────────────────────── + "9A": { name: "Transaction Date", constructed: false, source: "T", format: "n", class: "Application" }, + "9C": { name: "Transaction Type", constructed: false, source: "T", format: "n", class: "Application" }, + "9F21": { name: "Transaction Time", constructed: false, source: "T", format: "n", class: "Application" }, + "9F37": { name: "Unpredictable Number", constructed: false, source: "T", format: "b", class: "Application" }, + "9F41": { name: "Transaction Sequence Counter", constructed: false, source: "T", format: "n", class: "Application" }, + "9F7C": { name: "Merchant Custom Data", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Terminal Data ────────────────────────────────────────────────────────── + "9F1A": { name: "Terminal Country Code", constructed: false, source: "T", format: "n", class: "Application" }, + "9F33": { name: "Terminal Capabilities", constructed: false, source: "T", format: "b", class: "Application" }, + "9F35": { name: "Terminal Type", constructed: false, source: "T", format: "n", class: "Application" }, + "9F40": { name: "Additional Terminal Capabilities", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1B": { name: "Terminal Floor Limit", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1C": { name: "Terminal Identification", constructed: false, source: "T", format: "an", class: "Application" }, + "9F1D": { name: "Terminal Risk Management Data", constructed: false, source: "T", format: "b", class: "Application" }, + "9F1E": { name: "Interface Device (IFD) Serial Number", constructed: false, source: "T", format: "an", class: "Application" }, + "9F15": { name: "Merchant Category Code", constructed: false, source: "T", format: "n", class: "Application" }, + "9F16": { name: "Merchant Identifier", constructed: false, source: "T", format: "ans", class: "Application" }, + + // ── Cryptographic Data ───────────────────────────────────────────────────── + "82": { name: "Application Interchange Profile (AIP)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F26": { name: "Application Cryptogram (ARQC/TC/AAC)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F27": { name: "Cryptogram Information Data (CID)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F36": { name: "Application Transaction Counter (ATC)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F10": { name: "Issuer Application Data (IAD)", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4B": { name: "Signed Dynamic Application Data", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4C": { name: "ICC Dynamic Number", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F45": { name: "Data Authentication Code", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F4A": { name: "Static Data Authentication Tag List", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Risk Management ──────────────────────────────────────────────────────── + "95": { name: "Terminal Verification Results (TVR)", constructed: false, source: "T", format: "b", class: "Application" }, + "9B": { name: "Transaction Status Information (TSI)", constructed: false, source: "Both", format: "b", class: "Application" }, + "9F0D": { name: "Issuer Action Code — Default", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F0E": { name: "Issuer Action Code — Denial", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F0F": { name: "Issuer Action Code — Online", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F07": { name: "Application Usage Control", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── CDOL / Script ────────────────────────────────────────────────────────── + "8C": { name: "Card Risk Management Data Object List 1 (CDOL1)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "8D": { name: "Card Risk Management Data Object List 2 (CDOL2)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "86": { name: "Issuer Script Command", constructed: false, source: "Host", format: "b", class: "Context-Specific" }, + "9F18": { name: "Issuer Script Identifier", constructed: false, source: "Host", format: "b", class: "Application" }, + + // ── CVM ──────────────────────────────────────────────────────────────────── + "8E": { name: "Cardholder Verification Method (CVM) List", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F34": { name: "CVM Results", constructed: false, source: "T", format: "b", class: "Application" }, + + // ── Short File Identifier / AFL ─────────────────────────────────────────── + "88": { name: "Short File Identifier (SFI)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "94": { name: "Application File Locator (AFL)", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "8F": { name: "Certification Authority Public Key Index", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + + // ── Issuer / Online Auth ─────────────────────────────────────────────────── + "89": { name: "Authorization Code", constructed: false, source: "Host", format: "an", class: "Context-Specific" }, + "8A": { name: "Authorization Response Code", constructed: false, source: "Host", format: "an", class: "Context-Specific" }, + "91": { name: "Issuer Authentication Data", constructed: false, source: "Host", format: "b", class: "Context-Specific" }, + "9F08": { name: "Application Version Number — ICC", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F09": { name: "Application Version Number — Terminal", constructed: false, source: "T", format: "b", class: "Application" }, + "9F0B": { name: "Cardholder Name Extended", constructed: false, source: "ICC", format: "ans", class: "Application" }, + "9F0C": { name: "Issuer Country Code (alpha2)", constructed: false, source: "ICC", format: "a", class: "Application" }, + "9F13": { name: "Last Online ATC Register", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F14": { name: "Lower Consecutive Offline Limit", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F23": { name: "Upper Consecutive Offline Limit", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F17": { name: "PIN Try Counter", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Public Key Data ──────────────────────────────────────────────────────── + "90": { name: "Issuer Public Key Certificate", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "92": { name: "Issuer Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "93": { name: "Signed Static Application Data", constructed: false, source: "ICC", format: "b", class: "Context-Specific" }, + "9F2D": { name: "ICC PIN Encipherment Public Key Certificate", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F2E": { name: "ICC PIN Encipherment Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F2F": { name: "ICC PIN Encipherment Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F32": { name: "Issuer Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F46": { name: "ICC Public Key Certificate", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F47": { name: "ICC Public Key Exponent", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F48": { name: "ICC Public Key Remainder", constructed: false, source: "ICC", format: "b", class: "Application" }, + "9F49": { name: "Dynamic Data Authentication Data Object List (DDOL)", constructed: false, source: "ICC", format: "b", class: "Application" }, + + // ── Contactless (EMVCo Book C / MSD) ────────────────────────────────────── + "9F6D": { name: "Mag-Stripe Application Version Number — Reader", constructed: false, source: "T", format: "b", class: "Application" }, + "9F6E": { name: "Third Party Data", constructed: false, source: "T", format: "b", class: "Application" }, + "9F7D": { name: "Application Capabilities Information", constructed: false, source: "ICC", format: "b", class: "Application" }, + "DF8117": { name: "Card Data Input Capability", constructed: false, source: "T", format: "b", class: "Private" }, + + // ── Directory ────────────────────────────────────────────────────────────── + "61": { name: "Application Template", constructed: true, source: "ICC", format: "b", class: "Application" }, + "73": { name: "Directory Discretionary Template", constructed: true, source: "ICC", format: "b", class: "Application" }, +}; + +export default EMV_TAG_DICTIONARY; diff --git a/src/core/lib/Iso9797.mjs b/src/core/lib/Iso9797.mjs new file mode 100644 index 0000000000..fedd75d4cc --- /dev/null +++ b/src/core/lib/Iso9797.mjs @@ -0,0 +1,215 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import forge from "node-forge"; +import OperationError from "../errors/OperationError.mjs"; +import { bytesToHex, parseHexBytes, toByteString } from "./PaymentUtils.mjs"; + +const ISO9797_PADDING_METHODS = ["Method 1", "Method 2"]; + +/** + * XORs two byte arrays of equal length. + * + * @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; +} + +/** + * Pads input according to ISO/IEC 9797-1 padding method 1 or 2. + * + * @param {Uint8Array} data + * @param {number} blockSize + * @param {string} paddingMethod + * @returns {Uint8Array} + */ +function applyIso9797Padding(data, blockSize, paddingMethod) { + if (!ISO9797_PADDING_METHODS.includes(paddingMethod)) { + throw new OperationError("Unsupported ISO9797 padding method."); + } + + if (paddingMethod === "Method 1") { + const remainder = data.length % blockSize; + if (remainder === 0) return Uint8Array.from(data); + const out = new Uint8Array(data.length + (blockSize - remainder)); + out.set(data, 0); + return out; + } + + const remainder = data.length % blockSize; + const extra = remainder === 0 ? blockSize : blockSize - remainder; + const out = new Uint8Array(data.length + extra); + out.set(data, 0); + out[data.length] = 0x80; + return out; +} + +/** + * Encrypts one 8-byte block with DES ECB. + * + * @param {Uint8Array} key8 + * @param {Uint8Array} block8 + * @returns {Uint8Array} + */ +function encryptDesBlock(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); +} + +/** + * Decrypts one 8-byte block with DES ECB. + * + * @param {Uint8Array} key8 + * @param {Uint8Array} block8 + * @returns {Uint8Array} + */ +function decryptDesBlock(key8, block8) { + const decipher = forge.cipher.createDecipher("DES-ECB", toByteString(key8)); + decipher.mode.unpad = function() { + return true; + }; + decipher.start(); + decipher.update(forge.util.createBuffer(toByteString(block8))); + decipher.finish(); + return Uint8Array.from(decipher.output.getBytes().split("").map(ch => ch.charCodeAt(0))).slice(0, 8); +} + +/** + * Encrypts one 8-byte block with TDES ECB. + * + * @param {Uint8Array} key + * @param {Uint8Array} block8 + * @returns {Uint8Array} + */ +function encryptTdesBlock(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); +} + +/** + * Encrypts blocks with DES CBC-MAC style chaining. + * + * @param {Uint8Array} key8 + * @param {Uint8Array} padded + * @returns {Uint8Array} + */ +function runDesCbcMac(key8, padded) { + let state = new Uint8Array(8); + for (let i = 0; i < padded.length; i += 8) { + const block = padded.slice(i, i + 8); + state = encryptDesBlock(key8, xorBytes(state, block)); + } + return state; +} + +/** + * Normalizes a MAC key for ISO9797-style MACs. + * + * @param {string} keyHex + * @returns {Uint8Array} + */ +function normalizeIso9797Key(keyHex) { + return parseHexBytes(keyHex, "MAC key", [16, 24]); +} + +/** + * Generates an ISO9797 algorithm 1 MAC. + * + * @param {string} inputHex + * @param {string} keyHex + * @param {string} paddingMethod + * @param {number} outputBytes + * @returns {Object} + */ +function generateIso9797Algorithm1Mac(inputHex, keyHex, paddingMethod, outputBytes=8) { + const data = parseHexBytes(inputHex, "Input data"); + const key = normalizeIso9797Key(keyHex); + const padded = applyIso9797Padding(data, 8, paddingMethod); + const fullMacBytes = encryptTdesBlock(key, runDesCbcMac(key.slice(0, 8), padded)); + const fullMacHex = bytesToHex(fullMacBytes); + const macHex = fullMacHex.substring(0, Math.max(1, Math.min(8, Number(outputBytes) || 8)) * 2); + + return { + algorithm: "ISO 9797-1 Algorithm 1", + paddingMethod, + inputHex: bytesToHex(data), + fullMacHex, + macHex, + }; +} + +/** + * Generates an ISO9797 algorithm 3 retail MAC. + * + * @param {string} inputHex + * @param {string} keyHex + * @param {string} paddingMethod + * @param {number} outputBytes + * @returns {Object} + */ +function generateIso9797Algorithm3Mac(inputHex, keyHex, paddingMethod, outputBytes=8) { + const data = parseHexBytes(inputHex, "Input data"); + const key = normalizeIso9797Key(keyHex); + const padded = applyIso9797Padding(data, 8, paddingMethod); + const key1 = key.slice(0, 8); + const key2 = key.slice(8, 16); + const key3 = key.length === 24 ? key.slice(16, 24) : key1; + const cbcState = runDesCbcMac(key1, padded); + const fullMacBytes = encryptDesBlock(key3, decryptDesBlock(key2, cbcState)); + const fullMacHex = bytesToHex(fullMacBytes); + const macHex = fullMacHex.substring(0, Math.max(1, Math.min(8, Number(outputBytes) || 8)) * 2); + + return { + algorithm: "ISO 9797-1 Algorithm 3", + paddingMethod, + inputHex: bytesToHex(data), + fullMacHex, + macHex, + }; +} + +/** + * Generates an AS2805 4.1 MAC. + * + * @param {string} inputHex + * @param {string} keyHex + * @param {string} paddingMethod + * @param {number} outputBytes + * @returns {Object} + */ +function generateAs2805Mac(inputHex, keyHex, paddingMethod="Method 1", outputBytes=8) { + const retail = generateIso9797Algorithm3Mac(inputHex, keyHex, paddingMethod, outputBytes); + return { + ...retail, + algorithm: "AS2805-4.1" + }; +} + +export { + ISO9797_PADDING_METHODS, + generateAs2805Mac, + generateIso9797Algorithm1Mac, + generateIso9797Algorithm3Mac, +}; diff --git a/src/core/lib/Pan.mjs b/src/core/lib/Pan.mjs new file mode 100644 index 0000000000..18d32d0840 --- /dev/null +++ b/src/core/lib/Pan.mjs @@ -0,0 +1,350 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; + +const PAN_BRANDS = ["Visa", "Mastercard", "American Express", "Discover"]; +const MASTERCARD_SERIES = ["Any", "5-series (51-55)", "2-series (2221-2720)"]; + +// ── Card classification tables ──────────────────────────────────────────────── + +const MII_DESCRIPTIONS = { + "0": "ISO/TC 68 — Reserved", + "1": "Airlines", + "2": "Airlines and other future industry assignments", + "3": "Travel and entertainment (American Express, Diners Club)", + "4": "Banking and financial (Visa)", + "5": "Banking and financial (Mastercard)", + "6": "Merchandising and banking (Discover, Maestro)", + "7": "Petroleum and other future industry assignments", + "8": "Healthcare, telecommunications, and other future assignments", + "9": "National government assignment", +}; + +// Best-effort card type per brand — cannot determine credit/debit/prepaid +// from the PAN alone without a BIN database lookup. +const BRAND_TYPE_HINTS = { + "Visa": { + likelyType: "Unknown", + confidence: "low", + note: "Visa issues credit, debit, and prepaid cards. The product type is determined by the issuer BIN, not the PAN prefix — a BIN database lookup is required.", + }, + "Mastercard": { + likelyType: "Unknown", + confidence: "low", + note: "Mastercard issues credit, debit, and prepaid cards. The product type is determined by the issuer BIN, not the PAN prefix — a BIN database lookup is required.", + }, + "American Express": { + likelyType: "Credit / Charge", + confidence: "high", + note: "American Express does not issue traditional debit cards. Cards in the 34/37 BIN range are charge cards or credit products.", + }, + "Discover": { + likelyType: "Credit", + confidence: "medium", + note: "The common Discover BIN ranges (6011, 644-649, 65, 622126-622925) are predominantly credit cards. Discover does offer some debit products on separate BIN ranges.", + }, +}; + +const PAN_BRAND_RULES = { + "Visa": { + lengths: [13, 16, 19], + curatedPan: "4024140000000131", + curatedSource: "Public Visa test PAN published in Mastercard AVS scenario documentation.", + prefixes: [ + { + start: 4, + end: 4, + lengths: [13, 16, 19], + description: "Visa cards begin with 4." + } + ] + }, + "Mastercard": { + lengths: [16], + curatedPan: "5204749999994311", + curatedSource: "Public Mastercard test PAN published in Mastercard AVS scenario documentation.", + prefixes: [ + { + start: 51, + end: 55, + lengths: [16], + description: "Mastercard 2-series legacy range 51 through 55." + }, + { + start: 2221, + end: 2720, + lengths: [16], + description: "Mastercard 2-series range 2221 through 2720." + } + ] + }, + "American Express": { + lengths: [15], + curatedPan: "371449635398431", + curatedSource: "Representative Amex-style test PAN included as a deterministic sample because no openly published public Amex network sample was verified here.", + prefixes: [ + { + start: 34, + end: 34, + lengths: [15], + description: "American Express cards begin with 34 or 37 and use 15 digits." + }, + { + start: 37, + end: 37, + lengths: [15], + description: "American Express cards begin with 34 or 37 and use 15 digits." + } + ] + }, + "Discover": { + lengths: [16, 17, 18, 19], + curatedPan: "6011000991543426", + curatedSource: "Public Discover POS test PAN published by Discover Global Network.", + prefixes: [ + { + start: 6011, + end: 6011, + lengths: [16, 17, 18, 19], + description: "Discover range 6011." + }, + { + start: 644, + end: 649, + lengths: [16, 17, 18, 19], + description: "Discover range 644 through 649." + }, + { + start: 65, + end: 65, + lengths: [16, 17, 18, 19], + description: "Discover range 65." + }, + { + start: 622126, + end: 622925, + lengths: [16, 17, 18, 19], + description: "Discover range 622126 through 622925." + } + ] + } +}; + +/** + * Normalizes 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; +} + +/** + * Calculates a Luhn check digit for a numeric body. + * + * @param {string} body + * @returns {number} + */ +function luhnCheckDigit(body) { + let sum = 0; + let doubleDigit = true; + + for (let i = body.length - 1; i >= 0; i--) { + let digit = parseInt(body.charAt(i), 10); + if (doubleDigit) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + doubleDigit = !doubleDigit; + } + + return (10 - (sum % 10)) % 10; +} + +/** + * Returns whether a full PAN passes Luhn validation. + * + * @param {string} pan + * @returns {boolean} + */ +function isLuhnValid(pan) { + const normalized = normalizePan(pan); + const body = normalized.slice(0, -1); + return luhnCheckDigit(body) === parseInt(normalized.slice(-1), 10); +} + +/** + * Returns the first matching brand rule for a PAN. + * + * @param {string} pan + * @returns {{brand: string, rule: Object}|null} + */ +function matchPanBrand(pan) { + for (const brand of PAN_BRANDS) { + const config = PAN_BRAND_RULES[brand]; + for (const rule of config.prefixes) { + const prefixLength = String(rule.start).length; + if (!rule.lengths.includes(pan.length)) continue; + const prefix = parseInt(pan.substring(0, prefixLength), 10); + if (prefix >= rule.start && prefix <= rule.end) { + return { brand, rule }; + } + } + } + + return null; +} + +/** + * Parses a PAN and returns payment-network details. + * + * @param {string} pan + * @returns {Object} + */ +function parsePan(pan) { + const normalized = normalizePan(pan); + const match = matchPanBrand(normalized); + + const mii = normalized.charAt(0); + const brand = match ? match.brand : null; + const typeHint = brand ? BRAND_TYPE_HINTS[brand] : null; + + return { + pan: normalized, + network: brand || "Unknown", + ...(typeHint && typeHint.confidence !== "low" ? { + cardType: typeHint.likelyType, + cardTypeConfidence: typeHint.confidence, + cardTypeNote: typeHint.note, + } : {}), + majorIndustryIdentifier: mii, + majorIndustryIdentifierDescription: MII_DESCRIPTIONS[mii] || "Unknown", + issuerIdentificationNumber: normalized.substring(0, Math.min(8, normalized.length)), + length: normalized.length, + luhnValid: isLuhnValid(normalized), + matchedRule: match ? { + rangeStart: String(match.rule.start), + rangeEnd: String(match.rule.end), + lengths: match.rule.lengths, + description: match.rule.description, + } : null, + }; +} + +/** + * Appends a Luhn check digit to a numeric PAN body. + * + * @param {string} body + * @returns {string} + */ +function finalizePan(body) { + return `${body}${luhnCheckDigit(body)}`; +} + +/** + * Generates random filler digits. + * + * @param {number} length + * @returns {string} + */ +function fillerDigits(length) { + const buf = new Uint8Array(length); + crypto.getRandomValues(buf); + return Array.from(buf, b => b % 10).join(""); +} + +/** + * Generates a random brand-valid PAN. + * + * @param {string} brand + * @param {number} requestedLength + * @param {string} mastercardSeries - "Any", "5-series (51-55)", or "2-series (2221-2720)" + * @returns {{pan: string, prefixDescription: string}} + */ +function generateBrandPan(brand, requestedLength, mastercardSeries = "Any") { + const config = PAN_BRAND_RULES[brand]; + if (!config) { + throw new OperationError("Unsupported payment network."); + } + + const length = config.lengths.includes(requestedLength) ? requestedLength : config.lengths[0]; + const eligibleRules = config.prefixes.filter(r => r.lengths.includes(length)); + + let selectedRule; + if (brand === "Mastercard") { + if (mastercardSeries === "5-series (51-55)") { + selectedRule = config.prefixes[0]; + } else if (mastercardSeries === "2-series (2221-2720)") { + selectedRule = config.prefixes[1]; + } else { + selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)]; + } + } else { + selectedRule = eligibleRules[Math.floor(Math.random() * eligibleRules.length)]; + } + + const prefixValue = selectedRule.start + Math.floor(Math.random() * (selectedRule.end - selectedRule.start + 1)); + const prefix = String(prefixValue); + const bodyLength = length - 1; + const body = `${prefix}${fillerDigits(bodyLength - prefix.length)}`.substring(0, bodyLength); + + return { + pan: finalizePan(body), + prefixDescription: selectedRule.description + }; +} + +/** + * Generates a test PAN. + * + * @param {string} brand + * @param {string} mode + * @param {number} length + * @param {string} mastercardSeries + * @returns {Object} + */ +function generateTestPan(brand, mode, length, mastercardSeries = "Any") { + const config = PAN_BRAND_RULES[brand]; + if (!config) { + throw new OperationError("Unsupported payment network."); + } + + if (mode === "Curated sample") { + const parsed = parsePan(config.curatedPan); + return { + brand, + mode, + pan: config.curatedPan, + source: config.curatedSource, + ...parsed + }; + } + + const generated = generateBrandPan(brand, Number(length) || config.lengths[0], mastercardSeries); + const parsed = parsePan(generated.pan); + return { + brand, + mode, + pan: generated.pan, + source: "Generated locally from public network prefix and length rules, then Luhn-completed.", + generationRule: generated.prefixDescription, + ...parsed + }; +} + +export { + PAN_BRANDS, + MASTERCARD_SERIES, + generateTestPan, + isLuhnValid, + parsePan, +}; diff --git a/src/core/lib/PaymentDataCipher.mjs b/src/core/lib/PaymentDataCipher.mjs new file mode 100644 index 0000000000..ff36fbc4ae --- /dev/null +++ b/src/core/lib/PaymentDataCipher.mjs @@ -0,0 +1,210 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import AESEncrypt from "../operations/AESEncrypt.mjs"; +import AESDecrypt from "../operations/AESDecrypt.mjs"; +import TripleDESEncrypt from "../operations/TripleDESEncrypt.mjs"; +import TripleDESDecrypt from "../operations/TripleDESDecrypt.mjs"; +import DeriveDUKPTKey from "../operations/DeriveDUKPTKey.mjs"; + +const PAYMENT_CIPHER_PROFILES = [ + "AES CBC", + "AES CTR", + "AES ECB", + "TDES CBC", + "TDES ECB", + "DUKPT TDES CBC", + "DUKPT TDES ECB", +]; + +const DUKPT_DATA_VARIANTS = ["None", "Data"]; + +/** + * Validates hex input. + * + * @param {string} value + * @param {string} name + * @param {boolean} allowEmpty + * @returns {string} + */ +function normalizeHex(value, name, allowEmpty=false) { + const normalized = (value || "").replace(/\s+/g, "").toUpperCase(); + if (!normalized.length && allowEmpty) return ""; + if (!/^[0-9A-F]+$/.test(normalized) || normalized.length % 2 !== 0) { + throw new OperationError(`${name} must be even-length hex.`); + } + return normalized; +} + +/** + * Resolves the working key for the selected cipher profile. + * + * @param {string} profile + * @param {string} keyHex + * @param {string} ksn + * @param {string} dukptVariant + * @returns {{keyHex: string, keyContext: Object}} + */ +function resolveCipherKey(profile, keyHex, ksn, dukptVariant) { + if (!profile.startsWith("DUKPT ")) { + return { + keyHex: normalizeHex(keyHex, "Key"), + keyContext: { keySource: "Direct key input" } + }; + } + + const normalizedKey = normalizeHex(keyHex, "BDK"); + const normalizedKsn = normalizeHex(ksn, "KSN"); + const dukpt = new DeriveDUKPTKey(); + const derivedKey = dukpt.run(normalizedKey, ["Derive Session Key", normalizedKsn, dukptVariant, false]); + + return { + keyHex: derivedKey, + keyContext: { + keySource: "Derived from DUKPT BDK", + ksn: normalizedKsn, + dukptVariant + } + }; +} + +/** + * Encrypts payment data using the selected profile. + * + * @param {string} inputHex + * @param {string} profile + * @param {string} keyHex + * @param {string} ivHex + * @param {string} ksn + * @param {string} dukptVariant + * @returns {Object} + */ +function encryptPaymentData(inputHex, profile, keyHex, ivHex, ksn, dukptVariant) { + const plaintextHex = normalizeHex(inputHex, "Input data"); + const normalizedIv = normalizeHex(ivHex, "IV", true); + const { keyHex: effectiveKeyHex, keyContext } = resolveCipherKey(profile, keyHex, ksn, dukptVariant); + + let ciphertextHex; + if (profile.startsWith("AES ")) { + const aes = new AESEncrypt(); + const mode = profile.substring(4); + ciphertextHex = aes.run(plaintextHex, [ + { string: effectiveKeyHex, option: "Hex" }, + { string: normalizedIv, option: "Hex" }, + mode, + "Hex", + "Hex", + { string: "", option: "Hex" } + ]).toUpperCase(); + } else { + const tdes = new TripleDESEncrypt(); + const mode = profile.endsWith("CBC") ? "CBC" : "ECB"; + ciphertextHex = tdes.run(plaintextHex, [ + { string: effectiveKeyHex, option: "Hex" }, + { string: normalizedIv, option: "Hex" }, + mode, + "Hex", + "Hex" + ]).toUpperCase(); + } + + return { + profile, + plaintextHex, + ciphertextHex, + ivHex: normalizedIv, + ...keyContext + }; +} + +/** + * Decrypts payment data using the selected profile. + * + * @param {string} inputHex + * @param {string} profile + * @param {string} keyHex + * @param {string} ivHex + * @param {string} ksn + * @param {string} dukptVariant + * @returns {Object} + */ +function decryptPaymentData(inputHex, profile, keyHex, ivHex, ksn, dukptVariant) { + const ciphertextHex = normalizeHex(inputHex, "Input data"); + const normalizedIv = normalizeHex(ivHex, "IV", true); + const { keyHex: effectiveKeyHex, keyContext } = resolveCipherKey(profile, keyHex, ksn, dukptVariant); + + let plaintextHex; + if (profile.startsWith("AES ")) { + const aes = new AESDecrypt(); + const mode = profile.substring(4); + plaintextHex = aes.run(ciphertextHex, [ + { string: effectiveKeyHex, option: "Hex" }, + { string: normalizedIv, option: "Hex" }, + mode, + "Hex", + "Hex", + { string: "", option: "Hex" }, + { string: "", option: "Hex" } + ]).toUpperCase(); + } else { + const tdes = new TripleDESDecrypt(); + const mode = profile.endsWith("CBC") ? "CBC" : "ECB"; + plaintextHex = tdes.run(ciphertextHex, [ + { string: effectiveKeyHex, option: "Hex" }, + { string: normalizedIv, option: "Hex" }, + mode, + "Hex", + "Hex" + ]).toUpperCase(); + } + + return { + profile, + ciphertextHex, + plaintextHex, + ivHex: normalizedIv, + ...keyContext + }; +} + +/** + * Re-encrypts payment data by decrypting under one profile and encrypting under another. + * + * @param {string} inputHex + * @param {Object} params + * @returns {Object} + */ +function reEncryptPaymentData(inputHex, params) { + const decrypted = decryptPaymentData( + inputHex, + params.sourceProfile, + params.sourceKeyHex, + params.sourceIvHex, + params.sourceKsn, + params.sourceDukptVariant + ); + const encrypted = encryptPaymentData( + decrypted.plaintextHex, + params.targetProfile, + params.targetKeyHex, + params.targetIvHex, + params.targetKsn, + params.targetDukptVariant + ); + + return { + source: decrypted, + target: encrypted + }; +} + +export { + DUKPT_DATA_VARIANTS, + PAYMENT_CIPHER_PROFILES, + decryptPaymentData, + encryptPaymentData, + reEncryptPaymentData, +}; diff --git a/src/core/lib/PaymentMac.mjs b/src/core/lib/PaymentMac.mjs new file mode 100644 index 0000000000..4acde5417c --- /dev/null +++ b/src/core/lib/PaymentMac.mjs @@ -0,0 +1,205 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Utils from "../Utils.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import HMAC from "../operations/HMAC.mjs"; +import CMAC from "../operations/CMAC.mjs"; +import DeriveDUKPTKey from "../operations/DeriveDUKPTKey.mjs"; +import { + ISO9797_PADDING_METHODS, + generateAs2805Mac, + generateIso9797Algorithm1Mac, + generateIso9797Algorithm3Mac, +} from "./Iso9797.mjs"; + +const PAYMENT_MAC_METHODS = [ + "HMAC SHA-224", + "HMAC SHA-256", + "HMAC SHA-384", + "HMAC SHA-512", + "AES-CMAC", + "TDES-CMAC", + "ISO 9797-1 Algorithm 1", + "ISO 9797-1 Algorithm 3", + "AS2805-4.1", + "DUKPT MAC Request CMAC", + "DUKPT MAC Response CMAC", + "DUKPT ISO 9797-1 Algorithm 1", + "DUKPT ISO 9797-1 Algorithm 3", +]; + +/** + * Converts a string input into an ArrayBuffer according to the selected format. + * + * @param {string} input + * @param {string} inputFormat + * @returns {ArrayBuffer} + */ +function convertInputToBuffer(input, inputFormat) { + const byteString = Utils.convertToByteString(input || "", inputFormat); + return Utils.strToArrayBuffer(byteString); +} + +/** + * Resolves the effective MAC key for the selected method. + * + * @param {string} method + * @param {Object} keySpec + * @returns {{keyHex: string, keyContext: Object}} + */ +function resolveMacKey(method, keySpec) { + const normalizedKey = (keySpec.keyValue || "").replace(/\s+/g, ""); + + if ( + method === "DUKPT MAC Request CMAC" || + method === "DUKPT MAC Response CMAC" || + method === "DUKPT ISO 9797-1 Algorithm 1" || + method === "DUKPT ISO 9797-1 Algorithm 3" + ) { + if (keySpec.keyFormat !== "Hex") { + throw new OperationError("DUKPT BDK must be provided in hex."); + } + if (!keySpec.ksn) { + throw new OperationError("KSN is required for DUKPT MAC methods."); + } + + const variant = method === "DUKPT MAC Response CMAC" ? "MAC Response" : "MAC Request"; + const dukpt = new DeriveDUKPTKey(); + const keyHex = dukpt.run(normalizedKey, ["Derive Session Key", keySpec.ksn, variant, false]); + + return { + keyHex, + keyContext: { + keySource: "Derived from DUKPT BDK", + ksn: keySpec.ksn.replace(/\s+/g, "").toUpperCase(), + dukptVariant: variant + } + }; + } + + const byteString = Utils.convertToByteString(keySpec.keyValue || "", keySpec.keyFormat); + if (!byteString.length) { + throw new OperationError("Key material is required."); + } + + return { + keyHex: byteStringToHex(byteString), + keyContext: { + keySource: "Direct key input" + } + }; +} + +/** + * Converts a byte string into uppercase hex. + * + * @param {string} byteString + * @returns {string} + */ +function byteStringToHex(byteString) { + return Array.from(byteString, ch => ch.charCodeAt(0).toString(16).padStart(2, "0")).join("").toUpperCase(); +} + +/** + * Generates a payment MAC using the selected method. + * + * @param {string} input + * @param {string} inputFormat + * @param {string} method + * @param {string} keyValue + * @param {string} keyFormat + * @param {string} ksn + * @param {number} outputBytes + * @param {string} paddingMethod + * @returns {Object} + */ +function generatePaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, outputBytes, paddingMethod="Method 1") { + const normalizedOutputBytes = Math.max(1, Number(outputBytes) || 8); + const inputBuffer = convertInputToBuffer(input, inputFormat); + const inputHex = byteStringToHex(Utils.arrayBufferToStr(inputBuffer, false)); + const { keyHex, keyContext } = resolveMacKey(method, { keyValue, keyFormat, ksn }); + + let fullMacHex; + if (method.startsWith("HMAC ")) { + const hmac = new HMAC(); + const hashName = { + "HMAC SHA-224": "SHA224", + "HMAC SHA-256": "SHA256", + "HMAC SHA-384": "SHA384", + "HMAC SHA-512": "SHA512", + }[method]; + fullMacHex = hmac.run(inputBuffer, [{ string: keyHex, option: "Hex" }, hashName]).toUpperCase(); + } else if (method === "AES-CMAC" || method === "TDES-CMAC" || method === "DUKPT MAC Request CMAC" || method === "DUKPT MAC Response CMAC") { + const cmac = new CMAC(); + const algorithm = method === "AES-CMAC" ? "AES" : "Triple DES"; + fullMacHex = cmac.run(inputBuffer, [{ string: keyHex, option: "Hex" }, algorithm]).toUpperCase(); + } else if (method === "ISO 9797-1 Algorithm 1" || method === "DUKPT ISO 9797-1 Algorithm 1") { + fullMacHex = generateIso9797Algorithm1Mac(inputHex, keyHex, paddingMethod, 8).fullMacHex; + } else if (method === "ISO 9797-1 Algorithm 3" || method === "DUKPT ISO 9797-1 Algorithm 3") { + fullMacHex = generateIso9797Algorithm3Mac(inputHex, keyHex, paddingMethod, 8).fullMacHex; + } else if (method === "AS2805-4.1") { + fullMacHex = generateAs2805Mac(inputHex, keyHex, paddingMethod, 8).fullMacHex; + } else { + throw new OperationError("Unsupported payment MAC method."); + } + + const macHex = fullMacHex.substring(0, normalizedOutputBytes * 2); + + return { + method, + inputFormat, + inputHex, + paddingMethod: method.startsWith("HMAC ") || method.includes("CMAC") ? null : paddingMethod, + outputBytes: normalizedOutputBytes, + fullMacHex, + macHex, + ...keyContext + }; +} + +/** + * Verifies a payment MAC by recomputing and comparing it. + * + * @param {string} input + * @param {string} inputFormat + * @param {string} method + * @param {string} keyValue + * @param {string} keyFormat + * @param {string} ksn + * @param {string} expectedMac + * @param {string} paddingMethod + * @returns {Object} + */ +function verifyPaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, expectedMac, paddingMethod="Method 1") { + const normalizedExpected = (expectedMac || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]+$/.test(normalizedExpected) || normalizedExpected.length % 2 !== 0) { + throw new OperationError("Expected MAC must be even-length hex."); + } + + const generated = generatePaymentMac( + input, + inputFormat, + method, + keyValue, + keyFormat, + ksn, + normalizedExpected.length / 2, + paddingMethod + ); + + return { + ...generated, + expectedMacHex: normalizedExpected, + valid: generated.macHex === normalizedExpected + }; +} + +export { + ISO9797_PADDING_METHODS, + PAYMENT_MAC_METHODS, + generatePaymentMac, + verifyPaymentMac, +}; diff --git a/src/core/lib/PaymentPinVerification.mjs b/src/core/lib/PaymentPinVerification.mjs new file mode 100644 index 0000000000..33c419f497 --- /dev/null +++ b/src/core/lib/PaymentPinVerification.mjs @@ -0,0 +1,262 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import { bytesToHex, parseHexBytes } from "./PaymentUtils.mjs"; +import { encryptTdesEcb } from "./CardValidationInternals.mjs"; + +/** + * Normalizes a PAN string. + * + * @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; +} + +/** + * Normalizes a clear PIN string. + * + * @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; +} + +/** + * Converts hexadecimal characters to decimal digits via a decimalization table. + * + * @param {string} hex + * @param {string} decimalizationTable + * @returns {string} + */ +function decimalizeHex(hex, decimalizationTable) { + const normalizedTable = (decimalizationTable || "").replace(/\s+/g, ""); + if (!/^\d{16}$/.test(normalizedTable)) { + throw new OperationError("Decimalization table must be 16 decimal digits."); + } + + let out = ""; + for (const ch of hex.toUpperCase()) { + out += normalizedTable[parseInt(ch, 16)]; + } + return out; +} + +/** + * Packs a hex string into bytes. + * + * @param {string} hex + * @returns {Uint8Array} + */ +function packHex(hex) { + return parseHexBytes(hex, "Packed block"); +} + +/** + * Generates the IBM 3624 natural PIN. + * + * @param {string} pvkHex + * @param {string} decimalizationTable + * @param {string} pinValidationData + * @param {string} padCharacter + * @param {number} pinLength + * @returns {Object} + */ +function generateIbm3624NaturalPin(pvkHex, decimalizationTable, pinValidationData, padCharacter, pinLength=4) { + const normalizedValidationData = (pinValidationData || "").replace(/\s+/g, ""); + const normalizedPad = (padCharacter || "").replace(/\s+/g, "").toUpperCase(); + const normalizedPinLength = Math.max(4, Math.min(12, Number(pinLength) || 4)); + + if (!/^\d{4,16}$/.test(normalizedValidationData)) { + throw new OperationError("PIN validation data must be 4 to 16 decimal digits."); + } + if (!/^[0-9A-F]$/.test(normalizedPad)) { + throw new OperationError("PIN validation data pad character must be one hex nibble."); + } + + const pvk = parseHexBytes(pvkHex, "PIN verification key", [16, 24]); + const blockHex = normalizedValidationData.padEnd(16, normalizedPad).substring(0, 16); + const cipherHex = bytesToHex(encryptTdesEcb(pvk, packHex(blockHex))); + const decimalized = decimalizeHex(cipherHex, decimalizationTable); + + return { + pinVerificationKeyHex: bytesToHex(pvk), + pinValidationData: normalizedValidationData, + pinValidationDataPadCharacter: normalizedPad, + pinLength: normalizedPinLength, + validationBlockHex: blockHex, + encryptedValidationBlockHex: cipherHex, + decimalized, + naturalPin: decimalized.substring(0, normalizedPinLength) + }; +} + +/** + * Generates an IBM 3624 offset for a supplied clear PIN. + * + * @param {string} pvkHex + * @param {string} decimalizationTable + * @param {string} pinValidationData + * @param {string} padCharacter + * @param {string} pin + * @returns {Object} + */ +function generateIbm3624PinOffset(pvkHex, decimalizationTable, pinValidationData, padCharacter, pin) { + const normalizedPin = normalizePin(pin); + const natural = generateIbm3624NaturalPin( + pvkHex, + decimalizationTable, + pinValidationData, + padCharacter, + normalizedPin.length + ); + let offset = ""; + for (let i = 0; i < normalizedPin.length; i++) { + offset += ((parseInt(normalizedPin[i], 10) - parseInt(natural.naturalPin[i], 10) + 10) % 10).toString(); + } + return { + ...natural, + pin: normalizedPin, + pinOffset: offset + }; +} + +/** + * Verifies a clear PIN against an IBM 3624 offset. + * + * @param {string} pvkHex + * @param {string} decimalizationTable + * @param {string} pinValidationData + * @param {string} padCharacter + * @param {string} pinOffset + * @param {string} pin + * @returns {Object} + */ +function verifyIbm3624Pin(pvkHex, decimalizationTable, pinValidationData, padCharacter, pinOffset, pin) { + const normalizedOffset = (pinOffset || "").replace(/\s+/g, ""); + const normalizedPin = normalizePin(pin); + if (!/^\d{4,12}$/.test(normalizedOffset) || normalizedOffset.length !== normalizedPin.length) { + throw new OperationError("PIN offset must be 4 to 12 digits and match PIN length."); + } + + const generated = generateIbm3624PinOffset( + pvkHex, + decimalizationTable, + pinValidationData, + padCharacter, + normalizedPin + ); + + return { + ...generated, + expectedPinOffset: normalizedOffset, + valid: generated.pinOffset === normalizedOffset + }; +} + +/** + * Decimalizes a PVV candidate using the standard two-pass rule: + * pass 1 collects decimal digits (0-9); pass 2 maps A=0 B=1 C=2 D=3 E=4 F=5. + * + * @param {string} hex + * @returns {string} + */ +function decimalizePvv(hex) { + const upper = hex.toUpperCase(); + let out = ""; + + for (const ch of upper) { + if (/\d/.test(ch)) { + out += ch; + if (out.length >= 4) return out.substring(0, 4); + } + } + + for (const ch of upper) { + if (/[A-F]/.test(ch)) { + out += String(ch.charCodeAt(0) - "A".charCodeAt(0)); + if (out.length >= 4) return out.substring(0, 4); + } + } + + return out.substring(0, 4); +} + +/** + * Generates a VISA PVV. + * + * @param {string} pvkHex + * @param {string} pan + * @param {string|number} pvki + * @param {string} pin + * @returns {Object} + */ +function generateVisaPvv(pvkHex, pan, pvki, pin) { + const normalizedPan = normalizePan(pan); + const normalizedPin = normalizePin(pin); + const normalizedPvki = String(pvki ?? "").replace(/\s+/g, ""); + + if (!/^[0-6]$/.test(normalizedPvki)) { + throw new OperationError("PVKI must be a single digit from 0 to 6."); + } + + const pvk = parseHexBytes(pvkHex, "PIN verification key", [16, 24]); + const pvvInput = `${normalizedPan.slice(-12, -1)}${normalizedPvki}${normalizedPin.substring(0, 4)}`; + const encryptedHex = bytesToHex(encryptTdesEcb(pvk, packHex(pvvInput))); + const pvv = decimalizePvv(encryptedHex); + + return { + pinVerificationKeyHex: bytesToHex(pvk), + pan: normalizedPan, + pinVerificationKeyIndex: Number(normalizedPvki), + pin: normalizedPin, + pvvInput, + encryptedPvvInputHex: encryptedHex, + pvv + }; +} + +/** + * Verifies a VISA PVV. + * + * @param {string} pvkHex + * @param {string} pan + * @param {string|number} pvki + * @param {string} pin + * @param {string} expectedPvv + * @returns {Object} + */ +function verifyVisaPvv(pvkHex, pan, pvki, pin, expectedPvv) { + const normalizedExpected = (expectedPvv || "").replace(/\s+/g, ""); + if (!/^\d{4}$/.test(normalizedExpected)) { + throw new OperationError("Expected PVV must be 4 digits."); + } + + const generated = generateVisaPvv(pvkHex, pan, pvki, pin); + return { + ...generated, + expectedPvv: normalizedExpected, + valid: generated.pvv === normalizedExpected + }; +} + +export { + generateIbm3624NaturalPin, + generateIbm3624PinOffset, + generateVisaPvv, + verifyIbm3624Pin, + verifyVisaPvv, +}; diff --git a/src/core/lib/PaymentUtils.mjs b/src/core/lib/PaymentUtils.mjs new file mode 100644 index 0000000000..8b12425917 --- /dev/null +++ b/src/core/lib/PaymentUtils.mjs @@ -0,0 +1,76 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import { toHexFast } from "./Hex.mjs"; + +/** + * Parses hex into bytes. + * + * @param {string} input + * @param {string} name + * @param {number[]} [allowedLengths] + * @returns {Uint8Array} + */ +function parseHexBytes(input, name, allowedLengths=[]) { + const normalized = (input || "").replace(/\s+/g, ""); + if (!/^[0-9a-fA-F]*$/.test(normalized) || normalized.length % 2 !== 0) { + throw new OperationError(`${name} must be hex.`); + } + + const out = new Uint8Array(normalized.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(normalized.substring(i * 2, i * 2 + 2), 16); + } + + if (allowedLengths.length && !allowedLengths.includes(out.length)) { + throw new OperationError(`${name} must be ${allowedLengths.join(" or ")} bytes.`); + } + + return out; +} + + +/** + * Converts bytes to uppercase hex. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function bytesToHex(bytes) { + return toHexFast(bytes).toUpperCase(); +} + + +/** + * Converts bytes to a forge-compatible byte string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function toByteString(bytes) { + return Array.from(bytes, byte => String.fromCharCode(byte)).join(""); +} + + +/** + * Converts hex to an ArrayBuffer. + * + * @param {string} input + * @param {string} name + * @returns {ArrayBuffer} + */ +function parseHexBuffer(input, name) { + const bytes = parseHexBytes(input, name); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + + +export { + bytesToHex, + parseHexBuffer, + parseHexBytes, + toByteString, +}; diff --git a/src/core/lib/PinBlock.mjs b/src/core/lib/PinBlock.mjs new file mode 100644 index 0000000000..1adb199580 --- /dev/null +++ b/src/core/lib/PinBlock.mjs @@ -0,0 +1,250 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import OperationError from "../errors/OperationError.mjs"; +import { toHexFast } from "./Hex.mjs"; + +const PIN_BLOCK_FORMATS = ["ISO Format 0", "ISO Format 1", "ISO Format 3"]; + +/** + * Returns a random nibble in the given inclusive range. + * + * @param {number} min + * @param {number} max + * @returns {number} + */ +function randomNibble(min, max) { + const range = max - min + 1; + + if (globalThis.crypto && globalThis.crypto.getRandomValues) { + const buf = new Uint8Array(1); + globalThis.crypto.getRandomValues(buf); + return min + (buf[0] % range); + } + + return min + Math.floor(Math.random() * range); +} + +/** + * Converts a hex string into nibble values. + * + * @param {string} hex + * @returns {number[]} + */ +function hexToNibbles(hex) { + return hex.toUpperCase().split("").map(ch => parseInt(ch, 16)); +} + +/** + * Converts nibble values into a byte array. + * + * @param {number[]} nibbles + * @returns {Uint8Array} + */ +function nibblesToBytes(nibbles) { + const out = new Uint8Array(nibbles.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = (nibbles[i * 2] << 4) | nibbles[i * 2 + 1]; + } + return out; +} + +/** + * XORs two nibble arrays. + * + * @param {number[]} a + * @param {number[]} b + * @returns {number[]} + */ +function xorNibbles(a, b) { + return a.map((value, index) => value ^ b[index]); +} + +/** + * Normalizes and validates a PIN. + * + * @param {string} pin + * @returns {string} + */ +function normalizePin(pin) { + const normalized = (pin || "").replace(/\s+/g, ""); + if (!/^\d{4,12}$/.test(normalized)) { + throw new OperationError("PIN must be 4 to 12 digits."); + } + return normalized; +} + +/** + * Normalizes and validates a PAN. + * + * @param {string} pan + * @returns {string} + */ +function normalizePan(pan) { + const normalized = (pan || "").replace(/\s+/g, ""); + if (!/^\d{12,19}$/.test(normalized)) { + throw new OperationError("PAN must be 12 to 19 digits."); + } + return normalized; +} + +/** + * Parses an 8-byte PIN block hex string. + * + * @param {string} blockHex + * @returns {string} + */ +function normalizeBlockHex(blockHex) { + const normalized = (blockHex || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]{16}$/.test(normalized)) { + throw new OperationError("PIN block must be 16 hex characters (8 bytes)."); + } + return normalized; +} + +/** + * Builds the PIN field for a clear PIN block. + * + * @param {string} format + * @param {string} pin + * @param {boolean} randomizeFill + * @returns {number[]} + */ +function buildPinField(format, pin, randomizeFill) { + const formatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3; + const pinNibbles = pin.split("").map(digit => parseInt(digit, 10)); + const out = [formatNibble, pin.length, ...pinNibbles]; + + while (out.length < 16) { + if (format === "ISO Format 0") { + out.push(0xF); + } else if (format === "ISO Format 1") { + out.push(randomizeFill ? randomNibble(0x0, 0xF) : 0xF); + } else { + out.push(randomizeFill ? randomNibble(0xA, 0xF) : 0xA); + } + } + + return out; +} + +/** + * Builds the PAN field for PAN-bound PIN block formats. + * + * @param {string} pan + * @returns {number[]} + */ +function buildPanField(pan) { + const normalizedPan = normalizePan(pan); + const pan12 = normalizedPan.slice(0, -1).slice(-12).padStart(12, "0"); + return hexToNibbles(`0000${pan12}`); +} + +/** + * Builds a clear PIN block. + * + * @param {string} format + * @param {string} pin + * @param {string} pan + * @param {boolean} randomizeFill + * @returns {string} + */ +function buildPinBlock(format, pin, pan, randomizeFill) { + if (!PIN_BLOCK_FORMATS.includes(format)) { + throw new OperationError("Unsupported PIN block format."); + } + + const normalizedPin = normalizePin(pin); + const pinField = buildPinField(format, normalizedPin, randomizeFill); + + if (format === "ISO Format 1") { + return toHexFast(nibblesToBytes(pinField)).toUpperCase(); + } + + const panField = buildPanField(pan); + return toHexFast(nibblesToBytes(xorNibbles(pinField, panField))).toUpperCase(); +} + +/** + * Parses a clear PIN block. + * + * @param {string} format + * @param {string} blockHex + * @param {string} pan + * @returns {Object} + */ +function parsePinBlock(format, blockHex, pan) { + if (!PIN_BLOCK_FORMATS.includes(format)) { + throw new OperationError("Unsupported PIN block format."); + } + + const normalizedBlock = normalizeBlockHex(blockHex); + const clearField = format === "ISO Format 1" ? + hexToNibbles(normalizedBlock) : + xorNibbles(hexToNibbles(normalizedBlock), buildPanField(pan)); + + const formatNibble = clearField[0]; + const expectedFormatNibble = format === "ISO Format 0" ? 0x0 : format === "ISO Format 1" ? 0x1 : 0x3; + if (formatNibble !== expectedFormatNibble) { + throw new OperationError(`PIN block does not decode as ${format}.`); + } + + const pinLength = clearField[1]; + if (pinLength < 4 || pinLength > 12) { + throw new OperationError("Decoded PIN length is invalid."); + } + + const pinDigits = clearField.slice(2, 2 + pinLength); + if (pinDigits.some(nibble => nibble < 0x0 || nibble > 0x9)) { + throw new OperationError("Decoded PIN contains non-decimal digits."); + } + + const fillDigits = clearField.slice(2 + pinLength); + if (format === "ISO Format 0" && fillDigits.some(nibble => nibble !== 0xF)) { + throw new OperationError("Format 0 filler must be 0xF."); + } + if (format === "ISO Format 3" && fillDigits.some(nibble => nibble < 0xA || nibble > 0xF)) { + throw new OperationError("Format 3 filler must be in the range 0xA to 0xF."); + } + + return { + format, + pin: pinDigits.join(""), + pinLength, + pinFieldHex: toHexFast(nibblesToBytes(clearField)).toUpperCase(), + panFieldHex: format === "ISO Format 1" ? null : toHexFast(nibblesToBytes(buildPanField(pan))).toUpperCase(), + blockHex: normalizedBlock, + fillDigitsHex: fillDigits.map(nibble => nibble.toString(16).toUpperCase()).join("") + }; +} + +/** + * Translates a clear PIN block between formats. + * + * @param {string} blockHex + * @param {string} sourceFormat + * @param {string} sourcePan + * @param {string} targetFormat + * @param {string} targetPan + * @param {boolean} randomizeFill + * @returns {Object} + */ +function translatePinBlock(blockHex, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill) { + const parsed = parsePinBlock(sourceFormat, blockHex, sourcePan); + return { + source: parsed, + target: { + format: targetFormat, + blockHex: buildPinBlock(targetFormat, parsed.pin, targetPan, randomizeFill) + } + }; +} + +export { + PIN_BLOCK_FORMATS, + buildPinBlock, + parsePinBlock, + translatePinBlock, +}; diff --git a/src/core/operations/BuildEMVARPCData.mjs b/src/core/operations/BuildEMVARPCData.mjs new file mode 100644 index 0000000000..c2375fad99 --- /dev/null +++ b/src/core/operations/BuildEMVARPCData.mjs @@ -0,0 +1,104 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { + METHODS, METHOD1, METHOD2, + buildMethod1, buildMethod2, + formatJson, formatAnnotated, +} from "../lib/EmvArpc.mjs"; + +/** + * EMV Build ARPC Data operation. + */ +class BuildEMVARPCData extends Operation { + + /** @inheritdoc */ + constructor() { + super(); + + this.name = "EMV Build ARPC Data"; + this.module = "Payment"; + this.description = "Assemble the EMV authorization-response preimage from named fields and output it as hex for use with EMV Generate ARPC. All data comes from arguments — the input field is not used.

Method 1 (Visa, Amex, Discover, JCB): ARQC (8 bytes) || ARC (2 bytes) — 10 bytes total.
Method 2 (Mastercard M/Chip): ARQC (8 bytes) || CSU (4 bytes) || ProprietaryAuthData (0–8 bytes) — 12–20 bytes total.

Input: ignored.
Arguments: method selector plus one field per preimage element. Fields irrelevant to the selected method are ignored.

Chaining: set Output format to Hex and place this operation first in a recipe to supply the preimage directly into EMV Generate ARPC."; + this.inlineHelp = "Args: select method (1 = Visa/Amex, 2 = Mastercard) and fill the relevant fields. Set format to Hex to chain into EMV Generate ARPC."; + this.testDataSamples = [ + { + name: "Method 1 (Visa/Amex) — hex output", + input: "", + args: [METHOD1, "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }, + { + name: "Method 2 (Mastercard) — hex output", + input: "", + args: [METHOD2, "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }, + { + name: "Method 2 with Proprietary Auth Data — annotated", + input: "", + args: [METHOD2, "A1B2C3D4E5F60708", "5931", "00000000", "AABBCCDD", "Annotated"] + }, + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "ARPC method", + type: "option", + value: METHODS, + comment: "Method 1: Visa, Amex, Discover, JCB. Method 2: Mastercard M/Chip.", + }, + { + name: "ARQC (hex, 8 bytes)", + type: "string", + value: "", + comment: "Authorization Request Cryptogram — output of EMV Generate ARQC.", + }, + { + name: "ARC (hex, 2 bytes) — Method 1", + type: "string", + value: "3030", + comment: "Authorization Response Code. Common values: 3030=00, 5931=Y1 (approve), 5933=Y3, 5A31=Z1 (decline). Used only for Method 1.", + }, + { + name: "Card Status Update / CSU (hex, 4 bytes) — Method 2", + type: "string", + value: "00000000", + comment: "Issuer response flags for PIN change/unblock and go-online. Used only for Method 2.", + }, + { + name: "Proprietary Auth Data (hex, 0–8 bytes) — Method 2", + type: "string", + value: "", + comment: "Optional scheme-specific data appended after CSU. Leave empty if not used. Used only for Method 2.", + }, + { + name: "Output format", + type: "option", + value: ["Hex", "JSON", "Annotated"], + comment: "Hex: flat hex for piping into EMV Generate ARPC. JSON/Annotated: human-readable inspection.", + }, + ]; + } + + /** + * @param {string} input ignored + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [method, arqc, arc, csu, pad, fmt] = args; + + const { fields, hex } = method === METHOD2 ? + buildMethod2(arqc, csu, pad) : + buildMethod1(arqc, arc); + + if (fmt === "JSON") return formatJson(fields, method); + if (fmt === "Annotated") return formatAnnotated(fields, method); + return hex; + } +} + +export default BuildEMVARPCData; diff --git a/src/core/operations/BuildEMVARQCData.mjs b/src/core/operations/BuildEMVARQCData.mjs new file mode 100644 index 0000000000..90812e5f1f --- /dev/null +++ b/src/core/operations/BuildEMVARQCData.mjs @@ -0,0 +1,104 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { buildCdol1, formatHex, formatJson, formatAnnotatedTlv } from "../lib/EmvCdol.mjs"; + +/** + * EMV Build ARQC Data operation. + */ +class BuildEMVARQCData extends Operation { + + /** @inheritdoc */ + constructor() { + super(); + + this.name = "EMV Build ARQC Data"; + this.module = "Payment"; + this.description = "Assemble the 10 standard EMVCo CDOL1 fields into the preassembled ARQC input data block used as input to EMV Generate ARQC and EMV Verify ARQC. All data comes from arguments — the input field is not used.

Input: ignored.
Arguments: one hex field per CDOL1 element plus an output format selector.

Network coverage: the 10-field, 33-byte layout is identical across Visa, Mastercard, Amex, Discover, JCB, and UnionPay acquirer flows. Network differences (Visa/Amex Option A vs Mastercard Option B session-key derivation) occur upstream in key derivation and do not affect the CDOL1 data block structure.

Chaining: set Output format to Hex and place this operation first in a recipe to supply the preimage directly into EMV Generate ARQC without using the input field."; + this.inlineHelp = "Args: one hex field per CDOL1 element. Set format to Hex to chain into EMV Generate ARQC."; + this.testDataSamples = [ + { + name: "Standard CDOL1 — hex output (Visa $10.00 USD, USA terminal)", + input: "", + args: [ + "000000001000", + "000000000000", + "0840", + "0000000000", + "0840", + "260521", + "00", + "A1B2C3D4", + "5900", + "0001", + "Hex", + ] + }, + { + name: "Standard CDOL1 — annotated TLV", + input: "", + args: [ + "000000001000", + "000000000000", + "0840", + "0000000000", + "0840", + "260521", + "00", + "A1B2C3D4", + "5900", + "0001", + "Annotated TLV", + ] + }, + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Amount Authorised (9F02)", type: "string", value: "000000001000", comment: "6-byte BCD minor-unit amount, e.g. 000000001000 = $10.00" }, + { name: "Amount Other (9F03)", type: "string", value: "000000000000", comment: "6-byte BCD cashback amount; 000000000000 if none" }, + { name: "Terminal Country Code (9F1A)", type: "string", value: "0840", comment: "ISO 3166-1 numeric, e.g. 0840 = USA" }, + { name: "TVR (95)", type: "string", value: "0000000000", comment: "5-byte Terminal Verification Results" }, + { name: "Transaction Currency Code (5F2A)", type: "string", value: "0840", comment: "ISO 4217 numeric, e.g. 0840 = USD" }, + { name: "Transaction Date (9A)", type: "string", value: "260521", comment: "3-byte YYMMDD, e.g. 260521 = 2026-05-21" }, + { name: "Transaction Type (9C)", type: "string", value: "00", comment: "1-byte EMV type: 00 = Purchase, 01 = Cash, 09 = Cashback" }, + { name: "Unpredictable Number (9F37)", type: "string", value: "00000000", comment: "4-byte terminal random; use a real random value in production flows" }, + { name: "AIP (82)", type: "string", value: "5900", comment: "2-byte Application Interchange Profile" }, + { name: "ATC (9F36)", type: "string", value: "0001", comment: "2-byte Application Transaction Counter" }, + { + name: "Output format", + type: "option", + value: ["Hex", "JSON", "Annotated TLV"], + comment: "Hex: flat hex suitable for piping into EMV Generate ARQC. JSON/Annotated TLV: human-readable inspection.", + }, + ]; + } + + /** + * @param {string} input ignored + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [ + amountAuth, amountOther, countryCode, tvr, currencyCode, + txDate, txType, unpredictable, aip, atc, + fmt, + ] = args; + + const parsed = buildCdol1([ + amountAuth, amountOther, countryCode, tvr, currencyCode, + txDate, txType, unpredictable, aip, atc, + ]); + + if (fmt === "JSON") return formatJson(parsed); + if (fmt === "Annotated TLV") return formatAnnotatedTlv(parsed); + return formatHex(parsed); + } +} + +export default BuildEMVARQCData; diff --git a/src/core/operations/BuildEMVPINChangeScriptData.mjs b/src/core/operations/BuildEMVPINChangeScriptData.mjs new file mode 100644 index 0000000000..6db4e0cbf5 --- /dev/null +++ b/src/core/operations/BuildEMVPINChangeScriptData.mjs @@ -0,0 +1,60 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { PIN_CHANGE_MODES, buildPinChangeHeader, formatAnnotatedPinChangeHeader } from "../lib/EmvScript.mjs"; + +/** + * Build EMV PIN Change Script Data operation. + */ +class BuildEMVPINChangeScriptData extends Operation { + /** + * BuildEMVPINChangeScriptData constructor. + */ + constructor() { + super(); + + this.name = "EMV Build PIN Change Script Data"; + this.module = "Payment"; + this.description = "Assembles the 5-byte CHANGE REFERENCE DATA (INS=24) command header for a PIN-change issuer script. Use this as the first step in a recipe — the hex output feeds into the EMV Generate MAC (PIN Change) input field, which appends the encrypted PIN block before computing the MAC.

Output: CLA 24 P1 P2 Lc (5 bytes). The Lc field must cover all data bytes that follow in the final APDU: typically 8 bytes for the encrypted PIN block plus 8 bytes for the MAC = 0x10.

P1: 00 = change requires current PIN verification; 01 = change without verification.
P2: PIN reference — 80 is the global PIN reference used by most EMV cards.

Security: Software emulation for testing only."; + this.inlineHelp = "Output: 5-byte CHANGE REFERENCE DATA header. Feed into EMV Generate MAC (PIN Change) as input."; + this.testDataSamples = [ + { + name: "PIN change header sample", + input: "", + args: ["84", "Change with current PIN verification", "80", "10", "Hex"] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "CLA (hex)", type: "string", value: "84", comment: "Class byte. 84 = secure messaging with key from current DF (standard for issuer scripts)." }, + { name: "Change mode (P1)", type: "option", value: PIN_CHANGE_MODES, comment: "P1=00: change requires verification with the current PIN. P1=01: change without current PIN verification." }, + { name: "PIN reference (P2, hex)", type: "string", value: "80", comment: "PIN reference data qualifier. 80 = global PIN reference (most EMV cards). Check card spec for other values." }, + { name: "Lc (hex)", type: "string", value: "10", comment: "Total data length in the final APDU. Default 10 (hex) = 16 bytes: 8-byte encrypted PIN block + 8-byte MAC." }, + { name: "Output format", type: "option", value: ["Hex", "JSON", "Annotated"], comment: "Hex: header ready to chain. JSON: named fields. Annotated: field-by-field breakdown." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [claHex, changeMode, p2Hex, lcHex, outputFormat] = args; + const f = buildPinChangeHeader(claHex, changeMode, p2Hex, lcHex); + if (outputFormat === "JSON") { + return JSON.stringify({ cla: f.cla, ins: f.ins, p1: f.p1, p2: f.p2, lc: f.lc, header: f.header }, null, 4); + } + if (outputFormat === "Annotated") { + return formatAnnotatedPinChangeHeader(f); + } + return f.header; + } +} + +export default BuildEMVPINChangeScriptData; diff --git a/src/core/operations/BuildEMVScriptData.mjs b/src/core/operations/BuildEMVScriptData.mjs new file mode 100644 index 0000000000..fe0dbaac28 --- /dev/null +++ b/src/core/operations/BuildEMVScriptData.mjs @@ -0,0 +1,61 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { SCRIPT_COMMANDS, buildScriptApdu, formatAnnotatedApdu } from "../lib/EmvScript.mjs"; + +/** + * Build EMV Script Data operation. + */ +class BuildEMVScriptData extends Operation { + /** + * BuildEMVScriptData constructor. + */ + constructor() { + super(); + + this.name = "EMV Build Script Data"; + this.module = "Payment"; + this.description = "Assembles an issuer-script command APDU from named fields. Use this as the first step in a recipe chain — the hex output feeds directly into the EMV Generate MAC input field.

Output: CLA | INS | P1 | P2 | Lc | Data — Lc is computed automatically from the data length.

Common INS values: DA=PUT DATA, DB=PUT DATA (ODD), DC=UPDATE RECORD, D6=WRITE BINARY, 26=DISABLE VERIFICATION, 28=ENABLE VERIFICATION, 82=EXTERNAL AUTHENTICATE.

Security: Software emulation for testing only."; + this.inlineHelp = "Output: CLA INS P1 P2 Lc Data APDU hex. Feed into EMV Generate MAC as input."; + this.testDataSamples = [ + { + name: "PUT DATA sample", + input: "", + args: ["84", "PUT DATA", "00", "42", "0102030405060708090A", "Hex"] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "CLA (hex)", type: "string", value: "84", comment: "Class byte. 84 = secure messaging with key from current DF (standard for issuer scripts)." }, + { name: "Command", type: "option", value: SCRIPT_COMMANDS, comment: "Selects the INS byte. Common issuer script commands: PUT DATA (DA/DB), UPDATE RECORD (DC), WRITE BINARY (D6)." }, + { name: "P1 (hex)", type: "string", value: "00", comment: "Parameter 1. Meaning depends on command: record number for UPDATE RECORD, data reference for PUT DATA." }, + { name: "P2 (hex)", type: "string", value: "00", comment: "Parameter 2. Meaning depends on command: SFI+record selector for UPDATE RECORD, data object tag low byte for PUT DATA." }, + { name: "Data (hex)", type: "string", value: "", comment: "Command data payload. Lc is computed automatically from the length." }, + { name: "Output format", type: "option", value: ["Hex", "JSON", "Annotated"], comment: "Hex: APDU ready to chain. JSON: named fields. Annotated: field-by-field breakdown." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [claHex, commandName, p1Hex, p2Hex, dataHex, outputFormat] = args; + const f = buildScriptApdu(claHex, commandName, p1Hex, p2Hex, dataHex); + if (outputFormat === "JSON") { + return JSON.stringify({ cla: f.cla, ins: f.ins, p1: f.p1, p2: f.p2, lc: f.lc, data: f.data, apdu: f.apdu }, null, 4); + } + if (outputFormat === "Annotated") { + return formatAnnotatedApdu(f); + } + return f.apdu; + } +} + +export default BuildEMVScriptData; diff --git a/src/core/operations/BuildPINBlock.mjs b/src/core/operations/BuildPINBlock.mjs new file mode 100644 index 0000000000..95d515b674 --- /dev/null +++ b/src/core/operations/BuildPINBlock.mjs @@ -0,0 +1,67 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "PIN Block Build"; + this.module = "Payment"; + this.description = "Paste the clear PIN into the input field and choose the ISO 9564 clear PIN block format to build.

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

This operation currently builds clear test PIN blocks for ISO formats 0, 1, and 3."; + this.inlineHelp = "Input: clear PIN digits.
Args: choose the format, add the PAN for formats 0 and 3, then decide whether format 1 or 3 filler digits should be randomized."; + this.testDataSamples = [ + { + name: "Random ISO Format 0 sample", + input: "__RANDOM_PIN_4__", + args: ["ISO Format 0", "__RANDOM_PAN_16__", false] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Format", + type: "option", + value: PIN_BLOCK_FORMATS, + comment: "Choose the clear ISO 9564 block format to build. Assumption: only formats 0, 1, and 3 are implemented." + }, + { + name: "Primary account number", + type: "string", + value: "", + comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit." + }, + { + name: "Randomize fill digits", + type: "boolean", + value: false, + comment: "Affects only formats 1 and 3. When disabled, filler is deterministic so test vectors stay stable." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [format, pan, randomizeFill] = args; + return buildPinBlock(format, input, pan, randomizeFill); + } +} + +export default BuildPINBlock; diff --git a/src/core/operations/CBOREncode.mjs b/src/core/operations/CBOREncode.mjs index c6e094a9ae..d302774bfc 100644 --- a/src/core/operations/CBOREncode.mjs +++ b/src/core/operations/CBOREncode.mjs @@ -7,6 +7,73 @@ import Operation from "../Operation.mjs"; import Cbor from "cbor"; +// cbor v9: Encoder.encode/encodeCanonical return only the first byte. +// Pre-sort map keys ourselves and use a custom Map semantic type so the +// encoder writes keys in insertion order without re-sorting internally. + +/** + * Returns the byte-length of a CBOR-encoded text string key (header + payload). + * Used to implement RFC 7049 canonical map key ordering. + * + * @param {string} s + * @returns {number} + */ +function cborKeyEncodedLen(s) { + const n = Buffer.byteLength(s, "utf8"); + if (n < 24) return 1 + n; + if (n < 0x100) return 2 + n; + if (n < 0x10000) return 3 + n; + return 5 + n; +} + +/** + * Recursively converts plain objects to pre-sorted Maps so that the CBOR + * encoder emits keys in canonical (length-first, then lexicographic) order + * without relying on the cbor library's own canonical sort, which is broken + * in cbor v9 for streamed output. + * + * @param {*} val + * @returns {*} + */ +function prepareCBOR(val) { + if (Array.isArray(val)) return val.map(prepareCBOR); + if (val !== null && typeof val === "object" && !(val instanceof Map)) { + const sorted = Object.keys(val).sort((a, b) => { + const la = cborKeyEncodedLen(a), lb = cborKeyEncodedLen(b); + if (la !== lb) return la - lb; + return Buffer.from(a, "utf8").compare(Buffer.from(b, "utf8")); + }); + return new Map(sorted.map(k => [k, prepareCBOR(val[k])])); + } + return val; +} + +/** + * Encodes a value as canonical CBOR using a streaming Encoder. + * Returns a Promise that resolves to a Buffer containing the full encoding. + * + * @param {*} input + * @returns {Promise} + */ +function cborEncodeCanonical(input) { + return new Promise((resolve, reject) => { + const enc = new Cbor.Encoder({canonical: true}); + enc.addSemanticType(Map, (e, m) => { + if (!e._pushInt(m.size, 5)) return false; + for (const [k, v] of m) { + if (!e.pushAny(k) || !e.pushAny(v)) return false; + } + return true; + }); + const bufs = []; + enc.on("data", b => bufs.push(b)); + enc.on("error", reject); + enc.on("finish", () => resolve(Buffer.concat(bufs))); + enc.pushAny(prepareCBOR(input)); + enc.end(); + }); +} + /** * CBOR Encode operation */ @@ -32,8 +99,9 @@ class CBOREncode extends Operation { * @param {Object[]} args * @returns {ArrayBuffer} */ - run(input, args) { - return new Uint8Array(Cbor.encodeCanonical(input)).buffer; + async run(input, args) { + const buf = await cborEncodeCanonical(input); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } } diff --git a/src/core/operations/CalculatePaymentKCV.mjs b/src/core/operations/CalculatePaymentKCV.mjs new file mode 100644 index 0000000000..f890e3f0d6 --- /dev/null +++ b/src/core/operations/CalculatePaymentKCV.mjs @@ -0,0 +1,152 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "Payment Calculate KCV"; + this.module = "Payment"; + this.description = "Paste the key into the input field and choose how that key is encoded using Key format.

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

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

Returns an uppercase truncated hex KCV value."; + this.inlineHelp = "Input: key material.
Args: tell the op how the key is encoded, choose the KCV method, then set the output length."; + this.testDataSamples = [ + { + name: "Random AES-CMAC sample", + input: "__RANDOM_AES_128_HEX__", + args: ["Hex", "AES-CMAC (Empty)", 6] + }, + { + name: "Generate key then compute KCV", + recipeConfig: [ + { op: "Key Generate", args: ["AES-128 (16 bytes)", 16, false, false] }, + { op: "Payment Calculate KCV", args: ["Hex", "AES-CMAC (Empty)", 6] } + ] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Key format", + "type": "option", + "value": ["Hex", "UTF8", "Latin1", "Base64"], + "comment": "How the input field should be decoded before KCV calculation. Use Hex for payment keys entered as hexadecimal characters." + }, + { + "name": "Method", + "type": "option", + "value": ["TDES-ECB (Zeros)", "AES-CMAC (Empty)", "AES-CMAC (Zeros)", "AES-CMAC (Ones)", "AES-ECB (Zeros)", "HMAC SHA-224", "HMAC SHA-256", "HMAC SHA-384", "HMAC SHA-512"], + "comment": "Assumption: TDES expects a 16-byte or 24-byte key, AES expects 16/24/32 bytes, and the method name states the exact data block used for the KCV." + }, + { + "name": "Output hex chars", + "type": "number", + "value": 6, + "comment": "Number of uppercase hex characters returned from the left side of the calculated value. Common payment KCV length is 6." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [keyFormat, method, outputHexChars] = args; + const truncLength = Math.max(1, Number(outputHexChars) || 6); + const keyBytes = Utils.convertToByteString(input || "", keyFormat); + + if (!keyBytes.length) { + throw new OperationError("No key material was provided."); + } + + let hexOut; + + switch (method) { + case "TDES-ECB (Zeros)": { + if (keyBytes.length !== 16 && keyBytes.length !== 24) { + throw new OperationError("TDES key must be 16 or 24 bytes."); + } + const key = keyBytes.length === 16 ? keyBytes + keyBytes.substring(0, 8) : keyBytes; + const cipher = forge.cipher.createCipher("3DES-ECB", key); + cipher.start(); + cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00")); + cipher.finish(); + hexOut = cipher.output.toHex().toUpperCase(); + break; + } + case "AES-CMAC (Empty)": + case "AES-CMAC (Zeros)": + case "AES-CMAC (Ones)": { + if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) { + throw new OperationError("AES key must be 16, 24, or 32 bytes."); + } + const cmacOp = new CMAC(); + let data; + if (method === "AES-CMAC (Empty)") { + data = new Uint8Array(0).buffer; + } else if (method === "AES-CMAC (Zeros)") { + data = new Uint8Array(16).buffer; + } else { + data = Uint8Array.from(new Array(16).fill(0xFF)).buffer; + } + hexOut = cmacOp.run(data, [{ string: keyBytes, option: "Latin1" }, "AES"]).toUpperCase(); + break; + } + case "AES-ECB (Zeros)": { + if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) { + throw new OperationError("AES key must be 16, 24, or 32 bytes."); + } + const cipher = forge.cipher.createCipher("AES-ECB", keyBytes); + cipher.mode.pad = function() { + return true; + }; + cipher.start(); + cipher.update(forge.util.createBuffer("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")); + cipher.finish(); + hexOut = cipher.output.toHex().toUpperCase(); + break; + } + case "HMAC SHA-224": + case "HMAC SHA-256": + case "HMAC SHA-384": + case "HMAC SHA-512": { + const algorithmMap = { + "HMAC SHA-224": "sha224", + "HMAC SHA-256": "sha256", + "HMAC SHA-384": "sha384", + "HMAC SHA-512": "sha512" + }; + const hmac = forge.hmac.create(); + hmac.start(algorithmMap[method], keyBytes); + hmac.update(""); + hexOut = hmac.digest().toHex().toUpperCase(); + break; + } + default: + throw new OperationError("Unsupported method."); + } + + return hexOut.substring(0, truncLength); + } + +} + +export default CalculatePaymentKCV; diff --git a/src/core/operations/DecryptPaymentData.mjs b/src/core/operations/DecryptPaymentData.mjs new file mode 100644 index 0000000000..5346333207 --- /dev/null +++ b/src/core/operations/DecryptPaymentData.mjs @@ -0,0 +1,55 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, decryptPaymentData } from "../lib/PaymentDataCipher.mjs"; + +/** + * Decrypt payment data operation. + */ +class DecryptPaymentData extends Operation { + /** + * DecryptPaymentData constructor. + */ + constructor() { + super(); + + this.name = "Payment Decrypt Data"; + this.module = "Payment"; + this.description = "Paste ciphertext into the input field as hex and decrypt it using a payment-facing cipher wrapper.

Input: ciphertext hex.
Arguments: choose the cipher profile, provide a direct key or BDK, add IV where needed, and provide KSN plus DUKPT variant when using a DUKPT profile."; + this.inlineHelp = "Input: ciphertext hex.
Args: choose AES, TDES, or DUKPT-wrapped TDES, then provide key, IV, and optional KSN context."; + this.testDataSamples = [ + { + name: "AES CBC sample", + input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Cipher profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "Select the payment-facing decryption profile. DUKPT profiles derive a session key first, then run TDES decryption." }, + { name: "Key / BDK", type: "string", value: "", comment: "Provide the clear AES/TDES key for static profiles, or the clear BDK for DUKPT profiles." }, + { name: "IV (hex)", type: "string", value: "", comment: "Initialization vector as hex. Leave blank for ECB. Use 16 bytes for AES CBC/CTR and 8 bytes for TDES CBC." }, + { name: "KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT profiles. Provide the full 10-byte KSN as hex." }, + { name: "DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT profiles. Use Data for the current data-key masking behavior in this fork." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the effective cipher context and plaintext." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [profile, keyHex, ivHex, ksn, dukptVariant, outputJson] = args; + const result = decryptPaymentData(input, profile, keyHex, ivHex, ksn, dukptVariant); + return outputJson ? JSON.stringify(result, null, 4) : result.plaintextHex; + } +} + +export default DecryptPaymentData; diff --git a/src/core/operations/DeriveDUKPTAESKey.mjs b/src/core/operations/DeriveDUKPTAESKey.mjs new file mode 100644 index 0000000000..8d798df9f1 --- /dev/null +++ b/src/core/operations/DeriveDUKPTAESKey.mjs @@ -0,0 +1,335 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import forge from "node-forge"; +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { toHexFast } from "../lib/Hex.mjs"; + +// ── X9.24-3 key usage indicators (bytes 2-3 of derivation data) ─────────────── + +const KEY_USAGE = { + "IK Derivation": 0x8001, // BDK → device Initial Key (X9.24-3 §6.3.1) + Intermediate: 0x8000, // internal binary-tree node (X9.24-3 §6.3.2) + "PIN Encryption": 0x1000, + "MAC Generation": 0x2000, // sender / request direction + "MAC Verification": 0x2001, // receiver / response direction + "MAC Both Ways": 0x2002, + "Data Encryption": 0x3000, + "Data Decryption": 0x3001, + "Data Both Ways": 0x3002, +}; + +// AES-128 wire constants +const ALGO_CODE = 0x0002; // AES-128 algorithm identifier +const KEY_LEN_VAL = 0x0080; // 128 bits + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Parses a hex string into a Uint8Array, validating format and byte length. + * + * @param {string} hex + * @param {number} expectedBytes + * @param {string} name + * @returns {Uint8Array} + */ +function parseHex(hex, expectedBytes, name) { + const h = (hex || "").replace(/\s+/g, ""); + if (!/^[0-9a-fA-F]+$/.test(h) || h.length % 2 !== 0) + throw new OperationError(`${name} must be a hex string.`); + const bytes = new Uint8Array(h.length / 2); + for (let i = 0; i < bytes.length; i++) + bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); + if (expectedBytes && bytes.length !== expectedBytes) + throw new OperationError(`${name} must be ${expectedBytes} bytes (got ${bytes.length}).`); + return bytes; +} + +/** + * Converts a Uint8Array to a byte string for use with node-forge. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function toByteString(bytes) { + return Array.from(bytes, b => String.fromCharCode(b)).join(""); +} + +/** + * Converts a Uint8Array to an uppercase hex string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function hex(bytes) { + return toHexFast(bytes).toUpperCase(); +} + +// ── AES-128 ECB single-block encrypt ───────────────────────────────────────── + +/** + * Encrypts a single 16-byte block using AES-128-ECB. + * This is the primitive used by X9.24-3 for all key derivation steps. + * + * @param {Uint8Array} key16 + * @param {Uint8Array} block16 + * @returns {Uint8Array} + */ +function aesEncryptBlock(key16, block16) { + const cipher = forge.cipher.createCipher("AES-ECB", toByteString(key16)); + cipher.start(); + cipher.update(forge.util.createBuffer(toByteString(block16))); + cipher.finish(); + return Uint8Array.from(cipher.output.getBytes(), c => c.charCodeAt(0)).slice(0, 16); +} + +// ── X9.24-3 AES-128 DUKPT derivation ───────────────────────────────────────── + +/** + * Builds the 16-byte IK derivation data block (ANSI X9.24-3-2017 §6.3.1). + * + * Layout (IK derivation only — uses full 8-byte IKI, no counter field): + * [0] version = 0x01 + * [1] key size class = 0x01 (AES-128) + * [2-3] key usage = 0x8001 (IK Derivation) + * [4-5] algorithm = 0x0002 (AES-128) + * [6-7] key length = 0x0080 (128 bits) + * [8-15] IKI (full 8 bytes) + * + * @param {Uint8Array} iki8 + * @returns {Uint8Array} + */ +function ikDerivationData(iki8) { + const d = new Uint8Array(16); + d[0] = 0x01; d[1] = 0x01; + d[2] = (KEY_USAGE["IK Derivation"] >> 8) & 0xFF; + d[3] = KEY_USAGE["IK Derivation"] & 0xFF; + d[4] = (ALGO_CODE >> 8) & 0xFF; d[5] = ALGO_CODE & 0xFF; + d[6] = (KEY_LEN_VAL >> 8) & 0xFF; d[7] = KEY_LEN_VAL & 0xFF; + d.set(iki8, 8); + return d; +} + +/** + * Builds the 16-byte derivation data block for intermediate-node and working-key + * derivation (ANSI X9.24-3-2017 §6.3.2 / §6.3.3). + * + * Layout: + * [0] version = 0x01 + * [1] key size class = 0x01 (AES-128) + * [2-3] key usage indicator + * [4-5] algorithm = 0x0002 (AES-128) + * [6-7] key length = 0x0080 (128 bits) + * [8-11] last 4 bytes of IKI (IKI[4..7]) + * [12-15] counter register (4 bytes, big-endian) + * + * @param {number} usage + * @param {Uint8Array} iki8 + * @param {number} counterReg + * @returns {Uint8Array} + */ +function derivationData(usage, iki8, counterReg) { + const d = new Uint8Array(16); + d[0] = 0x01; d[1] = 0x01; + d[2] = (usage >> 8) & 0xFF; d[3] = usage & 0xFF; + d[4] = (ALGO_CODE >> 8) & 0xFF; d[5] = ALGO_CODE & 0xFF; + d[6] = (KEY_LEN_VAL >> 8) & 0xFF; d[7] = KEY_LEN_VAL & 0xFF; + // last 4 bytes of 8-byte IKI + d[8] = iki8[4]; d[9] = iki8[5]; d[10] = iki8[6]; d[11] = iki8[7]; + d[12] = (counterReg >>> 24) & 0xFF; + d[13] = (counterReg >>> 16) & 0xFF; + d[14] = (counterReg >>> 8) & 0xFF; + d[15] = counterReg & 0xFF; + return d; +} + +/** + * Derives the Initial Key (IK) from a BDK and IKI using AES-ECB (X9.24-3 §6.3.1). + * + * @param {Uint8Array} bdk16 + * @param {Uint8Array} iki8 + * @returns {Uint8Array} + */ +function deriveIK(bdk16, iki8) { + return aesEncryptBlock(bdk16, ikDerivationData(iki8)); +} + +/** + * Binary-tree traversal from IK to the leaf transaction key (X9.24-3 §6.3.2). + * Traverses all 32 counter bits from MSB to LSB, deriving one intermediate key + * per set bit using AES-ECB. + * + * @param {Uint8Array} ik16 + * @param {Uint8Array} iki8 + * @param {number} counter + * @returns {Uint8Array} + */ +function deriveTransactionKey(ik16, iki8, counter) { + if (counter === 0) throw new OperationError( + "Counter 0 is reserved — no transactions have occurred yet." + ); + let key = Uint8Array.from(ik16); + let reg = 0; + for (let bit = 31; bit >= 0; bit--) { + if ((counter >>> bit) & 1) { + reg = (reg | (1 << bit)) >>> 0; + key = aesEncryptBlock(key, derivationData(KEY_USAGE.Intermediate, iki8, reg)); + } + } + return key; +} + +/** + * Derives a purpose-specific working key from the transaction key (X9.24-3 §6.3.3). + * + * @param {Uint8Array} txKey16 + * @param {Uint8Array} iki8 + * @param {number} counter + * @param {string} purposeName + * @returns {Uint8Array} + */ +function deriveWorkingKey(txKey16, iki8, counter, purposeName) { + return aesEncryptBlock(txKey16, derivationData(KEY_USAGE[purposeName], iki8, counter)); +} + +// ── Operation class ─────────────────────────────────────────────────────────── + +/** + * Derive DUKPT AES Key operation. + */ +class DeriveDUKPTAESKey extends Operation { + + /** + * DeriveDUKPTAESKey constructor. + */ + constructor() { + super(); + + this.name = "DUKPT Derive AES Key"; + this.module = "Payment"; + this.description = [ + "Derives AES DUKPT working keys per ANSI X9.24-3 (AES-128). All derivation steps use AES-ECB.", + "

", + "Input: 16-byte BDK as hex, or the 16-byte Initial Key (IK) if you already have it.", + "

", + "The KSN is 12 bytes: 8-byte Initial Key Identifier (IKI) + 4-byte transaction counter.", + "Only the low 21 bits of the counter are used for derivation (max 2,097,151 transactions per IK).", + "

", + "Derivation data format (X9.24-3, 16 bytes — working keys):", + "
",
+            "[0]    version        = 0x01\n",
+            "[1]    key size class = 0x01 (AES-128)\n",
+            "[2-3]  key usage indicator\n",
+            "[4-5]  algorithm      = 0x0002 (AES-128)\n",
+            "[6-7]  key length     = 0x0080 (128 bits)\n",
+            "[8-11] last 4 bytes of IKI\n",
+            "[12-15] transaction counter (4 bytes, full 32-bit value)\n",
+            "
", + "IK derivation uses a separate 16-byte block with full 8-byte IKI at [8-15] and usage 0x8001.", + "Key usage codes: PIN Encryption=0x1000, MAC Generation=0x2000, ", + "MAC Verification=0x2001, MAC Both Ways=0x2002, ", + "Data Encryption=0x3000, Data Decryption=0x3001, Data Both Ways=0x3002.", + "

", + "AES-192 and AES-256 require a multi-block KDF and are not implemented here.", + " Cross-verify results against KABC (kabc.ca/payment/dukptaes) or the X9.24-3 annex test vectors.", + ].join(""); + this.inlineHelp = [ + "Input: BDK hex (16 bytes) or IK hex (16 bytes).", + "KSN: 24 hex chars = 8-byte IKI + 4-byte counter.", + ].join(" "); + this.testDataSamples = [ + { + name: "Derive IK from BDK", + input: "__RANDOM_AES_128_HEX__", + args: ["BDK", "Derive IK", "123456789012345600000001", "PIN Encryption", false], + }, + ]; + this.infoURL = "https://www.eftlab.com/knowledge-base/dukpt-aes"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Input key type", + type: "option", + value: ["BDK", "Initial Key (IK)"], + }, + { + name: "Derive", + type: "option", + value: ["Initial Key (IK)", "Working Key"], + }, + { + name: "KSN (24 hex chars — 8-byte IKI + 4-byte counter)", + type: "string", + value: "", + }, + { + name: "Key purpose", + type: "option", + value: [ + "PIN Encryption", + "MAC Generation", + "MAC Verification", + "MAC Both Ways", + "Data Encryption", + "Data Decryption", + "Data Both Ways", + ], + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [inputKeyType, deriveMode, ksnHex, purpose, outputJson] = args; + + const inputKey = parseHex(input, 16, "Input key"); + const ksn = parseHex(ksnHex, 12, "KSN"); + const iki = ksn.slice(0, 8); + const counter = (ksn[8] << 24 | ksn[9] << 16 | ksn[10] << 8 | ksn[11]) >>> 0; + + // Resolve IK + const ik = inputKeyType === "BDK" ? deriveIK(inputKey, iki) : Uint8Array.from(inputKey); + + if (deriveMode === "Initial Key (IK)") { + if (outputJson) { + const out = { inputKeyType, ksn: hex(ksn), iki: hex(iki), counter: `0x${counter.toString(16).padStart(8, "0").toUpperCase()}` }; + if (inputKeyType === "BDK") out.bdk = hex(inputKey); + out.ik = hex(ik); + return JSON.stringify(out, null, 4); + } + return hex(ik); + } + + // Derive working key + const txKey = deriveTransactionKey(ik, iki, counter); + const wkKey = deriveWorkingKey(txKey, iki, counter, purpose); + + if (outputJson) { + const out = { inputKeyType, ksn: hex(ksn), iki: hex(iki), counter: `0x${counter.toString(16).padStart(8, "0").toUpperCase()}` }; + if (inputKeyType === "BDK") out.bdk = hex(inputKey); + out.ik = hex(ik); + out.transactionKey = hex(txKey); + out.purpose = purpose; + out.workingKey = hex(wkKey); + return JSON.stringify(out, null, 4); + } + + return hex(wkKey); + } + +} + +export default DeriveDUKPTAESKey; diff --git a/src/core/operations/DeriveDUKPTKey.mjs b/src/core/operations/DeriveDUKPTKey.mjs new file mode 100644 index 0000000000..8f1ec8f22e --- /dev/null +++ b/src/core/operations/DeriveDUKPTKey.mjs @@ -0,0 +1,286 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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) { + // Accumulate one bit at a time — setting the full counter here would + // repeat the same ksnReg on every hit and produce wrong derived keys. + ksnReg[7] |= (bit >> 16) & 0x1F; + ksnReg[8] |= (bit >> 8) & 0xFF; + ksnReg[9] |= bit & 0xFF; + curKey = nonReversibleKeyGen(curKey, ksnReg); + } + } + + return curKey; +} + +/** + * Derive DUKPT key operation + */ +class DeriveDUKPTKey extends Operation { + + /** + * DeriveDUKPTKey constructor + */ + constructor() { + super(); + + this.name = "DUKPT Derive TDES Key"; + this.module = "Payment"; + this.description = "Paste the Base Derivation Key (BDK) into the input field as a 16-byte hex value.

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

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

This operation derives TDES DUKPT keys (ANSI X9.24 Part 1) in software for test and interoperability work. It uses a 16-byte BDK and a 10-byte KSN. For AES DUKPT (ANSI X9.24 Part 3), which uses a 12-byte KSN and AES keys, use the DUKPT Derive AES Key operation."; + this.inlineHelp = "Input: BDK hex.
Args: add the KSN, choose IPEK or transaction-key derivation, then optionally apply a variant."; + this.testDataSamples = [ + { + name: "Known transaction key vector", + input: "__RANDOM_TDES_16_HEX__", + 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 (ANSI X9.24 Part 1), not AES DUKPT (ANSI X9.24 Part 3)." + }, + { + "name": "KSN (hex, 10 bytes)", + "type": "string", + "value": "", + "comment": "Provide the full 10-byte KSN as 20 hex characters, for example FFFF9876543210E00008. Spaces are allowed. Note: AES DUKPT uses a 12-byte KSN — this operation only accepts 10-byte TDES DUKPT KSNs." + }, + { + "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(); + + const ksnHexOut = toHexFast(ksn).toUpperCase(); + + if (mode === "Derive IPEK") { + if (outputJson) { + return JSON.stringify({ mode, ksn: ksnHexOut, bdk: toHexFast(bdk).toUpperCase(), 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, + ksn: ksnHexOut, + bdk: toHexFast(bdk).toUpperCase(), + ipek: ipekHex, + sessionBase: toHexFast(sessionBase).toUpperCase(), + variant, + sessionKey: sessionHex + }, null, 4); + } + + return sessionHex; + } + +} + +export default DeriveDUKPTKey; diff --git a/src/core/operations/DeriveECDHKeyMaterial.mjs b/src/core/operations/DeriveECDHKeyMaterial.mjs new file mode 100644 index 0000000000..6566fbb348 --- /dev/null +++ b/src/core/operations/DeriveECDHKeyMaterial.mjs @@ -0,0 +1,282 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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. + * Accepts PKCS#8 PEM, SEC1 EC PEM (BEGIN EC PRIVATE KEY), or raw hex. + * + * @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 NIST SP 800-56A Concat KDF. + * + * @param {Uint8Array} rawSecret + * @param {Uint8Array} sharedInfo + * @param {string} hashAlg "SHA-256" or "SHA-512" + * @param {number} outputLen + * @returns {Promise} + */ +async function concatKdf(rawSecret, sharedInfo, hashAlg, outputLen) { + 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(hashAlg, 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 = "Ciphers"; + this.description = [ + "Paste your EC private key into the input field and provide the peer's public key as an argument.", + "

", + "Input: private key in PEM (BEGIN PRIVATE KEY or BEGIN EC PRIVATE KEY)", + " or as PKCS#8 DER hex.", + "
Arguments: curve, peer public key, optional KDF (NIST SP 800-56A Concat KDF),", + " shared info, output length, and output format.", + "

", + "Use KDF = None to obtain the raw shared secret (the x-coordinate of the shared EC point).", + " The output length argument is ignored in None mode.", + ].join(""); + this.inlineHelp = "Input: your EC private key (PEM or PKCS8 DER hex).
" + + "Args: pick the curve, paste the peer public key, then choose raw secret or KDF output."; + + this.testDataSamples = [ + { + name: "P-256 raw shared secret", + input: "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4HBsMvgcOvEQBrYJ\ndEXulke/dh5vYiOvfI41AToqfbWhRANCAAQgZgScW2pSpRRTOADLPL5D+8TF6xXx\nx9GDOE8V1xYj7arujDYH5935uCdVxXa84lUEw35+afHuh0bDmBDxolmx\n-----END PRIVATE KEY-----", + args: ["PEM", "P-256", "PEM", + "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa+FXJzzko0OZ9DcOaXpLzAkSt7bE\nXXVKQqYfsmuelH6QgH86dMR04/bvnhl4bF7YKbMWDlPRHs9haSeR/PhFNg==\n-----END 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": "PEM may be BEGIN PRIVATE KEY (PKCS#8) or BEGIN EC PRIVATE KEY (SEC1, auto-converted).", + }, + { + "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 the curve.", + }, + { + "name": "Peer public key format", + "type": "option", + "value": ["PEM", "Hex (SPKI DER)"], + "comment": "PEM should be an SPKI BEGIN PUBLIC KEY block.", + }, + { + "name": "Peer public key", + "type": "text", + "value": "-----BEGIN PUBLIC KEY-----", + "comment": "Paste the full peer public key here.", + }, + { + "name": "KDF", + "type": "option", + "value": ["None", "Concat KDF SHA-256", "Concat KDF SHA-512"], + "comment": "None returns the raw shared secret. Concat KDF follows NIST SP 800-56A §5.8.1.", + }, + { + "name": "Output length (bytes)", + "type": "number", + "value": 32, + "comment": "Used only with KDF modes. Ignored when KDF is None.", + }, + { + "name": "Shared info (hex)", + "type": "string", + "value": "", + "comment": "Optional KDF shared info as hex. Leave blank if not used.", + }, + { + "name": "Output format", + "type": "option", + "value": ["Hex", "Base64"], + }, + ]; + } + + /** + * @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, [] + ); + + // P-521 has a 521-bit field; deriveBits requires a multiple of 8, + // so request 528 bits (66 bytes) and WebCrypto returns the full x-coordinate. + 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; + 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 { + // None: return the full raw shared secret; output length arg is ignored. + out = rawSecret; + } + + return outputFormat === "Base64" ? toBase64(out) : toHexFast(out).toUpperCase(); + } + +} + +export default DeriveECDHKeyMaterial; diff --git a/src/core/operations/EncryptPaymentData.mjs b/src/core/operations/EncryptPaymentData.mjs new file mode 100644 index 0000000000..2f8bb14133 --- /dev/null +++ b/src/core/operations/EncryptPaymentData.mjs @@ -0,0 +1,55 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, encryptPaymentData } from "../lib/PaymentDataCipher.mjs"; + +/** + * Encrypt payment data operation. + */ +class EncryptPaymentData extends Operation { + /** + * EncryptPaymentData constructor. + */ + constructor() { + super(); + + this.name = "Payment Encrypt Data"; + this.module = "Payment"; + this.description = "Paste plaintext into the input field as hex and encrypt it using a payment-facing cipher wrapper.

Input: plaintext hex.
Arguments: choose the cipher profile, provide a direct key or BDK, add IV where needed, and provide KSN plus DUKPT variant when using a DUKPT profile."; + this.inlineHelp = "Input: plaintext hex.
Args: choose AES, TDES, or DUKPT-wrapped TDES, then provide key, IV, and optional KSN context."; + this.testDataSamples = [ + { + name: "AES CBC sample", + input: "00112233445566778899AABBCCDDEEFF", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Cipher profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "Select the payment-facing encryption profile. DUKPT profiles derive a session key first, then run TDES encryption." }, + { name: "Key / BDK", type: "string", value: "", comment: "Provide the clear AES/TDES key for static profiles, or the clear BDK for DUKPT profiles." }, + { name: "IV (hex)", type: "string", value: "", comment: "Initialization vector as hex. Leave blank for ECB. Use 16 bytes for AES CBC/CTR and 8 bytes for TDES CBC." }, + { name: "KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT profiles. Provide the full 10-byte KSN as hex." }, + { name: "DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT profiles. Use Data for the current data-key masking behavior in this fork." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the effective cipher context and ciphertext." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [profile, keyHex, ivHex, ksn, dukptVariant, outputJson] = args; + const result = encryptPaymentData(input, profile, keyHex, ivHex, ksn, dukptVariant); + return outputJson ? JSON.stringify(result, null, 4) : result.ciphertextHex; + } +} + +export default EncryptPaymentData; diff --git a/src/core/operations/GenerateAS2805KEKValidation.mjs b/src/core/operations/GenerateAS2805KEKValidation.mjs new file mode 100644 index 0000000000..e9c8d4d285 --- /dev/null +++ b/src/core/operations/GenerateAS2805KEKValidation.mjs @@ -0,0 +1,109 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import CalculatePaymentKCV from "./CalculatePaymentKCV.mjs"; +import { bytesToHex, parseHexBytes } from "../lib/PaymentUtils.mjs"; + +/** + * Returns cryptographically random bytes when available. + * + * @param {number} length + * @returns {Uint8Array} + */ +function randomBytes(length) { + const out = new Uint8Array(length); + if (globalThis.crypto && globalThis.crypto.getRandomValues) { + globalThis.crypto.getRandomValues(out); + return out; + } + + for (let i = 0; i < out.length; i++) { + out[i] = Math.floor(Math.random() * 256); + } + return out; +} + +/** + * Inverts all bytes. + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function invertBytes(bytes) { + return Uint8Array.from(bytes, byte => byte ^ 0xFF); +} + +/** + * Generate AS2805 KEK validation operation. + */ +class GenerateAS2805KEKValidation extends Operation { + /** + * GenerateAS2805KEKValidation constructor. + */ + constructor() { + super(); + + this.name = "AS2805 Generate KEK Validation"; + this.module = "Payment"; + this.description = "Paste the clear sending KEK into the input field as hex and generate an AS2805 KEK validation request or response.

Input: clear KEK as 16-byte or 24-byte hex.
Arguments: choose request or response mode, select the random-key length, choose the variant mask label, and optionally provide the incoming RandomKeySend value.

Validation: Emulation helper. This software implementation returns RandomKeyReceive as the bytewise inverse of RandomKeySend, which is useful for lab testing but does not claim exact HSM-side AS2805 node-initialization behavior.

Security: Clear KEKs in the recipe are test-use only."; + this.inlineHelp = "Input: clear KEK hex.
Args: choose request or response mode and provide RandomKeySend for response mode.
Validation: explicit emulation, not certified AS2805 behavior."; + this.testDataSamples = [ + { + name: "AS2805 request sample", + input: "__RANDOM_TDES_16_HEX__", + args: ["KekValidationRequest", "TDES_2KEY", "VARIANT_MASK_82", "", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/AS2805"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Validation type", type: "option", value: ["KekValidationRequest", "KekValidationResponse"], comment: "Request mode creates a fresh RandomKeySend. Response mode derives RandomKeyReceive from the supplied RandomKeySend." }, + { name: "Derive key algorithm", type: "option", value: ["TDES_2KEY", "TDES_3KEY"], comment: "Controls whether RandomKeySend / RandomKeyReceive are 16 bytes or 24 bytes long." }, + { name: "RandomKeySend variant mask", type: "option", value: ["VARIANT_MASK_82", "VARIANT_MASK_82C0"], comment: "Variant mask label used during AS2805 KEK validation. This emulation reports the selected label but does not model HSM-side key custody." }, + { name: "RandomKeySend (response only)", type: "string", value: "", comment: "Required only in response mode. Provide the incoming RandomKeySend hex value from the partner node." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the KEK KCV and both RandomKeySend / RandomKeyReceive values." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [validationType, deriveKeyAlgorithm, randomKeySendVariantMask, randomKeySendHex, outputJson] = args; + const kek = parseHexBytes(input, "KEK", deriveKeyAlgorithm === "TDES_2KEY" ? [16] : [24]); + const randomKeyLength = deriveKeyAlgorithm === "TDES_2KEY" ? 16 : 24; + + let randomKeySend; + if (validationType === "KekValidationRequest") { + randomKeySend = randomBytes(randomKeyLength); + } else { + if (!randomKeySendHex) { + throw new OperationError("RandomKeySend is required for KEK validation response mode."); + } + randomKeySend = parseHexBytes(randomKeySendHex, "RandomKeySend", [randomKeyLength]); + } + + const randomKeyReceive = invertBytes(randomKeySend); + const kcv = new CalculatePaymentKCV().run(bytesToHex(kek), ["Hex", "TDES-ECB (Zeros)", 6]); + + const result = { + validationType, + deriveKeyAlgorithm, + randomKeySendVariantMask, + keyCheckValue: kcv, + randomKeySend: bytesToHex(randomKeySend), + randomKeyReceive: bytesToHex(randomKeyReceive) + }; + + return outputJson ? JSON.stringify(result, null, 4) : result.randomKeyReceive; + } +} + +export default GenerateAS2805KEKValidation; diff --git a/src/core/operations/GenerateCardValidationData.mjs b/src/core/operations/GenerateCardValidationData.mjs new file mode 100644 index 0000000000..a4b7751df8 --- /dev/null +++ b/src/core/operations/GenerateCardValidationData.mjs @@ -0,0 +1,118 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "Card Validation Data Generate"; + this.module = "Payment"; + this.description = "Paste the combined CVK pair into the input field as hex and generate a card-verification value for software testing.

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

Profile behaviour: CVV2/CVC2 forces service code 000 regardless of the supplied service code. iCVV forces service code 999. CVV/CVC uses the supplied service code directly.

This implementation is intended for test harnesses and assumes the common CVV decimalization flow used by payment HSM integrations."; + this.inlineHelp = "Input: combined CVK pair hex.
Args: choose the validation-data profile, then provide PAN, expiry, and service-code inputs."; + this.testDataSamples = [ + { + name: "Known CVV2 test sample", + input: "0123456789ABCDEFFEDCBA9876543210", + args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false] + }, + { + name: "Generated CVK → CVV2", + recipeConfig: [ + { op: "Key Generate", args: ["AES-128 (16 bytes)", 16, false, false] }, + { op: "Card Validation Data Generate", args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false] } + ] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Card_security_code"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Validation data type", + type: "option", + value: CVV_PROFILES, + comment: "Choose whether the output should behave like CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code 000 and iCVV forces 999." + }, + { + name: "Primary account number", + type: "string", + value: "", + comment: "Provide the PAN as 13 to 19 decimal digits with no separators." + }, + { + name: "Expiry month (MM)", + type: "shortString", + value: "", + comment: "Two-digit month component used when assembling the expiry date." + }, + { + name: "Expiry year (YY)", + type: "shortString", + value: "", + comment: "Two-digit year component used when assembling the expiry date." + }, + { + name: "Expiry layout", + type: "option", + value: ["YYMM", "MMYY"], + defaultIndex: 1, + comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm." + }, + { + name: "Service code", + type: "shortString", + value: "101", + comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force 000 and 999." + }, + { + name: "Output digits", + type: "number", + value: 3, + min: 1, + max: 5, + comment: "How many digits of validation data to return. Common card-security-code lengths are 3 and sometimes 4." + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + comment: "When enabled, returns the assembled input, intermediate hex, and decimalized value along with the final output." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, outputDigits, outputJson] = args; + const result = generateCardValidationData( + input, + pan, + expiryMonth, + expiryYear, + expiryLayout, + serviceCode, + profile, + outputDigits + ); + + return outputJson ? JSON.stringify(result, null, 4) : result.validationData; + } +} + +export default GenerateCardValidationData; diff --git a/src/core/operations/GenerateEMVARPC.mjs b/src/core/operations/GenerateEMVARPC.mjs new file mode 100644 index 0000000000..65d2a59330 --- /dev/null +++ b/src/core/operations/GenerateEMVARPC.mjs @@ -0,0 +1,70 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "EMV Generate ARPC"; + this.module = "Payment"; + this.description = "Paste the already-assembled EMV authorization-response input into the input field as hex and generate an AES-CMAC-based ARPC.

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

Validation: Partially verified. This intentionally covers only supplied-key AES-CMAC-style EMV response profiles and does not derive issuer session keys or assemble response fields for you.

Session key derivation: The issuer session key for ARPC generation is typically derived from the same issuer master key used for ARQC verification, using the same ATC-based derivation. The ARPC input data is assembled from the ARQC value and the Authorization Response Code (ARC). This operation expects both the session key and the preimage to be assembled before calling it.

Security: Clear session keys are test-use only."; + this.inlineHelp = "Input: preassembled ARPC data as hex.
Args: provide the issuer AES session key and choose the truncated cryptogram length.
Validation: supplied-key AES-CMAC response profile only."; + this.testDataSamples = [ + { + name: "AES-CMAC ARPC sample", + input: "11223344556677889900AABBCCDDEEFF", + args: ["00112233445566778899AABBCCDDEEFF", 8, false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Issuer session key (hex)", + type: "string", + value: "", + comment: "Provide the already-derived issuer session key as hex. Assumption: this op does not derive EMV issuer session keys." + }, + { + name: "Cryptogram bytes", + type: "number", + value: 8, + min: 1, + max: 16, + comment: "Number of leftmost CMAC bytes to return. Common ARPC length is 8 bytes." + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + comment: "When enabled, returns the full AES-CMAC and the truncated ARPC value." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [issuerSessionKeyHex, cryptogramBytes, outputJson] = args; + const result = generateEmvAesCmacCryptogram(input, issuerSessionKeyHex, cryptogramBytes); + return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex; + } +} + +export default GenerateEMVARPC; diff --git a/src/core/operations/GenerateEMVARQC.mjs b/src/core/operations/GenerateEMVARQC.mjs new file mode 100644 index 0000000000..cb36d907b2 --- /dev/null +++ b/src/core/operations/GenerateEMVARQC.mjs @@ -0,0 +1,70 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "EMV Generate ARQC"; + this.module = "Payment"; + this.description = "Paste the already-assembled EMV authorization-request input into the input field as hex and generate an AES-CMAC-based ARQC.

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

Validation: Partially verified. This intentionally covers only supplied-key AES-CMAC-style EMV profiles and does not derive EMV session keys or assemble CDOL data for you.

Session key derivation: In a full EMV flow the session key is derived from the issuer master key using the Application Transaction Counter (ATC) and PAN sequence number. Visa and Amex use EMV Common Session Key Derivation (sometimes called Option A); Mastercard uses a different derivation (Option B). This operation expects you to supply the already-derived session key — use a separate key-derivation step before calling this operation if you need to reproduce a full end-to-end flow.

Security: Clear session keys are test-use only."; + this.inlineHelp = "Input: preassembled ARQC data as hex.
Args: provide the AES session key and choose the truncated cryptogram length.
Validation: supplied-key AES-CMAC profile only."; + this.testDataSamples = [ + { + name: "AES-CMAC ARQC sample", + input: "000102030405060708090A0B0C0D0E0F", + args: ["00112233445566778899AABBCCDDEEFF", 8, false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Session key (hex)", + type: "string", + value: "", + comment: "Provide the already-derived EMV session key as hex. Assumption: this op does not derive EMV session keys." + }, + { + name: "Cryptogram bytes", + type: "number", + value: 8, + min: 1, + max: 16, + comment: "Number of leftmost CMAC bytes to return. Common ARQC length is 8 bytes." + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + comment: "When enabled, returns the full AES-CMAC and the truncated ARQC value." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sessionKeyHex, cryptogramBytes, outputJson] = args; + const result = generateEmvAesCmacCryptogram(input, sessionKeyHex, cryptogramBytes); + return outputJson ? JSON.stringify(result, null, 4) : result.cryptogramHex; + } +} + +export default GenerateEMVARQC; diff --git a/src/core/operations/GenerateEMVMAC.mjs b/src/core/operations/GenerateEMVMAC.mjs new file mode 100644 index 0000000000..fb3c508ccc --- /dev/null +++ b/src/core/operations/GenerateEMVMAC.mjs @@ -0,0 +1,53 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { generateEmvMac } from "../lib/EmvMac.mjs"; + +/** + * Generate EMV MAC operation. + */ +class GenerateEMVMAC extends Operation { + /** + * GenerateEMVMAC constructor. + */ + constructor() { + super(); + + this.name = "EMV Generate MAC"; + this.module = "Payment"; + this.description = "Paste the issuer-script or EMV command payload into the input field as hex and generate an EMV MAC.

Input: message data as hex.
Arguments: provide the already-derived EMV session integrity key and choose how many leftmost MAC bytes to return.

Validation: Partially verified. This implements a retail-MAC style EMV helper with a supplied session key, not full EMV session derivation or brand-specific issuer processing.

Key context: In a full issuer implementation, the session integrity key used here corresponds to the secure-messaging integrity key (distinct from the confidentiality key used to encrypt data and the PIN encryption key used for PIN blocks). This operation accepts any key you supply and does not enforce that separation.

Security: Clear session keys in the recipe are test-use only."; + this.inlineHelp = "Input: issuer-script message data as hex.
Args: provide the derived EMV session integrity key.
Validation: supplied-key EMV MAC helper, not full EMV derivation."; + this.testDataSamples = [ + { + name: "EMV MAC sample", + input: "8424000008999E57FD0F47CACE0007", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV integrity session key in hex. This op does not derive EMV keys for you." }, + { name: "Padding method", type: "option", value: ["Method 2", "Method 1"], comment: "Method 2 appends 0x80 then zero-pads to block boundary (ISO 7816-4; standard for EMV issuer scripts). Method 1 zero-pads to block boundary only." }, + { name: "Output bytes", type: "number", value: 8, min: 1, max: 8, comment: "Number of leftmost MAC bytes to return. EMV issuer scripts commonly use 8 bytes." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the issuer-script input and full retail-MAC details." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sessionKeyHex, paddingMethod, outputBytes, outputJson] = args; + const result = generateEmvMac(input, sessionKeyHex, outputBytes, paddingMethod); + return outputJson ? JSON.stringify(result, null, 4) : result.macHex; + } +} + +export default GenerateEMVMAC; diff --git a/src/core/operations/GenerateEMVMACForPINChange.mjs b/src/core/operations/GenerateEMVMACForPINChange.mjs new file mode 100644 index 0000000000..714a3b86c8 --- /dev/null +++ b/src/core/operations/GenerateEMVMACForPINChange.mjs @@ -0,0 +1,53 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { generateEmvPinChangeMac } from "../lib/EmvMac.mjs"; + +/** + * Generate EMV MAC for PIN change operation. + */ +class GenerateEMVMACForPINChange extends Operation { + /** + * GenerateEMVMACForPINChange constructor. + */ + constructor() { + super(); + + this.name = "EMV Generate MAC (PIN Change)"; + this.module = "Payment"; + this.description = "Paste the issuer-script APDU command into the input field as hex and generate the MAC for an offline EMV PIN-change script.

Input: issuer-script message data as hex.
Arguments: provide the already-encrypted target PIN block in hex and the already-derived EMV session integrity key.

Validation: Test helper. The new PIN block must already be encrypted, and this op appends it to the supplied message before applying the same supplied-key EMV MAC profile used elsewhere in this fork.

Key context: In a full issuer implementation, a PIN-change script involves three distinct keys: a secure-messaging integrity key (for the MAC), a secure-messaging confidentiality key (for encrypting the script data), and a PIN encryption key (for the new PIN block). This operation accepts a single session integrity key and a pre-encrypted PIN block — it does not model the full three-key separation.

Security: Test-only issuer-script assembly with clear session keys in the recipe."; + this.inlineHelp = "Input: issuer-script APDU message as hex.
Args: provide the encrypted target PIN block and derived EMV integrity key.
Validation: test helper for PIN-change script MAC assembly."; + this.testDataSamples = [ + { + name: "EMV PIN change MAC sample", + input: "00A4040008A000000004101080D80500000001010A04000000000000", + args: ["67FB27C75580EFE7", "0123456789ABCDEFFEDCBA9876543210", 8, false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "New encrypted PIN block (hex)", type: "string", value: "", comment: "Provide the already-encrypted new PIN block that will be appended to the issuer-script message." }, + { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV session integrity key in hex. This operation does not derive EMV keys or encrypt the PIN block for you." }, + { name: "Output bytes", type: "number", value: 8, min: 1, max: 8, comment: "Number of leftmost MAC bytes to return. EMV issuer scripts commonly use 8 bytes." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the composed issuer-script message and the computed MAC." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [encryptedPinBlockHex, sessionKeyHex, outputBytes, outputJson] = args; + const result = generateEmvPinChangeMac(input, encryptedPinBlockHex, sessionKeyHex, outputBytes); + return outputJson ? JSON.stringify(result, null, 4) : result.macHex; + } +} + +export default GenerateEMVMACForPINChange; diff --git a/src/core/operations/GenerateIBM3624PINOffset.mjs b/src/core/operations/GenerateIBM3624PINOffset.mjs new file mode 100644 index 0000000000..d75d0e81f0 --- /dev/null +++ b/src/core/operations/GenerateIBM3624PINOffset.mjs @@ -0,0 +1,54 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { generateIbm3624PinOffset } from "../lib/PaymentPinVerification.mjs"; + +/** + * Generate IBM 3624 PIN offset operation. + */ +class GenerateIBM3624PINOffset extends Operation { + /** + * GenerateIBM3624PINOffset constructor. + */ + constructor() { + super(); + + this.name = "PIN IBM 3624 Offset Generate"; + this.module = "Payment"; + this.description = "Paste the clear PIN into the input field and generate the IBM 3624 offset used by issuer-side PIN verification.

Input: clear PIN digits.
Arguments: provide the clear PVK in hex, decimalization table, validation data, and pad character.

Validation: Partially verified. This is a clear-key software implementation of the IBM 3624 PIN offset scheme rather than HSM-certified behavior.

Security: Clear PIN and PVK material are test-use only."; + this.inlineHelp = "Input: clear PIN digits.
Args: provide PVK, decimalization table, validation data, and pad character.
Validation: clear-key IBM 3624 helper."; + this.testDataSamples = [ + { + name: "IBM 3624 offset sample", + input: "__RANDOM_PIN_4__", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/IBM_3624"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear IBM 3624 PVK as 16-byte or 24-byte hex." }, + { name: "Decimalization table", type: "string", value: "0123456789012345", comment: "Sixteen decimal digits used to map hex nibbles to decimal digits." }, + { name: "PIN validation data", type: "string", value: "", comment: "Issuer validation data, typically PAN-derived digits, 4 to 16 digits." }, + { name: "Pad character", type: "shortString", value: "F", comment: "Single hex nibble used to right-pad validation data to 16 nibbles." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the intermediate natural PIN and validation-block details." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [pvkHex, decimalizationTable, pinValidationData, padCharacter, outputJson] = args; + const result = generateIbm3624PinOffset(pvkHex, decimalizationTable, pinValidationData, padCharacter, input); + return outputJson ? JSON.stringify(result, null, 4) : result.pinOffset; + } +} + +export default GenerateIBM3624PINOffset; diff --git a/src/core/operations/GenerateKey.mjs b/src/core/operations/GenerateKey.mjs new file mode 100644 index 0000000000..d3a58a4615 --- /dev/null +++ b/src/core/operations/GenerateKey.mjs @@ -0,0 +1,207 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import forge from "node-forge"; + +// ── Key / IV specs ──────────────────────────────────────────────────────────── + +const KEY_SPECS = { + "AES-128 (16 bytes)": { bytes: 16, algorithm: "A", type: "key", pciOk: true }, + "AES-192 (24 bytes)": { bytes: 24, algorithm: "A", type: "key", pciOk: true }, + "AES-256 (32 bytes)": { bytes: 32, algorithm: "A", type: "key", pciOk: true }, + "TDES Double-length (16 bytes)": { bytes: 16, algorithm: "T", type: "key", pciOk: false, + warn: "TDES prohibited for new PIN keys since 1 January 2023 (PCI PIN Req 2-2)" }, + "TDES Triple-length (24 bytes)": { bytes: 24, algorithm: "T", type: "key", pciOk: false, + warn: "TDES prohibited for new PIN keys since 1 January 2023 (PCI PIN Req 2-2)" }, + "AES IV / Nonce (16 bytes)": { bytes: 16, algorithm: "A", type: "iv", pciOk: true }, + "Custom random bytes (specify below)": { bytes: null, algorithm: null, type: "custom", pciOk: true }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Generates n cryptographically random bytes using WebCrypto or node-forge. + * + * @param {number} n + * @returns {Uint8Array} + */ +function randomBytes(n) { + const buf = new Uint8Array(n); + if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.getRandomValues) { + globalThis.crypto.getRandomValues(buf); + } else { + const raw = forge.random.getBytesSync(n); + for (let i = 0; i < n; i++) buf[i] = raw.charCodeAt(i); + } + return buf; +} + +/** + * Converts a Uint8Array to an uppercase hex string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function toHex(bytes) { + return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join(""); +} + +/** + * Converts a Uint8Array to a byte string for use with node-forge. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function toByteStr(bytes) { + return Array.from(bytes, b => String.fromCharCode(b)).join(""); +} + +/** + * Left-shifts a byte array by one bit. + * + * @param {Uint8Array} a + * @returns {Uint8Array} + */ +function shiftLeft1(a) { + const out = new Uint8Array(a.length); + for (let i = 0; i < a.length - 1; i++) + out[i] = ((a[i] << 1) | (a[i + 1] >> 7)) & 0xFF; + out[a.length - 1] = (a[a.length - 1] << 1) & 0xFF; + return out; +} + +/** + * Computes the AES CMAC KCV: CMAC(key, zero-block), first 3 bytes. + * Uses the PCI PIN-required method, not the legacy ECB-zeros method. + * + * @param {Uint8Array} key + * @returns {string} + */ +function aesCmacKcv(key) { + const k = key.slice(0, 16); + const RB = new Uint8Array(16); RB[15] = 0x87; + const cipher = forge.cipher.createCipher("AES-ECB", toByteStr(k)); + + const ecb = block => { + cipher.start(); + cipher.update(forge.util.createBuffer(toByteStr(block))); + cipher.finish(); + return Uint8Array.from(cipher.output.getBytes(), c => c.charCodeAt(0)).slice(0, 16); + }; + + const L = ecb(new Uint8Array(16)); + const K1 = shiftLeft1(L); + if (L[0] & 0x80) for (let i = 0; i < 16; i++) K1[i] ^= RB[i]; + + // Single full block (16 zero bytes) — complete block uses K1 + const finalBlock = new Uint8Array(16); + for (let i = 0; i < 16; i++) finalBlock[i] = K1[i]; // 0x00 XOR K1[i] + + return toHex(ecb(finalBlock).slice(0, 3)); +} + +// ── Operation ───────────────────────────────────────────────────────────────── + +/** + * Generate random payment key or IV. + */ +class GenerateKey extends Operation { + + /** + * GenerateKey constructor. + */ + constructor() { + super(); + + this.name = "Key Generate"; + this.module = "Payment"; + this.description = [ + "Generates a cryptographically random payment key, IV, or custom-length byte string.", + "

", + "Supported types: AES-128/192/256, TDES double/triple-length,", + " AES IV/Nonce (16 bytes), or a custom length for any other use.", + "

", + "For AES keys, optionally computes a CMAC KCV (3-byte, AES-CMAC of a zero block),", + " which is the PCI-required check value method — not the legacy ECB-zeros method.", + "

", + "Important: Keys generated in the browser are suitable for testing only.", + " For production, keys must be generated in an HSM or other FIPS 140-2+ approved device.", + ].join(""); + + this.inlineHelp = "Select a key type; output is hex. Use JSON output for KCV and metadata."; + + this.testDataSamples = [ + { name: "AES-128 key", input: "", args: ["AES-128 (16 bytes)", 16, true, true] }, + ]; + + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Key / material type", + type: "option", + value: Object.keys(KEY_SPECS), + }, + { + name: "Custom length (bytes)", + type: "number", + value: 16, + min: 1, + max: 256, + }, + { + name: "Compute AES CMAC KCV", + type: "boolean", + value: true, + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [keyType, customLength, computeKcv, outputJson] = args; + + const spec = KEY_SPECS[keyType]; + if (!spec) throw new OperationError("Unknown key / material type."); + + const byteCount = spec.type === "custom" ? Math.max(1, Math.min(256, customLength)) : spec.bytes; + const material = randomBytes(byteCount); + const hex = toHex(material); + + if (!outputJson) return hex; + + const out = { + type: keyType, + lengthBytes: byteCount, + lengthBits: byteCount * 8, + hex, + }; + + if (spec.algorithm) out.algorithm = spec.algorithm === "A" ? "AES" : "TDES"; + if (spec.warn) out.warning = spec.warn; + + if (computeKcv && spec.algorithm === "A" && byteCount >= 16) { + out.kcv = aesCmacKcv(material); + out.kcvMethod = "AES-CMAC of 16 zero bytes, first 3 bytes (PCI PIN compliant)"; + } + + out.note = "For testing only — production keys must be generated in an approved HSM."; + + return JSON.stringify(out, null, 4); + } +} + +export default GenerateKey; diff --git a/src/core/operations/GeneratePIN.mjs b/src/core/operations/GeneratePIN.mjs new file mode 100644 index 0000000000..afe656b0e1 --- /dev/null +++ b/src/core/operations/GeneratePIN.mjs @@ -0,0 +1,125 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { buildPinBlock } from "../lib/PinBlock.mjs"; + +const PIN_OUTPUT_MODES = [ + "PIN digits", + "ISO Format 0 clear PIN block", + "ISO Format 1 clear PIN block", + "ISO Format 3 clear PIN block", +]; + +// Maps output mode label → PIN_BLOCK_FORMATS string used by buildPinBlock +const OUTPUT_TO_FORMAT = { + "ISO Format 0 clear PIN block": "ISO Format 0", + "ISO Format 1 clear PIN block": "ISO Format 1", + "ISO Format 3 clear PIN block": "ISO Format 3", +}; + +/** + * Generate PIN operation. + */ +class GeneratePIN extends Operation { + /** + * GeneratePIN constructor. + */ + constructor() { + super(); + + this.name = "PIN Generate"; + this.module = "Payment"; + this.description = "Generate a cryptographically random cardholder PIN and optionally encode it as a clear ISO 9564 PIN block for use in test recipes.

Input: ignored.
Arguments: choose the PIN length, the output mode, and (for block modes) the PAN.

The PIN digits are drawn using crypto.getRandomValues with rejection sampling to guarantee uniform distribution across 0–9.

Block output modes produce the clear (unencrypted) PIN block directly; these are test artifacts and must not be treated as production PIN blocks.

Security: Test data only. Do not use generated PINs or clear PIN blocks in production systems."; + this.inlineHelp = "Input: ignored.
Args: PIN length, output mode, and PAN for block formats.
Validation: uniform random digits via crypto.getRandomValues; clear ISO 9564 block formats 0, 1, and 3."; + this.testDataSamples = [ + { + name: "4-digit PIN, digits only", + input: "", + args: [4, "PIN digits", ""] + }, + { + name: "4-digit PIN, Format 0 block", + input: "", + args: [4, "ISO Format 0 clear PIN block", "5432101234567890"] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Personal_identification_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "PIN length", + type: "number", + value: 4, + min: 4, + max: 12, + comment: "Number of PIN digits to generate. Most cardholder PINs are 4 digits." + }, + { + name: "Output", + type: "option", + value: PIN_OUTPUT_MODES, + comment: "PIN digits only, or a clear ISO 9564 PIN block. Block modes require the PAN argument." + }, + { + name: "PAN (for block formats)", + type: "string", + value: "", + comment: "Required for ISO Format 0 and Format 3 block output. Ignored for PIN digits and ISO Format 1." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [length, outputMode, pan] = args; + + if (!Number.isInteger(length) || length < 4 || length > 12) { + throw new OperationError("PIN length must be between 4 and 12."); + } + + const pin = generateRandomPin(length); + + if (outputMode === "PIN digits") return pin; + + const format = OUTPUT_TO_FORMAT[outputMode]; + return buildPinBlock(format, pin, pan, true); + } +} + +/** + * Generates a single random decimal digit using rejection sampling. + * Rejects values >= 250 to ensure uniform distribution across 0–9 + * (250 = 25 × 10, so bytes 0–249 map to exactly 25 values per digit). + * + * @returns {number} + */ +function randomDecimalDigit() { + const buf = new Uint8Array(1); + let b; + do { + globalThis.crypto.getRandomValues(buf); + b = buf[0]; + } while (b >= 250); + return b % 10; +} + +/** + * Generates a random PIN of the given length. + * + * @param {number} length + * @returns {string} + */ +function generateRandomPin(length) { + return Array.from({ length }, () => randomDecimalDigit()).join(""); +} + +export default GeneratePIN; diff --git a/src/core/operations/GeneratePaymentMAC.mjs b/src/core/operations/GeneratePaymentMAC.mjs new file mode 100644 index 0000000000..3842612f9d --- /dev/null +++ b/src/core/operations/GeneratePaymentMAC.mjs @@ -0,0 +1,100 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { ISO9797_PADDING_METHODS, PAYMENT_MAC_METHODS, generatePaymentMac } from "../lib/PaymentMac.mjs"; + +/** + * Generate payment MAC operation. + */ +class GeneratePaymentMAC extends Operation { + + /** + * GeneratePaymentMAC constructor. + */ + constructor() { + super(); + + this.name = "MAC Generate"; + this.module = "Payment"; + this.description = "Paste the message data into the input field and generate a payment-oriented MAC using one payment-facing operation.

Input: message data in the selected input format.
Arguments: choose the MAC method, provide either a direct key or a DUKPT BDK, optionally provide a KSN for DUKPT methods, choose the ISO9797 padding rule when applicable, and choose the truncation length.

Validation: Mixed. HMAC/CMAC rely on established primitives. ISO9797 / AS2805 and DUKPT modes are software-emulation helpers that need to be interpreted in the scope called out by each method and key context.

Security: Uses clear key material in the recipe. Do not paste production keys into shared or untrusted environments."; + this.inlineHelp = "Input: message data.
Args: choose the payment MAC method, then provide either a direct key or a DUKPT BDK plus KSN.
Validation: primitive-backed for HMAC/CMAC; broader payment semantics are profile-specific."; + this.testDataSamples = [ + { + name: "Static AES-CMAC sample", + input: "1122334455667788", + args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", 8, false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Input format", + type: "option", + value: ["Hex", "UTF8", "Latin1", "Base64"], + comment: "How to decode the input field before MAC generation. Use Hex for payment test vectors expressed as hex." + }, + { + name: "MAC method", + type: "option", + value: PAYMENT_MAC_METHODS, + comment: "Static-key HMAC and CMAC modes reuse the existing generic primitives. ISO9797 and AS2805 modes apply TDES-based payment MAC logic. DUKPT modes derive a TDES session key first. Note: ISO 9797-1 Algorithm 1 and Algorithm 3 are legacy MAC profiles — prefer AES-CMAC for new implementations." + }, + { + name: "Key / BDK", + type: "string", + value: "", + comment: "Provide the direct MAC key for HMAC or CMAC methods, or the clear BDK for DUKPT methods." + }, + { + name: "Key format", + type: "option", + value: ["Hex", "UTF8", "Latin1", "Base64"], + comment: "How to decode the key input. Assumption: DUKPT BDK input must be Hex." + }, + { + name: "KSN (DUKPT only)", + type: "string", + value: "", + comment: "Required only for DUKPT MAC methods. Provide the full 10-byte KSN as 20 hex characters." + }, + { + name: "ISO9797 padding", + type: "option", + value: ISO9797_PADDING_METHODS, + comment: "Used only for ISO9797 and AS2805 MAC methods. Method 1 pads with zero bytes to the next block. Method 2 appends 80 then zeros." + }, + { + name: "Output bytes", + type: "number", + value: 8, + min: 1, + max: 64, + comment: "Number of leftmost MAC bytes to return. Leave at 8 for common payment truncation lengths." + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + comment: "When enabled, returns the full MAC, truncation details, and key-context metadata." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [inputFormat, method, keyValue, keyFormat, ksn, paddingMethod, outputBytes, outputJson] = args; + const result = generatePaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, outputBytes, paddingMethod); + return outputJson ? JSON.stringify(result, null, 4) : result.macHex; + } +} + +export default GeneratePaymentMAC; diff --git a/src/core/operations/GeneratePaymentPINData.mjs b/src/core/operations/GeneratePaymentPINData.mjs new file mode 100644 index 0000000000..df594ce0b0 --- /dev/null +++ b/src/core/operations/GeneratePaymentPINData.mjs @@ -0,0 +1,55 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import BuildPINBlock from "./BuildPINBlock.mjs"; + +/** + * Generate payment PIN data operation. + */ +class GeneratePaymentPINData extends Operation { + /** + * GeneratePaymentPINData constructor. + */ + constructor() { + super(); + + this.name = "PIN Data Generate"; + this.module = "Payment"; + this.description = "Paste the clear PIN into the input field and generate clear PIN-block test data.

Input: clear PIN digits.
Arguments: choose the PIN-block format, provide the PAN when required, and optionally return structured JSON.

Validation: Partially verified. This wrapper currently covers clear ISO 9564 formats 0, 1, and 3 only.

Security: Clear PIN handling is test-use only."; + this.inlineHelp = "Input: clear PIN digits.
Args: choose the block format and provide the PAN for PAN-bound formats.
Validation: clear ISO formats 0, 1, and 3 only."; + this.testDataSamples = [ + { + name: "Format 0 sample", + input: "__RANDOM_PIN_4__", + args: ["ISO Format 0", "5432101234567890", false, false] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Format", type: "option", value: ["ISO Format 0", "ISO Format 1", "ISO Format 3"], comment: "Clear ISO 9564 format to generate. This wrapper currently supports only formats 0, 1, and 3." }, + { name: "Primary account number", type: "string", value: "", comment: "Required for formats 0 and 3. Enter digits only." }, + { name: "Randomize fill digits", type: "boolean", value: false, comment: "Affects only formats 1 and 3. Leave disabled for repeatable vectors." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns the clear PIN block plus the source context." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [format, pan, randomizeFill, outputJson] = args; + const builder = new BuildPINBlock(); + const pinBlockHex = builder.run(input, [format, pan, randomizeFill]); + const result = { format, pan, pinBlockHex }; + return outputJson ? JSON.stringify(result, null, 4) : pinBlockHex; + } +} + +export default GeneratePaymentPINData; diff --git a/src/core/operations/GenerateTestPAN.mjs b/src/core/operations/GenerateTestPAN.mjs new file mode 100644 index 0000000000..6534916e57 --- /dev/null +++ b/src/core/operations/GenerateTestPAN.mjs @@ -0,0 +1,81 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { PAN_BRANDS, MASTERCARD_SERIES, generateTestPan } from "../lib/Pan.mjs"; + +/** + * Generate test PAN operation. + */ +class GenerateTestPAN extends Operation { + /** + * GenerateTestPAN constructor. + */ + constructor() { + super(); + + this.name = "PAN Generate"; + this.module = "Payment"; + this.description = "Generate a brand-valid payment card number for test workflows.

Input: ignored.
Arguments: choose the payment network, decide whether to use a curated sample or a locally generated brand-valid PAN, and choose the target length when the network supports multiple lengths.

Validation: Partially verified. Network classification and Luhn behavior are based on public numbering rules. Some curated samples are from public vendor docs, while generated samples are local deterministic test values rather than network-certified sandbox cards.

Security: Test data only. Do not treat generated PANs as live accounts."; + this.inlineHelp = "Input: ignored.
Args: choose the network, sample mode, and target length.
Validation: public numbering rules + Luhn; not all curated samples are network-published official test cards."; + this.testDataSamples = [ + { + name: "Visa curated sample", + input: "", + args: ["Visa", "Curated sample", 16, "Any", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Network", + type: "option", + value: PAN_BRANDS, + comment: "Choose the payment network whose public numbering rules should be applied." + }, + { + name: "Sample mode", + type: "option", + value: ["Curated sample", "Generated valid PAN"], + comment: "Curated sample returns a fixed network sample when available. Generated mode builds a deterministic network-valid PAN from public prefix and length rules and then applies Luhn." + }, + { + name: "Target length", + type: "number", + value: 16, + min: 13, + max: 19, + comment: "Used only in generated mode. Networks that do not support the requested length fall back to their first supported length." + }, + { + name: "Mastercard series", + type: "option", + value: MASTERCARD_SERIES, + comment: "Applies only when Network is Mastercard in generated mode. '5-series' restricts to the 51–55 range. '2-series' restricts to 2221–2720. 'Any' picks randomly between both ranges." + }, + { + name: "Output as JSON", + type: "boolean", + value: true, + comment: "When enabled, returns the PAN plus the detected network, IIN, Luhn status, and source note." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [brand, mode, length, mastercardSeries, outputJson] = args; + const result = generateTestPan(brand, mode, length, mastercardSeries); + return outputJson ? JSON.stringify(result, null, 4) : result.pan; + } +} + +export default GenerateTestPAN; diff --git a/src/core/operations/GenerateVISAPVV.mjs b/src/core/operations/GenerateVISAPVV.mjs new file mode 100644 index 0000000000..0b998ef4d0 --- /dev/null +++ b/src/core/operations/GenerateVISAPVV.mjs @@ -0,0 +1,53 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { generateVisaPvv } from "../lib/PaymentPinVerification.mjs"; + +/** + * Generate VISA PVV operation. + */ +class GenerateVISAPVV extends Operation { + /** + * GenerateVISAPVV constructor. + */ + constructor() { + super(); + + this.name = "VISA PVV Generate"; + this.module = "Payment"; + this.description = "Paste the clear PIN into the input field and generate a VISA PIN Verification Value (PVV).

Input: clear PIN digits.
Arguments: provide the clear PVK in hex, PAN, and PVKI.

Validation: Partially verified. This is a clear-key software implementation of the common VISA PVV assembly pattern, not an HSM-certified PVV service.

Security: Clear PIN and PVK material are test-use only."; + this.inlineHelp = "Input: clear PIN digits.
Args: provide PVK, PAN, and PVKI.
Validation: clear-key VISA PVV helper."; + this.testDataSamples = [ + { + name: "VISA PVV sample", + input: "__RANDOM_PIN_4__", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear VISA PVK as 16-byte or 24-byte hex." }, + { name: "Primary account number", type: "string", value: "", comment: "Provide the PAN as digits only. The standard PVV input uses the rightmost 11 digits before the check digit." }, + { name: "PVKI", type: "number", value: 1, min: 0, max: 6, comment: "PIN verification key index from 0 through 6." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the assembled PVV input and intermediate encrypted block." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [pvkHex, pan, pvki, outputJson] = args; + const result = generateVisaPvv(pvkHex, pan, pvki, input); + return outputJson ? JSON.stringify(result, null, 4) : result.pvv; + } +} + +export default GenerateVISAPVV; diff --git a/src/core/operations/KeyComponentCombine.mjs b/src/core/operations/KeyComponentCombine.mjs new file mode 100644 index 0000000000..a59d851046 --- /dev/null +++ b/src/core/operations/KeyComponentCombine.mjs @@ -0,0 +1,103 @@ +/** + * @author Jacob Marks [jacob.marks@jacobmarks.com] + * @copyright Jacob Marks 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * Key Component Combine operation + */ +class KeyComponentCombine extends Operation { + + /** + * KeyComponentCombine constructor + */ + constructor() { + super(); + + this.name = "Key Component Combine"; + this.module = "Payment"; + this.description = "Combines XOR key components into the original key. Each component is XOR'd together to reconstruct the key. Accepts 2–8 components.

Input: one hex component per line, or JSON output from Key Component Split. Plain hex output chains directly into wrap and encryption operations."; + this.infoURL = ""; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Output as JSON", + type: "boolean", + value: false + } + ]; + this.testDataSamples = [{ + input: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\nFEDCBA98765432100123456789ABCDEF", + args: [false] + }]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [outputJson] = args; + + const trimmed = input.trim(); + if (!trimmed) throw new Error("Input is empty."); + + let hexComponents; + if (trimmed.startsWith("{")) { + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (e) { + throw new Error("Invalid JSON input."); + } + if (!Array.isArray(parsed.components) || parsed.components.length === 0) { + throw new Error("JSON input must contain a non-empty 'components' array."); + } + hexComponents = parsed.components; + } else { + hexComponents = trimmed.split("\n") + .map(l => l.trim().toUpperCase().replace(/\s+/g, "")) + .filter(l => l.length > 0); + } + + if (hexComponents.length < 2) throw new Error("At least 2 components are required."); + if (hexComponents.length > 8) throw new Error("Maximum 8 components are supported."); + + for (const hex of hexComponents) { + if (!/^[0-9A-F]+$/.test(hex) || hex.length % 2 !== 0) { + throw new Error(`Invalid hex component: ${hex.slice(0, 16)}${hex.length > 16 ? "…" : ""}`); + } + } + + const byteLen = hexComponents[0].length / 2; + if (hexComponents.some(h => h.length / 2 !== byteLen)) { + throw new Error("All components must be the same length."); + } + + const result = new Uint8Array(byteLen); + for (const hex of hexComponents) { + for (let i = 0; i < byteLen; i++) { + result[i] ^= parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + } + + const keyHex = Array.from(result, b => b.toString(16).padStart(2, "0").toUpperCase()).join(""); + + if (!outputJson) return keyHex; + + return JSON.stringify({ + algorithm: "XOR", + keyLengthBits: byteLen * 8, + componentCount: hexComponents.length, + keyHex + }, null, 4); + } + +} + +export default KeyComponentCombine; diff --git a/src/core/operations/KeyComponentSplit.mjs b/src/core/operations/KeyComponentSplit.mjs new file mode 100644 index 0000000000..f5b7a75507 --- /dev/null +++ b/src/core/operations/KeyComponentSplit.mjs @@ -0,0 +1,128 @@ +/** + * @author Jacob Marks [jacob.marks@jacobmarks.com] + * @copyright Jacob Marks 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; + +/** + * Returns cryptographically random bytes. + * + * @param {number} n + * @returns {Uint8Array} + */ +function randomBytes(n) { + const buf = new Uint8Array(n); + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + crypto.getRandomValues(buf); + } else { + for (let i = 0; i < n; i++) buf[i] = Math.floor(Math.random() * 256); + } + return buf; +} + +/** + * Converts a Uint8Array to an uppercase hex string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function toHex(bytes) { + return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join(""); +} + +/** + * Parses a hex string to a Uint8Array. + * + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** + * Key Component Split operation + */ +class KeyComponentSplit extends Operation { + + /** + * KeyComponentSplit constructor + */ + constructor() { + super(); + + this.name = "Key Component Split"; + this.module = "Payment"; + this.description = "Splits a symmetric key into N XOR components for key ceremony use. N-1 components are generated randomly; the final component is derived so that XOR of all N components equals the original key. Accepts 2–8 components. Recombine with Key Component Combine.

Output is one component per line (hex). Use JSON output to include component count and key length metadata."; + this.infoURL = ""; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Number of components", + type: "number", + value: 3 + }, + { + name: "Output as JSON", + type: "boolean", + value: false + } + ]; + this.testDataSamples = [{ + input: "0123456789ABCDEFFEDCBA9876543210", + args: [3, false] + }]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [numComponents, outputJson] = args; + + const keyHex = input.trim().toUpperCase().replace(/\s+/g, ""); + if (keyHex.length === 0) throw new Error("Input key is empty."); + if (!/^[0-9A-F]+$/.test(keyHex) || keyHex.length % 2 !== 0) { + throw new Error("Input must be a valid even-length hex string."); + } + + const n = Math.round(numComponents); + if (n < 2 || n > 8) throw new Error("Number of components must be between 2 and 8."); + + const keyBytes = hexToBytes(keyHex); + const len = keyBytes.length; + + // Generate N-1 random components; last = key XOR all others + const components = []; + for (let i = 0; i < n - 1; i++) components.push(randomBytes(len)); + + const last = new Uint8Array(keyBytes); + for (const c of components) { + for (let i = 0; i < len; i++) last[i] ^= c[i]; + } + components.push(last); + + const hexComponents = components.map(toHex); + + if (!outputJson) return hexComponents.join("\n"); + + return JSON.stringify({ + algorithm: "XOR", + keyLengthBits: len * 8, + componentCount: n, + components: hexComponents + }, null, 4); + } + +} + +export default KeyComponentSplit; diff --git a/src/core/operations/ParseEMVARPCData.mjs b/src/core/operations/ParseEMVARPCData.mjs new file mode 100644 index 0000000000..6172b0cf1b --- /dev/null +++ b/src/core/operations/ParseEMVARPCData.mjs @@ -0,0 +1,74 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { + METHODS, METHOD1, METHOD2, + parseMethod1, parseMethod2, + formatJson, formatAnnotated, +} from "../lib/EmvArpc.mjs"; + +/** + * EMV Parse ARPC Data operation. + */ +class ParseEMVARPCData extends Operation { + + /** @inheritdoc */ + constructor() { + super(); + + this.name = "EMV Parse ARPC Data"; + this.module = "Payment"; + this.description = "Parse a preassembled EMV authorization-response preimage and display each field by name. Inverse of EMV Build ARPC Data.

Method 1 (Visa, Amex, Discover, JCB): expects exactly 20 hex chars (10 bytes) — ARQC || ARC.
Method 2 (Mastercard M/Chip): expects 24–40 hex chars (12–20 bytes) — ARQC || CSU || [ProprietaryAuthData].

Input: preassembled ARPC data as hex.
Arguments: method selector and output format."; + this.inlineHelp = "Input: hex ARPC preimage. Select method to control field layout. Inverse of EMV Build ARPC Data."; + this.testDataSamples = [ + { + name: "Method 1 parse (ARQC + ARC)", + input: "A1B2C3D4E5F607085931", + args: [METHOD1, "Annotated"] + }, + { + name: "Method 2 parse (ARQC + CSU)", + input: "A1B2C3D4E5F6070800000000", + args: [METHOD2, "Annotated"] + }, + { + name: "Method 2 parse with Proprietary Auth Data", + input: "A1B2C3D4E5F60708000000 00AABBCCDD", + args: [METHOD2, "JSON"] + }, + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "ARPC method", + type: "option", + value: METHODS, + comment: "Method 1: Visa, Amex, Discover, JCB (10 bytes). Method 2: Mastercard M/Chip (12–20 bytes).", + }, + { + name: "Output format", + type: "option", + value: ["Annotated", "JSON"], + comment: "Annotated: one line per field with name, value, and length. JSON: key-value object.", + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [method, fmt] = args; + const { fields } = method === METHOD2 ? parseMethod2(input) : parseMethod1(input); + return fmt === "JSON" ? formatJson(fields, method) : formatAnnotated(fields, method); + } +} + +export default ParseEMVARPCData; diff --git a/src/core/operations/ParseEMVARQCData.mjs b/src/core/operations/ParseEMVARQCData.mjs new file mode 100644 index 0000000000..20dc44dd53 --- /dev/null +++ b/src/core/operations/ParseEMVARQCData.mjs @@ -0,0 +1,59 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { parseCdol1, formatJson, formatAnnotatedTlv } from "../lib/EmvCdol.mjs"; + +/** + * EMV Parse ARQC Data operation. + */ +class ParseEMVARQCData extends Operation { + + /** @inheritdoc */ + constructor() { + super(); + + this.name = "EMV Parse ARQC Data"; + this.module = "Payment"; + this.description = "Parse a preassembled EMV ARQC input data block (standard 10-field CDOL1, 33 bytes) and display each field by name and tag.

Input: preassembled ARQC data as hex (66 hex chars / 33 bytes).
Arguments: output format.

Network coverage: the 10-field layout is identical across Visa, Mastercard, Amex, Discover, JCB, and UnionPay acquirer flows. Use this as the inverse of EMV Build ARQC Data."; + this.inlineHelp = "Input: 33-byte CDOL1 hex block. Inverse of EMV Build ARQC Data."; + this.testDataSamples = [ + { + name: "Standard CDOL1 parse — annotated TLV", + input: "000000001000000000000000084000000000000840260521 00A1B2C3D459000001", + args: ["Annotated TLV"] + }, + { + name: "Standard CDOL1 parse — JSON", + input: "000000001000000000000000084000000000000840260521 00A1B2C3D459000001", + args: ["JSON"] + }, + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Output format", + type: "option", + value: ["Annotated TLV", "JSON"], + comment: "Annotated TLV: one line per field with tag, length, value, and name. JSON: key-value object.", + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [fmt] = args; + const parsed = parseCdol1(input); + return fmt === "JSON" ? formatJson(parsed) : formatAnnotatedTlv(parsed); + } +} + +export default ParseEMVARQCData; diff --git a/src/core/operations/ParseEMVTLV.mjs b/src/core/operations/ParseEMVTLV.mjs new file mode 100644 index 0000000000..3038737bd2 --- /dev/null +++ b/src/core/operations/ParseEMVTLV.mjs @@ -0,0 +1,73 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { parseEmvTlv, EMV_TAG_DICTIONARY } from "../lib/EmvTlv.mjs"; + +/** + * EMV Parse TLV operation. + */ +class ParseEMVTLV extends Operation { + + /** @inheritdoc */ + constructor() { + super(); + + this.name = "EMV Parse TLV"; + this.module = "Payment"; + this.description = "Parse hex-encoded BER-TLV data (e.g., DE 55 field, ICC response, terminal data, ARQC preimage in TLV form) and annotate each tag using the built-in EMV tag dictionary.

Input: hex-encoded BER-TLV data.
Output: JSON tree. Each record includes the tag hex value, name from the EMV tag dictionary, source (ICC / Terminal / Host / Both), value format, length, value in hex, and — for constructed tags — a children array with the recursively parsed inner TLVs.

Tag dictionary: covers EMV Books 1–4, EMVCo contactless Book C, and common Nexo/acquirer tags (~90 entries). Unknown tags are decoded structurally but marked with name Unknown.

Constructed tags: tags with the constructed bit set (e.g., 70, 77, 6F, A5, BF0C) are recursively parsed into child arrays.

Note: indefinite-length BER encoding is not supported; this covers the definite short- and long-form lengths used by all standard EMV cards."; + this.inlineHelp = "Input: hex-encoded BER-TLV (DE 55, ICC response, GPO reply, etc.). Outputs annotated JSON with EMV tag names and nested children."; + this.testDataSamples = [ + { + name: "GPO response (Format 2): AIP=5900 + AFL", + input: "770A82025900940408010401", + args: [false] + }, + { + name: "DE 55 fragment: ARQC cryptogram tags", + input: "9F2608A1B2C3D4E5F607089F2701809F360200019F10120110A0000F040000000000000000000000FF", + args: [false] + }, + { + name: "Tag dictionary listing", + input: "", + args: [true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Show tag dictionary only", + type: "boolean", + value: false, + comment: "When enabled, ignores input and prints the full EMV tag dictionary as JSON.", + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [dictionaryMode] = args; + + if (dictionaryMode) { + const dict = {}; + for (const [tag, meta] of Object.entries(EMV_TAG_DICTIONARY)) { + dict[tag] = { name: meta.name, constructed: meta.constructed, source: meta.source, format: meta.format, class: meta.class }; + } + return JSON.stringify(dict, null, 4); + } + + const parsed = parseEmvTlv(input); + return JSON.stringify(parsed, null, 4); + } +} + +export default ParseEMVTLV; diff --git a/src/core/operations/ParseFuturexExcryptCommand.mjs b/src/core/operations/ParseFuturexExcryptCommand.mjs new file mode 100644 index 0000000000..5941001f5a --- /dev/null +++ b/src/core/operations/ParseFuturexExcryptCommand.mjs @@ -0,0 +1,177 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +const COMMANDS = { + CAAV: "Calculate Account holder Authentication Value", + DAPT: "Decrypt Apple Pay Token", + DCDK: "Decrypt Cardholder Data Using DUKPT", + DGPT: "Decrypt Google Pay Token", + DRKI: "Identification Request", + DRKK: "Key Request", + DRKV: "Key Verification Request", + DSPT: "Decrypt Samsung Pay Token", + ECDK: "Encrypt Cardholder Data Using DUKPT", + EMPT: "Translate PIN Block for EMV Personalization", + EMVA: "Verify ARQC and optionally generate ARPC", + EMVG: "Generate Master Key", + EMVK: "Derive Key from Vendor Master Key and Derivation Data", + EMVM: "Generate or Verify MAC", + EMVP: "PIN Change", + EMVR: "EMV RSA Private Key or Component Translation to Encryption Under a Personalization Key", + EMVS: "Translate an ICC Master Key to Encryption Under a Personalization Key", + EMVT: "EMV Translate Sensitive Data", + GCAV: "Generate CAVV", + GCIV: "Generate a CVC3 IV", + GCSC: "Generate American Express CSC Value", + GCVC: "Generate CVC and CVC2", + GCVV: "Generate CVV or CVC Value", + GDAC: "Generate a Data Authentication Code", + GDCV: "Generate dCVV/CVC3", + GDDC: "Generate Discover dynamic CVV", + GEMC: "Generate EMV ICC Certificate", + GEMQ: "Generate EMV Issuer CSR", + GHMC: "Generate HCE Mobile Cryptogram", + GHMD: "Generate HCE Magstripe Verification Value", + GHMK: "Generate HCE Mobile Keys", + GHPB: "Generate HMAC and PBKDF2 Obfuscated Value", + GIDN: "Generate an ICC dynamic number", + GMAC: "Generate Message Authentication Code", + GNOF: "Generate New Offset", + GOFC: "Generate Offset of Clear PIN", + GOFF: "Generate PIN offset value", + GOPC: "Generate Offset and EMV PIN Change", + GPIN: "Generate PIN", + GPMC: "General Purpose Symmetric MAC", + GVDC: "Generate dynamic CVV", + HMAC: "Generate MAC Hash", + OFPC: "Perform EMV PIN Change Using Offset", + ONGQ: "Translate PAN Encrypted Under an Asymmetric Key Pair to a Different Trusted Public Key", + PEDK: "Key Request", + RKHM: "Generate or Verify HMAC", + RPIN: "PIN Change and Optional PIN Verification", + SSAD: "Sign Static Authentication Data with Issuer Private Key", + TCDK: "Translate Cardholder Data Using DUKPT", + TDKD: "Translate Cardholder Data Using DUKPT and Symmetric Keys", + TKDR: "Translate DUKPT Data to RSA with Specific Output Data", + TPCP: "Translate Encrypted PIN Coordinates to a PEK for Generate New Map Collection", + TPDD: "Allow an encrypted ANSI PIN block to be translated", + TPIN: "Translate PIN blocks", + TRPN: "Translate PIN from RSA to Symmetric PIN Block", + TSPN: "Translate PIN from PIN block to RSA encryption", + VAAV: "Verify Account Holder Authentication Value", + VCAC: "Verify EMV Mastercard CAP Token", + VCAV: "Verify Cardholder Authentication Verification Value", + VCSC: "Verify American Express CSC Value", + VCVC: "Verify CVC and CVC2", + VCVV: "Verify CVV", + VDAC: "Verify a Data Authentication Code", + VDCV: "Verify CVC3", + VDDC: "Verify dynamic CVC value", + VEMI: "Verify an EMV Issuer Certificate", + VHMC: "Verify HCE Mobile Cryptogram", + VHMD: "Verify HCE Magstripe Verification Value", + VIDN: "Verify an ICC dynamic number", + VMAC: "Verify Message Authentication Code", + VMAP: "Verify MAC and PIN", + VPIN: "Verify PIN", + VVDC: "Verify a dynamic CVV", + WPIN: "Weak PIN checking", + XPIN: "PIN translation" +}; + +/** + * Parses an Excrypt field into tag/value components. + * + * @param {string} field + * @returns {{raw: string, tag: string, value: string}} + */ +function parseField(field) { + const tag = field.substring(0, Math.min(2, field.length)).toUpperCase(); + return { + raw: field, + tag, + value: field.substring(tag.length) + }; +} + +/** + * Parse Futurex Excrypt Command operation. + */ +class ParseFuturexExcryptCommand extends Operation { + + /** + * ParseFuturexExcryptCommand constructor + */ + constructor() { + super(); + + this.name = "HSM Parse Futurex Command"; + this.module = "Payment"; + this.description = "Paste a Futurex Excrypt command or response into the input field as text.

Scope: This operation performs syntax parsing only. It splits the bracketed message into tag/value fields and resolves the command code to a name when known. It does not interpret, validate, or execute the command — field values, key material, and transaction semantics are not checked.

General syntax: Excrypt messages are enclosed by opening and closing delimiters, typically [ and ]. Inside the message, fields are semicolon-delimited. Each field is a tag/value pair, for example AOECHO where AO is the tag and ECHO is the value. The command code is commonly carried in the AO field.

Input: raw Excrypt message text."; + this.inlineHelp = "Scope: syntax parser only — fields are split and labelled but not validated or executed.
Syntax: [tagvalue;tagvalue;...] where fields are separated by semicolons and tags are typically two characters such as AO.
Input: raw Futurex Excrypt message text."; + this.testDataSamples = [ + { + name: "Excrypt command sample", + input: "[AOGMAC;FS6;RV0011223344556677;]" + } + ]; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @returns {string} + */ + run(input) { + const rawInput = (input || "").replace(/\r?\n/g, ""); + if (!rawInput.length) { + throw new OperationError("No input."); + } + + const openingDelimiterPresent = rawInput.startsWith("["); + const closingDelimiterPresent = rawInput.endsWith("]"); + const body = rawInput.replace(/^\[/, "").replace(/\]$/, ""); + const rawFields = body.split(";").filter(field => field.length > 0); + + if (!rawFields.length) { + throw new OperationError("No Excrypt fields found."); + } + + const fields = rawFields.map(parseField); + const commandField = fields.find(field => field.tag === "AO") || fields[0]; + const commandCode = commandField.value.toUpperCase(); + const commandName = COMMANDS[commandCode] || null; + const notes = []; + + if (!openingDelimiterPresent || !closingDelimiterPresent) { + notes.push("Message is missing one or both expected Excrypt outer delimiters."); + } + + if (!commandName) { + notes.push("Command code was not found in the Futurex payment integration guide lookup."); + } + + return JSON.stringify({ + rawInput, + openingDelimiterPresent, + closingDelimiterPresent, + body, + rawFields, + fields, + commandFieldTag: commandField.tag, + commandCode, + commandName, + fieldCount: fields.length, + notes + }, null, 4); + } +} + +export default ParseFuturexExcryptCommand; diff --git a/src/core/operations/ParsePAN.mjs b/src/core/operations/ParsePAN.mjs new file mode 100644 index 0000000000..c89a58574a --- /dev/null +++ b/src/core/operations/ParsePAN.mjs @@ -0,0 +1,45 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { parsePan } from "../lib/Pan.mjs"; + +/** + * Parse PAN operation. + */ +class ParsePAN extends Operation { + /** + * ParsePAN constructor. + */ + constructor() { + super(); + + this.name = "PAN Parse"; + this.module = "Payment"; + this.description = "Paste a payment card number into the input field and classify it by public network rules.

Input: PAN digits.
Arguments: none.

Validation: Verified for Luhn behavior and public range matching used in this fork. Classification is limited to the implemented Visa, Mastercard, American Express, and Discover ranges.

Security: PANs may still be sensitive. Use test data wherever possible."; + this.inlineHelp = "Input: PAN digits only.
Args: none.
Validation: public range matching + Luhn."; + this.testDataSamples = [ + { + name: "Discover sample", + input: "6011000991543426", + args: [] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Payment_card_number"; + this.inputType = "string"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {string} input + * @returns {string} + */ + run(input) { + return JSON.stringify(parsePan(input), null, 4); + } +} + +export default ParsePAN; diff --git a/src/core/operations/ParsePINBlock.mjs b/src/core/operations/ParsePINBlock.mjs new file mode 100644 index 0000000000..30fffe7465 --- /dev/null +++ b/src/core/operations/ParsePINBlock.mjs @@ -0,0 +1,61 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "PIN Block Parse"; + this.module = "Payment"; + this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and decode it into its component fields.

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

This operation currently parses clear test PIN blocks for ISO formats 0, 1, and 3."; + this.inlineHelp = "Input: clear PIN block hex.
Args: choose the format and provide the PAN for formats 0 and 3 so the block can be decoded."; + this.testDataSamples = [ + { + name: "Known ISO Format 0 vector", + input: "041215FEDCBA9876", + args: ["ISO Format 0", "5432101234567890"] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Format", + type: "option", + value: PIN_BLOCK_FORMATS, + comment: "Choose the format you expect the input block to decode as. The parser validates the format nibble after PAN unmasking." + }, + { + name: "Primary account number", + type: "string", + value: "", + comment: "Required for formats 0 and 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [format, pan] = args; + return JSON.stringify(parsePinBlock(format, input, pan), null, 4); + } +} + +export default ParsePINBlock; diff --git a/src/core/operations/ParseTR31KeyBlock.mjs b/src/core/operations/ParseTR31KeyBlock.mjs new file mode 100644 index 0000000000..18c78239a8 --- /dev/null +++ b/src/core/operations/ParseTR31KeyBlock.mjs @@ -0,0 +1,292 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +// ── X9.143 (TR-31) lookup tables ───────────────────────────────────────────── + +const VERSION_IDS = { + A: "ANSI X9.24-1 (2009) — DEA, no MAC authentication (deprecated, insecure)", + B: "ANSI X9.24-1 (2009) — TDEA, Key Derivation Binding Method", + C: "ANSI X9.24-1 (2009) — TDEA, Key Variant Binding Method", + D: "ANSI X9.24-2 (2017) — AES, Key Derivation Binding Method (current PCI standard)", + R: "AS 2805.6.1 — Australian Standard extension", +}; + +const KEY_USAGE_CODES = { + B0: "BDK — Base Derivation Key (DUKPT)", + B1: "Initial DUKPT Key (IK)", + B2: "Base Derivation Key, version 2", + C0: "CVK — Card Verification Key", + D0: "Symmetric Data Encryption Key (DEK)", + D1: "Asymmetric Data Encryption Key", + D2: "Data Decryption Key", + E0: "EMV Issuer Master Key — Application Cryptogram", + E1: "EMV Issuer Master Key — Secure Messaging Confidentiality", + E2: "EMV Issuer Master Key — Secure Messaging Integrity", + E3: "EMV Issuer Master Key — Data Authentication Code", + E4: "EMV Issuer Master Key — Dynamic Number", + E5: "EMV Issuer Master Key — Card Personalization", + E6: "EMV Issuer Master Key — Session Key (DEA)", + I0: "Initialization Value (IV) — Encryption", + I1: "Initialization Value (IV) — MACs", + K0: "Key Encryption or Wrapping (KEK)", + K1: "TR-34 Asymmetric RSA Key for Key Wrapping", + K2: "TR-31 Key Block Protection Key (KBPK)", + K3: "DUKPT Key (Derived Unique Key Per Transaction)", + M0: "ISO 16609 MAC — Algorithm 1 (3DEA)", + M1: "ISO 9797-1 MAC — Algorithm 1", + M2: "ISO 9797-1 MAC — Algorithm 2", + M3: "ISO 9797-1 MAC — Algorithm 3", + M4: "ISO 9797-1 MAC — Algorithm 4", + M5: "ISO 9797-1 MAC — Algorithm 5", + M6: "ISO 9797-1 MAC — Algorithm 6 (CMAC; PCI default for AES)", + M7: "HMAC", + M8: "ISO 9797-1 MAC — Algorithm 3 Padded", + P0: "PIN Encryption", + S0: "Asymmetric Key Pair for Digital Signature", + S1: "Asymmetric Key Pair — CA Certificate", + S2: "Asymmetric Key Pair — Non-X9.24", + V0: "PIN Verification Key (PVK)", + V1: "PIN Verification Key — IBM 3624 PIN Offset Method", + V2: "PIN Verification Key — Visa PVV", + V3: "PIN Verification Key — PIN Change", + V4: "PIN Verification Key — Other", +}; + +const ALGORITHMS = { + A: "AES", + D: "DEA (Single DES) — PROHIBITED for new keys", + E: "Elliptic Curve", + H: "HMAC", + R: "RSA", + S: "DSA", + T: "Triple DEA (3DES / TDEA)", + "0": "Not applicable", +}; + +const MODES_OF_USE = { + B: "Both Encrypt and Decrypt / Both Generate and Verify", + C: "Combined MAC Generate and Verify", + D: "Decrypt only", + E: "Encrypt only", + G: "MAC Generate only", + N: "No restrictions / Not applicable", + S: "Secure Messaging (Sign/Verify)", + T: "Both Sign and Decrypt (asymmetric)", + V: "MAC Verify only", + X: "Key Derivation only", + Y: "Derivation Data (e.g. session keys)", +}; + +const EXPORTABILITY = { + E: "Exportable — can be wrapped under a KEK in a trusted key block", + N: "Non-exportable", + S: "Sensitive — exportable only to certain authorised systems", +}; + +const OPTIONAL_BLOCK_IDS = { + AL: "Algorithm — algorithm override for non-standard usage", + AT: "Asymmetric key type", + BI: "Key block identifier", + CT: "Certificate type", + DA: "Derivations allowed", + DD: "Derivation data", + HM: "Hash algorithm for HMAC", + IK: "Initial Key Identifier (AES DUKPT)", + IS: "Issuer identification", + KC: "Key check value — AES CMAC", + KP: "Key parity / KCV", + KS: "KSN Descriptor (DUKPT)", + LB: "Label", + PB: "Padding block", + TS: "Time stamp", + WP: "Wrapping key padding algorithm", +}; + +// ── Operation ───────────────────────────────────────────────────────────────── + +/** + * Parse TR-31 Key Block operation. + */ +class ParseTR31KeyBlock extends Operation { + + /** + * ParseTR31KeyBlock constructor. + */ + constructor() { + super(); + + this.name = "TR-31 Parse Key Block"; + this.module = "Payment"; + this.description = [ + "Parses a TR-31 (ANSI X9.143) key block and decodes every header field into a human-readable description.", + "

", + "Input: Complete TR-31 key block string, with or without spaces.", + " Enable Trim leading R prefix if the block begins with a transport R.", + "

", + "The 16-character fixed header layout: V LLLL UU A M KK X CC RR", + "
", + "V=version, L=block length, U=key usage, A=algorithm,", + " M=mode of use, K=key version, X=exportability,", + " C=optional block count, R=reserved.", + "

", + "Version D (AES Key Derivation) is the current PCI PIN standard.", + " Versions A/B/C use TDEA or lack MAC authentication — flag for migration.", + "

", + "References: ANSI X9.143 / TR-31, PCI PIN v3.1 Req 18-3.", + ].join(""); + + this.inlineHelp = "Input: full TR-31 key block text.
Args: enable R-prefix trim if the block starts with R."; + + this.testDataSamples = [ + { + name: "AES KBPK header sample", + input: "D0016K2AB00E0000", + 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, + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [trimLeadingR] = args; + let keyBlock = (input || "").replace(/\s+/g, "").toUpperCase(); + const notes = []; + const compliance = []; + + 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 (need ≥16 characters)."); + + const fixedHeader = keyBlock.substring(0, 16); + const versionId = keyBlock[0]; + const declaredBlockLength = parseInt(keyBlock.substring(1, 5), 10); + const keyUsage = keyBlock.substring(5, 7); + const algorithm = keyBlock[7]; + const modeOfUse = keyBlock[8]; + const keyVersionNumber = keyBlock.substring(9, 11); + const exportability = keyBlock[11]; + const optionalBlocksDeclared = parseInt(keyBlock.substring(12, 14), 10); + const reserved = keyBlock.substring(14, 16); + + // ── Compliance checks ─────────────────────────────────────────────── + if (versionId === "A") { + compliance.push("HARD STOP: Version A has no MAC authentication — vulnerable to forgery; upgrade to D"); + } else if (versionId === "B" || versionId === "C") { + compliance.push("WARN: Version B/C uses TDEA — consider migrating to AES (version D) per PCI PIN 18-3"); + } else if (versionId === "D") { + compliance.push("OK: Version D (AES Key Derivation) — current PCI-required format"); + } + + if (algorithm === "D") { + compliance.push("HARD STOP: Single DES (DEA) is prohibited for all new key deployments"); + } + + if (keyUsage === "P0" && algorithm === "T") { + compliance.push("HARD STOP: Fixed TDES PIN Encryption key — prohibited since 1 January 2023 (PCI PIN Req 2-2)"); + } + + if (exportability === "E") { + compliance.push("NOTE: Exportable key — verify the wrapping KEK is a PCI-approved key block protection key"); + } + + // ── Optional block parsing ────────────────────────────────────────── + 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: invalid block length at offset ${offset}.`); + break; + } + if (offset + blockLength > keyBlock.length) { + notes.push(`Stopped optional block parsing: truncated block at offset ${offset}.`); + break; + } + + optionalBlocks.push({ + id: blockId, + idDescription: OPTIONAL_BLOCK_IDS[blockId] || "Unknown optional block type", + length: blockLength, + value: keyBlock.substring(offset + 4, offset + blockLength), + }); + optionalBlocksParsed++; + offset += blockLength; + } + + // ── Assemble result ───────────────────────────────────────────────── + const result = { + raw: keyBlock, + fixedHeader: { + raw: fixedHeader, + versionId, + versionDescription: VERSION_IDS[versionId] || "Unknown version ID", + declaredBlockLength: Number.isFinite(declaredBlockLength) ? declaredBlockLength : null, + keyUsage, + keyUsageDescription: KEY_USAGE_CODES[keyUsage] || "Unknown key usage code", + algorithm, + algorithmDescription: ALGORITHMS[algorithm] || "Unknown algorithm code", + modeOfUse, + modeOfUseDescription: MODES_OF_USE[modeOfUse] || "Unknown mode of use", + keyVersionNumber, + exportability, + exportabilityDescription: EXPORTABILITY[exportability] || "Unknown exportability code", + optionalBlocksDeclared: Number.isFinite(optionalBlocksDeclared) ? optionalBlocksDeclared : null, + reserved, + }, + compliance, + optionalBlocks, + bodyOffset: offset, + remainingBody: keyBlock.substring(offset), + 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 ${result.fixedHeader.optionalBlocksDeclared} optional block(s) ` + + `but parsed ${optionalBlocks.length}.` + ); + } + + return JSON.stringify(result, null, 4); + } +} + +export default ParseTR31KeyBlock; diff --git a/src/core/operations/ParseTR34B9Envelope.mjs b/src/core/operations/ParseTR34B9Envelope.mjs new file mode 100644 index 0000000000..c196ab217f --- /dev/null +++ b/src/core/operations/ParseTR34B9Envelope.mjs @@ -0,0 +1,236 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +// ── TR-34 message type table ────────────────────────────────────────────────── + +const TR34_MESSAGE_TYPES = { + B0: "GetCredential — KRD requests KDH credentials", + B1: "KeyCertificate — KDH distributes its certificate", + B2: "GetData — KDH requests data from KRD", + B3: "ReceiveCert — KRD acknowledges certificate receipt", + B4: "BindBegin — KDH initiates key transport binding", + B5: "BindRequest — KRD sends public key / OWHF to KDH", + B6: "BindResponse — KDH sends certificate binding confirmation", + B8: "KeyToken — encrypted key transport object (CMS EnvelopedData)", + B9: "BindResponse — final key delivery; contains CMS EnvelopedData + signature", +}; + +const TR34_ERROR_CODES = { + "00": "Success", + "01": "General failure", + "02": "Invalid message format", + "03": "Unsupported message type", + "04": "Certificate not found", + "05": "Invalid signature", + "06": "Decryption failure", + "07": "Invalid key usage", + "08": "KDH not authorized", + "09": "KRD not authorized", + "10": "Message replay detected", + "11": "Key already loaded", + "12": "Invalid random number", + "FF": "Unspecified error", +}; + +// ── ASN.1 helpers ───────────────────────────────────────────────────────────── + +/** + * Parses an ASN.1 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 }; +} + +/** Best-effort parse of the outermost ASN.1 SEQUENCE tag in a byte array. */ +function peekAsnSequence(bytes) { + if (!bytes || bytes.length < 2) return null; + if (bytes[0] !== 0x30) return null; // not SEQUENCE + try { + const meta = parseAsnLength(bytes, 0); + return { + tag: "0x30 (SEQUENCE)", + headerBytes: meta.headerLength, + valueLength: meta.valueLength, + totalExpected: meta.headerLength + meta.valueLength, + complete: (meta.headerLength + meta.valueLength) === bytes.length, + }; + } catch (_) { + return null; + } +} + +/** + * Converts a Uint8Array to an uppercase hex string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +function hexStr(bytes) { + return Array.from(bytes, b => b.toString(16).padStart(2, "0").toUpperCase()).join(""); +} + +// ── Operation ───────────────────────────────────────────────────────────────── + +/** + * Parse TR-34 Key Transport message operation. + */ +class ParseTR34B9Envelope extends Operation { + + /** + * ParseTR34B9Envelope constructor. + */ + constructor() { + super(); + + this.name = "TR-34 Parse Key Transport"; + this.module = "Payment"; + this.description = [ + "Parses a TR-34 key transport message frame (hex input) and decodes each section.", + "

", + "Input: Complete TR-34 message frame encoded as hex, including the 2-byte length prefix.", + "

", + "TR-34 defines a family of messages (B0–B9) for transporting symmetric keys using RSA.", + " This operation auto-detects the message type and labels each field accordingly.", + " The Envelope Data section is a CMS EnvelopedData (ASN.1 SEQUENCE) —", + " the outer tag and length are shown; full CMS parsing is not performed here.", + "

", + "Key transport flow: KDH (Key Distribution Host) wraps a symmetric key under the", + " KRD's RSA public key and signs the result. KRD verifies the signature, then decrypts.", + "

", + "References: ANS X9.143, ANSI TR-34, PCI PIN v3.1 Req 18-4.", + ].join(""); + + this.inlineHelp = "Input: full TR-34 message frame as hex, including the 2-byte length prefix."; + + 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 for a TR-34 frame."); + + const notes = []; + + // ── Outer frame ───────────────────────────────────────────────────── + 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 msgDesc = TR34_MESSAGE_TYPES[responseType] || "Unknown message type"; + const errDesc = TR34_ERROR_CODES[errorCode] || "Unknown error code"; + + if (errorCode !== "00") { + notes.push(`Non-zero error code: ${errorCode} — ${errDesc}`); + } + + // ── Authentication data (ASN.1 variable length) ────────────────────── + const authLenMeta = parseAsnLength(bytes, offset); + const authTotalLen = authLenMeta.headerLength + authLenMeta.valueLength; + const authData = bytes.slice(offset, offset + authTotalLen); + const authAsn = peekAsnSequence(authData); + offset += authTotalLen; + + // ── KCV (3 bytes) ──────────────────────────────────────────────────── + const kcv = bytes.slice(offset, offset + 3); offset += 3; + + // ── Envelope data (CMS EnvelopedData, ASN.1 variable length) ───────── + const envLenMeta = parseAsnLength(bytes, offset); + const envTotalLen = envLenMeta.headerLength + envLenMeta.valueLength; + const envelopeData = bytes.slice(offset, offset + envTotalLen); + const envAsn = peekAsnSequence(envelopeData); + offset += envTotalLen; + + // ── Signature length (4-byte ASCII decimal) ────────────────────────── + const sigLenAscii = String.fromCharCode(...bytes.slice(offset, offset + 4)); offset += 4; + const sigLength = parseInt(sigLenAscii, 10); + const signature = Number.isFinite(sigLength) ? bytes.slice(offset, offset + sigLength) : new Uint8Array(); + if (Number.isFinite(sigLength)) offset += sigLength; + + // ── Build output ───────────────────────────────────────────────────── + const out = { + declaredLength, + actualLengthExcludingLengthField: bytes.length - 2, + header, + messageType: responseType, + messageDescription: msgDesc, + errorCode, + errorDescription: errDesc, + authData: { + hex: hexStr(authData), + byteCount: authData.length, + asnOuter: authAsn, + }, + kcvHex: hexStr(kcv), + envelopeData: { + hex: hexStr(envelopeData), + byteCount: envelopeData.length, + description: "CMS EnvelopedData — wrapped symmetric key (decrypt with KRD private RSA key)", + asnOuter: envAsn, + }, + signatureLengthAscii: sigLenAscii, + signatureLength: Number.isFinite(sigLength) ? sigLength : null, + signatureHex: hexStr(signature), + trailingHex: hexStr(bytes.slice(offset)), + notes, + }; + + if (out.declaredLength !== bytes.length - 2) { + out.notes.push( + `Declared length ${out.declaredLength} does not match ` + + `actual payload length ${bytes.length - 2}.` + ); + } + + return JSON.stringify(out, null, 4); + } +} + +export default ParseTR34B9Envelope; diff --git a/src/core/operations/ParseThalesPayShieldCommand.mjs b/src/core/operations/ParseThalesPayShieldCommand.mjs new file mode 100644 index 0000000000..38a3eb23c9 --- /dev/null +++ b/src/core/operations/ParseThalesPayShieldCommand.mjs @@ -0,0 +1,337 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +const STX = "\x02"; +const ETX = "\x03"; +const END_MESSAGE_DELIMITER = "\x19"; + +const REQUEST_COMMANDS = { + AA: { responseCodes: ["AB"], names: ["Translate a TMK, TPK or PVK"], manualPages: [49] }, + AC: { responseCodes: ["AD"], names: ["Translate a TAK"], manualPages: [54] }, + AE: { responseCodes: ["AF"], names: ["Translate a TMK, TPK or PVK from LMK to Another TMK, TPK or PVK"], manualPages: [22] }, + AG: { responseCodes: ["AH"], names: ["Translate a TAK from LMK to TMK Encryption"], manualPages: [24] }, + AI: { responseCodes: ["AJ"], names: ["Encrypt Data Block with SEED algorithm"], manualPages: [161] }, + AK: { responseCodes: ["AL"], names: ["Decrypt Data Block with SEED algorithm"], manualPages: [163] }, + AM: { responseCodes: ["AN"], names: ["Translate Data Block with SEED algorithm"], manualPages: [165] }, + AO: { responseCodes: ["AP"], names: ["Generate Round Key from SEED Key"], manualPages: [167] }, + AS: { responseCodes: ["AT"], names: ["Generate a CVK Pair"], manualPages: [26] }, + AU: { responseCodes: ["AV"], names: ["Translate a CVK Pair from LMK to ZMK Encryption"], manualPages: [45] }, + AW: { responseCodes: ["AX"], names: ["Translate a CVK Pair from ZMK to LMK Encryption"], manualPages: [47] }, + AY: { responseCodes: ["AZ"], names: ["Translate a CVK Pair from Old LMK to New LMK Encryption"], manualPages: [44] }, + BI: { responseCodes: ["BJ"], names: ["Generate a BDK"], manualPages: [80] }, + CI: { responseCodes: ["CJ"], names: ["Translate a PIN from BDK to ZPK Encryption (DUKPT)"], manualPages: [110] }, + CK: { responseCodes: ["CL"], names: ["Verify a PIN Using the IBM Offset Method (DUKPT)"], manualPages: [112] }, + CM: { responseCodes: ["CN"], names: ["Verify a PIN Using the ABA PVV Method (DUKPT)"], manualPages: [115] }, + CO: { responseCodes: ["CP"], names: ["Verify a PIN Using the Diebold Method (DUKPT)"], manualPages: [117] }, + CQ: { responseCodes: ["CR"], names: ["Verify a PIN Using the Encrypted PIN Method (DUKPT)"], manualPages: [119] }, + DI: { responseCodes: ["DJ"], names: ["Generate and Export a KML"], manualPages: [85] }, + DK: { responseCodes: ["DL"], names: ["Import a KML"], manualPages: [87] }, + DM: { responseCodes: ["DN"], names: ["Verify Load Signature S1 and Generate Load Signature S2"], manualPages: [128] }, + DO: { responseCodes: ["DP"], names: ["Verify Load Completion Signature S3"], manualPages: [130] }, + DQ: { responseCodes: ["DR"], names: ["Verify Unload Signature S1 and Generate Unload Signature S2"], manualPages: [131] }, + DS: { responseCodes: ["DT"], names: ["Verify Unload Completion Signature S3"], manualPages: [133] }, + DW: { responseCodes: ["DX"], names: ["Translate a BDK from ZMK to LMK Encryption"], manualPages: [81] }, + DY: { responseCodes: ["DZ"], names: ["Translate a BDK from LMK to ZMK Encryption"], manualPages: [83] }, + FA: { responseCodes: ["FB"], names: ["Translate a ZPK from ZMK to LMK Encryption"], manualPages: [70] }, + FC: { responseCodes: ["FD"], names: ["Translate a TMK, TPK or PVK from ZMK to LMK Encryption"], manualPages: [52] }, + FE: { responseCodes: ["FF"], names: ["Translate a TMK, TPK or PVK from LMK to ZMK Encryption"], manualPages: [50] }, + FG: { responseCodes: ["FH"], names: ["Generate a Pair of PVKs"], manualPages: [29] }, + FI: { responseCodes: ["FJ"], names: ["Generate ZEK/ZAK"], manualPages: [32] }, + FK: { responseCodes: ["FL"], names: ["Translate a ZEK/ZAK from ZMK to LMK Encryption"], manualPages: [65] }, + FM: { responseCodes: ["FN"], names: ["Translate a ZEK/ZAK from LMK to ZMK Encryption"], manualPages: [63] }, + FO: { responseCodes: ["FP"], names: ["Generate a Watchword Key"], manualPages: [31] }, + FQ: { responseCodes: ["FR"], names: ["Translate a Watchword Key from LMK to ZMK Encryption"], manualPages: [59] }, + FS: { responseCodes: ["FT"], names: ["Translate a Watchword Key from ZMK to LMK Encryption"], manualPages: [61] }, + FU: { responseCodes: ["FV"], names: ["Verify a Watchword Response"], manualPages: [135] }, + G2: { responseCodes: ["G3"], names: ["Verify an Interchange PIN using the comparison method with SEED encryption algorithm"], manualPages: [155] }, + G4: { responseCodes: ["G5"], names: ["Verify a Terminal PIN using the comparison method with SEED encryption algorithm"], manualPages: [156] }, + G6: { responseCodes: ["G7"], names: ["Translate a PIN from one ZPK to another ZPK with SEED encryption algorithm"], manualPages: [157] }, + G8: { responseCodes: ["G9"], names: ["Translate a PIN from TPK to ZPK with SEED encryption algorithm"], manualPages: [159] }, + GC: { responseCodes: ["GD"], names: ["Translate a ZPK from LMK to ZMK Encryption"], manualPages: [68] }, + GE: { responseCodes: ["GF"], names: ["Translate a ZMK"], manualPages: [72] }, + GG: { responseCodes: ["GH"], names: ["Form a ZMK from Three ZMK Components"], manualPages: [36] }, + GY: { responseCodes: ["GZ"], names: ["Form a ZMK from 2 to 9 ZMK Components"], manualPages: [38] }, + HA: { responseCodes: ["HB"], names: ["Generate a TAK"], manualPages: [20] }, + HC: { responseCodes: ["HD"], names: ["Generate a TMK, TPK or PVK"], manualPages: [19] }, + HE: { responseCodes: ["HF"], names: ["Encrypt Data Block"], manualPages: [107] }, + HG: { responseCodes: ["HH"], names: ["Decrypt Data Block"], manualPages: [108] }, + IA: { responseCodes: ["IB"], names: ["Generate a ZPK"], manualPages: [34] }, + JS: { responseCodes: ["JT"], names: ["ARQC Verification and/or ARPC Generation (UnionPay)"], manualPages: [122] }, + JU: { responseCodes: ["JV"], names: ["Generate Secure Message with Integrity and optional Confidentiality (UnionPay)"], manualPages: [124] }, + KA: { responseCodes: ["KB"], names: ["Generate a Key Check Value (Not Double-Length ZMK)"], manualPages: [73] }, + KC: { responseCodes: ["KD"], names: ["Translate a ZPK"], manualPages: [67] }, + LK: { responseCodes: ["LL"], names: ["Generate a Decimal MAC"], manualPages: [136] }, + LM: { responseCodes: ["LN"], names: ["Verify a Decimal MAC"], manualPages: [137] }, + MA: { responseCodes: ["MB"], names: ["Generate a MAC"], manualPages: [90] }, + MC: { responseCodes: ["MD"], names: ["Verify a MAC"], manualPages: [91] }, + ME: { responseCodes: ["MF"], names: ["Verify and Translate a MAC"], manualPages: [92] }, + MG: { responseCodes: ["MH"], names: ["Translate a TAK from LMK to ZMK Encryption"], manualPages: [55] }, + MI: { responseCodes: ["MJ"], names: ["Translate a TAK from ZMK to LMK Encryption"], manualPages: [57] }, + MK: { responseCodes: ["ML"], names: ["Generate a Binary MAC"], manualPages: [98] }, + MM: { responseCodes: ["MN"], names: ["Verify a Binary MAC"], manualPages: [99] }, + MO: { responseCodes: ["MP"], names: ["Verify and Translate a Binary MAC"], manualPages: [100] }, + MQ: { responseCodes: ["MR"], names: ["Generate MAC (MAB) for Large Message"], manualPages: [94] }, + MS: { responseCodes: ["MT"], names: ["Generate MAC (MAB) using ANSI X9.19 Method for a Large Message"], manualPages: [96] }, + MU: { responseCodes: ["MV"], names: ["Generate a MAC on a Binary Message"], manualPages: [102] }, + MW: { responseCodes: ["MX"], names: ["Verify a MAC on a Binary Message"], manualPages: [104] }, + OC: { responseCodes: ["OD", "OZ"], names: ["Generate and Print a ZMK Component"], manualPages: [40] }, + OE: { responseCodes: ["OF", "OZ"], names: ["Generate and Print a TMK, TPK or PVK"], manualPages: [27] }, + R2: { responseCodes: ["R3"], names: ["Export Electronic Purse Card Key Set"], manualPages: [207] }, + RY: { responseCodes: ["RZ"], names: ["Generate a CSCK", "Export a CSCK", "Import a CSCK"], manualPages: [75, 76, 78] }, + T0: { responseCodes: ["T1"], names: ["Unlinked Load Transaction Request"], manualPages: [184] }, + T2: { responseCodes: ["T3"], names: ["Release RLSAM"], manualPages: [186] }, + T4: { responseCodes: ["T5"], names: ["Release R2LSAM"], manualPages: [187] }, + T6: { responseCodes: ["T7"], names: ["Verify RCEP"], manualPages: [188] }, + TA: { responseCodes: ["TB", "TZ"], names: ["Print TMK Mailer"], manualPages: [42] }, + U0: { responseCodes: ["U1"], names: ["Decrypt R1 and validate the MACLSAM"], manualPages: [169] }, + U2: { responseCodes: ["U3"], names: ["Compute HCEP"], manualPages: [171] }, + U4: { responseCodes: ["U5"], names: ["Validate the S1 MAC (Load and Unload)"], manualPages: [172] }, + U6: { responseCodes: ["U7"], names: ["Validate the S1 MAC (Currency Exchange)"], manualPages: [174] }, + U8: { responseCodes: ["U9"], names: ["Generate the S2 MAC (Linked load, declined unlinked load, unload)"], manualPages: [176] }, + V0: { responseCodes: ["V1"], names: ["Generate the S2 MAC (Currency Exchange)"], manualPages: [177] }, + V2: { responseCodes: ["V3"], names: ["Generate the S2 MAC (Approved Unlinked Load)"], manualPages: [178] }, + V4: { responseCodes: ["V5"], names: ["Validate the S3 MAC (Currency Exchange transactions)"], manualPages: [179] }, + V6: { responseCodes: ["V7"], names: ["Validate the S3 MAC (Load or Unload transactions)"], manualPages: [181] }, + V8: { responseCodes: ["V9"], names: ["Validate the H2LSAM"], manualPages: [183] }, + W0: { responseCodes: ["W1"], names: ["Validate S6 MAC"], manualPages: [189] }, + W2: { responseCodes: ["W3"], names: ["Validate S6' MAC"], manualPages: [190] }, + W4: { responseCodes: ["W5"], names: ["Validate S6'' MAC"], manualPages: [191] }, + W6: { responseCodes: ["W7"], names: ["Validate S5',DLT MAC"], manualPages: [192] }, + W8: { responseCodes: ["W9"], names: ["Validate S5',ISS MAC"], manualPages: [193] }, + X0: { responseCodes: ["X1"], names: ["Validate the S4 MAC (Old Terminals)"], manualPages: [194] }, + X2: { responseCodes: ["X3"], names: ["Validate the S4 MAC (New Terminals)"], manualPages: [195] }, + X4: { responseCodes: ["X5"], names: ["Validate the S5 MAC (Old Terminals)"], manualPages: [196] }, + X6: { responseCodes: ["X7"], names: ["Validate the S5' MAC (MAC of the PSAM for a Transaction) (New Terminals)"], manualPages: [197] }, + X8: { responseCodes: ["X9"], names: ["Validate the S5 Variant MAC (MAC of the PSAM for an Issuer Total) (New Terminals)"], manualPages: [199] }, + XK: { responseCodes: ["XL"], names: ["Verify PIN Block from Internet and Verify MAC"], manualPages: [140] }, + XM: { responseCodes: ["XN"], names: ["Verify PIN Block from Internet, Verify MAC & Return New Encrypted PIN"], manualPages: [142] }, + XO: { responseCodes: ["XP"], names: ["Verify MAC"], manualPages: [144] }, + XQ: { responseCodes: ["XR"], names: ["Generate MAC"], manualPages: [146] }, + XS: { responseCodes: ["XT"], names: ["Translate PIN Block from Internet, Verify MAC and Optionally Generate a MAC"], manualPages: [148] }, + XU: { responseCodes: ["XV"], names: ["Decrypt Data"], manualPages: [150] }, + XW: { responseCodes: ["XX"], names: ["Encrypt Data"], manualPages: [152] }, + Y0: { responseCodes: ["Y1"], names: ["Create the Acknowledgement MAC (Old Terminals)"], manualPages: [201] }, + Y2: { responseCodes: ["Y3"], names: ["Create the Acknowledgement MAC (New Terminals)"], manualPages: [202] }, + Y4: { responseCodes: ["Y5"], names: ["Create the Update MAC"], manualPages: [203] }, + Y6: { responseCodes: ["Y7"], names: ["Validate the SADMIN MAC (Administrative MAC of the PSAM)"], manualPages: [204] }, + Y8: { responseCodes: ["Y9"], names: ["Create the Merchant Acquirer MAC"], manualPages: [205] }, + Z0: { responseCodes: ["Z1"], names: ["Validate the Card Issuer MAC"], manualPages: [206] }, +}; + +const RESPONSE_COMMANDS = Object.entries(REQUEST_COMMANDS).reduce((responses, [requestCode, details]) => { + details.responseCodes.forEach((responseCode) => { + if (!responses[responseCode]) { + responses[responseCode] = { + requestCodes: [], + names: [], + manualPages: [] + }; + } + + responses[responseCode].requestCodes.push(requestCode); + details.names.forEach((name) => { + if (!responses[responseCode].names.includes(name)) { + responses[responseCode].names.push(name); + } + }); + details.manualPages.forEach((page) => { + if (!responses[responseCode].manualPages.includes(page)) { + responses[responseCode].manualPages.push(page); + } + }); + }); + return responses; +}, {}); + +/** + * Parses transport framing and trailer fields from a payShield message. + * + * @param {string} input + * @returns {{message: string, framing: object, messageTrailer: string}} + */ +function parseTransport(input) { + let message = input; + const framing = { + stxPresent: false, + etxPresent: false, + endMessageDelimiterPresent: false + }; + + if (message.startsWith(STX)) { + framing.stxPresent = true; + message = message.substring(1); + } + + if (message.endsWith(ETX)) { + framing.etxPresent = true; + message = message.substring(0, message.length - 1); + } + + let messageTrailer = ""; + const endMessageIndex = message.lastIndexOf(END_MESSAGE_DELIMITER); + if (endMessageIndex !== -1) { + framing.endMessageDelimiterPresent = true; + messageTrailer = message.substring(endMessageIndex + 1); + message = message.substring(0, endMessageIndex); + } + + return { message, framing, messageTrailer }; +} + +/** + * Resolves request/response metadata for a command code. + * + * @param {string} commandCode + * @returns {{commandCodeType: string, commandNames: string[], expectedResponseCodes: string[], requestCodes: string[], manualPages: number[]}} + */ +function resolveCommandMetadata(commandCode) { + if (REQUEST_COMMANDS[commandCode]) { + return { + commandCodeType: "request", + commandNames: REQUEST_COMMANDS[commandCode].names, + expectedResponseCodes: REQUEST_COMMANDS[commandCode].responseCodes, + requestCodes: [commandCode], + manualPages: REQUEST_COMMANDS[commandCode].manualPages + }; + } + + if (RESPONSE_COMMANDS[commandCode]) { + return { + commandCodeType: "response", + commandNames: RESPONSE_COMMANDS[commandCode].names, + expectedResponseCodes: [commandCode], + requestCodes: RESPONSE_COMMANDS[commandCode].requestCodes, + manualPages: RESPONSE_COMMANDS[commandCode].manualPages + }; + } + + return { + commandCodeType: "unknown", + commandNames: [], + expectedResponseCodes: [], + requestCodes: [], + manualPages: [] + }; +} + +/** + * Parses an optional trailing LMK identifier segment from a command payload. + * + * @param {string} payload + * @returns {{payload: string, lmkIdentifier: string|null, lmkIdentifierDelimiterPresent: boolean, tildeDelimiterPresent: boolean}} + */ +function parseTrailingLmkIdentifier(payload) { + const match = payload.match(/^(.*?)(~)?%([0-9]{2})$/); + if (!match) { + return { + payload, + lmkIdentifier: null, + lmkIdentifierDelimiterPresent: false, + tildeDelimiterPresent: false + }; + } + + return { + payload: match[1], + lmkIdentifier: match[3], + lmkIdentifierDelimiterPresent: true, + tildeDelimiterPresent: Boolean(match[2]) + }; +} + +/** + * Parse Thales payShield host command operation. + */ +class ParseThalesPayShieldCommand extends Operation { + + /** + * ParseThalesPayShieldCommand constructor + */ + constructor() { + super(); + + this.name = "HSM Parse Thales Command"; + this.module = "Payment"; + this.description = "Paste a Thales payShield 10K legacy host command or response into the input field as text.

Scope: This operation performs syntax parsing only. It identifies framing delimiters, splits the header, command code, payload, LMK suffix, and message trailer into labelled fields. It does not interpret, validate, or execute the command payload — field values, key material, and transaction semantics are not checked.

General syntax: optional STX, then m header characters, then a 2-character command or response code, then the command payload, then optionally an LMK suffix such as %nn or ~%nn, then optionally X'19' and a message trailer, then optional ETX.

Input: raw command text, optionally including STX/ETX framing, message header, X'19' end-message delimiter, and message trailer.
Arguments: provide the configured message-header length."; + this.inlineHelp = "Scope: syntax parser only — field values are split and labelled but not validated or executed.
Syntax: [STX][header m][code 2][payload][~][%nn][EM trailer-delimiter][trailer][ETX].
Input: raw payShield command or response text.
Args: set the message-header length configured on the HSM link."; + this.testDataSamples = [ + { + name: "Encrypt Data Block with header and trailer", + input: "\u0002HEADHE0123456789ABCDEF0011223344556677%00\u0019TAIL\u0003", + args: [4] + } + ]; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Message header length", + type: "number", + value: 0, + min: 0, + max: 64, + comment: "Number of characters at the start of the message that should be treated as the transport header (m A in the manual)." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [messageHeaderLength] = args; + const rawInput = input || ""; + const notes = []; + + if (!rawInput.length) { + throw new OperationError("No input."); + } + + const { message, framing, messageTrailer } = parseTransport(rawInput.replace(/\r?\n/g, "")); + if (message.length < messageHeaderLength + 2) { + throw new OperationError("Input is too short for the configured message header length plus command code."); + } + + const messageHeader = message.substring(0, messageHeaderLength); + const commandCode = message.substring(messageHeaderLength, messageHeaderLength + 2).toUpperCase(); + const payloadWithSuffixes = message.substring(messageHeaderLength + 2); + const lmk = parseTrailingLmkIdentifier(payloadWithSuffixes); + const metadata = resolveCommandMetadata(commandCode); + + if (metadata.commandCodeType === "unknown") { + notes.push("Command code was not found in the payShield 10K Legacy Host Commands manual lookup."); + } + + const result = { + rawInput, + framing, + normalizedMessage: message, + messageHeaderLength, + messageHeader, + commandCode, + commandCodeType: metadata.commandCodeType, + commandNames: metadata.commandNames, + requestCodes: metadata.requestCodes, + expectedResponseCodes: metadata.expectedResponseCodes, + manualPages: metadata.manualPages, + payload: lmk.payload, + payloadLength: lmk.payload.length, + lmkIdentifier: lmk.lmkIdentifier, + lmkIdentifierDelimiterPresent: lmk.lmkIdentifierDelimiterPresent, + tildeDelimiterPresentBeforeLmkIdentifier: lmk.tildeDelimiterPresent, + messageTrailer, + notes + }; + + return JSON.stringify(result, null, 4); + } +} + +export default ParseThalesPayShieldCommand; diff --git a/src/core/operations/ReEncryptPaymentData.mjs b/src/core/operations/ReEncryptPaymentData.mjs new file mode 100644 index 0000000000..08a99b30e8 --- /dev/null +++ b/src/core/operations/ReEncryptPaymentData.mjs @@ -0,0 +1,71 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { DUKPT_DATA_VARIANTS, PAYMENT_CIPHER_PROFILES, reEncryptPaymentData } from "../lib/PaymentDataCipher.mjs"; + +/** + * Re-encrypt payment data operation. + */ +class ReEncryptPaymentData extends Operation { + /** + * ReEncryptPaymentData constructor. + */ + constructor() { + super(); + + this.name = "Payment Re-Encrypt Data"; + this.module = "Payment"; + this.description = "Paste ciphertext into the input field as hex, decrypt it under the source key context, then re-encrypt it under the target key context.

Input: source ciphertext hex.
Arguments: choose source and target profiles, provide the corresponding key or BDK material, add IVs, and supply KSN plus DUKPT variant when using DUKPT profiles."; + this.inlineHelp = "Input: source ciphertext hex.
Args: define the source decrypt context, then the target encrypt context."; + this.testDataSamples = [ + { + name: "AES CBC to TDES CBC sample", + input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", "TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "1234567890ABCDEF", "", "Data", false] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Source profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "How to decrypt the input ciphertext." }, + { name: "Source key / BDK", type: "string", value: "", comment: "Source clear AES/TDES key or DUKPT BDK." }, + { name: "Source IV (hex)", type: "string", value: "", comment: "Source IV as hex. Leave blank for ECB." }, + { name: "Source KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT source profiles." }, + { name: "Source DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT source profiles." }, + { name: "Target profile", type: "option", value: PAYMENT_CIPHER_PROFILES, comment: "How to encrypt the recovered plaintext." }, + { name: "Target key / BDK", type: "string", value: "", comment: "Target clear AES/TDES key or DUKPT BDK." }, + { name: "Target IV (hex)", type: "string", value: "", comment: "Target IV as hex. Leave blank for ECB." }, + { name: "Target KSN (DUKPT only)", type: "string", value: "", comment: "Required only for DUKPT target profiles." }, + { name: "Target DUKPT variant", type: "option", value: DUKPT_DATA_VARIANTS, defaultIndex: 1, comment: "Applies only to DUKPT target profiles." }, + { name: "Output as JSON", type: "boolean", value: false, comment: "When enabled, returns both the source decrypt and target encrypt contexts." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sourceProfile, sourceKeyHex, sourceIvHex, sourceKsn, sourceDukptVariant, targetProfile, targetKeyHex, targetIvHex, targetKsn, targetDukptVariant, outputJson] = args; + const result = reEncryptPaymentData(input, { + sourceProfile, + sourceKeyHex, + sourceIvHex, + sourceKsn, + sourceDukptVariant, + targetProfile, + targetKeyHex, + targetIvHex, + targetKsn, + targetDukptVariant, + }); + return outputJson ? JSON.stringify(result, null, 4) : result.target.ciphertextHex; + } +} + +export default ReEncryptPaymentData; diff --git a/src/core/operations/TranslatePINBlock.mjs b/src/core/operations/TranslatePINBlock.mjs new file mode 100644 index 0000000000..0b2decf4ae --- /dev/null +++ b/src/core/operations/TranslatePINBlock.mjs @@ -0,0 +1,84 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "PIN Block Translate"; + this.module = "Payment"; + this.description = "Paste a clear ISO 9564 PIN block into the input field as hex and translate it between supported clear block formats.

Input: 8-byte clear PIN block as hex.
Arguments: choose the source and target formats, provide source and target PAN values when required, and optionally randomize target filler digits for formats 1 and 3.

This operation currently translates clear test PIN blocks for ISO formats 0, 1, and 3.

Important: PIN translation must not change the cardholder PAN. Translating a PIN block from one PAN to a different PAN is prohibited by PCI PIN security requirements. Always supply the same PAN for both source and target when the formats require it."; + this.inlineHelp = "Input: source clear PIN block hex.
Args: choose source and target formats, then provide the source and target PAN values where the formats require them."; + this.testDataSamples = [ + { + name: "ISO Format 0 to 1 translation", + input: "041215FEDCBA9876", + args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Source format", + type: "option", + value: PIN_BLOCK_FORMATS, + comment: "How the input block should be decoded before translation." + }, + { + name: "Source PAN", + type: "string", + value: "", + comment: "Required when the source format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit." + }, + { + name: "Target format", + type: "option", + value: PIN_BLOCK_FORMATS, + defaultIndex: 1, + comment: "The clear PIN block format to emit after decoding the source block." + }, + { + name: "Target PAN", + type: "string", + value: "", + comment: "Required when the target format is 0 or 3. Enter digits only; the implementation uses the rightmost 12 digits excluding the check digit. The target PAN must match the source PAN — translating a PIN block to a different PAN is prohibited by PCI PIN security requirements." + }, + { + name: "Randomize target fill digits", + type: "boolean", + value: false, + comment: "Affects only target formats 1 and 3. Leave disabled if you want repeatable vectors." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill] = args; + return JSON.stringify( + translatePinBlock(input, sourceFormat, sourcePan, targetFormat, targetPan, randomizeFill), + null, + 4 + ); + } +} + +export default TranslatePINBlock; diff --git a/src/core/operations/TranslatePINBlockEncrypted.mjs b/src/core/operations/TranslatePINBlockEncrypted.mjs new file mode 100644 index 0000000000..06aa7bf13e --- /dev/null +++ b/src/core/operations/TranslatePINBlockEncrypted.mjs @@ -0,0 +1,222 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import forge from "node-forge"; +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { PIN_BLOCK_FORMATS, buildPinBlock, parsePinBlock } from "../lib/PinBlock.mjs"; + +// ── Crypto helpers ──────────────────────────────────────────────────────────── + +/** + * Validates and normalises a TDES key hex string. + * Accepts 16-byte (2-key) or 24-byte (3-key) TDES. + * + * @param {string} hex + * @param {string} label + * @returns {string} normalised uppercase hex, always 24 bytes (48 hex chars) + */ +function normaliseTdesKey(hex, label) { + const h = (hex || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]+$/.test(h)) throw new OperationError(`${label} must be hex.`); + if (h.length === 32) return h + h.slice(0, 16); // expand 2-key to 3-key + if (h.length === 48) return h; + throw new OperationError(`${label} must be 16 bytes (32 hex chars) or 24 bytes (48 hex chars).`); +} + +/** + * Converts a hex string to a forge binary string. + * + * @param {string} hex + * @returns {string} + */ +function hexToForgeBin(hex) { + return forge.util.hexToBytes(hex.toLowerCase()); +} + +/** + * Encrypts one 8-byte block with 3DES-ECB. + * + * @param {string} key48hex 24-byte key as 48 uppercase hex chars + * @param {string} block16hex 8-byte block as 16 uppercase hex chars + * @returns {string} 16 uppercase hex chars + */ +function tdesEcbEncrypt(key48hex, block16hex) { + const cipher = forge.cipher.createCipher("3DES-ECB", hexToForgeBin(key48hex)); + cipher.mode.pad = () => true; + cipher.start(); + cipher.update(forge.util.createBuffer(hexToForgeBin(block16hex))); + cipher.finish(); + return forge.util.bytesToHex(cipher.output.getBytes()).toUpperCase().slice(0, 16); +} + +/** + * Decrypts one 8-byte block with 3DES-ECB. + * + * @param {string} key48hex + * @param {string} block16hex + * @returns {string} 16 uppercase hex chars + */ +function tdesEcbDecrypt(key48hex, block16hex) { + const decipher = forge.cipher.createDecipher("3DES-ECB", hexToForgeBin(key48hex)); + decipher.mode.pad = () => true; + decipher.start(); + decipher.update(forge.util.createBuffer(hexToForgeBin(block16hex))); + decipher.finish(); + return forge.util.bytesToHex(decipher.output.getBytes()).toUpperCase().slice(0, 16); +} + +// ── Operation ───────────────────────────────────────────────────────────────── + +/** + * PIN Block Translate Encrypted operation + */ +class TranslatePINBlockEncrypted extends Operation { + + /** + * TranslatePINBlockEncrypted constructor + */ + constructor() { + super(); + + this.name = "PIN Block Translate Encrypted"; + this.module = "Payment"; + this.description = [ + "Decrypt an encrypted PIN block under an incoming zone key (ZPK / PEK),", + " optionally change the PIN block format, and re-encrypt under an outgoing zone key.", + " The clear PIN is never present in the output — only the re-encrypted block is returned.", + "

", + "This is the acquirer's core PIN routing operation.", + " It corresponds to TranslatePinData in AWS Payment Cryptography and to the", + " CA / CC command family on Thales payShield.", + "

", + "Input: encrypted PIN block as hex (8 bytes / 16 hex chars).", + "
", + "Key algorithm: TDES — key must be 16 bytes (2-key TDES, 32 hex chars) or", + " 24 bytes (3-key TDES, 48 hex chars).", + " 2-key input is automatically expanded to 3-key (K3 = K1).", + "

", + "Supported formats: ISO Format 0, ISO Format 1, ISO Format 3.", + " ISO Format 4 (AES, 16-byte block) is not yet supported.", + "

", + "PCI PIN requirement: the cardholder PAN must not change between incoming and", + " outgoing formats (PCI PIN Security Req 3-3).", + " Supplying a different PAN for the target format is permitted only when the target", + " format does not use PAN binding (Format 1).", + ].join(""); + this.inlineHelp = [ + "Input: encrypted PIN block hex.", + "Args: incoming ZPK/PEK, incoming format and PAN;", + " outgoing ZPK/PEK, outgoing format and PAN.", + ].join(" "); + this.testDataSamples = [ + { + name: "TDES ZPK-to-ZPK, same format", + input: "7F381DBF9F6906C4", + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", + "0123456789ABCDEFFEDCBA9876543210", "ISO Format 0", "5432101234567890", false] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Incoming key (TDES hex)", + type: "string", + value: "", + comment: "Zone PIN Key (ZPK) or PIN Encryption Key (PEK) used to decrypt the incoming block. 16 bytes (32 hex) for 2-key TDES or 24 bytes (48 hex) for 3-key TDES." + }, + { + name: "Incoming format", + type: "option", + value: PIN_BLOCK_FORMATS, + comment: "ISO 9564 format of the incoming encrypted block." + }, + { + name: "Incoming PAN", + type: "string", + value: "", + comment: "Primary account number — required when the incoming format is 0 or 3. The implementation uses the rightmost 12 digits excluding the check digit." + }, + { + name: "Outgoing key (TDES hex)", + type: "string", + value: "", + comment: "Zone PIN Key (ZPK) or PIN Encryption Key (PEK) used to encrypt the outgoing block. Same key-length rules as the incoming key." + }, + { + name: "Outgoing format", + type: "option", + value: PIN_BLOCK_FORMATS, + comment: "ISO 9564 format of the outgoing encrypted block." + }, + { + name: "Outgoing PAN", + type: "string", + value: "", + comment: "Required when the outgoing format is 0 or 3. Per PCI PIN Req 3-3, this must equal the incoming PAN when both formats use PAN binding." + }, + { + name: "Output as JSON", + type: "boolean", + value: false, + comment: "When enabled, returns the intermediate values (incoming clear block, outgoing clear block) along with the final encrypted block. Use for debugging only — do not expose clear PIN block values in production." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [inKeyHex, inFormat, inPan, outKeyHex, outFormat, outPan, outputJson] = args; + + const encIn = (input || "").replace(/\s+/g, "").toUpperCase(); + if (!/^[0-9A-F]{16}$/.test(encIn)) { + throw new OperationError("Encrypted PIN block must be 16 hex characters (8 bytes)."); + } + + const inKey = normaliseTdesKey(inKeyHex, "Incoming key"); + const outKey = normaliseTdesKey(outKeyHex, "Outgoing key"); + + // Decrypt incoming encrypted block → clear PIN block + const clearIn = tdesEcbDecrypt(inKey, encIn); + + // Parse the clear block to recover the PIN + const parsed = parsePinBlock(inFormat, clearIn, inPan); + + // Re-encode in the target format + const clearOut = buildPinBlock(outFormat, parsed.pin, outPan, false); + + // Re-encrypt under the outgoing key + const encOut = tdesEcbEncrypt(outKey, clearOut); + + if (outputJson) { + return JSON.stringify({ + incoming: { + format: inFormat, + pan: inPan || null, + encryptedBlockHex: encIn, + clearBlockHex: clearIn, + }, + pin: parsed.pin, + outgoing: { + format: outFormat, + pan: outPan || null, + clearBlockHex: clearOut, + encryptedBlockHex: encOut, + }, + }, null, 4); + } + + return encOut; + } + +} + +export default TranslatePINBlockEncrypted; diff --git a/src/core/operations/VerifyCardValidationData.mjs b/src/core/operations/VerifyCardValidationData.mjs new file mode 100644 index 0000000000..b4a0384d82 --- /dev/null +++ b/src/core/operations/VerifyCardValidationData.mjs @@ -0,0 +1,105 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +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 = "Card Validation Data Verify"; + this.module = "Payment"; + this.description = "Paste the combined CVK pair into the input field as hex and verify a CVV/CVC-style value for software testing.

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

Profile behaviour: CVV2/CVC2 forces service code 000 and iCVV forces 999 — the service code arg is ignored for those profiles.

This operation recomputes the validation value using the same assumptions as the generate operation and reports whether the supplied value matches."; + this.inlineHelp = "Input: combined CVK pair hex.
Args: provide PAN, expiry, service-code context, and the validation data to check."; + this.testDataSamples = [ + { + name: "Known CVV2 verification sample", + input: "0123456789ABCDEFFEDCBA9876543210", + args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Card_security_code"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Validation data type", + type: "option", + value: CVV_PROFILES, + comment: "Choose whether the supplied value should be interpreted as CVV/CVC, CVV2/CVC2, or iCVV. Assumption: CVV2 forces service code 000 and iCVV forces 999." + }, + { + name: "Primary account number", + type: "string", + value: "", + comment: "Provide the PAN as 13 to 19 decimal digits with no separators." + }, + { + name: "Expiry month (MM)", + type: "shortString", + value: "", + comment: "Two-digit month component used when assembling the expiry date." + }, + { + name: "Expiry year (YY)", + type: "shortString", + value: "", + comment: "Two-digit year component used when assembling the expiry date." + }, + { + name: "Expiry layout", + type: "option", + value: ["YYMM", "MMYY"], + defaultIndex: 1, + comment: "Assumption: this controls only how the month and year are assembled into the 4-digit expiry value used by the CVV algorithm." + }, + { + name: "Service code", + type: "shortString", + value: "101", + comment: "Three-digit service code. Used directly for CVV/CVC. Ignored for CVV2 and iCVV because those profiles force 000 and 999." + }, + { + name: "Expected value", + type: "shortString", + value: "", + comment: "Validation data to compare against, using 1 to 5 decimal digits." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [profile, pan, expiryMonth, expiryYear, expiryLayout, serviceCode, expectedValue] = args; + return JSON.stringify( + verifyCardValidationData( + input, + pan, + expiryMonth, + expiryYear, + expiryLayout, + serviceCode, + profile, + expectedValue + ), + null, + 4 + ); + } +} + +export default VerifyCardValidationData; diff --git a/src/core/operations/VerifyEMVARQC.mjs b/src/core/operations/VerifyEMVARQC.mjs new file mode 100644 index 0000000000..774895e830 --- /dev/null +++ b/src/core/operations/VerifyEMVARQC.mjs @@ -0,0 +1,59 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { generateEmvAesCmacCryptogram } from "../lib/EmvCryptogram.mjs"; + +/** + * Verify EMV ARQC operation. + */ +class VerifyEMVARQC extends Operation { + /** + * VerifyEMVARQC constructor. + */ + constructor() { + super(); + + this.name = "EMV Verify ARQC"; + this.module = "Payment"; + this.description = "Paste the stored ARQC into the input field and verify it against an AES-CMAC recomputed from the preimage data.

Input: stored ARQC cryptogram as hex (typically 8 bytes = 16 hex chars).
Arguments: provide the EMV session key, cryptogram length, the preassembled ARQC input data, and choose output format.

This operation recomputes the ARQC from the supplied preimage and key, then compares it to the input ARQC. Use this directly after EMV Generate ARQC in a recipe — the ARQC output flows naturally into this input.

Validation: Partially verified. This checks the same supplied-key AES-CMAC EMV profile as generation and does not claim full scheme-level ARQC validation semantics.

Session key derivation: In a full EMV flow the session key is derived from the issuer master key using the Application Transaction Counter (ATC) and PAN sequence number. Visa and Amex use EMV Common Session Key Derivation (Option A); Mastercard uses a different derivation (Option B). This operation expects you to supply the already-derived session key.

Security: Clear session keys are test-use only."; + this.inlineHelp = "Input: stored ARQC cryptogram as hex.
Args: provide the AES session key, preimage data, and cryptogram length.
Validation: same supplied-key EMV profile as generation."; + this.testDataSamples = [ + { + name: "AES-CMAC ARQC verification sample", + input: "C1F732B52FB20CAA", + args: ["00112233445566778899AABBCCDDEEFF", 8, "000102030405060708090A0B0C0D0E0F", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + 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. This wrapper does not derive EMV session keys." }, + { name: "Cryptogram bytes", type: "number", value: 8, min: 1, max: 16, comment: "Number of leftmost CMAC bytes to compare." }, + { name: "Preimage data (hex)", type: "string", value: "", comment: "Preassembled ARQC input data as hex — the same data used by EMV Generate ARQC to produce the ARQC." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed ARQC and validity result." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sessionKeyHex, cryptogramBytes, preimage, outputJson] = args; + const generated = generateEmvAesCmacCryptogram(preimage, sessionKeyHex, cryptogramBytes); + const normalizedInput = (input || "").replace(/\s+/g, "").toUpperCase(); + const result = { + ...generated, + expectedArqcHex: normalizedInput, + valid: generated.cryptogramHex === normalizedInput + }; + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyEMVARQC; diff --git a/src/core/operations/VerifyEMVMAC.mjs b/src/core/operations/VerifyEMVMAC.mjs new file mode 100644 index 0000000000..da479ab0e6 --- /dev/null +++ b/src/core/operations/VerifyEMVMAC.mjs @@ -0,0 +1,53 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { verifyEmvMac } from "../lib/EmvMac.mjs"; + +/** + * Verify EMV MAC operation. + */ +class VerifyEMVMAC extends Operation { + /** + * VerifyEMVMAC constructor. + */ + constructor() { + super(); + + this.name = "EMV Verify MAC"; + this.module = "Payment"; + this.description = "Paste the issuer-script or EMV command payload into the input field as hex and verify an EMV MAC.

Input: message data as hex.
Arguments: provide the already-derived EMV session integrity key and the expected MAC as hex.

Validation: Partially verified. This checks the same supplied-key EMV MAC profile as the generate operation and does not claim full issuer-host or scheme-specific EMV verification semantics.

Key context: In a full issuer implementation, the session integrity key used here corresponds to the secure-messaging integrity key (distinct from the confidentiality key used to encrypt data and the PIN encryption key used for PIN blocks). This operation accepts any key you supply and does not enforce that separation.

Security: Clear session keys in the recipe are test-use only."; + this.inlineHelp = "Input: issuer-script message data as hex.
Args: provide the derived EMV session integrity key and expected MAC.
Validation: same supplied-key EMV profile as generation."; + this.testDataSamples = [ + { + name: "EMV MAC verification sample", + input: "8424000008999E57FD0F47CACE0007", + args: ["0123456789ABCDEFFEDCBA9876543210", "22CB48394DFD1977", "Method 2", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/EMV"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Session integrity key (hex)", type: "string", value: "", comment: "Provide the already-derived EMV integrity session key in hex. This op does not derive EMV keys for you." }, + { name: "Expected MAC (hex)", type: "string", value: "", comment: "Issuer-script MAC to compare against, expressed as even-length hex." }, + { name: "Padding method", type: "option", value: ["Method 2", "Method 1"], comment: "Must match the method used during generation. Method 2 appends 0x80 then zero-pads (ISO 7816-4; standard for EMV issuer scripts). Method 1 zero-pads only." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed MAC and validity result." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [sessionKeyHex, expectedMac, paddingMethod, outputJson] = args; + const result = verifyEmvMac(input, sessionKeyHex, expectedMac, paddingMethod); + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyEMVMAC; diff --git a/src/core/operations/VerifyIBM3624PIN.mjs b/src/core/operations/VerifyIBM3624PIN.mjs new file mode 100644 index 0000000000..a135b13c2c --- /dev/null +++ b/src/core/operations/VerifyIBM3624PIN.mjs @@ -0,0 +1,55 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { verifyIbm3624Pin } from "../lib/PaymentPinVerification.mjs"; + +/** + * Verify IBM 3624 PIN operation. + */ +class VerifyIBM3624PIN extends Operation { + /** + * VerifyIBM3624PIN constructor. + */ + constructor() { + super(); + + this.name = "PIN IBM 3624 Verify"; + this.module = "Payment"; + this.description = "Paste the stored PIN offset into the input field and verify it against a clear PIN.

Input: stored IBM 3624 PIN offset (4 to 12 decimal digits).
Arguments: provide the clear PVK in hex, decimalization table, validation data, pad character, and the clear PIN to verify.

This operation re-derives the offset from the supplied PIN and keying material and compares it to the input offset. Use this directly after PIN IBM 3624 Offset Generate in a recipe — the offset output flows naturally into this input.

Validation: Partially verified. This is the verification pair for the same clear-key IBM 3624 helper logic used by generation.

Security: Clear PIN and PVK material are test-use only."; + this.inlineHelp = "Input: stored IBM 3624 PIN offset.
Args: provide PVK, decimalization table, validation data, pad character, and the clear PIN to verify.
Validation: clear-key IBM 3624 verification helper."; + this.testDataSamples = [ + { + name: "IBM 3624 verify sample", + input: "3207", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", "1234", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/IBM_3624"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear IBM 3624 PVK as 16-byte or 24-byte hex." }, + { name: "Decimalization table", type: "string", value: "0123456789012345", comment: "Sixteen decimal digits used to map hex nibbles to decimal digits." }, + { name: "PIN validation data", type: "string", value: "", comment: "Issuer validation data, typically PAN-derived digits, 4 to 16 digits." }, + { name: "Pad character", type: "shortString", value: "F", comment: "Single hex nibble used to right-pad validation data to 16 nibbles." }, + { name: "Clear PIN", type: "string", value: "", comment: "The PIN to verify. The operation re-derives the offset from this PIN and compares it to the input offset." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the recomputed offset and validity result." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [pvkHex, decimalizationTable, pinValidationData, padCharacter, pin, outputJson] = args; + const result = verifyIbm3624Pin(pvkHex, decimalizationTable, pinValidationData, padCharacter, input, pin); + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyIBM3624PIN; diff --git a/src/core/operations/VerifyPaymentMAC.mjs b/src/core/operations/VerifyPaymentMAC.mjs new file mode 100644 index 0000000000..62e4f5ed3e --- /dev/null +++ b/src/core/operations/VerifyPaymentMAC.mjs @@ -0,0 +1,98 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { ISO9797_PADDING_METHODS, PAYMENT_MAC_METHODS, verifyPaymentMac } from "../lib/PaymentMac.mjs"; + +/** + * Verify payment MAC operation. + */ +class VerifyPaymentMAC extends Operation { + + /** + * VerifyPaymentMAC constructor. + */ + constructor() { + super(); + + this.name = "MAC Verify"; + this.module = "Payment"; + this.description = "Paste the message data into the input field and verify a payment-oriented MAC using one payment-facing operation.

Input: message data in the selected input format.
Arguments: choose the MAC method, provide either a direct key or a DUKPT BDK, add the KSN for DUKPT methods, choose the ISO9797 padding rule when applicable, and supply the expected MAC as hex.

Validation: Uses the same implementation paths and assumptions as the generate operation. Treat ISO9797, AS2805, DUKPT, and EMV-adjacent usage as profile-specific software verification rather than HSM certification.

Security: Uses clear key material in the recipe."; + this.inlineHelp = "Input: message data.
Args: choose the payment MAC method, provide the key context, then paste the expected MAC.
Validation: same assumptions as generation."; + this.testDataSamples = [ + { + name: "Static AES-CMAC verification sample", + input: "1122334455667788", + args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", "339AF1AD1650E908", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/Message_authentication_code"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Input format", + type: "option", + value: ["Hex", "UTF8", "Latin1", "Base64"], + comment: "How to decode the input field before MAC verification." + }, + { + name: "MAC method", + type: "option", + value: PAYMENT_MAC_METHODS, + comment: "Static-key HMAC and CMAC modes reuse the existing generic primitives. ISO9797 and AS2805 modes apply TDES-based payment MAC logic. DUKPT modes derive a TDES session key first. Note: ISO 9797-1 Algorithm 1 and Algorithm 3 are legacy MAC profiles — prefer AES-CMAC for new implementations." + }, + { + name: "Key / BDK", + type: "string", + value: "", + comment: "Provide the direct MAC key for HMAC or CMAC methods, or the clear BDK for DUKPT methods." + }, + { + name: "Key format", + type: "option", + value: ["Hex", "UTF8", "Latin1", "Base64"], + comment: "How to decode the key input. Assumption: DUKPT BDK input must be Hex." + }, + { + name: "KSN (DUKPT only)", + type: "string", + value: "", + comment: "Required only for DUKPT MAC methods. Provide the full 10-byte KSN as 20 hex characters." + }, + { + name: "ISO9797 padding", + type: "option", + value: ISO9797_PADDING_METHODS, + comment: "Used only for ISO9797 and AS2805 MAC methods. Keep this aligned with the sender." + }, + { + name: "Expected MAC (hex)", + type: "string", + value: "", + comment: "MAC value to compare against, expressed as even-length hex." + }, + { + name: "Output as JSON", + type: "boolean", + value: true, + comment: "When enabled, returns the recomputed MAC, comparison target, and validity result." + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [inputFormat, method, keyValue, keyFormat, ksn, paddingMethod, expectedMac, outputJson] = args; + const result = verifyPaymentMac(input, inputFormat, method, keyValue, keyFormat, ksn, expectedMac, paddingMethod); + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyPaymentMAC; diff --git a/src/core/operations/VerifyPaymentPINData.mjs b/src/core/operations/VerifyPaymentPINData.mjs new file mode 100644 index 0000000000..6ea384fa36 --- /dev/null +++ b/src/core/operations/VerifyPaymentPINData.mjs @@ -0,0 +1,59 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import ParsePINBlock from "./ParsePINBlock.mjs"; + +/** + * Verify payment PIN data operation. + */ +class VerifyPaymentPINData extends Operation { + /** + * VerifyPaymentPINData constructor. + */ + constructor() { + super(); + + this.name = "PIN Data Verify"; + this.module = "Payment"; + this.description = "Paste a clear PIN block into the input field as hex and verify it against an expected PIN.

Input: clear PIN block hex.
Arguments: choose the format, provide the PAN when required, supply the expected clear PIN, and optionally return structured JSON.

Validation: Partially verified. This wrapper currently covers clear ISO 9564 formats 0, 1, and 3 only.

Security: Clear PIN handling is test-use only."; + this.inlineHelp = "Input: clear PIN block hex.
Args: define the PIN-block format, PAN context, expected PIN, and output format.
Validation: clear ISO formats 0, 1, and 3 only."; + this.testDataSamples = [ + { + name: "Format 0 verification sample", + input: "041215FEDCBA9876", + args: ["ISO Format 0", "5432101234567890", "1234", true] + } + ]; + this.infoURL = "https://wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "Format", type: "option", value: ["ISO Format 0", "ISO Format 1", "ISO Format 3"], comment: "How to decode the input PIN block." }, + { name: "Primary account number", type: "string", value: "", comment: "Required for formats 0 and 3." }, + { name: "Expected PIN", type: "string", value: "", comment: "Clear PIN digits to compare against." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the parsed PIN block and validity result." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [format, pan, expectedPin, outputJson] = args; + const parser = new ParsePINBlock(); + const parsed = JSON.parse(parser.run(input, [format, pan])); + const result = { + ...parsed, + expectedPin, + valid: parsed.pin === String(expectedPin || "") + }; + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyPaymentPINData; diff --git a/src/core/operations/VerifyVISAPVV.mjs b/src/core/operations/VerifyVISAPVV.mjs new file mode 100644 index 0000000000..2fd381d623 --- /dev/null +++ b/src/core/operations/VerifyVISAPVV.mjs @@ -0,0 +1,54 @@ +/** + * @license Apache-2.0 + * @author Jacob Marks [https://jacobmarks.com] + */ + +import Operation from "../Operation.mjs"; +import { verifyVisaPvv } from "../lib/PaymentPinVerification.mjs"; + +/** + * Verify VISA PVV operation. + */ +class VerifyVISAPVV extends Operation { + /** + * VerifyVISAPVV constructor. + */ + constructor() { + super(); + + this.name = "VISA PVV Verify"; + this.module = "Payment"; + this.description = "Paste the stored PVV into the input field and verify it against a clear PIN.

Input: stored PVV (4 decimal digits).
Arguments: provide the clear PVK in hex, PAN, PVKI, and the clear PIN to verify.

This operation re-derives the PVV from the supplied PIN and keying material and compares it to the input PVV. Use this directly after VISA PVV Generate in a recipe — the PVV output flows naturally into this input.

Validation: Partially verified. This is the verification pair for the same clear-key VISA PVV helper logic used by generation.

Security: Clear PIN and PVK material are test-use only."; + this.inlineHelp = "Input: stored PVV (4 decimal digits).
Args: provide PVK, PAN, PVKI, and the clear PIN to verify.
Validation: clear-key VISA PVV verification helper."; + this.testDataSamples = [ + { + name: "VISA PVV verify sample", + input: "6776", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, "1234", true] + } + ]; + this.infoURL = "https://en.wikipedia.org/wiki/ISO_9564"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { name: "PIN verification key (hex)", type: "string", value: "", comment: "Provide the clear VISA PVK as 16-byte or 24-byte hex." }, + { name: "Primary account number", type: "string", value: "", comment: "Provide the PAN as digits only. The standard PVV input uses the rightmost 11 digits before the check digit." }, + { name: "PVKI", type: "number", value: 1, min: 0, max: 6, comment: "PIN verification key index from 0 through 6." }, + { name: "Clear PIN", type: "string", value: "", comment: "The PIN to verify. The operation re-derives the PVV from this PIN and compares it to the input PVV." }, + { name: "Output as JSON", type: "boolean", value: true, comment: "When enabled, returns the assembled PVV input and validity result." }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const [pvkHex, pan, pvki, pin, outputJson] = args; + const result = verifyVisaPvv(pvkHex, pan, pvki, pin, input); + return outputJson ? JSON.stringify(result, null, 4) : String(result.valid); + } +} + +export default VerifyVISAPVV; diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index 91cbed891d..d09ed060f9 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -27,6 +27,7 @@ class HTMLIngredient { this.value = config.value; this.disabled = config.disabled || false; this.hint = config.hint || false; + this.comment = config.comment || ""; this.rows = config.rows || false; this.target = config.target; this.defaultIndex = config.defaultIndex || 0; @@ -49,6 +50,7 @@ class HTMLIngredient { toHtml() { let html = "", i, m, eventFn; + const commentHtml = this.comment ? `
${this.comment}
` : ""; const hintHtml = this.hint ? `data-toggle="tooltip" title="${this.hint}"` : ""; switch (this.type) { @@ -65,6 +67,7 @@ class HTMLIngredient { value="${this.value}" ${this.disabled ? "disabled" : ""} ${this.maxLength ? `maxlength="${this.maxLength}"` : ""}> + ${commentHtml} `; break; case "shortString": @@ -79,6 +82,7 @@ class HTMLIngredient { value="${this.value}" ${this.disabled ? "disabled" : ""} ${this.maxLength ? `maxlength="${this.maxLength}"` : ""}> + ${commentHtml} `; break; case "toggleString": @@ -102,7 +106,7 @@ class HTMLIngredient { } html += ` - + ${commentHtml} `; break; case "number": @@ -118,6 +122,7 @@ class HTMLIngredient { max="${this.max}" step="${this.step}" ${this.disabled ? "disabled" : ""}> + ${commentHtml} `; break; case "boolean": @@ -134,6 +139,7 @@ class HTMLIngredient { value="${this.name}"> ${this.name} + ${commentHtml} `; break; case "option": @@ -155,6 +161,7 @@ class HTMLIngredient { } } html += ` + ${commentHtml} `; break; case "populateOption": @@ -180,6 +187,7 @@ class HTMLIngredient { } } html += ` + ${commentHtml} `; eventFn = this.type === "populateMultiOption" ? @@ -212,6 +220,7 @@ class HTMLIngredient { } html += ` + ${commentHtml} `; this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this); @@ -241,6 +250,7 @@ class HTMLIngredient { } html += ` + ${commentHtml} `; this.manager.addDynamicListener(".editable-option-menu a", "click", this.editableOptionClick, this); @@ -255,6 +265,7 @@ class HTMLIngredient { arg-name="${this.name}" rows="${this.rows ? this.rows : 3}" ${this.disabled ? "disabled" : ""}>${this.value} + ${commentHtml} `; break; case "argSelector": @@ -274,6 +285,7 @@ class HTMLIngredient { `; } html += ` + ${commentHtml} `; this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this); diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs index 725f0b5f39..06ed2d3de0 100755 --- a/src/web/HTMLOperation.mjs +++ b/src/web/HTMLOperation.mjs @@ -28,6 +28,8 @@ class HTMLOperation { this.name = name; this.description = config.description; + this.inlineHelp = config.inlineHelp || ""; + this.testDataSamples = config.testDataSamples || []; this.infoURL = config.infoURL; this.manualBake = config.manualBake || false; this.config = config; @@ -80,7 +82,19 @@ class HTMLOperation { * @returns {string} */ toFullHtml() { - let html = `
${Utils.escapeHtml(this.name)}
+ let html = `
${Utils.escapeHtml(this.name)}
`; + + if (this.inlineHelp) { + html += `
${this.inlineHelp}
`; + } + + if (this.testDataSamples.length) { + html += `
+ +
`; + } + + html += `
`; for (let i = 0; i < this.ingList.length; i++) { diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index 7cde638db2..b5a994da42 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -160,6 +160,7 @@ class Manager { this.addDynamicListener(".hide-args-icon", "click", this.recipe.hideArgsClick, this.recipe); this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe); this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe); + this.addDynamicListener(".populate-test-data", "click", this.recipe.populateTestDataClick, this.recipe); this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe); this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe); this.addDynamicListener("#rec-list .dropdown-menu.toggle-dropdown a", "click", this.recipe.dropdownToggleClick, this.recipe); diff --git a/src/web/stylesheets/components/_operation.css b/src/web/stylesheets/components/_operation.css index a97fed70be..1b36d460db 100755 --- a/src/web/stylesheets/components/_operation.css +++ b/src/web/stylesheets/components/_operation.css @@ -26,6 +26,58 @@ font-weight: var(--op-title-font-weight); } +.op-inline-help { + margin-top: 8px; + padding: 8px 10px; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.18); + font-size: 0.9em; + line-height: 1.35; +} + +.op-inline-help strong { + font-weight: 700; +} + +.op-test-data { + margin-top: 8px; +} + +.populate-test-data { + display: inline-block; + padding: 7px 12px; + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 6px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.14)); + color: #fff; + font-size: 0.85em; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.01em; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); +} + +.populate-test-data:hover, +.populate-test-data:focus { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.2)); + border-color: rgba(255, 255, 255, 0.65); + color: #fff; +} + +.arg-comment { + margin-top: 6px; + color: rgba(255, 255, 255, 0.88); + font-size: 0.82em; + line-height: 1.4; +} + +.arg-comment code { + color: inherit; + background: rgba(255, 255, 255, 0.12); + padding: 1px 4px; + border-radius: 3px; +} + .ingredients { display: flex; flex-flow: row wrap; @@ -169,6 +221,8 @@ input.toggle-string { filter: brightness(100%); } +.operation .form-group.is-filled label.bmd-label-floating, +.operation .form-group.is-focused label.bmd-label-floating, .operation .bmd-form-group.is-filled label.bmd-label-floating, .operation .bmd-form-group.is-focused label.bmd-label-floating { top: 4px !important; diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs index 4272ef3b67..acc38c8401 100755 --- a/src/web/waiters/RecipeWaiter.mjs +++ b/src/web/waiters/RecipeWaiter.mjs @@ -11,6 +11,18 @@ import {escapeControlChars} from "../utils/editorUtils.mjs"; import DOMPurify from "dompurify"; +const ECDH_TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVPecKErSPjan5fSz +f+jsKPKthv3Ao5N0IxkbatQNw16hRANCAARhg779GdYIpH0QnY66FmGX1nMFyybu +sjExdXFN15BBa1+zh1Cf7Cr484KJ8Mh2ga/Qs8qKk/8VbWSj0SbLb6Os +-----END PRIVATE KEY-----`; + +const ECDH_TEST_PEER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZWOfvFUyA5ITdtEUar7aAz308Llr +pPVK74bCKbeq3gIA5ZN0we6T18GSkTHtCCOG266YyCGTcE2JrnswYk1f8A== +-----END PUBLIC KEY-----`; + + /** * Waiter to handle events related to the recipe. */ @@ -213,6 +225,9 @@ class RecipeWaiter { */ ingChange(e) { if (e && e?.target?.classList?.contains("no-state-change")) return; + if (e?.target?.classList?.contains("arg")) { + this.syncArgVisualState(e.target); + } window.dispatchEvent(this.manager.statechange); } @@ -481,6 +496,251 @@ 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.syncArgVisualState(ingEls[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. * @@ -498,6 +758,30 @@ class RecipeWaiter { } + /** + * Keeps floating-label state in sync for programmatically populated args. + * + * @param {HTMLElement} el + */ + syncArgVisualState(el) { + if (!el) return; + + const group = el.closest(".form-group, .bmd-form-group"); + if (!group) return; + + let isFilled = false; + if (el.getAttribute("type") === "checkbox" || el.getAttribute("type") === "radio") { + isFilled = el.checked; + } else if (typeof el.value === "string") { + isFilled = el.value.trim().length > 0; + } else { + isFilled = Boolean(el.value); + } + + group.classList.toggle("is-filled", isFilled); + } + + /** * Handler for operationadd events. * @@ -577,6 +861,7 @@ class RecipeWaiter { if (text) { targ.value = text; + this.syncArgVisualState(targ); return; } @@ -585,7 +870,7 @@ class RecipeWaiter { const self = this; reader.onload = function (e) { targ.value = e.target.result; - // Trigger floating label move + self.syncArgVisualState(targ); const changeEvent = new Event("change"); targ.dispatchEvent(changeEvent); window.dispatchEvent(self.manager.statechange); diff --git a/tests/lib/utils.mjs b/tests/lib/utils.mjs index e29dbf90fb..a0a13cb02d 100644 --- a/tests/lib/utils.mjs +++ b/tests/lib/utils.mjs @@ -82,7 +82,8 @@ export function logTestReport(testStatus, results) { * Fail if the process takes longer than 60 seconds. */ export function setLongTestFailure() { - const timeLimit = 120; + const configuredTimeLimit = parseInt(process.env.CYBERCHEF_TEST_TIMEOUT_SECONDS || "", 10); + const timeLimit = Number.isFinite(configuredTimeLimit) && configuredTimeLimit > 0 ? configuredTimeLimit : 120; setTimeout(function() { console.log(`Tests took longer than ${timeLimit} seconds to run, returning.`); process.exit(1); diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index 2510ef1779..5f2476ee21 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -136,7 +136,7 @@ TestRegister.addApiTests([ it("chef.help: returns multiple results", () => { const result = chef.help("base 64"); - assert.strictEqual(result.length, 13); + assert.strictEqual(result.length, 14); }), it("chef.help: looks in description for matches too", () => { diff --git a/tests/node/tests/operations.mjs b/tests/node/tests/operations.mjs index 3b2bbda6c2..9673555441 100644 --- a/tests/node/tests/operations.mjs +++ b/tests/node/tests/operations.mjs @@ -136,7 +136,7 @@ Tiger-128`; it("Bcrypt", async () => { const result = await chef.bcrypt("Put a Sock In It"); const strResult = result.toString(); - assert.match(strResult, /^\$2b\$10\$[./A-Za-z0-9]{53}$/); + assert.match(strResult, /^\$2[ab]\$10\$[./A-Za-z0-9]{53}$/); assert.equal(strResult.split("$").length, 4); }), diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index c12c271098..2ab979ce86 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -213,3 +213,4 @@ const logOpsTestReport = logTestReport.bind(null, testStatus); const results = await TestRegister.runTests(); logOpsTestReport(results); })(); +import "./tests/Payment.mjs"; diff --git a/tests/operations/tests/DeriveECDHKeyMaterial.mjs b/tests/operations/tests/DeriveECDHKeyMaterial.mjs new file mode 100644 index 0000000000..37224643eb --- /dev/null +++ b/tests/operations/tests/DeriveECDHKeyMaterial.mjs @@ -0,0 +1,66 @@ +/** + * DeriveECDHKeyMaterial tests. + * + * Test vectors generated with Node.js WebCrypto (crypto.subtle) and cross-verified + * in both directions (Alice→Bob and Bob→Alice produce identical shared secrets). + * + * @author Jacob Marks + * @copyright Crown Copyright 2024 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +// Alice's P-256 private key (PKCS#8 PEM) +const ALICE_PRIV_P256 = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4HBsMvgcOvEQBrYJ +dEXulke/dh5vYiOvfI41AToqfbWhRANCAAQgZgScW2pSpRRTOADLPL5D+8TF6xXx +x9GDOE8V1xYj7arujDYH5935uCdVxXa84lUEw35+afHuh0bDmBDxolmx +-----END PRIVATE KEY-----`; + +// Bob's P-256 public key (SPKI PEM) +const BOB_PUB_P256 = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa+FXJzzko0OZ9DcOaXpLzAkSt7bE +XXVKQqYfsmuelH6QgH86dMR04/bvnhl4bF7YKbMWDlPRHs9haSeR/PhFNg== +-----END PUBLIC KEY-----`; + +// Shared secret = x-coordinate of ECDH(Alice_priv, Bob_pub) +const SHARED_SECRET_HEX = "4E030938FB1958545CCEFC98007DFB5F5780497161EB92D004391AF41D431ACF"; + +// Concat KDF SHA-256 over the shared secret, 32 bytes output, no shared info +const KDF_SHA256_32B_HEX = "61F4121E618428606D52ADC4626990A34BB59C14C4D14C3DD3AF5D082475FA85"; + +TestRegister.addTests([ + { + name: "Derive ECDH Key Material: P-256 raw shared secret (None KDF)", + input: ALICE_PRIV_P256, + expectedOutput: SHARED_SECRET_HEX, + recipeConfig: [ + { + "op": "Derive ECDH Key Material", + "args": ["PEM", "P-256", "PEM", BOB_PUB_P256, "None", 32, "", "Hex"] + } + ] + }, + { + name: "Derive ECDH Key Material: P-256 Concat KDF SHA-256 (32 bytes, no shared info)", + input: ALICE_PRIV_P256, + expectedOutput: KDF_SHA256_32B_HEX, + recipeConfig: [ + { + "op": "Derive ECDH Key Material", + "args": ["PEM", "P-256", "PEM", BOB_PUB_P256, "Concat KDF SHA-256", 32, "", "Hex"] + } + ] + }, + { + name: "Derive ECDH Key Material: missing peer public key returns error", + input: ALICE_PRIV_P256, + expectedOutput: "Missing key input.", + recipeConfig: [ + { + "op": "Derive ECDH Key Material", + "args": ["PEM", "P-256", "PEM", "", "None", 32, "", "Hex"] + } + ] + }, +]); diff --git a/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs new file mode 100644 index 0000000000..1f124468ac --- /dev/null +++ b/tests/operations/tests/Payment.mjs @@ -0,0 +1,1997 @@ +/** + * 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 Thales payShield command: header, LMK identifier, trailer", + input: "HEADHE0123456789ABCDEF0011223344556677%00TAIL", + expectedOutput: JSON.stringify({ + rawInput: "HEADHE0123456789ABCDEF0011223344556677%00TAIL", + framing: { + stxPresent: true, + etxPresent: true, + endMessageDelimiterPresent: true + }, + normalizedMessage: "HEADHE0123456789ABCDEF0011223344556677%00", + messageHeaderLength: 4, + messageHeader: "HEAD", + commandCode: "HE", + commandCodeType: "request", + commandNames: ["Encrypt Data Block"], + requestCodes: ["HE"], + expectedResponseCodes: ["HF"], + manualPages: [107], + payload: "0123456789ABCDEF0011223344556677", + payloadLength: 32, + lmkIdentifier: "00", + lmkIdentifierDelimiterPresent: true, + tildeDelimiterPresentBeforeLmkIdentifier: false, + messageTrailer: "TAIL", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "HSM Parse Thales Command", + args: [4] + } + ] + }, + { + name: "Parse Thales payShield command: trailing tilde before LMK identifier", + input: "MA0123456789ABCDEFHELLO~%12", + expectedOutput: JSON.stringify({ + rawInput: "MA0123456789ABCDEFHELLO~%12", + framing: { + stxPresent: false, + etxPresent: false, + endMessageDelimiterPresent: false + }, + normalizedMessage: "MA0123456789ABCDEFHELLO~%12", + messageHeaderLength: 0, + messageHeader: "", + commandCode: "MA", + commandCodeType: "request", + commandNames: ["Generate a MAC"], + requestCodes: ["MA"], + expectedResponseCodes: ["MB"], + manualPages: [90], + payload: "0123456789ABCDEFHELLO", + payloadLength: 21, + lmkIdentifier: "12", + lmkIdentifierDelimiterPresent: true, + tildeDelimiterPresentBeforeLmkIdentifier: true, + messageTrailer: "", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "HSM Parse Thales Command", + args: [0] + } + ] + }, + { + name: "Parse Futurex Excrypt command: bracketed fields", + input: "[AOGMAC;FS6;RV0011223344556677;]", + expectedOutput: JSON.stringify({ + rawInput: "[AOGMAC;FS6;RV0011223344556677;]", + openingDelimiterPresent: true, + closingDelimiterPresent: true, + body: "AOGMAC;FS6;RV0011223344556677;", + rawFields: [ + "AOGMAC", + "FS6", + "RV0011223344556677" + ], + fields: [ + { + raw: "AOGMAC", + tag: "AO", + value: "GMAC" + }, + { + raw: "FS6", + tag: "FS", + value: "6" + }, + { + raw: "RV0011223344556677", + tag: "RV", + value: "0011223344556677" + } + ], + commandFieldTag: "AO", + commandCode: "GMAC", + commandName: "Generate Message Authentication Code", + fieldCount: 3, + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "HSM Parse Futurex Command", + args: [] + } + ] + }, + { + name: "Parse Futurex Excrypt command: missing closing delimiter", + input: "[AOVMAC;FS6;RV89ABCDEF", + expectedOutput: JSON.stringify({ + rawInput: "[AOVMAC;FS6;RV89ABCDEF", + openingDelimiterPresent: true, + closingDelimiterPresent: false, + body: "AOVMAC;FS6;RV89ABCDEF", + rawFields: [ + "AOVMAC", + "FS6", + "RV89ABCDEF" + ], + fields: [ + { + raw: "AOVMAC", + tag: "AO", + value: "VMAC" + }, + { + raw: "FS6", + tag: "FS", + value: "6" + }, + { + raw: "RV89ABCDEF", + tag: "RV", + value: "89ABCDEF" + } + ], + commandFieldTag: "AO", + commandCode: "VMAC", + commandName: "Verify Message Authentication Code", + fieldCount: 3, + notes: [ + "Message is missing one or both expected Excrypt outer delimiters." + ] + }, null, 4), + recipeConfig: [ + { + op: "HSM Parse Futurex Command", + args: [] + } + ] + }, + { + name: "Parse TR-31 key block: fixed header only", + input: "D0016D0AB00E0000", + expectedOutput: JSON.stringify({ + raw: "D0016D0AB00E0000", + fixedHeader: { + raw: "D0016D0AB00E0000", + versionId: "D", + versionDescription: "ANSI X9.24-2 (2017) — AES, Key Derivation Binding Method (current PCI standard)", + declaredBlockLength: 16, + keyUsage: "D0", + keyUsageDescription: "Symmetric Data Encryption Key (DEK)", + algorithm: "A", + algorithmDescription: "AES", + modeOfUse: "B", + modeOfUseDescription: "Both Encrypt and Decrypt / Both Generate and Verify", + keyVersionNumber: "00", + exportability: "E", + exportabilityDescription: "Exportable — can be wrapped under a KEK in a trusted key block", + optionalBlocksDeclared: 0, + reserved: "00" + }, + compliance: [ + "OK: Version D (AES Key Derivation) — current PCI-required format", + "NOTE: Exportable key — verify the wrapping KEK is a PCI-approved key block protection key" + ], + optionalBlocks: [], + bodyOffset: 16, + remainingBody: "", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "TR-31 Parse Key Block", + args: [true] + } + ] + }, + { + name: "Parse TR-34 key transport: split sections", + input: "001730303030423930303100112233300030303034AABBCCDD", + expectedOutput: JSON.stringify({ + declaredLength: 23, + actualLengthExcludingLengthField: 23, + header: "0000", + messageType: "B9", + messageDescription: "BindResponse — final key delivery; contains CMS EnvelopedData + signature", + errorCode: "00", + errorDescription: "Success", + authData: { + hex: "3100", + byteCount: 2, + asnOuter: null + }, + kcvHex: "112233", + envelopeData: { + hex: "3000", + byteCount: 2, + description: "CMS EnvelopedData — wrapped symmetric key (decrypt with KRD private RSA key)", + asnOuter: { + tag: "0x30 (SEQUENCE)", + headerBytes: 2, + valueLength: 0, + totalExpected: 2, + complete: true + } + }, + signatureLengthAscii: "0004", + signatureLength: 4, + signatureHex: "AABBCCDD", + trailingHex: "", + notes: [] + }, null, 4), + recipeConfig: [ + { + op: "TR-34 Parse Key Transport", + args: [] + } + ] + }, + { + name: "Payment Calculate KCV: HMAC SHA-256", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "E8A065", + recipeConfig: [ + { + op: "Payment Calculate KCV", + args: ["Hex", "HMAC SHA-256", 6] + } + ] + }, + { + name: "Payment Calculate KCV: AES-CMAC empty", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "917737", + recipeConfig: [ + { + op: "Payment Calculate KCV", + args: ["Hex", "AES-CMAC (Empty)", 6] + } + ] + }, + { + name: "Payment Calculate KCV: AES-CMAC zeros", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "53E107", + recipeConfig: [ + { + op: "Payment Calculate KCV", + args: ["Hex", "AES-CMAC (Zeros)", 6] + } + ] + }, + { + name: "Payment Calculate KCV: AES-CMAC ones", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "7B3046", + recipeConfig: [ + { + op: "Payment Calculate KCV", + args: ["Hex", "AES-CMAC (Ones)", 6] + } + ] + }, + { + name: "Payment Calculate KCV: AES-ECB zeros", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "FDE4FB", + recipeConfig: [ + { + op: "Payment Calculate KCV", + args: ["Hex", "AES-ECB (Zeros)", 6] + } + ] + }, + { + // ── DUKPT Derive AES Key — ANSI X9.24-3-2017 official test vectors ─────── + // BDK-128: FEDCBA9876543210F1F1F1F1F1F1F1F1 + // KSN: 1234567890123456 (IKI) + counter + // Source: https://x9.org/standards/x9-24-part-3-test-vectors/ + name: "DUKPT Derive AES Key: IK from BDK (X9.24-3 §6.3.1)", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "1273671EA26AC29AFA4D1084127652A1", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Initial Key (IK)", "123456789012345600000001", "PIN Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: PIN Encryption key, counter 1 (X9.24-3 §6.3.3)", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "AF8CB133A78F8DC2D1359F18527593FB", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "123456789012345600000001", "PIN Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: MAC Generation key, counter 1", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "A2DC23DE6FDE0824A2BC321E08E4B8B7", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "123456789012345600000001", "MAC Generation", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: Data Encryption key, counter 1", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "A35C412EFD41FDB98B69797C02DCD08F", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "123456789012345600000001", "Data Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: PIN Encryption key, counter 8", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "4D9DF3FBEE3448FC3E676D04320A90F5", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "123456789012345600000008", "PIN Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: PIN Encryption key, counter 131072 (0x20000, first skipped-bit counter)", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "AB828BE7B58C7EC5D5ED0D5D320A0C9D", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "123456789012345600020000", "PIN Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: PIN Encryption key, counter 8675309 (0x845FED, midrange)", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: "D1DDA386AA4A556AF0119FDCB5D132C6", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Working Key", "1234567890123456 00845FED", "PIN Encryption", false] + } + ] + }, + { + name: "DUKPT Derive AES Key: working key from IK input, counter 1 PIN Encryption", + input: "1273671EA26AC29AFA4D1084127652A1", + expectedOutput: "AF8CB133A78F8DC2D1359F18527593FB", + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["Initial Key (IK)", "Working Key", "123456789012345600000001", "PIN Encryption", false] + } + ] + }, + { + // ── DUKPT Derive AES Key — json=true output shape ──────────────────────── + // Verifies that IK derivation JSON output includes ksn, iki, and counter + // in addition to bdk and ik. Same BDK/KSN as §6.3.1 vector above. + // iki = first 8 bytes of KSN; counter = last 4 bytes = 0x00000001. + name: "DUKPT Derive AES Key: IK JSON output includes ksn, iki, counter", + input: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + expectedOutput: JSON.stringify({ + inputKeyType: "BDK", + ksn: "123456789012345600000001", + iki: "1234567890123456", + counter: "0x00000001", + bdk: "FEDCBA9876543210F1F1F1F1F1F1F1F1", + ik: "1273671EA26AC29AFA4D1084127652A1" + }, null, 4), + recipeConfig: [ + { + op: "DUKPT Derive AES Key", + args: ["BDK", "Initial Key (IK)", "123456789012345600000001", "PIN Encryption", true] + } + ] + }, + { + name: "DUKPT Derive TDES Key: known IPEK vector", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "6AC292FAA1315B4D858AB3A3D7D5933A", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive IPEK", "FFFF9876543210E00008", "None", false] + } + ] + }, + { + // ── DUKPT Derive TDES Key — session key variants (ANSI X9.24-1) ────────── + // Same BDK/KSN as IPEK test above. IPEK = 6AC292FAA1315B4D858AB3A3D7D5933A. + // sessionBase at counter 1 = 042666B49184CFA368DE9628D0397BC9 (confirmed + // empirically; variant keys are sessionBase XOR the ANSI X9.24-1 masks). + name: "DUKPT Derive TDES Key: session key, variant None, counter 1", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "042666B49184CFA368DE9628D0397BC9", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "None", false] + } + ] + }, + { + name: "DUKPT Derive TDES Key: session key, variant PIN, counter 1", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "042666B49184CF5C68DE9628D0397B36", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "PIN", false] + } + ] + }, + { + name: "DUKPT Derive TDES Key: session key, variant MAC Request, counter 1", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "042666B4918430A368DE9628D03984C9", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "MAC Request", false] + } + ] + }, + { + name: "DUKPT Derive TDES Key: session key, variant MAC Response, counter 1", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "042666B46E84CFA368DE96282F397BC9", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "MAC Response", false] + } + ] + }, + { + name: "DUKPT Derive TDES Key: session key, variant Data, counter 1", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "042666B4917BCFA368DE9628D0C67BC9", + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "Data", false] + } + ] + }, + { + name: "DUKPT Derive TDES Key: session key JSON output includes ipek and sessionBase", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: JSON.stringify({ + mode: "Derive Session Key", + ksn: "FFFF9876543210E00001", + bdk: "0123456789ABCDEFFEDCBA9876543210", + ipek: "6AC292FAA1315B4D858AB3A3D7D5933A", + sessionBase: "042666B49184CFA368DE9628D0397BC9", + variant: "None", + sessionKey: "042666B49184CFA368DE9628D0397BC9" + }, null, 4), + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive Session Key", "FFFF9876543210E00001", "None", true] + } + ] + }, + { + // ── DUKPT Derive TDES Key — IPEK json=true output shape ────────────────── + // Verifies that IPEK derivation JSON output includes ksn and bdk in + // addition to ipek. Same BDK/KSN as the known IPEK vector test above. + name: "DUKPT Derive TDES Key: IPEK JSON output includes ksn and bdk", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: JSON.stringify({ + mode: "Derive IPEK", + ksn: "FFFF9876543210E00008", + bdk: "0123456789ABCDEFFEDCBA9876543210", + ipek: "6AC292FAA1315B4D858AB3A3D7D5933A" + }, null, 4), + recipeConfig: [ + { + op: "DUKPT Derive TDES Key", + args: ["Derive IPEK", "FFFF9876543210E00008", "None", true] + } + ] + }, + { + name: "PIN Block Build: ISO Format 0", + input: "1234", + expectedOutput: "041215FEDCBA9876", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 0", "5432101234567890", false] + } + ] + }, + { + name: "PIN Block Parse: 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: "PIN Block Parse", + args: ["ISO Format 0", "5432101234567890"] + } + ] + }, + { + name: "PIN Block Translate: 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: "PIN Block Translate", + args: ["ISO Format 0", "5432101234567890", "ISO Format 1", "", false] + } + ] + }, + { + name: "PIN Block Build: ISO Format 1 deterministic", + input: "1234", + expectedOutput: "141234FFFFFFFFFF", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 1", "", false] + } + ] + }, + { + name: "PIN Block Parse: ISO Format 1", + input: "141234FFFFFFFFFF", + expectedOutput: JSON.stringify({ + format: "ISO Format 1", + pin: "1234", + pinLength: 4, + pinFieldHex: "141234FFFFFFFFFF", + panFieldHex: null, + blockHex: "141234FFFFFFFFFF", + fillDigitsHex: "FFFFFFFFFF" + }, null, 4), + recipeConfig: [ + { + op: "PIN Block Parse", + args: ["ISO Format 1", ""] + } + ] + }, + { + name: "PIN Block Build: ISO Format 3 deterministic", + input: "1234", + expectedOutput: "341215AB89EFCD23", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 3", "5432101234567890", false] + } + ] + }, + { + name: "PIN Block Parse: ISO Format 3", + input: "341215AB89EFCD23", + expectedOutput: JSON.stringify({ + format: "ISO Format 3", + pin: "1234", + pinLength: 4, + pinFieldHex: "341234AAAAAAAAAA", + panFieldHex: "0000210123456789", + blockHex: "341215AB89EFCD23", + fillDigitsHex: "AAAAAAAAAA" + }, null, 4), + recipeConfig: [ + { + op: "PIN Block Parse", + args: ["ISO Format 3", "5432101234567890"] + } + ] + }, + { + // ── PIN Block — edge cases ──────────────────────────────────────────────── + // Leading-zero PAN: exercises the padStart("0",12) path in buildPanField. + // PAN "0000001234567890": strip check → "000000123456789", right-12 → "000123456789" + name: "PIN Block Build: ISO Format 0, leading-zero PAN", + input: "1234", + expectedOutput: "041234FEDCBA9876", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 0", "0000001234567890", false] + } + ] + }, + { + name: "PIN Block Parse: ISO Format 0, leading-zero PAN", + input: "041234FEDCBA9876", + expectedOutput: JSON.stringify({ + format: "ISO Format 0", + pin: "1234", + pinLength: 4, + pinFieldHex: "041234FFFFFFFFFF", + panFieldHex: "0000000123456789", + blockHex: "041234FEDCBA9876", + fillDigitsHex: "FFFFFFFFFF" + }, null, 4), + recipeConfig: [ + { + op: "PIN Block Parse", + args: ["ISO Format 0", "0000001234567890"] + } + ] + }, + { + // 12-digit PAN: strip check → 11 digits, padStart adds one leading zero. + // PAN "123456789012": strip check → "12345678901" (11 chars), right-12 pads to "012345678901" + name: "PIN Block Build: ISO Format 0, 12-digit PAN (padStart path)", + input: "1234", + expectedOutput: "041235DCBA9876FE", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 0", "123456789012", false] + } + ] + }, + { + // 6-digit PIN: maximum PIN length per ISO 9564; length nibble = 6 and fill is 8 nibbles. + name: "PIN Block Build: ISO Format 0, 6-digit PIN", + input: "123456", + expectedOutput: "06121557DCBA9876", + recipeConfig: [ + { + op: "PIN Block Build", + args: ["ISO Format 0", "5432101234567890", false] + } + ] + }, + { + name: "PIN Block Parse: ISO Format 0, 6-digit PIN", + input: "06121557DCBA9876", + expectedOutput: JSON.stringify({ + format: "ISO Format 0", + pin: "123456", + pinLength: 6, + pinFieldHex: "06123456FFFFFFFF", + panFieldHex: "0000210123456789", + blockHex: "06121557DCBA9876", + fillDigitsHex: "FFFFFFFF" + }, null, 4), + recipeConfig: [ + { + op: "PIN Block Parse", + args: ["ISO Format 0", "5432101234567890"] + } + ] + }, + { + name: "Card Validation Data Generate: known CVV2 sample", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "221", + recipeConfig: [ + { + op: "Card Validation Data Generate", + args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", 3, false] + } + ] + }, + { + name: "PAN Generate: Visa curated sample", + input: "", + expectedOutput: JSON.stringify({ + brand: "Visa", + mode: "Curated sample", + pan: "4024140000000131", + source: "Public Visa test PAN published in Mastercard AVS scenario documentation.", + network: "Visa", + majorIndustryIdentifier: "4", + majorIndustryIdentifierDescription: "Banking and financial (Visa)", + issuerIdentificationNumber: "40241400", + length: 16, + luhnValid: true, + matchedRule: { + rangeStart: "4", + rangeEnd: "4", + lengths: [13, 16, 19], + description: "Visa cards begin with 4." + } + }, null, 4), + recipeConfig: [ + { + op: "PAN Generate", + args: ["Visa", "Curated sample", 16, "Any", true] + } + ] + }, + { + name: "PAN Generate: American Express curated sample", + input: "", + expectedOutput: "371449635398431", + recipeConfig: [ + { + op: "PAN Generate", + args: ["American Express", "Curated sample", 15, "Any", false] + } + ] + }, + { + name: "PAN Parse: Discover sample", + input: "6011000991543426", + expectedOutput: JSON.stringify({ + pan: "6011000991543426", + network: "Discover", + cardType: "Credit", + cardTypeConfidence: "medium", + cardTypeNote: "The common Discover BIN ranges (6011, 644-649, 65, 622126-622925) are predominantly credit cards. Discover does offer some debit products on separate BIN ranges.", + majorIndustryIdentifier: "6", + majorIndustryIdentifierDescription: "Merchandising and banking (Discover, Maestro)", + issuerIdentificationNumber: "60110009", + length: 16, + luhnValid: true, + matchedRule: { + rangeStart: "6011", + rangeEnd: "6011", + lengths: [16, 17, 18, 19], + description: "Discover range 6011." + } + }, null, 4), + recipeConfig: [ + { + op: "PAN Parse", + args: [] + } + ] + }, + { + name: "Card Validation Data Verify: 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: "Card Validation Data Verify", + args: ["CVV2 / CVC2 (force 000)", "4123456789012345", "02", "25", "MMYY", "101", "221"] + } + ] + }, + { + name: "EMV Generate ARQC: AES-CMAC profile", + input: "000102030405060708090A0B0C0D0E0F", + expectedOutput: "C1F732B52FB20CAA", + recipeConfig: [ + { + op: "EMV Generate ARQC", + args: ["00112233445566778899AABBCCDDEEFF", 8, false] + } + ] + }, + { + name: "EMV Generate ARPC: AES-CMAC profile", + input: "11223344556677889900AABBCCDDEEFF", + expectedOutput: "312442B1A4D64F94", + recipeConfig: [ + { + op: "EMV Generate ARPC", + args: ["00112233445566778899AABBCCDDEEFF", 8, false] + } + ] + }, + { + name: "EMV Verify ARQC: AES-CMAC profile", + input: "C1F732B52FB20CAA", + expectedOutput: JSON.stringify({ + inputHex: "000102030405060708090A0B0C0D0E0F", + outputBytes: 8, + fullMacHex: "C1F732B52FB20CAAB58D5B6C78CBD514", + cryptogramHex: "C1F732B52FB20CAA", + expectedArqcHex: "C1F732B52FB20CAA", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "EMV Verify ARQC", + args: ["00112233445566778899AABBCCDDEEFF", 8, "000102030405060708090A0B0C0D0E0F", true] + } + ] + }, + // ── EMV Build / Parse ARPC Data ─────────────────────────────────────────── + // Method 1 (Visa/Amex): ARQC=A1B2C3D4E5F60708, ARC=5931 → 10 bytes + // Method 2 (Mastercard): ARQC=A1B2C3D4E5F60708, CSU=00000000 → 12 bytes + { + name: "EMV Build ARPC Data: Method 1 hex output", + input: "", + expectedOutput: "A1B2C3D4E5F607085931", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }] + }, + { + name: "EMV Build ARPC Data: Method 2 hex output (no PAD)", + input: "", + expectedOutput: "A1B2C3D4E5F6070800000000", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 2 (Mastercard)", "A1B2C3D4E5F60708", "5931", "00000000", "", "Hex"] + }] + }, + { + name: "EMV Build ARPC Data: Method 2 hex output (with PAD)", + input: "", + expectedOutput: "A1B2C3D4E5F6070800000000AABBCCDD", + recipeConfig: [{ + op: "EMV Build ARPC Data", + args: ["Method 2 (Mastercard)", "A1B2C3D4E5F60708", "5931", "00000000", "AABBCCDD", "Hex"] + }] + }, + { + name: "EMV Parse ARPC Data: Method 1 JSON", + input: "A1B2C3D4E5F607085931", + expectedOutput: JSON.stringify({ + method: "Method 1 (Visa/Amex/Discover)", + ARQC: "A1B2C3D4E5F60708", + ARC: "5931", + }, null, 4), + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "JSON"] + }] + }, + { + name: "EMV Parse ARPC Data: Method 2 JSON (with PAD)", + input: "A1B2C3D4E5F6070800000000AABBCCDD", + expectedOutput: JSON.stringify({ + method: "Method 2 (Mastercard)", + ARQC: "A1B2C3D4E5F60708", + "Card Status Update (CSU)": "00000000", + "Proprietary Auth Data": "AABBCCDD", + }, null, 4), + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 2 (Mastercard)", "JSON"] + }] + }, + { + name: "EMV Parse ARPC Data: wrong length for Method 1 throws", + input: "A1B2C3D4", + expectedOutput: "Method 1 preimage requires 20 hex chars (10 bytes); got 8.", + recipeConfig: [{ + op: "EMV Parse ARPC Data", + args: ["Method 1 (Visa/Amex/Discover)", "JSON"] + }] + }, + + // ── EMV Build / Parse ARQC Data ─────────────────────────────────────────── + // CDOL1 sample: Visa $10.00 USD, USA terminal, date 2026-05-21 + // 9F02 000000001000 9F03 000000000000 9F1A 0840 95 0000000000 + // 5F2A 0840 9A 260521 9C 00 9F37 A1B2C3D4 82 5900 9F36 0001 + // Assembled hex (33 bytes / 66 chars): + // 00000000100000000000000008400000000000084026052100A1B2C3D459000001 + { + name: "EMV Build ARQC Data: hex output", + input: "", + expectedOutput: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Hex"] + } + ] + }, + { + name: "EMV Build ARQC Data: JSON output", + input: "", + expectedOutput: JSON.stringify({ + "Amount Authorised (9F02)": "000000001000", + "Amount Other (9F03)": "000000000000", + "Terminal Country Code (9F1A)": "0840", + "TVR (95)": "0000000000", + "Transaction Currency Code (5F2A)": "0840", + "Transaction Date (9A)": "260521", + "Transaction Type (9C)": "00", + "Unpredictable Number (9F37)": "A1B2C3D4", + "AIP (82)": "5900", + "ATC (9F36)": "0001", + }, null, 4), + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "JSON"] + } + ] + }, + { + name: "EMV Build ARQC Data: annotated TLV output", + input: "", + expectedOutput: [ + "9F02 06 000000001000 [Amount Authorised]", + "9F03 06 000000000000 [Amount Other]", + "9F1A 02 0840 [Terminal Country Code]", + "95 05 0000000000 [TVR]", + "5F2A 02 0840 [Transaction Currency Code]", + "9A 03 260521 [Transaction Date]", + "9C 01 00 [Transaction Type]", + "9F37 04 A1B2C3D4 [Unpredictable Number]", + "82 02 5900 [AIP]", + "9F36 02 0001 [ATC]", + ].join("\n"), + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["000000001000", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Annotated TLV"] + } + ] + }, + { + name: "EMV Parse ARQC Data: annotated TLV", + input: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + expectedOutput: [ + "9F02 06 000000001000 [Amount Authorised]", + "9F03 06 000000000000 [Amount Other]", + "9F1A 02 0840 [Terminal Country Code]", + "95 05 0000000000 [TVR]", + "5F2A 02 0840 [Transaction Currency Code]", + "9A 03 260521 [Transaction Date]", + "9C 01 00 [Transaction Type]", + "9F37 04 A1B2C3D4 [Unpredictable Number]", + "82 02 5900 [AIP]", + "9F36 02 0001 [ATC]", + ].join("\n"), + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["Annotated TLV"] + } + ] + }, + { + name: "EMV Parse ARQC Data: JSON", + input: "00000000100000000000000008400000000000084026052100A1B2C3D459000001", + expectedOutput: JSON.stringify({ + "Amount Authorised (9F02)": "000000001000", + "Amount Other (9F03)": "000000000000", + "Terminal Country Code (9F1A)": "0840", + "TVR (95)": "0000000000", + "Transaction Currency Code (5F2A)": "0840", + "Transaction Date (9A)": "260521", + "Transaction Type (9C)": "00", + "Unpredictable Number (9F37)": "A1B2C3D4", + "AIP (82)": "5900", + "ATC (9F36)": "0001", + }, null, 4), + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["JSON"] + } + ] + }, + { + name: "EMV Build ARQC Data: bad field length throws", + input: "", + expectedOutput: "Amount Authorised: expected 12 hex chars (6 bytes), got 4.", + recipeConfig: [ + { + op: "EMV Build ARQC Data", + args: ["0001", "000000000000", "0840", "0000000000", "0840", "260521", "00", "A1B2C3D4", "5900", "0001", "Hex"] + } + ] + }, + { + name: "EMV Parse ARQC Data: too-short input throws", + input: "000000001000", + expectedOutput: "Standard CDOL1 requires 66 hex chars (33 bytes); got 12.", + recipeConfig: [ + { + op: "EMV Parse ARQC Data", + args: ["JSON"] + } + ] + }, + { + name: "Payment Encrypt Data: AES CBC", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false] + } + ] + }, + { + name: "Payment Decrypt Data: AES CBC", + input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B", + expectedOutput: "00112233445566778899AABBCCDDEEFF", + recipeConfig: [ + { + op: "Payment Decrypt Data", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", false] + } + ] + }, + { + name: "Payment Re-Encrypt Data: AES CBC to TDES CBC", + input: "67423557CA0509243B9EE04A5DA3448AA397F6D29B5C8BCE065D9CDC936B7F9B", + expectedOutput: "C47BC6E91A9D566F649D750BCE1CE9889FB5AE1489A16692", + recipeConfig: [ + { + op: "Payment Re-Encrypt Data", + args: ["AES CBC", "00112233445566778899AABBCCDDEEFF", "000102030405060708090A0B0C0D0E0F", "", "Data", "TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "1234567890ABCDEF", "", "Data", false] + } + ] + }, + { + // ── Payment Encrypt / Decrypt — TDES profiles ──────────────────────────── + // TDES ECB vector: APC cross-validated for the first block (✅ MATCH, 2026-05-19). + // key = tdes_dek1 (0101…FEFE…) — D0 data-encryption key from APC test set + // The operation appends an ISO 9797-1 method-2 padding block, so 8 bytes + // of plaintext produces 16 bytes of ciphertext. APC compared only block 1. + name: "Payment Encrypt Data: TDES ECB", + input: "0102030405060708", + expectedOutput: "B064B6C2571C65D5ACB2CF1241618C8B", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["TDES ECB", "0101010101010101FEFEFEFEFEFEFEFE", "", "", "Data", false] + } + ] + }, + { + name: "Payment Decrypt Data: TDES ECB", + input: "B064B6C2571C65D5ACB2CF1241618C8B", + expectedOutput: "0102030405060708", + recipeConfig: [ + { + op: "Payment Decrypt Data", + args: ["TDES ECB", "0101010101010101FEFEFEFEFEFEFEFE", "", "", "Data", false] + } + ] + }, + { + // TDES CBC vectors derived from the AES→TDES re-encrypt test above: + // AES CBC decrypt of the re-encrypt input recovers the original plaintext, + // which TDES CBC then re-encrypts to the re-encrypt expected output. + name: "Payment Encrypt Data: TDES CBC", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "C47BC6E91A9D566F649D750BCE1CE9889FB5AE1489A16692", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "1234567890ABCDEF", "", "Data", false] + } + ] + }, + { + name: "Payment Decrypt Data: TDES CBC", + input: "C47BC6E91A9D566F649D750BCE1CE9889FB5AE1489A16692", + expectedOutput: "00112233445566778899AABBCCDDEEFF", + recipeConfig: [ + { + op: "Payment Decrypt Data", + args: ["TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "1234567890ABCDEF", "", "Data", false] + } + ] + }, + { + // DUKPT TDES ECB: CyberChef follows ANSI X9.24-1 "Data" variant (bytes 5 + // and 13 of the session key XOR 0xFF). APC uses a different internal + // variant — see PAYMENT_RECIPES.md §DUKPT TDES Encrypt for details. + // Derivation chain: BDK → IPEK (at E00008) → session key (at E00001). + // Session key (variant "Data") = 042666B4917BCFA368DE9628D0C67BC9. + // ISO 9797-1 method-2 padding appends a second block to the output. + name: "Payment Encrypt Data: DUKPT TDES ECB (Data variant, counter 1)", + input: "0102030405060708", + expectedOutput: "92A5157E4607D1B098E2F2D4660798DF", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["DUKPT TDES ECB", "0123456789ABCDEFFEDCBA9876543210", "", "FFFF9876543210E00001", "Data", false] + } + ] + }, + { + // Golden-value tests: verify wrapper arg wiring and padding behaviour are + // stable. AES is backed by the upstream forge library; these tests catch + // regressions in mode selection, IV handling, and key plumbing. + // ISO 9797-1 method-2 padding: 16-byte input → 32-byte ciphertext (data + padding block). + name: "Payment Encrypt Data: AES ECB", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "62F679BE2BF0D931641E039CA3401BB200657EA140655A44782747705D422FAD", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["AES ECB", "00112233445566778899AABBCCDDEEFF", "", "", "Data", false] + } + ] + }, + { + name: "Payment Decrypt Data: AES ECB", + input: "62F679BE2BF0D931641E039CA3401BB200657EA140655A44782747705D422FAD", + expectedOutput: "00112233445566778899AABBCCDDEEFF", + recipeConfig: [ + { + op: "Payment Decrypt Data", + args: ["AES ECB", "00112233445566778899AABBCCDDEEFF", "", "", "Data", false] + } + ] + }, + { + // CTR is a stream mode — no ISO 9797-1 padding appended. + name: "Payment Encrypt Data: AES CTR", + input: "00112233445566778899AABBCCDDEEFF", + expectedOutput: "FDF5D99D0E5C8657676E882D535E6DD4", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["AES CTR", "00112233445566778899AABBCCDDEEFF", "00000000000000000000000000000000", "", "Data", false] + } + ] + }, + { + name: "Payment Decrypt Data: AES CTR", + input: "FDF5D99D0E5C8657676E882D535E6DD4", + expectedOutput: "00112233445566778899AABBCCDDEEFF", + recipeConfig: [ + { + op: "Payment Decrypt Data", + args: ["AES CTR", "00112233445566778899AABBCCDDEEFF", "00000000000000000000000000000000", "", "Data", false] + } + ] + }, + { + // DUKPT TDES CBC: same BDK/KSN as TDES ECB test; CBC chains blocks using IV. + // ISO 9797-1 method-2 padding appends a second block to the output. + name: "Payment Encrypt Data: DUKPT TDES CBC (Data variant, counter 1)", + input: "0102030405060708", + expectedOutput: "92A5157E4607D1B0D64C005667C8C4DB", + recipeConfig: [ + { + op: "Payment Encrypt Data", + args: ["DUKPT TDES CBC", "0123456789ABCDEFFEDCBA9876543210", "0000000000000000", "FFFF9876543210E00001", "Data", false] + } + ] + }, + { + name: "MAC Generate: AES-CMAC", + input: "1122334455667788", + expectedOutput: "339AF1AD1650E908", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", 8, false] + } + ] + }, + { + name: "MAC Generate: HMAC SHA-256", + input: "1122334455667788", + expectedOutput: "9300E1D36DD30415", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "HMAC SHA-256", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", 8, false] + } + ] + }, + { + name: "MAC Generate: DUKPT MAC Request CMAC", + input: "1122334455667788", + expectedOutput: "3616961727FE155D", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "DUKPT MAC Request CMAC", "0123456789ABCDEFFEDCBA9876543210", "Hex", "FFFF9876543210E00008", "Method 1", 8, false] + } + ] + }, + { + name: "MAC Generate: ISO 9797-1 Algorithm 1", + input: "1122334455667788", + expectedOutput: "0C949BCDEF6FDF1D", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "ISO 9797-1 Algorithm 1", "0123456789ABCDEFFEDCBA9876543210", "Hex", "", "Method 1", 8, false] + } + ] + }, + { + name: "MAC Generate: ISO 9797-1 Algorithm 3", + input: "1122334455667788", + expectedOutput: "7E2AEA5CF35FDC0E", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "ISO 9797-1 Algorithm 3", "0123456789ABCDEFFEDCBA9876543210", "Hex", "", "Method 2", 8, false] + } + ] + }, + { + name: "MAC Generate: AS2805-4.1", + input: "1122334455667788", + expectedOutput: "3EB3B72576BBBE83", + recipeConfig: [ + { + op: "MAC Generate", + args: ["Hex", "AS2805-4.1", "0123456789ABCDEFFEDCBA9876543210", "Hex", "", "Method 1", 8, false] + } + ] + }, + { + name: "MAC Verify: AES-CMAC", + input: "1122334455667788", + expectedOutput: JSON.stringify({ + method: "AES-CMAC", + inputFormat: "Hex", + inputHex: "1122334455667788", + paddingMethod: null, + outputBytes: 8, + fullMacHex: "339AF1AD1650E908A794284D91DC6D29", + macHex: "339AF1AD1650E908", + keySource: "Direct key input", + expectedMacHex: "339AF1AD1650E908", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "MAC Verify", + args: ["Hex", "AES-CMAC", "00112233445566778899AABBCCDDEEFF", "Hex", "", "Method 1", "339AF1AD1650E908", true] + } + ] + }, + { + name: "EMV Generate MAC: issuer script sample", + input: "8424000008999E57FD0F47CACE0007", + expectedOutput: "22CB48394DFD1977", + recipeConfig: [ + { + op: "EMV Generate MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ] + }, + { + // ── EMV Generate MAC — Method 2 padding boundary cases ─────────────────── + // Method 2: append 0x80 then zeros to next block boundary; if already + // block-aligned, a full extra 8-byte block is appended. These tests + // cover 0-byte (one block of pure padding), 1-byte (pads to 8), and + // 8-byte / 16-byte inputs (each triggers the full-extra-block path). + name: "EMV Generate MAC: Method 2, empty input (0 bytes — pure padding block)", + input: "", + expectedOutput: "F1FBCF2A56D19BA7", + recipeConfig: [ + { + op: "EMV Generate MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ] + }, + { + name: "EMV Generate MAC: Method 2, 1-byte input (pads to single block)", + input: "FF", + expectedOutput: "3A8AE1947D2AD964", + recipeConfig: [ + { + op: "EMV Generate MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ] + }, + { + name: "EMV Generate MAC: Method 2, 8-byte input (block-aligned — extra block appended)", + input: "0102030405060708", + expectedOutput: "59997D5B782645F9", + recipeConfig: [ + { + op: "EMV Generate MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ] + }, + { + name: "EMV Generate MAC: Method 2, 16-byte input (two-block-aligned — extra block appended)", + input: "000102030405060708090A0B0C0D0E0F", + expectedOutput: "99F6CC9FB8367150", + recipeConfig: [ + { + op: "EMV Generate MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "Method 2", 8, false] + } + ] + }, + { + name: "EMV Verify MAC: issuer script sample", + input: "8424000008999E57FD0F47CACE0007", + expectedOutput: JSON.stringify({ + algorithm: "EMV MAC", + paddingMethod: "Method 2", + inputHex: "8424000008999E57FD0F47CACE0007", + fullMacHex: "22CB48394DFD1977", + macHex: "22CB48394DFD1977", + expectedMacHex: "22CB48394DFD1977", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "EMV Verify MAC", + args: ["0123456789ABCDEFFEDCBA9876543210", "22CB48394DFD1977", "Method 2", true] + } + ] + }, + { + name: "EMV Generate MAC (PIN Change): issuer script sample", + input: "00A4040008A000000004101080D80500000001010A04000000000000", + expectedOutput: "C0F24786EF1C4522", + recipeConfig: [ + { + op: "EMV Generate MAC (PIN Change)", + args: ["67FB27C75580EFE7", "0123456789ABCDEFFEDCBA9876543210", 8, false] + } + ] + }, + { + name: "PIN Data Generate: ISO Format 0", + input: "1234", + expectedOutput: "041215FEDCBA9876", + recipeConfig: [ + { + op: "PIN Data Generate", + args: ["ISO Format 0", "5432101234567890", false, false] + } + ] + }, + { + name: "PIN Generate: 4-digit PIN digits", + input: "", + expectedMatch: /^\d{4}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "PIN digits", ""] + } + ] + }, + { + name: "PIN Generate: 6-digit PIN digits", + input: "", + expectedMatch: /^\d{6}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [6, "PIN digits", ""] + } + ] + }, + { + name: "PIN Generate: ISO Format 0 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 0 clear PIN block", "5432101234567890"] + } + ] + }, + { + name: "PIN Generate: ISO Format 1 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 1 clear PIN block", ""] + } + ] + }, + { + name: "PIN Generate: ISO Format 3 block", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "ISO Format 3 clear PIN block", "5432101234567890"] + } + ] + }, + { + name: "Chain: PIN Generate → PIN Data Generate (Format 0)", + input: "", + expectedMatch: /^[0-9A-F]{16}$/, + recipeConfig: [ + { + op: "PIN Generate", + args: [4, "PIN digits", ""] + }, + { + op: "PIN Data Generate", + args: ["ISO Format 0", "5432101234567890", false, false] + } + ] + }, + { + name: "PIN IBM 3624 Offset Generate: known sample", + input: "1234", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pinValidationData: "5432101234567890", + pinValidationDataPadCharacter: "F", + pinLength: 4, + validationBlockHex: "5432101234567890", + encryptedValidationBlockHex: "8A3712EE04F010A0", + decimalized: "8037124404501000", + naturalPin: "8037", + pin: "1234", + pinOffset: "3207" + }, null, 4), + recipeConfig: [ + { + op: "PIN IBM 3624 Offset Generate", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", true] + } + ] + }, + { + name: "PIN IBM 3624 Verify: known sample", + input: "3207", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pinValidationData: "5432101234567890", + pinValidationDataPadCharacter: "F", + pinLength: 4, + validationBlockHex: "5432101234567890", + encryptedValidationBlockHex: "8A3712EE04F010A0", + decimalized: "8037124404501000", + naturalPin: "8037", + pin: "1234", + pinOffset: "3207", + expectedPinOffset: "3207", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "PIN IBM 3624 Verify", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", "1234", true] + } + ] + }, + { + name: "VISA PVV Generate: known sample", + input: "1234", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pan: "5432101234567890", + pinVerificationKeyIndex: 1, + pin: "1234", + pvvInput: "1012345678911234", + encryptedPvvInputHex: "6A77E65CFE349D60", + pvv: "6776" + }, null, 4), + recipeConfig: [ + { + op: "VISA PVV Generate", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, true] + } + ] + }, + { + name: "VISA PVV Verify: known sample", + input: "6776", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pan: "5432101234567890", + pinVerificationKeyIndex: 1, + pin: "1234", + pvvInput: "1012345678911234", + encryptedPvvInputHex: "6A77E65CFE349D60", + pvv: "6776", + expectedPvv: "6776", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "VISA PVV Verify", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, "1234", true] + } + ] + }, + { + name: "AS2805 Generate KEK Validation: response sample", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: JSON.stringify({ + validationType: "KekValidationResponse", + deriveKeyAlgorithm: "TDES_2KEY", + randomKeySendVariantMask: "VARIANT_MASK_82", + keyCheckValue: "08D7B4", + randomKeySend: "9217DC67B8763BABCFDF3DADFCD0F84A", + randomKeyReceive: "6DE823984789C4543020C252032F07B5" + }, null, 4), + recipeConfig: [ + { + op: "AS2805 Generate KEK Validation", + args: ["KekValidationResponse", "TDES_2KEY", "VARIANT_MASK_82", "9217DC67B8763BABCFDF3DADFCD0F84A", true] + } + ] + }, + { + name: "PIN Data Verify: ISO Format 0", + input: "041215FEDCBA9876", + expectedOutput: JSON.stringify({ + format: "ISO Format 0", + pin: "1234", + pinLength: 4, + pinFieldHex: "041234FFFFFFFFFF", + panFieldHex: "0000210123456789", + blockHex: "041215FEDCBA9876", + fillDigitsHex: "FFFFFFFFFF", + expectedPin: "1234", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "PIN Data Verify", + args: ["ISO Format 0", "5432101234567890", "1234", true] + } + ] + }, + { + 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"] + } + ] + }, + { + name: "Chain: VISA PVV Generate → Verify", + input: "1234", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pan: "5432101234567890", + pinVerificationKeyIndex: 1, + pin: "1234", + pvvInput: "1012345678911234", + encryptedPvvInputHex: "6A77E65CFE349D60", + pvv: "6776", + expectedPvv: "6776", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "VISA PVV Generate", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, false] + }, + { + op: "VISA PVV Verify", + args: ["0123456789ABCDEFFEDCBA9876543210", "5432101234567890", 1, "1234", true] + } + ] + }, + { + name: "Chain: PIN IBM 3624 Offset Generate → PIN Verify", + input: "1234", + expectedOutput: JSON.stringify({ + pinVerificationKeyHex: "0123456789ABCDEFFEDCBA9876543210", + pinValidationData: "5432101234567890", + pinValidationDataPadCharacter: "F", + pinLength: 4, + validationBlockHex: "5432101234567890", + encryptedValidationBlockHex: "8A3712EE04F010A0", + decimalized: "8037124404501000", + naturalPin: "8037", + pin: "1234", + pinOffset: "3207", + expectedPinOffset: "3207", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "PIN IBM 3624 Offset Generate", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", false] + }, + { + op: "PIN IBM 3624 Verify", + args: ["0123456789ABCDEFFEDCBA9876543210", "0123456789012345", "5432101234567890", "F", "1234", true] + } + ] + }, + { + name: "Chain: EMV Generate ARQC → Verify ARQC", + input: "000102030405060708090A0B0C0D0E0F", + expectedOutput: JSON.stringify({ + inputHex: "000102030405060708090A0B0C0D0E0F", + outputBytes: 8, + fullMacHex: "C1F732B52FB20CAAB58D5B6C78CBD514", + cryptogramHex: "C1F732B52FB20CAA", + expectedArqcHex: "C1F732B52FB20CAA", + valid: true + }, null, 4), + recipeConfig: [ + { + op: "EMV Generate ARQC", + args: ["00112233445566778899AABBCCDDEEFF", 8, false] + }, + { + op: "EMV Verify ARQC", + args: ["00112233445566778899AABBCCDDEEFF", 8, "000102030405060708090A0B0C0D0E0F", true] + } + ] + }, + + // ── Parse EMV TLV ───────────────────────────────────────────────────────── + { + name: "EMV Parse TLV: GPO Format 2 (constructed 77 > AIP + AFL)", + input: "770A82025900940408010401", + expectedOutput: JSON.stringify([ + { + tag: "77", name: "Response Message Template Format 2", + constructed: true, class: "Application", source: "ICC", format: "b", + length: 10, valueHex: "82025900940408010401", + children: [ + { tag: "82", name: "Application Interchange Profile (AIP)", constructed: false, class: "Context-Specific", source: "ICC", format: "b", length: 2, valueHex: "5900" }, + { tag: "94", name: "Application File Locator (AFL)", constructed: false, class: "Context-Specific", source: "ICC", format: "b", length: 4, valueHex: "08010401" }, + ], + }, + ], null, 4), + recipeConfig: [{ op: "EMV Parse TLV", args: [false] }] + }, + { + name: "EMV Parse TLV: primitive tags (ARQC / CID / ATC)", + input: "9F2608A1B2C3D4E5F607089F2701809F360200 01", + expectedOutput: JSON.stringify([ + { tag: "9F26", name: "Application Cryptogram (ARQC/TC/AAC)", constructed: false, class: "Application", source: "ICC", format: "b", length: 8, valueHex: "A1B2C3D4E5F60708" }, + { tag: "9F27", name: "Cryptogram Information Data (CID)", constructed: false, class: "Application", source: "ICC", format: "b", length: 1, valueHex: "80" }, + { tag: "9F36", name: "Application Transaction Counter (ATC)", constructed: false, class: "Application", source: "ICC", format: "b", length: 2, valueHex: "0001" }, + ], null, 4), + recipeConfig: [{ op: "EMV Parse TLV", args: [false] }] + }, + { + name: "EMV Parse TLV: unknown tag decoded structurally", + input: "FF0203AABBCC", + expectedMatch: /"name":\s*"Unknown"/, + recipeConfig: [{ op: "EMV Parse TLV", args: [false] }] + }, + { + name: "EMV Parse TLV: dictionary mode returns tag index", + input: "", + expectedMatch: /"9F26":/, + recipeConfig: [{ op: "EMV Parse TLV", args: [true] }] + }, + { + name: "EMV Parse TLV: bad hex throws", + input: "GG", + expectedOutput: "Input is not valid hex (odd length or non-hex chars).", + recipeConfig: [{ op: "EMV Parse TLV", args: [false] }] + }, + + // ── EMV Build Script Data ───────────────────────────────────────────────── + { + name: "EMV Build Script Data: PUT DATA hex output", + input: "", + expectedOutput: "84DA00420A0102030405060708090A", + recipeConfig: [{ op: "EMV Build Script Data", args: ["84", "PUT DATA", "00", "42", "0102030405060708090A", "Hex"] }] + }, + { + name: "EMV Build Script Data: PUT DATA JSON output", + input: "", + expectedOutput: JSON.stringify({ cla: "84", ins: "DA", p1: "00", p2: "42", lc: "0A", data: "0102030405060708090A", apdu: "84DA00420A0102030405060708090A" }, null, 4), + recipeConfig: [{ op: "EMV Build Script Data", args: ["84", "PUT DATA", "00", "42", "0102030405060708090A", "JSON"] }] + }, + { + name: "EMV Build Script Data: empty data (DISABLE VERIFICATION REQUIREMENT)", + input: "", + expectedOutput: "8426000000", + recipeConfig: [{ op: "EMV Build Script Data", args: ["84", "DISABLE VERIFICATION REQUIREMENT", "00", "00", "", "Hex"] }] + }, + { + name: "EMV Build Script Data: annotated output includes APDU line", + input: "", + expectedMatch: /APDU\s+84DC/, + recipeConfig: [{ op: "EMV Build Script Data", args: ["84", "UPDATE RECORD", "01", "04", "AABB", "Annotated"] }] + }, + { + name: "EMV Build Script Data: bad CLA throws", + input: "", + expectedOutput: "CLA must be exactly 1 byte (2 hex chars).", + recipeConfig: [{ op: "EMV Build Script Data", args: ["8400", "PUT DATA", "00", "00", "", "Hex"] }] + }, + { + name: "EMV Build Script Data: odd-length data throws", + input: "", + expectedOutput: "Data must be even-length hex.", + recipeConfig: [{ op: "EMV Build Script Data", args: ["84", "PUT DATA", "00", "00", "ABC", "Hex"] }] + }, + + // ── EMV Build PIN Change Script Data ────────────────────────────────────── + { + name: "EMV Build PIN Change Script Data: hex output (P1=00)", + input: "", + expectedOutput: "8424008010", + recipeConfig: [{ op: "EMV Build PIN Change Script Data", args: ["84", "Change with current PIN verification", "80", "10", "Hex"] }] + }, + { + name: "EMV Build PIN Change Script Data: hex output (P1=01, no-verify)", + input: "", + expectedOutput: "8424018010", + recipeConfig: [{ op: "EMV Build PIN Change Script Data", args: ["84", "Change without verification", "80", "10", "Hex"] }] + }, + { + name: "EMV Build PIN Change Script Data: JSON output", + input: "", + expectedOutput: JSON.stringify({ cla: "84", ins: "24", p1: "00", p2: "80", lc: "10", header: "8424008010" }, null, 4), + recipeConfig: [{ op: "EMV Build PIN Change Script Data", args: ["84", "Change with current PIN verification", "80", "10", "JSON"] }] + }, + { + name: "EMV Build PIN Change Script Data: bad Lc throws", + input: "", + expectedOutput: "Lc must be exactly 1 byte (2 hex chars).", + recipeConfig: [{ op: "EMV Build PIN Change Script Data", args: ["84", "Change with current PIN verification", "80", "GG", "Hex"] }] + }, + + // ── PIN Block Translate Encrypted ───────────────────────────────────────── + // Vectors: PIN=1234, PAN=5432101234567890 + // clear Format 0 block : 041215FEDCBA9876 + // ZPK_IN (2-key TDES) : DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE KCV 06332B + // ZPK_OUT (2-key TDES) : AABBCCDDEEFF00112233445566778899 KCV C4F0A4 + // encrypted under ZPK_IN : 7F381DBF9F6906C4 + // encrypted under ZPK_OUT : 06C0408B869B2CEB + // AWS Payment Cryptography comparison (translate_pin_data, TR31_P0_PIN_ENCRYPTION_KEY): + // incoming key ARN: arn:aws:payment-cryptography:us-east-1:030716882260:key/yqictqre4fccxmzn + // outgoing key ARN: arn:aws:payment-cryptography:us-east-1:030716882260:key/czgtcqq5cpspwcgk + { + name: "PIN Block Translate Encrypted: same key / same format (round-trip identity)", + input: "7F381DBF9F6906C4", + expectedOutput: "7F381DBF9F6906C4", + recipeConfig: [ + { + op: "PIN Block Translate Encrypted", + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", + "DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", false] + } + ] + }, + { + name: "PIN Block Translate Encrypted: ZPK-to-ZPK same format", + input: "7F381DBF9F6906C4", + expectedOutput: "06C0408B869B2CEB", + recipeConfig: [ + { + op: "PIN Block Translate Encrypted", + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", + "AABBCCDDEEFF00112233445566778899", "ISO Format 0", "5432101234567890", false] + } + ] + }, + { + name: "PIN Block Translate Encrypted: ZPK-to-ZPK Format 0 to Format 1", + input: "7F381DBF9F6906C4", + expectedOutput: "CAC0E6065A56F5F3", + recipeConfig: [ + { + op: "PIN Block Translate Encrypted", + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", + "AABBCCDDEEFF00112233445566778899", "ISO Format 1", "", false] + } + ] + }, + { + name: "PIN Block Translate Encrypted: JSON output mode", + input: "7F381DBF9F6906C4", + expectedOutput: JSON.stringify({ + incoming: { + format: "ISO Format 0", + pan: "5432101234567890", + encryptedBlockHex: "7F381DBF9F6906C4", + clearBlockHex: "041215FEDCBA9876" + }, + pin: "1234", + outgoing: { + format: "ISO Format 0", + pan: "5432101234567890", + clearBlockHex: "041215FEDCBA9876", + encryptedBlockHex: "06C0408B869B2CEB" + } + }, null, 4), + recipeConfig: [ + { + op: "PIN Block Translate Encrypted", + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEE", "ISO Format 0", "5432101234567890", + "AABBCCDDEEFF00112233445566778899", "ISO Format 0", "5432101234567890", true] + } + ] + }, + { + name: "PIN Block Translate Encrypted: 3-key TDES (48 hex) accepted", + input: "7F381DBF9F6906C4", + expectedOutput: "06C0408B869B2CEB", + recipeConfig: [ + { + op: "PIN Block Translate Encrypted", + // 3-key expansion of 2-key keys: K3_IN = K2_IN + K2_IN[0..15], same for OUT + args: ["DDDDEEEEFFFFAAAABBBBCCCCDDDDEEEEDDDDEEEEFFFFAAAA", "ISO Format 0", "5432101234567890", + "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF0011", "ISO Format 0", "5432101234567890", false] + } + ] + }, + + // ── Key Component Split / Combine ───────────────────────────────────────── + // Vectors: fixed 2-component split using known components so the test is + // deterministic. Split is non-deterministic by design so only combine is + // tested with known vectors; round-trip is verified via the chain test. + // Key : 0123456789ABCDEFFEDCBA9876543210 + // C1 : FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF + // C2 : FEDCBA98765432100123456789ABCDEF (= Key XOR C1) + { + name: "Key Component Combine: 2-component XOR", + input: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\nFEDCBA98765432100123456789ABCDEF", + expectedOutput: "0123456789ABCDEFFEDCBA9876543210", + recipeConfig: [ + { + op: "Key Component Combine", + args: [false] + } + ] + }, + { + name: "Key Component Combine: 3-component XOR", + // C1 XOR C2 XOR C3 = Key + // C1: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + // C2: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB + // C1 XOR C2: 1111111111111111 (repeated) + // C3: Key XOR C1 XOR C2 = 0123... XOR 1111... = 10325476 98BADCFE EFCDAB89 67452301 + // 01^11=10, 23^11=32, 45^11=54, 67^11=76, 89^11=98, AB^11=BA, CD^11=DC, EF^11=FE + // FE^11=EF, DC^11=CD, BA^11=AB, 98^11=89, 76^11=67, 54^11=45, 32^11=23, 10^11=01 + input: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n10325476 98BADCFE EFCDAB8967452301", + expectedOutput: "0123456789ABCDEFFEDCBA9876543210", + recipeConfig: [ + { + op: "Key Component Combine", + args: [false] + } + ] + }, + { + name: "Key Component Combine: JSON input from Split", + input: JSON.stringify({ + algorithm: "XOR", + keyLengthBits: 128, + componentCount: 2, + components: [ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "FEDCBA98765432100123456789ABCDEF" + ] + }, null, 4), + expectedOutput: "0123456789ABCDEFFEDCBA9876543210", + recipeConfig: [ + { + op: "Key Component Combine", + args: [false] + } + ] + }, + { + name: "Key Component Combine: JSON output mode", + input: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\nFEDCBA98765432100123456789ABCDEF", + expectedOutput: JSON.stringify({ + algorithm: "XOR", + keyLengthBits: 128, + componentCount: 2, + keyHex: "0123456789ABCDEFFEDCBA9876543210" + }, null, 4), + recipeConfig: [ + { + op: "Key Component Combine", + args: [true] + } + ] + }, + { + name: "Chain: Key Component Split → Combine (round-trip)", + input: "0123456789ABCDEFFEDCBA9876543210", + expectedOutput: "0123456789ABCDEFFEDCBA9876543210", + recipeConfig: [ + { + op: "Key Component Split", + args: [3, false] + }, + { + op: "Key Component Combine", + args: [false] + } + ] + } +]); +