Description
VerifyRFC3161Timestamp in pkg/cosign/verify.go silently drops the user-provided TSA chain
(co.TSACertificate, co.TSAIntermediateCertificates, co.TSARootCertificates) whenever
co.TrustedMaterial is non-nil. This is a regression from cosign v2, where the same call always
honoured the explicit TSA fields. As a result, callers who set TrustedMaterial for Fulcio/Rekor
trust and an explicit TSA chain for an out-of-tree TSA (e.g. GitHub's private TSA used by
actions/attest-build-provenance on private repositories) get verification failures — and in
some cases a runtime panic — with no indication that their TSA chain was ignored.
Background
GitHub Actions Artifact Attestations on private repositories use:
- GitHub's private TUF root (
https://tuf-repo.github.com) for trust material
- GitHub's private TSA — no public Rekor entry by design
- The Sigstore Bundle format (OCI 1.1 referrer, predicate
slsa.dev/provenance/v1)
To verify these in a third-party admission controller, callers populate cosign.CheckOpts with:
TrustedMaterial from the public sigstore TUF root (for Fulcio CA + Rekor pubkeys)
TSACertificate / TSAIntermediateCertificates / TSARootCertificates from GitHub's TSA
cert chain — GitHub's private TSA isn't in any public sigstore TUF root, so the legacy fields
are the only place to put it
This is exactly how Kyverno's ImageValidatingPolicy (IVPOL) builds CheckOpts; see
kyverno/kyverno pkg/image/verifiers/ivpol/cosign/opts.go:112-148.
The bug
pkg/cosign/verify.go:1394-1407:
if co.TrustedMaterial != nil {
entity := &signedEntityForTimestamp{
timestamp: ts,
sigContent: &sigContent{rawSig: tsBytes},
}
verifiedTimestamps, verifyErrs, err := verify.VerifySignedTimestamp(entity, co.TrustedMaterial)
if err != nil {
return nil, fmt.Errorf("unable to verify signed timestamps with trusted root: %w", err)
}
if len(verifyErrs) > 0 {
log.Printf("Warning: subset of signed timestamps failed to verify: %v", verifyErrs)
}
return ×tamp.Timestamp{Time: verifiedTimestamps[0].Time}, nil
}
return tsaverification.VerifyTimestampResponse(ts.SignedRFC3161Timestamp, bytes.NewReader(tsBytes),
tsaverification.VerifyOpts{
TSACertificate: co.TSACertificate,
Intermediates: co.TSAIntermediateCertificates,
Roots: co.TSARootCertificates,
})
The if co.TrustedMaterial != nil branch never consults co.TSACertificate / TSARootCertificates /
TSAIntermediateCertificates. If TrustedMaterial.TimestampingAuthorities() doesn't contain a TSA
matching the timestamp being verified, the verification silently fails — even though the caller
provided exactly the right TSA chain in the legacy fields.
v2 / v3 comparison
In cosign v2.5.0 VerifyRFC3161Timestamp,
this branch did not exist — the function unconditionally used the explicit TSA fields:
return tsaverification.VerifyTimestampResponse(ts.SignedRFC3161Timestamp, bytes.NewReader(tsBytes),
tsaverification.VerifyOpts{
TSACertificate: co.TSACertificate,
Intermediates: co.TSAIntermediateCertificates,
Roots: co.TSARootCertificates,
})
The v3 branch was introduced in 32a2d62a "Upgrade to TUF v2 client with trusted root".
The commit message states the design intent:
Where possible, use sigstore-go's verifiers which natively accept the trusted root as its trusted material.
The case where a caller sets both TrustedMaterial (for keyless trust) and the legacy TSA
fields (for an out-of-tree TSA) doesn't appear to have been considered.
Reproduction
Self-contained Go program (90 lines including helper). It calls cosign.VerifyRFC3161Timestamp
twice with identical inputs except for one bit: co.TrustedMaterial set vs. nil. The toggle
between fail and pass demonstrates the bug.
package main
import (
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"github.com/sigstore/cosign/v3/pkg/cosign"
cosignbundle "github.com/sigstore/cosign/v3/pkg/cosign/bundle"
"github.com/sigstore/cosign/v3/pkg/oci/static"
"github.com/sigstore/sigstore-go/pkg/root"
)
// emptyButNonNilTrustedMaterial mimics the state a caller ends up with after
// loading a trusted root that doesn't happen to contain the TSA they need
// (for example: public sigstore TUF root used to verify a GitHub-TSA-signed
// attestation).
type emptyButNonNilTrustedMaterial struct {
root.BaseTrustedMaterial
}
func main() {
if len(os.Args) != 4 {
log.Fatalf("usage: %s <tsr.bin> <signed-payload.bin> <tsa-chain.pem>", os.Args[0])
}
tsr, err := os.ReadFile(os.Args[1])
if err != nil { log.Fatal(err) }
sigBytes, err := os.ReadFile(os.Args[2])
if err != nil { log.Fatal(err) }
chainPEM, err := os.ReadFile(os.Args[3])
if err != nil { log.Fatal(err) }
leaf, intermediates, roots := splitChain(chainPEM)
rfc3161 := &cosignbundle.RFC3161Timestamp{SignedRFC3161Timestamp: tsr}
sig, err := static.NewAttestation(sigBytes, static.WithRFC3161Timestamp(rfc3161))
if err != nil { log.Fatal(err) }
co := &cosign.CheckOpts{
TrustedMaterial: &emptyButNonNilTrustedMaterial{},
TSACertificate: leaf,
TSAIntermediateCertificates: intermediates,
TSARootCertificates: roots,
UseSignedTimestamps: true,
}
fmt.Println("Case 1: TrustedMaterial != nil + TSA chain populated")
if _, err := cosign.VerifyRFC3161Timestamp(sig, co); err != nil {
fmt.Printf(" ✗ %v\n", err)
} else {
fmt.Println(" ✓ verified")
}
fmt.Println("Case 2: TrustedMaterial == nil + same TSA chain")
co.TrustedMaterial = nil
if _, err := cosign.VerifyRFC3161Timestamp(sig, co); err != nil {
fmt.Printf(" ✗ %v\n", err)
} else {
fmt.Println(" ✓ verified")
}
}
func splitChain(b []byte) (leaf *x509.Certificate, ints, roots []*x509.Certificate) {
rest := b
for {
block, r := pem.Decode(rest)
if block == nil { break }
rest = r
c, err := x509.ParseCertificate(block.Bytes)
if err != nil { log.Fatal(err) }
switch {
case !c.IsCA:
leaf = c
case bytes.Equal(c.RawIssuer, c.RawSubject):
roots = append(roots, c)
default:
ints = append(ints, c)
}
}
return
}
Run against any image attested via actions/attest-build-provenance plus the matching TSA chain
(e.g. GitHub's TSA chain from https://tuf-repo.github.com/.../trusted_root.json):
Case 1: TrustedMaterial != nil + TSA chain populated
✗ runtime error: index out of range [0] with length 0 <-- separate panic bug, see #4846
Case 2: TrustedMaterial == nil + same TSA chain
✓ verified
The single bit co.TrustedMaterial == nil decides whether the caller's TSA chain matters. Inputs
otherwise identical. (Case 1 currently surfaces as a panic from the same function — filed
separately as #4846 because it's an independent issue. With that panic fixed but nothing else
changed, Case 1 returns a verification error instead of panicking; the chain is still ignored.)
Real-world impact
Anyone migrating an admission controller from sigstore/cosign/v2 to v3 while verifying
GitHub Actions Artifact Attestations on private repositories will hit this:
- A
cosign.CheckOpts shape that worked on v2 silently regresses on v3
sigstore/policy-controller (still on cosign v2) avoids the bug structurally because it
mutually-excludes TrustedMaterial and the legacy TSA fields, putting custom TSAs into a
TrustRoot CR. Anyone whose admission controller mixes the two patterns (Kyverno IVPOL is
the case I encountered) hits it.
Proposed fix
I have two candidate shapes; happy to PR whichever you prefer.
Option A (smaller, preserves v2 contract): prefer the legacy TSA fields when explicitly set —
treat them as a directive that the caller wants to verify against a specific TSA, regardless of
whether TrustedMaterial happens to also contain a TSA:
if co.TSARootCertificates != nil {
return tsaverification.VerifyTimestampResponse(...) // honour caller's chain
}
if co.TrustedMaterial != nil {
// existing branch, unchanged
}
Option B (more idiomatic for the v3 sigstore-go path): when both are set, merge the legacy
fields into TrustedMaterial as a SigstoreTimestampingAuthority and combine via
TrustedMaterialCollection, then use the existing branch.
Option A is what I implemented and verified locally — the fix passes go test ./pkg/cosign/...
with no regressions. Option B is closer to the v3 architectural direction but needs a small
wrapper type. Happy to PR either.
Version
cosign v3.0.6
sigstore-go v1.1.4
timestamp-authority/v2 v2.0.6
Description
VerifyRFC3161Timestampinpkg/cosign/verify.gosilently drops the user-provided TSA chain(
co.TSACertificate,co.TSAIntermediateCertificates,co.TSARootCertificates) wheneverco.TrustedMaterialis non-nil. This is a regression from cosign v2, where the same call alwayshonoured the explicit TSA fields. As a result, callers who set
TrustedMaterialfor Fulcio/Rekortrust and an explicit TSA chain for an out-of-tree TSA (e.g. GitHub's private TSA used by
actions/attest-build-provenanceon private repositories) get verification failures — and insome cases a runtime panic — with no indication that their TSA chain was ignored.
Background
GitHub Actions Artifact Attestations on private repositories use:
https://tuf-repo.github.com) for trust materialslsa.dev/provenance/v1)To verify these in a third-party admission controller, callers populate
cosign.CheckOptswith:TrustedMaterialfrom the public sigstore TUF root (for Fulcio CA + Rekor pubkeys)TSACertificate/TSAIntermediateCertificates/TSARootCertificatesfrom GitHub's TSAcert chain — GitHub's private TSA isn't in any public sigstore TUF root, so the legacy fields
are the only place to put it
This is exactly how Kyverno's
ImageValidatingPolicy(IVPOL) builds CheckOpts; seekyverno/kyverno pkg/image/verifiers/ivpol/cosign/opts.go:112-148.
The bug
pkg/cosign/verify.go:1394-1407:The
if co.TrustedMaterial != nilbranch never consultsco.TSACertificate/TSARootCertificates/TSAIntermediateCertificates. IfTrustedMaterial.TimestampingAuthorities()doesn't contain a TSAmatching the timestamp being verified, the verification silently fails — even though the caller
provided exactly the right TSA chain in the legacy fields.
v2 / v3 comparison
In cosign v2.5.0
VerifyRFC3161Timestamp,this branch did not exist — the function unconditionally used the explicit TSA fields:
The v3 branch was introduced in
32a2d62a"Upgrade to TUF v2 client with trusted root".The commit message states the design intent:
The case where a caller sets both
TrustedMaterial(for keyless trust) and the legacy TSAfields (for an out-of-tree TSA) doesn't appear to have been considered.
Reproduction
Self-contained Go program (90 lines including helper). It calls
cosign.VerifyRFC3161Timestamptwice with identical inputs except for one bit:
co.TrustedMaterialset vs. nil. The togglebetween fail and pass demonstrates the bug.
Run against any image attested via
actions/attest-build-provenanceplus the matching TSA chain(e.g. GitHub's TSA chain from
https://tuf-repo.github.com/.../trusted_root.json):The single bit
co.TrustedMaterial == nildecides whether the caller's TSA chain matters. Inputsotherwise identical. (Case 1 currently surfaces as a panic from the same function — filed
separately as #4846 because it's an independent issue. With that panic fixed but nothing else
changed, Case 1 returns a verification error instead of panicking; the chain is still ignored.)
Real-world impact
Anyone migrating an admission controller from
sigstore/cosign/v2tov3while verifyingGitHub Actions Artifact Attestations on private repositories will hit this:
cosign.CheckOptsshape that worked on v2 silently regresses on v3sigstore/policy-controller(still on cosign v2) avoids the bug structurally because itmutually-excludes
TrustedMaterialand the legacy TSA fields, putting custom TSAs into aTrustRootCR. Anyone whose admission controller mixes the two patterns (Kyverno IVPOL isthe case I encountered) hits it.
Proposed fix
I have two candidate shapes; happy to PR whichever you prefer.
Option A (smaller, preserves v2 contract): prefer the legacy TSA fields when explicitly set —
treat them as a directive that the caller wants to verify against a specific TSA, regardless of
whether
TrustedMaterialhappens to also contain a TSA:Option B (more idiomatic for the v3 sigstore-go path): when both are set, merge the legacy
fields into
TrustedMaterialas aSigstoreTimestampingAuthorityand combine viaTrustedMaterialCollection, then use the existing branch.Option A is what I implemented and verified locally — the fix passes
go test ./pkg/cosign/...with no regressions. Option B is closer to the v3 architectural direction but needs a small
wrapper type. Happy to PR either.
Version
cosignv3.0.6sigstore-gov1.1.4timestamp-authority/v2v2.0.6