Skip to content

Commit 5f73c9d

Browse files
author
Sonarly Claude Code
committed
fix(verification): use timestamp time for TSA cert chain validation instead of current time
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).
1 parent e93bf92 commit 5f73c9d

2 files changed

Lines changed: 259 additions & 7 deletions

File tree

pkg/attestation/verifier/timestamp.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import (
2424

2525
"github.com/digitorus/timestamp"
2626
"github.com/sigstore/sigstore-go/pkg/bundle"
27-
"github.com/sigstore/timestamp-authority/v2/pkg/verification"
2827
)
2928

3029
func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
@@ -68,12 +67,7 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
6867
roots = tsa[len(tsa)-1:]
6968
intermediates = tsa[1 : len(tsa)-1]
7069
}
71-
ts, err := verification.VerifyTimestampResponse(st, bytes.NewReader(sigBytes),
72-
verification.VerifyOpts{
73-
TSACertificate: tsaCert,
74-
Intermediates: intermediates,
75-
Roots: roots,
76-
})
70+
ts, err := verifyTimestampAtTime(st, sigBytes, tsaCert, intermediates, roots)
7771
if err != nil {
7872
continue
7973
}
@@ -98,3 +92,46 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
9892
}
9993
return nil
10094
}
95+
96+
// verifyTimestampAtTime parses and verifies a timestamp response, validating
97+
// the TSA certificate chain at the timestamp's time rather than the current time.
98+
// This is semantically correct because a timestamp proves a signature existed at a
99+
// specific point in time — the TSA certificate only needs to have been valid then.
100+
func verifyTimestampAtTime(tsrBytes, signature []byte, tsaCert *x509.Certificate, intermediates, roots []*x509.Certificate) (*timestamp.Timestamp, error) {
101+
// Parse and verify the PKCS7 signature in the timestamp response
102+
ts, err := timestamp.ParseResponse(tsrBytes)
103+
if err != nil {
104+
return nil, fmt.Errorf("parsing timestamp response: %w", err)
105+
}
106+
107+
// Verify the hashed message matches the provided signature
108+
h := ts.HashAlgorithm.New()
109+
h.Write(signature)
110+
if !bytes.Equal(h.Sum(nil), ts.HashedMessage) {
111+
return nil, fmt.Errorf("hashed message mismatch")
112+
}
113+
114+
// Verify the TSA certificate chain at the timestamp's time.
115+
// The upstream verification.VerifyTimestampResponse uses time.Now() for
116+
// x509 chain validation, which causes failures when TSA certs expire
117+
// even though the timestamps they issued were valid.
118+
rootPool := x509.NewCertPool()
119+
for _, r := range roots {
120+
rootPool.AddCert(r)
121+
}
122+
intermediatePool := x509.NewCertPool()
123+
for _, im := range intermediates {
124+
intermediatePool.AddCert(im)
125+
}
126+
_, err = tsaCert.Verify(x509.VerifyOptions{
127+
Roots: rootPool,
128+
Intermediates: intermediatePool,
129+
CurrentTime: ts.Time,
130+
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
131+
})
132+
if err != nil {
133+
return nil, fmt.Errorf("verifying TSA certificate chain: %w", err)
134+
}
135+
136+
return ts, nil
137+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//
2+
// Copyright 2025 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package verifier
17+
18+
import (
19+
"crypto"
20+
"crypto/rand"
21+
"crypto/rsa"
22+
"crypto/x509"
23+
"crypto/x509/pkix"
24+
"encoding/asn1"
25+
"math/big"
26+
"testing"
27+
"time"
28+
29+
"github.com/digitorus/pkcs7"
30+
"github.com/digitorus/timestamp"
31+
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/require"
33+
)
34+
35+
func TestVerifyTimestampAtTime(t *testing.T) {
36+
// Create a CA (root) certificate
37+
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
38+
require.NoError(t, err)
39+
40+
caTemplate := &x509.Certificate{
41+
SerialNumber: big.NewInt(1),
42+
Subject: pkix.Name{CommonName: "Test Root CA"},
43+
NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
44+
NotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
45+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
46+
IsCA: true,
47+
BasicConstraintsValid: true,
48+
}
49+
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
50+
require.NoError(t, err)
51+
caCert, err := x509.ParseCertificate(caDER)
52+
require.NoError(t, err)
53+
54+
// signatureToTimestamp is the artifact being timestamped
55+
signatureToTimestamp := []byte("test-signature-data")
56+
57+
// createTSACertAndResponse creates a TSA leaf cert with the given validity window,
58+
// then generates a signed timestamp response at the given timestamp time.
59+
createTSACertAndResponse := func(t *testing.T, certNotBefore, certNotAfter, tsTime time.Time) (*x509.Certificate, []byte) {
60+
t.Helper()
61+
62+
tsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
63+
require.NoError(t, err)
64+
65+
tsaTemplate := &x509.Certificate{
66+
SerialNumber: big.NewInt(2),
67+
Subject: pkix.Name{CommonName: "Test TSA"},
68+
NotBefore: certNotBefore,
69+
NotAfter: certNotAfter,
70+
KeyUsage: x509.KeyUsageDigitalSignature,
71+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
72+
}
73+
tsaDER, err := x509.CreateCertificate(rand.Reader, tsaTemplate, caTemplate, &tsaKey.PublicKey, caKey)
74+
require.NoError(t, err)
75+
tsaCert, err := x509.ParseCertificate(tsaDER)
76+
require.NoError(t, err)
77+
78+
// Build a timestamp token (RFC 3161)
79+
h := crypto.SHA256.New()
80+
h.Write(signatureToTimestamp)
81+
hashedMessage := h.Sum(nil)
82+
83+
tsrBytes := buildTimestampResponse(t, tsaCert, tsaKey, hashedMessage, tsTime)
84+
return tsaCert, tsrBytes
85+
}
86+
87+
cases := []struct {
88+
name string
89+
certNotBefore time.Time
90+
certNotAfter time.Time
91+
tsTime time.Time
92+
expectErr string
93+
}{
94+
{
95+
name: "valid: timestamp within cert validity",
96+
certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
97+
certNotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
98+
tsTime: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
99+
},
100+
{
101+
name: "valid: cert expired now but was valid at timestamp time",
102+
certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
103+
certNotAfter: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
104+
tsTime: time.Date(2023, 6, 1, 0, 0, 0, 0, time.UTC),
105+
},
106+
{
107+
name: "invalid: timestamp before cert validity",
108+
certNotBefore: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
109+
certNotAfter: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
110+
tsTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
111+
expectErr: "verifying TSA certificate chain",
112+
},
113+
{
114+
name: "invalid: timestamp after cert validity",
115+
certNotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
116+
certNotAfter: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
117+
tsTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
118+
expectErr: "verifying TSA certificate chain",
119+
},
120+
}
121+
122+
for _, tc := range cases {
123+
t.Run(tc.name, func(t *testing.T) {
124+
tsaCert, tsrBytes := createTSACertAndResponse(t, tc.certNotBefore, tc.certNotAfter, tc.tsTime)
125+
126+
ts, err := verifyTimestampAtTime(tsrBytes, signatureToTimestamp, tsaCert, nil, []*x509.Certificate{caCert})
127+
if tc.expectErr != "" {
128+
assert.Error(t, err)
129+
assert.Contains(t, err.Error(), tc.expectErr)
130+
return
131+
}
132+
require.NoError(t, err)
133+
assert.False(t, ts.Time.IsZero())
134+
})
135+
}
136+
137+
t.Run("invalid: hash mismatch", func(t *testing.T) {
138+
tsaCert, tsrBytes := createTSACertAndResponse(t,
139+
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
140+
time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
141+
time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
142+
)
143+
wrongSignature := []byte("wrong-signature-data")
144+
_, err := verifyTimestampAtTime(tsrBytes, wrongSignature, tsaCert, nil, []*x509.Certificate{caCert})
145+
assert.Error(t, err)
146+
assert.Contains(t, err.Error(), "hashed message mismatch")
147+
})
148+
}
149+
150+
// buildTimestampResponse creates a minimal RFC 3161 timestamp response for testing.
151+
func buildTimestampResponse(t *testing.T, tsaCert *x509.Certificate, tsaKey *rsa.PrivateKey, hashedMessage []byte, tsTime time.Time) []byte {
152+
t.Helper()
153+
154+
// Build the TSTInfo (timestamp token info)
155+
tstInfo := struct {
156+
Version int
157+
Policy asn1.ObjectIdentifier
158+
MessageImprint struct {
159+
HashAlgorithm pkix.AlgorithmIdentifier
160+
HashedMessage []byte
161+
}
162+
SerialNumber *big.Int
163+
GenTime time.Time `asn1:"generalized"`
164+
}{
165+
Version: 1,
166+
Policy: asn1.ObjectIdentifier{1, 2, 3, 4},
167+
MessageImprint: struct {
168+
HashAlgorithm pkix.AlgorithmIdentifier
169+
HashedMessage []byte
170+
}{
171+
HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}}, // SHA-256
172+
HashedMessage: hashedMessage,
173+
},
174+
SerialNumber: big.NewInt(100),
175+
GenTime: tsTime,
176+
}
177+
tstInfoDER, err := asn1.Marshal(tstInfo)
178+
require.NoError(t, err)
179+
180+
// Wrap in a PKCS7 signed data structure
181+
signedData, err := pkcs7.NewSignedData(tstInfoDER)
182+
require.NoError(t, err)
183+
// Use OID for id-smime-ct-TSTInfo
184+
signedData.SetContentType(asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 1, 4})
185+
err = signedData.AddSigner(tsaCert, tsaKey, pkcs7.SignerInfoConfig{})
186+
require.NoError(t, err)
187+
p7DER, err := signedData.Finish()
188+
require.NoError(t, err)
189+
190+
// Wrap in a TimeStampResp structure
191+
tsResp := struct {
192+
Status struct {
193+
Status int
194+
}
195+
TimeStampToken asn1.RawValue
196+
}{
197+
Status: struct{ Status int }{Status: 0}, // granted
198+
TimeStampToken: asn1.RawValue{
199+
Class: asn1.ClassUniversal,
200+
Tag: asn1.TagSequence,
201+
IsCompound: true,
202+
Bytes: p7DER,
203+
},
204+
}
205+
206+
// Re-parse the PKCS7 to get the full DER with the outer SEQUENCE tag
207+
tsRespBytes, err := asn1.Marshal(tsResp)
208+
require.NoError(t, err)
209+
210+
// Verify our test fixture is valid by parsing it
211+
_, err = timestamp.ParseResponse(tsRespBytes)
212+
require.NoError(t, err, "test timestamp response should be parseable")
213+
214+
return tsRespBytes
215+
}

0 commit comments

Comments
 (0)