codekingpro/portable-devtools
114k
1'use strict'
2
3const crypto = require('crypto')
4const { Minipass } = require('minipass')
5
6const SPEC_ALGORITHMS = ['sha512', 'sha384', 'sha256']
7const DEFAULT_ALGORITHMS = ['sha512']
8const NODE_HASHES = crypto.getHashes()
9
10// TODO: this should really be a hardcoded list of algorithms we support, rather than [a-z0-9].
11const BASE64_REGEX = /^[a-z0-9+/]+(?:=?=?)$/i
12const SRI_REGEX = /^([a-z0-9]+)-([^?]+)(\?[?\S*]*)?$/
13const STRICT_SRI_REGEX = /^([a-z0-9]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)?$/
14const VCHAR_REGEX = /^[\x21-\x7E]+$/
15
16// This is a Best Effort™ at a reasonable priority for hash algos
17const DEFAULT_PRIORITY = [
18 'md5', 'whirlpool', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
19 // TODO - it's unclear _which_ of these Node will actually use as its name for the algorithm, so we guesswork it based on the OpenSSL names.
20 'sha3', 'sha3-256', 'sha3-384', 'sha3-512', 'sha3_256', 'sha3_384', 'sha3_512',
21].filter(algo => NODE_HASHES.includes(algo))
22
23const getOptString = options => options?.length ? `?${options.join('?')}` : ''
24
25class IntegrityStream extends Minipass {
26 #emittedIntegrity
27 #emittedSize
28 #emittedVerified
29
30 constructor (opts) {
31 super()
32 this.size = 0
33 this.opts = opts
34
35 // may be overridden later, but set now for class consistency
36 this.#getOptions()
37
38 // options used for calculating stream. can't be changed.
39 if (opts?.algorithms) {
40 this.algorithms = [...opts.algorithms]
41 } else {
42 this.algorithms = [...DEFAULT_ALGORITHMS]
43 }
44 if (this.algorithm !== null && !this.algorithms.includes(this.algorithm)) {
45 this.algorithms.push(this.algorithm)
46 }
47
48 this.hashes = this.algorithms.map(crypto.createHash)
49 }
50
51 #getOptions () {
52 // For verification
53 this.sri = this.opts?.integrity ? parse(this.opts?.integrity, this.opts) : null
54 this.expectedSize = this.opts?.size
55
56 if (!this.sri) {
57 this.algorithm = null
58 } else if (this.sri.isHash) {
59 this.goodSri = true
60 this.algorithm = this.sri.algorithm
61 } else {
62 this.goodSri = !this.sri.isEmpty()
63 this.algorithm = this.sri.pickAlgorithm(this.opts)
64 }
65
66 this.digests = this.goodSri ? this.sri[this.algorithm] : null
67 this.optString = getOptString(this.opts?.options)
68 }
69
70 on (ev, handler) {
71 if (ev === 'size' && this.#emittedSize) {
72 return handler(this.#emittedSize)
73 }
74
75 if (ev === 'integrity' && this.#emittedIntegrity) {
76 return handler(this.#emittedIntegrity)
77 }
78
79 if (ev === 'verified' && this.#emittedVerified) {
80 return handler(this.#emittedVerified)
81 }
82
83 return super.on(ev, handler)
84 }
85
86 emit (ev, data) {
87 if (ev === 'end') {
88 this.#onEnd()
89 }
90 return super.emit(ev, data)
91 }
92
93 write (data) {
94 this.size += data.length
95 this.hashes.forEach(h => h.update(data))
96 return super.write(data)
97 }
98
99 #onEnd () {
100 if (!this.goodSri) {
101 this.#getOptions()
102 }
103 const newSri = parse(this.hashes.map((h, i) => {
104 return `${this.algorithms[i]}-${h.digest('base64')}${this.optString}`
105 }).join(' '), this.opts)
106 // Integrity verification mode
107 const match = this.goodSri && newSri.match(this.sri, this.opts)
108 if (typeof this.expectedSize === 'number' && this.size !== this.expectedSize) {
109 const err = new Error(`stream size mismatch when checking ${this.sri}.\n Wanted: ${this.expectedSize}\n Found: ${this.size}`)
110 err.code = 'EBADSIZE'
111 err.found = this.size
112 err.expected = this.expectedSize
113 err.sri = this.sri
114 this.emit('error', err)
115 } else if (this.sri && !match) {
116 const err = new Error(`${this.sri} integrity checksum failed when using ${this.algorithm}: wanted ${this.digests} but got ${newSri}. (${this.size} bytes)`)
117 err.code = 'EINTEGRITY'
118 err.found = newSri
119 err.expected = this.digests
120 err.algorithm = this.algorithm
121 err.sri = this.sri
122 this.emit('error', err)
123 } else {
124 this.#emittedSize = this.size
125 this.emit('size', this.size)
126 this.#emittedIntegrity = newSri
127 this.emit('integrity', newSri)
128 if (match) {
129 this.#emittedVerified = match
130 this.emit('verified', match)
131 }
132 }
133 }
134}
135
136class Hash {
137 get isHash () {
138 return true
139 }
140
141 constructor (hash, opts) {
142 const strict = opts?.strict
143 this.source = hash.trim()
144
145 // set default values so that we make V8 happy to always see a familiar object template.
146 this.digest = ''
147 this.algorithm = ''
148 this.options = []
149
150 // 3.1. Integrity metadata (called "Hash" by ssri)
151 // https://w3c.github.io/webappsec-subresource-integrity/#integrity-metadata-description
152 const match = this.source.match(
153 strict
154 ? STRICT_SRI_REGEX
155 : SRI_REGEX
156 )
157 if (!match) {
158 return
159 }
160 if (strict && !SPEC_ALGORITHMS.includes(match[1])) {
161 return
162 }
163 if (!NODE_HASHES.includes(match[1])) {
164 return
165 }
166 this.algorithm = match[1]
167 this.digest = match[2]
168
169 const rawOpts = match[3]
170 if (rawOpts) {
171 this.options = rawOpts.slice(1).split('?')
172 }
173 }
174
175 hexDigest () {
176 return this.digest && Buffer.from(this.digest, 'base64').toString('hex')
177 }
178
179 toJSON () {
180 return this.toString()
181 }
182
183 match (integrity, opts) {
184 const other = parse(integrity, opts)
185 if (!other) {
186 return false
187 }
188 if (other.isIntegrity) {
189 const algo = other.pickAlgorithm(opts, [this.algorithm])
190
191 if (!algo) {
192 return false
193 }
194
195 const foundHash = other[algo].find(hash => hash.digest === this.digest)
196
197 if (foundHash) {
198 return foundHash
199 }
200
201 return false
202 }
203 return other.digest === this.digest ? other : false
204 }
205
206 toString (opts) {
207 if (opts?.strict) {
208 // Strict mode enforces the standard as close to the foot of the letter as it can.
209 if (!(
210 // The spec has very restricted productions for algorithms.
211 // https://www.w3.org/TR/CSP2/#source-list-syntax
212 SPEC_ALGORITHMS.includes(this.algorithm) &&
213 // Usually, if someone insists on using a "different" base64, we leave it as-is, since there are multiple standards, and the specified is not a URL-safe variant.
214 // https://www.w3.org/TR/CSP2/#base64_value
215 this.digest.match(BASE64_REGEX) &&
216 // Option syntax is strictly visual chars.
217 // https://w3c.github.io/webappsec-subresource-integrity/#grammardef-option-expression
218 // https://tools.ietf.org/html/rfc5234#appendix-B.1
219 this.options.every(opt => opt.match(VCHAR_REGEX))
220 )) {
221 return ''
222 }
223 }
224 return `${this.algorithm}-${this.digest}${getOptString(this.options)}`
225 }
226}
227
228function integrityHashToString (toString, sep, opts, hashes) {
229 const toStringIsNotEmpty = toString !== ''
230
231 let shouldAddFirstSep = false
232 let complement = ''
233
234 const lastIndex = hashes.length - 1
235
236 for (let i = 0; i < lastIndex; i++) {
237 const hashString = Hash.prototype.toString.call(hashes[i], opts)
238
239 if (hashString) {
240 shouldAddFirstSep = true
241
242 complement += hashString
243 complement += sep
244 }
245 }
246
247 const finalHashString = Hash.prototype.toString.call(hashes[lastIndex], opts)
248
249 if (finalHashString) {
250 shouldAddFirstSep = true
251 complement += finalHashString
252 }
253
254 if (toStringIsNotEmpty && shouldAddFirstSep) {
255 return toString + sep + complement
256 }
257
258 return toString + complement
259}
260
261class Integrity {
262 get isIntegrity () {
263 return true
264 }
265
266 toJSON () {
267 return this.toString()
268 }
269
270 isEmpty () {
271 return Object.keys(this).length === 0
272 }
273
274 toString (opts) {
275 let sep = opts?.sep || ' '
276 let toString = ''
277
278 if (opts?.strict) {
279 // Entries must be separated by whitespace, according to spec.
280 sep = sep.replace(/\S+/g, ' ')
281
282 for (const hash of SPEC_ALGORITHMS) {
283 if (this[hash]) {
284 toString = integrityHashToString(toString, sep, opts, this[hash])
285 }
286 }
287 } else {
288 for (const hash of Object.keys(this)) {
289 toString = integrityHashToString(toString, sep, opts, this[hash])
290 }
291 }
292
293 return toString
294 }
295
296 concat (integrity, opts) {
297 const other = typeof integrity === 'string'
298 ? integrity
299 : stringify(integrity, opts)
300 return parse(`${this.toString(opts)} ${other}`, opts)
301 }
302
303 hexDigest () {
304 return parse(this, { single: true }).hexDigest()
305 }
306
307 // add additional hashes to an integrity value, but prevent *changing* an existing integrity hash.
308 merge (integrity, opts) {
309 const other = parse(integrity, opts)
310 for (const algo in other) {
311 if (this[algo]) {
312 if (!this[algo].find(hash =>
313 other[algo].find(otherhash =>
314 hash.digest === otherhash.digest))) {
315 throw new Error('hashes do not match, cannot update integrity')
316 }
317 } else {
318 this[algo] = other[algo]
319 }
320 }
321 }
322
323 match (integrity, opts) {
324 const other = parse(integrity, opts)
325 if (!other) {
326 return false
327 }
328 const algo = other.pickAlgorithm(opts, Object.keys(this))
329 return !!algo && this[algo].find(hash =>
330 other[algo].find(otherhash =>
331 hash.digest === otherhash.digest
332 )
333 ) || false
334 }
335
336 // Pick the highest priority algorithm present, optionally also limited to a set of hashes found in another integrity.
337 // When limiting it may return nothing.
338 pickAlgorithm (opts, hashes) {
339 const pickAlgorithm = opts?.pickAlgorithm || getPrioritizedHash
340 let keys = Object.keys(this)
341 if (hashes?.length) {
342 keys = keys.filter(k => hashes.includes(k))
343 }
344 if (keys.length) {
345 return keys.reduce((acc, algo) => pickAlgorithm(acc, algo) || acc)
346 }
347 // no intersection between this and hashes
348 return null
349 }
350}
351
352module.exports.parse = parse
353function parse (sri, opts) {
354 if (!sri) {
355 return null
356 }
357 if (typeof sri === 'string') {
358 return _parse(sri, opts)
359 } else if (sri.algorithm && sri.digest) {
360 const fullSri = new Integrity()
361 fullSri[sri.algorithm] = [sri]
362 return _parse(stringify(fullSri, opts), opts)
363 } else {
364 return _parse(stringify(sri, opts), opts)
365 }
366}
367
368function _parse (integrity, opts) {
369 // 3.4.3. Parse metadata
370 // https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata
371 if (opts?.single) {
372 return new Hash(integrity, opts)
373 }
374 const hashes = integrity.trim().split(/\s+/).reduce((acc, string) => {
375 const hash = new Hash(string, opts)
376 if (hash.algorithm && hash.digest) {
377 const algo = hash.algorithm
378 if (!Object.keys(acc).includes(algo)) {
379 acc[algo] = []
380 }
381 acc[algo].push(hash)
382 }
383 return acc
384 }, new Integrity())
385 return hashes.isEmpty() ? null : hashes
386}
387
388module.exports.stringify = stringify
389function stringify (obj, opts) {
390 if (obj.algorithm && obj.digest) {
391 return Hash.prototype.toString.call(obj, opts)
392 } else if (typeof obj === 'string') {
393 return stringify(parse(obj, opts), opts)
394 } else {
395 return Integrity.prototype.toString.call(obj, opts)
396 }
397}
398
399module.exports.fromHex = fromHex
400function fromHex (hexDigest, algorithm, opts) {
401 const optString = getOptString(opts?.options)
402 return parse(
403 `${algorithm}-${
404 Buffer.from(hexDigest, 'hex').toString('base64')
405 }${optString}`, opts
406 )
407}
408
409module.exports.fromData = fromData
410function fromData (data, opts) {
411 const algorithms = opts?.algorithms || [...DEFAULT_ALGORITHMS]
412 const optString = getOptString(opts?.options)
413 return algorithms.reduce((acc, algo) => {
414 const digest = crypto.createHash(algo).update(data).digest('base64')
415 const hash = new Hash(
416 `${algo}-${digest}${optString}`,
417 opts
418 )
419 // istanbul ignore else - it would be VERY strange if the string we just calculated with an algo did not have an algo or digest.
420 if (hash.algorithm && hash.digest) {
421 const hashAlgo = hash.algorithm
422 if (!acc[hashAlgo]) {
423 acc[hashAlgo] = []
424 }
425 acc[hashAlgo].push(hash)
426 }
427 return acc
428 }, new Integrity())
429}
430
431module.exports.fromStream = fromStream
432function fromStream (stream, opts) {
433 const istream = integrityStream(opts)
434 return new Promise((resolve, reject) => {
435 stream.pipe(istream)
436 stream.on('error', reject)
437 istream.on('error', reject)
438 let sri
439 istream.on('integrity', s => {
440 sri = s
441 })
442 istream.on('end', () => resolve(sri))
443 istream.resume()
444 })
445}
446
447module.exports.checkData = checkData
448function checkData (data, sri, opts) {
449 sri = parse(sri, opts)
450 if (!sri || !Object.keys(sri).length) {
451 if (opts?.error) {
452 throw Object.assign(
453 new Error('No valid integrity hashes to check against'), {
454 code: 'EINTEGRITY',
455 }
456 )
457 } else {
458 return false
459 }
460 }
461 const algorithm = sri.pickAlgorithm(opts)
462 const digest = crypto.createHash(algorithm).update(data).digest('base64')
463 const newSri = parse({ algorithm, digest })
464 const match = newSri.match(sri, opts)
465 opts = opts || {}
466 if (match || !(opts.error)) {
467 return match
468 } else if (typeof opts.size === 'number' && (data.length !== opts.size)) {
469 const err = new Error(`data size mismatch when checking ${sri}.\n Wanted: ${opts.size}\n Found: ${data.length}`)
470 err.code = 'EBADSIZE'
471 err.found = data.length
472 err.expected = opts.size
473 err.sri = sri
474 throw err
475 } else {
476 const err = new Error(`Integrity checksum failed when using ${algorithm}: Wanted ${sri}, but got ${newSri}. (${data.length} bytes)`)
477 err.code = 'EINTEGRITY'
478 err.found = newSri
479 err.expected = sri
480 err.algorithm = algorithm
481 err.sri = sri
482 throw err
483 }
484}
485
486module.exports.checkStream = checkStream
487function checkStream (stream, sri, opts) {
488 opts = opts || Object.create(null)
489 opts.integrity = sri
490 sri = parse(sri, opts)
491 if (!sri || !Object.keys(sri).length) {
492 return Promise.reject(Object.assign(
493 new Error('No valid integrity hashes to check against'), {
494 code: 'EINTEGRITY',
495 }
496 ))
497 }
498 const checker = integrityStream(opts)
499 return new Promise((resolve, reject) => {
500 stream.pipe(checker)
501 stream.on('error', reject)
502 checker.on('error', reject)
503 let verified
504 checker.on('verified', s => {
505 verified = s
506 })
507 checker.on('end', () => resolve(verified))
508 checker.resume()
509 })
510}
511
512module.exports.integrityStream = integrityStream
513function integrityStream (opts = Object.create(null)) {
514 return new IntegrityStream(opts)
515}
516
517module.exports.create = createIntegrity
518function createIntegrity (opts) {
519 const algorithms = opts?.algorithms || [...DEFAULT_ALGORITHMS]
520 const optString = getOptString(opts?.options)
521
522 const hashes = algorithms.map(crypto.createHash)
523
524 return {
525 update: function (chunk, enc) {
526 hashes.forEach(h => h.update(chunk, enc))
527 return this
528 },
529 digest: function () {
530 const integrity = algorithms.reduce((acc, algo) => {
531 const digest = hashes.shift().digest('base64')
532 const hash = new Hash(`${algo}-${digest}${optString}`, opts)
533 if (!acc[hash.algorithm]) {
534 acc[hash.algorithm] = []
535 }
536 acc[hash.algorithm].push(hash)
537 return acc
538 }, new Integrity())
539
540 return integrity
541 },
542 }
543}
544
545function getPrioritizedHash (algo1, algo2) {
546 /* eslint-disable-next-line max-len */
547 return DEFAULT_PRIORITY.indexOf(algo1.toLowerCase()) >= DEFAULT_PRIORITY.indexOf(algo2.toLowerCase())
548 ? algo1
549 : algo2
550}
551 