diff --git a/CLAUDE.md b/CLAUDE.md index cf455e5..f39db91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ task license-fix # Add missing license headers | `validation/http` | RFC 7230/8707 compliant HTTP header and URI validation | | `validation/group` | Group name validation (lowercase alphanumeric, underscore, dash, space) | | `registry/types` | Skill/Server/Plugin catalog types + JSON-schema validation (Alpha) | +| `container/verifier` | Sigstore verification of OCI artifacts: online server verification + bundle retrieval, offline/key verify, identity extraction (Alpha) | ### Mock Generation diff --git a/container/verifier/attestations.go b/container/verifier/attestations.go index 5f3844a..868ea55 100644 --- a/container/verifier/attestations.go +++ b/container/verifier/attestations.go @@ -4,6 +4,7 @@ package verifier import ( + "context" "encoding/hex" "fmt" "io" @@ -21,11 +22,11 @@ import ( // bundleFromAttestation retrieves the attestation bundles from the image reference. Note that the attestation // bundles are stored as OCI image references. The function uses the referrers API to get the attestation. GitHub supports // discovering the attestations via their API, but this is not supported here for now. -func bundleFromAttestation(imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) { +func bundleFromAttestation(ctx context.Context, imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) { var bundles []sigstoreBundle // Get the auth options - opts := []remote.Option{remote.WithAuthFromKeychain(keychain)} + opts := []remote.Option{remote.WithAuthFromKeychain(keychain), remote.WithContext(ctx)} // Get the image reference ref, err := name.ParseReference(imageRef) @@ -116,7 +117,9 @@ func extractBundleFromImage(img v1.Image) (*bundle.Bundle, error) { if err != nil { return nil, fmt.Errorf("error uncompressing referrer layer: %w", err) } - bundleBytes, err := io.ReadAll(layer0) + // Cap the read: the layer comes from the registry (untrusted) and the + // signature-manifest path enforces the same limit. + bundleBytes, err := io.ReadAll(io.LimitReader(layer0, MaxAttestationsBytesLimit)) if err != nil { return nil, fmt.Errorf("error reading referrer layer: %w", err) } diff --git a/container/verifier/bundles.go b/container/verifier/bundles.go new file mode 100644 index 0000000..ce61a43 --- /dev/null +++ b/container/verifier/bundles.go @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "context" + "crypto" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/sigstore/sigstore-go/pkg/bundle" + "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/signature" +) + +// DigestAlgorithmSHA256 is the digest algorithm name used throughout the +// Sigstore bundle formats this package handles. +const DigestAlgorithmSHA256 = "sha256" + +// ErrNoBundles is returned by RetrieveBundles when the artifact carries no +// Sigstore signature or attestation in any supported layout — i.e. the +// artifact is unsigned as far as this package can tell. +var ErrNoBundles = errors.New("no sigstore bundles found for artifact") + +// ErrVerificationFailed wraps every cryptographic verification failure +// returned by the VerifyBundle* functions, so callers can distinguish +// "signed but failed verification" from malformed input with errors.Is +// instead of matching sigstore-go's (unstable) error strings. +var ErrVerificationFailed = errors.New("sigstore bundle verification failed") + +// Bundle is a Sigstore bundle retrieved for an artifact, in both parsed and +// serialized form. Raw is the canonical JSON encoding, suitable for durable +// storage and later re-verification with VerifyBundleOffline. +type Bundle struct { + // Parsed is the decoded bundle. + Parsed *bundle.Bundle + // Raw is the bundle's canonical JSON serialization. + Raw []byte + // DigestAlgo is the algorithm of the artifact digest the bundle signs + // (e.g. "sha256"). + DigestAlgo string + // DigestHex is the hex-encoded artifact digest the bundle signs. + DigestHex string +} + +// Identity is the signer identity extracted from a verified Sigstore bundle. +type Identity struct { + // SignerIdentity is the certificate's subject identity. For + // certificates issued through GitHub Actions tokens this is the + // workflow path relative to the repository (see + // signerIdentityFromCertificate); otherwise it is the certificate SAN + // verbatim (a URI, email, or SPIFFE ID). + SignerIdentity string + // CertIssuer is the OIDC issuer that authenticated the signer. + CertIssuer string + // SourceRepositoryURI is the source repository recorded in the Fulcio + // certificate extensions, when present. + SourceRepositoryURI string +} + +// IdentityFromResult extracts the signer Identity from a verification result. +func IdentityFromResult(r *verify.VerificationResult) (Identity, error) { + if r == nil || r.Signature == nil || r.Signature.Certificate == nil { + return Identity{}, errors.New("verification result carries no certificate summary") + } + signer, err := signerIdentityFromCertificate(r.Signature.Certificate) + if err != nil { + return Identity{}, fmt.Errorf("extracting signer identity: %w", err) + } + return Identity{ + SignerIdentity: signer, + CertIssuer: r.Signature.Certificate.Issuer, + SourceRepositoryURI: r.Signature.Certificate.SourceRepositoryURI, + }, nil +} + +// RetrieveBundles fetches the Sigstore bundles attached to imageRef, trying +// both layouts this package understands: a cosign-style signature manifest +// (the "sha256-.sig" tag) and attestation manifests. It returns +// ErrNoBundles when the artifact has no discoverable signature material — +// the caller's signal that the artifact is unsigned. +func RetrieveBundles(ctx context.Context, imageRef string, keychain authn.Keychain) ([]Bundle, error) { + internal, err := getSigstoreBundles(ctx, imageRef, keychain) + if errors.Is(err, ErrProvenanceNotFoundOrIncomplete) { + return nil, fmt.Errorf("%w: %w", ErrNoBundles, err) + } + if err != nil { + return nil, err + } + if len(internal) == 0 { + return nil, ErrNoBundles + } + + bundles := make([]Bundle, 0, len(internal)) + for _, b := range internal { + // MarshalJSON is protojson under the hood — the canonical bundle + // encoding; called explicitly so it doesn't rely on json.Marshal's + // interface dispatch. + raw, marshalErr := b.bundle.MarshalJSON() + if marshalErr != nil { + return nil, fmt.Errorf("serializing sigstore bundle: %w", marshalErr) + } + bundles = append(bundles, Bundle{ + Parsed: b.bundle, + Raw: raw, + DigestAlgo: b.digestAlgo, + DigestHex: hex.EncodeToString(b.digestBytes), + }) + } + return bundles, nil +} + +// OfflineTrustedMaterial returns trusted material for the Sigstore +// public-good instance built entirely from the trusted root embedded in this +// package — no network access, no TUF refresh. The embedded root is a +// point-in-time snapshot: key rotations in the public-good instance require +// a package update to pick up. This cuts both ways — newly rotated-in keys +// are unknown (verification of fresh signatures fails until the snapshot is +// updated), and a key rotated out BECAUSE OF COMPROMISE keeps being trusted +// here until a new release ships and consumers bump. Callers that need live +// freshness or timely compromise revocation should use New (which performs +// a TUF fetch) instead; offline verification trades that for hermeticity. +// See tufroots/README.md for the snapshot's provenance. +func OfflineTrustedMaterial() (root.TrustedMaterial, error) { + rawRoot, err := embeddedTufRoots.ReadFile( + "tufroots/" + TrustedRootSigstorePublicGoodInstance + "/trusted_root.json") + if err != nil { + return nil, fmt.Errorf("reading embedded trusted root: %w", err) + } + tr, err := root.NewTrustedRootFromJSON(rawRoot) + if err != nil { + return nil, fmt.Errorf("parsing embedded trusted root: %w", err) + } + return tr, nil +} + +// PublicKeyMaterial returns trusted material that verifies bundles signed +// with the private counterpart of the given PEM-encoded public key (the +// cosign key-pair flow, as opposed to keyless/Fulcio). The key is trusted +// without validity-period bounds: key-signed bundles carry no certificate +// whose lifetime could scope it. +func PublicKeyMaterial(pubKeyPEM []byte) (root.TrustedMaterial, error) { + pub, err := cryptoutils.UnmarshalPEMToPublicKey(pubKeyPEM) + if err != nil { + return nil, fmt.Errorf("parsing public key: %w", err) + } + sigVerifier, err := signature.LoadVerifier(pub, crypto.SHA256) + if err != nil { + return nil, fmt.Errorf("loading signature verifier: %w", err) + } + return root.NewTrustedPublicKeyMaterial(func(string) (root.TimeConstrainedVerifier, error) { + return root.NewExpiringKey(sigVerifier, time.Time{}, time.Time{}), nil + }), nil +} + +// VerifyBundle verifies a retrieved bundle against the given trusted +// material. When expected is non-nil, the identity is bound into the +// Sigstore verification policy itself (certificate SAN and issuer must +// match) rather than compared after the fact; a nil expected — the +// trust-on-first-use case — verifies the chain of trust only, and the +// caller records the identity from the returned result. +// +// verifierOpts configure the verifier and MUST match the trusted material: +// pass DefaultVerifierOptions() with public-good material (SCT + +// transparency log + observer timestamps), and +// verify.WithNoObserverTimestamps() with PublicKeyMaterial (key-signed +// bundles carry no certificate transparency or Fulcio timestamps). +// Requiring the options explicitly prevents public-good defaults being fed +// to a different root, which surfaces as confusing sigstore-go internals +// rather than a clear mismatch. +func VerifyBundle( + b Bundle, + tm root.TrustedMaterial, + expected *Identity, + verifierOpts ...verify.VerifierOption, +) (*verify.VerificationResult, error) { + if b.Parsed == nil { + return nil, errors.New("bundle is not parsed") + } + if len(verifierOpts) == 0 { + return nil, errors.New( + "verifier options are required and must match the trusted material: " + + "use DefaultVerifierOptions() for the Sigstore public-good instance " + + "or verify.WithNoObserverTimestamps() for key material") + } + sev, err := verify.NewVerifier(tm, verifierOpts...) + if err != nil { + return nil, fmt.Errorf("building verifier: %w", err) + } + + digestBytes, err := hex.DecodeString(b.DigestHex) + if err != nil { + return nil, fmt.Errorf("decoding artifact digest: %w", err) + } + policyOpts := []verify.PolicyOption{} + identityOpt, err := identityPolicyOption(expected) + if err != nil { + return nil, err + } + policyOpts = append(policyOpts, identityOpt) + + result, err := sev.Verify(b.Parsed, verify.NewPolicy( + verify.WithArtifactDigest(b.DigestAlgo, digestBytes), + policyOpts..., + )) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrVerificationFailed, err) + } + return result, nil +} + +// DefaultVerifierOptions returns the verifier options matching the Sigstore +// public-good instance trust root (SCT, transparency log, and observer +// timestamp requirements). Pass these to VerifyBundle together with +// OfflineTrustedMaterial (or the live public-good root). +func DefaultVerifierOptions() ([]verify.VerifierOption, error) { + return verifierOptions(TrustedRootSigstorePublicGoodInstance) +} + +// VerifyBundleWithKey verifies a bundle signed with a plain key pair (the +// cosign --key flow) against the given PEM public key. Key-signed bundles +// carry no certificate, so there is no identity to bind — trust is the key +// itself — and no transparency-log or timestamp material to require. +func VerifyBundleWithKey(b Bundle, pubKeyPEM []byte) (*verify.VerificationResult, error) { + if b.Parsed == nil { + return nil, errors.New("bundle is not parsed") + } + tm, err := PublicKeyMaterial(pubKeyPEM) + if err != nil { + return nil, err + } + sev, err := verify.NewVerifier(tm, verify.WithNoObserverTimestamps()) + if err != nil { + return nil, fmt.Errorf("building verifier: %w", err) + } + digestBytes, err := hex.DecodeString(b.DigestHex) + if err != nil { + return nil, fmt.Errorf("decoding artifact digest: %w", err) + } + result, err := sev.Verify(b.Parsed, verify.NewPolicy( + verify.WithArtifactDigest(b.DigestAlgo, digestBytes), + verify.WithKey(), + )) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrVerificationFailed, err) + } + return result, nil +} + +// VerifyBundleOffline re-verifies a stored bundle (the Raw form produced by +// RetrieveBundles or a signing flow) against the artifact digest +// ("sha256:"), using only the embedded trusted root — no network. See +// OfflineTrustedMaterial for the freshness trade-off. expected behaves as +// in VerifyBundle. +func VerifyBundleOffline( + rawBundle []byte, + artifactDigest string, + expected *Identity, +) (*verify.VerificationResult, error) { + digestAlgo, digestHex, ok := strings.Cut(artifactDigest, ":") + if !ok || digestAlgo == "" || digestHex == "" { + return nil, fmt.Errorf("artifact digest %q is not in : form", artifactDigest) + } + tm, err := OfflineTrustedMaterial() + if err != nil { + return nil, err + } + opts, err := DefaultVerifierOptions() + if err != nil { + return nil, err + } + parsed := &bundle.Bundle{} + if err := parsed.UnmarshalJSON(rawBundle); err != nil { + return nil, fmt.Errorf("parsing stored bundle: %w", err) + } + return VerifyBundle(Bundle{ + Parsed: parsed, + Raw: rawBundle, + DigestAlgo: digestAlgo, + DigestHex: digestHex, + }, tm, expected, opts...) +} + +// identityPolicyOption translates an expected Identity into a Sigstore +// certificate-identity policy. For identities recorded from GitHub Actions +// certificates the SAN is the repository URI + workflow path (+ "@ref"), so +// the match is anchored by prefix; other identities match the SAN exactly. +func identityPolicyOption(expected *Identity) (verify.PolicyOption, error) { + if expected == nil { + //nolint:staticcheck // deliberate: TOFU first use has no identity to pin yet + return verify.WithoutIdentitiesUnsafe(), nil + } + var certID verify.CertificateIdentity + var err error + if expected.SourceRepositoryURI != "" { + // GitHub-Actions-derived identity: SAN = repoURI + workflowPath[@ref]. + sanRegex := "^" + regexp.QuoteMeta(expected.SourceRepositoryURI+expected.SignerIdentity) + "(@.*)?$" + certID, err = verify.NewShortCertificateIdentity(expected.CertIssuer, "", "", sanRegex) + } else { + certID, err = verify.NewShortCertificateIdentity(expected.CertIssuer, "", expected.SignerIdentity, "") + } + if err != nil { + return nil, fmt.Errorf("building certificate identity policy: %w", err) + } + return verify.WithCertificateIdentity(certID), nil +} diff --git a/container/verifier/bundles_test.go b/container/verifier/bundles_test.go new file mode 100644 index 0000000..e7f3e87 --- /dev/null +++ b/container/verifier/bundles_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/sign" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// signTestBundle signs payload with a fresh ephemeral key and returns the +// serialized bundle, the signer's public key PEM, and the payload digest. +// testWorkflowIdentity mirrors the workflow-path fixture used by the +// package's integration tests. +const testWorkflowIdentity = "/.github/workflows/release.yml" + +func signTestBundle(t *testing.T, payload []byte) (raw []byte, pubPEM string, digestHex string) { + t.Helper() + + keypair, err := sign.NewEphemeralKeypair(nil) + require.NoError(t, err) + + pb, err := sign.Bundle(&sign.PlainData{Data: payload}, keypair, sign.BundleOptions{}) + require.NoError(t, err) + + parsed, err := bundle.NewBundle(pb) + require.NoError(t, err) + rawBytes, err := json.Marshal(parsed) + require.NoError(t, err) + + pem, err := keypair.GetPublicKeyPem() + require.NoError(t, err) + + digest := sha256.Sum256(payload) + return rawBytes, pem, hex.EncodeToString(digest[:]) +} + +// TestKeySignedBundleRoundTrip is the contract downstream signing flows rely +// on: a bundle produced by sigstore-go's signing path with a plain key pair +// verifies through this package's exported API using PublicKeyMaterial. +func TestKeySignedBundleRoundTrip(t *testing.T) { + t.Parallel() + + payload := []byte("skill artifact digest payload") + raw, pubPEM, digestHex := signTestBundle(t, payload) + + parsed := &bundle.Bundle{} + require.NoError(t, parsed.UnmarshalJSON(raw)) + + result, err := VerifyBundleWithKey(Bundle{ + Parsed: parsed, + Raw: raw, + DigestAlgo: DigestAlgorithmSHA256, + DigestHex: digestHex, + }, []byte(pubPEM)) + require.NoError(t, err) + require.NotNil(t, result) +} + +func TestKeySignedBundleRejectsWrongDigest(t *testing.T) { + t.Parallel() + + raw, pubPEM, _ := signTestBundle(t, []byte("original content")) + otherDigest := sha256.Sum256([]byte("tampered content")) + + parsed := &bundle.Bundle{} + require.NoError(t, parsed.UnmarshalJSON(raw)) + + _, err := VerifyBundleWithKey(Bundle{ + Parsed: parsed, + DigestAlgo: DigestAlgorithmSHA256, + DigestHex: hex.EncodeToString(otherDigest[:]), + }, []byte(pubPEM)) + require.Error(t, err, "a bundle must not verify against a digest it did not sign") +} + +func TestKeySignedBundleRejectsWrongKey(t *testing.T) { + t.Parallel() + + raw, _, digestHex := signTestBundle(t, []byte("content")) + // A different signer's public key must not verify this bundle. + _, otherPubPEM, _ := signTestBundle(t, []byte("unrelated")) + + parsed := &bundle.Bundle{} + require.NoError(t, parsed.UnmarshalJSON(raw)) + + _, err := VerifyBundleWithKey(Bundle{ + Parsed: parsed, + DigestAlgo: DigestAlgorithmSHA256, + DigestHex: digestHex, + }, []byte(otherPubPEM)) + assert.ErrorIs(t, err, ErrVerificationFailed) + require.Error(t, err) +} + +func TestOfflineTrustedMaterial(t *testing.T) { + t.Parallel() + + tm, err := OfflineTrustedMaterial() + require.NoError(t, err, "the embedded trusted root must parse") + require.NotNil(t, tm) + assert.NotEmpty(t, tm.FulcioCertificateAuthorities(), + "the public-good trusted root carries Fulcio CAs") + assert.NotEmpty(t, tm.RekorLogs(), + "the public-good trusted root carries Rekor transparency logs") +} + +func TestVerifyBundleOfflineParsesStoredBundles(t *testing.T) { + t.Parallel() + + // A stored key-signed bundle fails against the Fulcio trusted root (no + // certificate), but must fail at verification — not at parsing — which + // proves the offline path round-trips the stored Raw form. + raw, _, digestHex := signTestBundle(t, []byte("content")) + _, err := VerifyBundleOffline(raw, DigestAlgorithmSHA256+":"+digestHex, nil) + require.Error(t, err) + assert.NotContains(t, err.Error(), "parsing stored bundle", + "a well-formed stored bundle must reach verification") + assert.ErrorIs(t, err, ErrVerificationFailed, + "a verification failure must be branchable via the sentinel") + + _, err = VerifyBundleOffline([]byte("not json"), DigestAlgorithmSHA256+":"+digestHex, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing stored bundle") + assert.NotErrorIs(t, err, ErrVerificationFailed, + "malformed input is not a verification failure") + + _, err = VerifyBundleOffline(raw, digestHex, nil) + require.Error(t, err, "a digest without an : prefix must be rejected") +} + +func TestIdentityPolicyOption(t *testing.T) { + t.Parallel() + + t.Run("nil expected yields a TOFU policy", func(t *testing.T) { + t.Parallel() + opt, err := identityPolicyOption(nil) + require.NoError(t, err) + require.NotNil(t, opt) + }) + + t.Run("plain identity binds SAN exactly", func(t *testing.T) { + t.Parallel() + opt, err := identityPolicyOption(&Identity{ + SignerIdentity: "dev@example.com", + CertIssuer: "https://accounts.example.com", + }) + require.NoError(t, err) + require.NotNil(t, opt) + }) + + t.Run("github actions identity binds SAN by repo-anchored prefix", func(t *testing.T) { + t.Parallel() + opt, err := identityPolicyOption(&Identity{ + SignerIdentity: testWorkflowIdentity, + CertIssuer: githubTokenIssuer, + SourceRepositoryURI: "https://github.com/org/repo", + }) + require.NoError(t, err) + require.NotNil(t, opt) + }) + + t.Run("empty identity is rejected", func(t *testing.T) { + t.Parallel() + _, err := identityPolicyOption(&Identity{}) + require.Error(t, err, "an identity with neither SAN nor issuer cannot form a policy") + }) +} + +// TestKeySignedBundleIdentityPolicyRejects proves the expected-identity +// binding is enforced inside the Sigstore policy: a key-signed bundle has no +// certificate, so any expected identity must fail verification rather than +// silently passing. +func TestKeySignedBundleIdentityPolicyRejects(t *testing.T) { + t.Parallel() + + raw, pubPEM, digestHex := signTestBundle(t, []byte("content")) + tm, err := PublicKeyMaterial([]byte(pubPEM)) + require.NoError(t, err) + + parsed := &bundle.Bundle{} + require.NoError(t, parsed.UnmarshalJSON(raw)) + + _, err = VerifyBundle(Bundle{ + Parsed: parsed, + DigestAlgo: DigestAlgorithmSHA256, + DigestHex: digestHex, + }, tm, &Identity{ + SignerIdentity: "dev@example.com", + CertIssuer: "https://accounts.example.com", + }, verify.WithNoObserverTimestamps()) + require.Error(t, err, "an expected identity must not verify against a certificate-less bundle") +} + +// TestVerifyBundleRequiresExplicitOptions guards the opts/material contract: +// passing trusted material without matching verifier options must fail +// loudly instead of silently applying public-good defaults to the wrong +// root. +func TestVerifyBundleRequiresExplicitOptions(t *testing.T) { + t.Parallel() + + raw, pubPEM, digestHex := signTestBundle(t, []byte("content")) + tm, err := PublicKeyMaterial([]byte(pubPEM)) + require.NoError(t, err) + + parsed := &bundle.Bundle{} + require.NoError(t, parsed.UnmarshalJSON(raw)) + + _, err = VerifyBundle(Bundle{ + Parsed: parsed, + DigestAlgo: DigestAlgorithmSHA256, + DigestHex: digestHex, + }, tm, nil) + require.ErrorContains(t, err, "verifier options are required") +} + +func TestDefaultVerifierOptions(t *testing.T) { + t.Parallel() + opts, err := DefaultVerifierOptions() + require.NoError(t, err) + assert.NotEmpty(t, opts) +} + +func TestRetrieveBundlesUnreachableRegistry(t *testing.T) { + t.Parallel() + _, err := RetrieveBundles(t.Context(), "invalid.invalid/org/artifact:v1", nil) + require.Error(t, err) +} diff --git a/container/verifier/doc.go b/container/verifier/doc.go new file mode 100644 index 0000000..32ee8f6 --- /dev/null +++ b/container/verifier/doc.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package verifier verifies Sigstore signatures and attestations on OCI +// artifacts. +// +// Two entry points cover the two trust flows: +// +// - [New] builds a [Sigstore] verifier with a live TUF-refreshed trust +// root for verifying MCP server images against registry-declared +// provenance ([Sigstore.VerifyServer]). +// - [RetrieveBundles], [VerifyBundle], [VerifyBundleWithKey], and +// [VerifyBundleOffline] expose the bundle-level building blocks for +// consumers that manage their own trust decisions — retrieving the +// bundles attached to an artifact, verifying them against keyless +// (Fulcio) or key-pair material, binding an expected [Identity] into +// the verification policy, and re-verifying stored bundles offline +// against the embedded trust root. +// +// # Verifying a retrieved bundle +// +// bundles, err := verifier.RetrieveBundles(ctx, imageRef, keychain) +// // handle err; errors.Is(err, verifier.ErrNoBundles) means unsigned +// tm, _ := verifier.OfflineTrustedMaterial() +// opts, _ := verifier.DefaultVerifierOptions() +// result, err := verifier.VerifyBundle(bundles[0], tm, nil, opts...) +// // errors.Is(err, verifier.ErrVerificationFailed) means signed but invalid +// identity, _ := verifier.IdentityFromResult(result) +// // store bundles[0].Raw and identity; later: +// _, err = verifier.VerifyBundleOffline(storedRaw, "sha256:"+digestHex, &identity) +// +// # Stability +// +// This package is Alpha stability. The API may change without notice. +package verifier diff --git a/container/verifier/sigstore.go b/container/verifier/sigstore.go index 0c97546..2d13876 100644 --- a/container/verifier/sigstore.go +++ b/container/verifier/sigstore.go @@ -6,6 +6,7 @@ package verifier import ( "bytes" + "context" "encoding/base64" "encoding/hex" "encoding/json" @@ -33,15 +34,15 @@ type sigstoreBundle struct { } // bundleFromSigstoreSignedImage returns a bundle from a Sigstore signed image -func bundleFromSigstoreSignedImage(imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) { +func bundleFromSigstoreSignedImage(ctx context.Context, imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) { // Get the signature manifest from the OCI image reference - signatureRef, err := getSignatureReferenceFromOCIImage(imageRef, keychain) + signatureRef, err := getSignatureReferenceFromOCIImage(ctx, imageRef, keychain) if err != nil { return nil, fmt.Errorf("error getting signature reference from OCI image: %w", err) } // Parse the manifest and return a list of all simple signing layers we managed to extract - simpleSigningLayers, err := getSimpleSigningLayersFromSignatureManifest(signatureRef, keychain) + simpleSigningLayers, err := getSimpleSigningLayersFromSignatureManifest(ctx, signatureRef, keychain) if err != nil { return nil, fmt.Errorf("%w: %s", ErrProvenanceNotFoundOrIncomplete, err.Error()) } @@ -101,9 +102,9 @@ func bundleFromSigstoreSignedImage(imageRef string, keychain authn.Keychain) ([] } // getSignatureReferenceFromOCIImage returns the simple signing layer from the OCI image reference -func getSignatureReferenceFromOCIImage(imageRef string, keychain authn.Keychain) (string, error) { +func getSignatureReferenceFromOCIImage(ctx context.Context, imageRef string, keychain authn.Keychain) (string, error) { // 0. Get the auth options - opts := []remote.Option{remote.WithAuthFromKeychain(keychain)} + opts := []remote.Option{remote.WithAuthFromKeychain(keychain), remote.WithContext(ctx)} // 1. Get the image reference ref, err := name.ParseReference(imageRef) @@ -132,8 +133,10 @@ func getSignatureReferenceFromOCIImage(imageRef string, keychain authn.Keychain) } // getSimpleSigningLayersFromSignatureManifest returns the identity and issuer from the certificate -func getSimpleSigningLayersFromSignatureManifest(manifestRef string, keychain authn.Keychain) ([]v1.Descriptor, error) { - craneOpts := []crane.Option{crane.WithAuthFromKeychain(keychain)} +func getSimpleSigningLayersFromSignatureManifest( + ctx context.Context, manifestRef string, keychain authn.Keychain, +) ([]v1.Descriptor, error) { + craneOpts := []crane.Option{crane.WithAuthFromKeychain(keychain), crane.WithContext(ctx)} // Get the manifest of the signature mf, err := crane.Manifest(manifestRef, craneOpts...) if err != nil { @@ -214,13 +217,20 @@ func getVerificationMaterialTlogEntries(manifestLayer v1.Descriptor) ( if err != nil { return nil, fmt.Errorf("error unmarshaling json: %w", err) } - // 2. Get the log index, log ID, integrated time, signed entry timestamp and body - logIndex, ok := jsonData["Payload"].(map[string]interface{})["logIndex"].(float64) + // 2. Get the log index, log ID, integrated time, signed entry timestamp and body. + // Every assertion is two-valued: the annotation is registry-supplied + // (attacker-controlled) data, and a malformed shape must be an error, + // never a panic. + payload, ok := jsonData["Payload"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("error getting Payload") + } + logIndex, ok := payload["logIndex"].(float64) if !ok { return nil, fmt.Errorf("error getting logIndex") } logIndexInt64 := int64(logIndex) - li, ok := jsonData["Payload"].(map[string]interface{})["logID"].(string) + li, ok := payload["logID"].(string) if !ok { return nil, fmt.Errorf("error getting logID") } @@ -228,7 +238,7 @@ func getVerificationMaterialTlogEntries(manifestLayer v1.Descriptor) ( if err != nil { return nil, fmt.Errorf("error decoding logID: %w", err) } - integratedTime, ok := jsonData["Payload"].(map[string]interface{})["integratedTime"].(float64) + integratedTime, ok := payload["integratedTime"].(float64) if !ok { return nil, fmt.Errorf("error getting integratedTime") } @@ -241,7 +251,7 @@ func getVerificationMaterialTlogEntries(manifestLayer v1.Descriptor) ( return nil, fmt.Errorf("error decoding signedEntryTimestamp: %w", err) } // 3. Unmarshal the body and extract the rekor KindVersion details - body, ok := jsonData["Payload"].(map[string]interface{})["body"].(string) + body, ok := payload["body"].(string) if !ok { return nil, fmt.Errorf("error getting body") } @@ -253,8 +263,14 @@ func getVerificationMaterialTlogEntries(manifestLayer v1.Descriptor) ( if err != nil { return nil, fmt.Errorf("error unmarshaling json: %w", err) } - apiVersion := jsonData["apiVersion"].(string) - kind := jsonData["kind"].(string) + apiVersion, ok := jsonData["apiVersion"].(string) + if !ok { + return nil, fmt.Errorf("error getting apiVersion") + } + kind, ok := jsonData["kind"].(string) + if !ok { + return nil, fmt.Errorf("error getting kind") + } // 4. Construct the transparency log entry list return []*protorekor.TransparencyLogEntry{ { @@ -281,7 +297,7 @@ func getBundleMsgSignature(simpleSigningLayer v1.Descriptor) (*protobundle.Bundl // 1. Get the message digest algorithm var msgHashAlg protocommon.HashAlgorithm switch simpleSigningLayer.Digest.Algorithm { - case "sha256": + case DigestAlgorithmSHA256: msgHashAlg = protocommon.HashAlgorithm_SHA2_256 default: return nil, fmt.Errorf("unknown digest algorithm: %s", simpleSigningLayer.Digest.Algorithm) diff --git a/container/verifier/sigstore_test.go b/container/verifier/sigstore_test.go new file mode 100644 index 0000000..289a424 --- /dev/null +++ b/container/verifier/sigstore_test.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "encoding/base64" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/stretchr/testify/require" +) + +// TestGetVerificationMaterialTlogEntriesMalformedAnnotations feeds the +// registry-supplied cosign bundle annotation with every malformed shape that +// previously hit a single-value type assertion. Each case must return an +// error — never panic — since the annotation is attacker-controlled. +func TestGetVerificationMaterialTlogEntriesMalformedAnnotations(t *testing.T) { + t.Parallel() + + validBody := base64.StdEncoding.EncodeToString([]byte(`{"apiVersion":"0.0.1","kind":"hashedrekord"}`)) + + tests := []struct { + name string + annotation string + }{ + {name: "not json", annotation: `not json at all`}, + {name: "payload missing", annotation: `{"SignedEntryTimestamp":"c2ln"}`}, + {name: "payload not an object", annotation: `{"Payload":"scalar"}`}, + {name: "logIndex wrong type", annotation: `{"Payload":{"logIndex":"nope"}}`}, + {name: "logID wrong type", annotation: `{"Payload":{"logIndex":1,"logID":42}}`}, + { + name: "integratedTime wrong type", + annotation: `{"Payload":{"logIndex":1,"logID":"abcd","integratedTime":"nope"}}`, + }, + { + name: "SignedEntryTimestamp missing", + annotation: `{"Payload":{"logIndex":1,"logID":"abcd","integratedTime":1}}`, + }, + { + name: "body wrong type", + annotation: `{"Payload":{"logIndex":1,"logID":"abcd","integratedTime":1,"body":7},` + + `"SignedEntryTimestamp":"c2ln"}`, + }, + { + name: "body decodes but apiVersion wrong type", + annotation: `{"Payload":{"logIndex":1,"logID":"abcd","integratedTime":1,` + + `"body":"` + base64.StdEncoding.EncodeToString([]byte(`{"apiVersion":1,"kind":"x"}`)) + `"},` + + `"SignedEntryTimestamp":"c2ln"}`, + }, + { + name: "body decodes but kind missing", + annotation: `{"Payload":{"logIndex":1,"logID":"abcd","integratedTime":1,` + + `"body":"` + base64.StdEncoding.EncodeToString([]byte(`{"apiVersion":"0.0.1"}`)) + `"},` + + `"SignedEntryTimestamp":"c2ln"}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + layer := v1.Descriptor{ + Annotations: map[string]string{ + "dev.sigstore.cosign/bundle": tc.annotation, + }, + } + require.NotPanics(t, func() { + _, err := getVerificationMaterialTlogEntries(layer) + require.Error(t, err) + }) + }) + } + + t.Run("well-formed annotation parses", func(t *testing.T) { + t.Parallel() + layer := v1.Descriptor{ + Annotations: map[string]string{ + "dev.sigstore.cosign/bundle": `{"Payload":{"logIndex":1,"logID":"abcd",` + + `"integratedTime":1,"body":"` + validBody + `"},"SignedEntryTimestamp":"c2ln"}`, + }, + } + entries, err := getVerificationMaterialTlogEntries(layer) + require.NoError(t, err) + require.Len(t, entries, 1) + }) +} diff --git a/container/verifier/tufroots/README.md b/container/verifier/tufroots/README.md new file mode 100644 index 0000000..8927f93 --- /dev/null +++ b/container/verifier/tufroots/README.md @@ -0,0 +1,36 @@ +# Embedded TUF snapshots + +This directory embeds point-in-time snapshots of Sigstore trust material, +keyed by TUF repository host. + +## Contents + +- `/root.json` — the TUF *bootstrap* root used to start a live TUF + refresh (consumed by `New` / the online verifier). +- `tuf-repo-cdn.sigstore.dev/trusted_root.json` — the `trusted_root.json` + *target* from the Sigstore public-good TUF repository, used by + `OfflineTrustedMaterial` for network-free bundle verification. sigstore-go + embeds only the bootstrap root, not this target, so vendoring it is the + canonical offline pattern (see sigstore-go's own verification example). + +## Provenance of `trusted_root.json` + +Fetched 2026-07-24 from the `tuf-repo-cdn.sigstore.dev` TUF repository via +`sigstore-go`'s TUF client (`tuf.New(tuf.DefaultOptions())` + +`client.GetTarget("trusted_root.json")`), which verifies the target against +the TUF metadata chain before returning it. Do not edit by hand; refresh +with the same client and commit the new snapshot. + +## Staleness trade-off + +The snapshot cannot see key rotations that happen after the fetch date, in +either direction: + +- signatures made with newly rotated-in keys fail offline verification + until the snapshot is refreshed and consumers pick up the release; +- a key rotated out **because of compromise** keeps being trusted by + offline verification until the same release + bump cycle completes. + +Consumers that need timely revocation must use the online verifier (`New`), +which refreshes over TUF on construction. Refresh this snapshot on a +regular cadence and whenever Sigstore announces a rotation. diff --git a/container/verifier/tufroots/tuf-repo-cdn.sigstore.dev/trusted_root.json b/container/verifier/tufroots/tuf-repo-cdn.sigstore.dev/trusted_root.json new file mode 100644 index 0000000..effb0a1 --- /dev/null +++ b/container/verifier/tufroots/tuf-repo-cdn.sigstore.dev/trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/container/verifier/utils.go b/container/verifier/utils.go index e95b8fd..5331ca5 100644 --- a/container/verifier/utils.go +++ b/container/verifier/utils.go @@ -4,6 +4,7 @@ package verifier import ( + "context" "embed" "errors" "fmt" @@ -126,15 +127,16 @@ func embeddedRootJson(tufRootURL string) ([]byte, error) { // getSigstoreBundles returns the sigstore bundles, either through the OCI registry or the GitHub attestation endpoint func getSigstoreBundles( + ctx context.Context, imageRef string, keychain authn.Keychain, ) ([]sigstoreBundle, error) { // Try to build a bundle from a Sigstore signed image - bundles, err := bundleFromSigstoreSignedImage(imageRef, keychain) + bundles, err := bundleFromSigstoreSignedImage(ctx, imageRef, keychain) if errors.Is(err, ErrProvenanceNotFoundOrIncomplete) { // If we get this error, it means that the image is not signed // or the signature is incomplete. Let's try to see if we can find attestation for the image. - return bundleFromAttestation(imageRef, keychain) + return bundleFromAttestation(ctx, imageRef, keychain) } else if err != nil { return nil, err } diff --git a/container/verifier/verifier.go b/container/verifier/verifier.go index 79d8bb8..b057060 100644 --- a/container/verifier/verifier.go +++ b/container/verifier/verifier.go @@ -4,6 +4,7 @@ package verifier import ( + "context" "errors" "fmt" "log/slog" @@ -88,8 +89,10 @@ func (s *Sigstore) WithKeychain(keychain authn.Keychain) *Sigstore { func (s *Sigstore) GetVerificationResults( imageRef string, ) ([]*verify.VerificationResult, error) { - // Construct the bundle(s) for the image reference - bundles, err := getSigstoreBundles(imageRef, s.keychain) + // Construct the bundle(s) for the image reference. The exported + // signature predates context plumbing, so the fetch is not cancellable + // from here; RetrieveBundles is the context-aware entry point. + bundles, err := getSigstoreBundles(context.Background(), imageRef, s.keychain) if err != nil && !errors.Is(err, ErrProvenanceNotFoundOrIncomplete) { // We got some other unexpected error prior to querying for the signature/attestation return nil, err diff --git a/go.mod b/go.mod index e5e9fc7..e43d53f 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,8 @@ require ( oras.land/oras-go/v2 v2.6.2 ) +require github.com/sigstore/sigstore v1.10.8 + require ( cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -49,12 +51,14 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.20.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/docker/cli v29.6.0+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.25.2 // indirect @@ -83,24 +87,32 @@ require ( github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/rekor v1.5.3 // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect - github.com/sigstore/sigstore v1.10.8 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect diff --git a/go.sum b/go.sum index 95349b3..04cc347 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,7 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= @@ -106,6 +107,8 @@ github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+ github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -167,6 +170,8 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -185,6 +190,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -205,6 +212,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= @@ -253,6 +262,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -281,6 +294,7 @@ github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAt github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= @@ -321,6 +335,7 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -356,6 +371,16 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= @@ -408,6 +433,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=