Skip to content

Commit e4d3a22

Browse files
authored
feat: support truncating digests (#329)
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
1 parent b973870 commit e4d3a22

File tree

4 files changed

+167
-11
lines changed

4 files changed

+167
-11
lines changed

src/hashes/hasher.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,43 @@ import type { MultihashHasher } from './interface.js'
33

44
type Await<T> = Promise<T> | T
55

6-
export function from <Name extends string, Code extends number> ({ name, code, encode }: { name: Name, code: Code, encode(input: Uint8Array): Await<Uint8Array> }): Hasher<Name, Code> {
7-
return new Hasher(name, code, encode)
6+
const DEFAULT_MIN_DIGEST_LENGTH = 20
7+
8+
export interface HasherInit <Name extends string, Code extends number> {
9+
name: Name
10+
code: Code
11+
encode(input: Uint8Array): Await<Uint8Array>
12+
13+
/**
14+
* The minimum length a hash is allowed to be truncated to in bytes
15+
*
16+
* @default 20
17+
*/
18+
minDigestLength?: number
19+
20+
/**
21+
* The maximum length a hash is allowed to be truncated to in bytes. If not
22+
* specified it will be inferred from the length of the digest.
23+
*/
24+
maxDigestLength?: number
25+
}
26+
27+
export function from <Name extends string, Code extends number> ({ name, code, encode, minDigestLength, maxDigestLength }: HasherInit<Name, Code>): Hasher<Name, Code> {
28+
return new Hasher(name, code, encode, minDigestLength, maxDigestLength)
29+
}
30+
31+
export interface DigestOptions {
32+
/**
33+
* Truncate the returned digest to this number of bytes.
34+
*
35+
* This may cause the digest method to throw/reject if the passed value is
36+
* greater than the digest length or below a threshold under which the risk of
37+
* hash collisions is significant.
38+
*
39+
* The actual value of this threshold can depend on the hashing algorithm in
40+
* use.
41+
*/
42+
truncate?: number
843
}
944

1045
/**
@@ -15,23 +50,55 @@ export class Hasher<Name extends string, Code extends number> implements Multiha
1550
readonly name: Name
1651
readonly code: Code
1752
readonly encode: (input: Uint8Array) => Await<Uint8Array>
53+
readonly minDigestLength: number
54+
readonly maxDigestLength?: number
1855

19-
constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await<Uint8Array>) {
56+
constructor (name: Name, code: Code, encode: (input: Uint8Array) => Await<Uint8Array>, minDigestLength?: number, maxDigestLength?: number) {
2057
this.name = name
2158
this.code = code
2259
this.encode = encode
60+
this.minDigestLength = minDigestLength ?? DEFAULT_MIN_DIGEST_LENGTH
61+
this.maxDigestLength = maxDigestLength
2362
}
2463

25-
digest (input: Uint8Array): Await<Digest.Digest<Code, number>> {
64+
digest (input: Uint8Array, options?: DigestOptions): Await<Digest.Digest<Code, number>> {
65+
if (options?.truncate != null) {
66+
if (options.truncate < this.minDigestLength) {
67+
throw new Error(`Invalid truncate option, must be greater than or equal to ${this.minDigestLength}`)
68+
}
69+
70+
if (this.maxDigestLength != null && options.truncate > this.maxDigestLength) {
71+
throw new Error(`Invalid truncate option, must be less than or equal to ${this.maxDigestLength}`)
72+
}
73+
}
74+
2675
if (input instanceof Uint8Array) {
2776
const result = this.encode(input)
28-
return result instanceof Uint8Array
29-
? Digest.create(this.code, result)
30-
/* c8 ignore next 1 */
31-
: result.then(digest => Digest.create(this.code, digest))
77+
78+
if (result instanceof Uint8Array) {
79+
return createDigest(result, this.code, options?.truncate)
80+
}
81+
82+
return result.then(digest => createDigest(digest, this.code, options?.truncate))
3283
} else {
3384
throw Error('Unknown type, must be binary type')
3485
/* c8 ignore next 1 */
3586
}
3687
}
3788
}
89+
90+
/**
91+
* Create a Digest from the passed uint8array and code, optionally truncating it
92+
* first.
93+
*/
94+
function createDigest <Code extends number> (digest: Uint8Array, code: Code, truncate?: number): Digest.Digest<Code, number> {
95+
if (truncate != null && truncate !== digest.byteLength) {
96+
if (truncate > digest.byteLength) {
97+
throw new Error(`Invalid truncate option, must be less than or equal to ${digest.byteLength}`)
98+
}
99+
100+
digest = digest.subarray(0, truncate)
101+
}
102+
103+
return Digest.create(code, digest)
104+
}

src/hashes/identity.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { coerce } from '../bytes.js'
22
import * as Digest from './digest.js'
3+
import type { DigestOptions } from './hasher.js'
34

45
const code: 0x0 = 0x0
56
const name = 'identity'
67

78
const encode: (input: Uint8Array) => Uint8Array = coerce
89

9-
function digest (input: Uint8Array): Digest.Digest<typeof code, number> {
10+
function digest (input: Uint8Array, options?: DigestOptions): Digest.Digest<typeof code, number> {
11+
if (options?.truncate != null && options.truncate !== input.byteLength) {
12+
if (options.truncate < 0 || options.truncate > input.byteLength) {
13+
throw new Error(`Invalid truncate option, must be less than or equal to ${input.byteLength}`)
14+
}
15+
16+
input = input.subarray(0, options.truncate)
17+
}
18+
1019
return Digest.create(code, encode(input))
1120
}
1221

src/hashes/interface.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// # Multihash
22

3+
import type { DigestOptions } from './hasher.js'
4+
35
/**
46
* Represents a multihash digest which carries information about the
57
* hashing algorithm and an actual hash digest.
@@ -42,7 +44,7 @@ export interface MultihashHasher<Code extends number = number> {
4244
* while performance critical code may asses return value to decide whether
4345
* await is needed.
4446
*/
45-
digest(input: Uint8Array): Promise<MultihashDigest<Code>> | MultihashDigest<Code>
47+
digest(input: Uint8Array, options?: DigestOptions): Promise<MultihashDigest<Code>> | MultihashDigest<Code>
4648

4749
/**
4850
* Name of the multihash
@@ -66,5 +68,5 @@ export interface MultihashHasher<Code extends number = number> {
6668
* impractical e.g. implementation of Hash Array Mapped Trie (HAMT).
6769
*/
6870
export interface SyncMultihashHasher<Code extends number = number> extends MultihashHasher<Code> {
69-
digest(input: Uint8Array): MultihashDigest<Code>
71+
digest(input: Uint8Array, options?: DigestOptions): MultihashDigest<Code>
7072
}

test/test-multihash.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,32 @@ describe('multihash', () => {
7171
assert.deepStrictEqual(hash2.bytes, hash.bytes)
7272
})
7373

74+
it('hash sha2-256 truncated', async () => {
75+
const hash = await sha256.digest(fromString('test'), {
76+
truncate: 24
77+
})
78+
assert.deepStrictEqual(hash.code, sha256.code)
79+
assert.deepStrictEqual(hash.digest.byteLength, 24)
80+
81+
const hash2 = decodeDigest(hash.bytes)
82+
assert.deepStrictEqual(hash2.code, sha256.code)
83+
assert.deepStrictEqual(hash2.bytes, hash.bytes)
84+
})
85+
86+
it('hash sha2-256 truncated (invalid option)', async () => {
87+
await assert.isRejected((async () => {
88+
await sha256.digest(fromString('test'), {
89+
truncate: 10
90+
})
91+
})(), /Invalid truncate option/)
92+
93+
await assert.isRejected((async () => {
94+
await sha256.digest(fromString('test'), {
95+
truncate: 64
96+
})
97+
})(), /Invalid truncate option/)
98+
})
99+
74100
if (typeof navigator === 'undefined') {
75101
it('sync sha-256', () => {
76102
const hash = sha256.digest(fromString('test'))
@@ -97,6 +123,32 @@ describe('multihash', () => {
97123
assert.deepStrictEqual(hash2.bytes, hash.bytes)
98124
})
99125

126+
it('hash sha2-512 truncated', async () => {
127+
const hash = await sha512.digest(fromString('test'), {
128+
truncate: 32
129+
})
130+
assert.deepStrictEqual(hash.code, sha512.code)
131+
assert.deepStrictEqual(hash.digest.byteLength, 32)
132+
133+
const hash2 = decodeDigest(hash.bytes)
134+
assert.deepStrictEqual(hash2.code, sha512.code)
135+
assert.deepStrictEqual(hash2.bytes, hash.bytes)
136+
})
137+
138+
it('hash sha2-512 truncated (invalid option)', async () => {
139+
await assert.isRejected((async () => {
140+
await sha512.digest(fromString('test'), {
141+
truncate: 10
142+
})
143+
})(), /Invalid truncate option/)
144+
145+
await assert.isRejected((async () => {
146+
await sha512.digest(fromString('test'), {
147+
truncate: 100
148+
})
149+
})(), /Invalid truncate option/)
150+
})
151+
100152
it('hash identity async', async () => {
101153
// eslint-disable-next-line @typescript-eslint/await-thenable
102154
const hash = await identity.digest(fromString('test'))
@@ -119,6 +171,32 @@ describe('multihash', () => {
119171
assert.deepStrictEqual(hash2.code, identity.code)
120172
assert.deepStrictEqual(hash2.bytes, hash.bytes)
121173
})
174+
175+
it('hash identity truncated', async () => {
176+
const hash = identity.digest(fromString('test'), {
177+
truncate: 2
178+
})
179+
assert.deepStrictEqual(hash.code, identity.code)
180+
assert.deepStrictEqual(hash.digest.byteLength, 2)
181+
182+
const hash2 = decodeDigest(hash.bytes)
183+
assert.deepStrictEqual(hash2.code, identity.code)
184+
assert.deepStrictEqual(hash2.bytes, hash.bytes)
185+
})
186+
187+
it('hash identity truncated (invalid option)', async () => {
188+
assert.throws(() => {
189+
identity.digest(fromString('test'), {
190+
truncate: -1
191+
})
192+
}, /Invalid truncate option/)
193+
194+
assert.throws(() => {
195+
identity.digest(fromString('test'), {
196+
truncate: 100
197+
})
198+
}, /Invalid truncate option/)
199+
})
122200
})
123201
describe('decode', () => {
124202
for (const { encoding, hex, size } of valid) {

0 commit comments

Comments
 (0)