From dd6f6d47901cf73e1053a4a49633079fd6d8faa0 Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:05:29 +0300 Subject: [PATCH 1/8] Installed `hash-wasm` as optional peer dependency and dev dependency --- package.json | 11 ++++++++++- pnpm-lock.yaml | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 65474ae..b8ffd67 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,19 @@ "@types/node": "^18.0.4", "c8": "latest", "eslint": "latest", + "hash-wasm": "^4.9.0", "standard-version": "latest", "typescript": "latest", "unbuild": "latest", "vitest": "latest" }, - "packageManager": "pnpm@7.5.2" + "packageManager": "pnpm@7.5.2", + "peerDependencies": { + "hash-wasm": "^4.9.0" + }, + "peerDependenciesMeta": { + "hash-wasm": { + "optional": true + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a74cdbf..977efe4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ specifiers: '@types/node': ^18.0.4 c8: latest eslint: latest + hash-wasm: ^4.9.0 standard-version: latest typescript: latest unbuild: latest @@ -15,6 +16,7 @@ devDependencies: '@types/node': 18.0.4 c8: 7.11.3 eslint: 8.19.0 + hash-wasm: 4.9.0 standard-version: 9.5.0 typescript: 4.7.4 unbuild: 0.7.4 @@ -890,7 +892,7 @@ packages: dev: true /concat-map/0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} dev: true /concat-stream/2.0.0: @@ -2315,6 +2317,10 @@ packages: function-bind: 1.1.1 dev: true + /hash-wasm/4.9.0: + resolution: {integrity: sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==} + dev: true + /hookable/5.1.1: resolution: {integrity: sha512-7qam9XBFb+DijNBthaL1k/7lHU2TEMZkWSyuqmU3sCQze1wFm5w9AlEx30PD7a+QVAjOy6Ec2goFwe1YVyk2uA==} dev: true From 2788b7cb83d39d0e54f23c723041daff8caddbce Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:06:52 +0300 Subject: [PATCH 2/8] Created `HashFn` type --- src/hash-fn.type.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/hash-fn.type.ts diff --git a/src/hash-fn.type.ts b/src/hash-fn.type.ts new file mode 100644 index 0000000..1bbde78 --- /dev/null +++ b/src/hash-fn.type.ts @@ -0,0 +1 @@ +export type HashFn = (message: string) => Promise | string; From 1a7432b348635d2684eea32c66e923711c5083c4 Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:07:36 +0300 Subject: [PATCH 3/8] Created `initXxHash()` function with `createXXHash32()` hasher --- src/init-xx-hash.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/init-xx-hash.ts diff --git a/src/init-xx-hash.ts b/src/init-xx-hash.ts new file mode 100644 index 0000000..6ab83f9 --- /dev/null +++ b/src/init-xx-hash.ts @@ -0,0 +1,17 @@ +import type { HashFn } from './hash-fn.type' + +/** + * Init xxHash + * @description Only if `hash-wasm` peer dependency is installed + */ +export async function initXxHash (): Promise { + const { createXXHash32 } = await import('hash-wasm') + + const hasher = await createXXHash32() + + return (message) => { + hasher.init() + hasher.update(message) + return hasher.digest() + } +} From f195150cb654ab6b3dc872314bec05df3e83d28b Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:08:12 +0300 Subject: [PATCH 4/8] Created `toHex()` function for fast convert from ArrayBuffer to hex string --- src/to-hex.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/to-hex.ts diff --git a/src/to-hex.ts b/src/to-hex.ts new file mode 100644 index 0000000..c20e673 --- /dev/null +++ b/src/to-hex.ts @@ -0,0 +1,23 @@ +const byteToHex = [] + +for (let n = 0; n <= 0xFF; ++n) { + const hexOctet = n.toString(16).padStart(2, '0') + byteToHex.push(hexOctet) +} + +/** + * Convert ArrayBuffer to hex string + * @see https://stackoverflow.com/a/55200387 + * @param {ArrayBuffer} arrayBuffer + * @return {string} Hex + */ +export function toHex (arrayBuffer: ArrayBuffer) { + const bytes = new Uint8Array(arrayBuffer) + const hexOctets = new Array(bytes.length) + + for (let i = 0; i < bytes.length; ++i) { + hexOctets[i] = byteToHex[bytes[i]] + } + + return hexOctets.join('') +} From 3cee612e555ab7300e75968d83b21ce11c099ad3 Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:10:19 +0300 Subject: [PATCH 5/8] Created `initSha()` function that using the SHA-256 algorithm from Web Crypto API (browser global and Node.js `crypto`), `crypto.createHash()` and `sha256base64()` fallback --- src/init-sha.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/init-sha.ts diff --git a/src/init-sha.ts b/src/init-sha.ts new file mode 100644 index 0000000..b919250 --- /dev/null +++ b/src/init-sha.ts @@ -0,0 +1,40 @@ +import type { HashFn } from './hash-fn.type' +import { toHex } from './to-hex' +import { sha256base64 } from './crypto/sha256' + +export const ALGORITHM = 'SHA-256' +export const MAX_LENGTH = 10 + +function getWebCryptoHash (digest: SubtleCrypto['digest']): HashFn { + const encoder = new TextEncoder() + + return message => digest(ALGORITHM, encoder.encode(message)).then(arrayBuffer => toHex(arrayBuffer).slice(0, MAX_LENGTH)) +} + +/** + * Init SHA2-256 + * @description Works with Web Crypto API, Node.js `crypto` module and JavaScript + */ +export async function initSha (): Promise { + // Web Crypto API, global: browser, Node.js >= 17.6.0 with `--experimental-global-webcrypto` flag + if (typeof crypto !== 'undefined' && crypto?.subtle) { + return getWebCryptoHash(crypto.subtle.digest) + } + + // Web Crypto API: Node.js >= 15 + try { + const { webcrypto } = await import('crypto') + return getWebCryptoHash(webcrypto.subtle.digest) + } catch (_e) { + } + + // Crypto API: Node.js + try { + const { createHash } = await import('crypto') + return message => createHash(ALGORITHM).update(message).digest('hex').slice(0, MAX_LENGTH) + } catch (_e) { + } + + // Fallback to JavaScript + return message => sha256base64(message).slice(0, MAX_LENGTH) +} From bbf06f097509ea40139f8254e3a3edbdcb67a335 Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:11:08 +0300 Subject: [PATCH 6/8] Created `useHash()` function and export it in `index.ts` --- src/index.ts | 1 + src/use-hash.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/use-hash.ts diff --git a/src/index.ts b/src/index.ts index f025816..3a8b21f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export { objectHash } from './object-hash' export { hash } from './hash' export { murmurHash } from './crypto/murmur' export { sha256 } from './crypto/sha256' +export { useHash } from './use-hash' diff --git a/src/use-hash.ts b/src/use-hash.ts new file mode 100644 index 0000000..996c8ea --- /dev/null +++ b/src/use-hash.ts @@ -0,0 +1,45 @@ +import { initSha } from './init-sha' +import { initXxHash } from './init-xx-hash' +import { HashOptions, objectHash } from './object-hash' +import type { HashFn } from './hash-fn.type' + +let hashFn: Promise | undefined + +function initHashFn () { + // Check for cached + if (hashFn) { + return hashFn + } + + try { + hashFn = initXxHash() + } catch (_e) { + hashFn = initSha() + } + return hashFn +} + +/** + * Use hash + * @description + * @example + * const hash = await useHash(); + * const key = await hash(obj); + */ +export async function useHash () { + const hashFn = await initHashFn() + + return { + /** + * Hash any JS value into a string + * @param {object} object value to hash + * @param {HashOptions} [options] hashing options + * @return {string | Promise} hash value + * @api public + */ + hash (object: unknown, options?: HashOptions) { + const hashed = typeof object === 'string' ? object : objectHash(object, options) + return hashFn(hashed) + } + } +} From c2486f8cd77227214796a2554bc1f53997871950 Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:11:42 +0300 Subject: [PATCH 7/8] Created tests for functions: `initSha()`; `initXxHash()`; `toHex()`, `useHash()` --- test/init-sha.test.ts | 21 +++++++++++++++++++++ test/init-xx-hash.test.ts | 21 +++++++++++++++++++++ test/to-hex.test.ts | 11 +++++++++++ test/use-hash.test.ts | 10 ++++++++++ 4 files changed, 63 insertions(+) create mode 100644 test/init-sha.test.ts create mode 100644 test/init-xx-hash.test.ts create mode 100644 test/to-hex.test.ts create mode 100644 test/use-hash.test.ts diff --git a/test/init-sha.test.ts b/test/init-sha.test.ts new file mode 100644 index 0000000..7d03d9d --- /dev/null +++ b/test/init-sha.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { initSha } from '../src/init-sha' + +describe('initSha()', () => { + it('should return hash function', async () => { + expect(await initSha()).toMatchInlineSnapshot('[Function]') + }) + + it('should create hash', async () => { + const hash = await initSha() + + expect(await hash('test')).toMatchInlineSnapshot('"9f86d08188"') + }) + + // See: https://github.com/unjs/ohash/issues/11 + it('should prevent `ufo` and `vue` collision', async () => { + const hash = await initSha() + + expect(await hash('vue') !== await hash('ufo')).toBeTruthy() + }) +}) diff --git a/test/init-xx-hash.test.ts b/test/init-xx-hash.test.ts new file mode 100644 index 0000000..824c122 --- /dev/null +++ b/test/init-xx-hash.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { initXxHash } from '../src/init-xx-hash' + +describe('initXxHash64()', () => { + it('should return hash function', async () => { + expect(await initXxHash()).toMatchInlineSnapshot('[Function]') + }) + + it('should create hash', async () => { + const hash = await initXxHash() + + expect(await hash('test')).toMatchInlineSnapshot('"3e2023cf"') + }) + + // See: https://github.com/unjs/ohash/issues/11 + it('should prevent `ufo` and `vue` collision', async () => { + const hash = await initXxHash() + + expect(await hash('vue') !== await hash('ufo')).toBeTruthy() + }) +}) diff --git a/test/to-hex.test.ts b/test/to-hex.test.ts new file mode 100644 index 0000000..99b9dca --- /dev/null +++ b/test/to-hex.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { toHex } from '../src/to-hex' + +describe('toHex()', () => { + it('should return hex string from ArrayBuffer', () => { + const encoder = new TextEncoder() + const arrayBuffer = encoder.encode('test').buffer + + expect(toHex(arrayBuffer)).toMatchInlineSnapshot('"74657374"') + }) +}) diff --git a/test/use-hash.test.ts b/test/use-hash.test.ts new file mode 100644 index 0000000..9e0d6eb --- /dev/null +++ b/test/use-hash.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { useHash } from '../src' + +describe('useHash()', () => { + it('should return hash function', async () => { + const { hash } = await useHash() + + expect(await hash('test')).toMatchInlineSnapshot('"3e2023cf"') + }) +}) From df1256c8385f880425b4800b84aa30fb266b41ae Mon Sep 17 00:00:00 2001 From: Mrau Hu Date: Fri, 15 Jul 2022 03:49:39 +0300 Subject: [PATCH 8/8] Fix: using base64 instead of hex string --- src/init-sha.ts | 7 ++--- src/init-xx-hash.ts | 3 ++- src/to-base64.ts | 55 +++++++++++++++++++++++++++++++++++++++ src/to-hex.ts | 23 ---------------- test/init-sha.test.ts | 2 +- test/init-xx-hash.test.ts | 2 +- test/to-base64.test.ts | 11 ++++++++ test/to-hex.test.ts | 11 -------- test/use-hash.test.ts | 2 +- 9 files changed, 75 insertions(+), 41 deletions(-) create mode 100644 src/to-base64.ts delete mode 100644 src/to-hex.ts create mode 100644 test/to-base64.test.ts delete mode 100644 test/to-hex.test.ts diff --git a/src/init-sha.ts b/src/init-sha.ts index b919250..0699979 100644 --- a/src/init-sha.ts +++ b/src/init-sha.ts @@ -1,5 +1,5 @@ import type { HashFn } from './hash-fn.type' -import { toHex } from './to-hex' +import { toBase64 } from './to-base64' import { sha256base64 } from './crypto/sha256' export const ALGORITHM = 'SHA-256' @@ -8,7 +8,8 @@ export const MAX_LENGTH = 10 function getWebCryptoHash (digest: SubtleCrypto['digest']): HashFn { const encoder = new TextEncoder() - return message => digest(ALGORITHM, encoder.encode(message)).then(arrayBuffer => toHex(arrayBuffer).slice(0, MAX_LENGTH)) + return message => digest(ALGORITHM, encoder.encode(message)) + .then(arrayBuffer => toBase64(arrayBuffer).slice(0, MAX_LENGTH)) } /** @@ -31,7 +32,7 @@ export async function initSha (): Promise { // Crypto API: Node.js try { const { createHash } = await import('crypto') - return message => createHash(ALGORITHM).update(message).digest('hex').slice(0, MAX_LENGTH) + return message => createHash(ALGORITHM).update(message).digest('base64').slice(0, MAX_LENGTH) } catch (_e) { } diff --git a/src/init-xx-hash.ts b/src/init-xx-hash.ts index 6ab83f9..8d60685 100644 --- a/src/init-xx-hash.ts +++ b/src/init-xx-hash.ts @@ -6,12 +6,13 @@ import type { HashFn } from './hash-fn.type' */ export async function initXxHash (): Promise { const { createXXHash32 } = await import('hash-wasm') + const { encodeBase64 } = await import('hash-wasm/lib/util') const hasher = await createXXHash32() return (message) => { hasher.init() hasher.update(message) - return hasher.digest() + return encodeBase64(hasher.digest('binary')) } } diff --git a/src/to-base64.ts b/src/to-base64.ts new file mode 100644 index 0000000..c378544 --- /dev/null +++ b/src/to-base64.ts @@ -0,0 +1,55 @@ +const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +export function encodeBase64 (data: Uint8Array, pad = true): string { + const len = data.length + const extraBytes = len % 3 + const parts = [] + + const len2 = len - extraBytes + for (let i = 0; i < len2; i += 3) { + const tmp = ((data[i] << 16) & 0xFF0000) + + ((data[i + 1] << 8) & 0xFF00) + + (data[i + 2] & 0xFF) + + const triplet = base64Chars.charAt((tmp >> 18) & 0x3F) + + base64Chars.charAt((tmp >> 12) & 0x3F) + + base64Chars.charAt((tmp >> 6) & 0x3F) + + base64Chars.charAt(tmp & 0x3F) + + parts.push(triplet) + } + + if (extraBytes === 1) { + const tmp = data[len - 1] + const a = base64Chars.charAt(tmp >> 2) + const b = base64Chars.charAt((tmp << 4) & 0x3F) + + parts.push(`${a}${b}`) + if (pad) { + parts.push('==') + } + } else if (extraBytes === 2) { + const tmp = (data[len - 2] << 8) + data[len - 1] + const a = base64Chars.charAt(tmp >> 10) + const b = base64Chars.charAt((tmp >> 4) & 0x3F) + const c = base64Chars.charAt((tmp << 2) & 0x3F) + parts.push(`${a}${b}${c}`) + if (pad) { + parts.push('=') + } + } + + return parts.join('') +} + +/** + * Convert ArrayBuffer to base64 string + * @see https://github.com/Daninet/hash-wasm/blob/bd3a205ca5603fc80adf71d0966fc72e8d4fa0ef/lib/util.ts#L103 + * @param {ArrayBuffer} arrayBuffer + * @return {string} Base64 + */ +export function toBase64 (arrayBuffer: ArrayBuffer) { + const bytes = new Uint8Array(arrayBuffer) + + return encodeBase64(bytes) +} diff --git a/src/to-hex.ts b/src/to-hex.ts deleted file mode 100644 index c20e673..0000000 --- a/src/to-hex.ts +++ /dev/null @@ -1,23 +0,0 @@ -const byteToHex = [] - -for (let n = 0; n <= 0xFF; ++n) { - const hexOctet = n.toString(16).padStart(2, '0') - byteToHex.push(hexOctet) -} - -/** - * Convert ArrayBuffer to hex string - * @see https://stackoverflow.com/a/55200387 - * @param {ArrayBuffer} arrayBuffer - * @return {string} Hex - */ -export function toHex (arrayBuffer: ArrayBuffer) { - const bytes = new Uint8Array(arrayBuffer) - const hexOctets = new Array(bytes.length) - - for (let i = 0; i < bytes.length; ++i) { - hexOctets[i] = byteToHex[bytes[i]] - } - - return hexOctets.join('') -} diff --git a/test/init-sha.test.ts b/test/init-sha.test.ts index 7d03d9d..93f0da8 100644 --- a/test/init-sha.test.ts +++ b/test/init-sha.test.ts @@ -9,7 +9,7 @@ describe('initSha()', () => { it('should create hash', async () => { const hash = await initSha() - expect(await hash('test')).toMatchInlineSnapshot('"9f86d08188"') + expect(await hash('test')).toMatchInlineSnapshot('"n4bQgYhMfW"') }) // See: https://github.com/unjs/ohash/issues/11 diff --git a/test/init-xx-hash.test.ts b/test/init-xx-hash.test.ts index 824c122..9897e93 100644 --- a/test/init-xx-hash.test.ts +++ b/test/init-xx-hash.test.ts @@ -9,7 +9,7 @@ describe('initXxHash64()', () => { it('should create hash', async () => { const hash = await initXxHash() - expect(await hash('test')).toMatchInlineSnapshot('"3e2023cf"') + expect(await hash('test')).toMatchInlineSnapshot('"PiAjzw=="') }) // See: https://github.com/unjs/ohash/issues/11 diff --git a/test/to-base64.test.ts b/test/to-base64.test.ts new file mode 100644 index 0000000..6d9c4fc --- /dev/null +++ b/test/to-base64.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { toBase64 } from '../src/to-base64' + +describe('toBase64()', () => { + it('should return Base64 string from ArrayBuffer', () => { + const encoder = new TextEncoder() + const arrayBuffer = encoder.encode('test').buffer + + expect(toBase64(arrayBuffer)).toMatchInlineSnapshot('"dGVzdA=="') + }) +}) diff --git a/test/to-hex.test.ts b/test/to-hex.test.ts deleted file mode 100644 index 99b9dca..0000000 --- a/test/to-hex.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { toHex } from '../src/to-hex' - -describe('toHex()', () => { - it('should return hex string from ArrayBuffer', () => { - const encoder = new TextEncoder() - const arrayBuffer = encoder.encode('test').buffer - - expect(toHex(arrayBuffer)).toMatchInlineSnapshot('"74657374"') - }) -}) diff --git a/test/use-hash.test.ts b/test/use-hash.test.ts index 9e0d6eb..0273b7e 100644 --- a/test/use-hash.test.ts +++ b/test/use-hash.test.ts @@ -5,6 +5,6 @@ describe('useHash()', () => { it('should return hash function', async () => { const { hash } = await useHash() - expect(await hash('test')).toMatchInlineSnapshot('"3e2023cf"') + expect(await hash('test')).toMatchInlineSnapshot('"PiAjzw=="') }) })