From a4b70ca39f601e2621cb27f440c386aacfec2530 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 5 Sep 2025 13:45:29 -0400 Subject: [PATCH 1/5] feat: add uint8array compare function --- src/bytes.test.ts | 30 ++++++++++++++++++++++++++++++ src/bytes.ts | 24 ++++++++++++++++++++++++ src/index.test.ts | 1 + src/node.test.ts | 1 + 4 files changed, 56 insertions(+) diff --git a/src/bytes.test.ts b/src/bytes.test.ts index d40fe6c02..42d3dc7fd 100644 --- a/src/bytes.test.ts +++ b/src/bytes.test.ts @@ -7,6 +7,7 @@ import { UTF_8_BYTES_FIXTURES, } from './__fixtures__'; import { + areUint8ArraysEqual, assertIsBytes, base64ToBytes, bigIntToBytes, @@ -536,4 +537,33 @@ describe('createDataView', () => { expect(dataView.getUint8(2)).toBe(4); }); }); + + describe('areUint8ArraysEqual', () => { + it('returns true if the Uint8Arrays are equal', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(32).fill(1), + ), + ).toBe(true); + }); + + it('returns false if the Uint8Arrays are not equal', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(32).fill(2), + ), + ).toBe(false); + }); + + it('returns false if the Uint8Arrays length is different', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(31).fill(1), + ), + ).toBe(false); + }); + }); }); diff --git a/src/bytes.ts b/src/bytes.ts index 92965df28..2d4a5ab41 100644 --- a/src/bytes.ts +++ b/src/bytes.ts @@ -459,3 +459,27 @@ export function createDataView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); } + +/** + * Compares two Uint8Arrays in a timing-safe manner. + * + * @param a - The first Uint8Array to compare. + * @param b - The second Uint8Array to compare. + * @returns Whether the Uint8Arrays are equal. + */ +export function areUint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) { + return false; + } + + const viewA = new DataView(a.buffer, a.byteOffset, a.byteLength); + const viewB = new DataView(b.buffer, b.byteOffset, b.byteLength); + + let diff = 0; + + for (let i = 0; i < a.byteLength; i++) { + diff += viewA.getUint8(i) === viewB.getUint8(i) ? 0 : 1; + } + + return diff === 0; +} diff --git a/src/index.test.ts b/src/index.test.ts index 428dd4bee..38e73f5c6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -52,6 +52,7 @@ describe('index', () => { "VersionRangeStruct", "VersionStruct", "add0x", + "areUint8ArraysEqual", "assert", "assertExhaustive", "assertIsBytes", diff --git a/src/node.test.ts b/src/node.test.ts index 9879912f6..3190513a5 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -52,6 +52,7 @@ describe('node', () => { "VersionRangeStruct", "VersionStruct", "add0x", + "areUint8ArraysEqual", "assert", "assertExhaustive", "assertIsBytes", From 3b09fa442f2d09c5118b354b59f4f54306e7f8e0 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 5 Sep 2025 13:53:15 -0400 Subject: [PATCH 2/5] chore: add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a964d3d..3c47102e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `areUint8ArraysEqual` function to compare two `Uint8Array` types ([#268](https://github.com/MetaMask/utils/pull/268)) + ## [11.7.0] ### Added From f561ca6b15f466ec90b56da73dcac9d7708e93a3 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 5 Sep 2025 14:13:04 -0400 Subject: [PATCH 3/5] refactor: change implementation to address timing safe comment --- src/bytes.test.ts | 9 +++++++++ src/bytes.ts | 16 ++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/bytes.test.ts b/src/bytes.test.ts index 42d3dc7fd..a4e2903b8 100644 --- a/src/bytes.test.ts +++ b/src/bytes.test.ts @@ -565,5 +565,14 @@ describe('createDataView', () => { ), ).toBe(false); }); + + it('returns false if the first Uint8Array is shorter than the second', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(31).fill(1), + new Uint8Array(32).fill(1), + ), + ).toBe(false); + }); }); }); diff --git a/src/bytes.ts b/src/bytes.ts index 2d4a5ab41..5a1a0a9d6 100644 --- a/src/bytes.ts +++ b/src/bytes.ts @@ -468,17 +468,17 @@ export function createDataView(bytes: Uint8Array): DataView { * @returns Whether the Uint8Arrays are equal. */ export function areUint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.byteLength !== b.byteLength) { - return false; - } + const viewA = createDataView(a); + const viewB = createDataView(b); - const viewA = new DataView(a.buffer, a.byteOffset, a.byteLength); - const viewB = new DataView(b.buffer, b.byteOffset, b.byteLength); + const maxLength = Math.max(a.byteLength, b.byteLength); - let diff = 0; + let diff = Math.abs(a.byteLength - b.byteLength); - for (let i = 0; i < a.byteLength; i++) { - diff += viewA.getUint8(i) === viewB.getUint8(i) ? 0 : 1; + for (let i = 0; i < maxLength; i++) { + const aByte = i < a.byteLength ? viewA.getUint8(i) : 0; + const bByte = i < b.byteLength ? viewB.getUint8(i) : 0; + diff += Math.abs(aByte - bByte); } return diff === 0; From b64a5989c49b12bca757375792e0e5f115d9fcc6 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 8 Sep 2025 10:22:07 -0400 Subject: [PATCH 4/5] refactor: apply code review --- src/bytes.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++++ src/bytes.ts | 27 +++++++++++-------- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/bytes.test.ts b/src/bytes.test.ts index a4e2903b8..b1adaa559 100644 --- a/src/bytes.test.ts +++ b/src/bytes.test.ts @@ -574,5 +574,71 @@ describe('createDataView', () => { ), ).toBe(false); }); + + it('returns true if the Uint8Arrays are both empty', () => { + expect(areUint8ArraysEqual(new Uint8Array(), new Uint8Array())).toBe( + true, + ); + }); + + it('returns false if there is a subtle difference in the Uint8Arrays', () => { + expect( + areUint8ArraysEqual( + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + new Uint8Array([1, 2, 3, 4, 5, 6, 2, 8, 9, 10, 11, 12]), + ), + ).toBe(false); + }); + + it('has similar runtime for early vs late differences on large arrays', () => { + const LENGTH = 100_000; + const ITERATIONS = 200; + + const base = new Uint8Array(LENGTH).fill(7); + const early = base.slice(); + const late = base.slice(); + early[0] = 6; // first element differs + late[LENGTH - 1] = 6; // last element differs + + // Warm up JIT + for (let i = 0; i < 20; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(base, base); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(early, base); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(late, base); + } + + const now = () => Number(process.hrtime.bigint()); + + let earlyTotal = 0; + let lateTotal = 0; + + // Measure early difference + const startEarly = now(); + for (let i = 0; i < ITERATIONS; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(early, base); + } + earlyTotal = now() - startEarly; + + // Measure late difference + const startLate = now(); + for (let i = 0; i < ITERATIONS; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(late, base); + } + lateTotal = now() - startLate; + + // Ratio ≈ 1.0 ⇒ similar runtimes regardless of diff position. + // The threshold enforces the same order of magnitude while allowing normal system jitter. + // It's an empirical upper bound (~p95). To tune: run multiple trials, take a high percentile, and set slightly above it. + const ratio = + earlyTotal > lateTotal + ? earlyTotal / lateTotal + : lateTotal / earlyTotal; + expect(ratio).toBeLessThan(1.1); + }); }); }); diff --git a/src/bytes.ts b/src/bytes.ts index 5a1a0a9d6..2b3bc44cf 100644 --- a/src/bytes.ts +++ b/src/bytes.ts @@ -461,24 +461,29 @@ export function createDataView(bytes: Uint8Array): DataView { } /** - * Compares two Uint8Arrays in a timing-safe manner. + * Compare two Uint8Arrays using a constant-time style loop to reduce timing + * side-channels when comparing sensitive data (e.g., mnemonic bytes, keys, + * authentication tags). Does not early-return on the first difference: + * work done depends only on the input lengths, so byte content does not affect timing. + * + * When to use: + * - Use for secret or security-sensitive byte comparisons to avoid content-based timing leaks. + * - Prefer when inputs are fixed-length (or validated to equal length) at the API boundary. * * @param a - The first Uint8Array to compare. * @param b - The second Uint8Array to compare. * @returns Whether the Uint8Arrays are equal. */ export function areUint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean { - const viewA = createDataView(a); - const viewB = createDataView(b); - - const maxLength = Math.max(a.byteLength, b.byteLength); + // eslint-disable-next-line no-bitwise + let diff = a.byteLength ^ b.byteLength; + const len = Math.max(a.byteLength, b.byteLength); - let diff = Math.abs(a.byteLength - b.byteLength); - - for (let i = 0; i < maxLength; i++) { - const aByte = i < a.byteLength ? viewA.getUint8(i) : 0; - const bByte = i < b.byteLength ? viewB.getUint8(i) : 0; - diff += Math.abs(aByte - bByte); + for (let i = 0; i < len; i++) { + const aByte = a[i] ?? 0; + const bByte = b[i] ?? 0; + // eslint-disable-next-line no-bitwise + diff |= aByte ^ bByte; } return diff === 0; From 7b59476ff75b83e2e5b8bb4906cff10b87bb9223 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 8 Sep 2025 17:37:34 -0400 Subject: [PATCH 5/5] fix: move areUint8ArraysEqual out of DataView test block --- src/bytes.test.ts | 188 +++++++++++++++++++++++----------------------- 1 file changed, 92 insertions(+), 96 deletions(-) diff --git a/src/bytes.test.ts b/src/bytes.test.ts index b1adaa559..15a43a724 100644 --- a/src/bytes.test.ts +++ b/src/bytes.test.ts @@ -537,108 +537,104 @@ describe('createDataView', () => { expect(dataView.getUint8(2)).toBe(4); }); }); +}); - describe('areUint8ArraysEqual', () => { - it('returns true if the Uint8Arrays are equal', () => { - expect( - areUint8ArraysEqual( - new Uint8Array(32).fill(1), - new Uint8Array(32).fill(1), - ), - ).toBe(true); - }); +describe('areUint8ArraysEqual', () => { + it('returns true if the Uint8Arrays are equal', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(32).fill(1), + ), + ).toBe(true); + }); - it('returns false if the Uint8Arrays are not equal', () => { - expect( - areUint8ArraysEqual( - new Uint8Array(32).fill(1), - new Uint8Array(32).fill(2), - ), - ).toBe(false); - }); + it('returns false if the Uint8Arrays are not equal', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(32).fill(2), + ), + ).toBe(false); + }); - it('returns false if the Uint8Arrays length is different', () => { - expect( - areUint8ArraysEqual( - new Uint8Array(32).fill(1), - new Uint8Array(31).fill(1), - ), - ).toBe(false); - }); + it('returns false if the Uint8Arrays length is different', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(32).fill(1), + new Uint8Array(31).fill(1), + ), + ).toBe(false); + }); - it('returns false if the first Uint8Array is shorter than the second', () => { - expect( - areUint8ArraysEqual( - new Uint8Array(31).fill(1), - new Uint8Array(32).fill(1), - ), - ).toBe(false); - }); + it('returns false if the first Uint8Array is shorter than the second', () => { + expect( + areUint8ArraysEqual( + new Uint8Array(31).fill(1), + new Uint8Array(32).fill(1), + ), + ).toBe(false); + }); - it('returns true if the Uint8Arrays are both empty', () => { - expect(areUint8ArraysEqual(new Uint8Array(), new Uint8Array())).toBe( - true, - ); - }); + it('returns true if the Uint8Arrays are both empty', () => { + expect(areUint8ArraysEqual(new Uint8Array(), new Uint8Array())).toBe(true); + }); - it('returns false if there is a subtle difference in the Uint8Arrays', () => { - expect( - areUint8ArraysEqual( - new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - new Uint8Array([1, 2, 3, 4, 5, 6, 2, 8, 9, 10, 11, 12]), - ), - ).toBe(false); - }); + it('returns false if there is a subtle difference in the Uint8Arrays', () => { + expect( + areUint8ArraysEqual( + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + new Uint8Array([1, 2, 3, 4, 5, 6, 2, 8, 9, 10, 11, 12]), + ), + ).toBe(false); + }); - it('has similar runtime for early vs late differences on large arrays', () => { - const LENGTH = 100_000; - const ITERATIONS = 200; - - const base = new Uint8Array(LENGTH).fill(7); - const early = base.slice(); - const late = base.slice(); - early[0] = 6; // first element differs - late[LENGTH - 1] = 6; // last element differs - - // Warm up JIT - for (let i = 0; i < 20; i++) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - areUint8ArraysEqual(base, base); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - areUint8ArraysEqual(early, base); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - areUint8ArraysEqual(late, base); - } - - const now = () => Number(process.hrtime.bigint()); - - let earlyTotal = 0; - let lateTotal = 0; - - // Measure early difference - const startEarly = now(); - for (let i = 0; i < ITERATIONS; i++) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - areUint8ArraysEqual(early, base); - } - earlyTotal = now() - startEarly; - - // Measure late difference - const startLate = now(); - for (let i = 0; i < ITERATIONS; i++) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - areUint8ArraysEqual(late, base); - } - lateTotal = now() - startLate; - - // Ratio ≈ 1.0 ⇒ similar runtimes regardless of diff position. - // The threshold enforces the same order of magnitude while allowing normal system jitter. - // It's an empirical upper bound (~p95). To tune: run multiple trials, take a high percentile, and set slightly above it. - const ratio = - earlyTotal > lateTotal - ? earlyTotal / lateTotal - : lateTotal / earlyTotal; - expect(ratio).toBeLessThan(1.1); - }); + it('has similar runtime for early vs late differences on large arrays', () => { + const LENGTH = 100_000; + const ITERATIONS = 200; + + const base = new Uint8Array(LENGTH).fill(7); + const early = base.slice(); + const late = base.slice(); + early[0] = 6; // first element differs + late[LENGTH - 1] = 6; // last element differs + + // Warm up JIT + for (let i = 0; i < 20; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(base, base); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(early, base); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(late, base); + } + + const now = () => Number(process.hrtime.bigint()); + + let earlyTotal = 0; + let lateTotal = 0; + + // Measure early difference + const startEarly = now(); + for (let i = 0; i < ITERATIONS; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(early, base); + } + earlyTotal = now() - startEarly; + + // Measure late difference + const startLate = now(); + for (let i = 0; i < ITERATIONS; i++) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + areUint8ArraysEqual(late, base); + } + lateTotal = now() - startLate; + + // Ratio ≈ 1.0 ⇒ similar runtimes regardless of diff position. + // The threshold enforces the same order of magnitude while allowing normal system jitter. + // It's an empirical upper bound (~p95). To tune: run multiple trials, take a high percentile, and set slightly above it. + const ratio = + earlyTotal > lateTotal ? earlyTotal / lateTotal : lateTotal / earlyTotal; + expect(ratio).toBeLessThan(1.1); }); });