Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions pkg/attestation/verifier/timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Security regression: the PKCS7 signer certificate is never verified against the trusted TSA certificate. timestamp.ParseResponse verifies the PKCS7 signature using the certificate embedded in the response itself, while tsaCert.Verify independently validates the expected TSA cert against roots. These two checks are disconnected — nothing confirms the timestamp was actually signed by tsaCert. The old VerifyTimestampResponse verified this binding via pkcs7.VerifyWithChain (signature against trusted chain) and verifyEmbeddedLeafCert (embedded cert matches expected cert).

To fix this, after parsing the timestamp, verify that the signing certificate in the response matches tsaCert (e.g., check ts.Certificates contains a cert equal to tsaCert, or re-parse ts.RawToken and use pkcs7.VerifyWithChain with the root pool and CurrentTime set).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pkg/attestation/verifier/timestamp.go, line 102:

<comment>Security regression: the PKCS7 signer certificate is never verified against the trusted TSA certificate. `timestamp.ParseResponse` verifies the PKCS7 signature using the certificate *embedded in the response itself*, while `tsaCert.Verify` independently validates the expected TSA cert against roots. These two checks are disconnected — nothing confirms the timestamp was actually signed by `tsaCert`. The old `VerifyTimestampResponse` verified this binding via `pkcs7.VerifyWithChain` (signature against trusted chain) and `verifyEmbeddedLeafCert` (embedded cert matches expected cert).

To fix this, after parsing the timestamp, verify that the signing certificate in the response matches `tsaCert` (e.g., check `ts.Certificates` contains a cert equal to `tsaCert`, or re-parse `ts.RawToken` and use `pkcs7.VerifyWithChain` with the root pool and `CurrentTime` set).</comment>

<file context>
@@ -98,3 +92,46 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
+// 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)
</file context>
Fix with Cubic

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
}
215 changes: 215 additions & 0 deletions pkg/attestation/verifier/timestamp_test.go
Original file line number Diff line number Diff line change
@@ -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),

Check failure on line 41 in pkg/attestation/verifier/timestamp_test.go

View workflow job for this annotation

GitHub Actions / lint (main-module)

File is not properly formatted (gofmt)
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
}
Loading