From 4c05dae00d8444fb9327494ac2190163c169d64b Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 12:54:42 +0900 Subject: [PATCH 1/2] fix(dispatcher): Verify installer attestation before running self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher self-update flow (uloop update / scheduled 24h refresh) now requires a valid Sigstore attestation for install.sh / install.ps1 before it will execute the downloaded installer. Fail-closed on bundle-missing, network-failure, digest-mismatch, and identity-mismatch — the .sha256 file ships from the same origin as the asset and cannot certify authenticity if that origin is compromised, so attestation is the true root of trust. - update.Command gains a ReleaseTag field so the verifier can resolve the tag to a commit SHA and bind it to the certificate's Source Repository Digest extension (OID 1.3.6.1.4.1.57264.1.13). - New attestation_verify.go wires the attestation package into the dispatcher package with a swappable var so tests can isolate download/checksum behavior from real network + sigstore verification. Also exposes the two publish-workflow SANs (dispatcher-publish, native-cli-publish) for Phase B3's runner-download reuse. - downloadVerifiedUpdateInstaller now calls the verifier with attestationDispatcherPublishWorkflowPath after checksum verification succeeds. - Tests cover happy path, attestation failure fail-closed, and the workflow path / release tag actually passed through to the verifier. --- .../internal/dispatcher/attestation_verify.go | 92 +++++++++++++++++++ cli/dispatcher/internal/dispatcher/update.go | 3 + .../internal/dispatcher/update_test.go | 83 +++++++++++++++++ cli/dispatcher/internal/update/command.go | 7 ++ 4 files changed, 185 insertions(+) create mode 100644 cli/dispatcher/internal/dispatcher/attestation_verify.go diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify.go b/cli/dispatcher/internal/dispatcher/attestation_verify.go new file mode 100644 index 0000000000..bdaf32df7b --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/attestation_verify.go @@ -0,0 +1,92 @@ +package dispatcher + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/attestation" +) + +// Attestation identity constants for the two workflows that publish signed +// release assets. Adding a new signed workflow requires appending the SAN here +// so a leaked OIDC token for an unrelated workflow cannot forge our releases. +const ( + attestationDispatcherPublishWorkflowPath = ".github/workflows/dispatcher-publish.yml" + attestationRunnerPublishWorkflowPath = ".github/workflows/native-cli-publish.yml" +) + +// attestationAllowedRefs is the closed set of refs the OIDC token may be issued +// for. Exact strings, no regex — a stolen token on a different branch fails. +var attestationAllowedRefs = []string{ + "refs/heads/v3-beta", + "refs/heads/main", +} + +// verifyReleaseAssetAttestation is the hook production code calls to verify +// a downloaded release asset. Tests override it to isolate checksum/extract +// logic from real network + sigstore verification. +var verifyReleaseAssetAttestation = defaultVerifyReleaseAssetAttestation + +// defaultVerifyReleaseAssetAttestation fetches the `.sigstore.json` +// bundle and validates it against the local asset file, the resolved release +// tag commit SHA, and the given workflow identity. Fail-closed on every +// branch: bundle-missing, network-failure, digest-mismatch, identity-mismatch. +// Callers must NOT run the asset when this returns a non-nil error. +func defaultVerifyReleaseAssetAttestation(ctx context.Context, releaseTag string, assetURL string, assetPath string, workflowPath string) error { + digestHex, err := computeAssetSHA256Hex(assetPath) + if err != nil { + return fmt.Errorf("compute asset digest for attestation: %w", err) + } + + bundleData, err := attestation.FetchBundle(ctx, assetURL+".sigstore.json") + if err != nil { + return err + } + + commitSHA, err := attestation.FetchTagCommitSHA(ctx, dispatcherReleaseRepository, releaseTag) + if err != nil { + return err + } + + trustedMaterial, err := attestation.LoadEmbeddedTrustedMaterial() + if err != nil { + return fmt.Errorf("%w: load embedded trusted root: %v", attestation.ErrVerificationFailed, err) + } + + return attestation.Verify(trustedMaterial, attestation.VerifyOptions{ + AssetDigest: digestHex, + BundleData: bundleData, + ExpectedCommitSHA: commitSHA, + Identity: attestation.Identity{ + Repository: dispatcherReleaseRepository, + WorkflowPath: workflowPath, + Refs: attestationAllowedRefs, + }, + }) +} + +// computeAssetSHA256Hex returns the lowercase hex-encoded SHA-256 of the file +// at path. The dispatcher already sha256-verifies release assets against the +// sibling `.sha256` file for transport-corruption detection; this second read +// exists so the attestation layer never depends on the checksum file that +// ships from the same origin as the asset — the attestation must be able to +// stand on its own if that origin is compromised. +func computeAssetSHA256Hex(path string) (string, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/cli/dispatcher/internal/dispatcher/update.go b/cli/dispatcher/internal/dispatcher/update.go index ff26e21827..5788cebb7f 100644 --- a/cli/dispatcher/internal/dispatcher/update.go +++ b/cli/dispatcher/internal/dispatcher/update.go @@ -117,6 +117,9 @@ func downloadVerifiedUpdateInstaller(ctx context.Context, updateCommand update.C if err := verifyDispatcherChecksum(installerPath, checksumPath); err != nil { return "", err } + if err := verifyReleaseAssetAttestation(ctx, updateCommand.ReleaseTag, updateCommand.InstallerURL, installerPath, attestationDispatcherPublishWorkflowPath); err != nil { + return "", err + } return installerPath, nil } diff --git a/cli/dispatcher/internal/dispatcher/update_test.go b/cli/dispatcher/internal/dispatcher/update_test.go index a55ce78cf2..08af5ab57f 100644 --- a/cli/dispatcher/internal/dispatcher/update_test.go +++ b/cli/dispatcher/internal/dispatcher/update_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "io" "net/http" "path/filepath" @@ -205,6 +206,8 @@ func TestDownloadVerifiedUpdateInstallerRejectsChecksumMismatch(t *testing.T) { // Verifies update installers are not executable unless the downloaded checksum matches. restoreHTTPClient := stubUpdateInstallerHTTPClient([]byte("echo install\n"), []byte("bad install.sh\n")) defer restoreHTTPClient() + restoreAttestation := stubAttestationVerifyPasses() + defer restoreAttestation() _, err := downloadVerifiedUpdateInstaller(context.Background(), update.Command{ InstallerName: update.PosixScriptName, @@ -224,11 +227,14 @@ func TestDownloadVerifiedUpdateInstallerReturnsVerifiedFile(t *testing.T) { checksumContent := []byte(hex.EncodeToString(checksum[:]) + " install.sh\n") restoreHTTPClient := stubUpdateInstallerHTTPClient(installerContent, checksumContent) defer restoreHTTPClient() + restoreAttestation := stubAttestationVerifyPasses() + defer restoreAttestation() installerPath, err := downloadVerifiedUpdateInstaller(context.Background(), update.Command{ InstallerName: update.PosixScriptName, InstallerURL: "https://example.test/install.sh", InstallerChecksumURL: "https://example.test/install.sh.sha256", + ReleaseTag: "dispatcher-v9.9.9", }, t.TempDir()) if err != nil { t.Fatalf("downloadVerifiedUpdateInstaller failed: %v", err) @@ -240,6 +246,69 @@ func TestDownloadVerifiedUpdateInstallerReturnsVerifiedFile(t *testing.T) { } } +func TestDownloadVerifiedUpdateInstallerFailsClosedOnAttestationError(t *testing.T) { + // Verifies installers are rejected when attestation verification fails, even when the sha256 file matches. + installerContent := []byte("echo install\n") + checksum := sha256.Sum256(installerContent) + checksumContent := []byte(hex.EncodeToString(checksum[:]) + " install.sh\n") + restoreHTTPClient := stubUpdateInstallerHTTPClient(installerContent, checksumContent) + defer restoreHTTPClient() + restoreAttestation := stubAttestationVerifyReturns(fmt.Errorf("simulated attestation failure")) + defer restoreAttestation() + + _, err := downloadVerifiedUpdateInstaller(context.Background(), update.Command{ + InstallerName: update.PosixScriptName, + InstallerURL: "https://example.test/install.sh", + InstallerChecksumURL: "https://example.test/install.sh.sha256", + ReleaseTag: "dispatcher-v9.9.9", + }, t.TempDir()) + + if err == nil || !strings.Contains(err.Error(), "simulated attestation failure") { + t.Fatalf("expected attestation failure to fail closed, got %v", err) + } +} + +func TestDownloadVerifiedUpdateInstallerPassesInstallerIdentityToAttestation(t *testing.T) { + // Verifies the dispatcher-publish workflow SAN and dispatcher release tag are what get sent to the attestation hook. + installerContent := []byte("echo install\n") + checksum := sha256.Sum256(installerContent) + checksumContent := []byte(hex.EncodeToString(checksum[:]) + " install.sh\n") + restoreHTTPClient := stubUpdateInstallerHTTPClient(installerContent, checksumContent) + defer restoreHTTPClient() + + var seenReleaseTag string + var seenAssetURL string + var seenWorkflowPath string + previous := verifyReleaseAssetAttestation + verifyReleaseAssetAttestation = func(_ context.Context, releaseTag string, assetURL string, _ string, workflowPath string) error { + seenReleaseTag = releaseTag + seenAssetURL = assetURL + seenWorkflowPath = workflowPath + return nil + } + defer func() { + verifyReleaseAssetAttestation = previous + }() + + if _, err := downloadVerifiedUpdateInstaller(context.Background(), update.Command{ + InstallerName: update.PosixScriptName, + InstallerURL: "https://example.test/install.sh", + InstallerChecksumURL: "https://example.test/install.sh.sha256", + ReleaseTag: "dispatcher-v3.0.1-beta.12", + }, t.TempDir()); err != nil { + t.Fatalf("downloadVerifiedUpdateInstaller failed: %v", err) + } + if seenReleaseTag != "dispatcher-v3.0.1-beta.12" { + t.Fatalf("attestation hook received wrong release tag: %s", seenReleaseTag) + } + if seenAssetURL != "https://example.test/install.sh" { + t.Fatalf("attestation hook received wrong asset URL: %s", seenAssetURL) + } + if seenWorkflowPath != attestationDispatcherPublishWorkflowPath { + t.Fatalf("attestation hook received wrong workflow path: %s", seenWorkflowPath) + } +} + func TestUpdateExecutionArgsRunsDownloadedInstallerFile(t *testing.T) { // Verifies update execution runs the verified installer path directly. posixArgs := updateExecutionArgs(update.Command{Name: "sh"}, "/tmp/install.sh") @@ -341,6 +410,20 @@ func stubManualUpdateHooks(t *testing.T, updatedVersion string) func() { } } +func stubAttestationVerifyPasses() func() { + return stubAttestationVerifyReturns(nil) +} + +func stubAttestationVerifyReturns(returnErr error) func() { + previous := verifyReleaseAssetAttestation + verifyReleaseAssetAttestation = func(context.Context, string, string, string, string) error { + return returnErr + } + return func() { + verifyReleaseAssetAttestation = previous + } +} + func stubUpdateInstallerHTTPClient(installerContent []byte, checksumContent []byte) func() { previousHTTPClient := dispatcherHTTPClient dispatcherHTTPClient = &http.Client{ diff --git a/cli/dispatcher/internal/update/command.go b/cli/dispatcher/internal/update/command.go index e9337f2c3e..c0beb6992e 100644 --- a/cli/dispatcher/internal/update/command.go +++ b/cli/dispatcher/internal/update/command.go @@ -23,6 +23,12 @@ type Command struct { InstallerName string InstallerURL string InstallerChecksumURL string + // ReleaseTag is the dispatcher release tag the InstallerURL resolves to + // (e.g. "dispatcher-v3.0.1-beta.12"). The attestation verifier resolves + // this to a commit SHA and binds it to the certificate's Source Repository + // Digest extension so a stolen OIDC token cannot be reused on an unrelated + // tag. + ReleaseTag string } func CommandForOS(goos string, options Options) (Command, error) { @@ -46,6 +52,7 @@ func commandForScript(name string, scriptName string, version string, updateSele InstallerName: scriptName, InstallerURL: installerURL, InstallerChecksumURL: installerURL + ".sha256", + ReleaseTag: DispatcherReleaseTag(version), } } From 16d5b7851538a8e2ae8b4312fecd8593061e34ac Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 13:06:08 +0900 Subject: [PATCH 2/2] fix(dispatcher): Reject empty release tag before hitting git-refs API Follow-up to Fable 5 review on PR #1671. The verifier previously relied on the git-refs endpoint returning 404 for an empty tag, which meant correctness depended on remote behavior for what should be a local contract violation (this repo's CLAUDE.md standardizes on contract programming). Fail closed locally with ErrVerificationFailed and add a test so a future refactor cannot silently regress the guard. --- .../internal/dispatcher/attestation_verify.go | 3 ++ .../dispatcher/attestation_verify_test.go | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 cli/dispatcher/internal/dispatcher/attestation_verify_test.go diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify.go b/cli/dispatcher/internal/dispatcher/attestation_verify.go index bdaf32df7b..9a6d6313bf 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_verify.go +++ b/cli/dispatcher/internal/dispatcher/attestation_verify.go @@ -38,6 +38,9 @@ var verifyReleaseAssetAttestation = defaultVerifyReleaseAssetAttestation // branch: bundle-missing, network-failure, digest-mismatch, identity-mismatch. // Callers must NOT run the asset when this returns a non-nil error. func defaultVerifyReleaseAssetAttestation(ctx context.Context, releaseTag string, assetURL string, assetPath string, workflowPath string) error { + if releaseTag == "" { + return fmt.Errorf("%w: releaseTag required to resolve the release's commit SHA", attestation.ErrVerificationFailed) + } digestHex, err := computeAssetSHA256Hex(assetPath) if err != nil { return fmt.Errorf("compute asset digest for attestation: %w", err) diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify_test.go b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go new file mode 100644 index 0000000000..65ed4bd9ef --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go @@ -0,0 +1,32 @@ +package dispatcher + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/attestation" +) + +func TestDefaultVerifyReleaseAssetAttestationRejectsEmptyTag(t *testing.T) { + // Verifies the attestation verifier refuses to hit the network when the caller passes an empty release tag — + // contract-programming safeguard so correctness never depends on the git-refs endpoint's 404 response for a + // caller bug that could be caught locally. + err := defaultVerifyReleaseAssetAttestation( + context.Background(), + "", + "https://example.test/install.sh", + "/tmp/install.sh", + attestationDispatcherPublishWorkflowPath, + ) + if err == nil { + t.Fatal("expected empty releaseTag to fail closed, got nil") + } + if !errors.Is(err, attestation.ErrVerificationFailed) { + t.Fatalf("expected ErrVerificationFailed sentinel, got %v", err) + } + if !strings.Contains(err.Error(), "releaseTag required") { + t.Fatalf("expected error to mention the missing field, got %v", err) + } +}