diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 6ad3adb7..6d316564 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -470,6 +470,7 @@
"Compare CTPH hashes",
"HMAC",
"CMAC",
+ "Key Check Value",
"Bcrypt",
"Bcrypt compare",
"Bcrypt parse",
diff --git a/src/core/operations/KeyCheckValue.mjs b/src/core/operations/KeyCheckValue.mjs
new file mode 100644
index 00000000..1a8c98f9
--- /dev/null
+++ b/src/core/operations/KeyCheckValue.mjs
@@ -0,0 +1,122 @@
+/**
+ * @author J8k3 [https://jacobmarks.com]
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+
+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";
+
+/**
+ * Key Check Value operation.
+ *
+ * A KCV is a short public value derived from a symmetric key so operators can
+ * verify two systems hold the same key without exposing the key itself.
+ *
+ * Three standard methods are supported:
+ * - TDES-ECB (Zeros): ANSI X9.24-1 legacy KCV — encrypt an 8-byte zero block.
+ * - AES-ECB (Zeros): encrypt a 16-byte zero block, an AES analogue of the TDES form.
+ * - AES-CMAC (Empty): RFC 4493 / TR-31 KC-block KCV — CMAC of the empty message.
+ */
+class KeyCheckValue extends Operation {
+
+ /**
+ * KeyCheckValue constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Key Check Value";
+ this.module = "Crypto";
+ this.description = "Computes a Key Check Value (KCV) for a symmetric key. A KCV lets two parties verify they hold the same key without revealing it.
- TDES-ECB (Zeros) — ANSI X9.24-1 legacy KCV: encrypt an 8-byte zero block with the key.
- AES-ECB (Zeros) — encrypt a 16-byte zero block with the key.
- AES-CMAC (Empty) — RFC 4493 CMAC of the empty message, used by TR-31 (ANSI X9.143) KC optional blocks.
Returns the leading N hex characters of the resulting cryptogram.";
+ this.infoURL = "https://wikipedia.org/wiki/Key_checksum_value";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Key",
+ "type": "toggleString",
+ "value": "",
+ "toggleValues": ["Hex", "UTF8", "Latin1", "Base64"]
+ },
+ {
+ "name": "Method",
+ "type": "option",
+ "value": ["TDES-ECB (Zeros)", "AES-ECB (Zeros)", "AES-CMAC (Empty)"]
+ },
+ {
+ "name": "Output length (hex chars)",
+ "type": "number",
+ "value": 6
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [keyArg, method, outputHexChars] = args;
+ const keyBytes = Utils.convertToByteString(keyArg.string, keyArg.option);
+
+ if (!keyBytes.length) {
+ throw new OperationError("No key material was provided.");
+ }
+
+ const truncLength = Math.max(1, Number(outputHexChars) || 6);
+ 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 (currently " + keyBytes.length + " bytes).");
+ }
+ const key = keyBytes.length === 16 ? keyBytes + keyBytes.substring(0, 8) : keyBytes;
+ const cipher = forge.cipher.createCipher("3DES-ECB", key);
+ cipher.mode.pad = function() {
+ return true;
+ };
+ 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-ECB (Zeros)": {
+ if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
+ throw new OperationError("AES key must be 16, 24, or 32 bytes (currently " + keyBytes.length + " 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 "AES-CMAC (Empty)": {
+ if (keyBytes.length !== 16 && keyBytes.length !== 24 && keyBytes.length !== 32) {
+ throw new OperationError("AES key must be 16, 24, or 32 bytes (currently " + keyBytes.length + " bytes).");
+ }
+ const cmacOp = new CMAC();
+ const emptyInput = new ArrayBuffer(0);
+ hexOut = cmacOp.run(emptyInput, [{string: keyBytes, option: "Latin1"}, "AES"]).toUpperCase();
+ break;
+ }
+ default:
+ throw new OperationError("Unsupported method: " + method);
+ }
+
+ return hexOut.substring(0, truncLength);
+ }
+
+}
+
+export default KeyCheckValue;
diff --git a/tests/operations/tests/KeyCheckValue.mjs b/tests/operations/tests/KeyCheckValue.mjs
new file mode 100644
index 00000000..46d5c000
--- /dev/null
+++ b/tests/operations/tests/KeyCheckValue.mjs
@@ -0,0 +1,92 @@
+/**
+ * @author J8k3 [https://jacobmarks.com]
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+import TestRegister from "../../lib/TestRegister.mjs";
+
+// TDES-ECB (Zeros) — key from NIST SP 800-67 Rev 2 §B.1, "Numerical Example for TDEA".
+// AES-ECB (Zeros) and AES-CMAC (Empty) — key from NIST SP 800-38B "Recommendation for
+// Block Cipher Modes of Operation: The CMAC Mode for Authentication", Appendix D.1
+// (also RFC 4493 §4). The AES-CMAC empty-message MAC 0xbb1d6929e95937287fa37d129b756746
+// is corroborated by the existing "CMAC-AES128 NIST's CSRC Example #1" test.
+
+TestRegister.addTests([
+ {
+ "name": "Key Check Value: TDES-ECB (Zeros), NIST SP 800-67 key",
+ "input": "0123456789ABCDEF23456789ABCDEF01456789ABCDEF0123",
+ "expectedOutput": "4EBA73",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "0123456789ABCDEF23456789ABCDEF01456789ABCDEF0123"}, "TDES-ECB (Zeros)", 6]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: TDES-ECB (Zeros), double-length 16-byte key expanded to k1||k2||k1",
+ "input": "0123456789ABCDEF23456789ABCDEF01",
+ "expectedOutput": "86E965",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "0123456789ABCDEF23456789ABCDEF01"}, "TDES-ECB (Zeros)", 6]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: AES-ECB (Zeros), NIST SP 800-38B AES-128 key",
+ "input": "2b7e151628aed2a6abf7158809cf4f3c",
+ "expectedOutput": "7DF76B",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "2b7e151628aed2a6abf7158809cf4f3c"}, "AES-ECB (Zeros)", 6]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: AES-CMAC (Empty), NIST SP 800-38B AES-128 key",
+ "input": "2b7e151628aed2a6abf7158809cf4f3c",
+ "expectedOutput": "BB1D69",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "2b7e151628aed2a6abf7158809cf4f3c"}, "AES-CMAC (Empty)", 6]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: output length respects hex-char argument",
+ "input": "2b7e151628aed2a6abf7158809cf4f3c",
+ "expectedOutput": "BB1D6929",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "2b7e151628aed2a6abf7158809cf4f3c"}, "AES-CMAC (Empty)", 8]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: empty key rejected",
+ "input": "",
+ "expectedOutput": "No key material was provided.",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": ""}, "AES-CMAC (Empty)", 6]
+ }
+ ]
+ },
+ {
+ "name": "Key Check Value: invalid TDES key length rejected",
+ "input": "0123456789ABCDEF",
+ "expectedOutput": "TDES key must be 16 or 24 bytes (currently 8 bytes).",
+ "recipeConfig": [
+ {
+ "op": "Key Check Value",
+ "args": [{"option": "Hex", "string": "0123456789ABCDEF"}, "TDES-ECB (Zeros)", 6]
+ }
+ ]
+ }
+]);