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