From bd875063a05cd7b624948048c36627102dc16c4d Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:11:58 +0000 Subject: [PATCH 1/4] fix(signing): retry transient sigstore failures Co-authored-by: Codex --- pkg/leeway/signing/attestation.go | 43 +++- pkg/leeway/signing/attestation_test.go | 2 +- pkg/leeway/signing/errors.go | 259 +++++++++++++++++++++++-- pkg/leeway/signing/errors_test.go | 105 ++++++++++ 4 files changed, 386 insertions(+), 23 deletions(-) create mode 100644 pkg/leeway/signing/errors_test.go diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index 2a832d04..b754b43c 100644 --- a/pkg/leeway/signing/attestation.go +++ b/pkg/leeway/signing/attestation.go @@ -77,8 +77,25 @@ type SignedAttestationResult struct { ArtifactName string `json:"artifact_name"` // Name of the artifact } +// SLSASigningOptions configures SLSA attestation signing. +type SLSASigningOptions struct { + RetryOptions RetryOptions +} + +// DefaultSLSASigningOptions returns conservative defaults for CI signing. +func DefaultSLSASigningOptions() SLSASigningOptions { + return SLSASigningOptions{ + RetryOptions: DefaultRetryOptions(), + } +} + // GenerateSignedSLSAAttestation generates and signs SLSA provenance in one integrated step func GenerateSignedSLSAAttestation(ctx context.Context, artifactPath string, githubCtx *GitHubContext) (*SignedAttestationResult, error) { + return GenerateSignedSLSAAttestationWithOptions(ctx, artifactPath, githubCtx, DefaultSLSASigningOptions()) +} + +// GenerateSignedSLSAAttestationWithOptions generates and signs SLSA provenance with explicit signing options. +func GenerateSignedSLSAAttestationWithOptions(ctx context.Context, artifactPath string, githubCtx *GitHubContext, signingOptions SLSASigningOptions) (*SignedAttestationResult, error) { // Calculate artifact checksum checksum, err := computeSHA256(artifactPath) if err != nil { @@ -94,8 +111,20 @@ func GenerateSignedSLSAAttestation(ctx context.Context, artifactPath string, git // Extract builder ID from OIDC token to match certificate identity // This is critical for compatibility with reusable workflows - builderID, err := extractBuilderIDFromOIDC(ctx, githubCtx) - if err != nil { + var builderID string + if err := WithRetryOptions(ctx, signingOptions.RetryOptions, func() error { + var retryErr error + builderID, retryErr = extractBuilderIDFromOIDC(ctx, githubCtx) + if retryErr != nil { + return &SigningError{ + Type: ErrorTypeSigstore, + Artifact: filepath.Base(artifactPath), + Message: fmt.Sprintf("failed to extract builder ID from OIDC token: %v", retryErr), + Cause: retryErr, + } + } + return nil + }); err != nil { return nil, fmt.Errorf("failed to extract builder ID from OIDC token: %w", err) } @@ -163,8 +192,12 @@ func GenerateSignedSLSAAttestation(ctx context.Context, artifactPath string, git }).Debug("Generated SLSA provenance, proceeding with integrated signing") // Generate and sign the SLSA provenance using Sigstore - signedAttestation, err := signProvenanceWithSigstore(ctx, stmt) - if err != nil { + var signedAttestation []byte + if err := WithRetryOptions(ctx, signingOptions.RetryOptions, func() error { + var retryErr error + signedAttestation, retryErr = signProvenanceWithSigstore(ctx, stmt, signingOptions) + return retryErr + }); err != nil { return nil, fmt.Errorf("failed to sign SLSA provenance: %w", err) } @@ -192,7 +225,7 @@ func computeSHA256(filePath string) (string, error) { } // signProvenanceWithSigstore signs SLSA provenance using Sigstore keyless signing -func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statement) ([]byte, error) { +func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statement, signingOptions SLSASigningOptions) ([]byte, error) { // Validate GitHub OIDC environment if err := validateSigstoreEnvironment(); err != nil { return nil, fmt.Errorf("sigstore environment validation failed: %w", err) diff --git a/pkg/leeway/signing/attestation_test.go b/pkg/leeway/signing/attestation_test.go index b656dddf..bcded5aa 100644 --- a/pkg/leeway/signing/attestation_test.go +++ b/pkg/leeway/signing/attestation_test.go @@ -926,7 +926,7 @@ func TestSigningError_IsRetryable_AllTypes(t *testing.T) { retryable bool }{ {ErrorTypeNetwork, true}, - {ErrorTypeSigstore, true}, + {ErrorTypeSigstore, false}, {ErrorTypePermission, false}, {ErrorTypeValidation, false}, {ErrorTypeFileSystem, false}, diff --git a/pkg/leeway/signing/errors.go b/pkg/leeway/signing/errors.go index 6b76ad40..9870e0ef 100644 --- a/pkg/leeway/signing/errors.go +++ b/pkg/leeway/signing/errors.go @@ -1,7 +1,11 @@ package signing import ( + "context" + "errors" "fmt" + "math/rand" + "net" "strings" "time" @@ -49,9 +53,22 @@ func NewSigningError(errorType SigningErrorType, artifact, message string, cause // IsRetryable determines if an error type should be retried func (e *SigningError) IsRetryable() bool { + return isRetryableSigningError(e) +} + +func isRetryableSigningError(e *SigningError) bool { switch e.Type { - case ErrorTypeNetwork, ErrorTypeSigstore: + case ErrorTypeNetwork: return true + case ErrorTypeSigstore: + message := e.Message + if e.Cause != nil { + message += ": " + e.Cause.Error() + } + if isDeterministicErrorMessage(message) { + return false + } + return IsTransientError(e.Cause) || isTransientErrorMessage(e.Message) case ErrorTypePermission, ErrorTypeValidation, ErrorTypeFileSystem: return false default: @@ -59,35 +76,74 @@ func (e *SigningError) IsRetryable() bool { } } +// RetryOptions configures bounded exponential retry behavior. +type RetryOptions struct { + MaxAttempts int + InitialBackoff time.Duration + MaxBackoff time.Duration + JitterFraction float64 + Sleep func(context.Context, time.Duration) error + IsRetryable func(error) bool +} + +// DefaultRetryOptions returns conservative defaults for transient signing failures. +func DefaultRetryOptions() RetryOptions { + return RetryOptions{ + MaxAttempts: 3, + InitialBackoff: time.Second, + MaxBackoff: 10 * time.Second, + JitterFraction: 0.2, + Sleep: sleepWithContext, + IsRetryable: IsRetryableError, + } +} + // WithRetry executes an operation with exponential backoff retry logic func WithRetry(maxAttempts int, operation func() error) error { + opts := DefaultRetryOptions() + opts.MaxAttempts = maxAttempts + return WithRetryOptions(context.Background(), opts, operation) +} + +// WithRetryOptions executes an operation with bounded exponential backoff and jitter. +func WithRetryOptions(ctx context.Context, opts RetryOptions, operation func() error) error { var lastErr error - backoff := time.Second + opts = normalizeRetryOptions(opts) + backoff := opts.InitialBackoff + + for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return err + } - for attempt := 1; attempt <= maxAttempts; attempt++ { if err := operation(); err != nil { lastErr = err - // Check if this is a retryable error - if signingErr, ok := err.(*SigningError); ok && !signingErr.IsRetryable() { - log.WithFields(log.Fields{ - "error_type": signingErr.Type, - "artifact": signingErr.Artifact, - }).Debug("Non-retryable error encountered") + if !opts.IsRetryable(err) { + var signingErr *SigningError + if errors.As(err, &signingErr) { + log.WithFields(log.Fields{ + "error_type": signingErr.Type, + "artifact": signingErr.Artifact, + }).Debug("Non-retryable error encountered") + } return err } - if attempt < maxAttempts { + if attempt < opts.MaxAttempts { + delay := applyJitter(backoff, opts.JitterFraction) log.WithFields(log.Fields{ "attempt": attempt, - "max_attempts": maxAttempts, - "backoff": backoff, + "max_attempts": opts.MaxAttempts, + "backoff": delay, }).WithError(err).Warn("Operation failed, retrying") - time.Sleep(backoff) + if sleepErr := opts.Sleep(ctx, delay); sleepErr != nil { + return sleepErr + } backoff *= 2 // Exponential backoff - if backoff > 30*time.Second { - backoff = 30 * time.Second // Cap at 30 seconds + if backoff > opts.MaxBackoff { + backoff = opts.MaxBackoff } continue } @@ -96,12 +152,13 @@ func WithRetry(maxAttempts int, operation func() error) error { } } - return fmt.Errorf("operation failed after %d attempts: %w", maxAttempts, lastErr) + return fmt.Errorf("operation failed after %d attempts: %w", opts.MaxAttempts, lastErr) } // CategorizeError attempts to categorize a generic error into a SigningError func CategorizeError(artifact string, err error) *SigningError { - if signingErr, ok := err.(*SigningError); ok { + var signingErr *SigningError + if errors.As(err, &signingErr) { return signingErr } @@ -147,6 +204,174 @@ func CategorizeError(artifact string, err error) *SigningError { } } +func normalizeRetryOptions(opts RetryOptions) RetryOptions { + defaults := DefaultRetryOptions() + if opts.MaxAttempts <= 0 { + opts.MaxAttempts = defaults.MaxAttempts + } + if opts.InitialBackoff <= 0 { + opts.InitialBackoff = defaults.InitialBackoff + } + if opts.MaxBackoff <= 0 { + opts.MaxBackoff = defaults.MaxBackoff + } + if opts.MaxBackoff < opts.InitialBackoff { + opts.MaxBackoff = opts.InitialBackoff + } + if opts.JitterFraction < 0 { + opts.JitterFraction = 0 + } + if opts.JitterFraction > 1 { + opts.JitterFraction = 1 + } + if opts.Sleep == nil { + opts.Sleep = defaults.Sleep + } + if opts.IsRetryable == nil { + opts.IsRetryable = defaults.IsRetryable + } + return opts +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func applyJitter(d time.Duration, fraction float64) time.Duration { + if d <= 0 || fraction <= 0 { + return d + } + + delta := int64(float64(d) * fraction) + if delta <= 0 { + return d + } + + return d - time.Duration(delta) + time.Duration(rand.Int63n(2*delta+1)) +} + +// IsRetryableError returns true for transient transport failures that are safe to retry. +func IsRetryableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) { + return false + } + + var signingErr *SigningError + if errors.As(err, &signingErr) { + return isRetryableSigningError(signingErr) + } + + if IsTransientError(err) { + return true + } + + msg := err.Error() + if isDeterministicErrorMessage(msg) { + return false + } + return isTransientErrorMessage(msg) +} + +// IsTransientError identifies transport-level errors that can succeed on retry. +func IsTransientError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + + return isTransientErrorMessage(err.Error()) +} + +func isTransientErrorMessage(msg string) bool { + return containsAny(msg, []string{ + "INTERNAL_ERROR", + "stream error", + "http2", + "timeout", + "timed out", + "temporary", + "connection reset", + "connection refused", + "connection closed", + "broken pipe", + "network", + "dial tcp", + "dns", + "no such host", + "i/o timeout", + "EOF", + "unexpected EOF", + "server misbehaving", + "too many requests", + "rate limit", + "status: 429", + "status: 500", + "status: 502", + "status: 503", + "status: 504", + "bad gateway", + "service unavailable", + "gateway timeout", + }) +} + +func isDeterministicErrorMessage(msg string) bool { + return containsAny(msg, []string{ + "createLogEntryConflict", + "equivalent entry already exists", + "rekor conflict cannot be verified", + "failed to fetch conflicting Rekor entry", + "conflicting Rekor entry", + "does not match signed payload", + "checksum calculation failed", + "incomplete GitHub context", + "invalid GitHub context", + "malformed", + "invalid JWT", + "failed to decode JWT", + "sub claim not found", + "job_workflow_ref not found", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN not found", + "ACTIONS_ID_TOKEN_REQUEST_URL not found", + "not running in GitHub Actions", + "permission denied", + "access denied", + "forbidden", + "unauthorized", + "no such file", + "not found", + "is a directory", + "read-only", + "failed to marshal statement", + "failed to marshal signed bundle", + }) +} + // containsAny checks if a string contains any of the given substrings (case-insensitive) func containsAny(s string, substrings []string) bool { s = strings.ToLower(s) diff --git a/pkg/leeway/signing/errors_test.go b/pkg/leeway/signing/errors_test.go new file mode 100644 index 00000000..9c0bffd0 --- /dev/null +++ b/pkg/leeway/signing/errors_test.go @@ -0,0 +1,105 @@ +package signing + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func noSleepRetryOptions(maxAttempts int) RetryOptions { + opts := DefaultRetryOptions() + opts.MaxAttempts = maxAttempts + opts.InitialBackoff = time.Nanosecond + opts.MaxBackoff = time.Nanosecond + opts.JitterFraction = 0 + opts.Sleep = func(context.Context, time.Duration) error { return nil } + return opts +} + +func TestRetryClassification(t *testing.T) { + tests := []struct { + name string + err error + retryable bool + }{ + { + name: "HTTP/2 internal error is transient", + err: fmt.Errorf("stream error: stream ID 7; INTERNAL_ERROR; received from peer"), + retryable: true, + }, + { + name: "deadline exceeded is transient", + err: context.DeadlineExceeded, + retryable: true, + }, + { + name: "network signing error is transient", + err: NewSigningError(ErrorTypeNetwork, "artifact.tar.gz", "connection timeout", fmt.Errorf("timeout")), + retryable: true, + }, + { + name: "validation error is deterministic", + err: NewSigningError(ErrorTypeValidation, "artifact.tar.gz", "incomplete GitHub context", nil), + retryable: false, + }, + { + name: "filesystem error is deterministic", + err: NewSigningError(ErrorTypeFileSystem, "artifact.tar.gz", "no such file", nil), + retryable: false, + }, + { + name: "Rekor conflict is not retried blindly", + err: fmt.Errorf("[POST /api/v1/log/entries][409] createLogEntryConflict an equivalent entry already exists"), + retryable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.retryable, IsRetryableError(tt.err)) + }) + } +} + +func TestWithRetryOptions_RetriesTransientErrorsOnly(t *testing.T) { + t.Run("transient error is retried and succeeds", func(t *testing.T) { + attempts := 0 + err := WithRetryOptions(context.Background(), noSleepRetryOptions(3), func() error { + attempts++ + if attempts == 1 { + return NewSigningError(ErrorTypeSigstore, "artifact.tar.gz", "stream error: INTERNAL_ERROR", fmt.Errorf("stream error: INTERNAL_ERROR")) + } + return nil + }) + + require.NoError(t, err) + assert.Equal(t, 2, attempts) + }) + + t.Run("deterministic error is not retried", func(t *testing.T) { + attempts := 0 + err := WithRetryOptions(context.Background(), noSleepRetryOptions(3), func() error { + attempts++ + return NewSigningError(ErrorTypeValidation, "artifact.tar.gz", "malformed GitHub context", nil) + }) + + require.Error(t, err) + assert.Equal(t, 1, attempts) + }) +} + +func TestWithRetryOptions_Exhaustion(t *testing.T) { + attempts := 0 + err := WithRetryOptions(context.Background(), noSleepRetryOptions(2), func() error { + attempts++ + return fmt.Errorf("stream error: stream ID 3; INTERNAL_ERROR; received from peer") + }) + + require.Error(t, err) + assert.Equal(t, 2, attempts) + assert.Contains(t, err.Error(), "operation failed after 2 attempts") +} From 31927ab85d76eae2fe8784ce6d39a42c03347c94 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:12:18 +0000 Subject: [PATCH 2/4] fix(signing): recover verified rekor conflicts Co-authored-by: Codex --- go.mod | 6 +- pkg/leeway/signing/attestation.go | 11 +- pkg/leeway/signing/rekor.go | 282 ++++++++++++++++++++++++++++++ pkg/leeway/signing/rekor_test.go | 210 ++++++++++++++++++++++ 4 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 pkg/leeway/signing/rekor.go create mode 100644 pkg/leeway/signing/rekor_test.go diff --git a/go.mod b/go.mod index 331041a8..4e59e683 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/disiqueira/gotree v1.0.0 github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd github.com/fsnotify/fsnotify v1.9.0 + github.com/go-openapi/runtime v0.28.0 + github.com/go-openapi/strfmt v0.23.0 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.6 github.com/google/uuid v1.6.0 @@ -28,6 +30,7 @@ require ( github.com/segmentio/analytics-go/v3 v3.3.0 github.com/segmentio/textio v1.2.0 github.com/sigstore/protobuf-specs v0.5.0 + github.com/sigstore/rekor v1.4.2 github.com/sigstore/sigstore-go v1.1.3 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.1 @@ -185,9 +188,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect - github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.24.1 // indirect github.com/go-openapi/swag/cmdutils v0.24.0 // indirect github.com/go-openapi/swag/conv v0.24.0 // indirect @@ -307,7 +308,6 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sigstore/rekor v1.4.2 // indirect github.com/sigstore/rekor-tiles v0.1.11 // indirect github.com/sigstore/sigstore v1.9.6-0.20250729224751-181c5d3339b3 // indirect github.com/sigstore/timestamp-authority v1.2.9 // indirect diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index b754b43c..14f32f34 100644 --- a/pkg/leeway/signing/attestation.go +++ b/pkg/leeway/signing/attestation.go @@ -340,7 +340,16 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen Retries: 1, Version: rekorService.MajorAPIVersion, } - bundleOpts.TransparencyLogs = append(bundleOpts.TransparencyLogs, sign.NewRekor(rekorOpts)) + if rekorService.MajorAPIVersion == 1 { + bundleOpts.TransparencyLogs = append(bundleOpts.TransparencyLogs, newRecoveringRekorV1(recoveringRekorOptions{ + BaseURL: rekorOpts.BaseURL, + Timeout: rekorOpts.Timeout, + Retries: rekorOpts.Retries, + RetryOptions: signingOptions.RetryOptions, + })) + } else { + bundleOpts.TransparencyLogs = append(bundleOpts.TransparencyLogs, sign.NewRekor(rekorOpts)) + } } } diff --git a/pkg/leeway/signing/rekor.go b/pkg/leeway/signing/rekor.go new file mode 100644 index 00000000..6bcda3db --- /dev/null +++ b/pkg/leeway/signing/rekor.go @@ -0,0 +1,282 @@ +package signing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "path" + "regexp" + "strings" + "time" + + "github.com/go-openapi/runtime" + "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + protorekor "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1" + rekorclient "github.com/sigstore/rekor/pkg/client" + "github.com/sigstore/rekor/pkg/generated/client/entries" + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/rekor/pkg/tle" + "github.com/sigstore/rekor/pkg/types" + rekordsse "github.com/sigstore/rekor/pkg/types/dsse" + "github.com/sigstore/sigstore-go/pkg/sign" + "github.com/sigstore/sigstore-go/pkg/util" + + _ "github.com/sigstore/rekor/pkg/types/dsse/v0.0.1" +) + +type recoveringRekorClient interface { + CreateLogEntry(params *entries.CreateLogEntryParams, opts ...entries.ClientOption) (*entries.CreateLogEntryCreated, error) + GetLogEntryByUUID(params *entries.GetLogEntryByUUIDParams, opts ...entries.ClientOption) (*entries.GetLogEntryByUUIDOK, error) +} + +type recoveringRekorOptions struct { + BaseURL string + Timeout time.Duration + Retries uint + Client recoveringRekorClient + RetryOptions RetryOptions +} + +type recoveringRekor struct { + options recoveringRekorOptions +} + +func newRecoveringRekorV1(opts recoveringRekorOptions) sign.Transparency { + if opts.RetryOptions.MaxAttempts == 0 { + opts.RetryOptions = DefaultRetryOptions() + } + return &recoveringRekor{options: opts} +} + +func (r *recoveringRekor) GetTransparencyLogEntry(ctx context.Context, keyOrCertPEM []byte, b *v1.Bundle) error { + proposedEntry, expectedCanonicalBody, err := createDSSEProposedEntry(ctx, keyOrCertPEM, b) + if err != nil { + return err + } + + params := entries.NewCreateLogEntryParams() + if r.options.Timeout >= 0 { + timeout := r.options.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + params.SetTimeout(timeout) + } + params.SetProposedEntry(proposedEntry) + params.SetContext(ctx) + + if err := r.ensureClient(); err != nil { + return err + } + + resp, err := r.options.Client.CreateLogEntry(params) + if err != nil { + tlogEntry, recoverErr := r.recoverExistingEntry(ctx, err, expectedCanonicalBody) + if recoverErr != nil { + return recoverErr + } + appendTlogEntry(b, tlogEntry) + return nil + } + + entry, ok := resp.Payload[resp.ETag] + if !ok { + return fmt.Errorf("created Rekor entry response missing ETag %q", resp.ETag) + } + + tlogEntry, err := tle.GenerateTransparencyLogEntry(entry) + if err != nil { + return err + } + appendTlogEntry(b, tlogEntry) + return nil +} + +func (r *recoveringRekor) ensureClient() error { + if r.options.Client != nil { + return nil + } + + client, err := rekorclient.GetRekorClient( + r.options.BaseURL, + rekorclient.WithUserAgent(util.ConstructUserAgent()), + rekorclient.WithRetryCount(r.options.Retries), + ) + if err != nil { + return err + } + r.options.Client = client.Entries + return nil +} + +func (r *recoveringRekor) recoverExistingEntry(ctx context.Context, createErr error, expectedCanonicalBody []byte) (*protorekor.TransparencyLogEntry, error) { + var conflict *entries.CreateLogEntryConflict + if !errors.As(createErr, &conflict) { + return nil, createErr + } + + uuid, err := extractRekorConflictUUID(conflict) + if err != nil { + return nil, fmt.Errorf("rekor conflict cannot be verified: %w", err) + } + + var existingEntry models.LogEntryAnon + fetchErr := WithRetryOptions(ctx, r.options.RetryOptions, func() error { + params := entries.NewGetLogEntryByUUIDParams() + if r.options.Timeout >= 0 { + timeout := r.options.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + params.SetTimeout(timeout) + } + params.SetContext(ctx) + params.SetEntryUUID(uuid) + + resp, err := r.options.Client.GetLogEntryByUUID(params) + if err != nil { + return err + } + + entry, err := selectFetchedRekorEntry(resp.Payload, uuid) + if err != nil { + return err + } + existingEntry = entry + return nil + }) + if fetchErr != nil { + return nil, fmt.Errorf("failed to fetch conflicting Rekor entry %s: %w", uuid, fetchErr) + } + + return verifyRekorConflictEntry(ctx, uuid, existingEntry, expectedCanonicalBody) +} + +func createDSSEProposedEntry(ctx context.Context, keyOrCertPEM []byte, b *v1.Bundle) (models.ProposedEntry, []byte, error) { + dsseEnvelope := b.GetDsseEnvelope() + if dsseEnvelope == nil { + return nil, nil, fmt.Errorf("unable to find DSSE envelope in bundle") + } + + artifactBytes, err := json.Marshal(dsseEnvelope) + if err != nil { + return nil, nil, err + } + + dsseType := rekordsse.New() + proposedEntry, err := dsseType.CreateProposedEntry(ctx, "", types.ArtifactProperties{ + PublicKeyBytes: [][]byte{keyOrCertPEM}, + ArtifactBytes: artifactBytes, + }) + if err != nil { + return nil, nil, err + } + + entryImpl, err := types.UnmarshalEntry(proposedEntry) + if err != nil { + return nil, nil, err + } + expectedCanonicalBody, err := types.CanonicalizeEntry(ctx, entryImpl) + if err != nil { + return nil, nil, err + } + + return proposedEntry, expectedCanonicalBody, nil +} + +func verifyRekorConflictEntry(ctx context.Context, uuid string, entry models.LogEntryAnon, expectedCanonicalBody []byte) (*protorekor.TransparencyLogEntry, error) { + tlogEntry, err := tle.GenerateTransparencyLogEntry(entry) + if err != nil { + return nil, fmt.Errorf("failed to decode conflicting Rekor entry %s: %w", uuid, err) + } + + if !bytes.Equal(tlogEntry.CanonicalizedBody, expectedCanonicalBody) { + return nil, fmt.Errorf("conflicting Rekor entry %s does not match signed payload", uuid) + } + + proposedEntry, err := models.UnmarshalProposedEntry(bytes.NewReader(tlogEntry.CanonicalizedBody), runtime.JSONConsumer()) + if err != nil { + return nil, fmt.Errorf("failed to parse canonical Rekor body for %s: %w", uuid, err) + } + entryImpl, err := types.UnmarshalEntry(proposedEntry) + if err != nil { + return nil, fmt.Errorf("failed to verify canonical Rekor body for %s: %w", uuid, err) + } + actualCanonicalBody, err := types.CanonicalizeEntry(ctx, entryImpl) + if err != nil { + return nil, fmt.Errorf("failed to canonicalize Rekor body for %s: %w", uuid, err) + } + if !bytes.Equal(actualCanonicalBody, expectedCanonicalBody) { + return nil, fmt.Errorf("conflicting Rekor entry %s canonicalizes to different signed payload", uuid) + } + + return tlogEntry, nil +} + +func appendTlogEntry(b *v1.Bundle, tlogEntry *protorekor.TransparencyLogEntry) { + if b.VerificationMaterial.TlogEntries == nil { + b.VerificationMaterial.TlogEntries = []*protorekor.TransparencyLogEntry{} + } + b.VerificationMaterial.TlogEntries = append(b.VerificationMaterial.TlogEntries, tlogEntry) +} + +func selectFetchedRekorEntry(payload models.LogEntry, uuid string) (models.LogEntryAnon, error) { + if len(payload) == 0 { + return models.LogEntryAnon{}, fmt.Errorf("rekor entry %s response was empty", uuid) + } + + if entry, ok := payload[uuid]; ok { + return entry, nil + } + if len(payload) == 1 { + for _, entry := range payload { + return entry, nil + } + } + + return models.LogEntryAnon{}, fmt.Errorf("rekor entry %s response did not contain matching UUID", uuid) +} + +var rekorUUIDPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{64}(?:[0-9a-f]{16})?\b`) + +func extractRekorConflictUUID(conflict *entries.CreateLogEntryConflict) (string, error) { + if conflict == nil { + return "", fmt.Errorf("missing Rekor conflict response") + } + + location := strings.TrimSpace(string(conflict.Location)) + if location != "" { + if uuid := extractUUIDFromLocation(location); uuid != "" { + return uuid, nil + } + } + + if conflict.Payload != nil { + if uuid := rekorUUIDPattern.FindString(conflict.Payload.Message); uuid != "" { + return uuid, nil + } + } + + return "", fmt.Errorf("rekor conflict response did not include an entry UUID") +} + +func extractUUIDFromLocation(location string) string { + parsed, err := url.Parse(location) + if err != nil { + return "" + } + + candidate := path.Base(parsed.Path) + if rekorUUIDPattern.MatchString(candidate) { + return candidate + } + + if parsed.Fragment != "" && rekorUUIDPattern.MatchString(parsed.Fragment) { + return parsed.Fragment + } + + return "" +} diff --git a/pkg/leeway/signing/rekor_test.go b/pkg/leeway/signing/rekor_test.go new file mode 100644 index 00000000..ba354e0a --- /dev/null +++ b/pkg/leeway/signing/rekor_test.go @@ -0,0 +1,210 @@ +package signing + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "testing" + + "github.com/go-openapi/strfmt" + protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + "github.com/sigstore/rekor/pkg/generated/client/entries" + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/sigstore-go/pkg/sign" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testRekorUUID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +type fakeRecoveringRekorClient struct { + createErr error + getResp *entries.GetLogEntryByUUIDOK + getErr error + + createCalls int + getCalls int +} + +func (f *fakeRecoveringRekorClient) CreateLogEntry(params *entries.CreateLogEntryParams, opts ...entries.ClientOption) (*entries.CreateLogEntryCreated, error) { + f.createCalls++ + return nil, f.createErr +} + +func (f *fakeRecoveringRekorClient) GetLogEntryByUUID(params *entries.GetLogEntryByUUIDParams, opts ...entries.ClientOption) (*entries.GetLogEntryByUUIDOK, error) { + f.getCalls++ + if f.getErr != nil { + return nil, f.getErr + } + return f.getResp, nil +} + +func testDSSEBundle(t *testing.T) (*protobundle.Bundle, []byte, []byte) { + t.Helper() + + ctx := context.Background() + keypair, err := sign.NewEphemeralKeypair(nil) + require.NoError(t, err) + + content := &sign.DSSEData{ + Data: []byte(`{"_type":"https://in-toto.io/Statement/v0.1","subject":[]}`), + PayloadType: "application/vnd.in-toto+json", + } + signature, digest, err := keypair.SignData(ctx, content.PreAuthEncoding()) + require.NoError(t, err) + + bundle := &protobundle.Bundle{ + MediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + VerificationMaterial: &protobundle.VerificationMaterial{}, + } + content.Bundle(bundle, signature, digest, keypair.GetHashAlgorithm()) + + publicKeyPEM, err := keypair.GetPublicKeyPem() + require.NoError(t, err) + + _, expectedCanonicalBody, err := createDSSEProposedEntry(ctx, []byte(publicKeyPEM), bundle) + require.NoError(t, err) + + return bundle, []byte(publicKeyPEM), expectedCanonicalBody +} + +func rekorLogEntry(canonicalBody []byte) models.LogEntryAnon { + integratedTime := int64(123) + logID := strings.Repeat("a", 64) + logIndex := int64(1) + rootHash := strings.Repeat("b", 64) + treeSize := int64(1) + checkpoint := "checkpoint" + + return models.LogEntryAnon{ + Body: base64.StdEncoding.EncodeToString(canonicalBody), + IntegratedTime: &integratedTime, + LogID: &logID, + LogIndex: &logIndex, + Verification: &models.LogEntryAnonVerification{ + InclusionProof: &models.InclusionProof{ + Checkpoint: &checkpoint, + Hashes: []string{}, + LogIndex: &logIndex, + RootHash: &rootHash, + TreeSize: &treeSize, + }, + SignedEntryTimestamp: strfmt.Base64("signed-entry-timestamp"), + }, + } +} + +func rekorConflict(uuid string) *entries.CreateLogEntryConflict { + location := strfmt.URI("https://rekor.example.test/api/v1/log/entries/" + uuid) + return &entries.CreateLogEntryConflict{ + Location: location, + Payload: &models.Error{ + Code: 409, + Message: fmt.Sprintf("an equivalent entry already exists in the transparency log with UUID %s", uuid), + }, + } +} + +func TestRecoveringRekor_ConflictWithMatchingExistingEntrySucceeds(t *testing.T) { + bundle, publicKeyPEM, expectedCanonicalBody := testDSSEBundle(t) + client := &fakeRecoveringRekorClient{ + createErr: rekorConflict(testRekorUUID), + getResp: &entries.GetLogEntryByUUIDOK{ + Payload: models.LogEntry{ + testRekorUUID: rekorLogEntry(expectedCanonicalBody), + }, + }, + } + rekor := newRecoveringRekorV1(recoveringRekorOptions{ + Client: client, + RetryOptions: noSleepRetryOptions(1), + }) + + err := rekor.GetTransparencyLogEntry(context.Background(), publicKeyPEM, bundle) + + require.NoError(t, err) + assert.Equal(t, 1, client.createCalls) + assert.Equal(t, 1, client.getCalls) + require.Len(t, bundle.VerificationMaterial.TlogEntries, 1) + assert.Equal(t, expectedCanonicalBody, bundle.VerificationMaterial.TlogEntries[0].CanonicalizedBody) +} + +func TestRecoveringRekor_ConflictWithMismatchedExistingEntryFailsClosed(t *testing.T) { + bundle, publicKeyPEM, expectedCanonicalBody := testDSSEBundle(t) + _, _, mismatchedCanonicalBody := testDSSEBundle(t) + require.NotEqual(t, string(expectedCanonicalBody), string(mismatchedCanonicalBody)) + + client := &fakeRecoveringRekorClient{ + createErr: rekorConflict(testRekorUUID), + getResp: &entries.GetLogEntryByUUIDOK{ + Payload: models.LogEntry{ + testRekorUUID: rekorLogEntry(mismatchedCanonicalBody), + }, + }, + } + rekor := newRecoveringRekorV1(recoveringRekorOptions{ + Client: client, + RetryOptions: noSleepRetryOptions(1), + }) + + err := rekor.GetTransparencyLogEntry(context.Background(), publicKeyPEM, bundle) + + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match signed payload") + assert.Empty(t, bundle.VerificationMaterial.TlogEntries) +} + +func TestRecoveringRekor_ConflictFetchFailureFailsClosed(t *testing.T) { + bundle, publicKeyPEM, _ := testDSSEBundle(t) + client := &fakeRecoveringRekorClient{ + createErr: rekorConflict(testRekorUUID), + getErr: fmt.Errorf("fetch failed"), + } + rekor := newRecoveringRekorV1(recoveringRekorOptions{ + Client: client, + RetryOptions: noSleepRetryOptions(1), + }) + + err := rekor.GetTransparencyLogEntry(context.Background(), publicKeyPEM, bundle) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch conflicting Rekor entry") + assert.Empty(t, bundle.VerificationMaterial.TlogEntries) +} + +func TestRecoveringRekor_ConflictWithoutProofFailsClosed(t *testing.T) { + bundle, publicKeyPEM, _ := testDSSEBundle(t) + client := &fakeRecoveringRekorClient{ + createErr: &entries.CreateLogEntryConflict{ + Payload: &models.Error{ + Code: 409, + Message: "an equivalent entry already exists in the transparency log", + }, + }, + } + rekor := newRecoveringRekorV1(recoveringRekorOptions{ + Client: client, + RetryOptions: noSleepRetryOptions(1), + }) + + err := rekor.GetTransparencyLogEntry(context.Background(), publicKeyPEM, bundle) + + require.Error(t, err) + assert.Contains(t, err.Error(), "did not include an entry UUID") + assert.Equal(t, 0, client.getCalls) + assert.Empty(t, bundle.VerificationMaterial.TlogEntries) +} + +func TestVerifyRekorConflictEntry_RequiresCanonicalPayloadMatch(t *testing.T) { + _, _, expectedCanonicalBody := testDSSEBundle(t) + _, _, mismatchedCanonicalBody := testDSSEBundle(t) + require.NotEqual(t, string(expectedCanonicalBody), string(mismatchedCanonicalBody)) + + _, err := verifyRekorConflictEntry(context.Background(), testRekorUUID, rekorLogEntry(expectedCanonicalBody), expectedCanonicalBody) + require.NoError(t, err) + + _, err = verifyRekorConflictEntry(context.Background(), testRekorUUID, rekorLogEntry(mismatchedCanonicalBody), expectedCanonicalBody) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match signed payload") +} From 2dff5ab5e297aa6ef860aa4fa5aa0d6cc70413c9 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:12:24 +0000 Subject: [PATCH 3/4] feat(sign-cache): configure signing retry attempts Co-authored-by: Codex --- cmd/root.go | 4 +++ cmd/sign-cache.go | 43 ++++++++++++++++++++++++++---- cmd/sign-cache_test.go | 59 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 9d5e6088..3327eb8b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,9 @@ const ( // EnvvarMaxSigningConcurrency configures maximum concurrent signing operations EnvvarMaxSigningConcurrency = "LEEWAY_MAX_SIGNING_CONCURRENCY" + + // EnvvarSigningRetryAttempts configures retry attempts for transient signing failures + EnvvarSigningRetryAttempts = "LEEWAY_SIGNING_RETRY_ATTEMPTS" ) const ( @@ -134,6 +137,7 @@ variables have an effect on leeway: LEEWAY_SLSA_SOURCE_URI Expected source URI for SLSA verification (github.com/owner/repo). LEEWAY_SLSA_REQUIRE_ATTESTATION Require valid attestations; missing/invalid → build locally (true/false). LEEWAY_ENABLE_IN_FLIGHT_CHECKSUMS Enable checksumming of cache artifacts (true/false). +LEEWAY_SIGNING_RETRY_ATTEMPTS Retry attempts for transient Sigstore/Rekor signing failures. LEEWAY_EXPERIMENTAL Enables experimental leeway features and commands. `), PersistentPreRun: func(cmd *cobra.Command, args []string) { diff --git a/cmd/sign-cache.go b/cmd/sign-cache.go index b1e5655c..e04d48ba 100644 --- a/cmd/sign-cache.go +++ b/cmd/sign-cache.go @@ -31,11 +31,17 @@ Concurrency: Configure via --max-signing-concurrency flag or LEEWAY_MAX_SIGNING_CONCURRENCY env var Valid range: 1-100 (automatically capped) +Retries: + Default: 3 attempts for transient Sigstore/Rekor signing failures + Configure via --signing-retry-attempts flag or LEEWAY_SIGNING_RETRY_ATTEMPTS env var + Valid range: 1-10 (automatically capped) + Example: leeway plumbing sign-cache --from-manifest artifacts-to-sign.txt leeway plumbing sign-cache --from-manifest artifacts.txt --dry-run leeway plumbing sign-cache --from-manifest artifacts.txt --max-signing-concurrency 30 - LEEWAY_MAX_SIGNING_CONCURRENCY=30 leeway plumbing sign-cache --from-manifest artifacts.txt`, + leeway plumbing sign-cache --from-manifest artifacts.txt --signing-retry-attempts 5 + LEEWAY_MAX_SIGNING_CONCURRENCY=30 LEEWAY_SIGNING_RETRY_ATTEMPTS=5 leeway plumbing sign-cache --from-manifest artifacts.txt`, RunE: func(cmd *cobra.Command, args []string) error { manifestPath, _ := cmd.Flags().GetString("from-manifest") dryRun, _ := cmd.Flags().GetBool("dry-run") @@ -50,6 +56,15 @@ Example: } } + signingRetryAttempts, _ := cmd.Flags().GetInt("signing-retry-attempts") + if !cmd.Flags().Changed("signing-retry-attempts") { + if envVal := os.Getenv(EnvvarSigningRetryAttempts); envVal != "" { + if parsed, err := strconv.Atoi(envVal); err == nil && parsed > 0 { + signingRetryAttempts = parsed + } + } + } + if manifestPath == "" { return fmt.Errorf("--from-manifest flag is required") } @@ -59,7 +74,9 @@ Example: return fmt.Errorf("manifest file does not exist: %s", manifestPath) } - return runSignCache(cmd.Context(), manifestPath, dryRun, maxConcurrency) + signingOptions := signing.DefaultSLSASigningOptions() + signingOptions.RetryOptions.MaxAttempts = signingRetryAttempts + return runSignCacheWithOptions(cmd.Context(), manifestPath, dryRun, maxConcurrency, signingOptions) }, } @@ -68,11 +85,16 @@ func init() { signCacheCmd.Flags().String("from-manifest", "", "Path to newline-separated artifact paths file") signCacheCmd.Flags().Bool("dry-run", false, "Log actions without signing or uploading") signCacheCmd.Flags().Int("max-signing-concurrency", 20, "Maximum concurrent signing operations (env: LEEWAY_MAX_SIGNING_CONCURRENCY)") + signCacheCmd.Flags().Int("signing-retry-attempts", 3, "Retry attempts for transient Sigstore/Rekor signing failures (env: LEEWAY_SIGNING_RETRY_ATTEMPTS)") _ = signCacheCmd.MarkFlagRequired("from-manifest") } // runSignCache implements the main signing logic func runSignCache(ctx context.Context, manifestPath string, dryRun bool, maxConcurrency int) error { + return runSignCacheWithOptions(ctx, manifestPath, dryRun, maxConcurrency, signing.DefaultSLSASigningOptions()) +} + +func runSignCacheWithOptions(ctx context.Context, manifestPath string, dryRun bool, maxConcurrency int, signingOptions signing.SLSASigningOptions) error { log.WithFields(log.Fields{ "manifest": manifestPath, "dry_run": dryRun, @@ -141,6 +163,15 @@ func runSignCache(ctx context.Context, manifestPath string, dryRun bool, maxConc log.WithField("maxConcurrency", maxConcurrency).Info("Configured signing concurrency") + if signingOptions.RetryOptions.MaxAttempts < 1 { + log.WithField("provided", signingOptions.RetryOptions.MaxAttempts).Warn("signing retry attempts must be at least 1, using 1") + signingOptions.RetryOptions.MaxAttempts = 1 + } else if signingOptions.RetryOptions.MaxAttempts > 10 { + log.WithField("provided", signingOptions.RetryOptions.MaxAttempts).Warn("signing retry attempts exceeds maximum, capping at 10") + signingOptions.RetryOptions.MaxAttempts = 10 + } + log.WithField("signingRetryAttempts", signingOptions.RetryOptions.MaxAttempts).Info("Configured signing retry attempts") + const maxAcceptableFailureRate = 0.5 // Fail command if more than 50% of artifacts fail semaphore := make(chan struct{}, maxConcurrency) @@ -171,7 +202,7 @@ func runSignCache(ctx context.Context, manifestPath string, dryRun bool, maxConc log.WithField("artifact", artifactPath).Debug("Starting artifact processing") - if err := processArtifact(ctx, artifactPath, githubCtx, remoteCache, dryRun); err != nil { + if err := processArtifactWithOptions(ctx, artifactPath, githubCtx, remoteCache, dryRun, signingOptions); err != nil { signingErr := signing.CategorizeError(artifactPath, err) mu.Lock() @@ -227,8 +258,10 @@ func runSignCache(ctx context.Context, manifestPath string, dryRun bool, maxConc return nil } +var generateSignedSLSAAttestation = signing.GenerateSignedSLSAAttestationWithOptions + // processArtifact handles signing and uploading of a single artifact using integrated SLSA signing -func processArtifact(ctx context.Context, artifactPath string, githubCtx *signing.GitHubContext, remoteCache cache.RemoteCache, dryRun bool) error { +func processArtifactWithOptions(ctx context.Context, artifactPath string, githubCtx *signing.GitHubContext, remoteCache cache.RemoteCache, dryRun bool, signingOptions signing.SLSASigningOptions) error { log.WithFields(log.Fields{ "artifact": artifactPath, "dry_run": dryRun, @@ -240,7 +273,7 @@ func processArtifact(ctx context.Context, artifactPath string, githubCtx *signin } // Single step: generate and sign SLSA attestation using integrated approach - signedAttestation, err := signing.GenerateSignedSLSAAttestation(ctx, artifactPath, githubCtx) + signedAttestation, err := generateSignedSLSAAttestation(ctx, artifactPath, githubCtx, signingOptions) if err != nil { return fmt.Errorf("failed to generate signed attestation: %w", err) } diff --git a/cmd/sign-cache_test.go b/cmd/sign-cache_test.go index 6d3b4b29..a29aa95b 100644 --- a/cmd/sign-cache_test.go +++ b/cmd/sign-cache_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "fmt" "os" "path/filepath" "strconv" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/gitpod-io/leeway/pkg/leeway/cache" "github.com/gitpod-io/leeway/pkg/leeway/signing" ) @@ -41,6 +43,31 @@ func createMockArtifact(t *testing.T, dir string, name string) string { return artifactPath } +type signCacheMockRemoteCache struct { + uploadFileCalls int +} + +func (m *signCacheMockRemoteCache) ExistingPackages(ctx context.Context, pkgs []cache.Package) (map[cache.Package]struct{}, error) { + return map[cache.Package]struct{}{}, nil +} + +func (m *signCacheMockRemoteCache) Download(ctx context.Context, dst cache.LocalCache, pkgs []cache.Package) map[string]cache.DownloadResult { + return map[string]cache.DownloadResult{} +} + +func (m *signCacheMockRemoteCache) Upload(ctx context.Context, src cache.LocalCache, pkgs []cache.Package) error { + return nil +} + +func (m *signCacheMockRemoteCache) UploadFile(ctx context.Context, filePath string, key string) error { + m.uploadFileCalls++ + return nil +} + +func (m *signCacheMockRemoteCache) HasFile(ctx context.Context, key string) (bool, error) { + return false, nil +} + // TestSignCacheCommand_Exists verifies the command is properly registered func TestSignCacheCommand_Exists(t *testing.T) { // Verify sign-cache command exists under plumbing @@ -69,6 +96,10 @@ func TestSignCacheCommand_FlagDefinitions(t *testing.T) { require.NotNil(t, dryRunFlag, "dry-run flag should exist") assert.Equal(t, "bool", dryRunFlag.Value.Type()) + retryFlag := cmd.Flags().Lookup("signing-retry-attempts") + require.NotNil(t, retryFlag, "signing-retry-attempts flag should exist") + assert.Equal(t, "int", retryFlag.Value.Type()) + // Verify from-manifest is required annotations := cmd.Flags().Lookup("from-manifest").Annotations assert.NotNil(t, annotations, "from-manifest should have required annotation") @@ -479,6 +510,32 @@ func TestSignCache_DryRunMode(t *testing.T) { assert.False(t, operationsPerformed, "No real operations should occur in dry-run") } +func TestProcessArtifact_RekorConflictWithoutProofFailsBeforeUpload(t *testing.T) { + tmpDir := t.TempDir() + artifact := createMockArtifact(t, tmpDir, "conflict.tar.gz") + remoteCache := &signCacheMockRemoteCache{} + + originalGenerator := generateSignedSLSAAttestation + generateSignedSLSAAttestation = func(ctx context.Context, artifactPath string, githubCtx *signing.GitHubContext, options signing.SLSASigningOptions) (*signing.SignedAttestationResult, error) { + return nil, fmt.Errorf("[POST /api/v1/log/entries][409] createLogEntryConflict an equivalent entry already exists without verifiable proof") + } + t.Cleanup(func() { + generateSignedSLSAAttestation = originalGenerator + }) + + err := processArtifactWithOptions(context.Background(), artifact, &signing.GitHubContext{ + RunID: "123456", + Repository: "gitpod-io/leeway", + SHA: "abc123", + ServerURL: "https://github.com", + WorkflowRef: ".github/workflows/build.yml@main", + }, remoteCache, false, signing.DefaultSLSASigningOptions()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "createLogEntryConflict") + assert.Equal(t, 0, remoteCache.uploadFileCalls, "unverified Rekor conflicts must not upload unsigned artifacts or attestations") +} + // Helper: Set up minimal GitHub environment for testing func setupGitHubEnv(t *testing.T) { t.Setenv("GITHUB_RUN_ID", "123456") @@ -514,7 +571,7 @@ func TestSignCache_ErrorScenarios(t *testing.T) { manifestPath := createTestManifest(t, tmpDir, []string{artifact}) // Ensure no cache env vars set - os.Unsetenv("LEEWAY_REMOTE_CACHE_BUCKET") + _ = os.Unsetenv("LEEWAY_REMOTE_CACHE_BUCKET") return manifestPath, func() {} }, From 45c49cb0e283975a80ed385ac12d81cd25032fc8 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:06:50 +0000 Subject: [PATCH 4/4] test(signing): verify recovered rekor bundle path Co-authored-by: Codex --- pkg/leeway/signing/rekor_test.go | 179 +++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/pkg/leeway/signing/rekor_test.go b/pkg/leeway/signing/rekor_test.go index ba354e0a..dc0215b6 100644 --- a/pkg/leeway/signing/rekor_test.go +++ b/pkg/leeway/signing/rekor_test.go @@ -2,16 +2,26 @@ package signing import ( "context" + "crypto" + "crypto/rand" + "crypto/sha256" + "crypto/x509" "encoding/base64" + "encoding/pem" "fmt" "strings" "testing" + "time" "github.com/go-openapi/strfmt" protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" + protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1" "github.com/sigstore/rekor/pkg/generated/client/entries" "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/rekor/pkg/types" "github.com/sigstore/sigstore-go/pkg/sign" + "github.com/sigstore/sigstore-go/pkg/testing/ca" + "github.com/sigstore/sigstore-go/pkg/tlog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -40,6 +50,105 @@ func (f *fakeRecoveringRekorClient) GetLogEntryByUUID(params *entries.GetLogEntr return f.getResp, nil } +type verifiedConflictRekorClient struct { + t *testing.T + sigstore *ca.VirtualSigstore + + proposedEntry models.ProposedEntry + createCalls int + getCalls int +} + +func (v *verifiedConflictRekorClient) CreateLogEntry(params *entries.CreateLogEntryParams, opts ...entries.ClientOption) (*entries.CreateLogEntryCreated, error) { + v.createCalls++ + v.proposedEntry = params.ProposedEntry + return nil, rekorConflict(testRekorUUID) +} + +func (v *verifiedConflictRekorClient) GetLogEntryByUUID(params *entries.GetLogEntryByUUIDParams, opts ...entries.ClientOption) (*entries.GetLogEntryByUUIDOK, error) { + v.getCalls++ + require.Equal(v.t, testRekorUUID, params.EntryUUID) + require.NotNil(v.t, v.proposedEntry) + + entryImpl, err := types.UnmarshalEntry(v.proposedEntry) + require.NoError(v.t, err) + canonicalBody, err := types.CanonicalizeEntry(params.Context, entryImpl) + require.NoError(v.t, err) + + return &entries.GetLogEntryByUUIDOK{ + Payload: models.LogEntry{ + testRekorUUID: verifiedRekorLogEntry(v.t, v.sigstore, canonicalBody), + }, + }, nil +} + +type staticCertificateProvider struct { + cert *x509.Certificate +} + +func (s staticCertificateProvider) GetCertificate(context.Context, sign.Keypair, *sign.CertificateProviderOptions) ([]byte, error) { + return s.cert.Raw, nil +} + +type certificateKeypair struct { + privateKey crypto.Signer + hint []byte +} + +func newCertificateKeypair(t *testing.T, privateKey crypto.PrivateKey) *certificateKeypair { + t.Helper() + + signer, ok := privateKey.(crypto.Signer) + require.True(t, ok) + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(signer.Public()) + require.NoError(t, err) + hint := sha256.Sum256(publicKeyBytes) + + return &certificateKeypair{ + privateKey: signer, + hint: []byte(base64.StdEncoding.EncodeToString(hint[:])), + } +} + +func (c *certificateKeypair) GetHashAlgorithm() protocommon.HashAlgorithm { + return protocommon.HashAlgorithm_SHA2_256 +} + +func (c *certificateKeypair) GetSigningAlgorithm() protocommon.PublicKeyDetails { + return protocommon.PublicKeyDetails_PKIX_ECDSA_P256_SHA_256 +} + +func (c *certificateKeypair) GetHint() []byte { + return c.hint +} + +func (c *certificateKeypair) GetKeyAlgorithm() string { + return "ECDSA" +} + +func (c *certificateKeypair) GetPublicKey() crypto.PublicKey { + return c.privateKey.Public() +} + +func (c *certificateKeypair) GetPublicKeyPem() (string, error) { + publicKeyBytes, err := x509.MarshalPKIXPublicKey(c.privateKey.Public()) + if err != nil { + return "", err + } + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + }) + return string(publicKeyPEM), nil +} + +func (c *certificateKeypair) SignData(_ context.Context, data []byte) ([]byte, []byte, error) { + digest := sha256.Sum256(data) + signature, err := c.privateKey.Sign(rand.Reader, digest[:], crypto.SHA256) + return signature, digest[:], err +} + func testDSSEBundle(t *testing.T) (*protobundle.Bundle, []byte, []byte) { t.Helper() @@ -95,6 +204,38 @@ func rekorLogEntry(canonicalBody []byte) models.LogEntryAnon { } } +func verifiedRekorLogEntry(t *testing.T, sigstore *ca.VirtualSigstore, canonicalBody []byte) models.LogEntryAnon { + t.Helper() + + integratedTime := time.Now().Add(time.Minute).Unix() + body := base64.StdEncoding.EncodeToString(canonicalBody) + + logID, err := sigstore.RekorLogID() + require.NoError(t, err) + inclusionProof, err := sigstore.GetInclusionProof(canonicalBody) + require.NoError(t, err) + logIndex := *inclusionProof.LogIndex + + set, err := sigstore.RekorSignPayload(tlog.RekorPayload{ + Body: body, + IntegratedTime: integratedTime, + LogIndex: logIndex, + LogID: logID, + }) + require.NoError(t, err) + + return models.LogEntryAnon{ + Body: body, + IntegratedTime: &integratedTime, + LogID: &logID, + LogIndex: &logIndex, + Verification: &models.LogEntryAnonVerification{ + InclusionProof: inclusionProof, + SignedEntryTimestamp: strfmt.Base64(set), + }, + } +} + func rekorConflict(uuid string) *entries.CreateLogEntryConflict { location := strfmt.URI("https://rekor.example.test/api/v1/log/entries/" + uuid) return &entries.CreateLogEntryConflict{ @@ -106,6 +247,44 @@ func rekorConflict(uuid string) *entries.CreateLogEntryConflict { } } +func TestRecoveringRekor_ConflictRecoveryProducesVerifiableBundle(t *testing.T) { + ctx := context.Background() + virtualSigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + cert, privateKey, err := virtualSigstore.GenerateLeafCert("identity@example.test", "issuer") + require.NoError(t, err) + + client := &verifiedConflictRekorClient{ + t: t, + sigstore: virtualSigstore, + } + rekor := newRecoveringRekorV1(recoveringRekorOptions{ + Client: client, + RetryOptions: noSleepRetryOptions(1), + }) + + bundle, err := sign.Bundle( + &sign.DSSEData{ + Data: []byte(`{"_type":"https://in-toto.io/Statement/v0.1","subject":[]}`), + PayloadType: "application/vnd.in-toto+json", + }, + newCertificateKeypair(t, privateKey), + sign.BundleOptions{ + CertificateProvider: staticCertificateProvider{cert: cert}, + Context: ctx, + TransparencyLogs: []sign.Transparency{rekor}, + TrustedRoot: virtualSigstore, + }, + ) + + require.NoError(t, err) + assert.Equal(t, 1, client.createCalls) + assert.Equal(t, 1, client.getCalls) + require.Len(t, bundle.VerificationMaterial.TlogEntries, 1) + assert.NotNil(t, bundle.VerificationMaterial.TlogEntries[0].InclusionProof) + assert.NotNil(t, bundle.VerificationMaterial.TlogEntries[0].InclusionPromise) +} + func TestRecoveringRekor_ConflictWithMatchingExistingEntrySucceeds(t *testing.T) { bundle, publicKeyPEM, expectedCanonicalBody := testDSSEBundle(t) client := &fakeRecoveringRekorClient{