From 5f73c9dd356c6dc6dfb100260605689d258ea11a Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Fri, 20 Mar 2026 08:11:30 +0000 Subject: [PATCH] fix(verification): use timestamp time for TSA cert chain validation instead of current time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sonarly.com/issue/16857?type=bug Attestation timestamp verification fails for all attestations using the FreeTSA timestamp authority because the TSA leaf certificate expired on March 11, 2026, and the verification code validates the cert chain at current system time instead of at the timestamp's issue time. Fix: ## What changed Replaced `verification.VerifyTimestampResponse` from `sigstore/timestamp-authority/v2` with a new `verifyTimestampAtTime` function that validates the TSA certificate chain at the **timestamp's time** rather than the current system time. ### Why The upstream `verification.VerifyTimestampResponse` delegates certificate chain validation to Go's `x509.VerifyOptions` without setting `CurrentTime`, which defaults to `time.Now()`. When a TSA certificate expires (as FreeTSA's leaf cert did on March 11, 2026), all timestamp verification fails — even for timestamps that were validly issued while the cert was still active. For timestamp verification, the semantically correct behavior is to validate the TSA certificate chain at the time the timestamp was issued, not at the current time. A timestamp proves a signature existed at a specific point in time; the TSA cert only needs to have been valid at that moment. ### How The new `verifyTimestampAtTime` function: 1. **Parses the timestamp response** using `timestamp.ParseResponse` (from `digitorus/timestamp`, already a direct dependency), which also verifies the PKCS7 signature 2. **Verifies the hashed message** matches the provided signature bytes using the hash algorithm specified in the timestamp 3. **Validates the TSA certificate chain** using `x509.Certificate.Verify` with `CurrentTime` set to the parsed timestamp's time This removes the dependency on `sigstore/timestamp-authority/v2/pkg/verification` from this file. ### Additional note While this code fix handles verification of timestamps signed by expired TSA certs, the production deployment should also update the TSA configuration if the FreeTSA service itself is no longer issuing valid timestamps with an expired cert. New attestations would need a TSA with a valid certificate to obtain timestamps. A new test file `timestamp_test.go` covers the key scenarios including the exact bug case (cert expired at current time but valid at timestamp time). --- pkg/attestation/verifier/timestamp.go | 51 ++++- pkg/attestation/verifier/timestamp_test.go | 215 +++++++++++++++++++++ 2 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 pkg/attestation/verifier/timestamp_test.go diff --git a/pkg/attestation/verifier/timestamp.go b/pkg/attestation/verifier/timestamp.go index bb3f30050..7ffeba89d 100644 --- a/pkg/attestation/verifier/timestamp.go +++ b/pkg/attestation/verifier/timestamp.go @@ -24,7 +24,6 @@ import ( "github.com/digitorus/timestamp" "github.com/sigstore/sigstore-go/pkg/bundle" - "github.com/sigstore/timestamp-authority/v2/pkg/verification" ) func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error { @@ -68,12 +67,7 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error { roots = tsa[len(tsa)-1:] intermediates = tsa[1 : len(tsa)-1] } - ts, err := verification.VerifyTimestampResponse(st, bytes.NewReader(sigBytes), - verification.VerifyOpts{ - TSACertificate: tsaCert, - Intermediates: intermediates, - Roots: roots, - }) + ts, err := verifyTimestampAtTime(st, sigBytes, tsaCert, intermediates, roots) if err != nil { continue } @@ -98,3 +92,46 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error { } return nil } + +// verifyTimestampAtTime parses and verifies a timestamp response, validating +// the TSA certificate chain at the timestamp's time rather than the current time. +// This is semantically correct because a timestamp proves a signature existed at a +// specific point in time — the TSA certificate only needs to have been valid then. +func verifyTimestampAtTime(tsrBytes, signature []byte, tsaCert *x509.Certificate, intermediates, roots []*x509.Certificate) (*timestamp.Timestamp, error) { + // Parse and verify the PKCS7 signature in the timestamp response + ts, err := timestamp.ParseResponse(tsrBytes) + if err != nil { + return nil, fmt.Errorf("parsing timestamp response: %w", err) + } + + // Verify the hashed message matches the provided signature + h := ts.HashAlgorithm.New() + h.Write(signature) + if !bytes.Equal(h.Sum(nil), ts.HashedMessage) { + return nil, fmt.Errorf("hashed message mismatch") + } + + // Verify the TSA certificate chain at the timestamp's time. + // The upstream verification.VerifyTimestampResponse uses time.Now() for + // x509 chain validation, which causes failures when TSA certs expire + // even though the timestamps they issued were valid. + rootPool := x509.NewCertPool() + for _, r := range roots { + rootPool.AddCert(r) + } + intermediatePool := x509.NewCertPool() + for _, im := range intermediates { + intermediatePool.AddCert(im) + } + _, err = tsaCert.Verify(x509.VerifyOptions{ + Roots: rootPool, + Intermediates: intermediatePool, + CurrentTime: ts.Time, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping}, + }) + if err != nil { + return nil, fmt.Errorf("verifying TSA certificate chain: %w", err) + } + + return ts, nil +} diff --git a/pkg/attestation/verifier/timestamp_test.go b/pkg/attestation/verifier/timestamp_test.go new file mode 100644 index 000000000..7577dd25f --- /dev/null +++ b/pkg/attestation/verifier/timestamp_test.go @@ -0,0 +1,215 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// 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. + +package verifier + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "testing" + "time" + + "github.com/digitorus/pkcs7" + "github.com/digitorus/timestamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifyTimestampAtTime(t *testing.T) { + // Create a CA (root) certificate + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Root CA"}, + NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + // signatureToTimestamp is the artifact being timestamped + signatureToTimestamp := []byte("test-signature-data") + + // createTSACertAndResponse creates a TSA leaf cert with the given validity window, + // then generates a signed timestamp response at the given timestamp time. + createTSACertAndResponse := func(t *testing.T, certNotBefore, certNotAfter, tsTime time.Time) (*x509.Certificate, []byte) { + t.Helper() + + tsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + tsaTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test TSA"}, + NotBefore: certNotBefore, + NotAfter: certNotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping}, + } + tsaDER, err := x509.CreateCertificate(rand.Reader, tsaTemplate, caTemplate, &tsaKey.PublicKey, caKey) + require.NoError(t, err) + tsaCert, err := x509.ParseCertificate(tsaDER) + require.NoError(t, err) + + // Build a timestamp token (RFC 3161) + h := crypto.SHA256.New() + h.Write(signatureToTimestamp) + hashedMessage := h.Sum(nil) + + tsrBytes := buildTimestampResponse(t, tsaCert, tsaKey, hashedMessage, tsTime) + return tsaCert, tsrBytes + } + + cases := []struct { + name string + certNotBefore time.Time + certNotAfter time.Time + tsTime time.Time + expectErr string + }{ + { + name: "valid: timestamp within cert validity", + certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + certNotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), + tsTime: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "valid: cert expired now but was valid at timestamp time", + certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + certNotAfter: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + tsTime: time.Date(2023, 6, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "invalid: timestamp before cert validity", + certNotBefore: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + certNotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), + tsTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + expectErr: "verifying TSA certificate chain", + }, + { + name: "invalid: timestamp after cert validity", + certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + certNotAfter: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + tsTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + expectErr: "verifying TSA certificate chain", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tsaCert, tsrBytes := createTSACertAndResponse(t, tc.certNotBefore, tc.certNotAfter, tc.tsTime) + + ts, err := verifyTimestampAtTime(tsrBytes, signatureToTimestamp, tsaCert, nil, []*x509.Certificate{caCert}) + if tc.expectErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectErr) + return + } + require.NoError(t, err) + assert.False(t, ts.Time.IsZero()) + }) + } + + t.Run("invalid: hash mismatch", func(t *testing.T) { + tsaCert, tsrBytes := createTSACertAndResponse(t, + time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + ) + wrongSignature := []byte("wrong-signature-data") + _, err := verifyTimestampAtTime(tsrBytes, wrongSignature, tsaCert, nil, []*x509.Certificate{caCert}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "hashed message mismatch") + }) +} + +// buildTimestampResponse creates a minimal RFC 3161 timestamp response for testing. +func buildTimestampResponse(t *testing.T, tsaCert *x509.Certificate, tsaKey *rsa.PrivateKey, hashedMessage []byte, tsTime time.Time) []byte { + t.Helper() + + // Build the TSTInfo (timestamp token info) + tstInfo := struct { + Version int + Policy asn1.ObjectIdentifier + MessageImprint struct { + HashAlgorithm pkix.AlgorithmIdentifier + HashedMessage []byte + } + SerialNumber *big.Int + GenTime time.Time `asn1:"generalized"` + }{ + Version: 1, + Policy: asn1.ObjectIdentifier{1, 2, 3, 4}, + MessageImprint: struct { + HashAlgorithm pkix.AlgorithmIdentifier + HashedMessage []byte + }{ + HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}}, // SHA-256 + HashedMessage: hashedMessage, + }, + SerialNumber: big.NewInt(100), + GenTime: tsTime, + } + tstInfoDER, err := asn1.Marshal(tstInfo) + require.NoError(t, err) + + // Wrap in a PKCS7 signed data structure + signedData, err := pkcs7.NewSignedData(tstInfoDER) + require.NoError(t, err) + // Use OID for id-smime-ct-TSTInfo + signedData.SetContentType(asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 1, 4}) + err = signedData.AddSigner(tsaCert, tsaKey, pkcs7.SignerInfoConfig{}) + require.NoError(t, err) + p7DER, err := signedData.Finish() + require.NoError(t, err) + + // Wrap in a TimeStampResp structure + tsResp := struct { + Status struct { + Status int + } + TimeStampToken asn1.RawValue + }{ + Status: struct{ Status int }{Status: 0}, // granted + TimeStampToken: asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: p7DER, + }, + } + + // Re-parse the PKCS7 to get the full DER with the outer SEQUENCE tag + tsRespBytes, err := asn1.Marshal(tsResp) + require.NoError(t, err) + + // Verify our test fixture is valid by parsing it + _, err = timestamp.ParseResponse(tsRespBytes) + require.NoError(t, err, "test timestamp response should be parseable") + + return tsRespBytes +}