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) - `DUKPT Derive AES Key` — AES-128 DUKPT per ANSI X9.24-3 (12-byte KSN, IK-based)
- `Derive ECDH Key Material` - `Derive ECDH Key Material`
- `Key Generate` — random AES-128/192/256, TDES, or custom bytes; optional AES CMAC KCV - `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` - `Payment Calculate KCV`
- `AS2805 Generate KEK Validation` - `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 - you need transaction keys, shared secrets, random test keys, KCVs, or AS2805-style KEK-validation lab values
Important assumptions: 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 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 - `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 - `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 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 Generate` | Vendor-aligned | AWS `GeneratePinData` | Publish with guardrails |
| `PIN Data Verify` | Vendor-aligned | AWS `VerifyPinData` | 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 | | `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 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 | | `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", "EMV Verify MAC",
"HSM Parse Futurex Command", "HSM Parse Futurex Command",
"HSM Parse Thales Command", "HSM Parse Thales Command",
"Key Component Combine",
"Key Component Split",
"Key Generate", "Key Generate",
"MAC Generate", "MAC Generate",
"MAC Verify", "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] "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]
}
]
} }
]); ]);