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