From 64a3d68f517a3cac4f00c9a76a25f9c532db1fb3 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 22 Jul 2025 11:24:51 +0200 Subject: [PATCH 1/9] feat: support truncated hashes Allow truncating message digests (see 5.1 of [Recommendation for Applications Using Approved Hash Algorithms](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-107r1.pdf)) within sensible limits. Closes #328 --- src/hashes/hasher.ts | 71 ++++++++++++++++++++++++++++++++----- src/hashes/sha1-browser.ts | 3 +- src/hashes/sha1.ts | 3 +- src/hashes/sha2-browser.ts | 6 ++-- src/hashes/sha2.ts | 6 ++-- test/test-multihash.spec.ts | 52 +++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 5202a176..480075e9 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -3,8 +3,35 @@ import type { MultihashHasher } from './interface.js' type Await = Promise | T -export function from ({ name, code, encode }: { name: Name, code: Code, encode(input: Uint8Array): Await }): Hasher { - return new Hasher(name, code, encode) +const DEFAULT_MIN_DIGEST_LENGTH = 20 +const DEFAULT_MAX_DIGEST_LENGTH = 128 + +export interface HasherInit { + name: Name + code: Code + encode(input: Uint8Array): Await + + /** + * The minimum length a hash is allowed to be in bytes + * + * @default 20 + */ + minDigestLength?: number + + /** + * The maximum length a hash is allowed to be in bytes + * + * @default 128 + */ + maxDigestLength?: number +} + +export function from ({ name, code, encode, minDigestLength, maxDigestLength }: HasherInit): Hasher { + return new Hasher(name, code, encode, minDigestLength, maxDigestLength) +} + +export interface DigestOptions { + truncate?: number } /** @@ -15,20 +42,46 @@ export class Hasher implements Multiha readonly name: Name readonly code: Code readonly encode: (input: Uint8Array) => Await + readonly minDigestLength: number + readonly maxDigestLength: number - constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await) { + constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await, minDigestLength?: number, maxDigestLength?: number) { this.name = name this.code = code this.encode = encode + this.minDigestLength = minDigestLength ?? DEFAULT_MIN_DIGEST_LENGTH + this.maxDigestLength = maxDigestLength ?? DEFAULT_MAX_DIGEST_LENGTH } - digest (input: Uint8Array): Await> { + digest (input: Uint8Array, options?: DigestOptions): Await> { + if (options?.truncate != null) { + if (options.truncate < this.minDigestLength) { + throw new Error(`Invalid truncate option, must be greater than or equal to ${this.minDigestLength}`) + } + + if (options.truncate > this.maxDigestLength) { + throw new Error(`Invalid truncate option, must be less than or equal to ${this.maxDigestLength}`) + } + } + if (input instanceof Uint8Array) { - const result = this.encode(input) - return result instanceof Uint8Array - ? Digest.create(this.code, result) - /* c8 ignore next 1 */ - : result.then(digest => Digest.create(this.code, digest)) + let result = this.encode(input) + + if (result instanceof Uint8Array) { + if (options?.truncate != null) { + result = result.subarray(0, options.truncate) + } + + return Digest.create(this.code, result) + } + + return result.then(digest => { + if (options?.truncate != null) { + digest = digest.subarray(0, options.truncate) + } + + return Digest.create(this.code, digest) + }) } else { throw Error('Unknown type, must be binary type') /* c8 ignore next 1 */ diff --git a/src/hashes/sha1-browser.ts b/src/hashes/sha1-browser.ts index 6764ba8e..a26cf3ba 100644 --- a/src/hashes/sha1-browser.ts +++ b/src/hashes/sha1-browser.ts @@ -8,5 +8,6 @@ const sha = (name: AlgorithmIdentifier) => export const sha1 = from({ name: 'sha-1', code: 0x11, - encode: sha('SHA-1') + encode: sha('SHA-1'), + maxDigestLength: 20 }) diff --git a/src/hashes/sha1.ts b/src/hashes/sha1.ts index f8480519..9015c332 100644 --- a/src/hashes/sha1.ts +++ b/src/hashes/sha1.ts @@ -5,5 +5,6 @@ import { from } from './hasher.js' export const sha1 = from({ name: 'sha-1', code: 0x11, - encode: (input) => coerce(crypto.createHash('sha1').update(input).digest()) + encode: (input) => coerce(crypto.createHash('sha1').update(input).digest()), + maxDigestLength: 20 }) diff --git a/src/hashes/sha2-browser.ts b/src/hashes/sha2-browser.ts index cbf0b7b8..2f4cc583 100644 --- a/src/hashes/sha2-browser.ts +++ b/src/hashes/sha2-browser.ts @@ -9,11 +9,13 @@ function sha (name: AlgorithmIdentifier): (data: Uint8Array) => Promise coerce(crypto.createHash('sha256').update(input).digest()) + encode: (input) => coerce(crypto.createHash('sha256').update(input).digest()), + maxDigestLength: 32 }) export const sha512 = from({ name: 'sha2-512', code: 0x13, - encode: input => coerce(crypto.createHash('sha512').update(input).digest()) + encode: input => coerce(crypto.createHash('sha512').update(input).digest()), + maxDigestLength: 64 }) diff --git a/test/test-multihash.spec.ts b/test/test-multihash.spec.ts index bf0d1e33..91d16644 100644 --- a/test/test-multihash.spec.ts +++ b/test/test-multihash.spec.ts @@ -71,6 +71,32 @@ describe('multihash', () => { assert.deepStrictEqual(hash2.bytes, hash.bytes) }) + it('hash sha2-256 truncated', async () => { + const hash = await sha256.digest(fromString('test'), { + truncate: 24 + }) + assert.deepStrictEqual(hash.code, sha256.code) + assert.deepStrictEqual(hash.digest.byteLength, 24) + + const hash2 = decodeDigest(hash.bytes) + assert.deepStrictEqual(hash2.code, sha256.code) + assert.deepStrictEqual(hash2.bytes, hash.bytes) + }) + + it('hash sha2-256 truncated (invalid option)', async () => { + assert.throws(() => { + void sha256.digest(fromString('test'), { + truncate: 10 + }) + }, /Invalid truncate option/) + + assert.throws(() => { + void sha256.digest(fromString('test'), { + truncate: 64 + }) + }, /Invalid truncate option/) + }) + if (typeof navigator === 'undefined') { it('sync sha-256', () => { const hash = sha256.digest(fromString('test')) @@ -97,6 +123,32 @@ describe('multihash', () => { assert.deepStrictEqual(hash2.bytes, hash.bytes) }) + it('hash sha2-512 truncated', async () => { + const hash = await sha512.digest(fromString('test'), { + truncate: 32 + }) + assert.deepStrictEqual(hash.code, sha512.code) + assert.deepStrictEqual(hash.digest.byteLength, 32) + + const hash2 = decodeDigest(hash.bytes) + assert.deepStrictEqual(hash2.code, sha512.code) + assert.deepStrictEqual(hash2.bytes, hash.bytes) + }) + + it('hash sha2-512 truncated (invalid option)', async () => { + assert.throws(() => { + void sha512.digest(fromString('test'), { + truncate: 10 + }) + }, /Invalid truncate option/) + + assert.throws(() => { + void sha512.digest(fromString('test'), { + truncate: 100 + }) + }, /Invalid truncate option/) + }) + it('hash identity async', async () => { // eslint-disable-next-line @typescript-eslint/await-thenable const hash = await identity.digest(fromString('test')) From b55d072f7ad2adaffe4db302050030e3214a8f31 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 22 Jul 2025 13:25:42 +0200 Subject: [PATCH 2/9] chore: simplify --- src/hashes/hasher.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 480075e9..91cc9659 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -65,26 +65,28 @@ export class Hasher implements Multiha } if (input instanceof Uint8Array) { - let result = this.encode(input) + const result = this.encode(input) if (result instanceof Uint8Array) { - if (options?.truncate != null) { - result = result.subarray(0, options.truncate) - } - - return Digest.create(this.code, result) + return truncateDigest(result, this.code, options?.truncate) } - return result.then(digest => { - if (options?.truncate != null) { - digest = digest.subarray(0, options.truncate) - } - - return Digest.create(this.code, digest) - }) + return result.then(digest => truncateDigest(digest, this.code, options?.truncate)) } else { throw Error('Unknown type, must be binary type') /* c8 ignore next 1 */ } } } + +function truncateDigest (digest: Uint8Array, code: Code, truncate?: number): Digest.Digest { + if (truncate != null && truncate !== digest.byteLength) { + if (truncate > digest.byteLength) { + throw new Error(`Invalid truncate option, must be less than or equal to ${digest.byteLength}`) + } + + digest = digest.subarray(0, truncate) + } + + return Digest.create(code, digest) +} From d42373bdb944421ebbe550bb1c963a92a8d7d6b4 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 22 Jul 2025 13:30:01 +0200 Subject: [PATCH 3/9] chore: add comment --- src/hashes/hasher.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 91cc9659..6df4e39d 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -68,10 +68,10 @@ export class Hasher implements Multiha const result = this.encode(input) if (result instanceof Uint8Array) { - return truncateDigest(result, this.code, options?.truncate) + return createDigest(result, this.code, options?.truncate) } - return result.then(digest => truncateDigest(digest, this.code, options?.truncate)) + return result.then(digest => createDigest(digest, this.code, options?.truncate)) } else { throw Error('Unknown type, must be binary type') /* c8 ignore next 1 */ @@ -79,7 +79,11 @@ export class Hasher implements Multiha } } -function truncateDigest (digest: Uint8Array, code: Code, truncate?: number): Digest.Digest { +/** + * Create a Digest from the passed uint8array and code, optionally truncating it + * first. + */ +function createDigest (digest: Uint8Array, code: Code, truncate?: number): Digest.Digest { if (truncate != null && truncate !== digest.byteLength) { if (truncate > digest.byteLength) { throw new Error(`Invalid truncate option, must be less than or equal to ${digest.byteLength}`) From 161d7812d237b43d91b0291fae3409a225b9c14b Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 23 Jul 2025 12:53:13 +0200 Subject: [PATCH 4/9] chore: make max hash optional --- src/hashes/hasher.ts | 14 ++++++-------- src/hashes/sha1.ts | 3 +-- src/hashes/sha2-browser.ts | 6 ++---- src/hashes/sha2.ts | 6 ++---- test/test-multihash.spec.ts | 24 ++++++++++++------------ 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 6df4e39d..94277a1a 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -4,7 +4,6 @@ import type { MultihashHasher } from './interface.js' type Await = Promise | T const DEFAULT_MIN_DIGEST_LENGTH = 20 -const DEFAULT_MAX_DIGEST_LENGTH = 128 export interface HasherInit { name: Name @@ -12,16 +11,15 @@ export interface HasherInit { encode(input: Uint8Array): Await /** - * The minimum length a hash is allowed to be in bytes + * The minimum length a hash is allowed to be truncated to in bytes * * @default 20 */ minDigestLength?: number /** - * The maximum length a hash is allowed to be in bytes - * - * @default 128 + * The maximum length a hash is allowed to be truncated to in bytes. If not + * specified it will be inferred from the length of the digest. */ maxDigestLength?: number } @@ -43,14 +41,14 @@ export class Hasher implements Multiha readonly code: Code readonly encode: (input: Uint8Array) => Await readonly minDigestLength: number - readonly maxDigestLength: number + readonly maxDigestLength?: number constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await, minDigestLength?: number, maxDigestLength?: number) { this.name = name this.code = code this.encode = encode this.minDigestLength = minDigestLength ?? DEFAULT_MIN_DIGEST_LENGTH - this.maxDigestLength = maxDigestLength ?? DEFAULT_MAX_DIGEST_LENGTH + this.maxDigestLength = maxDigestLength } digest (input: Uint8Array, options?: DigestOptions): Await> { @@ -59,7 +57,7 @@ export class Hasher implements Multiha throw new Error(`Invalid truncate option, must be greater than or equal to ${this.minDigestLength}`) } - if (options.truncate > this.maxDigestLength) { + if (this.maxDigestLength != null && options.truncate > this.maxDigestLength) { throw new Error(`Invalid truncate option, must be less than or equal to ${this.maxDigestLength}`) } } diff --git a/src/hashes/sha1.ts b/src/hashes/sha1.ts index 9015c332..f8480519 100644 --- a/src/hashes/sha1.ts +++ b/src/hashes/sha1.ts @@ -5,6 +5,5 @@ import { from } from './hasher.js' export const sha1 = from({ name: 'sha-1', code: 0x11, - encode: (input) => coerce(crypto.createHash('sha1').update(input).digest()), - maxDigestLength: 20 + encode: (input) => coerce(crypto.createHash('sha1').update(input).digest()) }) diff --git a/src/hashes/sha2-browser.ts b/src/hashes/sha2-browser.ts index 2f4cc583..cbf0b7b8 100644 --- a/src/hashes/sha2-browser.ts +++ b/src/hashes/sha2-browser.ts @@ -9,13 +9,11 @@ function sha (name: AlgorithmIdentifier): (data: Uint8Array) => Promise coerce(crypto.createHash('sha256').update(input).digest()), - maxDigestLength: 32 + encode: (input) => coerce(crypto.createHash('sha256').update(input).digest()) }) export const sha512 = from({ name: 'sha2-512', code: 0x13, - encode: input => coerce(crypto.createHash('sha512').update(input).digest()), - maxDigestLength: 64 + encode: input => coerce(crypto.createHash('sha512').update(input).digest()) }) diff --git a/test/test-multihash.spec.ts b/test/test-multihash.spec.ts index 91d16644..52c66638 100644 --- a/test/test-multihash.spec.ts +++ b/test/test-multihash.spec.ts @@ -84,17 +84,17 @@ describe('multihash', () => { }) it('hash sha2-256 truncated (invalid option)', async () => { - assert.throws(() => { - void sha256.digest(fromString('test'), { + await assert.isRejected((async () => { + await sha256.digest(fromString('test'), { truncate: 10 }) - }, /Invalid truncate option/) + })(), /Invalid truncate option/) - assert.throws(() => { - void sha256.digest(fromString('test'), { + await assert.isRejected((async () => { + await sha256.digest(fromString('test'), { truncate: 64 }) - }, /Invalid truncate option/) + })(), /Invalid truncate option/) }) if (typeof navigator === 'undefined') { @@ -136,17 +136,17 @@ describe('multihash', () => { }) it('hash sha2-512 truncated (invalid option)', async () => { - assert.throws(() => { - void sha512.digest(fromString('test'), { + await assert.isRejected((async () => { + await sha512.digest(fromString('test'), { truncate: 10 }) - }, /Invalid truncate option/) + })(), /Invalid truncate option/) - assert.throws(() => { - void sha512.digest(fromString('test'), { + await assert.isRejected((async () => { + await sha512.digest(fromString('test'), { truncate: 100 }) - }, /Invalid truncate option/) + })(), /Invalid truncate option/) }) it('hash identity async', async () => { From 2a32fe79ab929ab933731cfb84b0791ec839886c Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 23 Jul 2025 12:53:53 +0200 Subject: [PATCH 5/9] chore: remove redundant option --- src/hashes/sha1-browser.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hashes/sha1-browser.ts b/src/hashes/sha1-browser.ts index a26cf3ba..6764ba8e 100644 --- a/src/hashes/sha1-browser.ts +++ b/src/hashes/sha1-browser.ts @@ -8,6 +8,5 @@ const sha = (name: AlgorithmIdentifier) => export const sha1 = from({ name: 'sha-1', code: 0x11, - encode: sha('SHA-1'), - maxDigestLength: 20 + encode: sha('SHA-1') }) From 01ae0bb8999bbd33a4f4b91fde21a480dd82845a Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 23 Jul 2025 12:57:27 +0200 Subject: [PATCH 6/9] chore: add jsdoc --- src/hashes/hasher.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 94277a1a..b711da2b 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -29,6 +29,16 @@ export function from ({ name, code, e } export interface DigestOptions { + /** + * Truncate the returned digest to this number of bytes. + * + * This may cause the digest method to throw/reject if the passed value is + * greater than the digest length or below a threshold under which the risk of + * hash collisions is significant. + * + * The actual value of this threshold can depend on the hashing algorithm in + * use. + */ truncate?: number } From cba078d3d3f0ff9d1fc91184ec4e55e4c601ccf1 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 29 Jul 2025 15:28:23 +0200 Subject: [PATCH 7/9] fix: allow truncating identity hashes --- src/hashes/identity.ts | 18 ++++++++++++++++-- test/test-multihash.spec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/hashes/identity.ts b/src/hashes/identity.ts index cacfef8e..bec524ac 100644 --- a/src/hashes/identity.ts +++ b/src/hashes/identity.ts @@ -1,13 +1,27 @@ import { coerce } from '../bytes.js' import * as Digest from './digest.js' +import type { DigestOptions } from './hasher.ts' const code: 0x0 = 0x0 const name = 'identity' const encode: (input: Uint8Array) => Uint8Array = coerce -function digest (input: Uint8Array): Digest.Digest { +function digest (input: Uint8Array, options?: DigestOptions): Digest.Digest { + if (options?.truncate != null && options.truncate !== input.byteLength) { + if (options.truncate < 0 || options.truncate > input.byteLength) { + throw new Error(`Invalid truncate option, must be less than or equal to ${input.byteLength}`) + } + + input = input.subarray(0, options.truncate) + } + return Digest.create(code, encode(input)) } -export const identity = { code, name, encode, digest } +export const identity = { + code, + name, + encode, + digest +} diff --git a/test/test-multihash.spec.ts b/test/test-multihash.spec.ts index 52c66638..31a57bcd 100644 --- a/test/test-multihash.spec.ts +++ b/test/test-multihash.spec.ts @@ -171,6 +171,32 @@ describe('multihash', () => { assert.deepStrictEqual(hash2.code, identity.code) assert.deepStrictEqual(hash2.bytes, hash.bytes) }) + + it('hash identity truncated', async () => { + const hash = identity.digest(fromString('test'), { + truncate: 2 + }) + assert.deepStrictEqual(hash.code, identity.code) + assert.deepStrictEqual(hash.digest.byteLength, 2) + + const hash2 = decodeDigest(hash.bytes) + assert.deepStrictEqual(hash2.code, identity.code) + assert.deepStrictEqual(hash2.bytes, hash.bytes) + }) + + it('hash identity truncated (invalid option)', async () => { + assert.throws(() => { + identity.digest(fromString('test'), { + truncate: -1 + }) + }, /Invalid truncate option/) + + assert.throws(() => { + identity.digest(fromString('test'), { + truncate: 100 + }) + }, /Invalid truncate option/) + }) }) describe('decode', () => { for (const { encoding, hex, size } of valid) { From 71db8d2fa774cff24e6cc5b204b62fc0a3641415 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 29 Jul 2025 15:37:19 +0200 Subject: [PATCH 8/9] chore: update interfaces --- src/hashes/identity.ts | 7 +------ src/hashes/interface.ts | 6 ++++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/hashes/identity.ts b/src/hashes/identity.ts index bec524ac..c9a26873 100644 --- a/src/hashes/identity.ts +++ b/src/hashes/identity.ts @@ -19,9 +19,4 @@ function digest (input: Uint8Array, options?: DigestOptions): Digest.Digest { * while performance critical code may asses return value to decide whether * await is needed. */ - digest(input: Uint8Array): Promise> | MultihashDigest + digest(input: Uint8Array, options?: DigestOptions): Promise> | MultihashDigest /** * Name of the multihash @@ -66,5 +68,5 @@ export interface MultihashHasher { * impractical e.g. implementation of Hash Array Mapped Trie (HAMT). */ export interface SyncMultihashHasher extends MultihashHasher { - digest(input: Uint8Array): MultihashDigest + digest(input: Uint8Array, options?: DigestOptions): MultihashDigest } From f250f45c78a0088563d2c1928e2ff5dbc761ab6b Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 29 Jul 2025 15:46:46 +0200 Subject: [PATCH 9/9] chore: linting again --- src/hashes/identity.ts | 2 +- src/hashes/interface.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hashes/identity.ts b/src/hashes/identity.ts index c9a26873..d13d575b 100644 --- a/src/hashes/identity.ts +++ b/src/hashes/identity.ts @@ -1,6 +1,6 @@ import { coerce } from '../bytes.js' import * as Digest from './digest.js' -import type { DigestOptions } from './hasher.ts' +import type { DigestOptions } from './hasher.js' const code: 0x0 = 0x0 const name = 'identity' diff --git a/src/hashes/interface.ts b/src/hashes/interface.ts index f1874aeb..92c4be09 100644 --- a/src/hashes/interface.ts +++ b/src/hashes/interface.ts @@ -1,6 +1,6 @@ // # Multihash -import type { DigestOptions } from "./hasher.ts" +import type { DigestOptions } from './hasher.js' /** * Represents a multihash digest which carries information about the