diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 434c8bb619..cff7da7c98 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -155,6 +155,8 @@
"Citrix CTX1 Decode",
"AES Key Wrap",
"AES Key Unwrap",
+ "AES Key Wrap With Padding",
+ "AES Key Unwrap With Padding",
"Pseudo-Random Number Generator",
"Enigma",
"Bombe",
diff --git a/src/core/lib/AESKeyWrap.mjs b/src/core/lib/AESKeyWrap.mjs
new file mode 100644
index 0000000000..74f7ab381e
--- /dev/null
+++ b/src/core/lib/AESKeyWrap.mjs
@@ -0,0 +1,99 @@
+/**
+ * AES Key Wrap/Unwrap defined in RFC 3394
+ *
+ * @author aosterhage [aaron.osterhage@gmail.com]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import Utils from "../Utils.mjs";
+import forge from "node-forge";
+
+/**
+ * AES Key Wrap algorithm defined in RFC 3394.
+ *
+ * @param {string} plaintext
+ * @param {string} kek
+ * @param {string} iv
+ * @returns {string} ciphertext
+ */
+export function aesKeyWrap(plaintext, kek, iv) {
+ const cipher = forge.cipher.createCipher("AES-ECB", kek);
+
+ let A = iv;
+ const R = [];
+ for (let i = 0; i < plaintext.length; i += 8) {
+ R.push(plaintext.substring(i, i + 8));
+ }
+ let cntLower = 1, cntUpper = 0;
+ for (let j = 0; j < 6; j++) {
+ for (let i = 0; i < R.length; i++) {
+ cipher.start();
+ cipher.update(forge.util.createBuffer(A + R[i]));
+ cipher.finish();
+ const B = cipher.output.getBytes();
+ const msbBuffer = Utils.strToArrayBuffer(B.substring(0, 8));
+ const msbView = new DataView(msbBuffer);
+ msbView.setUint32(0, msbView.getUint32(0) ^ cntUpper);
+ msbView.setUint32(4, msbView.getUint32(4) ^ cntLower);
+ A = Utils.arrayBufferToStr(msbBuffer, false);
+ R[i] = B.substring(8, 16);
+ cntLower++;
+ if (cntLower > 0xffffffff) {
+ cntUpper++;
+ cntLower = 0;
+ }
+ }
+ }
+
+ return A + R.join("");
+}
+
+/**
+ * AES Key Unwrap algorithm defined in RFC 3394.
+ *
+ * @param {string} ciphertext
+ * @param {string} kek
+ * @returns {[string, string]} [plaintext, iv]
+ */
+export function aesKeyUnwrap(ciphertext, kek) {
+ const cipher = forge.cipher.createCipher("AES-ECB", kek);
+ cipher.start();
+ cipher.update(forge.util.createBuffer(""));
+ cipher.finish();
+ const paddingBlock = cipher.output.getBytes();
+
+ const decipher = forge.cipher.createDecipher("AES-ECB", kek);
+
+ let A = ciphertext.substring(0, 8);
+ const R = [];
+ for (let i = 8; i < ciphertext.length; i += 8) {
+ R.push(ciphertext.substring(i, i + 8));
+ }
+ let cntLower = R.length >>> 0;
+ let cntUpper = (R.length / ((1 << 30) * 4)) >>> 0;
+ cntUpper = cntUpper * 6 + ((cntLower * 6 / ((1 << 30) * 4)) >>> 0);
+ cntLower = cntLower * 6 >>> 0;
+ for (let j = 5; j >= 0; j--) {
+ for (let i = R.length - 1; i >= 0; i--) {
+ const aBuffer = Utils.strToArrayBuffer(A);
+ const aView = new DataView(aBuffer);
+ aView.setUint32(0, aView.getUint32(0) ^ cntUpper);
+ aView.setUint32(4, aView.getUint32(4) ^ cntLower);
+ A = Utils.arrayBufferToStr(aBuffer, false);
+ decipher.start();
+ decipher.update(forge.util.createBuffer(A + R[i] + paddingBlock));
+ decipher.finish();
+ const B = decipher.output.getBytes();
+ A = B.substring(0, 8);
+ R[i] = B.substring(8, 16);
+ cntLower--;
+ if (cntLower < 0) {
+ cntUpper--;
+ cntLower = 0xffffffff;
+ }
+ }
+ }
+
+ return [R.join(""), A];
+}
diff --git a/src/core/operations/AESKeyUnwrap.mjs b/src/core/operations/AESKeyUnwrap.mjs
index 1558847af1..936875cc21 100644
--- a/src/core/operations/AESKeyUnwrap.mjs
+++ b/src/core/operations/AESKeyUnwrap.mjs
@@ -5,10 +5,10 @@
*/
import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs";
+import { aesKeyUnwrap } from "../lib/AESKeyWrap.mjs";
import { toHexFast } from "../lib/Hex.mjs";
-import forge from "node-forge";
-import OperationError from "../errors/OperationError.mjs";
/**
* AES Key Unwrap operation
@@ -75,52 +75,13 @@ class AESKeyUnwrap extends Operation {
throw new OperationError("input must be 8n (n>=3) bytes (currently " + inputData.length + " bytes)");
}
- const cipher = forge.cipher.createCipher("AES-ECB", kek);
- cipher.start();
- cipher.update(forge.util.createBuffer(""));
- cipher.finish();
- const paddingBlock = cipher.output.getBytes();
-
- const decipher = forge.cipher.createDecipher("AES-ECB", kek);
+ const [output, outputIv] = aesKeyUnwrap(inputData, kek);
- let A = inputData.substring(0, 8);
- const R = [];
- for (let i = 8; i < inputData.length; i += 8) {
- R.push(inputData.substring(i, i + 8));
- }
- let cntLower = R.length >>> 0;
- let cntUpper = (R.length / ((1 << 30) * 4)) >>> 0;
- cntUpper = cntUpper * 6 + ((cntLower * 6 / ((1 << 30) * 4)) >>> 0);
- cntLower = cntLower * 6 >>> 0;
- for (let j = 5; j >= 0; j--) {
- for (let i = R.length - 1; i >= 0; i--) {
- const aBuffer = Utils.strToArrayBuffer(A);
- const aView = new DataView(aBuffer);
- aView.setUint32(0, aView.getUint32(0) ^ cntUpper);
- aView.setUint32(4, aView.getUint32(4) ^ cntLower);
- A = Utils.arrayBufferToStr(aBuffer, false);
- decipher.start();
- decipher.update(forge.util.createBuffer(A + R[i] + paddingBlock));
- decipher.finish();
- const B = decipher.output.getBytes();
- A = B.substring(0, 8);
- R[i] = B.substring(8, 16);
- cntLower--;
- if (cntLower < 0) {
- cntUpper--;
- cntLower = 0xffffffff;
- }
- }
- }
- if (A !== iv) {
+ if (outputIv !== iv) {
throw new OperationError("IV mismatch");
}
- const P = R.join("");
- if (outputType === "Hex") {
- return toHexFast(Utils.strToArrayBuffer(P));
- }
- return P;
+ return outputType === "Hex" ? toHexFast(Utils.strToArrayBuffer(output)) : output;
}
}
diff --git a/src/core/operations/AESKeyUnwrapWithPadding.mjs b/src/core/operations/AESKeyUnwrapWithPadding.mjs
new file mode 100644
index 0000000000..c224fbe984
--- /dev/null
+++ b/src/core/operations/AESKeyUnwrapWithPadding.mjs
@@ -0,0 +1,98 @@
+/**
+ * @author aosterhage [aaron.osterhage@gmail.com]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import Utils from "../Utils.mjs";
+import forge from "node-forge";
+import { aesKeyUnwrap } from "../lib/AESKeyWrap.mjs";
+import { toHexFast } from "../lib/Hex.mjs";
+
+/**
+ * AES Key Unwrap With Padding operation
+ */
+class AESKeyUnwrapWithPadding extends Operation {
+
+ /**
+ * AESKeyUnwrapWithPadding constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "AES Key Unwrap With Padding";
+ this.module = "Ciphers";
+ this.description = "Decryptor for a key wrapping algorithm defined in RFC 3394 combined with a padding convention defined in RFC 5649.";
+ this.infoURL = "https://wikipedia.org/wiki/Key_wrap";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Key (KEK)",
+ "type": "toggleString",
+ "value": "",
+ "toggleValues": ["Hex", "UTF8", "Latin1", "Base64"]
+ },
+ {
+ "name": "Input",
+ "type": "option",
+ "value": ["Hex", "Raw"]
+ },
+ {
+ "name": "Output",
+ "type": "option",
+ "value": ["Hex", "Raw"]
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const kek = Utils.convertToByteString(args[0].string, args[0].option),
+ inputType = args[1],
+ outputType = args[2];
+
+ if (kek.length !== 16 && kek.length !== 24 && kek.length !== 32) {
+ throw new OperationError("KEK must be either 16, 24, or 32 bytes (currently " + kek.length + " bytes)");
+ }
+
+ input = Utils.convertToByteString(input, inputType);
+ if (input.length % 8 !== 0 || input.length < 16) {
+ throw new OperationError("input must be 8n (n>=2) bytes (currently " + input.length + " bytes)");
+ }
+
+ const decipher = forge.cipher.createDecipher("AES-ECB", kek);
+ let output, aiv;
+
+ if (input.length === 16) {
+ // Special case where the unwrapped data is one 64-bit block.
+ decipher.start();
+ decipher.update(forge.util.createBuffer(input));
+ decipher.finish();
+ output = decipher.output.getBytes();
+ aiv = output.substring(0, 8);
+ output = output.substring(8, 16);
+ } else {
+ // Otherwise, follow the unwrapping process from RFC 3394 (AESKeyUnwrap operation).
+ [output, aiv] = aesKeyUnwrap(input, kek);
+ }
+
+ // Get the unpadded length from the AIV (which is the MLI). Remove the padding from the output.
+ const unpaddedLength = Utils.byteArrayToInt(Utils.strToByteArray(aiv.substring(4, 8)), "big");
+ if (aiv.substring(0, 4) !== "\xa6\x59\x59\xa6" || unpaddedLength > output.length) {
+ throw new OperationError("invalid AIV found");
+ }
+ output = output.substring(0, unpaddedLength);
+
+ return outputType === "Hex" ? toHexFast(Utils.strToArrayBuffer(output)) : output;
+ }
+
+}
+
+export default AESKeyUnwrapWithPadding;
diff --git a/src/core/operations/AESKeyWrap.mjs b/src/core/operations/AESKeyWrap.mjs
index 3886715613..e9ee061769 100644
--- a/src/core/operations/AESKeyWrap.mjs
+++ b/src/core/operations/AESKeyWrap.mjs
@@ -5,10 +5,10 @@
*/
import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs";
+import { aesKeyWrap } from "../lib/AESKeyWrap.mjs";
import { toHexFast } from "../lib/Hex.mjs";
-import forge from "node-forge";
-import OperationError from "../errors/OperationError.mjs";
/**
* AES Key Wrap operation
@@ -70,44 +70,15 @@ class AESKeyWrap extends Operation {
if (iv.length !== 8) {
throw new OperationError("IV must be 8 bytes (currently " + iv.length + " bytes)");
}
+
const inputData = Utils.convertToByteString(input, inputType);
if (inputData.length % 8 !== 0 || inputData.length < 16) {
throw new OperationError("input must be 8n (n>=2) bytes (currently " + inputData.length + " bytes)");
}
- const cipher = forge.cipher.createCipher("AES-ECB", kek);
+ const output = aesKeyWrap(inputData, kek, iv);
- let A = iv;
- const R = [];
- for (let i = 0; i < inputData.length; i += 8) {
- R.push(inputData.substring(i, i + 8));
- }
- let cntLower = 1, cntUpper = 0;
- for (let j = 0; j < 6; j++) {
- for (let i = 0; i < R.length; i++) {
- cipher.start();
- cipher.update(forge.util.createBuffer(A + R[i]));
- cipher.finish();
- const B = cipher.output.getBytes();
- const msbBuffer = Utils.strToArrayBuffer(B.substring(0, 8));
- const msbView = new DataView(msbBuffer);
- msbView.setUint32(0, msbView.getUint32(0) ^ cntUpper);
- msbView.setUint32(4, msbView.getUint32(4) ^ cntLower);
- A = Utils.arrayBufferToStr(msbBuffer, false);
- R[i] = B.substring(8, 16);
- cntLower++;
- if (cntLower > 0xffffffff) {
- cntUpper++;
- cntLower = 0;
- }
- }
- }
- const C = A + R.join("");
-
- if (outputType === "Hex") {
- return toHexFast(Utils.strToArrayBuffer(C));
- }
- return C;
+ return outputType === "Hex" ? toHexFast(Utils.strToArrayBuffer(output)) : output;
}
}
diff --git a/src/core/operations/AESKeyWrapWithPadding.mjs b/src/core/operations/AESKeyWrapWithPadding.mjs
new file mode 100644
index 0000000000..d3a373e926
--- /dev/null
+++ b/src/core/operations/AESKeyWrapWithPadding.mjs
@@ -0,0 +1,101 @@
+/**
+ * @author aosterhage [aaron.osterhage@gmail.com]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import Utils from "../Utils.mjs";
+import forge from "node-forge";
+import { aesKeyWrap } from "../lib/AESKeyWrap.mjs";
+import { toHexFast } from "../lib/Hex.mjs";
+
+/**
+ * AES Key Wrap With Padding operation
+ */
+class AESKeyWrapWithPadding extends Operation {
+
+ /**
+ * AESKeyWrapWithPadding constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "AES Key Wrap With Padding";
+ this.module = "Ciphers";
+ this.description = "A key wrapping algorithm defined in RFC 3394 combined with a padding convention defined in RFC 5649.
The padding convention defined in RFC 5649 eliminates the requirement that the length of the key to be wrapped be a multiple of 64 bits, allowing a key of any practical length to be wrapped.";
+ this.infoURL = "https://wikipedia.org/wiki/Key_wrap";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ "name": "Key (KEK)",
+ "type": "toggleString",
+ "value": "",
+ "toggleValues": ["Hex", "UTF8", "Latin1", "Base64"]
+ },
+ {
+ "name": "Input",
+ "type": "option",
+ "value": ["Hex", "Raw"]
+ },
+ {
+ "name": "Output",
+ "type": "option",
+ "value": ["Hex", "Raw"]
+ },
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const kek = Utils.convertToByteString(args[0].string, args[0].option),
+ inputType = args[1],
+ outputType = args[2];
+
+ if (kek.length !== 16 && kek.length !== 24 && kek.length !== 32) {
+ throw new OperationError("KEK must be either 16, 24, or 32 bytes (currently " + kek.length + " bytes)");
+ }
+
+ input = Utils.convertToByteString(input, inputType);
+ if (input.length <= 0) {
+ throw new OperationError("input must be > 0 bytes");
+ }
+
+ // Construct the "Alternative Initial Value" (AIV).
+ const aiv = "\xa6\x59\x59\xa6" + Utils.byteArrayToChars(Utils.intToByteArray(input.length, 4, "big"));;
+
+ // Pad the input as needed.
+ const isMultipleOf8 = (input.length % 8) === 0;
+ const paddedLength = input.length + (isMultipleOf8 ? 0 : (8 - (input.length % 8)));
+ input = input.padEnd(paddedLength, "\0");
+
+ let output;
+
+ if (paddedLength === 8) {
+ // Special case where the padded input is one 64-bit block.
+
+ // Get the cipher ready and disable PKCS#7 padding.
+ const cipher = forge.cipher.createCipher("AES-ECB", kek);
+ cipher.mode.pad = false;
+
+ cipher.start();
+ cipher.update(forge.util.createBuffer(aiv + input));
+ cipher.finish();
+ output = cipher.output.getBytes();
+ } else {
+ // Otherwise, follow the wrapping process from RFC 3394 (AESKeyWrap operation).
+ output = aesKeyWrap(input, kek, aiv);
+ }
+
+ return outputType === "Hex" ? toHexFast(Utils.strToArrayBuffer(output)) : output;
+ }
+
+}
+
+export default AESKeyWrapWithPadding;
diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs
index f147e9e7c7..6863853e81 100644
--- a/tests/operations/index.mjs
+++ b/tests/operations/index.mjs
@@ -15,6 +15,7 @@ import { setLongTestFailure, logTestReport } from "../lib/utils.mjs";
import TestRegister from "../lib/TestRegister.mjs";
import "./tests/AESKeyWrap.mjs";
+import "./tests/AESKeyWrapWithPadding.mjs";
import "./tests/AlternatingCaps.mjs";
import "./tests/AvroToJSON.mjs";
import "./tests/BaconCipher.mjs";
diff --git a/tests/operations/tests/AESKeyWrapWithPadding.mjs b/tests/operations/tests/AESKeyWrapWithPadding.mjs
new file mode 100644
index 0000000000..7556224a46
--- /dev/null
+++ b/tests/operations/tests/AESKeyWrapWithPadding.mjs
@@ -0,0 +1,136 @@
+/**
+ * @author aosterhage [aaron.osterhage@gmail.com]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import TestRegister from "../../lib/TestRegister.mjs";
+
+TestRegister.addTests([
+ {
+ "name": "AES Key Wrap With Padding: RFC Test Vector, 20 octets data, 192-bit KEK",
+ "input": "c37b7e6492584340bed12207808941155068f738",
+ "expectedOutput": "138bdeaa9b8fa7fc61f97742e72248ee5ae6ae5360d1ae6a5f54f373fa543b6a",
+ "recipeConfig": [
+ {
+ "op": "AES Key Wrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "5840df6e29b02af1ab493b705bf16ea1ae8338f4dcc176a8"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Wrap With Padding: RFC Test Vector, 7 octets data, 192-bit KEK",
+ "input": "466f7250617369",
+ "expectedOutput": "afbeb0f07dfbf5419200f2ccb50bb24f",
+ "recipeConfig": [
+ {
+ "op": "AES Key Wrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "5840df6e29b02af1ab493b705bf16ea1ae8338f4dcc176a8"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Wrap With Padding: invalid KEK length",
+ "input": "00112233445566778899aabbccddeeff",
+ "expectedOutput": "KEK must be either 16, 24, or 32 bytes (currently 10 bytes)",
+ "recipeConfig": [
+ {
+ "op": "AES Key Wrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "00010203040506070809"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Wrap With Padding: input too short",
+ "input": "",
+ "expectedOutput": "input must be > 0 bytes",
+ "recipeConfig": [
+ {
+ "op": "AES Key Wrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "000102030405060708090a0b0c0d0e0f"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Unwrap With Padding: RFC Test Vector, 20 octets data, 192-bit KEK",
+ "input": "138bdeaa9b8fa7fc61f97742e72248ee5ae6ae5360d1ae6a5f54f373fa543b6a",
+ "expectedOutput": "c37b7e6492584340bed12207808941155068f738",
+ "recipeConfig": [
+ {
+ "op": "AES Key Unwrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "5840df6e29b02af1ab493b705bf16ea1ae8338f4dcc176a8"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Unwrap With Padding: RFC Test Vector, 7 octets data, 192-bit KEK",
+ "input": "afbeb0f07dfbf5419200f2ccb50bb24f",
+ "expectedOutput": "466f7250617369",
+ "recipeConfig": [
+ {
+ "op": "AES Key Unwrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "5840df6e29b02af1ab493b705bf16ea1ae8338f4dcc176a8"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Unwrap With Padding: invalid KEK length",
+ "input": "1fa68b0a8112b447aef34bd8fb5a7b829d3e862371d2cfe5",
+ "expectedOutput": "KEK must be either 16, 24, or 32 bytes (currently 10 bytes)",
+ "recipeConfig": [
+ {
+ "op": "AES Key Unwrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "00010203040506070809"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Unwrap With Padding: input length not multiple of 8",
+ "input": "1fa68b0a8112b447aef34bd8fb5a7b829d3e862371d2cfe5e621",
+ "expectedOutput": "input must be 8n (n>=2) bytes (currently 26 bytes)",
+ "recipeConfig": [
+ {
+ "op": "AES Key Unwrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "000102030405060708090a0b0c0d0e0f"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+ {
+ "name": "AES Key Unwrap With Padding: input too short",
+ "input": "1fa68b0a8112b447",
+ "expectedOutput": "input must be 8n (n>=2) bytes (currently 8 bytes)",
+ "recipeConfig": [
+ {
+ "op": "AES Key Unwrap With Padding",
+ "args": [
+ {"option": "Hex", "string": "000102030405060708090a0b0c0d0e0f"},
+ "Hex", "Hex"
+ ],
+ },
+ ],
+ },
+]);