Skip to content

Commit 65639f0

Browse files
committed
Fix bug to support rotated keys in signature/attestation audit
*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 install loop. Signed-off-by: Philip Harrison <[email protected]>
1 parent 9e9e187 commit 65639f0

File tree

2 files changed

+95
-6
lines changed

2 files changed

+95
-6
lines changed

lib/registry.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ 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 to
18+
// fallback to a time that is before we could have a registry rotate keys. This
19+
// is snapped to just before we shipped `npm audit signatures` in 8.14.0.
20+
const NO_TIME_CUTOFF_TIME = '2022-07-12T00:00:00.000Z'
21+
1722
const fetch = require('npm-registry-fetch')
1823

1924
const _headers = Symbol('_headers')
@@ -124,6 +129,13 @@ class RegistryFetcher extends Fetcher {
124129
mani = rpj.normalize(mani)
125130
/* XXX add ETARGET and E403 revalidation of cached packuments here */
126131

132+
// add _time from packument if fetched with fullMetadata
133+
const { time: verTimes } = packument
134+
const time = verTimes && verTimes[mani.version] && verTimes[mani.version]
135+
if (time) {
136+
mani._time = time
137+
}
138+
127139
// add _resolved and _integrity from dist object
128140
const { dist } = mani
129141
if (dist) {
@@ -171,8 +183,10 @@ class RegistryFetcher extends Fetcher {
171183
'but no corresponding public key can be found'
172184
), { code: 'EMISSINGSIGNATUREKEY' })
173185
}
174-
const validPublicKey =
175-
!publicKey.expires || (Date.parse(publicKey.expires) > Date.now())
186+
187+
const publishedTime = Date.parse(mani._time || NO_TIME_CUTOFF_TIME)
188+
const validPublicKey = !publicKey.expires ||
189+
publishedTime < Date.parse(publicKey.expires)
176190
if (!validPublicKey) {
177191
throw Object.assign(new Error(
178192
`${mani._id} has a registry signature with keyid: ${signature.keyid} ` +
@@ -254,8 +268,13 @@ class RegistryFetcher extends Fetcher {
254268
), { code: 'EMISSINGSIGNATUREKEY' })
255269
}
256270

257-
const validPublicKey =
258-
!publicKey.expires || (Date.parse(publicKey.expires) > Date.now())
271+
const integratedTime = new Date(
272+
Number(
273+
bundle.verificationMaterial.tlogEntries[0].integratedTime
274+
) * 1000
275+
)
276+
const validPublicKey = !publicKey.expires ||
277+
(integratedTime < Date.parse(publicKey.expires))
259278
if (!validPublicKey) {
260279
throw Object.assign(new Error(
261280
`${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)