Skip to content

VerifyRFC3161Timestamp silently drops user-provided TSA chain when TrustedMaterial is set (regression from v2) #4847

Description

@jimgus

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 &timestamp.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions