index.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. 'use strict'
  2. const crypto = require('crypto')
  3. const figgyPudding = require('figgy-pudding')
  4. const MiniPass = require('minipass')
  5. const SPEC_ALGORITHMS = ['sha256', 'sha384', 'sha512']
  6. const BASE64_REGEX = /^[a-z0-9+/]+(?:=?=?)$/i
  7. const SRI_REGEX = /^([^-]+)-([^?]+)([?\S*]*)$/
  8. const STRICT_SRI_REGEX = /^([^-]+)-([A-Za-z0-9+/=]{44,88})(\?[\x21-\x7E]*)?$/
  9. const VCHAR_REGEX = /^[\x21-\x7E]+$/
  10. const SsriOpts = figgyPudding({
  11. algorithms: { default: ['sha512'] },
  12. error: { default: false },
  13. integrity: {},
  14. options: { default: [] },
  15. pickAlgorithm: { default: () => getPrioritizedHash },
  16. sep: { default: ' ' },
  17. single: { default: false },
  18. size: {},
  19. strict: { default: false }
  20. })
  21. const getOptString = options => !options || !options.length ? ''
  22. : `?${options.join('?')}`
  23. const _onEnd = Symbol('_onEnd')
  24. const _getOptions = Symbol('_getOptions')
  25. class IntegrityStream extends MiniPass {
  26. constructor (opts) {
  27. super()
  28. this.size = 0
  29. this.opts = opts
  30. // may be overridden later, but set now for class consistency
  31. this[_getOptions]()
  32. // options used for calculating stream. can't be changed.
  33. this.algorithms = Array.from(
  34. new Set(opts.algorithms.concat(this.algorithm ? [this.algorithm] : []))
  35. )
  36. this.hashes = this.algorithms.map(crypto.createHash)
  37. }
  38. [_getOptions] () {
  39. const opts = this.opts
  40. // For verification
  41. this.sri = opts.integrity ? parse(opts.integrity, opts) : null
  42. this.expectedSize = opts.size
  43. this.goodSri = this.sri ? !!Object.keys(this.sri).length : false
  44. this.algorithm = this.goodSri ? this.sri.pickAlgorithm(opts) : null
  45. this.digests = this.goodSri ? this.sri[this.algorithm] : null
  46. this.optString = getOptString(opts.options)
  47. }
  48. emit (ev, data) {
  49. if (ev === 'end') this[_onEnd]()
  50. return super.emit(ev, data)
  51. }
  52. write (data) {
  53. this.size += data.length
  54. this.hashes.forEach(h => h.update(data))
  55. return super.write(data)
  56. }
  57. [_onEnd] () {
  58. if (!this.goodSri) {
  59. this[_getOptions]()
  60. }
  61. const newSri = parse(this.hashes.map((h, i) => {
  62. return `${this.algorithms[i]}-${h.digest('base64')}${this.optString}`
  63. }).join(' '), this.opts)
  64. // Integrity verification mode
  65. const match = this.goodSri && newSri.match(this.sri, this.opts)
  66. if (typeof this.expectedSize === 'number' && this.size !== this.expectedSize) {
  67. const err = new Error(`stream size mismatch when checking ${this.sri}.\n Wanted: ${this.expectedSize}\n Found: ${this.size}`)
  68. err.code = 'EBADSIZE'
  69. err.found = this.size
  70. err.expected = this.expectedSize
  71. err.sri = this.sri
  72. this.emit('error', err)
  73. } else if (this.sri && !match) {
  74. const err = new Error(`${this.sri} integrity checksum failed when using ${this.algorithm}: wanted ${this.digests} but got ${newSri}. (${this.size} bytes)`)
  75. err.code = 'EINTEGRITY'
  76. err.found = newSri
  77. err.expected = this.digests
  78. err.algorithm = this.algorithm
  79. err.sri = this.sri
  80. this.emit('error', err)
  81. } else {
  82. this.emit('size', this.size)
  83. this.emit('integrity', newSri)
  84. match && this.emit('verified', match)
  85. }
  86. }
  87. }
  88. class Hash {
  89. get isHash () { return true }
  90. constructor (hash, opts) {
  91. opts = SsriOpts(opts)
  92. const strict = !!opts.strict
  93. this.source = hash.trim()
  94. // set default values so that we make V8 happy to
  95. // always see a familiar object template.
  96. this.digest = ''
  97. this.algorithm = ''
  98. this.options = []
  99. // 3.1. Integrity metadata (called "Hash" by ssri)
  100. // https://w3c.github.io/webappsec-subresource-integrity/#integrity-metadata-description
  101. const match = this.source.match(
  102. strict
  103. ? STRICT_SRI_REGEX
  104. : SRI_REGEX
  105. )
  106. if (!match) { return }
  107. if (strict && !SPEC_ALGORITHMS.some(a => a === match[1])) { return }
  108. this.algorithm = match[1]
  109. this.digest = match[2]
  110. const rawOpts = match[3]
  111. if (rawOpts) {
  112. this.options = rawOpts.slice(1).split('?')
  113. }
  114. }
  115. hexDigest () {
  116. return this.digest && Buffer.from(this.digest, 'base64').toString('hex')
  117. }
  118. toJSON () {
  119. return this.toString()
  120. }
  121. toString (opts) {
  122. opts = SsriOpts(opts)
  123. if (opts.strict) {
  124. // Strict mode enforces the standard as close to the foot of the
  125. // letter as it can.
  126. if (!(
  127. // The spec has very restricted productions for algorithms.
  128. // https://www.w3.org/TR/CSP2/#source-list-syntax
  129. SPEC_ALGORITHMS.some(x => x === this.algorithm) &&
  130. // Usually, if someone insists on using a "different" base64, we
  131. // leave it as-is, since there's multiple standards, and the
  132. // specified is not a URL-safe variant.
  133. // https://www.w3.org/TR/CSP2/#base64_value
  134. this.digest.match(BASE64_REGEX) &&
  135. // Option syntax is strictly visual chars.
  136. // https://w3c.github.io/webappsec-subresource-integrity/#grammardef-option-expression
  137. // https://tools.ietf.org/html/rfc5234#appendix-B.1
  138. this.options.every(opt => opt.match(VCHAR_REGEX))
  139. )) {
  140. return ''
  141. }
  142. }
  143. const options = this.options && this.options.length
  144. ? `?${this.options.join('?')}`
  145. : ''
  146. return `${this.algorithm}-${this.digest}${options}`
  147. }
  148. }
  149. class Integrity {
  150. get isIntegrity () { return true }
  151. toJSON () {
  152. return this.toString()
  153. }
  154. toString (opts) {
  155. opts = SsriOpts(opts)
  156. let sep = opts.sep || ' '
  157. if (opts.strict) {
  158. // Entries must be separated by whitespace, according to spec.
  159. sep = sep.replace(/\S+/g, ' ')
  160. }
  161. return Object.keys(this).map(k => {
  162. return this[k].map(hash => {
  163. return Hash.prototype.toString.call(hash, opts)
  164. }).filter(x => x.length).join(sep)
  165. }).filter(x => x.length).join(sep)
  166. }
  167. concat (integrity, opts) {
  168. opts = SsriOpts(opts)
  169. const other = typeof integrity === 'string'
  170. ? integrity
  171. : stringify(integrity, opts)
  172. return parse(`${this.toString(opts)} ${other}`, opts)
  173. }
  174. hexDigest () {
  175. return parse(this, { single: true }).hexDigest()
  176. }
  177. // add additional hashes to an integrity value, but prevent
  178. // *changing* an existing integrity hash.
  179. merge (integrity, opts) {
  180. opts = SsriOpts(opts)
  181. const other = parse(integrity, opts)
  182. for (const algo in other) {
  183. if (this[algo]) {
  184. if (!this[algo].find(hash =>
  185. other[algo].find(otherhash =>
  186. hash.digest === otherhash.digest))) {
  187. throw new Error('hashes do not match, cannot update integrity')
  188. }
  189. } else {
  190. this[algo] = other[algo]
  191. }
  192. }
  193. }
  194. match (integrity, opts) {
  195. opts = SsriOpts(opts)
  196. const other = parse(integrity, opts)
  197. const algo = other.pickAlgorithm(opts)
  198. return (
  199. this[algo] &&
  200. other[algo] &&
  201. this[algo].find(hash =>
  202. other[algo].find(otherhash =>
  203. hash.digest === otherhash.digest
  204. )
  205. )
  206. ) || false
  207. }
  208. pickAlgorithm (opts) {
  209. opts = SsriOpts(opts)
  210. const pickAlgorithm = opts.pickAlgorithm
  211. const keys = Object.keys(this)
  212. if (!keys.length) {
  213. throw new Error(`No algorithms available for ${
  214. JSON.stringify(this.toString())
  215. }`)
  216. }
  217. return keys.reduce((acc, algo) => {
  218. return pickAlgorithm(acc, algo) || acc
  219. })
  220. }
  221. }
  222. module.exports.parse = parse
  223. function parse (sri, opts) {
  224. opts = SsriOpts(opts)
  225. if (typeof sri === 'string') {
  226. return _parse(sri, opts)
  227. } else if (sri.algorithm && sri.digest) {
  228. const fullSri = new Integrity()
  229. fullSri[sri.algorithm] = [sri]
  230. return _parse(stringify(fullSri, opts), opts)
  231. } else {
  232. return _parse(stringify(sri, opts), opts)
  233. }
  234. }
  235. function _parse (integrity, opts) {
  236. // 3.4.3. Parse metadata
  237. // https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata
  238. if (opts.single) {
  239. return new Hash(integrity, opts)
  240. }
  241. return integrity.trim().split(/\s+/).reduce((acc, string) => {
  242. const hash = new Hash(string, opts)
  243. if (hash.algorithm && hash.digest) {
  244. const algo = hash.algorithm
  245. if (!acc[algo]) { acc[algo] = [] }
  246. acc[algo].push(hash)
  247. }
  248. return acc
  249. }, new Integrity())
  250. }
  251. module.exports.stringify = stringify
  252. function stringify (obj, opts) {
  253. opts = SsriOpts(opts)
  254. if (obj.algorithm && obj.digest) {
  255. return Hash.prototype.toString.call(obj, opts)
  256. } else if (typeof obj === 'string') {
  257. return stringify(parse(obj, opts), opts)
  258. } else {
  259. return Integrity.prototype.toString.call(obj, opts)
  260. }
  261. }
  262. module.exports.fromHex = fromHex
  263. function fromHex (hexDigest, algorithm, opts) {
  264. opts = SsriOpts(opts)
  265. const optString = getOptString(opts.options)
  266. return parse(
  267. `${algorithm}-${
  268. Buffer.from(hexDigest, 'hex').toString('base64')
  269. }${optString}`, opts
  270. )
  271. }
  272. module.exports.fromData = fromData
  273. function fromData (data, opts) {
  274. opts = SsriOpts(opts)
  275. const algorithms = opts.algorithms
  276. const optString = getOptString(opts.options)
  277. return algorithms.reduce((acc, algo) => {
  278. const digest = crypto.createHash(algo).update(data).digest('base64')
  279. const hash = new Hash(
  280. `${algo}-${digest}${optString}`,
  281. opts
  282. )
  283. /* istanbul ignore else - it would be VERY strange if the string we
  284. * just calculated with an algo did not have an algo or digest.
  285. */
  286. if (hash.algorithm && hash.digest) {
  287. const algo = hash.algorithm
  288. if (!acc[algo]) { acc[algo] = [] }
  289. acc[algo].push(hash)
  290. }
  291. return acc
  292. }, new Integrity())
  293. }
  294. module.exports.fromStream = fromStream
  295. function fromStream (stream, opts) {
  296. opts = SsriOpts(opts)
  297. const istream = integrityStream(opts)
  298. return new Promise((resolve, reject) => {
  299. stream.pipe(istream)
  300. stream.on('error', reject)
  301. istream.on('error', reject)
  302. let sri
  303. istream.on('integrity', s => { sri = s })
  304. istream.on('end', () => resolve(sri))
  305. istream.on('data', () => {})
  306. })
  307. }
  308. module.exports.checkData = checkData
  309. function checkData (data, sri, opts) {
  310. opts = SsriOpts(opts)
  311. sri = parse(sri, opts)
  312. if (!Object.keys(sri).length) {
  313. if (opts.error) {
  314. throw Object.assign(
  315. new Error('No valid integrity hashes to check against'), {
  316. code: 'EINTEGRITY'
  317. }
  318. )
  319. } else {
  320. return false
  321. }
  322. }
  323. const algorithm = sri.pickAlgorithm(opts)
  324. const digest = crypto.createHash(algorithm).update(data).digest('base64')
  325. const newSri = parse({ algorithm, digest })
  326. const match = newSri.match(sri, opts)
  327. if (match || !opts.error) {
  328. return match
  329. } else if (typeof opts.size === 'number' && (data.length !== opts.size)) {
  330. const err = new Error(`data size mismatch when checking ${sri}.\n Wanted: ${opts.size}\n Found: ${data.length}`)
  331. err.code = 'EBADSIZE'
  332. err.found = data.length
  333. err.expected = opts.size
  334. err.sri = sri
  335. throw err
  336. } else {
  337. const err = new Error(`Integrity checksum failed when using ${algorithm}: Wanted ${sri}, but got ${newSri}. (${data.length} bytes)`)
  338. err.code = 'EINTEGRITY'
  339. err.found = newSri
  340. err.expected = sri
  341. err.algorithm = algorithm
  342. err.sri = sri
  343. throw err
  344. }
  345. }
  346. module.exports.checkStream = checkStream
  347. function checkStream (stream, sri, opts) {
  348. opts = SsriOpts(opts)
  349. const checker = integrityStream(opts.concat({
  350. integrity: sri
  351. }))
  352. return new Promise((resolve, reject) => {
  353. stream.pipe(checker)
  354. stream.on('error', reject)
  355. checker.on('error', reject)
  356. let sri
  357. checker.on('verified', s => { sri = s })
  358. checker.on('end', () => resolve(sri))
  359. checker.on('data', () => {})
  360. })
  361. }
  362. module.exports.integrityStream = integrityStream
  363. function integrityStream (opts) {
  364. return new IntegrityStream(SsriOpts(opts))
  365. }
  366. module.exports.create = createIntegrity
  367. function createIntegrity (opts) {
  368. opts = SsriOpts(opts)
  369. const algorithms = opts.algorithms
  370. const optString = getOptString(opts.options)
  371. const hashes = algorithms.map(crypto.createHash)
  372. return {
  373. update: function (chunk, enc) {
  374. hashes.forEach(h => h.update(chunk, enc))
  375. return this
  376. },
  377. digest: function (enc) {
  378. const integrity = algorithms.reduce((acc, algo) => {
  379. const digest = hashes.shift().digest('base64')
  380. const hash = new Hash(
  381. `${algo}-${digest}${optString}`,
  382. opts
  383. )
  384. /* istanbul ignore else - it would be VERY strange if the hash we
  385. * just calculated with an algo did not have an algo or digest.
  386. */
  387. if (hash.algorithm && hash.digest) {
  388. const algo = hash.algorithm
  389. if (!acc[algo]) { acc[algo] = [] }
  390. acc[algo].push(hash)
  391. }
  392. return acc
  393. }, new Integrity())
  394. return integrity
  395. }
  396. }
  397. }
  398. const NODE_HASHES = new Set(crypto.getHashes())
  399. // This is a Best Effort™ at a reasonable priority for hash algos
  400. const DEFAULT_PRIORITY = [
  401. 'md5', 'whirlpool', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
  402. // TODO - it's unclear _which_ of these Node will actually use as its name
  403. // for the algorithm, so we guesswork it based on the OpenSSL names.
  404. 'sha3',
  405. 'sha3-256', 'sha3-384', 'sha3-512',
  406. 'sha3_256', 'sha3_384', 'sha3_512'
  407. ].filter(algo => NODE_HASHES.has(algo))
  408. function getPrioritizedHash (algo1, algo2) {
  409. return DEFAULT_PRIORITY.indexOf(algo1.toLowerCase()) >= DEFAULT_PRIORITY.indexOf(algo2.toLowerCase())
  410. ? algo1
  411. : algo2
  412. }