Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.4 // indirect
Expand Down
21 changes: 21 additions & 0 deletions pkg/attestation/verifier/testdata/bundle_with_bad_timestamp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3",
"verificationMaterial": {
"timestampVerificationData": {
"rfc3161Timestamps": [
{
"signedTimestamp": "bm90LWEtdmFsaWQtdHNy"
}
]
}
},
"dsseEnvelope": {
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEifQ==",
"payloadType": "application/vnd.in-toto+json",
"signatures": [
{
"sig": "TUVVQ0lRQ1JjOFVmeFVSMnBkL2UxVW1pVmhkRE5BZUxVRi83ODZ2WGxCT1VWM0dJcUFJZ0NqOTROczZwbzFQSzJjTW95MzBpOVB3Smx5c1E0R1RmTGI4TnZ5WlB1ckk9"
}
]
}
}
10 changes: 10 additions & 0 deletions pkg/attestation/verifier/testdata/dsse_envelope.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEifQ==",
"signatures": [
{
"keyid": "",
"sig": "MEUCIQDtest"
}
]
}
113 changes: 72 additions & 41 deletions pkg/attestation/verifier/timestamp.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 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.
Expand All @@ -22,24 +22,45 @@ import (
"errors"
"fmt"

"github.com/digitorus/timestamp"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/sigstore/timestamp-authority/v2/pkg/verification"
)

var (
// ErrTSAResponseInvalid indicates the RFC3161 timestamp response could not
// be verified against the TSA certificate chain.
ErrTSAResponseInvalid = errors.New("TSA response verification failed")

// ErrTimestampOutsideTSAValidity indicates the timestamp's time falls
// outside the TSA certificate's NotBefore/NotAfter window.
ErrTimestampOutsideTSAValidity = errors.New("timestamp outside TSA certificate validity window")

// ErrSigningCertNotValidAtTimestamp indicates the signing certificate
// was not valid at the timestamp's time.
ErrSigningCertNotValidAtTimestamp = errors.New("signing certificate not valid at timestamp time")

// ErrNoTSARootsConfigured indicates the bundle contains signed timestamps
// but no TSA trust roots are configured on the server.
ErrNoTSARootsConfigured = errors.New("no TSA trust roots configured")
)

func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
signedTimestamps, err := sb.Timestamps()
if err != nil {
if errors.Is(err, bundle.ErrMissingVerificationMaterial) {
// translate error
return ErrMissingVerificationMaterial
}
return fmt.Errorf("could not get timestamps: %w", err)
}
if len(signedTimestamps) == 0 {
// nothing to do
return ErrMissingVerificationMaterial
}

if len(tr.TimestampAuthorities) == 0 {
return ErrNoTSARootsConfigured
}

sc, err := sb.SignatureContent()
if err != nil {
return fmt.Errorf("could not get signature material: %w", err)
Expand All @@ -53,48 +74,58 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
dst := make([]byte, base64.RawURLEncoding.DecodedLen(len(signature)))
i, err := base64.StdEncoding.Decode(dst, signature)
if err == nil {
// get the decoded one
sigBytes = dst[:i]
}

var verifiedTimestamps []*timestamp.Timestamp
vc, vcErr := sb.VerificationContent()
if vcErr != nil && !errors.Is(vcErr, bundle.ErrMissingVerificationMaterial) {
return fmt.Errorf("could not get verification material: %w", vcErr)
}

for _, st := range signedTimestamps {
// let's try with all TSAs
for _, tsa := range tr.TimestampAuthorities {
tsaCert := tsa[0]
var roots []*x509.Certificate
var intermediates []*x509.Certificate
if len(tsa) > 1 {
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,
})
if err != nil {
continue
}
// verify timestamp time
if ts.Time.After(tsaCert.NotAfter) || ts.Time.Before(tsaCert.NotBefore) {
continue
}

vc, err := sb.VerificationContent()
if err != nil && !errors.Is(err, bundle.ErrMissingVerificationMaterial) {
return fmt.Errorf("could not get verification material: %w", err)
}
// verify signing certificate issuing time
if vc != nil && vc.Certificate() != nil && !vc.ValidAtTime(ts.Time, nil) {
continue
}
verifiedTimestamps = append(verifiedTimestamps, ts)
if err := verifyTimestamp(st, sigBytes, vc, tr); err != nil {
return err
}
}
if len(verifiedTimestamps) < len(signedTimestamps) {
return fmt.Errorf("some timestamps verification failed")
}
return nil
}

// verifyTimestamp tries to verify a single signed timestamp against every
// configured TSA. Returns the error from the last attempted TSA on failure.
func verifyTimestamp(st []byte, sigBytes []byte, vc verify.VerificationContent, tr *TrustedRoot) error {
var lastErr error
for _, tsa := range tr.TimestampAuthorities {
tsaCert := tsa[0]
var roots []*x509.Certificate
var intermediates []*x509.Certificate
if len(tsa) > 1 {
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,
})
if err != nil {
lastErr = fmt.Errorf("%w: %w", ErrTSAResponseInvalid, err)
continue
}

if ts.Time.After(tsaCert.NotAfter) || ts.Time.Before(tsaCert.NotBefore) {
lastErr = fmt.Errorf("%w: timestamp=%s, cert validity=[%s, %s]",
ErrTimestampOutsideTSAValidity, ts.Time, tsaCert.NotBefore, tsaCert.NotAfter)
continue
}

if vc != nil && vc.Certificate() != nil && !vc.ValidAtTime(ts.Time, nil) {
lastErr = fmt.Errorf("%w: timestamp=%s", ErrSigningCertNotValidAtTimestamp, ts.Time)
continue
}

return nil
}
return lastErr
}
49 changes: 48 additions & 1 deletion pkg/attestation/verifier/verifier_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025 The Chainloop Authors.
// Copyright 2025-2026 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.
Expand All @@ -19,6 +19,7 @@ import (
"bytes"
"context"
"crypto/x509"
"errors"
"os"
"testing"

Expand Down Expand Up @@ -64,6 +65,12 @@ func TestVerifyBundle(t *testing.T) {
bundle: "testdata/bundle_invalid.json",
expectErr: "validating the DSSE envelope",
},
{
name: "legacy DSSE envelope (not a bundle)",
roots: roots,
bundle: "testdata/dsse_envelope.json",
expectErr: "invalid bundle",
},
}

for _, tc := range cases {
Expand All @@ -80,3 +87,43 @@ func TestVerifyBundle(t *testing.T) {
})
}
}

func TestVerifyTimestamps_TypedErrors(t *testing.T) {
ca, err := os.ReadFile("testdata/ca.pub")
require.NoError(t, err)
certs, err := cryptoutils.LoadCertificatesFromPEM(bytes.NewReader(ca))
require.NoError(t, err)

cases := []struct {
name string
roots *TrustedRoot
expectSentinel error
}{
{
name: "bad timestamp with TSA configured",
roots: &TrustedRoot{
TimestampAuthorities: map[string][]*x509.Certificate{
"fake-tsa": certs,
},
},
expectSentinel: ErrTSAResponseInvalid,
},
{
name: "timestamp with no TSA roots configured",
roots: &TrustedRoot{},
expectSentinel: ErrNoTSARootsConfigured,
},
}

bundleBytes, err := os.ReadFile("testdata/bundle_with_bad_timestamp.json")
require.NoError(t, err)

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := VerifyBundle(context.TODO(), bundleBytes, tc.roots)
require.Error(t, err)
assert.True(t, errors.Is(err, tc.expectSentinel),
"expected %v, got: %v", tc.expectSentinel, err)
})
}
}
Loading