From 08b7957901d7e0a2ed77088fd0cad92d6c7b4581 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Fri, 5 Jan 2024 10:16:13 -0800 Subject: [PATCH] verify RFC3161 timestamps (#932) Signed-off-by: Brian DeHamer --- .changeset/neat-gorillas-eat.md | 5 + packages/mock-server/src/index.ts | 2 +- .../src/__tests__/timestamp/tsa.test.ts | 121 ++++++++++++++++++ .../verify/src/__tests__/verifier.test.ts | 5 + packages/verify/src/key/certificate.ts | 2 +- packages/verify/src/timestamp/index.ts | 13 +- packages/verify/src/timestamp/tsa.ts | 95 ++++++++++++++ packages/verify/src/verifier.ts | 6 +- 8 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 .changeset/neat-gorillas-eat.md create mode 100644 packages/verify/src/__tests__/timestamp/tsa.test.ts create mode 100644 packages/verify/src/timestamp/tsa.ts diff --git a/.changeset/neat-gorillas-eat.md b/.changeset/neat-gorillas-eat.md new file mode 100644 index 00000000..f372f514 --- /dev/null +++ b/.changeset/neat-gorillas-eat.md @@ -0,0 +1,5 @@ +--- +"@sigstore/verify": minor +--- + +Enable verification of RFC3161 timestamps diff --git a/packages/mock-server/src/index.ts b/packages/mock-server/src/index.ts index 2513a9b4..0689f470 100644 --- a/packages/mock-server/src/index.ts +++ b/packages/mock-server/src/index.ts @@ -137,7 +137,7 @@ function assembleTrustedRoot({ ctlogs: [transparencyLogInstance(ctlog.publicKey, url)], tlogs: [transparencyLogInstance(tlog.publicKey, url)], timestampAuthorities: [ - certificateAuthority([tsa.rootCertificate, tsa.intCertificate], url), + certificateAuthority([tsa.intCertificate, tsa.rootCertificate], url), ], }; } diff --git a/packages/verify/src/__tests__/timestamp/tsa.test.ts b/packages/verify/src/__tests__/timestamp/tsa.test.ts new file mode 100644 index 00000000..e323028f --- /dev/null +++ b/packages/verify/src/__tests__/timestamp/tsa.test.ts @@ -0,0 +1,121 @@ +import { RFC3161Timestamp, X509Certificate } from '@sigstore/core'; +import { VerificationError } from '../../error'; +import { verifyRFC3161Timestamp } from '../../timestamp/tsa'; +import { CertAuthority } from '../../trust'; + +describe('verifyRFC3161Timestamp', () => { + const artifact = Buffer.from('hello, world!'); + const tsBytes = Buffer.from( + 'MIICIDADAgEAMIICFwYJKoZIhvcNAQcCoIICCDCCAgQCAQMxDzANBglghkgBZQMEAgEFADB4BgsqhkiG9w0BCRABBKBpBGcwZQIBAQYJKwYBBAGDvzACMC8wCwYJYIZIAWUDBAIBBCBo5layUeZ+g1i++Eg6sNUcZhnz56Gp8OdYONQf82j3KAIE3q2+7xgTMjAyMzEyMjMwMDE3MDMuMDk5WjADAgEBAgRJlgLSoAAxggFwMIIBbAIBATArMCYxDDAKBgNVBAMTA3RzYTEWMBQGA1UEChMNc2lnc3RvcmUubW9jawIBAjANBglghkgBZQMEAgEFAKCB1TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8XDTIzMTIyMzAwMTcwM1owLwYJKoZIhvcNAQkEMSIEIGZcDv7LEHC7SY1TBs1MKmI0MCMChJVp+RCMeyE1mWYZMGgGCyqGSIb3DQEJEAIvMVkwVzBVMFMEIL4AsycoQkVSQDvExPW0mWmW0BAOwQXYUdo6JtfWSP4nMC8wKqQoMCYxDDAKBgNVBAMTA3RzYTEWMBQGA1UEChMNc2lnc3RvcmUubW9jawIBAjAKBggqhkjOPQQDAgRHMEUCIA9EmBywagALb8P8jnn+iZTpb1R9SvQ1QOJ26ICn8SbiAiEAs21Z3xI++DUtb74Yaq0y8Nf5kVPYFtXHQAiG/iBnbKs=', + 'base64' + ); + const signingPEM = `-----BEGIN CERTIFICATE----- +MIIBuzCCAWGgAwIBAgIBAjAKBggqhkjOPQQDAzAmMQwwCgYDVQQDEwN0c2ExFjAU +BgNVBAoTDXNpZ3N0b3JlLm1vY2swHhcNMjMxMjIzMDAxMzU4WhcNMjQxMjIyMDAx +MzU4WjAuMRQwEgYDVQQDEwt0c2Egc2lnbmluZzEWMBQGA1UEChMNc2lnc3RvcmUu +bW9jazBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIVX5ocFJ8M3Z2urwxgiKlpg +cuOI/kU/2Kw3uWtDXhX/J408sXDC+KadlbCOnJW1wk6gnKEw08taYKMN0CVxXbOj +eDB2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoG +CCsGAQUFBwMIMB0GA1UdDgQWBBTw4R0V5NxFPZCd34gdGn1eqokHhjAfBgNVHSME +GDAWgBTw4R0V5NxFPZCd34gdGn1eqokHhjAKBggqhkjOPQQDAwNIADBFAiA8eoBs +sQJyxMwPuAgtqsf33aA6F+9HmJbRmt2dvYBfvgIhAL5bxfSG/5VjH5+DHFX0XF7h +6rqeM9DKbNU0HmxMV0Te +-----END CERTIFICATE-----`; + const rootPEM = `-----BEGIN CERTIFICATE----- +MIIBnjCCAUSgAwIBAgIBATAKBggqhkjOPQQDAzAmMQwwCgYDVQQDEwN0c2ExFjAU +BgNVBAoTDXNpZ3N0b3JlLm1vY2swHhcNMjMxMjIzMDAxMzU4WhcNMjQxMjIyMDAx +MzU4WjAmMQwwCgYDVQQDEwN0c2ExFjAUBgNVBAoTDXNpZ3N0b3JlLm1vY2swWTAT +BgcqhkjOPQIBBggqhkjOPQMBBwNCAASFV+aHBSfDN2drq8MYIipaYHLjiP5FP9is +N7lrQ14V/yeNPLFwwvimnZWwjpyVtcJOoJyhMNPLWmCjDdAlcV2zo2MwYTAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU8OEdFeTcRT2Q +nd+IHRp9XqqJB4YwHwYDVR0jBBgwFoAU8OEdFeTcRT2Qnd+IHRp9XqqJB4YwCgYI +KoZIzj0EAwMDSAAwRQIhAPRvk0qPFRYZWSIH7UQnG00D0I7pV6B4TPjgpL5VvYaQ +AiB13l000lcAWegsMFZJmPEpKNCndg9LC4ttahNN7REmjw== +-----END CERTIFICATE-----`; + + describe('when the timestamp is valid', () => { + const ts = RFC3161Timestamp.parse(tsBytes); + const signingCert = X509Certificate.parse(signingPEM); + const rootCert = X509Certificate.parse(rootPEM); + const certAuthority: CertAuthority = { + certChain: [signingCert, rootCert], + validFor: { start: new Date(1), end: new Date() }, + }; + + it('does NOT throw an error', () => { + verifyRFC3161Timestamp(ts, artifact, [certAuthority]); + }); + }); + + describe('when there are no matching CAs to verify against', () => { + const ts = RFC3161Timestamp.parse(tsBytes); + const certAuthority: CertAuthority = { + certChain: [], + validFor: { start: new Date(1), end: new Date() }, + }; + + it('throws an error', () => { + expect(() => + verifyRFC3161Timestamp(ts, artifact, [certAuthority]) + ).toThrowWithCode(VerificationError, 'TIMESTAMP_ERROR'); + }); + }); + + describe('when the CA certificate chain is invalid', () => { + const ts = RFC3161Timestamp.parse(tsBytes); + const signingCert = X509Certificate.parse(signingPEM); + + // No root cert in CA + const certAuthority: CertAuthority = { + certChain: [signingCert], + validFor: { start: new Date(1), end: new Date() }, + }; + + it('throws an error', () => { + expect(() => + verifyRFC3161Timestamp(ts, artifact, [certAuthority]) + ).toThrowWithCode(VerificationError, 'TIMESTAMP_ERROR'); + }); + }); + + describe('when the artifact does NOT match the signature', () => { + const ts = RFC3161Timestamp.parse(tsBytes); + const signingCert = X509Certificate.parse(signingPEM); + const rootCert = X509Certificate.parse(rootPEM); + const certAuthority: CertAuthority = { + certChain: [signingCert, rootCert], + validFor: { start: new Date(1), end: new Date() }, + }; + + it('throws an error', () => { + expect(() => + verifyRFC3161Timestamp(ts, Buffer.from('oops'), [certAuthority]) + ).toThrowWithCode(VerificationError, 'TIMESTAMP_ERROR'); + }); + }); + + describe('when the timestamp is outside the validity window of the CA', () => { + // The signed timestamp is 2030-01-01, but the CA is only valid until 2024-12-22 + const ts = RFC3161Timestamp.parse( + Buffer.from( + 'MIICGzADAgEAMIICEgYJKoZIhvcNAQcCoIICAzCCAf8CAQMxDzANBglghkgBZQMEAgEFADB0BgsqhkiG9w0BCRABBKBlBGMwYQIBAQYJKwYBBAGDvzACMC8wCwYJYIZIAWUDBAIBBCBo5layUeZ+g1i++Eg6sNUcZhnz56Gp8OdYONQf82j3KAIE3q2+7xgPMjAzMDAxMDEwMDAwMDBaMAMCAQECBEmWAtKgADGCAW8wggFrAgEBMCswJjEMMAoGA1UEAxMDdHNhMRYwFAYDVQQKEw1zaWdzdG9yZS5tb2NrAgECMA0GCWCGSAFlAwQCAQUAoIHVMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMzAwMTAxMDAwMDAwWjAvBgkqhkiG9w0BCQQxIgQghLJ03rMmmDV8/1BGWyKNpH5kwjqbDox0jKcux/tbCKMwaAYLKoZIhvcNAQkQAi8xWTBXMFUwUwQgvgCzJyhCRVJAO8TE9bSZaZbQEA7BBdhR2jom19ZI/icwLzAqpCgwJjEMMAoGA1UEAxMDdHNhMRYwFAYDVQQKEw1zaWdzdG9yZS5tb2NrAgECMAoGCCqGSM49BAMCBEYwRAIgdgyZVd9hRlf0eL6ConhIwsuQD5CcxqluBWgNmU8p03sCIEsO7OyjSVyk8wzMntY17C4UaRCnNKIjjHElijJDUb4t', + 'base64' + ) + ); + const signingCert = X509Certificate.parse(signingPEM); + const rootCert = X509Certificate.parse(rootPEM); + + // Setting the validity window to be far in the future so that the timestamp can be + // compared against the validity window embedded in the CA certs themselves. + const certAuthority: CertAuthority = { + certChain: [signingCert, rootCert], + validFor: { start: new Date(1), end: new Date('2040-01-01') }, + }; + + it('throws an error', () => { + expect(() => + verifyRFC3161Timestamp(ts, artifact, [certAuthority]) + ).toThrowWithCode(VerificationError, 'TIMESTAMP_ERROR'); + }); + }); +}); diff --git a/packages/verify/src/__tests__/verifier.test.ts b/packages/verify/src/__tests__/verifier.test.ts index 3a3e1159..84d6a6d5 100644 --- a/packages/verify/src/__tests__/verifier.test.ts +++ b/packages/verify/src/__tests__/verifier.test.ts @@ -49,6 +49,11 @@ describe('Verifier', () => { const subject = new Verifier(trustMaterial); describe('when the certificate-signed message signature bundle is valid', () => { + const subject = new Verifier(trustMaterial, { + ctlogThreshold: 1, + tlogThreshold: 1, + tsaThreshold: 1, + }); const bundle = bundleFromJSON( bundles.V1.MESSAGE_SIGNATURE.WITH_SIGNING_CERT ); diff --git a/packages/verify/src/key/certificate.ts b/packages/verify/src/key/certificate.ts index 3dc80058..58cc0a0b 100644 --- a/packages/verify/src/key/certificate.ts +++ b/packages/verify/src/key/certificate.ts @@ -58,7 +58,7 @@ interface CertificateChainVerifierOptions { untrustedCert: X509Certificate; } -class CertificateChainVerifier { +export class CertificateChainVerifier { private untrustedCert: X509Certificate; private trustedCerts: X509Certificate[]; private localCerts: X509Certificate[]; diff --git a/packages/verify/src/timestamp/index.ts b/packages/verify/src/timestamp/index.ts index c7f0f16b..f2c7846e 100644 --- a/packages/verify/src/timestamp/index.ts +++ b/packages/verify/src/timestamp/index.ts @@ -13,12 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import type { RFC3161Timestamp } from '@sigstore/core'; -import assert from 'assert'; +import { RFC3161Timestamp } from '@sigstore/core'; import { VerificationError } from '../error'; import { verifyCheckpoint } from './checkpoint'; import { verifyMerkleInclusion } from './merkle'; import { verifyTLogSET } from './set'; +import { verifyRFC3161Timestamp } from './tsa'; import type { TLogEntryWithInclusionPromise, @@ -37,14 +37,15 @@ export type TimestampVerificationResult = { export function verifyTSATimestamp( timestamp: RFC3161Timestamp, + data: Buffer, timestampAuthorities: CertAuthority[] ): TimestampVerificationResult { - // TODO: Insert TSA verification logic here. - assert(timestampAuthorities); + verifyRFC3161Timestamp(timestamp, data, timestampAuthorities); + return { - logID: timestamp.signerSerialNumber, - timestamp: timestamp.tstInfo.genTime, type: 'timestamp-authority', + logID: timestamp.signerSerialNumber, + timestamp: timestamp.signingTime, }; } diff --git a/packages/verify/src/timestamp/tsa.ts b/packages/verify/src/timestamp/tsa.ts new file mode 100644 index 00000000..0d5c2b48 --- /dev/null +++ b/packages/verify/src/timestamp/tsa.ts @@ -0,0 +1,95 @@ +import { RFC3161Timestamp, crypto } from '@sigstore/core'; +import { VerificationError } from '../error'; +import { CertificateChainVerifier } from '../key/certificate'; +import { CertAuthority, filterCertAuthorities } from '../trust'; + +export function verifyRFC3161Timestamp( + timestamp: RFC3161Timestamp, + data: Buffer, + timestampAuthorities: CertAuthority[] +): void { + const signingTime = timestamp.signingTime; + + // Filter for CAs which were valid at the time of signing + timestampAuthorities = filterCertAuthorities(timestampAuthorities, { + start: signingTime, + end: signingTime, + }); + + // Filter for CAs which match serial and issuer embedded in the timestamp + timestampAuthorities = filterCAsBySerialAndIssuer(timestampAuthorities, { + serialNumber: timestamp.signerSerialNumber, + issuer: timestamp.signerIssuer, + }); + + // Check that we can verify the timestamp with AT LEAST ONE of the remaining + // CAs + const verified = timestampAuthorities.some((ca) => { + try { + verifyTimestampForCA(timestamp, data, ca); + return true; + } catch (e) { + return false; + } + }); + + if (!verified) { + throw new VerificationError({ + code: 'TIMESTAMP_ERROR', + message: 'timestamp could not be verified', + }); + } +} + +function verifyTimestampForCA( + timestamp: RFC3161Timestamp, + data: Buffer, + ca: CertAuthority +): void { + const [leaf, ...cas] = ca.certChain; + const signingKey = crypto.createPublicKey(leaf.publicKey); + const signingTime = timestamp.signingTime; + + // Verify the certificate chain for the provided CA + try { + new CertificateChainVerifier({ + untrustedCert: leaf, + trustedCerts: cas, + }).verify(); + } catch (e) { + throw new VerificationError({ + code: 'TIMESTAMP_ERROR', + message: 'invalid certificate chain', + }); + } + + // Check that all of the CA certs were valid at the time of signing + const validAtSigningTime = ca.certChain.every((cert) => + cert.validForDate(signingTime) + ); + + if (!validAtSigningTime) { + throw new VerificationError({ + code: 'TIMESTAMP_ERROR', + message: 'timestamp was signed with an expired certificate', + }); + } + + // Check that the signing certificate's key can be used to verify the + // timestamp signature. + timestamp.verify(data, signingKey); +} + +// Filters the list of CAs to those which have a leaf signing certificate which +// matches the given serial number and issuer. +function filterCAsBySerialAndIssuer( + timestampAuthorities: CertAuthority[], + criteria: { serialNumber: Buffer; issuer: Buffer } +): CertAuthority[] { + return timestampAuthorities.filter( + (ca) => + ca.certChain.length > 0 && + crypto.bufferEqual(ca.certChain[0].serialNumber, criteria.serialNumber) && + crypto.bufferEqual(ca.certChain[0].issuer, criteria.issuer) + ); +} diff --git a/packages/verify/src/verifier.ts b/packages/verify/src/verifier.ts index c5a5148d..646e9e25 100644 --- a/packages/verify/src/verifier.ts +++ b/packages/verify/src/verifier.ts @@ -63,15 +63,15 @@ export class Verifier { // Checks that all of the timestamps in the entity are valid and returns them private verifyTimestamps(entity: SignedEntity): Date[] { let tlogCount = 0; - const tsaCount = 0; + let tsaCount = 0; const timestamps = entity.timestamps.map((timestamp) => { switch (timestamp.$case) { case 'timestamp-authority': - // TODO: uncomment this when we are actually verifying timestamps - // tsaCount++; + tsaCount++; return verifyTSATimestamp( timestamp.timestamp, + entity.signature.signature, this.trustMaterial.timestampAuthorities ); case 'transparency-log':