Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions cli/dispatcher/internal/dispatcher/attestation_verify.go
Original file line number Diff line number Diff line change
@@ -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 `<assetURL>.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
}
32 changes: 32 additions & 0 deletions cli/dispatcher/internal/dispatcher/attestation_verify_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions cli/dispatcher/internal/dispatcher/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
83 changes: 83 additions & 0 deletions cli/dispatcher/internal/dispatcher/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"path/filepath"
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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{
Expand Down
7 changes: 7 additions & 0 deletions cli/dispatcher/internal/update/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -46,6 +52,7 @@ func commandForScript(name string, scriptName string, version string, updateSele
InstallerName: scriptName,
InstallerURL: installerURL,
InstallerChecksumURL: installerURL + ".sha256",
ReleaseTag: DispatcherReleaseTag(version),
}
}

Expand Down
Loading