diff --git a/packages/cryptography/CHANGELOG.md b/packages/cryptography/CHANGELOG.md index fc57355dea3..3b359b7841a 100644 --- a/packages/cryptography/CHANGELOG.md +++ b/packages/cryptography/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for Node.js 26 ([#10981](https://github.com/MetaMask/core/pull/10981)) +### Fixed + +- Derive HKDF from HMAC when the input keying material is empty, as some WebCrypto implementations such as `react-native-quick-crypto` reject an empty HKDF key ([#11024](https://github.com/MetaMask/core/pull/11024)) + ## [1.1.2] ### Fixed diff --git a/packages/cryptography/src/hkdf.test.ts b/packages/cryptography/src/hkdf.test.ts index 20ea0eb5838..857c99d23b2 100644 --- a/packages/cryptography/src/hkdf.test.ts +++ b/packages/cryptography/src/hkdf.test.ts @@ -208,3 +208,101 @@ describe('hkdfSha512', () => { ); }); }); + +describe('HKDF with an empty IKM', () => { + const emptyIkm = new Uint8Array(0); + const emptyInfo = new Uint8Array(0); + const options = { unsafeInputKeyingMaterial: true }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not import the empty IKM as a WebCrypto HKDF key', async () => { + // react-native-quick-crypto rejects an empty HKDF key. + const importKey = jest.spyOn(globalThis.crypto.subtle, 'importKey'); + + const key = await hkdfSha256( + emptyIkm, + new Uint8Array(32).fill(1), + emptyInfo, + 64, + options, + ); + + expect(bytesToHex(key)).toBe( + '0xb5a5789af1d00c74773ef327dc63f0f11c7041252d4916c43a252b92e3358ad272d7e204fba7b0ac19964ebcba5e88e3609cb8c3cafa4b940945df49da83680f', + ); + expect(importKey).not.toHaveBeenCalledWith( + 'raw', + expect.anything(), + { name: 'HKDF' }, + expect.anything(), + expect.anything(), + ); + }); + + it('uses a zero salt of hash length when the salt is empty', async () => { + const key = await hkdfSha256( + emptyIkm, + new Uint8Array(0), + emptyInfo, + 32, + options, + ); + + expect(bytesToHex(key)).toBe( + '0xeb70f01dede9afafa449eee1b1286504e1f62388b3f7dd4f956697b0e828fe18', + ); + }); + + it('includes info and derives more than one block', async () => { + const key = await hkdfSha256( + emptyIkm, + new Uint8Array(32).fill(1), + info, + 80, + options, + ); + + expect(bytesToHex(key)).toBe( + '0x12f2e1ee5e6ed702856aba724415af4fea6343248bfe221db0dce4ef4f5c2cc7c20a0acdcc06638d2ae58b55240230541813e6acca9936141786fa0bf8b658ca7585a3793a37bfedb9779e1f007f117b', + ); + }); + + it('derives key material with HKDF-SHA-384', async () => { + const key = await hkdfSha384( + emptyIkm, + new Uint8Array(48).fill(2), + emptyInfo, + 48, + options, + ); + + expect(bytesToHex(key)).toBe( + '0xa64c6c8eba82ddf729d3fcc16b9ef6057a82b4eb594b7c7def352047bcd5135c1d72ad30840f31488454b4b56383ee6a', + ); + }); + + it('derives key material with HKDF-SHA-512', async () => { + const key = await hkdfSha512( + emptyIkm, + new Uint8Array(64).fill(3), + emptyInfo, + 100, + options, + ); + + expect(bytesToHex(key)).toBe( + '0xeb78d46b71b65b758e187684283d5c1ee6f7c2cfe8149025a74ce46ff3410dfb3364c250215fa6ad98177b5d24e797375bb3426711e00030660c974227774d11e7f85e493893f0666e7feaad813f54206e7407502cdd1f639076c7d30abba283fe45b7de', + ); + }); + + it('throws if the key length is longer than HKDF allows', async () => { + await expect( + hkdfSha256(emptyIkm, salt, emptyInfo, 255 * 32 + 1, options), + ).rejects.toThrow( + 'Invalid key length: HKDF-SHA-256 can derive at most 8160 bytes.', + ); + }); +}); diff --git a/packages/cryptography/src/hkdf.ts b/packages/cryptography/src/hkdf.ts index 02999f7a6e5..0e290ae2331 100644 --- a/packages/cryptography/src/hkdf.ts +++ b/packages/cryptography/src/hkdf.ts @@ -1,7 +1,14 @@ import type { HashFunction } from './types.js'; +import { toUint8Array } from './utils.js'; const MIN_IKM_LENGTH = 32; +const HASH_LENGTH: Record = { + 'SHA-256': 32, + 'SHA-384': 48, + 'SHA-512': 64, +}; + export type HkdfOptions = { /** * Skip the minimum IKM length check. Using IKM shorter than 32 bytes @@ -101,6 +108,12 @@ async function hkdf( ); } + // Some WebCrypto implementations, such as react-native-quick-crypto, reject + // an empty HKDF key, so derive it from HMAC as RFC 5869 defines it. + if (ikm.byteLength === 0) { + return hkdfFromHmac(ikm, hash, salt, info, keyLength); + } + const subtleKey = await globalThis.crypto.subtle.importKey( 'raw', ikm, @@ -122,3 +135,78 @@ async function hkdf( return new Uint8Array(derivedBits); } + +/** + * Compute the HMAC of the given data with WebCrypto. + * + * @param key - The HMAC key. + * @param hash - The hash function to use. + * @param data - The data to authenticate. + * @returns The HMAC. + */ +async function hmacSign( + key: BufferSource, + hash: HashFunction, + data: BufferSource, +): Promise> { + const subtleKey = await globalThis.crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash }, + false, + ['sign'], + ); + + return new Uint8Array( + await globalThis.crypto.subtle.sign('HMAC', subtleKey, data), + ); +} + +/** + * Derive key material using HKDF built from HMAC, following the Extract and + * Expand steps of RFC 5869. + * + * @param ikm - The input keying material. + * @param hash - The hash function to use. + * @param salt - The salt to use. + * @param info - Context and application-specific information. + * @param keyLength - The desired key length in bytes. + * @returns The derived key material. + */ +async function hkdfFromHmac( + ikm: BufferSource, + hash: HashFunction, + salt: BufferSource, + info: BufferSource, + keyLength: number, +): Promise> { + const hashLength = HASH_LENGTH[hash]; + const blockCount = Math.ceil(keyLength / hashLength); + if (blockCount > 255) { + throw new Error( + `Invalid key length: HKDF-${hash} can derive at most ${255 * hashLength} bytes.`, + ); + } + + // https://datatracker.ietf.org/doc/html/rfc5869#section-2.2 + const pseudoRandomKey = await hmacSign( + salt.byteLength === 0 ? new Uint8Array(hashLength) : salt, + hash, + ikm, + ); + + // https://datatracker.ietf.org/doc/html/rfc5869#section-2.3 + const infoBytes = toUint8Array(info); + const output = new Uint8Array(blockCount * hashLength); + let previous = new Uint8Array(0); + for (let index = 0; index < blockCount; index++) { + const input = new Uint8Array(previous.length + infoBytes.length + 1); + input.set(previous); + input.set(infoBytes, previous.length); + input[input.length - 1] = index + 1; + previous = await hmacSign(pseudoRandomKey, hash, input); + output.set(previous, index * hashLength); + } + + return output.slice(0, keyLength); +}