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