diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index 4d9f098f137..df297b1cb85 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an `additionalData` option to AES-GCM `encrypt` and `decrypt` ([#10628](https://github.com/MetaMask/core/pull/10628)) +- Allow HKDF functions to derive from an empty input when `unsafeInputKeyingMaterial` is set ([#10628](https://github.com/MetaMask/core/pull/10628)) + ### Fixed - Narrow return type for AES `encrypt` function to `Uint8Array` ([#10623](https://github.com/MetaMask/core/pull/10623)) diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index 24fa595c83a..f976dfa7854 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -25,6 +25,19 @@ const nistCiphertext15 = hexToBytes( '0x522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662898015adb094dac5d93471bdec1a502270e3cc6c', ); +// Test Case 4: 128-bit key with associated data +const nistKey4 = hexToBytes('0xfeffe9928665731c6d6a8f9467308308'); +const nistIv4 = hexToBytes('0xcafebabefacedbaddecaf888'); +const nistPlaintext4 = hexToBytes( + '0xd9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39', +); +const nistAdditionalData4 = hexToBytes( + '0xfeedfacedeadbeeffeedfacedeadbeefabaddad2', +); +const nistCiphertext4 = hexToBytes( + '0x42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e0915bc94fbc3221a5db94fae95ae7121a47', +); + describe('encrypt', () => { it('matches test case 2', async () => { const result = await encrypt(nistKey2, nistPlaintext2, { @@ -96,6 +109,30 @@ describe('encrypt', () => { 'Unsafe IV length: IV must be exactly 12 bytes for AES-GCM. To bypass this check, set the `unsafeIvLength` option to `true`.', ); }); + + it('matches test case 4 with associated data', async () => { + const result = await encrypt(nistKey4, nistPlaintext4, { + unsafeIv: nistIv4, + additionalData: nistAdditionalData4, + }); + expect(bytesToHex(result.ciphertext)).toBe(bytesToHex(nistCiphertext4)); + }); + + it('accepts an ArrayBuffer of associated data', async () => { + const result = await encrypt(nistKey4, nistPlaintext4, { + unsafeIv: nistIv4, + additionalData: nistAdditionalData4.buffer, + }); + expect(bytesToHex(result.ciphertext)).toBe(bytesToHex(nistCiphertext4)); + }); + + it('accepts a DataView of associated data', async () => { + const result = await encrypt(nistKey4, nistPlaintext4, { + unsafeIv: nistIv4, + additionalData: new DataView(nistAdditionalData4.buffer), + }); + expect(bytesToHex(result.ciphertext)).toBe(bytesToHex(nistCiphertext4)); + }); }); describe('decrypt', () => { @@ -150,4 +187,33 @@ describe('decrypt', () => { 'Unsafe IV length: IV must be exactly 12 bytes for AES-GCM. To bypass this check, set the `unsafeIvLength` option to `true`.', ); }); + + it('matches test case 4 with associated data', async () => { + const result = await decrypt(nistKey4, nistIv4, nistCiphertext4, { + additionalData: nistAdditionalData4, + }); + expect(bytesToHex(result)).toBe(bytesToHex(nistPlaintext4)); + }); + + it('accepts an ArrayBuffer of associated data', async () => { + const result = await decrypt(nistKey4, nistIv4, nistCiphertext4, { + additionalData: nistAdditionalData4.buffer, + }); + expect(bytesToHex(result)).toBe(bytesToHex(nistPlaintext4)); + }); + + it('accepts a DataView of associated data', async () => { + const result = await decrypt(nistKey4, nistIv4, nistCiphertext4, { + additionalData: new DataView(nistAdditionalData4.buffer), + }); + expect(bytesToHex(result)).toBe(bytesToHex(nistPlaintext4)); + }); + + it('rejects ciphertext authenticated with different associated data', async () => { + await expect( + decrypt(nistKey4, nistIv4, nistCiphertext4, { + additionalData: new Uint8Array(nistAdditionalData4.length), + }), + ).rejects.toThrow(DOMException); + }); }); diff --git a/packages/cryptography/src/aes-gcm.ts b/packages/cryptography/src/aes-gcm.ts index c3aa2162b39..57008b501c2 100644 --- a/packages/cryptography/src/aes-gcm.ts +++ b/packages/cryptography/src/aes-gcm.ts @@ -14,6 +14,12 @@ export type AesGcmEncryptOptions = { } & AesGcmDecryptOptions; export type AesGcmDecryptOptions = { + /** + * Associated data authenticated with the ciphertext but not encrypted. + * Encryption and decryption must use the same value. + */ + additionalData?: BufferSource; + /** * Skip the IV length check. Using an IV other than 12 bytes deviates from * the standard and is considered less safe. @@ -71,7 +77,12 @@ export async function encrypt( const ciphertext = await globalThis.crypto.subtle.encrypt( // Converting to Uint8Array to work around a Node 22 bug, we may be able to remove in the future. - { name: 'AES-GCM', iv }, + { + name: 'AES-GCM', + iv, + additionalData: + options?.additionalData && toUint8Array(options.additionalData), + }, subtleKey, toUint8Array(plaintext), ); @@ -122,7 +133,12 @@ export async function decrypt( const plaintext = await globalThis.crypto.subtle.decrypt( // Converting to Uint8Array to work around a Node 22 bug, we may be able to remove in the future. - { name: 'AES-GCM', iv: toUint8Array(iv) }, + { + name: 'AES-GCM', + iv: toUint8Array(iv), + additionalData: + options?.additionalData && toUint8Array(options.additionalData), + }, subtleKey, toUint8Array(ciphertext), ); diff --git a/packages/cryptography/src/hkdf.test.ts b/packages/cryptography/src/hkdf.test.ts index fd8f42b4423..20ea0eb5838 100644 --- a/packages/cryptography/src/hkdf.test.ts +++ b/packages/cryptography/src/hkdf.test.ts @@ -69,7 +69,22 @@ describe('hkdfSha256', () => { it('throws if the IKM is empty', async () => { await expect(hkdfSha256(new Uint8Array(0), salt, info, 32)).rejects.toThrow( - 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-256.', + 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-256. To bypass this check, set the `unsafeInputKeyingMaterial` option to `true`.', + ); + }); + + it('derives key material from an empty IKM when the length check is skipped', async () => { + // HKDF-SHA-256, 32-byte salt of 0x01, empty IKM and info, 64-byte output. + // Matches Node crypto.hkdfSync and the Noise Split input shape. + const key = await hkdfSha256( + new Uint8Array(0), + new Uint8Array(32).fill(1), + new Uint8Array(0), + 64, + { unsafeInputKeyingMaterial: true }, + ); + expect(bytesToHex(key)).toBe( + '0xb5a5789af1d00c74773ef327dc63f0f11c7041252d4916c43a252b92e3358ad272d7e204fba7b0ac19964ebcba5e88e3609cb8c3cafa4b940945df49da83680f', ); }); }); @@ -129,7 +144,7 @@ describe('hkdfSha384', () => { it('throws if the IKM is empty', async () => { await expect(hkdfSha384(new Uint8Array(0), salt, info, 48)).rejects.toThrow( - 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-384.', + 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-384. To bypass this check, set the `unsafeInputKeyingMaterial` option to `true`.', ); }); }); @@ -189,7 +204,7 @@ describe('hkdfSha512', () => { it('throws if the IKM is empty', async () => { await expect(hkdfSha512(new Uint8Array(0), salt, info, 64)).rejects.toThrow( - 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-512.', + 'Unsafe input keying material length: IKM must not be zero bytes for HKDF-SHA-512. To bypass this check, set the `unsafeInputKeyingMaterial` option to `true`.', ); }); }); diff --git a/packages/cryptography/src/hkdf.ts b/packages/cryptography/src/hkdf.ts index d92fd0b6d57..02999f7a6e5 100644 --- a/packages/cryptography/src/hkdf.ts +++ b/packages/cryptography/src/hkdf.ts @@ -89,9 +89,9 @@ async function hkdf( keyLength: number, options: HkdfOptions = {}, ): Promise> { - if (ikm.byteLength === 0) { + if (ikm.byteLength === 0 && !options.unsafeInputKeyingMaterial) { throw new Error( - `Unsafe input keying material length: IKM must not be zero bytes for HKDF-${hash}.`, + `Unsafe input keying material length: IKM must not be zero bytes for HKDF-${hash}. To bypass this check, set the \`unsafeInputKeyingMaterial\` option to \`true\`.`, ); }