Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
registry.js374 linesDownload Raw Back to lib
1const crypto = require('node:crypto')
2const PackageJson = require('@npmcli/package-json')
3const pickManifest = require('npm-pick-manifest')
4const ssri = require('ssri')
5const npa = require('npm-package-arg')
6const sigstore = require('sigstore')
7const fetch = require('npm-registry-fetch')
8const Fetcher = require('./fetcher.js')
9const RemoteFetcher = require('./remote.js')
10const pacoteVersion = require('../package.json').version
11const removeTrailingSlashes = require('./util/trailing-slashes.js')
12const _ = require('./util/protected.js')
13
14// Corgis are cute. 🐕🐶
15const corgiDoc = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
16const fullDoc = 'application/json'
17
18// Some really old packages have no time field in their packument so we need a
19// cutoff date.
20const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
21
22class RegistryFetcher extends Fetcher {
23  #cacheKey
24  constructor (spec, opts) {
25    super(spec, opts)
26
27    // you usually don't want to fetch the same packument multiple times in
28    // the span of a given script or command, no matter how many pacote calls
29    // are made, so this lets us avoid doing that.  It's only relevant for
30    // registry fetchers, because other types simulate their packument from
31    // the manifest, which they memoize on this.package, so it's very cheap
32    // already.
33    this.packumentCache = this.opts.packumentCache || null
34
35    this.registry = fetch.pickRegistry(spec, opts)
36    this.packumentUrl = `${removeTrailingSlashes(this.registry)}/${this.spec.escapedName}`
37    this.#cacheKey = `${this.fullMetadata ? 'full' : 'corgi'}:${this.packumentUrl}`
38
39    const parsed = new URL(this.registry)
40    const regKey = `//${parsed.host}${parsed.pathname}`
41    // unlike the nerf-darted auth keys, this one does *not* allow a mismatch
42    // of trailing slashes.  It must match exactly.
43    if (this.opts[`${regKey}:_keys`]) {
44      this.registryKeys = this.opts[`${regKey}:_keys`]
45    }
46
47    // XXX pacote <=9 has some logic to ignore opts.resolved if
48    // the resolved URL doesn't go to the same registry.
49    // Consider reproducing that here, to throw away this.resolved
50    // in that case.
51  }
52
53  async resolve () {
54    // fetching the manifest sets resolved and (if present) integrity
55    await this.manifest()
56    if (!this.resolved) {
57      throw Object.assign(
58        new Error('Invalid package manifest: no `dist.tarball` field'),
59        { package: this.spec.toString() }
60      )
61    }
62    return this.resolved
63  }
64
65  #headers () {
66    return {
67      // npm will override UA, but ensure that we always send *something*
68      'user-agent': this.opts.userAgent ||
69        `pacote/${pacoteVersion} node/${process.version}`,
70      ...(this.opts.headers || {}),
71      'pacote-version': pacoteVersion,
72      'pacote-req-type': 'packument',
73      'pacote-pkg-id': `registry:${this.spec.name}`,
74      accept: this.fullMetadata ? fullDoc : corgiDoc,
75    }
76  }
77
78  async packument () {
79    // note this might be either an in-flight promise for a request,
80    // or the actual packument, but we never want to make more than
81    // one request at a time for the same thing regardless.
82    if (this.packumentCache?.has(this.#cacheKey)) {
83      return this.packumentCache.get(this.#cacheKey)
84    }
85
86    // npm-registry-fetch the packument
87    // set the appropriate header for corgis if fullMetadata isn't set
88    // return the res.json() promise
89    try {
90      const res = await fetch(this.packumentUrl, {
91        ...this.opts,
92        headers: this.#headers(),
93        spec: this.spec,
94
95        // never check integrity for packuments themselves
96        integrity: null,
97      })
98      const packument = await res.json()
99      const contentLength = res.headers.get('content-length')
100      if (contentLength) {
101        packument._contentLength = Number(contentLength)
102      }
103      this.packumentCache?.set(this.#cacheKey, packument)
104      return packument
105    } catch (err) {
106      this.packumentCache?.delete(this.#cacheKey)
107      if (err.code !== 'E404' || this.fullMetadata) {
108        throw err
109      }
110      // possible that corgis are not supported by this registry
111      this.fullMetadata = true
112      return this.packument()
113    }
114  }
115
116  async manifest () {
117    if (this.package) {
118      return this.package
119    }
120
121    // When verifying signatures, we need to fetch the full/uncompressed
122    // packument to get publish time as this is not included in the
123    // corgi/compressed packument.
124    if (this.opts.verifySignatures) {
125      this.fullMetadata = true
126    }
127
128    const packument = await this.packument()
129    const steps = PackageJson.normalizeSteps.filter(s => s !== '_attributes')
130    const mani = await new PackageJson().fromContent(pickManifest(packument, this.spec.fetchSpec, {
131      ...this.opts,
132      defaultTag: this.defaultTag,
133      before: this.before,
134    })).normalize({ steps }).then(p => p.content)
135
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 = new URL(attestationsPath, this.registry).href
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          // Only require registry keys when there are keyed attestations.
260          // Keyless (Sigstore/Fulcio) attestations embed their signing
261          // certificate in the bundle and don't need registry keys.
262          if (attestationKeyIds.length > 0 && !attestationRegistryKeys.length) {
263            throw Object.assign(new Error(
264              `${mani._id} has attestations but no corresponding public key(s) can be found`
265            ), { code: 'EMISSINGSIGNATUREKEY' })
266          }
267
268          for (const { predicateType, bundle, keyid, signature, statement } of bundles) {
269            const publicKey = attestationRegistryKeys.find(key => key.keyid === keyid)
270            // Publish attestations have a keyid set and a valid public key must be found
271            if (keyid) {
272              if (!publicKey) {
273                throw Object.assign(new Error(
274                  `${mani._id} has attestations with keyid: ${keyid} ` +
275                  'but no corresponding public key can be found'
276                ), { code: 'EMISSINGSIGNATUREKEY' })
277              }
278
279              const integratedTime = new Date(
280                Number(
281                  bundle.verificationMaterial.tlogEntries[0].integratedTime
282                ) * 1000
283              )
284              const validPublicKey = !publicKey.expires ||
285                (integratedTime < Date.parse(publicKey.expires))
286              if (!validPublicKey) {
287                throw Object.assign(new Error(
288                  `${mani._id} has attestations with keyid: ${keyid} ` +
289                  `but the corresponding public key has expired ${publicKey.expires}`
290                ), { code: 'EEXPIREDSIGNATUREKEY' })
291              }
292            }
293
294            const subject = {
295              name: statement.subject[0].name,
296              sha512: statement.subject[0].digest.sha512,
297            }
298
299            // Only type 'version' can be turned into a PURL
300            const purl = this.spec.type === 'version' ? npa.toPurl(this.spec) : this.spec
301            // Verify the statement subject matches the package, version
302            if (subject.name !== purl) {
303              throw Object.assign(new Error(
304                `${mani._id} package name and version (PURL): ${purl} ` +
305                `doesn't match what was signed: ${subject.name}`
306              ), { code: 'EATTESTATIONSUBJECT' })
307            }
308
309            // Verify the statement subject matches the tarball integrity
310            const integrityHexDigest = ssri.parse(this.integrity).hexDigest()
311            if (subject.sha512 !== integrityHexDigest) {
312              throw Object.assign(new Error(
313                `${mani._id} package integrity (hex digest): ` +
314                `${integrityHexDigest} ` +
315                `doesn't match what was signed: ${subject.sha512}`
316              ), { code: 'EATTESTATIONSUBJECT' })
317            }
318
319            try {
320              // Provenance attestations are signed with a signing certificate
321              // (including the key) so we don't need to return a public key.
322              //
323              // Publish attestations are signed with a keyid so we need to
324              // specify a public key from the keys endpoint: `registry-host.tld/-/npm/v1/keys`
325              const options = {
326                tufCachePath: this.tufCache,
327                tufForceCache: true,
328                keySelector: publicKey ? () => publicKey.pemkey : undefined,
329              }
330              await sigstore.verify(bundle, options)
331            } catch (e) {
332              throw Object.assign(new Error(
333                `${mani._id} failed to verify attestation: ${e.message}`
334              ), {
335                code: 'EATTESTATIONVERIFY',
336                predicateType,
337                keyid,
338                signature,
339                resolved: mani._resolved,
340                integrity: mani._integrity,
341              })
342            }
343          }
344          mani._attestations = dist.attestations
345          mani._attestationBundles = attestations
346        } else {
347          mani._attestations = dist.attestations
348        }
349      }
350    }
351
352    this.package = mani
353    return this.package
354  }
355
356  [_.tarballFromResolved] () {
357    // we use a RemoteFetcher to get the actual tarball stream
358    return new RemoteFetcher(this.resolved, {
359      ...this.opts,
360      resolved: this.resolved,
361      pkgid: `registry:${this.spec.name}@${this.resolved}`,
362    })[_.tarballFromResolved]()
363  }
364
365  get types () {
366    return [
367      'tag',
368      'version',
369      'range',
370    ]
371  }
372}
373module.exports = RegistryFetcher
374 
codekingpro/portable-devtools · Team Ai