diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify.go b/cli/dispatcher/internal/dispatcher/attestation_verify.go new file mode 100644 index 0000000000..9a6d6313bf --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/attestation_verify.go @@ -0,0 +1,95 @@ +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 { + 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) + } + + 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/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) + } +} 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), } }