Why a key block, not just an encrypted key
Before key blocks, payment keys travelled as bare 3DES ciphertext under a key-encrypting key. Nothing tied the ciphertext to what the key was for, so an attacker with access to an HSM's API could import a PIN key as a data key and then ask the HSM to 'decrypt data' with it: the classic key-separation attack. TR-31 (now standardized as ANSI X9.143) fixes this by putting a clear-text header in front of the key, stating its usage, algorithm, mode of use, and exportability, and binding that header to the encrypted key with a MAC. Change one header character and the block no longer verifies.
PCI PIN Security Requirement 18-3 requires encrypted symmetric keys in PIN processing to travel and be stored as key blocks, phased in from 2019 to 2025. That is why payment HSMs and cloud payment-cryptography services import and export keys as TR-31 today.
The header, field by field
The first 16 characters are fixed: version ID (A, B, C, or D), the total key block length as 4 decimal digits, a 2-character key usage (P0 PIN encryption, B0 BDK, K0 key-encrypting key, D0 data encryption, and so on), the wrapped key's algorithm, its mode of use, a 2-character key version number, exportability, the number of optional blocks, and 2 reserved characters. Optional blocks follow as ID, 2-hex-digit length, data, for example a TDES DUKPT Key Set Identifier (KS) or an AES DUKPT Initial Key ID (IK), the two blocks that travel with a BDK or initial key from the DUKPT tool. If they don't end on a cipher block boundary, a PB pad block is appended so the whole header is a multiple of 8 bytes (TDES) or 16 bytes (AES).
The header is never encrypted. It doesn't need to be secret; it needs to be impossible to change without detection, and that's the MAC's job.
const { count, text } = encodeOptionalBlocks(h.optionalBlocks, blockSizeFor(h.versionId));
// text already ends in a PB block if the optional blocks needed padding
return h.versionId + totalLength.toString().padStart(4, "0") +
h.keyUsage + h.algorithm + h.modeOfUse + h.keyVersionNumber +
h.exportability + count.toString().padStart(2, "0") + h.reserved + text;The total length counts every character: header, the encrypted payload as hex, and the MAC as hex. The tool computes it before writing the header, because the header (length field included) is MAC'd.
Versions A and C: key variants
The original binding method derives two keys from the Key Block Protection Key by XORing every byte with a constant: 0x45 ('E') for the encryption key (KBEK) and 0x4D ('M') for the MAC key (KBMK). The payload is encrypted with TDES-CBC, using the first 8 header bytes as the IV, and the MAC is a TDES CBC-MAC over the header and the encrypted payload, truncated to 4 bytes. Version C is the same computation as A with tighter rules on header values. Both are deprecated for new work: a 32-bit MAC is short, and variants of one key are weaker separation than independent derived keys.
Versions B and D: derived keys and CMAC
Version B (TDES) and version D (AES) derive KBEK and KBMK with the NIST SP 800-108 counter-mode KDF, using CMAC under the KBPK as the PRF. Each 8-byte input is a counter, a usage (0000 for encryption, 0001 for MAC), a 00 separator, an algorithm code, and the derived length in bits; the tool shows these inputs next to the keys they produce. For an AES-256 KBPK that's algorithm 0004 and length 0100, with two CMAC calls per key because each call yields 128 bits.
The MAC is then a full CMAC over the header and the clear payload: 8 bytes for B, 16 for D. That MAC also serves as the CBC IV for encrypting the payload, so the IV depends on the header and the key, and no separate IV needs to be sent.
for (let counter = 1; counter <= calls; counter++) {
for (const [name, usage] of [["kbek", 0x0000], ["kbmk", 0x0001]]) {
const input = new Uint8Array([
counter, usage >> 8, usage & 0xff, 0x00,
algorithm >> 8, algorithm & 0xff, bits >> 8, bits & 0xff,
]);
out[name].push(await cmac(cipher, input));
}
}The same CMAC function (SP 800-38B) serves the KDF and the key block MAC; only the key and the message change.
The payload, and why padding hides key length
What gets encrypted is a 2-byte key length in bits, then the key, then padding up to a whole number of cipher blocks. The padding is random. A sender can add extra whole blocks of it so a double-length TDES key and a triple-length one produce key blocks of the same size, which stops an observer from reading the key's strength off the block length. Because the padding is random, building the same key twice gives two different key blocks that both unwrap to the same key.
Where the crypto comes from
Versions A, B, and C run on the same from-scratch DES/3DES used by the PIN block and CVV tools, since no browser exposes 3DES. Version D's AES runs on Web Crypto, which only offers padded AES-CBC: a single-block AES encryption is taken as AES-CBC with a zero IV, and unpadded CBC decryption appends one extra ciphertext block crafted to decrypt to a full PKCS#7 pad, which Web Crypto then strips.
The implementation is checked by unit tests against 18 third-party known-answer key blocks covering all four versions, 2-key and 3-key TDES and AES-256 KBPKs, and KS and PB optional blocks. Every one unwraps to the published key, and every one is rebuilt byte for byte when the builder is given the original padding. Like the PIN block tool, this one shows you the clear key and the derived keys; a real HSM never returns either.
const lastBlock = data.slice(data.length - 16);
// AES-CBC(IV = lastBlock, 16 × 0x10) — this block decrypts to a full PKCS#7 pad
const forged = await cbcEncrypt(lastBlock, new Uint8Array(16).fill(16));
return new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, concat(data, forged))
);Decrypting the forged block gives D(AES(lastBlock ⊕ pad)) ⊕ lastBlock = pad, so Web Crypto's padding check passes and removes exactly the block that was added.