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
64 changes: 48 additions & 16 deletions internal/fork/ietf-cms/timestamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,49 @@ func TestTimestampsVerifications(t *testing.T) {
t.Fatal(err)
}

// Timestamped maybe before not-before
// Timestamped exactly on not-before (inclusive boundary - valid).
//
// Not-Before Not-After
// |--------------------------------|
// |--------|
// sig-min sig-max
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.Accuracy.Seconds = 30
info.GenTime = leaf.Certificate.NotBefore
return info
})
sd = getTimestampedSignedData()
if _, err := getTimestamp(sd.psd.SignerInfos[0], intermediateOpts); err != nil {
t.Fatal(err)
}
if _, err := sd.Verify(intermediateOpts, intermediateOpts); err != nil {
t.Fatal(err)
}

// Timestamped strictly before not-before (outside window - rejected).
//
// Not-Before Not-After
// |--------------------------------|
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.GenTime = leaf.Certificate.NotBefore.Add(-1 * time.Second)
return info
})
sd = getTimestampedSignedData()
if _, err := getTimestamp(sd.psd.SignerInfos[0], intermediateOpts); err != nil {
t.Fatal(err)
}
if _, err := sd.Verify(intermediateOpts, intermediateOpts); err == nil || !strings.HasPrefix(err.Error(), "x509: certificate has expired") {
t.Fatalf("expected expired error, got %v", err)
}

// Timestamped after not-before
// Timestamped after not-before (inside window - valid).
//
// Not-Before Not-After
// |--------------------------------|
// |--------|
// sig-min sig-max
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.Accuracy.Seconds = 30
info.GenTime = leaf.Certificate.NotBefore.Add(31 * time.Second)
return info
})
Expand All @@ -123,33 +139,49 @@ func TestTimestampsVerifications(t *testing.T) {
t.Fatal(err)
}

// Timestamped maybe after not-after
// Timestamped exactly on not-after (inclusive boundary - valid).
//
// Not-Before Not-After
// |--------------------------------|
// |--------|
// sig-min sig-max
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.Accuracy.Seconds = 30
info.GenTime = leaf.Certificate.NotAfter
return info
})
sd = getTimestampedSignedData()
if _, err := getTimestamp(sd.psd.SignerInfos[0], intermediateOpts); err != nil {
t.Fatal(err)
}
if _, err := sd.Verify(intermediateOpts, intermediateOpts); err != nil {
t.Fatal(err)
}

// Timestamped strictly after not-after (outside window - rejected).
//
// Not-Before Not-After
// |--------------------------------|
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.GenTime = leaf.Certificate.NotAfter.Add(1 * time.Second)
return info
})
sd = getTimestampedSignedData()
if _, err := getTimestamp(sd.psd.SignerInfos[0], intermediateOpts); err != nil {
t.Fatal(err)
}
if _, err := sd.Verify(intermediateOpts, intermediateOpts); err == nil || !strings.HasPrefix(err.Error(), "x509: certificate has expired") {
t.Fatalf("expected expired error, got %v", err)
}

// Timestamped before not-after
// Timestamped before not-after (inside window - valid).
//
// Not-Before Not-After
// |--------------------------------|
// |--------|
// sig-min sig-max
// *
// GenTime
tsa.HookInfo(func(info timestamp.Info) timestamp.Info {
info.Accuracy.Seconds = 30
info.GenTime = leaf.Certificate.NotAfter.Add(-31 * time.Second)
return info
})
Expand Down
14 changes: 10 additions & 4 deletions internal/fork/ietf-cms/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,16 @@ func (sd *SignedData) verify(econtent []byte, opts x509.VerifyOptions, tsOpts x5
return nil, err
}

// This check is slightly redundant, given that the cert validity times
// are checked by cert.Verify. We take the timestamp accuracy into account
// here though, whereas cert.Verify will not.
if !tsti.Before(cert.NotAfter) || !tsti.After(cert.NotBefore) {
// Check the TSA's recorded signing time against the cert validity
// window using GenTime directly, with inclusive bounds
// (NotBefore <= GenTime <= NotAfter is valid). tsti.Before/After
// pad GenTime by tsti.Accuracy, which produces false negatives on
// ephemeral Fulcio certs (~10min) when small clock skew between
// Fulcio and the TSA pushes the padded bound a few seconds outside
// the window. cert.Verify below enforces the same window against
// GenTime; this check stays so a TSA token claiming a time outside
// the cert validity window is rejected with a clear message.
if tsti.GenTime.After(cert.NotAfter) || tsti.GenTime.Before(cert.NotBefore) {
return nil, x509.CertificateInvalidError{Cert: cert, Reason: x509.Expired, Detail: "timestamp authority verification failed"}
}

Expand Down
38 changes: 38 additions & 0 deletions internal/fulcio/fulcioroots/fulcioroots.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"os"

"github.com/sigstore/gitsign/internal/config"
"github.com/sigstore/gitsign/internal/sigstore/localcache"
sgroot "github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/tuf"
)
Expand Down Expand Up @@ -90,9 +92,45 @@ func sourcesFromConfig(ctx context.Context, cfg *config.Config) []CertificateSou
if cfg.FulcioRoot != "" {
return []CertificateSource{FromFile(cfg.FulcioRoot)}
}
// A local sigstore-go-style TUF cache at ~/.sigstore/root/<mirror>/targets/
// trusted_root.json carries the full trust bundle (Fulcio + Rekor + CT +
// TSA). When present, use it instead of going through sigstore/sigstore's
// TUF client - that client embeds a prod root with a short lifetime, so
// verification breaks once it expires, and on private deployments it would
// be the wrong root anyway.
if path, ok := localcache.TrustedRootPath(); ok {
return []CertificateSource{FromTrustedRoot(path)}
}
return []CertificateSource{FromTUF(ctx)}
}

// FromTrustedRoot loads Fulcio CAs from a sigstore-go trusted_root.json file,
// returning every root and intermediate certificate across all configured
// Fulcio certificate authorities.
func FromTrustedRoot(path string) CertificateSource {
return func() ([]*x509.Certificate, error) {
tr, err := sgroot.NewTrustedRootFromPath(path)
if err != nil {
return nil, fmt.Errorf("loading trusted root %s: %w", path, err)
}
var certs []*x509.Certificate
for _, ca := range tr.FulcioCertificateAuthorities() {
fca, ok := ca.(*sgroot.FulcioCertificateAuthority)
if !ok {
continue
}
if fca.Root != nil {
certs = append(certs, fca.Root)
}
certs = append(certs, fca.Intermediates...)
}
if len(certs) == 0 {
return nil, fmt.Errorf("no Fulcio certificates in trusted root %s", path)
}
return certs, nil
}
}

const (
// This is the root in the fulcio project.
fulcioTargetStr = "fulcio.crt.pem"
Expand Down
37 changes: 35 additions & 2 deletions internal/gitsign/gitsign.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import (
"github.com/sigstore/gitsign/internal/config"
"github.com/sigstore/gitsign/internal/fulcio/fulcioroots"
rekorinternal "github.com/sigstore/gitsign/internal/rekor"
"github.com/sigstore/gitsign/internal/sigstore/localcache"
"github.com/sigstore/gitsign/pkg/git"
"github.com/sigstore/gitsign/pkg/rekor"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/tuf"
)

type Verifier struct {
Expand Down Expand Up @@ -98,7 +100,7 @@ func NewVerifierWithCosignOpts(ctx context.Context, cfg *config.Config, opts *co
// and warn if missing.
var certverifier cert.Verifier
if opts != nil {
ctpub, err := cosign.GetCTLogPubs(ctx)
ctpub, err := getCTLogPubs(ctx)
if err != nil {
return nil, fmt.Errorf("error getting CT log public key: %w", err)
}
Expand Down Expand Up @@ -140,7 +142,7 @@ func NewVerifierWithCosignOpts(ctx context.Context, cfg *config.Config, opts *co
// verification is disabled.
var ctPubs *cosign.TrustedTransparencyLogPubKeys
if !ignoreSCT {
ctPubs, err = cosign.GetCTLogPubs(ctx)
ctPubs, err = getCTLogPubs(ctx)
if err != nil {
return nil, fmt.Errorf("error getting CT log public keys: %w", err)
}
Expand Down Expand Up @@ -202,3 +204,34 @@ func (v *Verifier) verifyLegacy(ctx context.Context, data []byte, sig []byte, de

return summary, nil
}

// getCTLogPubs returns the CT log public keys used for SCT verification. When a
// local sigstore-go-style trusted_root.json is present (see
// internal/sigstore/localcache), the keys are loaded from it instead of going
// through cosign's TUF-backed loader, whose embedded root has a short lifetime
// and breaks once expired. Falls back to cosign.GetCTLogPubs when no local
// cache is configured.
func getCTLogPubs(ctx context.Context) (*cosign.TrustedTransparencyLogPubKeys, error) {
path, ok := localcache.TrustedRootPath()
if !ok {
return cosign.GetCTLogPubs(ctx)
}
tr, err := root.NewTrustedRootFromPath(path)
if err != nil {
return nil, fmt.Errorf("loading trusted root %s: %w", path, err)
}
keys := cosign.NewTrustedTransparencyLogPubKeys()
for _, log := range tr.CTLogs() {
pem, err := cryptoutils.MarshalPublicKeyToPEM(log.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshalling CT log public key: %w", err)
}
if err := keys.AddTransparencyLogPubKey(pem, tuf.Active); err != nil {
return nil, fmt.Errorf("adding CT log public key: %w", err)
}
}
if len(keys.Keys) == 0 {
return nil, fmt.Errorf("no CT log public keys in trusted root %s", path)
}
return &keys, nil
}
47 changes: 46 additions & 1 deletion internal/rekor/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ package rekor

import (
"context"
"fmt"

"github.com/sigstore/cosign/v3/pkg/cosign"
"github.com/sigstore/gitsign/internal/sigstore/localcache"
gitrekor "github.com/sigstore/gitsign/pkg/rekor"
rekor "github.com/sigstore/rekor/pkg/client"
sgroot "github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/tuf"
)

// NewClient returns a new Rekor client with common client options set.
Expand All @@ -29,6 +35,45 @@ func NewClient(url string) (*gitrekor.Client, error) {
}

// NewClientContext returns a new Rekor client with common client options set.
// When a local sigstore-go-style trusted_root.json is present (see
// internal/sigstore/localcache), the Rekor public keys are loaded from it
// instead of going through sigstore/sigstore's TUF client, whose embedded
// root has a short lifetime and breaks once expired.
func NewClientContext(ctx context.Context, url string) (*gitrekor.Client, error) {
return gitrekor.NewWithOptions(ctx, url, gitrekor.WithClientOption(rekor.WithUserAgent("gitsign")))
opts := []gitrekor.Option{gitrekor.WithClientOption(rekor.WithUserAgent("gitsign"))}
if path, ok := localcache.TrustedRootPath(); ok {
opts = append(opts, gitrekor.WithCosignRekorKeyProvider(rekorKeysFromTrustedRoot(path)))
}
return gitrekor.NewWithOptions(ctx, url, opts...)
}

// rekorKeysFromTrustedRoot returns a CosignRekorKeyProvider that loads the
// Rekor public keys from a sigstore-go trusted_root.json. The cosign
// TrustedTransparencyLogPubKeys map is keyed by the SHA-256 of the
// DER-encoded public key (see cosign.GetTransparencyLogID); we re-derive
// that key here rather than reusing the log ID embedded in the trusted
// root, so the produced map is structurally identical to one built by
// cosign.GetRekorPubs and can be used interchangeably by downstream
// verification helpers.
func rekorKeysFromTrustedRoot(path string) gitrekor.CosignRekorKeyProvider {
return func(_ context.Context) (*cosign.TrustedTransparencyLogPubKeys, error) {
tr, err := sgroot.NewTrustedRootFromPath(path)
if err != nil {
return nil, fmt.Errorf("loading trusted root %s: %w", path, err)
}
keys := cosign.NewTrustedTransparencyLogPubKeys()
for _, log := range tr.RekorLogs() {
pem, err := cryptoutils.MarshalPublicKeyToPEM(log.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshalling Rekor public key: %w", err)
}
if err := keys.AddTransparencyLogPubKey(pem, tuf.Active); err != nil {
return nil, fmt.Errorf("adding Rekor public key: %w", err)
}
}
if len(keys.Keys) == 0 {
return nil, fmt.Errorf("no Rekor public keys in trusted root %s", path)
}
return &keys, nil
}
}
77 changes: 77 additions & 0 deletions internal/sigstore/localcache/localcache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright 2026 The Sigstore 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 localcache locates a sigstore-go-style TUF cache on disk and
// exposes the trusted_root.json it contains. Used to bypass the embedded
// (short-lived) root.json in sigstore/sigstore's TUF client when a local
// cache - typically populated by sigstore-go-based tooling - is available.
package localcache

import (
"encoding/json"
"os"
"path/filepath"
"strings"
)

// TrustedRootPath returns the path to a cached trusted_root.json under
// $TUF_ROOT (or ~/.sigstore/root by default). The mirror URL is read from
// remote.json in the same directory and converted to the on-disk
// subdirectory name using the same scheme-strip / slash-replace rules
// sigstore-go uses (see sigstore-go's tuf.URLToPath). Returns false if any
// prerequisite is missing.
func TrustedRootPath() (string, bool) {
cacheRoot := os.Getenv("TUF_ROOT")
if cacheRoot == "" {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", false
}
cacheRoot = filepath.Join(home, ".sigstore", "root")
}

remoteBytes, err := os.ReadFile(filepath.Join(cacheRoot, "remote.json"))
if err != nil {
return "", false
}
var remote struct {
Mirror string `json:"mirror"`
}
if err := json.Unmarshal(remoteBytes, &remote); err != nil || remote.Mirror == "" {
return "", false
}

dir := urlToCacheDir(remote.Mirror)
if dir == "" {
return "", false
}
path := filepath.Join(cacheRoot, dir, "targets", "trusted_root.json")
if _, err := os.Stat(path); err != nil {
return "", false
}
return path, true
}

// urlToCacheDir mirrors sigstore-go's tuf.URLToPath: strip scheme, replace
// '/' and ':' with '-', lowercase. Kept in lock-step (rather than imported)
// so the cache layout stays compatible with any sigstore-go-based tool
// without taking a direct dep on the helper.
func urlToCacheDir(mirror string) string {
s := mirror
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, ":", "-")
return strings.ToLower(s)
}