-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathintegrity.ts
More file actions
181 lines (167 loc) · 5.24 KB
/
integrity.ts
File metadata and controls
181 lines (167 loc) · 5.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
/**
* @fileoverview Integrity specification helpers for dlx downloads.
*
* Single supported format per flavor:
* - integrity: SRI with sha512 only (what npm registry returns)
* - checksum: sha256 hex (what `shasum -a 256` produces; common for
* binary release assets on GitHub)
*
* Callers may pass a {@link HashSpec} as a bare string (sniffed via
* format) or as an explicit `{ type, value }` object. The normalized
* form carried around internally is always the object.
*/
import { timingSafeEqual } from 'node:crypto'
import { hash } from '../crypto'
import {
BufferFrom,
StringPrototypeSlice,
StringPrototypeStartsWith,
TypeErrorCtor,
} from '../primordials'
/**
* Tagged union representing an expected hash.
*
* @example
* // Bare SRI (sniffed as integrity):
* 'sha512-abc...'
*
* @example
* // Bare sha256 hex (sniffed as checksum):
* 'a1b2c3...'
*
* @example
* // Explicit:
* { type: 'integrity', value: 'sha512-abc...' }
* { type: 'checksum', value: 'a1b2c3...' }
*/
export type HashSpec =
| string
| { type: 'integrity'; value: string }
| { type: 'checksum'; value: string }
/**
* Normalized internal form. Always an object.
*/
export interface NormalizedHash {
type: 'integrity' | 'checksum'
value: string
}
/**
* Both hash formats for the same bytes. Returned from downloads so callers
* can record whichever format their config uses.
*/
export interface ComputedHashes {
/** SRI integrity: `sha512-<base64>`. Matches what the npm registry returns. */
integrity: string
/** SHA-256 hex (64 chars). Matches `shasum -a 256`. */
checksum: string
}
const INTEGRITY_PREFIX = 'sha512-'
const INTEGRITY_BODY_RE = /^[A-Za-z0-9+/=]+$/
const CHECKSUM_RE = /^[a-f0-9]{64}$/i
function isIntegrityString(s: string): boolean {
if (!StringPrototypeStartsWith(s, INTEGRITY_PREFIX)) {
return false
}
const body = StringPrototypeSlice(s, INTEGRITY_PREFIX.length)
return body.length > 0 && INTEGRITY_BODY_RE.test(body)
}
function isChecksumString(s: string): boolean {
return CHECKSUM_RE.test(s)
}
/**
* Normalize a {@link HashSpec} to its canonical `{ type, value }` form.
*
* - Object form is trusted (its `value` is validated for shape).
* - Bare string matching sha512 SRI → integrity.
* - Bare string of 64 hex chars → checksum.
* - Anything else throws TypeError.
*
* @throws TypeError if the string is not a recognized format, or if an
* explicit object's value doesn't match its declared type.
*/
export function normalizeHash(spec: HashSpec): NormalizedHash {
if (typeof spec === 'object' && spec !== null) {
if (spec.type === 'integrity') {
if (!isIntegrityString(spec.value)) {
throw new TypeErrorCtor(
`Expected SRI integrity string "sha512-<base64>", got: ${spec.value}`,
)
}
return { type: 'integrity', value: spec.value }
}
if (spec.type === 'checksum') {
if (!isChecksumString(spec.value)) {
throw new TypeErrorCtor(
`Expected sha256 hex string (64 hex chars), got: ${spec.value}`,
)
}
return { type: 'checksum', value: spec.value }
}
throw new TypeErrorCtor(
`Unknown hash type: ${(spec as { type: unknown }).type}`,
)
}
if (typeof spec !== 'string') {
throw new TypeErrorCtor(
`HashSpec must be a string or { type, value } object, got: ${typeof spec}`,
)
}
if (isIntegrityString(spec)) {
return { type: 'integrity', value: spec }
}
if (isChecksumString(spec)) {
return { type: 'checksum', value: spec }
}
throw new TypeErrorCtor(
`Unrecognized hash format. Expected SRI integrity ("sha512-<base64>") or sha256 hex (64 hex chars), got: ${spec}`,
)
}
/**
* Compute both integrity (sha512 SRI) and checksum (sha256 hex) for a
* buffer of bytes.
*/
export function computeHashes(bytes: Buffer): ComputedHashes {
const integrity = `sha512-${hash('sha512', bytes, 'base64')}`
const checksum = hash('sha256', bytes, 'hex')
return { integrity, checksum }
}
/**
* Verify computed hashes against an expected {@link NormalizedHash}.
* Uses `crypto.timingSafeEqual` for constant-time comparison.
*
* @throws DlxHashMismatchError when the hash of the matching type
* doesn't match the expected value.
*/
export function verifyHash(
expected: NormalizedHash,
computed: ComputedHashes,
): void {
const actual =
expected.type === 'integrity' ? computed.integrity : computed.checksum
const expectedBuf = BufferFrom!(expected.value)
const actualBuf = BufferFrom!(actual)
if (
expectedBuf.length !== actualBuf.length ||
!timingSafeEqual(expectedBuf, actualBuf)
) {
throw new DlxHashMismatchError(expected, computed)
}
}
/**
* Thrown when an expected hash doesn't match the computed hash of the
* downloaded bytes. Carries both sides for diagnostics.
*/
export class DlxHashMismatchError extends Error {
readonly expected: NormalizedHash
readonly actual: ComputedHashes
constructor(expected: NormalizedHash, actual: ComputedHashes) {
const actualValue =
expected.type === 'integrity' ? actual.integrity : actual.checksum
super(
`Hash mismatch (${expected.type}): expected ${expected.value}, got ${actualValue}`,
)
this.name = 'DlxHashMismatchError'
this.expected = expected
this.actual = actual
}
}