diff --git a/src/hashing.test.ts b/src/hashing.test.ts new file mode 100644 index 00000000..4e2f9b86 --- /dev/null +++ b/src/hashing.test.ts @@ -0,0 +1,65 @@ +import * as nobleHashes from '@noble/hashes/sha256'; +import { webcrypto } from 'crypto'; +import { parse } from 'semver'; + +import { bytesToHex, stringToBytes } from './bytes'; +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( + '0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75', + ); + }); + + 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 () => { + const nobleSpy = jest.spyOn(nobleHashes, 'sha256'); + + Object.defineProperty(globalThis.crypto.subtle, 'digest', { + value: undefined, + writable: true, + }); + + const digest = await sha256(stringToBytes('foo bar')); + expect(bytesToHex(digest)).toBe( + '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')); + expect(bytesToHex(digest)).toBe( + '0xfbc1a9f858ea9e177916964bd88c3d37b91a1e84412765e29950777f265c4b75', + ); + + expect(nobleSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/hashing.ts b/src/hashing.ts new file mode 100644 index 00000000..6a8619ec --- /dev/null +++ b/src/hashing.ts @@ -0,0 +1,25 @@ +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 + globalThis.crypto.subtle?.digest + ) { + // eslint-disable-next-line no-restricted-globals + return new Uint8Array( + await globalThis.crypto.subtle.digest('SHA-256', bytes), + ); + } + return nobleSha256(bytes); +} 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",