Skip to content

Commit 0c96b9e

Browse files
authored
fix: bug to support rotated keys in signature/attestation audit (#338)
*Context* `npm audit signatures` performs the registry signature and sigstore attestation bundle verification in `pacote`. The current code checks if the public key from the tuf trust root keys target has expires set to in the past: https://github.com/npm/pacote/blob/main/lib/registry.js#L174-L175 If we decide to rotate signing keys and add expires to a old public key, verification will always fail saying the key for old packages has expired. This means we can't rotate signing keys for npm at the moment! *Solution* Check public key expiry against either `integratedTime` for attestations or the publish time for registry signatures. This allows us to rotate a key, setting expiry to after all packages that where signed with that key where published. Complication: some really old npm packages don't have `time` set so we need some kind of cutoff date for these packages. Having time in the packument also requires the npm/cli to fetch the full manifest, not the minified packument that does not contain time. This will restrict usage in the npm/cli install loop. Some other solutions I considered where backfilling packages with a `signedAt` timestamp in the `dist` object but this is a pretty big effort. Signed-off-by: Philip Harrison <[email protected]>
1 parent 9e9e187 commit 0c96b9e

File tree

2 files changed

+100
-6
lines changed

2 files changed

+100
-6
lines changed

lib/registry.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const sigstore = require('sigstore')
1414
const corgiDoc = 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*'
1515
const fullDoc = 'application/json'
1616

17+
// Some really old packages have no time field in their packument so we need a
18+
// cutoff date.
19+
const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
20+
1721
const fetch = require('npm-registry-fetch')
1822

1923
const _headers = Symbol('_headers')
@@ -115,6 +119,13 @@ class RegistryFetcher extends Fetcher {
115119
return this.package
116120
}
117121

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+
118129
const packument = await this.packument()
119130
let mani = await pickManifest(packument, this.spec.fetchSpec, {
120131
...this.opts,
@@ -124,6 +135,12 @@ class RegistryFetcher extends Fetcher {
124135
mani = rpj.normalize(mani)
125136
/* XXX add ETARGET and E403 revalidation of cached packuments here */
126137

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+
127144
// add _resolved and _integrity from dist object
128145
const { dist } = mani
129146
if (dist) {
@@ -171,8 +188,10 @@ class RegistryFetcher extends Fetcher {
171188
'but no corresponding public key can be found'
172189
), { code: 'EMISSINGSIGNATUREKEY' })
173190
}
174-
const validPublicKey =
175-
!publicKey.expires || (Date.parse(publicKey.expires) > Date.now())
191+
192+
const publishedTime = Date.parse(mani._time || MISSING_TIME_CUTOFF)
193+
const validPublicKey = !publicKey.expires ||
194+
publishedTime < Date.parse(publicKey.expires)
176195
if (!validPublicKey) {
177196
throw Object.assign(new Error(
178197
`${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
@@ -254,8 +273,13 @@ class RegistryFetcher extends Fetcher {
254273
), { code: 'EMISSINGSIGNATUREKEY' })
255274
}
256275

257-
const validPublicKey =
258-
!publicKey.expires || (Date.parse(publicKey.expires) > Date.now())
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))
259283
if (!validPublicKey) {
260284
throw Object.assign(new Error(
261285
`${mani._id} has attestations with keyid: ${keyid} ` +

test/registry.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,13 @@ t.test('verifySignatures valid signature', async t => {
186186
t.ok(mani._integrity)
187187
})
188188

189-
t.test('verifySignatures expired signature', async t => {
189+
t.test('verifySignatures expired key', async t => {
190190
const f = new RegistryFetcher('@isaacs/namespace-test', {
191191
registry,
192192
cache,
193193
verifySignatures: true,
194194
[`//localhost:${port}/:_keys`]: [{
195-
expires: '2010-01-01',
195+
expires: '2010-01-01T00:00:00.000Z',
196196
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
197197
keytype: 'ecdsa-sha2-nistp256',
198198
scheme: 'ecdsa-sha2-nistp256',
@@ -210,6 +210,45 @@ t.test('verifySignatures expired signature', async t => {
210210
)
211211
})
212212

213+
t.test('verifySignatures rotated keys', async t => {
214+
const f = new RegistryFetcher('@isaacs/namespace-test', {
215+
registry,
216+
cache,
217+
verifySignatures: true,
218+
[`//localhost:${port}/:_keys`]: [{
219+
expires: '2020-06-28T18:46:27.981Z', // Expired AFTER publish time: 2019-06-28T18:46:27.981Z
220+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
221+
keytype: 'ecdsa-sha2-nistp256',
222+
scheme: 'ecdsa-sha2-nistp256',
223+
// eslint-disable-next-line max-len
224+
key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==',
225+
// eslint-disable-next-line max-len
226+
pemkey: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==\n-----END PUBLIC KEY-----',
227+
}, {
228+
expires: null,
229+
keyid: 'SHA256:123',
230+
keytype: 'ecdsa-sha2-nistp256',
231+
scheme: 'ecdsa-sha2-nistp256',
232+
// eslint-disable-next-line max-len
233+
key: '123',
234+
// eslint-disable-next-line max-len
235+
pemkey: '-----BEGIN PUBLIC KEY-----\n123\n-----END PUBLIC KEY-----',
236+
}],
237+
})
238+
const mani = await f.manifest()
239+
t.match(mani, {
240+
// eslint-disable-next-line max-len
241+
_integrity: 'sha512-5ZYe1LgwHIaag0p9loMwsf5N/wJ4XAuHVNhSO+qulQOXWnyJVuco6IZjo+5u4ZLF/GimdHJcX+QK892ONfOCqQ==',
242+
_signatures: [
243+
{
244+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
245+
// eslint-disable-next-line max-len
246+
sig: 'MEQCIHXwKYe70+xcDOvFhM1etZQFUKEwz9VarppUbp5/Ie1+AiAM7aZcT1a2JR0oF/XwjNb13YEHwiagnDapLgYbklRvtA==',
247+
},
248+
],
249+
})
250+
})
251+
213252
t.test('verifySignatures invalid signature', async t => {
214253
tnock(t, 'https://registry.npmjs.org')
215254
.get('/abbrev')
@@ -837,6 +876,37 @@ t.test('verifyAttestations no valid key', async t => {
837876
)
838877
})
839878

879+
t.test('verifyAttestations rotated key', async t => {
880+
const fixture = fs.readFileSync(
881+
path.join(__dirname, 'fixtures', 'sigstore/valid-attestations.json'),
882+
'utf8'
883+
)
884+
885+
tnock(t, 'https://registry.npmjs.org')
886+
.get('/-/npm/v1/attestations/[email protected]')
887+
.reply(200, JSON.parse(fixture))
888+
889+
const f = new MockedRegistryFetcher('[email protected]', {
890+
registry: 'https://registry.npmjs.org',
891+
cache,
892+
verifyAttestations: true,
893+
[`//registry.npmjs.org/:_keys`]: [{
894+
expires: '2023-04-01T00:00:00.000Z', // Rotated AFTER integratedTime 2023-01-11T17:31:54.000Z
895+
keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA',
896+
keytype: 'ecdsa-sha2-nistp256',
897+
scheme: 'ecdsa-sha2-nistp256',
898+
// eslint-disable-next-line max-len
899+
key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==',
900+
// eslint-disable-next-line max-len
901+
pemkey: '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==\n-----END PUBLIC KEY-----',
902+
}],
903+
})
904+
905+
const mani = await f.manifest()
906+
t.ok(mani._attestations)
907+
t.ok(mani._integrity)
908+
})
909+
840910
t.test('verifyAttestations no registry keys at all', async t => {
841911
tnock(t, 'https://registry.npmjs.org')
842912
.get('/sigstore')

0 commit comments

Comments
 (0)