Skip to content

Commit d88df06

Browse files
authored
fix(verifier): do not reject attestations when our pinned TSA chain is stale (#3396)
1 parent 06f5466 commit d88df06

4 files changed

Lines changed: 415 additions & 6 deletions

File tree

‎app/controlplane/pkg/biz/workflowrun.go‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,17 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu
472472

473473
// if it's verifiable, make sure it passed
474474
if validation != nil && !validation.Result {
475-
return nil, NewErrValidation(fmt.Errorf("attestation verification failed: %s", validation.FailureReason))
475+
// A failure caused by our own TSA trust configuration — typically an
476+
// upstream authority that rotated its responder certificate ahead of the
477+
// chain we pin — must not discard the evidence. The signature has already
478+
// been verified against a trusted certificate, and verification is
479+
// recomputed on every read, so the result self-heals once the
480+
// configuration catches up.
481+
if !validation.TrustConfigFault {
482+
return nil, NewErrValidation(fmt.Errorf("attestation verification failed: %s", validation.FailureReason))
483+
}
484+
uc.logger.Warnw("msg", "accepting attestation with an unverifiable timestamp, review the configured TSA certificate chains",
485+
"workflowRunID", runID.String(), "reason", validation.FailureReason)
476486
}
477487

478488
// Run some validations on the predicate
@@ -571,6 +581,10 @@ func (uc *WorkflowRunUseCase) GetByIDInOrg(ctx context.Context, orgID, runID str
571581
type VerificationResult struct {
572582
Result bool
573583
FailureReason string
584+
// TrustConfigFault is set when verification failed because of this server's
585+
// TSA trust configuration rather than because of a fault in the attestation.
586+
// Callers persisting an attestation must not reject it on such a failure.
587+
TrustConfigFault bool
574588
}
575589

576590
func (uc *WorkflowRunUseCase) VerifyRun(ctx context.Context, run *WorkflowRun) (*VerificationResult, error) {
@@ -604,7 +618,11 @@ func (uc *WorkflowRunUseCase) verifyBundle(ctx context.Context, bundle []byte) (
604618
return nil, err
605619
}
606620

607-
return &VerificationResult{Result: false, FailureReason: err.Error()}, nil
621+
return &VerificationResult{
622+
Result: false,
623+
FailureReason: err.Error(),
624+
TrustConfigFault: verifier.IsTrustConfigError(err),
625+
}, nil
608626
}
609627
return &VerificationResult{Result: true}, nil
610628
}

‎go.mod‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,8 @@ require (
352352
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
353353
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
354354
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
355-
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
356-
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
355+
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352
356+
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
357357
github.com/docker/cli v29.7.2+incompatible // indirect
358358
github.com/docker/docker-credential-helpers v0.9.5 // indirect
359359
github.com/docker/go-units v0.5.0 // indirect

‎pkg/attestation/verifier/timestamp.go‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import (
2121
"encoding/base64"
2222
"errors"
2323
"fmt"
24+
"math/big"
25+
"strings"
2426

27+
"github.com/digitorus/pkcs7"
28+
"github.com/digitorus/timestamp"
2529
"github.com/sigstore/sigstore-go/pkg/bundle"
2630
"github.com/sigstore/sigstore-go/pkg/verify"
2731
"github.com/sigstore/timestamp-authority/v2/pkg/verification"
@@ -43,8 +47,23 @@ var (
4347
// ErrNoTSARootsConfigured indicates the bundle contains signed timestamps
4448
// but no TSA trust roots are configured on the server.
4549
ErrNoTSARootsConfigured = errors.New("no TSA trust roots configured")
50+
51+
// ErrTSASignerNotTrusted indicates the timestamp was signed by a certificate
52+
// that belongs to none of the configured timestamp authorities. Upstream TSAs
53+
// rotate their responder certificates without notice, so this points at a
54+
// pinned chain that has fallen behind, not at a faulty attestation.
55+
ErrTSASignerNotTrusted = errors.New("TSA response signer is not a configured timestamp authority")
4656
)
4757

58+
// IsTrustConfigError reports whether err is a timestamp verification failure
59+
// attributable to the TSA trust configuration rather than to the attestation
60+
// itself. Such a failure must not reject an incoming attestation: the signature
61+
// is verified independently, and verification is recomputed on every read, so
62+
// the outcome self-heals once the configuration catches up with the upstream TSA.
63+
func IsTrustConfigError(err error) bool {
64+
return errors.Is(err, ErrNoTSARootsConfigured) || errors.Is(err, ErrTSASignerNotTrusted)
65+
}
66+
4867
func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
4968
signedTimestamps, err := sb.Timestamps()
5069
if err != nil {
@@ -91,11 +110,25 @@ func VerifyTimestamps(sb *bundle.Bundle, tr *TrustedRoot) error {
91110
}
92111

93112
// verifyTimestamp tries to verify a single signed timestamp against every
94-
// configured TSA. Returns the error from the last attempted TSA on failure.
113+
// configured TSA. Returns the error from the last attempted TSA on failure, or
114+
// ErrTSASignerNotTrusted when the response was signed by none of them.
95115
func verifyTimestamp(st []byte, sigBytes []byte, vc verify.VerificationContent, tr *TrustedRoot) error {
116+
// Chainloop's timestamp requests do not ask for certificates, so the response
117+
// carries none and the pinned leaf is injected as the only candidate signer.
118+
// Knowing up front which authority actually signed keeps a rotated upstream
119+
// responder distinguishable from a response we should reject.
120+
signers := tsrSigners(st)
121+
96122
var lastErr error
97-
for _, tsa := range tr.TimestampAuthorities {
123+
var skipped []string
124+
for name, tsa := range tr.TimestampAuthorities {
98125
tsaCert := tsa[0]
126+
if !signedByCert(signers, tsaCert) {
127+
skipped = append(skipped, fmt.Sprintf("%q (expected leaf %q, serial %s)",
128+
name, tsaCert.Subject.CommonName, tsaCert.SerialNumber))
129+
continue
130+
}
131+
99132
var roots []*x509.Certificate
100133
var intermediates []*x509.Certificate
101134
if len(tsa) > 1 {
@@ -127,5 +160,61 @@ func verifyTimestamp(st []byte, sigBytes []byte, vc verify.VerificationContent,
127160

128161
return nil
129162
}
163+
164+
// No authority signed this response: our pinned chains are behind the
165+
// upstream TSA rather than the response being at fault.
166+
if lastErr == nil && len(skipped) > 0 {
167+
return fmt.Errorf("%w: tried %s", ErrTSASignerNotTrusted, strings.Join(skipped, ", "))
168+
}
169+
130170
return lastErr
131171
}
172+
173+
// tsrSignerID identifies the certificate that signed an RFC3161 response, as
174+
// carried in the PKCS#7 SignerInfo.
175+
type tsrSignerID struct {
176+
rawIssuer []byte
177+
serial *big.Int
178+
}
179+
180+
// tsrSigners returns the identities of the certificates that signed the RFC3161
181+
// response. A nil result means the response could not be parsed; callers must
182+
// then treat every candidate as a possible signer so the underlying verifier
183+
// produces the authoritative error.
184+
func tsrSigners(st []byte) []tsrSignerID {
185+
ts, err := timestamp.ParseResponse(st)
186+
if err != nil {
187+
return nil
188+
}
189+
190+
p7, err := pkcs7.Parse(ts.RawToken)
191+
if err != nil || len(p7.Signers) == 0 {
192+
return nil
193+
}
194+
195+
signers := make([]tsrSignerID, 0, len(p7.Signers))
196+
for _, signer := range p7.Signers {
197+
signers = append(signers, tsrSignerID{
198+
rawIssuer: signer.IssuerAndSerialNumber.IssuerName.FullBytes,
199+
serial: signer.IssuerAndSerialNumber.SerialNumber,
200+
})
201+
}
202+
203+
return signers
204+
}
205+
206+
// signedByCert reports whether cert is one of the given signers. An unknown
207+
// signer set (a response we could not parse) matches every certificate.
208+
func signedByCert(signers []tsrSignerID, cert *x509.Certificate) bool {
209+
if signers == nil {
210+
return true
211+
}
212+
213+
for _, signer := range signers {
214+
if signer.serial != nil && cert.SerialNumber.Cmp(signer.serial) == 0 && bytes.Equal(cert.RawIssuer, signer.rawIssuer) {
215+
return true
216+
}
217+
}
218+
219+
return false
220+
}

0 commit comments

Comments
 (0)