From c8d34cb1f5f3da956494adf6e68be30070e0dbc3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 10:30:20 +0100 Subject: [PATCH 1/8] feat: Add SHA-256 utility --- src/hashing.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++ src/hashing.ts | 23 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/hashing.test.ts create mode 100644 src/hashing.ts diff --git a/src/hashing.test.ts b/src/hashing.test.ts new file mode 100644 index 00000000..e202eebd --- /dev/null +++ b/src/hashing.test.ts @@ -0,0 +1,47 @@ +import * as nobleHashes from '@noble/hashes/sha256'; +import { bytesToHex, stringToBytes } from "./bytes" +import { sha256 } from "./hashing" + +describe('sha256', () => { + it('returns a digest for a byte array', async () => { + const digest = await sha256(stringToBytes("foo bar")); + const hex = bytesToHex(digest); + expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); + }); + + it('returns a digest for a larger byte array', async () => { + const digest = await sha256(new Uint8Array(1024).fill(1)); + const hex = bytesToHex(digest); + expect(hex).toStrictEqual('0x5a648d8015900d89664e00e125df179636301a2d8fa191c1aa2bd9358ea53a69'); + }); + +it('falls back to noble when digest function is unavailable', async () => { + const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); + + Object.defineProperty(globalThis.crypto.subtle, 'digest', { + value: undefined, + writable: true, + }); + + const digest = await sha256(stringToBytes("foo bar")); + const hex = bytesToHex(digest); + expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); + + expect(nobleSpy).toHaveBeenCalled(); + }); + + it('falls back to noble when subtle APIs are unavailable', async () => { + const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); + + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + }); + + const digest = await sha256(stringToBytes("foo bar")); + const hex = bytesToHex(digest); + expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); + + expect(nobleSpy).toHaveBeenCalled(); + }); +}) \ No newline at end of file diff --git a/src/hashing.ts b/src/hashing.ts new file mode 100644 index 00000000..af6ab454 --- /dev/null +++ b/src/hashing.ts @@ -0,0 +1,23 @@ +import { sha256 as nobleSha256 } from '@noble/hashes/sha256'; + +/** + * Compute a SHA-256 digest for a given byte array. + * + * Uses the native crypto implementation and falls back to noble. + * + * @param bytes - A byte array. + * @returns The SHA-256 hash as a byte array. + */ +export async function sha256(bytes: Uint8Array): Promise { + // Use crypto.subtle.digest whenever possible as it is faster. + if ( + 'crypto' in globalThis && + typeof globalThis.crypto === 'object' && + // eslint-disable-next-line no-restricted-globals + crypto.subtle?.digest + ) { + // eslint-disable-next-line no-restricted-globals + return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + } + return nobleSha256(bytes); +} \ No newline at end of file From 47a84733d56714bc0eaaa097d33076c25092c4c1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 10:31:24 +0100 Subject: [PATCH 2/8] Fix lint --- src/hashing.test.ts | 45 +++++++++++++++++++++++++-------------------- src/hashing.ts | 2 +- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/hashing.test.ts b/src/hashing.test.ts index e202eebd..fd8c6160 100644 --- a/src/hashing.test.ts +++ b/src/hashing.test.ts @@ -1,21 +1,24 @@ import * as nobleHashes from '@noble/hashes/sha256'; -import { bytesToHex, stringToBytes } from "./bytes" -import { sha256 } from "./hashing" + +import { bytesToHex, stringToBytes } from './bytes'; +import { sha256 } from './hashing'; describe('sha256', () => { - it('returns a digest for a byte array', async () => { - const digest = await sha256(stringToBytes("foo bar")); - const hex = bytesToHex(digest); - expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); - }); + it('returns a digest for a byte array', async () => { + const digest = await sha256(stringToBytes('foo bar')); + expect(bytesToHex(digest)).toBe( + '0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75', + ); + }); - it('returns a digest for a larger byte array', async () => { - const digest = await sha256(new Uint8Array(1024).fill(1)); - const hex = bytesToHex(digest); - expect(hex).toStrictEqual('0x5a648d8015900d89664e00e125df179636301a2d8fa191c1aa2bd9358ea53a69'); - }); + it('returns a digest for a larger byte array', async () => { + const digest = await sha256(new Uint8Array(1024).fill(1)); + expect(bytesToHex(digest)).toBe( + '0x5a648d8015900d89664e00e125df179636301a2d8fa191c1aa2bd9358ea53a69', + ); + }); -it('falls back to noble when digest function is unavailable', async () => { + it('falls back to noble when digest function is unavailable', async () => { const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); Object.defineProperty(globalThis.crypto.subtle, 'digest', { @@ -23,9 +26,10 @@ it('falls back to noble when digest function is unavailable', async () => { writable: true, }); - const digest = await sha256(stringToBytes("foo bar")); - const hex = bytesToHex(digest); - expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); + const digest = await sha256(stringToBytes('foo bar')); + expect(bytesToHex(digest)).toBe( + '0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75', + ); expect(nobleSpy).toHaveBeenCalled(); }); @@ -38,10 +42,11 @@ it('falls back to noble when digest function is unavailable', async () => { writable: true, }); - const digest = await sha256(stringToBytes("foo bar")); - const hex = bytesToHex(digest); - expect(hex).toStrictEqual('0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75'); + const digest = await sha256(stringToBytes('foo bar')); + expect(bytesToHex(digest)).toBe( + '0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75', + ); expect(nobleSpy).toHaveBeenCalled(); }); -}) \ No newline at end of file +}); diff --git a/src/hashing.ts b/src/hashing.ts index af6ab454..aca09dc4 100644 --- a/src/hashing.ts +++ b/src/hashing.ts @@ -20,4 +20,4 @@ export async function sha256(bytes: Uint8Array): Promise { return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); } return nobleSha256(bytes); -} \ No newline at end of file +} From fc4902bc569a8c2a990d77dec2a9a11abc60f3fa Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 10:34:19 +0100 Subject: [PATCH 3/8] Export --- src/index.test.ts | 1 + src/index.ts | 1 + src/node.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 38e73f5c..9fd22c9d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -152,6 +152,7 @@ describe('index', () => { "parseCaipChainId", "remove0x", "satisfiesVersionRange", + "sha256", "signedBigIntToBytes", "stringToBytes", "timeSince", diff --git a/src/index.ts b/src/index.ts index f881536a..88ff8e79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './coercers'; export * from './collections'; export * from './encryption-types'; export * from './errors'; +export * from './hashing'; export type { Hex } from './hex'; export { HexStruct, diff --git a/src/node.test.ts b/src/node.test.ts index 3190513a..1532d9c3 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -159,6 +159,7 @@ describe('node', () => { "readJsonFile", "remove0x", "satisfiesVersionRange", + "sha256", "signedBigIntToBytes", "stringToBytes", "timeSince", From cc45448670f7cae500d4158ddbc2e0664b960bfd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 10:38:56 +0100 Subject: [PATCH 4/8] Tweak Node 18 tests --- src/hashing.test.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/hashing.test.ts b/src/hashing.test.ts index fd8c6160..787d4970 100644 --- a/src/hashing.test.ts +++ b/src/hashing.test.ts @@ -1,9 +1,12 @@ import * as nobleHashes from '@noble/hashes/sha256'; +import { parse } from 'semver'; import { bytesToHex, stringToBytes } from './bytes'; import { sha256 } from './hashing'; describe('sha256', () => { + const isNode18 = parse(process.version)?.major === 18; + it('returns a digest for a byte array', async () => { const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( @@ -21,10 +24,14 @@ describe('sha256', () => { it('falls back to noble when digest function is unavailable', async () => { const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); - Object.defineProperty(globalThis.crypto.subtle, 'digest', { - value: undefined, - writable: true, - }); + // The global does not exist in Node 18, so we cannot remove it. + // eslint-disable-next-line jest/no-if + if (!isNode18) { + Object.defineProperty(globalThis.crypto.subtle, 'digest', { + value: undefined, + writable: true, + }); + } const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( @@ -37,10 +44,14 @@ describe('sha256', () => { it('falls back to noble when subtle APIs are unavailable', async () => { const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); - Object.defineProperty(globalThis.crypto, 'subtle', { - value: undefined, - writable: true, - }); + // The global does not exist in Node 18, so we cannot remove it. + // eslint-disable-next-line jest/no-if + if (!isNode18) { + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + }); + } const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( From d33945d1fd003ffc0bdd4ce7defdffee8dd2d064 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 11:25:23 +0100 Subject: [PATCH 5/8] Use webcrypto as mock --- src/hashing.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/hashing.test.ts b/src/hashing.test.ts index 787d4970..77448ca3 100644 --- a/src/hashing.test.ts +++ b/src/hashing.test.ts @@ -1,4 +1,5 @@ import * as nobleHashes from '@noble/hashes/sha256'; +import { webcrypto } from 'crypto'; import { parse } from 'semver'; import { bytesToHex, stringToBytes } from './bytes'; @@ -7,6 +8,15 @@ import { sha256 } from './hashing'; describe('sha256', () => { const isNode18 = parse(process.version)?.major === 18; + // The global does not exist in Node 18, so we must add it. + // eslint-disable-next-line jest/no-if + if (isNode18) { + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + writable: true, + }); + } + it('returns a digest for a byte array', async () => { const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( From 85e2b843c7191ed2affc5c403252ef3d8285d40c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 11:27:38 +0100 Subject: [PATCH 6/8] Simplify --- src/hashing.test.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/hashing.test.ts b/src/hashing.test.ts index 77448ca3..4e2f9b86 100644 --- a/src/hashing.test.ts +++ b/src/hashing.test.ts @@ -34,14 +34,10 @@ describe('sha256', () => { it('falls back to noble when digest function is unavailable', async () => { const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); - // The global does not exist in Node 18, so we cannot remove it. - // eslint-disable-next-line jest/no-if - if (!isNode18) { - Object.defineProperty(globalThis.crypto.subtle, 'digest', { - value: undefined, - writable: true, - }); - } + Object.defineProperty(globalThis.crypto.subtle, 'digest', { + value: undefined, + writable: true, + }); const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( @@ -54,14 +50,10 @@ describe('sha256', () => { it('falls back to noble when subtle APIs are unavailable', async () => { const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); - // The global does not exist in Node 18, so we cannot remove it. - // eslint-disable-next-line jest/no-if - if (!isNode18) { - Object.defineProperty(globalThis.crypto, 'subtle', { - value: undefined, - writable: true, - }); - } + Object.defineProperty(globalThis.crypto, 'subtle', { + value: undefined, + writable: true, + }); const digest = await sha256(stringToBytes('foo bar')); expect(bytesToHex(digest)).toBe( From 6cbed7aacfc51f19d6dcc19bae3dfb586ae514b9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 13:35:47 +0100 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Maarten Zuidhoorn --- src/hashing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hashing.ts b/src/hashing.ts index aca09dc4..09c44e77 100644 --- a/src/hashing.ts +++ b/src/hashing.ts @@ -14,10 +14,10 @@ export async function sha256(bytes: Uint8Array): Promise { 'crypto' in globalThis && typeof globalThis.crypto === 'object' && // eslint-disable-next-line no-restricted-globals - crypto.subtle?.digest + globalThis.crypto.subtle?.digest ) { // eslint-disable-next-line no-restricted-globals - return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + return new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)); } return nobleSha256(bytes); } From 8961a54da11d0bcae6d9a5c7e8ea0e3e6a31b068 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 17 Dec 2025 13:37:51 +0100 Subject: [PATCH 8/8] Fix lint --- src/hashing.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hashing.ts b/src/hashing.ts index 09c44e77..6a8619ec 100644 --- a/src/hashing.ts +++ b/src/hashing.ts @@ -17,7 +17,9 @@ export async function sha256(bytes: Uint8Array): Promise { globalThis.crypto.subtle?.digest ) { // eslint-disable-next-line no-restricted-globals - return new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes)); + return new Uint8Array( + await globalThis.crypto.subtle.digest('SHA-256', bytes), + ); } return nobleSha256(bytes); }