registry.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. const crypto = require('node:crypto')
  2. const PackageJson = require('@npmcli/package-json')
  3. const pickManifest = require('npm-pick-manifest')
  4. const ssri = require('ssri')
  5. const npa = require('npm-package-arg')
  6. const sigstore = require('sigstore')
  7. const fetch = require('npm-registry-fetch')
  8. const Fetcher = require('./fetcher.js')
  9. const RemoteFetcher = require('./remote.js')
  10. const pacoteVersion = require('../package.json').version
  11. const removeTrailingSlashes = require('./util/trailing-slashes.js')
  12. const _ = require('./util/protected.js')
  13. // Corgis are cute. 🐕🐶
  14. const corgiDoc = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
  15. const fullDoc = 'application/json'
  16. // Some really old packages have no time field in their packument so we need a
  17. // cutoff date.
  18. const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
  19. class RegistryFetcher extends Fetcher {
  20. #cacheKey
  21. constructor (spec, opts) {
  22. super(spec, opts)
  23. // you usually don't want to fetch the same packument multiple times in
  24. // the span of a given script or command, no matter how many pacote calls
  25. // are made, so this lets us avoid doing that. It's only relevant for
  26. // registry fetchers, because other types simulate their packument from
  27. // the manifest, which they memoize on this.package, so it's very cheap
  28. // already.
  29. this.packumentCache = this.opts.packumentCache || null
  30. this.registry = fetch.pickRegistry(spec, opts)
  31. this.packumentUrl = `${removeTrailingSlashes(this.registry)}/${this.spec.escapedName}`
  32. this.#cacheKey = `${this.fullMetadata ? 'full' : 'corgi'}:${this.packumentUrl}`
  33. const parsed = new URL(this.registry)
  34. const regKey = `//${parsed.host}${parsed.pathname}`
  35. // unlike the nerf-darted auth keys, this one does *not* allow a mismatch
  36. // of trailing slashes. It must match exactly.
  37. if (this.opts[`${regKey}:_keys`]) {
  38. this.registryKeys = this.opts[`${regKey}:_keys`]
  39. }
  40. // XXX pacote <=9 has some logic to ignore opts.resolved if
  41. // the resolved URL doesn't go to the same registry.
  42. // Consider reproducing that here, to throw away this.resolved
  43. // in that case.
  44. }
  45. async resolve () {
  46. // fetching the manifest sets resolved and (if present) integrity
  47. await this.manifest()
  48. if (!this.resolved) {
  49. throw Object.assign(
  50. new Error('Invalid package manifest: no `dist.tarball` field'),
  51. { package: this.spec.toString() }
  52. )
  53. }
  54. return this.resolved
  55. }
  56. #headers () {
  57. return {
  58. // npm will override UA, but ensure that we always send *something*
  59. 'user-agent': this.opts.userAgent ||
  60. `pacote/${pacoteVersion} node/${process.version}`,
  61. ...(this.opts.headers || {}),
  62. 'pacote-version': pacoteVersion,
  63. 'pacote-req-type': 'packument',
  64. 'pacote-pkg-id': `registry:${this.spec.name}`,
  65. accept: this.fullMetadata ? fullDoc : corgiDoc,
  66. }
  67. }
  68. async packument () {
  69. // note this might be either an in-flight promise for a request,
  70. // or the actual packument, but we never want to make more than
  71. // one request at a time for the same thing regardless.
  72. if (this.packumentCache?.has(this.#cacheKey)) {
  73. return this.packumentCache.get(this.#cacheKey)
  74. }
  75. // npm-registry-fetch the packument
  76. // set the appropriate header for corgis if fullMetadata isn't set
  77. // return the res.json() promise
  78. try {
  79. const res = await fetch(this.packumentUrl, {
  80. ...this.opts,
  81. headers: this.#headers(),
  82. spec: this.spec,
  83. // never check integrity for packuments themselves
  84. integrity: null,
  85. })
  86. const packument = await res.json()
  87. const contentLength = res.headers.get('content-length')
  88. if (contentLength) {
  89. packument._contentLength = Number(contentLength)
  90. }
  91. this.packumentCache?.set(this.#cacheKey, packument)
  92. return packument
  93. } catch (err) {
  94. this.packumentCache?.delete(this.#cacheKey)
  95. if (err.code !== 'E404' || this.fullMetadata) {
  96. throw err
  97. }
  98. // possible that corgis are not supported by this registry
  99. this.fullMetadata = true
  100. return this.packument()
  101. }
  102. }
  103. async manifest () {
  104. if (this.package) {
  105. return this.package
  106. }
  107. // When verifying signatures, we need to fetch the full/uncompressed
  108. // packument to get publish time as this is not included in the
  109. // corgi/compressed packument.
  110. if (this.opts.verifySignatures) {
  111. this.fullMetadata = true
  112. }
  113. const packument = await this.packument()
  114. const steps = PackageJson.normalizeSteps.filter(s => s !== '_attributes')
  115. const mani = await new PackageJson().fromContent(pickManifest(packument, this.spec.fetchSpec, {
  116. ...this.opts,
  117. defaultTag: this.defaultTag,
  118. before: this.before,
  119. })).normalize({ steps }).then(p => p.content)
  120. /* XXX add ETARGET and E403 revalidation of cached packuments here */
  121. // add _time from packument if fetched with fullMetadata
  122. const time = packument.time?.[mani.version]
  123. if (time) {
  124. mani._time = time
  125. }
  126. // add _resolved and _integrity from dist object
  127. const { dist } = mani
  128. if (dist) {
  129. this.resolved = mani._resolved = dist.tarball
  130. mani._from = this.from
  131. const distIntegrity = dist.integrity ? ssri.parse(dist.integrity)
  132. : dist.shasum ? ssri.fromHex(dist.shasum, 'sha1', { ...this.opts })
  133. : null
  134. if (distIntegrity) {
  135. if (this.integrity && !this.integrity.match(distIntegrity)) {
  136. // only bork if they have algos in common.
  137. // otherwise we end up breaking if we have saved a sha512
  138. // previously for the tarball, but the manifest only
  139. // provides a sha1, which is possible for older publishes.
  140. // Otherwise, this is almost certainly a case of holding it
  141. // wrong, and will result in weird or insecure behavior
  142. // later on when building package tree.
  143. for (const algo of Object.keys(this.integrity)) {
  144. if (distIntegrity[algo]) {
  145. throw Object.assign(new Error(
  146. `Integrity checksum failed when using ${algo}: ` +
  147. `wanted ${this.integrity} but got ${distIntegrity}.`
  148. ), { code: 'EINTEGRITY' })
  149. }
  150. }
  151. }
  152. // made it this far, the integrity is worthwhile. accept it.
  153. // the setter here will take care of merging it into what we already
  154. // had.
  155. this.integrity = distIntegrity
  156. }
  157. }
  158. if (this.integrity) {
  159. mani._integrity = String(this.integrity)
  160. if (dist.signatures) {
  161. if (this.opts.verifySignatures) {
  162. // validate and throw on error, then set _signatures
  163. const message = `${mani._id}:${mani._integrity}`
  164. for (const signature of dist.signatures) {
  165. const publicKey = this.registryKeys &&
  166. this.registryKeys.filter(key => (key.keyid === signature.keyid))[0]
  167. if (!publicKey) {
  168. throw Object.assign(new Error(
  169. `${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
  170. 'but no corresponding public key can be found'
  171. ), { code: 'EMISSINGSIGNATUREKEY' })
  172. }
  173. const publishedTime = Date.parse(mani._time || MISSING_TIME_CUTOFF)
  174. const validPublicKey = !publicKey.expires ||
  175. publishedTime < Date.parse(publicKey.expires)
  176. if (!validPublicKey) {
  177. throw Object.assign(new Error(
  178. `${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
  179. `but the corresponding public key has expired ${publicKey.expires}`
  180. ), { code: 'EEXPIREDSIGNATUREKEY' })
  181. }
  182. const verifier = crypto.createVerify('SHA256')
  183. verifier.write(message)
  184. verifier.end()
  185. const valid = verifier.verify(
  186. publicKey.pemkey,
  187. signature.sig,
  188. 'base64'
  189. )
  190. if (!valid) {
  191. throw Object.assign(new Error(
  192. `${mani._id} has an invalid registry signature with ` +
  193. `keyid: ${publicKey.keyid} and signature: ${signature.sig}`
  194. ), {
  195. code: 'EINTEGRITYSIGNATURE',
  196. keyid: publicKey.keyid,
  197. signature: signature.sig,
  198. resolved: mani._resolved,
  199. integrity: mani._integrity,
  200. })
  201. }
  202. }
  203. mani._signatures = dist.signatures
  204. } else {
  205. mani._signatures = dist.signatures
  206. }
  207. }
  208. if (dist.attestations) {
  209. if (this.opts.verifyAttestations) {
  210. // Always fetch attestations from the current registry host
  211. const attestationsPath = new URL(dist.attestations.url).pathname
  212. const attestationsUrl = removeTrailingSlashes(this.registry) + attestationsPath
  213. const res = await fetch(attestationsUrl, {
  214. ...this.opts,
  215. // disable integrity check for attestations json payload, we check the
  216. // integrity in the verification steps below
  217. integrity: null,
  218. })
  219. const { attestations } = await res.json()
  220. const bundles = attestations.map(({ predicateType, bundle }) => {
  221. const statement = JSON.parse(
  222. Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8')
  223. )
  224. const keyid = bundle.dsseEnvelope.signatures[0].keyid
  225. const signature = bundle.dsseEnvelope.signatures[0].sig
  226. return {
  227. predicateType,
  228. bundle,
  229. statement,
  230. keyid,
  231. signature,
  232. }
  233. })
  234. const attestationKeyIds = bundles.map((b) => b.keyid).filter((k) => !!k)
  235. const attestationRegistryKeys = (this.registryKeys || [])
  236. .filter(key => attestationKeyIds.includes(key.keyid))
  237. if (!attestationRegistryKeys.length) {
  238. throw Object.assign(new Error(
  239. `${mani._id} has attestations but no corresponding public key(s) can be found`
  240. ), { code: 'EMISSINGSIGNATUREKEY' })
  241. }
  242. for (const { predicateType, bundle, keyid, signature, statement } of bundles) {
  243. const publicKey = attestationRegistryKeys.find(key => key.keyid === keyid)
  244. // Publish attestations have a keyid set and a valid public key must be found
  245. if (keyid) {
  246. if (!publicKey) {
  247. throw Object.assign(new Error(
  248. `${mani._id} has attestations with keyid: ${keyid} ` +
  249. 'but no corresponding public key can be found'
  250. ), { code: 'EMISSINGSIGNATUREKEY' })
  251. }
  252. const integratedTime = new Date(
  253. Number(
  254. bundle.verificationMaterial.tlogEntries[0].integratedTime
  255. ) * 1000
  256. )
  257. const validPublicKey = !publicKey.expires ||
  258. (integratedTime < Date.parse(publicKey.expires))
  259. if (!validPublicKey) {
  260. throw Object.assign(new Error(
  261. `${mani._id} has attestations with keyid: ${keyid} ` +
  262. `but the corresponding public key has expired ${publicKey.expires}`
  263. ), { code: 'EEXPIREDSIGNATUREKEY' })
  264. }
  265. }
  266. const subject = {
  267. name: statement.subject[0].name,
  268. sha512: statement.subject[0].digest.sha512,
  269. }
  270. // Only type 'version' can be turned into a PURL
  271. const purl = this.spec.type === 'version' ? npa.toPurl(this.spec) : this.spec
  272. // Verify the statement subject matches the package, version
  273. if (subject.name !== purl) {
  274. throw Object.assign(new Error(
  275. `${mani._id} package name and version (PURL): ${purl} ` +
  276. `doesn't match what was signed: ${subject.name}`
  277. ), { code: 'EATTESTATIONSUBJECT' })
  278. }
  279. // Verify the statement subject matches the tarball integrity
  280. const integrityHexDigest = ssri.parse(this.integrity).hexDigest()
  281. if (subject.sha512 !== integrityHexDigest) {
  282. throw Object.assign(new Error(
  283. `${mani._id} package integrity (hex digest): ` +
  284. `${integrityHexDigest} ` +
  285. `doesn't match what was signed: ${subject.sha512}`
  286. ), { code: 'EATTESTATIONSUBJECT' })
  287. }
  288. try {
  289. // Provenance attestations are signed with a signing certificate
  290. // (including the key) so we don't need to return a public key.
  291. //
  292. // Publish attestations are signed with a keyid so we need to
  293. // specify a public key from the keys endpoint: `registry-host.tld/-/npm/v1/keys`
  294. const options = {
  295. tufCachePath: this.tufCache,
  296. tufForceCache: true,
  297. keySelector: publicKey ? () => publicKey.pemkey : undefined,
  298. }
  299. await sigstore.verify(bundle, options)
  300. } catch (e) {
  301. throw Object.assign(new Error(
  302. `${mani._id} failed to verify attestation: ${e.message}`
  303. ), {
  304. code: 'EATTESTATIONVERIFY',
  305. predicateType,
  306. keyid,
  307. signature,
  308. resolved: mani._resolved,
  309. integrity: mani._integrity,
  310. })
  311. }
  312. }
  313. mani._attestations = dist.attestations
  314. } else {
  315. mani._attestations = dist.attestations
  316. }
  317. }
  318. }
  319. this.package = mani
  320. return this.package
  321. }
  322. [_.tarballFromResolved] () {
  323. // we use a RemoteFetcher to get the actual tarball stream
  324. return new RemoteFetcher(this.resolved, {
  325. ...this.opts,
  326. resolved: this.resolved,
  327. pkgid: `registry:${this.spec.name}@${this.resolved}`,
  328. })[_.tarballFromResolved]()
  329. }
  330. get types () {
  331. return [
  332. 'tag',
  333. 'version',
  334. 'range',
  335. ]
  336. }
  337. }
  338. module.exports = RegistryFetcher