Skip to content

Commit 4401c58

Browse files
committedMay 17, 2022
feat: add verifySignatures to registry.manifest (#170)
1 parent 8f94b28 commit 4401c58

File tree

3 files changed

+244
-77
lines changed

3 files changed

+244
-77
lines changed
 

‎README.md

+4
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ resolved, and other properties, as they are determined.
168168
is unlikely to change in the span of a single command.
169169
* `silent` A boolean that determines whether the banner is displayed
170170
when calling `@npmcli/run-script`.
171+
* `verifySignatures` A boolean that will make pacote verify the
172+
integrity signature of a manifest, if present. There must be a
173+
configured `_keys` entry in the config that is scoped to the
174+
registry the manifest is being fetched from.
171175

172176

173177
### Advanced API

‎lib/registry.js

+114-77
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ const npa = require('npm-package-arg')
77
const rpj = require('read-package-json-fast')
88
const pickManifest = require('npm-pick-manifest')
99
const ssri = require('ssri')
10+
const crypto = require('crypto')
1011

1112
// Corgis are cute. 🐕🐶
1213
const corgiDoc = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
1314
const fullDoc = 'application/json'
1415

1516
const fetch = require('npm-registry-fetch')
1617

17-
// TODO: memoize reg requests, so we don't even have to check cache
18-
1918
const _headers = Symbol('_headers')
2019
class RegistryFetcher extends Fetcher {
2120
constructor (spec, opts) {
@@ -39,28 +38,30 @@ class RegistryFetcher extends Fetcher {
3938
this.packumentUrl = removeTrailingSlashes(this.registry) + '/' +
4039
this.spec.escapedName
4140

41+
const parsed = new URL(this.registry)
42+
const regKey = `//${parsed.host}${parsed.pathname}`
43+
// unlike the nerf-darted auth keys, this one does *not* allow a mismatch
44+
// of trailing slashes. It must match exactly.
45+
if (this.opts[`${regKey}:_keys`]) {
46+
this.registryKeys = this.opts[`${regKey}:_keys`]
47+
}
48+
4249
// XXX pacote <=9 has some logic to ignore opts.resolved if
4350
// the resolved URL doesn't go to the same registry.
4451
// Consider reproducing that here, to throw away this.resolved
4552
// in that case.
4653
}
4754

48-
resolve () {
49-
if (this.resolved) {
50-
return Promise.resolve(this.resolved)
51-
}
52-
53-
// fetching the manifest sets resolved and (usually) integrity
54-
return this.manifest().then(() => {
55-
if (this.resolved) {
56-
return this.resolved
57-
}
58-
55+
async resolve () {
56+
// fetching the manifest sets resolved and (if present) integrity
57+
await this.manifest()
58+
if (!this.resolved) {
5959
throw Object.assign(
6060
new Error('Invalid package manifest: no `dist.tarball` field'),
6161
{ package: this.spec.toString() }
6262
)
63-
})
63+
}
64+
return this.resolved
6465
}
6566

6667
[_headers] () {
@@ -87,91 +88,127 @@ class RegistryFetcher extends Fetcher {
8788
// npm-registry-fetch the packument
8889
// set the appropriate header for corgis if fullMetadata isn't set
8990
// return the res.json() promise
90-
const p = fetch(this.packumentUrl, {
91-
...this.opts,
92-
headers: this[_headers](),
93-
spec: this.spec,
94-
// never check integrity for packuments themselves
95-
integrity: null,
96-
}).then(res => res.json().then(packument => {
91+
try {
92+
const res = await fetch(this.packumentUrl, {
93+
...this.opts,
94+
headers: this[_headers](),
95+
spec: this.spec,
96+
// never check integrity for packuments themselves
97+
integrity: null,
98+
})
99+
const packument = await res.json()
97100
packument._cached = res.headers.has('x-local-cache')
98101
packument._contentLength = +res.headers.get('content-length')
99102
if (this.packumentCache) {
100103
this.packumentCache.set(this.packumentUrl, packument)
101104
}
102105
return packument
103-
})).catch(er => {
106+
} catch (err) {
104107
if (this.packumentCache) {
105108
this.packumentCache.delete(this.packumentUrl)
106109
}
107-
if (er.code === 'E404' && !this.fullMetadata) {
108-
// possible that corgis are not supported by this registry
109-
this.fullMetadata = true
110-
return this.packument()
110+
if (err.code !== 'E404' || this.fullMetadata) {
111+
throw err
111112
}
112-
throw er
113-
})
114-
if (this.packumentCache) {
115-
this.packumentCache.set(this.packumentUrl, p)
113+
// possible that corgis are not supported by this registry
114+
this.fullMetadata = true
115+
return this.packument()
116116
}
117-
return p
118117
}
119118

120-
manifest () {
119+
async manifest () {
121120
if (this.package) {
122-
return Promise.resolve(this.package)
121+
return this.package
123122
}
124123

125-
return this.packument()
126-
.then(packument => pickManifest(packument, this.spec.fetchSpec, {
127-
...this.opts,
128-
defaultTag: this.defaultTag,
129-
before: this.before,
130-
}) /* XXX add ETARGET and E403 revalidation of cached packuments here */)
131-
.then(mani => {
132-
// add _resolved and _integrity from dist object
133-
const { dist } = mani
134-
if (dist) {
135-
this.resolved = mani._resolved = dist.tarball
136-
mani._from = this.from
137-
const distIntegrity = dist.integrity ? ssri.parse(dist.integrity)
138-
: dist.shasum ? ssri.fromHex(dist.shasum, 'sha1', { ...this.opts })
139-
: null
140-
if (distIntegrity) {
141-
if (!this.integrity) {
142-
this.integrity = distIntegrity
143-
} else if (!this.integrity.match(distIntegrity)) {
144-
// only bork if they have algos in common.
145-
// otherwise we end up breaking if we have saved a sha512
146-
// previously for the tarball, but the manifest only
147-
// provides a sha1, which is possible for older publishes.
148-
// Otherwise, this is almost certainly a case of holding it
149-
// wrong, and will result in weird or insecure behavior
150-
// later on when building package tree.
151-
for (const algo of Object.keys(this.integrity)) {
152-
if (distIntegrity[algo]) {
153-
throw Object.assign(new Error(
154-
`Integrity checksum failed when using ${algo}: ` +
155-
`wanted ${this.integrity} but got ${distIntegrity}.`
156-
), { code: 'EINTEGRITY' })
157-
}
158-
}
159-
// made it this far, the integrity is worthwhile. accept it.
160-
// the setter here will take care of merging it into what we
161-
// already had.
162-
this.integrity = distIntegrity
124+
const packument = await this.packument()
125+
const mani = await pickManifest(packument, this.spec.fetchSpec, {
126+
...this.opts,
127+
defaultTag: this.defaultTag,
128+
before: this.before,
129+
})
130+
/* XXX add ETARGET and E403 revalidation of cached packuments here */
131+
132+
// add _resolved and _integrity from dist object
133+
const { dist } = mani
134+
if (dist) {
135+
this.resolved = mani._resolved = dist.tarball
136+
mani._from = this.from
137+
const distIntegrity = dist.integrity ? ssri.parse(dist.integrity)
138+
: dist.shasum ? ssri.fromHex(dist.shasum, 'sha1', { ...this.opts })
139+
: null
140+
if (distIntegrity) {
141+
if (this.integrity && !this.integrity.match(distIntegrity)) {
142+
// only bork if they have algos in common.
143+
// otherwise we end up breaking if we have saved a sha512
144+
// previously for the tarball, but the manifest only
145+
// provides a sha1, which is possible for older publishes.
146+
// Otherwise, this is almost certainly a case of holding it
147+
// wrong, and will result in weird or insecure behavior
148+
// later on when building package tree.
149+
for (const algo of Object.keys(this.integrity)) {
150+
if (distIntegrity[algo]) {
151+
throw Object.assign(new Error(
152+
`Integrity checksum failed when using ${algo}: ` +
153+
`wanted ${this.integrity} but got ${distIntegrity}.`
154+
), { code: 'EINTEGRITY' })
163155
}
164156
}
165157
}
166-
if (this.integrity) {
167-
mani._integrity = String(this.integrity)
168-
if (dist.signatures) {
158+
// made it this far, the integrity is worthwhile. accept it.
159+
// the setter here will take care of merging it into what we already
160+
// had.
161+
this.integrity = distIntegrity
162+
}
163+
}
164+
if (this.integrity) {
165+
mani._integrity = String(this.integrity)
166+
if (dist.signatures) {
167+
if (this.opts.verifySignatures) {
168+
if (this.registryKeys) {
169+
// validate and throw on error, then set _signatures
170+
const message = `${mani._id}:${mani._integrity}`
171+
for (const signature of dist.signatures) {
172+
const publicKey = this.registryKeys.filter(key => (key.keyid === signature.keyid))[0]
173+
if (!publicKey) {
174+
throw Object.assign(new Error(
175+
`${mani._id} has a signature with keyid: ${signature.keyid} ` +
176+
'but no corresponding public key can be found.'
177+
), { code: 'EMISSINGSIGNATUREKEY' })
178+
}
179+
const validPublicKey =
180+
!publicKey.expires || (Date.parse(publicKey.expires) > Date.now())
181+
if (!validPublicKey) {
182+
throw Object.assign(new Error(
183+
`${mani._id} has a signature with keyid: ${signature.keyid} ` +
184+
`but the corresponding public key has expired ${publicKey.expires}`
185+
), { code: 'EEXPIREDSIGNATUREKEY' })
186+
}
187+
const verifier = crypto.createVerify('SHA256')
188+
verifier.write(message)
189+
verifier.end()
190+
const valid = verifier.verify(
191+
publicKey.pemkey,
192+
signature.sig,
193+
'base64'
194+
)
195+
if (!valid) {
196+
throw Object.assign(new Error(
197+
'Integrity checksum signature failed: ' +
198+
`key ${publicKey.keyid} signature ${signature.sig}`
199+
), { code: 'EINTEGRITYSIGNATURE' })
200+
}
201+
}
169202
mani._signatures = dist.signatures
170203
}
204+
// if no keys, don't set _signatures
205+
} else {
206+
mani._signatures = dist.signatures
171207
}
172-
this.package = rpj.normalize(mani)
173-
return this.package
174-
})
208+
}
209+
}
210+
this.package = rpj.normalize(mani)
211+
return this.package
175212
}
176213

177214
[_tarballFromResolved] () {

‎test/registry.js

+126
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,132 @@ t.test('provide matching integrity, totes ok, includes signature', async t => {
150150
})
151151
})
152152

153+
t.test('verifySignatures valid signature', async t => {
154+
const f = new RegistryFetcher('@isaacs/namespace-test', {
155+
registry,
156+
cache,
157+
verifySignatures: true,
158+
[`//localhost:${port}/:_keys`]: [{
159+
expires: null,
160+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
161+
keytype: 'ecdsa-sha2-nistp256',
162+
scheme: 'ecdsa-sha2-nistp256',
163+
// eslint-disable-next-line max-len
164+
key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==',
165+
// eslint-disable-next-line max-len
166+
pemkey: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==\n-----END PUBLIC KEY-----',
167+
}],
168+
})
169+
return f.manifest().then(mani => {
170+
t.ok(mani._signatures)
171+
t.ok(mani._integrity)
172+
})
173+
})
174+
175+
t.test('verifySignatures expired signature', async t => {
176+
const f = new RegistryFetcher('@isaacs/namespace-test', {
177+
registry,
178+
cache,
179+
verifySignatures: true,
180+
[`//localhost:${port}/:_keys`]: [{
181+
expires: '2010-01-01',
182+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
183+
keytype: 'ecdsa-sha2-nistp256',
184+
scheme: 'ecdsa-sha2-nistp256',
185+
// eslint-disable-next-line max-len
186+
key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==',
187+
// eslint-disable-next-line max-len
188+
pemkey: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==\n-----END PUBLIC KEY-----',
189+
}],
190+
})
191+
return t.rejects(
192+
f.manifest(),
193+
{
194+
code: 'EEXPIREDSIGNATUREKEY',
195+
}
196+
)
197+
})
198+
199+
t.test('verifySignatures invalid signature', async t => {
200+
tnock(t, 'https://registry.npmjs.org')
201+
.get('/abbrev')
202+
.reply(200, {
203+
_id: 'abbrev',
204+
_rev: 'deadbeef',
205+
name: 'abbrev',
206+
'dist-tags': { latest: '1.1.1' },
207+
versions: {
208+
'1.1.1': {
209+
name: 'abbrev',
210+
version: '1.1.1',
211+
dist: {
212+
// eslint-disable-next-line max-len
213+
integrity: 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==',
214+
shasum: 'f8f2c887ad10bf67f634f005b6987fed3179aac8',
215+
tarball: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz',
216+
signatures: [
217+
{
218+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
219+
sig: 'nope',
220+
},
221+
],
222+
},
223+
},
224+
},
225+
})
226+
227+
const f = new RegistryFetcher('abbrev', {
228+
registry: 'https://registry.npmjs.org',
229+
cache,
230+
verifySignatures: true,
231+
[`//registry.npmjs.org/:_keys`]: [{
232+
expires: null,
233+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
234+
keytype: 'ecdsa-sha2-nistp256',
235+
scheme: 'ecdsa-sha2-nistp256',
236+
// eslint-disable-next-line max-len
237+
key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==',
238+
// eslint-disable-next-line max-len
239+
pemkey: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==\n-----END PUBLIC KEY-----',
240+
}],
241+
})
242+
return t.rejects(
243+
f.manifest(),
244+
{
245+
code: 'EINTEGRITYSIGNATURE',
246+
}
247+
)
248+
})
249+
250+
t.test('verifySignatures no valid key', async t => {
251+
const f = new RegistryFetcher('@isaacs/namespace-test', {
252+
registry,
253+
cache,
254+
verifySignatures: true,
255+
[`//localhost:${port}/:_keys`]: [{
256+
keyid: 'someotherid',
257+
}],
258+
})
259+
return t.rejects(
260+
f.manifest(),
261+
{
262+
code: 'EMISSINGSIGNATUREKEY',
263+
}
264+
)
265+
})
266+
267+
t.test('verifySignatures no registry keys at all', async t => {
268+
const f = new RegistryFetcher('@isaacs/namespace-test', {
269+
registry,
270+
cache,
271+
verifySignatures: true,
272+
[`//localhost:${port}/:_keys`]: null,
273+
})
274+
return f.manifest().then(mani => {
275+
t.notOk(mani._signatures)
276+
})
277+
})
278+
153279
t.test('404 fails with E404', t => {
154280
const f = new RegistryFetcher('thing-is-not-here', { registry, cache })
155281
return t.rejects(f.resolve(), { code: 'E404' }).then(() =>

0 commit comments

Comments
 (0)
Please sign in to comment.