From 28cda9bad984cd7a09a205de81eb4b4c85f73af1 Mon Sep 17 00:00:00 2001 From: J8k3 Date: Wed, 20 May 2026 22:06:32 -0400 Subject: [PATCH] Add Key Component Split and Combine operations (issue #2) XOR key ceremony helpers: split a key into 2-8 components and recombine. Chains cleanly with Key Generate and wrap/encrypt operations. Co-Authored-By: Claude Sonnet 4.6 --- PAYMENT_RECIPES.md | 6 + src/core/config/Categories.json | 2 + src/core/operations/KeyComponentCombine.mjs | 101 +++++++++++++++ src/core/operations/KeyComponentSplit.mjs | 128 ++++++++++++++++++++ tests/operations/tests/Payment.mjs | 88 ++++++++++++++ 5 files changed, 325 insertions(+) create mode 100644 src/core/operations/KeyComponentCombine.mjs create mode 100644 src/core/operations/KeyComponentSplit.mjs diff --git a/PAYMENT_RECIPES.md b/PAYMENT_RECIPES.md index 67dbc4cd..6af8bc60 100644 --- a/PAYMENT_RECIPES.md +++ b/PAYMENT_RECIPES.md @@ -212,6 +212,8 @@ Operations: - `DUKPT Derive AES Key` — AES-128 DUKPT per ANSI X9.24-3 (12-byte KSN, IK-based) - `Derive ECDH Key Material` - `Key Generate` — random AES-128/192/256, TDES, or custom bytes; optional AES CMAC KCV +- `Key Component Split` — XOR-split a key into 2–8 components for key ceremony use +- `Key Component Combine` — XOR-combine components back into the original key - `Payment Calculate KCV` - `AS2805 Generate KEK Validation` @@ -219,6 +221,8 @@ Use this when: - you need transaction keys, shared secrets, random test keys, KCVs, or AS2805-style KEK-validation lab values Important assumptions: +- `Key Component Split` and `Key Component Combine` use XOR shares — all N components are required to reconstruct the key (no threshold/Shamir scheme) +- These operations are intended for testing and emulation, not production key ceremonies — production ceremonies must use a certified HSM - `DUKPT Derive TDES Key` is TDES DUKPT — do not confuse IPEK (TDES) with IK (AES DUKPT) - `DUKPT Derive AES Key` implements AES-128 via AES-CMAC per ANSI X9.24-3; AES-192/256 are not yet implemented - `Key Generate` is for test use only — production keys must be generated in an approved HSM @@ -396,6 +400,8 @@ Release guidance: `Publish` = safe with normal guardrails; `Publish with guardra | `PIN Block Translate Encrypted` | Vendor-aligned | AWS `TranslatePinData`; ISO 9564; PCI PIN Req 3-3 | Publish with guardrails | | `PIN Data Generate` | Vendor-aligned | AWS `GeneratePinData` | Publish with guardrails | | `PIN Data Verify` | Vendor-aligned | AWS `VerifyPinData` | Publish with guardrails | +| `Key Component Split` | Verified | XOR key split — standard PCI key ceremony primitive | Publish with guardrails | +| `Key Component Combine` | Verified | XOR key combine — standard PCI key ceremony primitive | Publish with guardrails | | `Payment Calculate KCV` | Verified | NIST SP 800-38B; generic AES/TDES/HMAC primitives | Publish | | `DUKPT Derive TDES Key` | Externally cross-checked | ANSI X9.24-1; AWS DUKPT terminology | Publish with guardrails | | `DUKPT Derive AES Key` | Externally cross-checked | ANSI X9.24-3 §6.3 official test vectors (x9.org) | Publish with guardrails | diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 2da87783..b5d5fea8 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -599,6 +599,8 @@ "EMV Verify MAC", "HSM Parse Futurex Command", "HSM Parse Thales Command", + "Key Component Combine", + "Key Component Split", "Key Generate", "MAC Generate", "MAC Verify", diff --git a/src/core/operations/KeyComponentCombine.mjs b/src/core/operations/KeyComponentCombine.mjs new file mode 100644 index 00000000..77d23b91 --- /dev/null +++ b/src/core/operations/KeyComponentCombine.mjs @@ -0,0 +1,101 @@ +/** + * @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 00000000..f5b7a755 --- /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/tests/operations/tests/Payment.mjs b/tests/operations/tests/Payment.mjs index 77b6fbd6..de8dee46 100644 --- a/tests/operations/tests/Payment.mjs +++ b/tests/operations/tests/Payment.mjs @@ -1427,5 +1427,93 @@ TestRegister.addTests([ "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] + } + ] } ]); +