diff --git a/src/hashes/hasher.ts b/src/hashes/hasher.ts index 5202a176..b711da2b 100644 --- a/src/hashes/hasher.ts +++ b/src/hashes/hasher.ts @@ -3,8 +3,43 @@ 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 + +export interface HasherInit { + name: Name + code: Code + encode(input: Uint8Array): Await + + /** + * 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 truncated to in bytes. If not + * specified it will be inferred from the length of the digest. + */ + maxDigestLength?: number +} + +export function from ({ name, code, encode, minDigestLength, maxDigestLength }: HasherInit): Hasher { + return new Hasher(name, code, encode, minDigestLength, maxDigestLength) +} + +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 } /** @@ -15,23 +50,55 @@ 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 } - 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 (this.maxDigestLength != null && 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)) + + if (result instanceof Uint8Array) { + return createDigest(result, 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 */ } } } + +/** + * 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}`) + } + + digest = digest.subarray(0, truncate) + } + + return Digest.create(code, digest) +} diff --git a/src/hashes/identity.ts b/src/hashes/identity.ts index cacfef8e..d13d575b 100644 --- a/src/hashes/identity.ts +++ b/src/hashes/identity.ts @@ -1,12 +1,21 @@ import { coerce } from '../bytes.js' import * as Digest from './digest.js' +import type { DigestOptions } from './hasher.js' 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)) } diff --git a/src/hashes/interface.ts b/src/hashes/interface.ts index a5b94536..92c4be09 100644 --- a/src/hashes/interface.ts +++ b/src/hashes/interface.ts @@ -1,5 +1,7 @@ // # Multihash +import type { DigestOptions } from './hasher.js' + /** * Represents a multihash digest which carries information about the * hashing algorithm and an actual hash digest. @@ -42,7 +44,7 @@ export interface MultihashHasher { * 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 } diff --git a/test/test-multihash.spec.ts b/test/test-multihash.spec.ts index bf0d1e33..31a57bcd 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 () => { + await assert.isRejected((async () => { + await sha256.digest(fromString('test'), { + truncate: 10 + }) + })(), /Invalid truncate option/) + + await assert.isRejected((async () => { + await 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 () => { + await assert.isRejected((async () => { + await sha512.digest(fromString('test'), { + truncate: 10 + }) + })(), /Invalid truncate option/) + + await assert.isRejected((async () => { + await 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')) @@ -119,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) {