Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cryptography/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions packages/cryptography/src/hkdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
});
});
88 changes: 88 additions & 0 deletions packages/cryptography/src/hkdf.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { HashFunction } from './types.js';
import { toUint8Array } from './utils.js';

const MIN_IKM_LENGTH = 32;

const HASH_LENGTH: Record<HashFunction, number> = {
'SHA-256': 32,
'SHA-384': 48,
'SHA-512': 64,
};

export type HkdfOptions = {
/**
* Skip the minimum IKM length check. Using IKM shorter than 32 bytes
Expand Down Expand Up @@ -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.
Comment on lines +111 to +112

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this seems like a bug in react-native-quick-crypto, where it doesn't conform to the spec and I have verified that our existing implementation works in both Node.js and the browser I think we should prefer upstreaming a fix. @matthiasgeihs has already opened a PR for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related PR that bumps react-native-quick-crypto on mobile.
MetaMask/metamask-mobile#38279
Once that's in, we might be able to drop this one here.

if (ikm.byteLength === 0) {
return hkdfFromHmac(ikm, hash, salt, info, keyLength);
}

const subtleKey = await globalThis.crypto.subtle.importKey(
'raw',
ikm,
Expand All @@ -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<Uint8Array<ArrayBuffer>> {
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<Uint8Array<ArrayBuffer>> {
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);
}
Loading