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