codekingpro/portable-devtools
114k
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 