update
This commit is contained in:
369
package/node_modules/pacote/lib/registry.js
generated
vendored
Normal file
369
package/node_modules/pacote/lib/registry.js
generated
vendored
Normal file
@@ -0,0 +1,369 @@
|
||||
const crypto = require('node:crypto')
|
||||
const PackageJson = require('@npmcli/package-json')
|
||||
const pickManifest = require('npm-pick-manifest')
|
||||
const ssri = require('ssri')
|
||||
const npa = require('npm-package-arg')
|
||||
const sigstore = require('sigstore')
|
||||
const fetch = require('npm-registry-fetch')
|
||||
const Fetcher = require('./fetcher.js')
|
||||
const RemoteFetcher = require('./remote.js')
|
||||
const pacoteVersion = require('../package.json').version
|
||||
const removeTrailingSlashes = require('./util/trailing-slashes.js')
|
||||
const _ = require('./util/protected.js')
|
||||
|
||||
// Corgis are cute. 🐕🐶
|
||||
const corgiDoc = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
|
||||
const fullDoc = 'application/json'
|
||||
|
||||
// Some really old packages have no time field in their packument so we need a
|
||||
// cutoff date.
|
||||
const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
|
||||
|
||||
class RegistryFetcher extends Fetcher {
|
||||
#cacheKey
|
||||
constructor (spec, opts) {
|
||||
super(spec, opts)
|
||||
|
||||
// you usually don't want to fetch the same packument multiple times in
|
||||
// the span of a given script or command, no matter how many pacote calls
|
||||
// are made, so this lets us avoid doing that. It's only relevant for
|
||||
// registry fetchers, because other types simulate their packument from
|
||||
// the manifest, which they memoize on this.package, so it's very cheap
|
||||
// already.
|
||||
this.packumentCache = this.opts.packumentCache || null
|
||||
|
||||
this.registry = fetch.pickRegistry(spec, opts)
|
||||
this.packumentUrl = `${removeTrailingSlashes(this.registry)}/${this.spec.escapedName}`
|
||||
this.#cacheKey = `${this.fullMetadata ? 'full' : 'corgi'}:${this.packumentUrl}`
|
||||
|
||||
const parsed = new URL(this.registry)
|
||||
const regKey = `//${parsed.host}${parsed.pathname}`
|
||||
// unlike the nerf-darted auth keys, this one does *not* allow a mismatch
|
||||
// of trailing slashes. It must match exactly.
|
||||
if (this.opts[`${regKey}:_keys`]) {
|
||||
this.registryKeys = this.opts[`${regKey}:_keys`]
|
||||
}
|
||||
|
||||
// XXX pacote <=9 has some logic to ignore opts.resolved if
|
||||
// the resolved URL doesn't go to the same registry.
|
||||
// Consider reproducing that here, to throw away this.resolved
|
||||
// in that case.
|
||||
}
|
||||
|
||||
async resolve () {
|
||||
// fetching the manifest sets resolved and (if present) integrity
|
||||
await this.manifest()
|
||||
if (!this.resolved) {
|
||||
throw Object.assign(
|
||||
new Error('Invalid package manifest: no `dist.tarball` field'),
|
||||
{ package: this.spec.toString() }
|
||||
)
|
||||
}
|
||||
return this.resolved
|
||||
}
|
||||
|
||||
#headers () {
|
||||
return {
|
||||
// npm will override UA, but ensure that we always send *something*
|
||||
'user-agent': this.opts.userAgent ||
|
||||
`pacote/${pacoteVersion} node/${process.version}`,
|
||||
...(this.opts.headers || {}),
|
||||
'pacote-version': pacoteVersion,
|
||||
'pacote-req-type': 'packument',
|
||||
'pacote-pkg-id': `registry:${this.spec.name}`,
|
||||
accept: this.fullMetadata ? fullDoc : corgiDoc,
|
||||
}
|
||||
}
|
||||
|
||||
async packument () {
|
||||
// note this might be either an in-flight promise for a request,
|
||||
// or the actual packument, but we never want to make more than
|
||||
// one request at a time for the same thing regardless.
|
||||
if (this.packumentCache?.has(this.#cacheKey)) {
|
||||
return this.packumentCache.get(this.#cacheKey)
|
||||
}
|
||||
|
||||
// npm-registry-fetch the packument
|
||||
// set the appropriate header for corgis if fullMetadata isn't set
|
||||
// return the res.json() promise
|
||||
try {
|
||||
const res = await fetch(this.packumentUrl, {
|
||||
...this.opts,
|
||||
headers: this.#headers(),
|
||||
spec: this.spec,
|
||||
|
||||
// never check integrity for packuments themselves
|
||||
integrity: null,
|
||||
})
|
||||
const packument = await res.json()
|
||||
const contentLength = res.headers.get('content-length')
|
||||
if (contentLength) {
|
||||
packument._contentLength = Number(contentLength)
|
||||
}
|
||||
this.packumentCache?.set(this.#cacheKey, packument)
|
||||
return packument
|
||||
} catch (err) {
|
||||
this.packumentCache?.delete(this.#cacheKey)
|
||||
if (err.code !== 'E404' || this.fullMetadata) {
|
||||
throw err
|
||||
}
|
||||
// possible that corgis are not supported by this registry
|
||||
this.fullMetadata = true
|
||||
return this.packument()
|
||||
}
|
||||
}
|
||||
|
||||
async manifest () {
|
||||
if (this.package) {
|
||||
return this.package
|
||||
}
|
||||
|
||||
// When verifying signatures, we need to fetch the full/uncompressed
|
||||
// packument to get publish time as this is not included in the
|
||||
// corgi/compressed packument.
|
||||
if (this.opts.verifySignatures) {
|
||||
this.fullMetadata = true
|
||||
}
|
||||
|
||||
const packument = await this.packument()
|
||||
const steps = PackageJson.normalizeSteps.filter(s => s !== '_attributes')
|
||||
const mani = await new PackageJson().fromContent(pickManifest(packument, this.spec.fetchSpec, {
|
||||
...this.opts,
|
||||
defaultTag: this.defaultTag,
|
||||
before: this.before,
|
||||
})).normalize({ steps }).then(p => p.content)
|
||||
|
||||
/* XXX add ETARGET and E403 revalidation of cached packuments here */
|
||||
|
||||
// add _time from packument if fetched with fullMetadata
|
||||
const time = packument.time?.[mani.version]
|
||||
if (time) {
|
||||
mani._time = time
|
||||
}
|
||||
|
||||
// add _resolved and _integrity from dist object
|
||||
const { dist } = mani
|
||||
if (dist) {
|
||||
this.resolved = mani._resolved = dist.tarball
|
||||
mani._from = this.from
|
||||
const distIntegrity = dist.integrity ? ssri.parse(dist.integrity)
|
||||
: dist.shasum ? ssri.fromHex(dist.shasum, 'sha1', { ...this.opts })
|
||||
: null
|
||||
if (distIntegrity) {
|
||||
if (this.integrity && !this.integrity.match(distIntegrity)) {
|
||||
// only bork if they have algos in common.
|
||||
// otherwise we end up breaking if we have saved a sha512
|
||||
// previously for the tarball, but the manifest only
|
||||
// provides a sha1, which is possible for older publishes.
|
||||
// Otherwise, this is almost certainly a case of holding it
|
||||
// wrong, and will result in weird or insecure behavior
|
||||
// later on when building package tree.
|
||||
for (const algo of Object.keys(this.integrity)) {
|
||||
if (distIntegrity[algo]) {
|
||||
throw Object.assign(new Error(
|
||||
`Integrity checksum failed when using ${algo}: ` +
|
||||
`wanted ${this.integrity} but got ${distIntegrity}.`
|
||||
), { code: 'EINTEGRITY' })
|
||||
}
|
||||
}
|
||||
}
|
||||
// made it this far, the integrity is worthwhile. accept it.
|
||||
// the setter here will take care of merging it into what we already
|
||||
// had.
|
||||
this.integrity = distIntegrity
|
||||
}
|
||||
}
|
||||
if (this.integrity) {
|
||||
mani._integrity = String(this.integrity)
|
||||
if (dist.signatures) {
|
||||
if (this.opts.verifySignatures) {
|
||||
// validate and throw on error, then set _signatures
|
||||
const message = `${mani._id}:${mani._integrity}`
|
||||
for (const signature of dist.signatures) {
|
||||
const publicKey = this.registryKeys &&
|
||||
this.registryKeys.filter(key => (key.keyid === signature.keyid))[0]
|
||||
if (!publicKey) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
|
||||
'but no corresponding public key can be found'
|
||||
), { code: 'EMISSINGSIGNATUREKEY' })
|
||||
}
|
||||
|
||||
const publishedTime = Date.parse(mani._time || MISSING_TIME_CUTOFF)
|
||||
const validPublicKey = !publicKey.expires ||
|
||||
publishedTime < Date.parse(publicKey.expires)
|
||||
if (!validPublicKey) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
|
||||
`but the corresponding public key has expired ${publicKey.expires}`
|
||||
), { code: 'EEXPIREDSIGNATUREKEY' })
|
||||
}
|
||||
const verifier = crypto.createVerify('SHA256')
|
||||
verifier.write(message)
|
||||
verifier.end()
|
||||
const valid = verifier.verify(
|
||||
publicKey.pemkey,
|
||||
signature.sig,
|
||||
'base64'
|
||||
)
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has an invalid registry signature with ` +
|
||||
`keyid: ${publicKey.keyid} and signature: ${signature.sig}`
|
||||
), {
|
||||
code: 'EINTEGRITYSIGNATURE',
|
||||
keyid: publicKey.keyid,
|
||||
signature: signature.sig,
|
||||
resolved: mani._resolved,
|
||||
integrity: mani._integrity,
|
||||
})
|
||||
}
|
||||
}
|
||||
mani._signatures = dist.signatures
|
||||
} else {
|
||||
mani._signatures = dist.signatures
|
||||
}
|
||||
}
|
||||
|
||||
if (dist.attestations) {
|
||||
if (this.opts.verifyAttestations) {
|
||||
// Always fetch attestations from the current registry host
|
||||
const attestationsPath = new URL(dist.attestations.url).pathname
|
||||
const attestationsUrl = removeTrailingSlashes(this.registry) + attestationsPath
|
||||
const res = await fetch(attestationsUrl, {
|
||||
...this.opts,
|
||||
// disable integrity check for attestations json payload, we check the
|
||||
// integrity in the verification steps below
|
||||
integrity: null,
|
||||
})
|
||||
const { attestations } = await res.json()
|
||||
const bundles = attestations.map(({ predicateType, bundle }) => {
|
||||
const statement = JSON.parse(
|
||||
Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8')
|
||||
)
|
||||
const keyid = bundle.dsseEnvelope.signatures[0].keyid
|
||||
const signature = bundle.dsseEnvelope.signatures[0].sig
|
||||
|
||||
return {
|
||||
predicateType,
|
||||
bundle,
|
||||
statement,
|
||||
keyid,
|
||||
signature,
|
||||
}
|
||||
})
|
||||
|
||||
const attestationKeyIds = bundles.map((b) => b.keyid).filter((k) => !!k)
|
||||
const attestationRegistryKeys = (this.registryKeys || [])
|
||||
.filter(key => attestationKeyIds.includes(key.keyid))
|
||||
if (!attestationRegistryKeys.length) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has attestations but no corresponding public key(s) can be found`
|
||||
), { code: 'EMISSINGSIGNATUREKEY' })
|
||||
}
|
||||
|
||||
for (const { predicateType, bundle, keyid, signature, statement } of bundles) {
|
||||
const publicKey = attestationRegistryKeys.find(key => key.keyid === keyid)
|
||||
// Publish attestations have a keyid set and a valid public key must be found
|
||||
if (keyid) {
|
||||
if (!publicKey) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has attestations with keyid: ${keyid} ` +
|
||||
'but no corresponding public key can be found'
|
||||
), { code: 'EMISSINGSIGNATUREKEY' })
|
||||
}
|
||||
|
||||
const integratedTime = new Date(
|
||||
Number(
|
||||
bundle.verificationMaterial.tlogEntries[0].integratedTime
|
||||
) * 1000
|
||||
)
|
||||
const validPublicKey = !publicKey.expires ||
|
||||
(integratedTime < Date.parse(publicKey.expires))
|
||||
if (!validPublicKey) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} has attestations with keyid: ${keyid} ` +
|
||||
`but the corresponding public key has expired ${publicKey.expires}`
|
||||
), { code: 'EEXPIREDSIGNATUREKEY' })
|
||||
}
|
||||
}
|
||||
|
||||
const subject = {
|
||||
name: statement.subject[0].name,
|
||||
sha512: statement.subject[0].digest.sha512,
|
||||
}
|
||||
|
||||
// Only type 'version' can be turned into a PURL
|
||||
const purl = this.spec.type === 'version' ? npa.toPurl(this.spec) : this.spec
|
||||
// Verify the statement subject matches the package, version
|
||||
if (subject.name !== purl) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} package name and version (PURL): ${purl} ` +
|
||||
`doesn't match what was signed: ${subject.name}`
|
||||
), { code: 'EATTESTATIONSUBJECT' })
|
||||
}
|
||||
|
||||
// Verify the statement subject matches the tarball integrity
|
||||
const integrityHexDigest = ssri.parse(this.integrity).hexDigest()
|
||||
if (subject.sha512 !== integrityHexDigest) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} package integrity (hex digest): ` +
|
||||
`${integrityHexDigest} ` +
|
||||
`doesn't match what was signed: ${subject.sha512}`
|
||||
), { code: 'EATTESTATIONSUBJECT' })
|
||||
}
|
||||
|
||||
try {
|
||||
// Provenance attestations are signed with a signing certificate
|
||||
// (including the key) so we don't need to return a public key.
|
||||
//
|
||||
// Publish attestations are signed with a keyid so we need to
|
||||
// specify a public key from the keys endpoint: `registry-host.tld/-/npm/v1/keys`
|
||||
const options = {
|
||||
tufCachePath: this.tufCache,
|
||||
tufForceCache: true,
|
||||
keySelector: publicKey ? () => publicKey.pemkey : undefined,
|
||||
}
|
||||
await sigstore.verify(bundle, options)
|
||||
} catch (e) {
|
||||
throw Object.assign(new Error(
|
||||
`${mani._id} failed to verify attestation: ${e.message}`
|
||||
), {
|
||||
code: 'EATTESTATIONVERIFY',
|
||||
predicateType,
|
||||
keyid,
|
||||
signature,
|
||||
resolved: mani._resolved,
|
||||
integrity: mani._integrity,
|
||||
})
|
||||
}
|
||||
}
|
||||
mani._attestations = dist.attestations
|
||||
} else {
|
||||
mani._attestations = dist.attestations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.package = mani
|
||||
return this.package
|
||||
}
|
||||
|
||||
[_.tarballFromResolved] () {
|
||||
// we use a RemoteFetcher to get the actual tarball stream
|
||||
return new RemoteFetcher(this.resolved, {
|
||||
...this.opts,
|
||||
resolved: this.resolved,
|
||||
pkgid: `registry:${this.spec.name}@${this.resolved}`,
|
||||
})[_.tarballFromResolved]()
|
||||
}
|
||||
|
||||
get types () {
|
||||
return [
|
||||
'tag',
|
||||
'version',
|
||||
'range',
|
||||
]
|
||||
}
|
||||
}
|
||||
module.exports = RegistryFetcher
|
Reference in New Issue
Block a user