From a2964f7c6db45aaa18cb9a6189ad15c8c21a7093 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 12:30:07 +0900 Subject: [PATCH 1/2] Add attestation verifier package for Sigstore bundle verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces cli/dispatcher/internal/attestation/, a self-contained package that verifies GitHub Artifact Attestation bundles emitted by the release workflows. The verifier is unused at this commit — Phase B2 and B3 will wire it into dispatcher self-update (update.go) and project-runner download (dispatcher_download.go) respectively — so the dispatcher binary still behaves exactly as before. Landing the package alone keeps the review surface small before the runtime call sites change. Trust anchor is the embedded Sigstore public-good trusted_root.json, fetched once via the new cli/dispatcher/cmd/refresh-attestation-trusted-root helper (which drives sigstore-go's TUF client). Embedding keeps verification offline and deterministic; a companion Go test in trusted_root_test.go fails the build 90 days before any embedded authority expires so a missed quarterly refresh surfaces as a red CI rather than a fleet-wide self-update outage after Rekor or Fulcio rotates. Refresh commits must land as fix(dispatcher) so release-please cuts a new dispatcher release that actually ships the refreshed root. Verification policy binds each attestation to (a) the local asset's SHA-256 digest appearing among the bundle's subjects, (b) a SAN allowlist of exact workflow@ref identities for hatayama/unity-cli-loop, and (c) the Fulcio Source Repository Digest extension (OID 1.3.6.1.4.1.57264.1.13) matching the release tag's commit SHA. The commit SHA binding prevents a leaked OIDC token from being reused on a different tag. The SAN is a closed allowlist (v3-beta and main branches for either dispatcher-publish.yml or native-cli-publish.yml at the call site) rather than a regex so an unrelated ref cannot forge releases. Fetcher helpers fail closed on every non-2xx status (including 403 and 429) so an attacker cannot strip verification by DoS'ing the attestation endpoint. GITHUB_TOKEN/GH_TOKEN authorization is passed through when present to lift rate limits in CI. Tests cover the five required patterns (happy path, digest mismatch, identity mismatch by SAN, identity mismatch by commit SHA, malformed bundle) plus fetcher fail-closed behavior on 404/403/429/empty-body and tag lookup for both lightweight and annotated tag objects. The happy path fixture is the real backfilled dispatcher-v3.0.1-beta.12 arm64 bundle so tests exercise a bundle with the same media type production callers will hand the verifier. The release-automation architecture guard now allows internal/attestation alongside the existing dispatcher, install, nativepath, uninstall, update packages. --- .../refresh-attestation-trusted-root/main.go | 49 +++ cli/dispatcher/go.mod | 19 +- cli/dispatcher/go.sum | 44 ++- cli/dispatcher/internal/attestation/errors.go | 25 ++ .../internal/attestation/fetcher.go | 150 +++++++++ .../testdata/happy_asset_digest.txt | 1 + .../attestation/testdata/happy_bundle.json | 67 ++++ .../attestation/testdata/happy_commit_sha.txt | 1 + .../internal/attestation/trusted_root.go | 35 ++ .../internal/attestation/trusted_root.json | 126 ++++++++ .../internal/attestation/trusted_root_test.go | 125 ++++++++ .../internal/attestation/verifier.go | 174 ++++++++++ .../internal/attestation/verifier_test.go | 302 ++++++++++++++++++ cli/go.work.sum | 110 +++++++ .../architecture/architecture_test.go | 2 +- 15 files changed, 1225 insertions(+), 5 deletions(-) create mode 100644 cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go create mode 100644 cli/dispatcher/internal/attestation/errors.go create mode 100644 cli/dispatcher/internal/attestation/fetcher.go create mode 100644 cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt create mode 100644 cli/dispatcher/internal/attestation/testdata/happy_bundle.json create mode 100644 cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt create mode 100644 cli/dispatcher/internal/attestation/trusted_root.go create mode 100644 cli/dispatcher/internal/attestation/trusted_root.json create mode 100644 cli/dispatcher/internal/attestation/trusted_root_test.go create mode 100644 cli/dispatcher/internal/attestation/verifier.go create mode 100644 cli/dispatcher/internal/attestation/verifier_test.go diff --git a/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go b/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go new file mode 100644 index 0000000000..941475e16b --- /dev/null +++ b/cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go @@ -0,0 +1,49 @@ +// Refreshes the Sigstore trusted_root.json embedded by cli/dispatcher/internal/attestation. +// +// Run this from the dispatcher module root to fetch the current Sigstore +// public-good trusted_root.json via TUF and write it as +// internal/attestation/trusted_root.json. The dispatcher then embeds that +// file so verification stays offline at runtime. Refresh cadence is +// quarterly; the attestation package has a CI guard test that fails when +// any embedded authority's validity window is within 90 days of expiring, +// so a missed refresh surfaces as a red build rather than a fleet outage. +// +// Runs from module root: +// +// go run ./cmd/refresh-attestation-trusted-root +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sigstore/sigstore-go/pkg/tuf" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "refresh-attestation-trusted-root:", err) + os.Exit(1) + } +} + +func run() error { + client, err := tuf.DefaultClient() + if err != nil { + return fmt.Errorf("create TUF client: %w", err) + } + if err := client.Refresh(); err != nil { + return fmt.Errorf("refresh TUF metadata: %w", err) + } + target, err := client.GetTarget("trusted_root.json") + if err != nil { + return fmt.Errorf("get trusted_root.json target: %w", err) + } + outputPath := filepath.Join("internal", "attestation", "trusted_root.json") + if err := os.WriteFile(outputPath, target, 0o644); err != nil { + return fmt.Errorf("write %s: %w", outputPath, err) + } + fmt.Printf("wrote %d bytes to %s\n", len(target), outputPath) + return nil +} diff --git a/cli/dispatcher/go.mod b/cli/dispatcher/go.mod index 8c4740f600..8d4b2bb5f0 100644 --- a/cli/dispatcher/go.mod +++ b/cli/dispatcher/go.mod @@ -2,11 +2,26 @@ module github.com/hatayama/unity-cli-loop/dispatcher go 1.26 -require github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000 +require ( + github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000 + github.com/sigstore/sigstore-go v1.2.2 +) require ( github.com/Microsoft/go-winio v0.6.2 // indirect - golang.org/x/sys v0.10.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.11 // indirect ) replace github.com/hatayama/unity-cli-loop/common => ../common diff --git a/cli/dispatcher/go.sum b/cli/dispatcher/go.sum index a001052ff3..36891e830d 100644 --- a/cli/dispatcher/go.sum +++ b/cli/dispatcher/go.sum @@ -1,4 +1,44 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0= +github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +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= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/dispatcher/internal/attestation/errors.go b/cli/dispatcher/internal/attestation/errors.go new file mode 100644 index 0000000000..401c225c99 --- /dev/null +++ b/cli/dispatcher/internal/attestation/errors.go @@ -0,0 +1,25 @@ +package attestation + +import "errors" + +// Sentinel errors let callers distinguish network trouble from a real signature +// or identity mismatch so update.go and dispatcher_download.go can render +// distinct messages ("update to a newer CLI" vs "release may be compromised"). +var ( + // ErrBundleFetch reports any failure retrieving .sigstore.json + // (network error, 4xx/5xx, empty body). Fail-closed at the call site. + ErrBundleFetch = errors.New("attestation bundle fetch failed") + + // ErrTagRefFetch reports failure resolving a release tag to its commit + // SHA via the GitHub git-refs API. Fail-closed at the call site. + ErrTagRefFetch = errors.New("release tag commit SHA lookup failed") + + // ErrMalformedBundle reports a bundle that could not be parsed as a + // Sigstore protobuf JSON. + ErrMalformedBundle = errors.New("attestation bundle is malformed") + + // ErrVerificationFailed reports Sigstore signature or policy failure — + // digest mismatch, identity mismatch, tlog absent, timestamp missing. + // This is the signal that a release asset may have been tampered with. + ErrVerificationFailed = errors.New("attestation verification failed") +) diff --git a/cli/dispatcher/internal/attestation/fetcher.go b/cli/dispatcher/internal/attestation/fetcher.go new file mode 100644 index 0000000000..4182e62547 --- /dev/null +++ b/cli/dispatcher/internal/attestation/fetcher.go @@ -0,0 +1,150 @@ +package attestation + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "time" +) + +// DefaultHTTPClient is the http.Client used for bundle and tag-ref fetches. +// It is a var so tests can substitute a client wired to httptest servers. +var DefaultHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// githubAPIBaseURL is mutable so tests can point it at an httptest server; +// production callers never touch it directly. +var githubAPIBaseURL = "https://api.github.com" + +func githubAPIBase() string { return githubAPIBaseURL } +func setGithubAPIBase(url string) { githubAPIBaseURL = url } + +const ( + githubReleaseBaseURL = "https://github.com/%s/releases/download/%s/%s" + envAuthTokenPrimary = "GITHUB_TOKEN" + envAuthTokenSecondary = "GH_TOKEN" + acceptHeaderGitHubJSON = "application/vnd.github+json" + apiVersionHeaderValue = "2022-11-28" +) + +// BundleAssetURL builds the release download URL for .sigstore.json. +// Callers pass the base asset name (without the .sigstore.json suffix). +func BundleAssetURL(repo, tag, assetName string) string { + return fmt.Sprintf(githubReleaseBaseURL, repo, tag, url.PathEscape(assetName+".sigstore.json")) +} + +// FetchBundle downloads the .sigstore.json body from the release. It +// wraps every failure in ErrBundleFetch so the caller fails closed. 403/429 +// are treated the same way as any other non-2xx: verification is not attempted +// because we cannot distinguish rate-limit denial from an attacker denying the +// bundle to skip verification. +func FetchBundle(ctx context.Context, bundleURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil) + if err != nil { + return nil, fmt.Errorf("%w: build request: %v", ErrBundleFetch, err) + } + setAuthorizationIfAvailable(req) + resp, err := DefaultHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrBundleFetch, err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: status %s from %s", ErrBundleFetch, resp.Status, bundleURL) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err) + } + if len(body) == 0 { + return nil, fmt.Errorf("%w: empty body from %s", ErrBundleFetch, bundleURL) + } + return body, nil +} + +// TagRefResponse mirrors the git ref API payload shape for the fields we +// consume. +type TagRefResponse struct { + Object struct { + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"object"` +} + +// FetchTagCommitSHA resolves a release tag to the git commit SHA it points at +// via the GitHub git-refs REST API. The verifier binds this SHA to the cert's +// Source Repository Digest extension so a stolen OIDC token cannot be reused +// on a tag it did not produce. We follow one level of "tag" indirection so +// annotated tags resolve to the same commit SHA as lightweight ones. +func FetchTagCommitSHA(ctx context.Context, repo, tag string) (string, error) { + initialURL := fmt.Sprintf("%s/repos/%s/git/ref/tags/%s", githubAPIBase(), repo, url.PathEscape(tag)) + sha, objectType, err := fetchGitRef(ctx, initialURL) + if err != nil { + return "", err + } + if objectType == "tag" { + tagURL := fmt.Sprintf("%s/repos/%s/git/tags/%s", githubAPIBase(), repo, url.PathEscape(sha)) + sha, objectType, err = fetchGitRef(ctx, tagURL) + if err != nil { + return "", err + } + } + if objectType != "commit" { + return "", fmt.Errorf("%w: unexpected object type %q for tag %s", ErrTagRefFetch, objectType, tag) + } + if !isHexCommitSHA(sha) { + return "", fmt.Errorf("%w: bad commit SHA %q for tag %s", ErrTagRefFetch, sha, tag) + } + return sha, nil +} + +func fetchGitRef(ctx context.Context, apiURL string) (string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", fmt.Errorf("%w: build request: %v", ErrTagRefFetch, err) + } + req.Header.Set("Accept", acceptHeaderGitHubJSON) + req.Header.Set("X-GitHub-Api-Version", apiVersionHeaderValue) + setAuthorizationIfAvailable(req) + resp, err := DefaultHTTPClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("%w: %v", ErrTagRefFetch, err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("%w: status %s from %s", ErrTagRefFetch, resp.Status, apiURL) + } + var payload TagRefResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", fmt.Errorf("%w: decode payload: %v", ErrTagRefFetch, err) + } + return payload.Object.SHA, payload.Object.Type, nil +} + +func setAuthorizationIfAvailable(req *http.Request) { + token := os.Getenv(envAuthTokenPrimary) + if token == "" { + token = os.Getenv(envAuthTokenSecondary) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +// AssetNameFromReleaseAsset strips any query fragments and returns the base +// asset name, so callers can pass a full URL and receive the file name. +func AssetNameFromReleaseAsset(assetURL string) string { + parsed, err := url.Parse(assetURL) + if err != nil { + return path.Base(assetURL) + } + return path.Base(parsed.Path) +} diff --git a/cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt b/cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt new file mode 100644 index 0000000000..ae0300b045 --- /dev/null +++ b/cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt @@ -0,0 +1 @@ +14c15a7c132f5a3b8eb11b7e3115fff35024793368da79cb90b325a343780a28 diff --git a/cli/dispatcher/internal/attestation/testdata/happy_bundle.json b/cli/dispatcher/internal/attestation/testdata/happy_bundle.json new file mode 100644 index 0000000000..310fb1f355 --- /dev/null +++ b/cli/dispatcher/internal/attestation/testdata/happy_bundle.json @@ -0,0 +1,67 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "tlogEntries": [ + { + "logIndex": "2064602974", + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1783123835", + "inclusionPromise": { + "signedEntryTimestamp": "MEYCIQDRQDI45IYTRoRdByBKI0yKp4Uz9NZelQEwvgJOs5K6BQIhAKUWl+o6eGILscaTlZZeimvRuOgx9MXBBSME+lZOiCrU" + }, + "inclusionProof": { + "logIndex": "1942698712", + "rootHash": "h/XYzy2m7EiyTjkEDcS8SvEJ6oAQXHWolFLhJzWYbMs=", + "treeSize": "1942698721", + "hashes": [ + "YuwdppnHHWCikgOnvkHhvrBbukuAXhXi4tPY0WOHbE8=", + "2hGAPlNsN+p/ZJVe3cM8TP+2U7F1ktOUzrrwuwvOk8Y=", + "dh3dtQP83tJywAZ59ycfA8190Xbls5ERHMvVCSPTz1k=", + "At+eCC+vaFKu2t2auwgjlP4smgRL2xkbyBE82HCDX4c=", + "H62tn3rDgucbbnC9/iTH9PHWbDNRa//Hgc6yGQCirMA=", + "ADRn5rTXSywloM0P1Hiv0UeUCRemw5EbenvPVxQHscU=", + "zuF9+aXuV8aWonTXK5QPcRyZHqI1pIV+qhHBkyw0dU4=", + "TXdrMfgKScfQz/9CkggbpNDHAWOmoxGrI4dswSHZ418=", + "TyUcqqZPPBFyDR4MxyyRz9daRRO34R7MtWX9gchYytg=", + "s1+/PhQV+6dILrjlQf8LsLPqQxc80WpKHadeJbkvlzY=", + "+y2IQ/CDKskkt3+PA+VUA/AtJN3A+yQUPP1FYzMk/Ls=", + "Rbp611iTkVhSjGTb1x6vEcbGBGUkPM7Ji6y//fGjPno=", + "I1oTnEORJwtsgPyrNUPaRrV9oPrJfWgOLOtITOBbMo0=", + "0NfyYCi9u2Iw7F7fVF1Bqxuuy0S0EewQjD3ESErETBg=", + "QK735m1ZlL+ztxDvYY2YCdj5JyEVLgBQWTaCGgdE4zw=", + "7avEtv08HZnHXt8ix9lCSATmFs0f8gaaHsayxqM3WwI=", + "RIZjwQJv61lwM5JOP8iNT/RxKPr522/etVqxVBWn7KA=", + "b69YlhA5qlDwN70us4bq9ysnikE1ulA7EwKBTb+atY0=", + "LM7F5iwbk8jG29X4FwpkCN6BqDCfuNQTlz5CyQWt87s=", + "+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=", + "daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=", + "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU=" + ], + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n1942698721\nh/XYzy2m7EiyTjkEDcS8SvEJ6oAQXHWolFLhJzWYbMs=\n\n— rekor.sigstore.dev wNI9ajBFAiA/EXbL5+l5A+F25iy2OZQo8WcjYYAZYpSM75q/TUl5EgIhAK1TD/sp/ZE9GofA59TNi1dFSLqCPABfJE2U4bkFsQBD\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYTgwNWYwOGJjOGE2MmY4MTcxY2Y3M2U1NWExN2ZiYzc1Y2U3MTc3ZTMxZDg4NThkODAzYjczMjEwN2FmZTFjNSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjNjNDAyMDg1M2RhODQ3MjBiNzk5OGYxMjBjMjY5NjUzNDFjNTU1ZDIzNTMzM2M1NDlmM2I4NThiMTMxOGU3N2QifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lGWDhVZ0pSN0UrNzNGckZQNVg2WW1FSHVJVlBFMDR0aGRNWnUvV0cyTWFqQWlCKytRWFl1bVBBd0dXSEJnSHlOKy9zNThpbWtCa2grQWpMZnFaaDM5bjY3Zz09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoU2FrTkRRbk15WjBGM1NVSkJaMGxWVFRVeFVYVXlZVFpOVVRZM1EwRXpWa2hhZEhGSlREVkVaak4zZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNTZRVEJOUkVGNFRVUk5NRmRvWTA1TmFsbDNUbnBCTUUxRVFYbE5SRTB3VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkdaSEozYzNoWVVGa3hkbkZVU1ZaRVQwd3dWRzEyY3pVeGJGSXZXa2RyV0ZoT05YVUtWbk5uUjA5UFFTOUNiMWhzVmpCVVZpOUlZMjVoYVdOQ1RYRlZVbmRvY2xCUVNXdFRkMGxXZGtjeWFqRmpiRzR2YUdGUFEwSmxkM2RuWjFodlRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVkJOazVzQ2tOUFRHSjBUM2N4TDNwdVdqTlNZM0ZpVUN0T2JtVlJkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMlJCV1VSV1VqQlNRVkZJTDBKSGIzZGhTVnB0WVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpvYUdSSFJqVlpWekZvVEROV2RRcGhXRkkxVEZkT2MyRlRNWE5pTWpsM1RIazFibUZZVW05a1YwbDJaREk1ZVdFeVduTmlNMlI2VERKU2NHTXpRbWhrUjA1dldsaEpkR05JVm1saVIyeDZDbUZETlRWaVYzaEJZMjFXYldONU9XOWFWMFpyWTNrNU1rMTVNV2xhV0ZKb1RVUnJSME5wYzBkQlVWRkNaemM0ZDBGUlJVVkxNbWd3WkVoQ2VrOXBPSFlLWkVjNWNscFhOSFZaVjA0d1lWYzVkV041Tlc1aFdGSnZaRmRLTVdNeVZubFpNamwxWkVkV2RXUkROV3BpTWpCM1JXZFpTMHQzV1VKQ1FVZEVkbnBCUWdwQloxRkZZMGhXZW1GRVFUSkNaMjl5UW1kRlJVRlpUeTlOUVVWRVFrTm5kMXBFUlhwTmJWVjVXV3BhYWxscVJYZE9hbXQ0V1RKS2EwNHlWbXRQVkVrMUNrOVhTWGhOVjBwcVRrZFNhRTlIVVhkTk1rbDRUVU5CUjBOcGMwZEJVVkZDWnpjNGQwRlJVVVZGYlZKd1l6TkNhR1JIVG05YVdFbDBZMGhXYVdKSGJIb0tZVVJCYkVKbmIzSkNaMFZGUVZsUEwwMUJSVVpDUW1SdldWaFNhR1ZYUm5SWlV6a3hZbTFzTUdWVE1XcGlSMnQwWWtjNWRtTkVRV2RDWjI5eVFtZEZSUXBCV1U4dlRVRkZSMEpDU25sYVYxcDZUREpvYkZsWFVucE1NMWw2VEZkS2JHUkhSWGRQZDFsTFMzZFpRa0pCUjBSMmVrRkNRMEZSZEVSRGRHOWtTRkozQ21ONmIzWk1NMUoyWVRKV2RVeHRSbXBrUjJ4MlltNU5kVm95YkRCaFNGWnBaRmhPYkdOdFRuWmlibEpzWW01UmRWa3lPWFJOU0ZsSFEybHpSMEZSVVVJS1p6YzRkMEZSYTBWaFFYaHRZVWhTTUdOSVRUWk1lVGx1WVZoU2IyUlhTWFZaTWpsMFRESm9hR1JIUmpWWlZ6Rm9URE5XZFdGWVVqVk1WMDV6WVZNeGN3cGlNamwzVEhrMWJtRllVbTlrVjBsMlpESTVlV0V5V25OaU0yUjZUREpTY0dNelFtaGtSMDV2V2xoSmRHTklWbWxpUjJ4NllVTTFOV0pYZUVGamJWWnRDbU41T1c5YVYwWnJZM2s1TWsxNU1XbGFXRkpvVFVSblIwTnBjMGRCVVZGQ1p6YzRkMEZSYjBWTFozZHZUVWRSZUUxNlNteE5iVWt5V1RKSmVFMUVXVFVLVFZkT2FWcEVaR3hhUkd0NVQxUnNhVTFVUm1sWmVsSnJXVlJvYTAxRVRtbE5WRUZrUW1kdmNrSm5SVVZCV1U4dlRVRkZURUpCT0UxRVYyUndaRWRvTVFwWmFURnZZak5PTUZwWFVYZFBaMWxMUzNkWlFrSkJSMFIyZWtGQ1JFRlJjMFJEY0c5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFpoUjBZd0NsbFliR2hpVjBWMlpGYzFjR1JJYTNSWk1uaHdURmQ0ZG1JelFYZFBRVmxMUzNkWlFrSkJSMFIyZWtGQ1JGRlJjVVJEWjNkYVJFVjZUVzFWZVZscVdtb0tXV3BGZDA1cWEzaFpNa3ByVGpKV2EwOVVTVFZQVjBsNFRWZEthazVIVW1oUFIxRjNUVEpKZUUxRFNVZERhWE5IUVZGUlFtYzNPSGRCVVRSRlJrRjNVd3BqYlZadFkzazViMXBYUm10amVUa3lUWGt4YVZwWVVtaE5RbTlIUTJselIwRlJVVUpuTnpoM1FWRTRSVVJCZDB0TlZFRjNUV3BSTUU1NlJYaFBSRUZ5Q2tKbmIzSkNaMFZGUVZsUEwwMUJSVkZDUWpCTlJ6Sm9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWIxbFlVbWhsVjBaMFdWUkJWMEpuYjNJS1FtZEZSVUZaVHk5TlFVVlNRa0ZuVFVKcVp6Qk5hbFUwVG5wQ01rSm5iM0pDWjBWRlFWbFBMMDFCUlZOQ1IyZE5XbTFvTUdSSVFucFBhVGgyV2pKc01BcGhTRlpwVEcxT2RtSlRPVzlaV0ZKb1pWZEdkRmxUT1RGaWJXd3daVk14YW1KSGEzUmlSemwyWTBNNGRWb3liREJoU0ZacFRETmtkbU50ZEcxaVJ6a3pDbU41T1d0aFdFNTNXVmhTYW1GSFZubE1XRUl4V1cxNGNHTXlaM1ZsVnpGelVVaEtiRnB1VFhaaFIxWm9Xa2hOZG1ScVRYUlpiVll3V1ZSQk5FSm5iM0lLUW1kRlJVRlpUeTlOUVVWVVFrTnZUVXRFUW10TlZFMTVXbFJLYVU1dFRtbE5WRUV5VDFSR2FsbHRVVE5hVjFFMVRXcHJOVmxxUlhoWmJVMHdXa2RGTkFwYVJFRjZXV3BGZDBaQldVdExkMWxDUWtGSFJIWjZRVUpHUVZGSFJFRlNkMlJZVG05TlJqUkhRMmx6UjBGUlVVSm5OemgzUVZKVlJWVkJlRTloU0ZJd0NtTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpvYUdSSFJqVlpWekZvVEROV2RXRllValZNVjA1ellWTXhjMkl5T1hkTU1rWnFaRWRzZG1KdVRYWUtZMjVXZFdONU9IbFBSRmswVDBSak5FNUVWVE5QUXpsb1pFaFNiR0pZUWpCamVUaDRUVUpaUjBOcGMwZEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhUXBpUjJ4cVRVVk5SME5wYzBkQlVWRkNaemM0ZDBGU1owVk9VWGQ2WTIxV2QySjZjRzlaV0ZKb1pWZEdkRmxUT1RGaWJXd3daVk14YW1KSGEzUmlSemwyQ21ORWNIbGFWMWsyWTIxV2JXTjVPVzlhVjBaclkzazVNazE1TVdsYVdGSm9UVWxIUzBKbmIzSkNaMFZGUVdSYU5VRm5VVU5DU0hkRlpXZENORUZJV1VFS00xUXdkMkZ6WWtoRlZFcHFSMUkwWTIxWFl6TkJjVXBMV0hKcVpWQkxNeTlvTkhCNVowTTRjRGR2TkVGQlFVZG1TMjVhTXpWblFVRkNRVTFCVW5wQ1JncEJhVVZCZUdaQlpsQTJTbFUyVmpobmFsVkROemhoUVd3emEwSlplbXBVY1RaR1JVdHZTa2hOWldSVU1WVllhME5KUVZwcU9HNXdVRFJsUm1oT1Z6SjBDbUUyUm5aUloxSjZlV1prY2l0M1pYUTFNVWRQZEdSdVZqQnZOVnBOUVc5SFEwTnhSMU5OTkRsQ1FVMUVRVEpqUVUxSFVVTk5Ra2hVUTJ0T1YwWXZPVGtLUVhWcVJHd3hjekkyUzFCcllubENLMGhZY21kWWIyaERTRUpKYzFCalowRTNjVXd2UzNKNE1FdFNjRk5rVUVSSGRDODNlVmwzU1hkQ2RrVTBiVVkxU3dwRWIwOVhaMlY1TUhsMVdXazFTSEIyZEd0dVZXZ3lXRGRzYzFGNVdVNTFNRUZGZVZOMGRHZFVlakpLYVVnNFMxRkVjMmxQUnpkd1pRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ==" + } + ], + "timestampVerificationData": {}, + "certificate": { + "rawBytes": "MIIHRjCCBs2gAwIBAgIUM51Qu2a6MQ67CA3VHZtqIL5Df3wwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzA0MDAxMDM0WhcNMjYwNzA0MDAyMDM0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFdrwsxXPY1vqTIVDOL0Tmvs51lR/ZGkXXN5uVsgGOOA/BoXlV0TV/HcnaicBMqURwhrPPIkSwIVvG2j1cln/haOCBewwggXoMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUA6NlCOLbtOw1/znZ3RcqbP+NneQwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wdAYDVR0RAQH/BGowaIZmaHR0cHM6Ly9naXRodWIuY29tL2hhdGF5YW1hL3VuaXR5LWNsaS1sb29wLy5naXRodWIvd29ya2Zsb3dzL2Rpc3BhdGNoZXItcHVibGlzaC55bWxAcmVmcy9oZWFkcy92My1iZXRhMDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wEgYKKwYBBAGDvzABAgQEcHVzaDA2BgorBgEEAYO/MAEDBCgwZDEzMmUyYjZjYjEwNjkxY2JkN2VkOTI5OWIxMWJjNGRhOGQwM2IxMCAGCisGAQQBg78wAQQEEmRpc3BhdGNoZXItcHVibGlzaDAlBgorBgEEAYO/MAEFBBdoYXRheWFtYS91bml0eS1jbGktbG9vcDAgBgorBgEEAYO/MAEGBBJyZWZzL2hlYWRzL3YzLWJldGEwOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMHYGCisGAQQBg78wAQkEaAxmaHR0cHM6Ly9naXRodWIuY29tL2hhdGF5YW1hL3VuaXR5LWNsaS1sb29wLy5naXRodWIvd29ya2Zsb3dzL2Rpc3BhdGNoZXItcHVibGlzaC55bWxAcmVmcy9oZWFkcy92My1iZXRhMDgGCisGAQQBg78wAQoEKgwoMGQxMzJlMmI2Y2IxMDY5MWNiZDdlZDkyOTliMTFiYzRkYThkMDNiMTAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwOgYKKwYBBAGDvzABDAQsDCpodHRwczovL2dpdGh1Yi5jb20vaGF0YXlhbWEvdW5pdHktY2xpLWxvb3AwOAYKKwYBBAGDvzABDQQqDCgwZDEzMmUyYjZjYjEwNjkxY2JkN2VkOTI5OWIxMWJjNGRhOGQwM2IxMCIGCisGAQQBg78wAQ4EFAwScmVmcy9oZWFkcy92My1iZXRhMBoGCisGAQQBg78wAQ8EDAwKMTAwMjQ0NzExODArBgorBgEEAYO/MAEQBB0MG2h0dHBzOi8vZ2l0aHViLmNvbS9oYXRheWFtYTAWBgorBgEEAYO/MAERBAgMBjg0MjU4NzB2BgorBgEEAYO/MAESBGgMZmh0dHBzOi8vZ2l0aHViLmNvbS9oYXRheWFtYS91bml0eS1jbGktbG9vcC8uZ2l0aHViL3dvcmtmbG93cy9kaXNwYXRjaGVyLXB1Ymxpc2gueW1sQHJlZnMvaGVhZHMvdjMtYmV0YTA4BgorBgEEAYO/MAETBCoMKDBkMTMyZTJiNmNiMTA2OTFjYmQ3ZWQ5Mjk5YjExYmM0ZGE4ZDAzYjEwFAYKKwYBBAGDvzABFAQGDARwdXNoMF4GCisGAQQBg78wARUEUAxOaHR0cHM6Ly9naXRodWIuY29tL2hhdGF5YW1hL3VuaXR5LWNsaS1sb29wL2FjdGlvbnMvcnVucy8yODY4ODc4NDU3OC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMEMGCisGAQQBg78wARgENQwzcmVwbzpoYXRheWFtYS91bml0eS1jbGktbG9vcDpyZWY6cmVmcy9oZWFkcy92My1iZXRhMIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGfKnZ35gAABAMARzBFAiEAxfAfP6JU6V8gjUC78aAl3kBYzjTq6FEKoJHMedT1UXkCIAZj8npP4eFhNW2ta6FvQgRzyfdr+wet51GOtdnV0o5ZMAoGCCqGSM49BAMDA2cAMGQCMBHTCkNWF/99AujDl1s26KPkbyB+HXrgXohCHBIsPcgA7qL/Krx0KRpSdPDGt/7yYwIwBvE4mF5KDoOWgey0yuYi5HpvtknUh2X7lsQyYNu0AEySttgTz2JiH8KQDsiOG7pe" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiaW5zdGFsbC5wczEiLCJkaWdlc3QiOnsic2hhMjU2IjoiYjBlOGJmZDIyZDdlMjc3ZjhhMWUwZDg1NTQ5NGQwNjg4ZTY2ODc5ZTA3ZjY4Y2FhOTUxNTczYTZhMjZkMTRhOSJ9fSx7Im5hbWUiOiJpbnN0YWxsLnBzMS5zaGEyNTYiLCJkaWdlc3QiOnsic2hhMjU2IjoiZmRmYjA4YzUxNGU0NGU1NWU2MWMwNjY4ODI1OTZiYTc3OTAwNWFlZGM0ZDNjYjhkMmNjZDFlOGI2ZDc5MWE3NCJ9fSx7Im5hbWUiOiJpbnN0YWxsLnNoIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImU2NmJhZDI0YmFhN2ZhZWVkYjQ4MDQ4YmRjODJiZTNjNTVjYjIzNThkMDkwNDk3MTJmOGY0M2M0ZTQzMGJhMGMifX0seyJuYW1lIjoiaW5zdGFsbC5zaC5zaGEyNTYiLCJkaWdlc3QiOnsic2hhMjU2IjoiMTQwNDRiYWQ0OGJkMDRmZWI2MjBiNzA5ODhlYzdiYzBhYTQ0NDEyNWJmZWZkYjcwZjRhNmQ5ZGMwNzZjNWM5ZSJ9fSx7Im5hbWUiOiJ1bG9vcC1kaXNwYXRjaGVyLWRhcndpbi1hbWQ2NC50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYmJjOTY2ODg1NWU1OTJjMzA5NWU0NGJiNTVmMTY2NjVmZGVmYmZlYmI0NzkyMjkxMDYzNjZmMThmZjAzMWNlMyJ9fSx7Im5hbWUiOiJ1bG9vcC1kaXNwYXRjaGVyLWRhcndpbi1hbWQ2NC50YXIuZ3ouc2hhMjU2IiwiZGlnZXN0Ijp7InNoYTI1NiI6Ijc1MTdmMDAwODA0Mjg5YjEzMWI3N2YzY2FlNWJkMTI1NDViYWI0MDQ2ZTg3YmEwMDJhMWUwMTdjMWIyZTFkZTcifX0seyJuYW1lIjoidWxvb3AtZGlzcGF0Y2hlci1kYXJ3aW4tYXJtNjQudGFyLmd6IiwiZGlnZXN0Ijp7InNoYTI1NiI6IjE0YzE1YTdjMTMyZjVhM2I4ZWIxMWI3ZTMxMTVmZmYzNTAyNDc5MzM2OGRhNzljYjkwYjMyNWEzNDM3ODBhMjgifX0seyJuYW1lIjoidWxvb3AtZGlzcGF0Y2hlci1kYXJ3aW4tYXJtNjQudGFyLmd6LnNoYTI1NiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI3NWZhMjJkZGUyMjkzOGY2ZDBlZWQ4MTI5NzJiMDI3ZTI1NjFiNzVhMmRjNWJjMjM4MmQyZjc2NjcxNzNjMDc5In19LHsibmFtZSI6InVsb29wLWRpc3BhdGNoZXItd2luZG93cy1hbWQ2NC56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiMDE5MmRkY2MxNWEzZWUxMDJlMThlYWMzZGY1MzhhZmVlNDczOGQ1ZDFkZjQyODQ4OWQxZjJiOThjZmZkOGRjMCJ9fSx7Im5hbWUiOiJ1bG9vcC1kaXNwYXRjaGVyLXdpbmRvd3MtYW1kNjQuemlwLnNoYTI1NiIsImRpZ2VzdCI6eyJzaGEyNTYiOiI2YzFjZWM0N2ZlNWI2MDhjNjNiZjY1NTljMGMyMjE2OGExMTEzMTE1YzI5MjQ0ZTU4N2FkODYzY2FmZjVmMTRlIn19XSwicHJlZGljYXRlVHlwZSI6Imh0dHBzOi8vc2xzYS5kZXYvcHJvdmVuYW5jZS92MSIsInByZWRpY2F0ZSI6eyJidWlsZERlZmluaXRpb24iOnsiYnVpbGRUeXBlIjoiaHR0cHM6Ly9hY3Rpb25zLmdpdGh1Yi5pby9idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvaGVhZHMvdjMtYmV0YSIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vaGF0YXlhbWEvdW5pdHktY2xpLWxvb3AiLCJwYXRoIjoiLmdpdGh1Yi93b3JrZmxvd3MvZGlzcGF0Y2hlci1wdWJsaXNoLnltbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoicHVzaCIsInJlcG9zaXRvcnlfaWQiOiIxMDAyNDQ3MTE4IiwicmVwb3NpdG9yeV9vd25lcl9pZCI6Ijg0MjU4NyIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL2hhdGF5YW1hL3VuaXR5LWNsaS1sb29wQHJlZnMvaGVhZHMvdjMtYmV0YSIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiIwZDEzMmUyYjZjYjEwNjkxY2JkN2VkOTI5OWIxMWJjNGRhOGQwM2IxIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9oYXRheWFtYS91bml0eS1jbGktbG9vcC8uZ2l0aHViL3dvcmtmbG93cy9kaXNwYXRjaGVyLXB1Ymxpc2gueW1sQHJlZnMvaGVhZHMvdjMtYmV0YSJ9LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSWQiOiJodHRwczovL2dpdGh1Yi5jb20vaGF0YXlhbWEvdW5pdHktY2xpLWxvb3AvYWN0aW9ucy9ydW5zLzI4Njg4Nzg0NTc4L2F0dGVtcHRzLzEifX19fQ==", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIFX8UgJR7E+73FrFP5X6YmEHuIVPE04thdMZu/WG2MajAiB++QXYumPAwGWHBgHyN+/s58imkBkh+AjLfqZh39n67g==" + } + ] + } +} diff --git a/cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt b/cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt new file mode 100644 index 0000000000..c2ae4ab34c --- /dev/null +++ b/cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt @@ -0,0 +1 @@ +0d132e2b6cb10691cbd7ed9299b11bc4da8d03b1 diff --git a/cli/dispatcher/internal/attestation/trusted_root.go b/cli/dispatcher/internal/attestation/trusted_root.go new file mode 100644 index 0000000000..7f1f1a98ee --- /dev/null +++ b/cli/dispatcher/internal/attestation/trusted_root.go @@ -0,0 +1,35 @@ +// Package attestation verifies GitHub Artifact Attestations for dispatcher +// self-update and project runner download flows. +// +// Trust anchor is embedded via go:embed at build time so verification stays +// offline and deterministic. Refresh via cmd/refresh-attestation-trusted-root. +// The CI guard test in trusted_root_test.go fails when the embedded root's +// authorities approach expiration so a missed quarterly refresh surfaces as a +// red build, not a fleet-wide self-update outage after Fulcio or Rekor rotates. +package attestation + +import ( + _ "embed" + "fmt" + + "github.com/sigstore/sigstore-go/pkg/root" +) + +//go:embed trusted_root.json +var embeddedTrustedRoot []byte + +// LoadEmbeddedTrustedMaterial parses the embedded Sigstore trusted_root.json +// into a root.TrustedMaterial suitable for verify.NewVerifier. +func LoadEmbeddedTrustedMaterial() (root.TrustedMaterial, error) { + tr, err := root.NewTrustedRootFromJSON(embeddedTrustedRoot) + if err != nil { + return nil, fmt.Errorf("parse embedded trusted_root.json: %w", err) + } + return tr, nil +} + +// EmbeddedTrustedRootBytes returns the raw embedded trusted_root.json bytes +// so tests can inspect authority validity windows without re-parsing. +func EmbeddedTrustedRootBytes() []byte { + return embeddedTrustedRoot +} diff --git a/cli/dispatcher/internal/attestation/trusted_root.json b/cli/dispatcher/internal/attestation/trusted_root.json new file mode 100644 index 0000000000..effb0a19e6 --- /dev/null +++ b/cli/dispatcher/internal/attestation/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/cli/dispatcher/internal/attestation/trusted_root_test.go b/cli/dispatcher/internal/attestation/trusted_root_test.go new file mode 100644 index 0000000000..588ed1a2c9 --- /dev/null +++ b/cli/dispatcher/internal/attestation/trusted_root_test.go @@ -0,0 +1,125 @@ +package attestation + +import ( + "encoding/json" + "testing" + "time" +) + +// Verifies the embedded Sigstore trusted_root.json parses into a usable +// TrustedMaterial so a corrupted commit is caught before it can silently +// break self-update verification. +func TestLoadEmbeddedTrustedMaterial_Parses(t *testing.T) { + tr, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("expected embedded trusted_root to parse, got: %v", err) + } + if tr == nil { + t.Fatal("expected non-nil trusted material") + } + if len(tr.FulcioCertificateAuthorities()) == 0 { + t.Fatal("expected at least one Fulcio certificate authority") + } + if len(tr.RekorLogs()) == 0 { + t.Fatal("expected at least one Rekor log") + } +} + +// Sigstore public-good roots rotate authorities and transparency-log keys +// periodically. When every embedded authority is about to expire, offline +// verification will start failing en masse. This test fails first — in CI — +// so a maintainer can refresh trusted_root.json (via +// cli/dispatcher/cmd/refresh-attestation-trusted-root) and ship it in a +// dispatcher release before customers notice. +// +// Rule: for each category (Fulcio CAs, Rekor logs, CT logs, TSAs) that is +// present in the root, at least one authority must be usable more than the +// refresh warning horizon into the future — either its validFor.end is set +// beyond that horizon or it has no end (still active indefinitely). +const trustedRootWarningHorizon = 90 * 24 * time.Hour + +type validFor struct { + Start string `json:"start"` + End string `json:"end,omitempty"` +} + +type rawTLog struct { + PublicKey struct { + ValidFor validFor `json:"validFor"` + } `json:"publicKey"` +} + +type rawTrustedRoot struct { + CertificateAuthorities []struct { + ValidFor validFor `json:"validFor"` + } `json:"certificateAuthorities"` + TimestampAuthorities []struct { + ValidFor validFor `json:"validFor"` + } `json:"timestampAuthorities"` + TLogs []rawTLog `json:"tlogs"` + CTLogs []rawTLog `json:"ctlogs"` +} + +func TestEmbeddedTrustedRoot_HasFutureCapableAuthorityPerCategory(t *testing.T) { + var root rawTrustedRoot + if err := json.Unmarshal(EmbeddedTrustedRootBytes(), &root); err != nil { + t.Fatalf("parse embedded trusted_root: %v", err) + } + + now := time.Now().UTC() + horizon := now.Add(trustedRootWarningHorizon) + + assertHorizon := func(category string, entries []validFor) { + if len(entries) == 0 { + return // category simply not populated in this root + } + for _, entry := range entries { + if isValidBeyond(t, entry, horizon) { + return + } + } + t.Fatalf("no %s authority is usable past %s (all end before). Refresh cli/dispatcher/internal/attestation/trusted_root.json via `go run ./cmd/refresh-attestation-trusted-root` and land a fix(dispatcher) release.", category, horizon.Format(time.RFC3339)) + } + + caEntries := make([]validFor, 0, len(root.CertificateAuthorities)) + for _, e := range root.CertificateAuthorities { + caEntries = append(caEntries, e.ValidFor) + } + tsaEntries := make([]validFor, 0, len(root.TimestampAuthorities)) + for _, e := range root.TimestampAuthorities { + tsaEntries = append(tsaEntries, e.ValidFor) + } + tlogEntries := make([]validFor, 0, len(root.TLogs)) + for _, e := range root.TLogs { + tlogEntries = append(tlogEntries, e.PublicKey.ValidFor) + } + ctlogEntries := make([]validFor, 0, len(root.CTLogs)) + for _, e := range root.CTLogs { + ctlogEntries = append(ctlogEntries, e.PublicKey.ValidFor) + } + + assertHorizon("Fulcio CA", caEntries) + assertHorizon("Timestamp Authority", tsaEntries) + assertHorizon("Rekor log", tlogEntries) + assertHorizon("CT log", ctlogEntries) +} + +// isValidBeyond returns true when the entry is either unlimited (no end date) +// or its end date is on/after the given horizon. Entries whose start hasn't +// arrived yet are not considered "future-capable" — they cannot verify +// present-day bundles. +func isValidBeyond(t *testing.T, v validFor, horizon time.Time) bool { + t.Helper() + if v.End == "" { + return true + } + end, err := time.Parse(time.RFC3339, v.End) + if err != nil { + // Some sigstore roots use millisecond precision. Try that too. + end, err = time.Parse("2006-01-02T15:04:05.999Z07:00", v.End) + if err != nil { + t.Fatalf("cannot parse validFor.end %q: %v", v.End, err) + } + } + return !end.Before(horizon) +} diff --git a/cli/dispatcher/internal/attestation/verifier.go b/cli/dispatcher/internal/attestation/verifier.go new file mode 100644 index 0000000000..f400297d48 --- /dev/null +++ b/cli/dispatcher/internal/attestation/verifier.go @@ -0,0 +1,174 @@ +package attestation + +import ( + "encoding/hex" + "fmt" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +// Identity describes the GitHub Actions workflow identity that a signed release +// asset must present. Refs is the set of git refs the workflow may have run +// from — we accept a small closed allowlist (e.g. v3-beta and main branches) +// rather than a regex so a leaked OIDC token for an unrelated ref cannot forge +// releases. +type Identity struct { + Repository string + WorkflowPath string + Refs []string +} + +// SubjectAlternativeNames constructs the exact-match SAN strings that a Fulcio +// certificate must expose to pass this identity — one per allowed ref. +func (id Identity) SubjectAlternativeNames() []string { + sans := make([]string, 0, len(id.Refs)) + for _, ref := range id.Refs { + sans = append(sans, fmt.Sprintf("https://github.com/%s/%s@%s", id.Repository, id.WorkflowPath, ref)) + } + return sans +} + +// SourceRepositoryURI is the value the Fulcio cert must expose under OID +// 1.3.6.1.4.1.57264.1.12 — the top-level repository the workflow ran in. +func (id Identity) SourceRepositoryURI() string { + return "https://github.com/" + id.Repository +} + +// VerifyOptions is the input to Verify. +type VerifyOptions struct { + // AssetDigest is the SHA-256 hex digest of the local release asset the + // caller intends to trust. Must be the same digest the caller will use + // to open/execute the file. + AssetDigest string + + // BundleData is the raw JSON bytes of the .sigstore.json bundle + // downloaded from the release. The single bundle carries multiple + // subjects (one per release asset); Verify enforces that AssetDigest + // appears in the subject list. + BundleData []byte + + // ExpectedCommitSHA is the 40-char hex git commit SHA that the release + // tag points at (resolved via /repos/{owner}/{repo}/git/ref/tags/{tag}). + // The Fulcio cert's OID 1.3.6.1.4.1.57264.1.13 (SourceRepositoryDigest) + // must match — this binds the attestation to a specific tree state and + // prevents a stolen OIDC token from being reused on a different commit. + ExpectedCommitSHA string + + // Identity is the SAN allowlist + repository URI the certificate must + // match. The verifier accepts any listed ref, so releases from either + // v3-beta or main pass. + Identity Identity +} + +// Verify runs full Sigstore verification on the bundle: signature validity, +// transparency log inclusion, integrated timestamps, artifact digest match, +// certificate identity (SAN + source repo digest binding). Fail-closed on any +// step. Callers should treat a nil return as "safe to execute this file". +// +// The trustedMaterial argument is threaded in to make CI guard tests possible; +// production callers should pass LoadEmbeddedTrustedMaterial() so verification +// stays offline and deterministic across dispatcher invocations. +func Verify(trustedMaterial root.TrustedMaterial, opts VerifyOptions) error { + if err := opts.validate(); err != nil { + return err + } + + digestBytes, err := hex.DecodeString(opts.AssetDigest) + if err != nil { + return fmt.Errorf("%w: asset digest must be hex sha256: %v", ErrVerificationFailed, err) + } + if len(digestBytes) != 32 { + return fmt.Errorf("%w: asset digest must be 32 bytes (sha256), got %d", ErrVerificationFailed, len(digestBytes)) + } + + var b bundle.Bundle + if err := b.UnmarshalJSON(opts.BundleData); err != nil { + return fmt.Errorf("%w: %v", ErrMalformedBundle, err) + } + + verifier, err := verify.NewVerifier(trustedMaterial, + verify.WithTransparencyLog(1), + verify.WithIntegratedTimestamps(1)) + if err != nil { + return fmt.Errorf("%w: build verifier: %v", ErrVerificationFailed, err) + } + + identities, err := buildCertificateIdentities(opts.Identity, opts.ExpectedCommitSHA) + if err != nil { + return fmt.Errorf("%w: build identities: %v", ErrVerificationFailed, err) + } + + policyOpts := make([]verify.PolicyOption, 0, len(identities)) + for _, id := range identities { + policyOpts = append(policyOpts, verify.WithCertificateIdentity(id)) + } + + policy := verify.NewPolicy(verify.WithArtifactDigest("sha256", digestBytes), policyOpts...) + + if _, err := verifier.Verify(&b, policy); err != nil { + return fmt.Errorf("%w: %v", ErrVerificationFailed, err) + } + return nil +} + +func (o VerifyOptions) validate() error { + if o.AssetDigest == "" { + return fmt.Errorf("%w: AssetDigest required", ErrVerificationFailed) + } + if len(o.BundleData) == 0 { + return fmt.Errorf("%w: BundleData required", ErrVerificationFailed) + } + if !isHexCommitSHA(o.ExpectedCommitSHA) { + return fmt.Errorf("%w: ExpectedCommitSHA must be 40-char hex", ErrVerificationFailed) + } + if o.Identity.Repository == "" || o.Identity.WorkflowPath == "" || len(o.Identity.Refs) == 0 { + return fmt.Errorf("%w: Identity.Repository, WorkflowPath, and at least one Ref required", ErrVerificationFailed) + } + return nil +} + +func isHexCommitSHA(s string) bool { + if len(s) != 40 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func buildCertificateIdentities(id Identity, expectedCommitSHA string) (verify.CertificateIdentities, error) { + sans := id.SubjectAlternativeNames() + if len(sans) == 0 { + return nil, fmt.Errorf("identity has no refs") + } + identities := make(verify.CertificateIdentities, 0, len(sans)) + issuerMatcher, err := verify.NewIssuerMatcher("https://token.actions.githubusercontent.com", "") + if err != nil { + return nil, err + } + for _, san := range sans { + sanMatcher, err := verify.NewSANMatcher(san, "") + if err != nil { + return nil, fmt.Errorf("build SAN matcher: %w", err) + } + identities = append(identities, verify.CertificateIdentity{ + SubjectAlternativeName: sanMatcher, + Issuer: issuerMatcher, + Extensions: certificate.Extensions{ + SourceRepositoryURI: id.SourceRepositoryURI(), + SourceRepositoryDigest: expectedCommitSHA, + }, + }) + } + if len(identities) == 0 { + return nil, fmt.Errorf("identity produced no matchers") + } + return identities, nil +} diff --git a/cli/dispatcher/internal/attestation/verifier_test.go b/cli/dispatcher/internal/attestation/verifier_test.go new file mode 100644 index 0000000000..3cece2cb7b --- /dev/null +++ b/cli/dispatcher/internal/attestation/verifier_test.go @@ -0,0 +1,302 @@ +package attestation + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// happyFixture returns fixture values pulled from a real backfilled +// dispatcher-v3.0.1-beta.12 arm64 tarball attestation. Verifier tests run +// offline against the embedded trusted_root.json so they stay deterministic. +type happyFixture struct { + bundle []byte + digest string + commitSHA string + identity Identity +} + +func loadHappyFixture(t *testing.T) happyFixture { + t.Helper() + fixtureDir := filepath.Join("testdata") + bundleBytes, err := os.ReadFile(filepath.Join(fixtureDir, "happy_bundle.json")) + if err != nil { + t.Fatalf("read happy_bundle.json: %v", err) + } + digest := readOneLine(t, filepath.Join(fixtureDir, "happy_asset_digest.txt")) + commitSHA := readOneLine(t, filepath.Join(fixtureDir, "happy_commit_sha.txt")) + return happyFixture{ + bundle: bundleBytes, + digest: digest, + commitSHA: commitSHA, + identity: Identity{ + Repository: "hatayama/unity-cli-loop", + WorkflowPath: ".github/workflows/dispatcher-publish.yml", + Refs: []string{"refs/heads/v3-beta", "refs/heads/main"}, + }, + } +} + +func readOneLine(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.TrimSpace(string(data)) +} + +// Verifies that a real bundle + digest + commit SHA + allowlisted SAN passes. +func TestVerify_HappyPath(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + err = Verify(trusted, VerifyOptions{ + AssetDigest: f.digest, + BundleData: f.bundle, + ExpectedCommitSHA: f.commitSHA, + Identity: f.identity, + }) + if err != nil { + t.Fatalf("expected happy path to verify, got: %v", err) + } +} + +// Verifies fail-closed when the local asset digest is not one of the bundle's subjects. +func TestVerify_DigestMismatch(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + wrongDigest := "0000000000000000000000000000000000000000000000000000000000000000" + err = Verify(trusted, VerifyOptions{ + AssetDigest: wrongDigest, + BundleData: f.bundle, + ExpectedCommitSHA: f.commitSHA, + Identity: f.identity, + }) + if err == nil { + t.Fatalf("expected digest mismatch to fail, got nil") + } + if !errors.Is(err, ErrVerificationFailed) { + t.Fatalf("expected ErrVerificationFailed, got: %v", err) + } +} + +// Verifies fail-closed when SAN allowlist does not include the cert's signer. +func TestVerify_IdentityMismatch_BadSAN(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + badIdentity := f.identity + badIdentity.Repository = "attacker/evil-fork" + err = Verify(trusted, VerifyOptions{ + AssetDigest: f.digest, + BundleData: f.bundle, + ExpectedCommitSHA: f.commitSHA, + Identity: badIdentity, + }) + if err == nil { + t.Fatalf("expected SAN mismatch to fail, got nil") + } + if !errors.Is(err, ErrVerificationFailed) { + t.Fatalf("expected ErrVerificationFailed, got: %v", err) + } +} + +// Verifies fail-closed when the expected commit SHA does not match cert's OID .13. +func TestVerify_IdentityMismatch_BadCommitSHA(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + wrongCommit := "0000000000000000000000000000000000000000" + err = Verify(trusted, VerifyOptions{ + AssetDigest: f.digest, + BundleData: f.bundle, + ExpectedCommitSHA: wrongCommit, + Identity: f.identity, + }) + if err == nil { + t.Fatalf("expected commit SHA mismatch to fail, got nil") + } + if !errors.Is(err, ErrVerificationFailed) { + t.Fatalf("expected ErrVerificationFailed, got: %v", err) + } +} + +// Verifies fail-closed when the bundle bytes are malformed. +func TestVerify_MalformedBundle(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + err = Verify(trusted, VerifyOptions{ + AssetDigest: f.digest, + BundleData: []byte("this is not a sigstore bundle"), + ExpectedCommitSHA: f.commitSHA, + Identity: f.identity, + }) + if err == nil { + t.Fatalf("expected malformed bundle to fail, got nil") + } + if !errors.Is(err, ErrMalformedBundle) { + t.Fatalf("expected ErrMalformedBundle, got: %v", err) + } +} + +// Verifies FetchBundle wraps a 404 (bundle-missing) into ErrBundleFetch. +func TestFetchBundle_MissingReturns404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + _, err := FetchBundle(context.Background(), server.URL+"/whatever.sigstore.json") + if err == nil { + t.Fatalf("expected 404 to fail, got nil") + } + if !errors.Is(err, ErrBundleFetch) { + t.Fatalf("expected ErrBundleFetch, got: %v", err) + } +} + +// Verifies FetchBundle fails closed on 403 (which GitHub returns during rate +// limiting and abuse detection). We must never treat 403 as "no bundle +// available so skip", or an attacker can DoS attestation lookups to strip +// verification. +func TestFetchBundle_403FailsClosed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + _, err := FetchBundle(context.Background(), server.URL+"/whatever.sigstore.json") + if err == nil { + t.Fatalf("expected 403 to fail-closed, got nil") + } + if !errors.Is(err, ErrBundleFetch) { + t.Fatalf("expected ErrBundleFetch, got: %v", err) + } +} + +// Verifies FetchBundle fails closed on 429 (rate limit). +func TestFetchBundle_429FailsClosed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + _, err := FetchBundle(context.Background(), server.URL+"/whatever.sigstore.json") + if err == nil { + t.Fatalf("expected 429 to fail-closed, got nil") + } + if !errors.Is(err, ErrBundleFetch) { + t.Fatalf("expected ErrBundleFetch, got: %v", err) + } +} + +// Verifies FetchBundle fails when the body is empty. Empty response from a +// caching CDN would otherwise let a stripped bundle look like "downloaded OK". +func TestFetchBundle_EmptyBodyFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := FetchBundle(context.Background(), server.URL+"/whatever.sigstore.json") + if err == nil { + t.Fatalf("expected empty body to fail, got nil") + } + if !errors.Is(err, ErrBundleFetch) { + t.Fatalf("expected ErrBundleFetch, got: %v", err) + } +} + +// Verifies FetchTagCommitSHA parses a lightweight tag ref API response. +func TestFetchTagCommitSHA_Lightweight(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, `{"object":{"sha":"1eb1ebb9841b1bcb8fc7dec3fa282568a1c31a4f","type":"commit"}}`) + })) + defer server.Close() + + original := githubAPIBase() + setGithubAPIBase(server.URL) + defer setGithubAPIBase(original) + + sha, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "dispatcher-v3.0.1-beta.12") + if err != nil { + t.Fatalf("expected happy tag lookup, got: %v", err) + } + if sha != "1eb1ebb9841b1bcb8fc7dec3fa282568a1c31a4f" { + t.Fatalf("unexpected sha: %s", sha) + } +} + +// Verifies FetchTagCommitSHA follows one indirection when the ref points at +// an annotated tag object rather than a commit. +func TestFetchTagCommitSHA_Annotated(t *testing.T) { + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + hits++ + switch hits { + case 1: + _, _ = fmt.Fprintln(w, `{"object":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","type":"tag"}}`) + case 2: + _, _ = fmt.Fprintln(w, `{"object":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","type":"commit"}}`) + default: + t.Errorf("unexpected extra call %d", hits) + } + })) + defer server.Close() + + original := githubAPIBase() + setGithubAPIBase(server.URL) + defer setGithubAPIBase(original) + + sha, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "annotated-tag") + if err != nil { + t.Fatalf("expected annotated tag lookup to resolve, got: %v", err) + } + if sha != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Fatalf("unexpected sha: %s", sha) + } + if hits != 2 { + t.Fatalf("expected 2 API hits (ref then tag), got %d", hits) + } +} + +// Verifies FetchTagCommitSHA fails closed on network / server errors. +func TestFetchTagCommitSHA_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + + original := githubAPIBase() + setGithubAPIBase(server.URL) + defer setGithubAPIBase(original) + + _, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "any") + if err == nil { + t.Fatalf("expected server error to fail-closed") + } + if !errors.Is(err, ErrTagRefFetch) { + t.Fatalf("expected ErrTagRefFetch, got: %v", err) + } +} diff --git a/cli/go.work.sum b/cli/go.work.sum index cc90caa9bb..f9299e67e9 100644 --- a/cli/go.work.sum +++ b/cli/go.work.sum @@ -1,3 +1,113 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= +github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= +github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= +github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +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-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= +google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 h1:Au6te5hbKUV8pIYWHqOUZ1pva5qK/rwbIhoXEUB9Lu8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= diff --git a/cli/release-automation/internal/architecture/architecture_test.go b/cli/release-automation/internal/architecture/architecture_test.go index 728bcbfff4..1c7de0269b 100644 --- a/cli/release-automation/internal/architecture/architecture_test.go +++ b/cli/release-automation/internal/architecture/architecture_test.go @@ -253,7 +253,7 @@ func TestInternalBoundariesPerModule(t *testing.T) { { moduleDir: filepath.Join(repositoryRoot, contract.Layout.Modules.Dispatcher), modulePath: dispatcherModulePath, - allowed: []string{"dispatcher", "install", "nativepath", "uninstall", "update"}, + allowed: []string{"attestation", "dispatcher", "install", "nativepath", "uninstall", "update"}, }, { moduleDir: filepath.Join(repositoryRoot, contract.Layout.Modules.ReleaseAutomation), From 71398fa4f9567d20e934fecf0162c5220b49061a Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 12:43:58 +0900 Subject: [PATCH 2/2] fix(dispatcher): Enforce SCT verification and trim bundle fetch Authorization Address Fable 5 review on PR #1670: - verifier: add verify.WithSignedCertificateTimestamps(1) so a Fulcio-CA-issued cert that skipped CT logs is rejected instead of accepted. TransparencyLog + IntegratedTimestamps alone did not cover this failure mode. - fetcher: stop attaching Authorization to release download URLs. GitHub's release download host is not API-rate-limited so the token buys nothing, and the http.Client strips it across the cross-host redirect to objects.githubusercontent.com anyway. Restrict the header to api.github.com calls (fetchGitRef) for least privilege. - trusted_root_test: align the isValidBeyond comment with the actual gate. The guard only inspects End; the Sigstore public-good root has never shipped an entry with a future Start, so document that scope instead of promising a Start check the code does not perform. --- cli/dispatcher/internal/attestation/fetcher.go | 8 +++++++- cli/dispatcher/internal/attestation/trusted_root_test.go | 7 ++++--- cli/dispatcher/internal/attestation/verifier.go | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cli/dispatcher/internal/attestation/fetcher.go b/cli/dispatcher/internal/attestation/fetcher.go index 4182e62547..bbf9f15e03 100644 --- a/cli/dispatcher/internal/attestation/fetcher.go +++ b/cli/dispatcher/internal/attestation/fetcher.go @@ -42,12 +42,18 @@ func BundleAssetURL(repo, tag, assetName string) string { // are treated the same way as any other non-2xx: verification is not attempted // because we cannot distinguish rate-limit denial from an attacker denying the // bundle to skip verification. +// +// No Authorization header is set here: release download URLs +// (github.com/.../releases/download) are not subject to the API rate limits +// that GITHUB_TOKEN would relax, and Go's http.Client strips Authorization +// across the cross-host redirect to objects.githubusercontent.com anyway, so +// leaking the token would be pointless. Least-privilege: keep the token on +// api.github.com calls only (see fetchGitRef). func FetchBundle(ctx context.Context, bundleURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil) if err != nil { return nil, fmt.Errorf("%w: build request: %v", ErrBundleFetch, err) } - setAuthorizationIfAvailable(req) resp, err := DefaultHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrBundleFetch, err) diff --git a/cli/dispatcher/internal/attestation/trusted_root_test.go b/cli/dispatcher/internal/attestation/trusted_root_test.go index 588ed1a2c9..abafa593df 100644 --- a/cli/dispatcher/internal/attestation/trusted_root_test.go +++ b/cli/dispatcher/internal/attestation/trusted_root_test.go @@ -105,9 +105,10 @@ func TestEmbeddedTrustedRoot_HasFutureCapableAuthorityPerCategory(t *testing.T) } // isValidBeyond returns true when the entry is either unlimited (no end date) -// or its end date is on/after the given horizon. Entries whose start hasn't -// arrived yet are not considered "future-capable" — they cannot verify -// present-day bundles. +// or its end date is on/after the given horizon. The Sigstore public-good +// root ships historical authorities with a Start value in the past, so we +// only gate on End here — a Start-in-the-future entry has never appeared in +// this root and is not part of the horizon contract this test enforces. func isValidBeyond(t *testing.T, v validFor, horizon time.Time) bool { t.Helper() if v.End == "" { diff --git a/cli/dispatcher/internal/attestation/verifier.go b/cli/dispatcher/internal/attestation/verifier.go index f400297d48..7710e9ae19 100644 --- a/cli/dispatcher/internal/attestation/verifier.go +++ b/cli/dispatcher/internal/attestation/verifier.go @@ -91,7 +91,8 @@ func Verify(trustedMaterial root.TrustedMaterial, opts VerifyOptions) error { verifier, err := verify.NewVerifier(trustedMaterial, verify.WithTransparencyLog(1), - verify.WithIntegratedTimestamps(1)) + verify.WithIntegratedTimestamps(1), + verify.WithSignedCertificateTimestamps(1)) if err != nil { return fmt.Errorf("%w: build verifier: %v", ErrVerificationFailed, err) }