diff --git a/internal/fork/ietf-cms/timestamp_test.go b/internal/fork/ietf-cms/timestamp_test.go index e290e752..1ccb54bc 100644 --- a/internal/fork/ietf-cms/timestamp_test.go +++ b/internal/fork/ietf-cms/timestamp_test.go @@ -85,14 +85,13 @@ 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 }) @@ -100,18 +99,35 @@ func TestTimestampsVerifications(t *testing.T) { 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 }) @@ -123,14 +139,13 @@ 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 }) @@ -138,18 +153,35 @@ func TestTimestampsVerifications(t *testing.T) { 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 }) diff --git a/internal/fork/ietf-cms/verify.go b/internal/fork/ietf-cms/verify.go index d1f1378d..b64faccd 100644 --- a/internal/fork/ietf-cms/verify.go +++ b/internal/fork/ietf-cms/verify.go @@ -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"} } diff --git a/internal/fulcio/fulcioroots/fulcioroots.go b/internal/fulcio/fulcioroots/fulcioroots.go index 01a31019..989b74fd 100644 --- a/internal/fulcio/fulcioroots/fulcioroots.go +++ b/internal/fulcio/fulcioroots/fulcioroots.go @@ -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" ) @@ -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//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" diff --git a/internal/gitsign/gitsign.go b/internal/gitsign/gitsign.go index bb517ba7..d20da05a 100644 --- a/internal/gitsign/gitsign.go +++ b/internal/gitsign/gitsign.go @@ -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 { @@ -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) } @@ -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) } @@ -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 +} diff --git a/internal/rekor/client.go b/internal/rekor/client.go index 2476765a..80f553ee 100644 --- a/internal/rekor/client.go +++ b/internal/rekor/client.go @@ -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. @@ -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 + } } diff --git a/internal/sigstore/localcache/localcache.go b/internal/sigstore/localcache/localcache.go new file mode 100644 index 00000000..17be8a0b --- /dev/null +++ b/internal/sigstore/localcache/localcache.go @@ -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) +}