From 4ef5a6a6212ab73756435074685d0f08f8b20b8b Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Wed, 30 Sep 2026 17:32:43 +0200 Subject: [PATCH 1/8] feat(cryptography): accept AES-GCM associated data and empty HKDF input Noise authenticates every handshake message with associated data, and Split derives the transport keys from an empty HKDF input. Co-authored-by: Cursor --- packages/cryptography/CHANGELOG.md | 5 ++ packages/cryptography/src/aes-gcm.test.ts | 57 +++++++++++++++++++++++ packages/cryptography/src/aes-gcm.ts | 32 ++++++++++++- packages/cryptography/src/hkdf.test.ts | 21 +++++++-- packages/cryptography/src/hkdf.ts | 9 ++-- 5 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index 07a485f03ee..a9b24c46b0a 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` +- Allow HKDF functions to derive from an empty input when `unsafeInputKeyingMaterial` is set + ## [1.0.0] ### Added diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index e63e55a2a23..57b2c284e92 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -33,6 +33,24 @@ const nistCiphertext15 = hexToBytes( '0x522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662898015adb094dac5d93471bdec1a502270e3cc6c', ) as Uint8Array; +// Test Case 4: 128-bit key with associated data +// https://csrc.nist.rip/groups/ST/toolkit/BCM/documents/proposedmodes/gcm/gcm-spec.pdf +const nistKey4 = hexToBytes( + '0xfeffe9928665731c6d6a8f9467308308', +) as Uint8Array; +const nistIv4 = hexToBytes( + '0xcafebabefacedbaddecaf888', +) as Uint8Array; +const nistPlaintext4 = hexToBytes( + '0xd9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39', +) as Uint8Array; +const nistAdditionalData4 = hexToBytes( + '0xfeedfacedeadbeeffeedfacedeadbeefabaddad2', +) as Uint8Array; +const nistCiphertext4 = hexToBytes( + '0x42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e0915bc94fbc3221a5db94fae95ae7121a47', +) as Uint8Array; + describe('encrypt', () => { it('matches test case 2', async () => { const result = await encrypt(nistKey2, nistPlaintext2, { @@ -104,6 +122,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', () => { @@ -158,4 +200,19 @@ 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('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 9a0035d05b9..c9cb075206e 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,7 @@ 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 }, + getAesGcmAlgorithm(iv, options), subtleKey, toUint8Array(plaintext), ); @@ -122,10 +128,32 @@ 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) }, + getAesGcmAlgorithm(toUint8Array(iv), options), subtleKey, toUint8Array(ciphertext), ); return new Uint8Array(plaintext); } + +/** + * Build the WebCrypto AES-GCM parameters. + * + * @param iv - The initialization vector. + * @param options - Options that may include associated data. + * @returns The algorithm parameters for `subtle.encrypt` or `subtle.decrypt`. + */ +function getAesGcmAlgorithm( + iv: Uint8Array, + options?: AesGcmDecryptOptions, +): AesGcmParams { + if (options?.additionalData === undefined) { + return { name: 'AES-GCM', iv }; + } + + return { + name: 'AES-GCM', + iv, + additionalData: toUint8Array(options.additionalData), + }; +} diff --git a/packages/cryptography/src/hkdf.test.ts b/packages/cryptography/src/hkdf.test.ts index 4aeb1456f4a..343d31814d7 100644 --- a/packages/cryptography/src/hkdf.test.ts +++ b/packages/cryptography/src/hkdf.test.ts @@ -77,7 +77,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', ); }); }); @@ -137,7 +152,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`.', ); }); }); @@ -197,7 +212,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..8d701483c77 100644 --- a/packages/cryptography/src/hkdf.ts +++ b/packages/cryptography/src/hkdf.ts @@ -4,8 +4,9 @@ const MIN_IKM_LENGTH = 32; export type HkdfOptions = { /** - * Skip the minimum IKM length check. Using IKM shorter than 32 bytes - * reduces the entropy of the derived key material. + * Skip the minimum IKM length check. Using IKM shorter than 32 bytes, + * including an empty input, reduces the entropy of the derived key material. + * An empty input is part of some protocols, such as Noise `Split`. */ unsafeInputKeyingMaterial?: boolean; }; @@ -89,9 +90,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\`.`, ); } From 26f866f1ea8d9a78c1f2e7c272e1660feda9c0a5 Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Wed, 30 Sep 2026 17:33:09 +0200 Subject: [PATCH 2/8] docs(cryptography): link the changelog entries to the pull request Co-authored-by: Cursor --- packages/cryptography/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index a9b24c46b0a..aefc63ef2a1 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add an `additionalData` option to AES-GCM `encrypt` and `decrypt` -- Allow HKDF functions to derive from an empty input when `unsafeInputKeyingMaterial` is set +- 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)) ## [1.0.0] From e1c0d7c35af45f2970138a1bd4ee9d6a832e4f4a Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 12:46:44 +0200 Subject: [PATCH 3/8] remove link --- packages/cryptography/src/aes-gcm.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index 57b2c284e92..9960802dee3 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -34,7 +34,6 @@ const nistCiphertext15 = hexToBytes( ) as Uint8Array; // Test Case 4: 128-bit key with associated data -// https://csrc.nist.rip/groups/ST/toolkit/BCM/documents/proposedmodes/gcm/gcm-spec.pdf const nistKey4 = hexToBytes( '0xfeffe9928665731c6d6a8f9467308308', ) as Uint8Array; From 5c74207a7542a316e60108ba9f206f5b666f72f4 Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 12:47:54 +0200 Subject: [PATCH 4/8] remove casts --- packages/cryptography/src/aes-gcm.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index 7383d07b9e5..42e31821340 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -28,19 +28,19 @@ const nistCiphertext15 = hexToBytes( // Test Case 4: 128-bit key with associated data const nistKey4 = hexToBytes( '0xfeffe9928665731c6d6a8f9467308308', -) as Uint8Array; +); const nistIv4 = hexToBytes( '0xcafebabefacedbaddecaf888', -) as Uint8Array; +); const nistPlaintext4 = hexToBytes( '0xd9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39', -) as Uint8Array; +); const nistAdditionalData4 = hexToBytes( '0xfeedfacedeadbeeffeedfacedeadbeefabaddad2', -) as Uint8Array; +); const nistCiphertext4 = hexToBytes( '0x42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e0915bc94fbc3221a5db94fae95ae7121a47', -) as Uint8Array; +); describe('encrypt', () => { it('matches test case 2', async () => { From 8dd4349eefb00c9fa1c5afd9dbe14d4fc12e0bd3 Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 12:50:07 +0200 Subject: [PATCH 5/8] revert comment --- packages/cryptography/src/hkdf.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cryptography/src/hkdf.ts b/packages/cryptography/src/hkdf.ts index 8d701483c77..02999f7a6e5 100644 --- a/packages/cryptography/src/hkdf.ts +++ b/packages/cryptography/src/hkdf.ts @@ -4,9 +4,8 @@ const MIN_IKM_LENGTH = 32; export type HkdfOptions = { /** - * Skip the minimum IKM length check. Using IKM shorter than 32 bytes, - * including an empty input, reduces the entropy of the derived key material. - * An empty input is part of some protocols, such as Noise `Split`. + * Skip the minimum IKM length check. Using IKM shorter than 32 bytes + * reduces the entropy of the derived key material. */ unsafeInputKeyingMaterial?: boolean; }; From 273c1901a6c77e098ab98c8a4b9394bea27fe09d Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 13:04:37 +0200 Subject: [PATCH 6/8] inline getAesGcmAlgorithm --- packages/cryptography/src/aes-gcm.ts | 36 ++++++++++------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/cryptography/src/aes-gcm.ts b/packages/cryptography/src/aes-gcm.ts index a76da910587..57008b501c2 100644 --- a/packages/cryptography/src/aes-gcm.ts +++ b/packages/cryptography/src/aes-gcm.ts @@ -77,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. - getAesGcmAlgorithm(iv, options), + { + name: 'AES-GCM', + iv, + additionalData: + options?.additionalData && toUint8Array(options.additionalData), + }, subtleKey, toUint8Array(plaintext), ); @@ -128,32 +133,15 @@ 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. - getAesGcmAlgorithm(toUint8Array(iv), options), + { + name: 'AES-GCM', + iv: toUint8Array(iv), + additionalData: + options?.additionalData && toUint8Array(options.additionalData), + }, subtleKey, toUint8Array(ciphertext), ); return new Uint8Array(plaintext); } - -/** - * Build the WebCrypto AES-GCM parameters. - * - * @param iv - The initialization vector. - * @param options - Options that may include associated data. - * @returns The algorithm parameters for `subtle.encrypt` or `subtle.decrypt`. - */ -function getAesGcmAlgorithm( - iv: Uint8Array, - options?: AesGcmDecryptOptions, -): AesGcmParams { - if (options?.additionalData === undefined) { - return { name: 'AES-GCM', iv }; - } - - return { - name: 'AES-GCM', - iv, - additionalData: toUint8Array(options.additionalData), - }; -} From a741db705c3b14de84547b1f2739166a6b3d1704 Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 13:08:39 +0200 Subject: [PATCH 7/8] lint --- packages/cryptography/src/aes-gcm.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index 42e31821340..5b9e81a0665 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -26,12 +26,8 @@ const nistCiphertext15 = hexToBytes( ); // Test Case 4: 128-bit key with associated data -const nistKey4 = hexToBytes( - '0xfeffe9928665731c6d6a8f9467308308', -); -const nistIv4 = hexToBytes( - '0xcafebabefacedbaddecaf888', -); +const nistKey4 = hexToBytes('0xfeffe9928665731c6d6a8f9467308308'); +const nistIv4 = hexToBytes('0xcafebabefacedbaddecaf888'); const nistPlaintext4 = hexToBytes( '0xd9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39', ); From c1dc07274827ffc83a61b06b8d08b8f0971425d5 Mon Sep 17 00:00:00 2001 From: Matthias Geihs Date: Thu, 1 Oct 2026 13:13:18 +0200 Subject: [PATCH 8/8] test(cryptography): cover ArrayBuffer and DataView associated data on decrypt Co-authored-by: Cursor --- packages/cryptography/src/aes-gcm.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cryptography/src/aes-gcm.test.ts b/packages/cryptography/src/aes-gcm.test.ts index 5b9e81a0665..f976dfa7854 100644 --- a/packages/cryptography/src/aes-gcm.test.ts +++ b/packages/cryptography/src/aes-gcm.test.ts @@ -195,6 +195,20 @@ describe('decrypt', () => { 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, {