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" + ], + }, + ], + }, +]);