diff --git a/README.md b/README.md index 2f57f9f..3e8bed6 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,33 @@ const jwe = new clientEncryption.JweEncryption(config); let responsePayload = jwe.decrypt(encryptedResponsePayload); ``` +**AES-CBC HMAC Authentication (A128CBC-HS256)** + +For enhanced security when using AES-CBC mode (A128CBC-HS256), you can enable HMAC authentication tag verification. This ensures data authenticity and integrity according to the JWE specification (RFC 7516). + +By default, HMAC verification is **disabled** for backward compatibility. To enable it: + +```js +config.enableHmacVerification = true; +``` + +**When to enable HMAC verification:** +- ✅ New integrations with systems that properly implement JWE A128CBC-HS256 +- ✅ When security and data authenticity are critical +- ✅ When working with compliant JWE encryption sources + +**When to keep it disabled (default):** +- ⚠️ Legacy systems that don't compute HMAC tags correctly +- ⚠️ Maintaining backward compatibility with existing deployments +- ⚠️ Encryption sources that don't fully follow the JWE specification + +**Technical Details:** +When enabled, the library: +- Splits the 256-bit Content Encryption Key (CEK) into a 128-bit HMAC key and 128-bit AES key +- Computes HMAC-SHA256 over: AAD || IV || Ciphertext || AL (AAD length in bits) +- Verifies the authentication tag (first 128 bits of HMAC output) before decryption +- Throws an `Error` if the authentication tag is invalid + ##### • Configuring the JWE Encryption `JweEncryption` needs a config object to instruct how to decrypt/decrypt the payloads. Example: diff --git a/lib/mcapi/crypto/jwe-crypto.js b/lib/mcapi/crypto/jwe-crypto.js index e14e6a9..69100e6 100644 --- a/lib/mcapi/crypto/jwe-crypto.js +++ b/lib/mcapi/crypto/jwe-crypto.js @@ -25,6 +25,7 @@ function JweCrypto(config) { computePublicFingerprint(config, this.encryptionCertificate); this.encryptedValueFieldName = config.encryptedValueFieldName; + this.enableHmacVerification = config.enableHmacVerification; /** * Perform data encryption @@ -132,6 +133,9 @@ function JweCrypto(config) { decryptionEncoding = "AES-256-GCM"; break; case "A128CBC-HS256": + if(this.enableHmacVerification) { + verifyCbcHmac(jweTokenParts[0], iv, encryptedText, authTag, secretKey); + } decryptionEncoding = "AES-128-CBC"; secretKey = secretKey.slice(16, 32); gcmMode = false; @@ -160,6 +164,28 @@ function JweCrypto(config) { }; } +function verifyCbcHmac(encodedHeaderB64Url, iv, ciphertext, authTag, secretKey) { + const macKey = secretKey.slice(0, 16); + const aad = Buffer.from(encodedHeaderB64Url, c.ASCII); + const al = Buffer.alloc(8); + const aadBits = aad.length * 8; + al.writeUInt32BE(Math.floor(aadBits / Math.pow(2, 32)), 0); + al.writeUInt32BE(aadBits >>> 0, 4); + + const hmac = nodeCrypto.createHmac("sha256", macKey); + hmac.update(aad); + hmac.update(iv); + hmac.update(ciphertext); + hmac.update(al); + const fullTag = hmac.digest(); + + const expectedTag = fullTag.subarray(0, 16); + + if(expectedTag.length !== authTag.length || !nodeCrypto.timingSafeEqual(expectedTag, authTag)) { + throw new Error("Authentication tag verification failed"); + } +} + function serialize(header, encryptedSecretKey, iv, encryptedText, authTag) { return ( header + diff --git a/test/jwe-crypto.test.js b/test/jwe-crypto.test.js index 684f0ea..d57f3d7 100644 --- a/test/jwe-crypto.test.js +++ b/test/jwe-crypto.test.js @@ -2,6 +2,8 @@ const assert = require("assert"); const rewire = require("rewire"); const Crypto = rewire("../lib/mcapi/crypto/jwe-crypto"); const utils = require("../lib/mcapi/utils/utils"); +const c = require("../lib/mcapi/utils/constants"); +const nodeCrypto = require("crypto"); const testConfig = require("./mock/jwe-config"); @@ -257,6 +259,120 @@ describe("JWE Crypto", () => { }); + describe("verifyCbcHmac()", () => { + let encodedHeaderB64Url; + let ciphertext; + let secretKey; + let iv; + let fullTag; + let authTag; + + before(() => { + const headerJson = JSON.stringify({ alg: "RSA-OAEP-256", enc: "A128CBC-HS256" }); + encodedHeaderB64Url = Buffer.from(headerJson, c.UTF8) + .toString(c.BASE64) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + iv = nodeCrypto.randomBytes(16); + ciphertext = nodeCrypto.randomBytes(32); + + const macKey = nodeCrypto.randomBytes(16); + const encKey = nodeCrypto.randomBytes(16); + secretKey = Buffer.concat([macKey, encKey]); + + const aad = Buffer.from(encodedHeaderB64Url, c.ASCII); + const al = Buffer.alloc(8); + const aadBits = aad.length * 8; + al.writeUInt32BE(Math.floor(aadBits / Math.pow(2, 32)), 0); + al.writeUInt32BE(aadBits >>> 0, 4); + + const hmac = nodeCrypto.createHmac("sha256", macKey); + hmac.update(aad); + hmac.update(iv); + hmac.update(ciphertext); + hmac.update(al); + fullTag = hmac.digest(); + authTag = fullTag.slice(0, 16); + }); + + it("should NOT throw when HMAC tag is valid", () => { + const verifyCbcHmac = Crypto.__get__("verifyCbcHmac"); + + assert.doesNotThrow(() => { + verifyCbcHmac(encodedHeaderB64Url, iv, ciphertext, authTag, secretKey); + }); + }); + + it("should throw when HMAC tag is invalid", () => { + const verifyCbcHmac = Crypto.__get__("verifyCbcHmac"); + + const tamperedTag = Buffer.from(authTag); + tamperedTag[0] ^= 0xff; + + assert.throws(() => { + verifyCbcHmac(encodedHeaderB64Url, iv, ciphertext, tamperedTag, secretKey); + }, /Authentication tag verification failed/); + }); + }); + + describe("HMAC verification toggle (A128CBC-HS256)", () => { + let CryptoRewired; + let verifySpy; + let token; + before(() => { + CryptoRewired = rewire("../lib/mcapi/crypto/jwe-crypto"); + verifySpy = { called: false }; + CryptoRewired.__set__("verifyCbcHmac", () => { verifySpy.called = true; }); + + CryptoRewired.__set__("nodeCrypto", { + constants: { RSA_PKCS1_OAEP_PADDING: 4 }, + privateDecrypt: () => { + return Buffer.alloc(32, 1); + }, + createDecipheriv: () => ({ + setAAD: () => {}, + setAuthTag: () => {}, + update: () => "", + final: () => "test" + }) + }); + + const header = Buffer.from(JSON.stringify({ alg: "RSA-OAEP-256", enc: "A128CBC-HS256" }), c.UTF8) + .toString(c.BASE64) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + const ek = Buffer.from("ek").toString(c.BASE64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + const iv = Buffer.from("1234567890123456").toString(c.BASE64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + const ct = Buffer.from("ciphertext").toString(c.BASE64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + const tag = Buffer.alloc(16).toString(c.BASE64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + token = `${header}.${ek}.${iv}.${ct}.${tag}`; + }); + + it("does NOT call verifyCbcHmac by default", () => { + const cfg = JSON.parse(JSON.stringify(testConfig)); + + const crypto = new CryptoRewired(cfg); + crypto.decryptData(token); + + assert.strictEqual(verifySpy.called, false, "verifyCbcHmac should not be called by default"); + }); + + it("calls verifyCbcHmac when config.enableHmacVerification is true", () => { + const cfg = JSON.parse(JSON.stringify(testConfig)); + cfg.enableHmacVerification = true; + + const crypto = new CryptoRewired(cfg); + crypto.decryptData(token); + + assert.strictEqual(verifySpy.called, true, "verifyCbcHmac should be called when enabled"); + }); + }); + + + describe("#readPublicCertificate", () => { it("not valid key", () => { const readPublicCertificate = Crypto.__get__("readPublicCertificate");