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 <noreply@anthropic.com>
This commit is contained in:
J8k3 2026-05-20 22:06:32 -04:00
parent 79ac440b14
commit 28cda9bad9
5 changed files with 325 additions and 0 deletions

View File

@ -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 28 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 |

View File

@ -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",

View File

@ -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 28 components.<br><br>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;

View File

@ -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 28 components. Recombine with Key Component Combine.<br><br>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;

View File

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