diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c1d49f2..8b60e17b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -472,6 +472,22 @@ jobs: find . -maxdepth 1 -type f ! -name 'sha256sums.txt' -print0 | sort -z | xargs -0 sha256sum > sha256sums.txt ) + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign checksum manifest + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + printf '%s' "$COSIGN_PRIVATE_KEY" > /tmp/cosign.key + cosign sign-blob \ + --key /tmp/cosign.key \ + --output-signature dist/sha256sums.txt.sig \ + dist/sha256sums.txt + rm -f /tmp/cosign.key + - name: Generate GitHub provenance attestations uses: actions/attest-build-provenance@v2 with: diff --git a/internal/cli/update.go b/internal/cli/update.go index 4a815f08..62f1bde4 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -142,6 +142,13 @@ func writeUpdateHuman(cmd *cobra.Command, result updateworkflow.Result) { } fmt.Fprintf(writer, "%s %s (%s)\n", style.Secondary.Render("checksum"), result.ChecksumAssetName, status) } + if result.SignatureAssetName != "" { + status := "available" + if result.SignatureVerified { + status = "verified" + } + fmt.Fprintf(writer, "%s %s (%s)\n", style.Secondary.Render("signature"), result.SignatureAssetName, status) + } if result.ReleaseURL != "" { fmt.Fprintf(writer, "%s %s\n", style.Secondary.Render("release"), result.ReleaseURL) } diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index 500722ee..e4b53166 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -3,14 +3,21 @@ package cli import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "testing" "time" "github.com/spf13/cobra" - "github.com/vriesdemichael/bitbucket-server-cli/internal/cli/style" "github.com/vriesdemichael/bitbucket-server-cli/internal/cli/jsonoutput" + "github.com/vriesdemichael/bitbucket-server-cli/internal/cli/style" apperrors "github.com/vriesdemichael/bitbucket-server-cli/internal/domain/errors" githubrelease "github.com/vriesdemichael/bitbucket-server-cli/internal/transport/githubrelease" updateworkflow "github.com/vriesdemichael/bitbucket-server-cli/internal/workflows/update" @@ -30,17 +37,54 @@ func (client updateCommandReleaseClient) Download(_ context.Context, assetURL st return client.downloads[assetURL], nil } +// cliTestKeyPair is an ECDSA P-256 key pair used in CLI-layer update tests to +// produce valid cosign-compatible signatures without depending on the update +// workflow's internal test helpers. +type cliTestKeyPair struct { + publicKeyPEM []byte + privateKey *ecdsa.PrivateKey +} + +func newCliTestKeyPair(t *testing.T) cliTestKeyPair { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("newCliTestKeyPair: %v", err) + } + pubBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("newCliTestKeyPair: marshal: %v", err) + } + return cliTestKeyPair{ + publicKeyPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}), + privateKey: key, + } +} + +func (kp cliTestKeyPair) sign(t *testing.T, content []byte) []byte { + t.Helper() + digest := sha256.Sum256(content) + sig, err := ecdsa.SignASN1(rand.Reader, kp.privateKey, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return []byte(base64.StdEncoding.EncodeToString(sig)) +} + func TestUpdateCommandJSONDryRun(t *testing.T) { t.Setenv("BB_REQUEST_TIMEOUT", "") t.Setenv("BB_CA_FILE", "") t.Setenv("BB_INSECURE_SKIP_VERIFY", "") + kp := newCliTestKeyPair(t) + archiveChecksum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + checksumContent := []byte(fmt.Sprintf("%s %s\n", archiveChecksum, "bb_1.2.0_linux_amd64.tar.gz")) + originalFactory := updateRunnerFactory defer func() { updateRunnerFactory = originalFactory }() - archiveChecksum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" updateRunnerFactory = func(version string, httpConfig updateCommandHTTPConfig) *updateworkflow.Runner { if httpConfig.requestTimeout != defaultUpdateRequestTimeout { t.Fatalf("expected default request timeout, got %s", httpConfig.requestTimeout) @@ -53,17 +97,20 @@ func TestUpdateCommandJSONDryRun(t *testing.T) { Assets: []githubrelease.Asset{ {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.test/bb_1.2.0_linux_amd64.tar.gz"}, {Name: "sha256sums.txt", BrowserDownloadURL: "https://example.test/sha256sums.txt"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "https://example.test/sha256sums.txt.sig"}, }, }, downloads: map[string][]byte{ - "https://example.test/sha256sums.txt": []byte(fmt.Sprintf("%s %s\n", archiveChecksum, "bb_1.2.0_linux_amd64.tar.gz")), + "https://example.test/sha256sums.txt": checksumContent, + "https://example.test/sha256sums.txt.sig": kp.sign(t, checksumContent), }, }, - RepositoryOwner: "vriesdemichael", - RepositoryName: "bitbucket-server-cli", - CurrentVersion: func() string { return version }, - ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, - Platform: func() (string, string) { return "linux", "amd64" }, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return version }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, }) } @@ -101,6 +148,9 @@ func TestUpdateCommandJSONDryRun(t *testing.T) { if result.AssetName != "bb_1.2.0_linux_amd64.tar.gz" { t.Fatalf("expected asset name in result, got %+v", result) } + if !result.SignatureVerified { + t.Fatalf("expected signature_verified in result, got %+v", result) + } } func TestUpdateCommandHumanOutputAndValidation(t *testing.T) { @@ -120,6 +170,9 @@ func TestUpdateCommandHumanOutputAndValidation(t *testing.T) { }) t.Run("human dry run output", func(t *testing.T) { + kp := newCliTestKeyPair(t) + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") + originalFactory := updateRunnerFactory defer func() { updateRunnerFactory = originalFactory @@ -137,17 +190,20 @@ func TestUpdateCommandHumanOutputAndValidation(t *testing.T) { Assets: []githubrelease.Asset{ {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.test/bb_1.2.0_linux_amd64.tar.gz"}, {Name: "sha256sums.txt", BrowserDownloadURL: "https://example.test/sha256sums.txt"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "https://example.test/sha256sums.txt.sig"}, }, }, downloads: map[string][]byte{ - "https://example.test/sha256sums.txt": []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n"), + "https://example.test/sha256sums.txt": checksumContent, + "https://example.test/sha256sums.txt.sig": kp.sign(t, checksumContent), }, }, - RepositoryOwner: "vriesdemichael", - RepositoryName: "bitbucket-server-cli", - CurrentVersion: func() string { return version }, - ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, - Platform: func() (string, string) { return "linux", "amd64" }, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return version }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, }) } @@ -166,6 +222,9 @@ func TestUpdateCommandHumanOutputAndValidation(t *testing.T) { if !bytes.Contains(buffer.Bytes(), []byte("Dry-run (static, capability=full)")) || !bytes.Contains(buffer.Bytes(), []byte("Update available")) || !bytes.Contains(buffer.Bytes(), []byte("planned_action replace")) { t.Fatalf("unexpected human output: %s", output) } + if !bytes.Contains(buffer.Bytes(), []byte("signature sha256sums.txt.sig (verified)")) { + t.Fatalf("expected signature verified in output: %s", output) + } }) t.Run("up to date human output", func(t *testing.T) { @@ -182,8 +241,8 @@ func TestUpdateCommandHumanOutputAndValidation(t *testing.T) { buffer := &bytes.Buffer{} command := &cobra.Command{} command.SetOut(buffer) - writeUpdateHuman(command, updateworkflow.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.2.0", Applied: true, AssetName: "bb.tgz", InstallPath: "/tmp/bb", ChecksumAssetName: "sha256sums.txt", ChecksumVerified: true, ReleaseURL: "https://example.test/releases/v1.2.0"}) - if !bytes.Contains(buffer.Bytes(), []byte("Updated bb")) || !bytes.Contains(buffer.Bytes(), []byte("checksum sha256sums.txt (verified)")) { + writeUpdateHuman(command, updateworkflow.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.2.0", Applied: true, AssetName: "bb.tgz", InstallPath: "/tmp/bb", ChecksumAssetName: "sha256sums.txt", ChecksumVerified: true, SignatureAssetName: "sha256sums.txt.sig", SignatureVerified: true, ReleaseURL: "https://example.test/releases/v1.2.0"}) + if !bytes.Contains(buffer.Bytes(), []byte("Updated bb")) || !bytes.Contains(buffer.Bytes(), []byte("checksum sha256sums.txt (verified)")) || !bytes.Contains(buffer.Bytes(), []byte("signature sha256sums.txt.sig (verified)")) { t.Fatalf("unexpected human output: %s", buffer.String()) } }) diff --git a/internal/workflows/update/runner.go b/internal/workflows/update/runner.go index 6b3aee49..22f94cbf 100644 --- a/internal/workflows/update/runner.go +++ b/internal/workflows/update/runner.go @@ -6,8 +6,12 @@ import ( "bytes" "compress/gzip" "context" + "crypto/ecdsa" "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/hex" + "encoding/pem" "fmt" "io" "io/fs" @@ -22,6 +26,20 @@ import ( githubrelease "github.com/vriesdemichael/bitbucket-server-cli/internal/transport/githubrelease" ) +// embeddedCosignPublicKeyPEM is the ECDSA P-256 public key used to verify +// the cosign signature on sha256sums.txt for each release. The corresponding +// private key is stored as the COSIGN_PRIVATE_KEY GitHub Actions secret and +// is never committed to the repository. +// +// To rotate the key: generate a new key pair, replace this constant with the +// new public key, store the new private key as the COSIGN_PRIVATE_KEY secret, +// and cut a new release. +const embeddedCosignPublicKeyPEM = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJIPtQPuffLo6RupWgwj1Mr7SKRD3 +wdS61XrtOXvMVxBDKAp2JS2vxKv02rMwc38bGyt30D4NlywI3nQiiXR5hg== +-----END PUBLIC KEY----- +` + type ReleaseClient interface { Latest(ctx context.Context, owner, repo string) (githubrelease.Release, error) Download(ctx context.Context, assetURL string) ([]byte, error) @@ -45,6 +63,8 @@ type Result struct { ChecksumAssetName string `json:"checksum_asset_name,omitempty"` ChecksumAvailable bool `json:"checksum_available"` ChecksumVerified bool `json:"checksum_verified"` + SignatureAssetName string `json:"signature_asset_name,omitempty"` + SignatureVerified bool `json:"signature_verified"` CurrentVersionComparable bool `json:"current_version_comparable"` LatestVersionComparable bool `json:"latest_version_comparable"` TargetPlatform string `json:"target_platform,omitempty"` @@ -53,23 +73,27 @@ type Result struct { } type Runner struct { - releases ReleaseClient - owner string - repo string - currentVersion func() string - executablePath func() (string, error) - platform func() (string, string) - writeBinary func(string, []byte, fs.FileMode) error + releases ReleaseClient + owner string + repo string + currentVersion func() string + executablePath func() (string, error) + platform func() (string, string) + writeBinary func(string, []byte, fs.FileMode) error + checksumPublicKey []byte } type Dependencies struct { - Releases ReleaseClient - RepositoryOwner string - RepositoryName string - CurrentVersion func() string - ExecutablePath func() (string, error) - Platform func() (string, string) - WriteBinary func(string, []byte, fs.FileMode) error + Releases ReleaseClient + RepositoryOwner string + RepositoryName string + CurrentVersion func() string + ExecutablePath func() (string, error) + Platform func() (string, string) + WriteBinary func(string, []byte, fs.FileMode) error + // ChecksumPublicKeyPEM overrides the embedded cosign public key. It is + // intended for testing only; leave nil in production to use the compiled-in key. + ChecksumPublicKeyPEM []byte } func NewRunner(deps Dependencies) *Runner { @@ -93,14 +117,20 @@ func NewRunner(deps Dependencies) *Runner { writeBinary = replaceBinary } + checksumPublicKey := deps.ChecksumPublicKeyPEM + if len(checksumPublicKey) == 0 { + checksumPublicKey = []byte(embeddedCosignPublicKeyPEM) + } + return &Runner{ - releases: deps.Releases, - owner: strings.TrimSpace(deps.RepositoryOwner), - repo: strings.TrimSpace(deps.RepositoryName), - currentVersion: currentVersion, - executablePath: executablePath, - platform: platform, - writeBinary: writeBinary, + releases: deps.Releases, + owner: strings.TrimSpace(deps.RepositoryOwner), + repo: strings.TrimSpace(deps.RepositoryName), + currentVersion: currentVersion, + executablePath: executablePath, + platform: platform, + writeBinary: writeBinary, + checksumPublicKey: checksumPublicKey, } } @@ -167,9 +197,15 @@ func (runner *Runner) Run(ctx context.Context, options Options) (Result, error) return Result{}, apperrors.New(apperrors.KindNotFound, "release checksum file sha256sums.txt was not found", nil) } + sigAsset, ok := findAsset(release.Assets, "sha256sums.txt.sig") + if !ok { + return Result{}, apperrors.New(apperrors.KindNotFound, "release checksum signature sha256sums.txt.sig was not found; the release may predate signed checksums or the signature was not uploaded", nil) + } + result.AssetName = asset.Name result.AssetURL = asset.BrowserDownloadURL result.ChecksumAssetName = checksumAsset.Name + result.SignatureAssetName = sigAsset.Name result.PlannedAction = plannedAction(goos) checksumsRaw, err := runner.releases.Download(ctx, checksumAsset.BrowserDownloadURL) @@ -177,6 +213,16 @@ func (runner *Runner) Run(ctx context.Context, options Options) (Result, error) return Result{}, err } + sigRaw, err := runner.releases.Download(ctx, sigAsset.BrowserDownloadURL) + if err != nil { + return Result{}, err + } + + if err := verifyChecksumSignature(runner.checksumPublicKey, checksumsRaw, sigRaw); err != nil { + return Result{}, apperrors.New(apperrors.KindPermanent, "checksum signature verification failed: the sha256sums.txt signature is invalid", err) + } + result.SignatureVerified = true + checksums, err := parseChecksums(checksumsRaw) if err != nil { return Result{}, err @@ -427,6 +473,39 @@ func replaceBinary(targetPath string, binary []byte, mode fs.FileMode) error { return nil } +// verifyChecksumSignature verifies that signatureData is a valid cosign +// (ECDSA P-256) signature over checksumContent using the embedded public key. +// signatureData must be the base64-encoded DER ECDSA signature as produced by +// "cosign sign-blob --output-signature". +func verifyChecksumSignature(publicKeyPEM, checksumContent, signatureData []byte) error { + block, _ := pem.Decode(publicKeyPEM) + if block == nil { + return apperrors.New(apperrors.KindInternal, "failed to decode embedded public key PEM", nil) + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return apperrors.New(apperrors.KindInternal, "failed to parse embedded public key", err) + } + + ecKey, ok := pub.(*ecdsa.PublicKey) + if !ok { + return apperrors.New(apperrors.KindInternal, "embedded public key is not an ECDSA key", nil) + } + + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(signatureData))) + if err != nil { + return apperrors.New(apperrors.KindPermanent, "failed to decode checksum signature (expected base64)", err) + } + + digest := sha256.Sum256(checksumContent) + if !ecdsa.VerifyASN1(ecKey, digest[:], sig) { + return apperrors.New(apperrors.KindPermanent, "ECDSA signature does not match", nil) + } + + return nil +} + func sha256Hex(raw []byte) string { hash := sha256.Sum256(raw) return hex.EncodeToString(hash[:]) diff --git a/internal/workflows/update/runner_test.go b/internal/workflows/update/runner_test.go index 64b6c85a..6c142c64 100644 --- a/internal/workflows/update/runner_test.go +++ b/internal/workflows/update/runner_test.go @@ -6,6 +6,13 @@ import ( "bytes" "compress/gzip" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" "fmt" "io/fs" "os" @@ -16,6 +23,42 @@ import ( githubrelease "github.com/vriesdemichael/bitbucket-server-cli/internal/transport/githubrelease" ) +// testKeyPair holds a generated ECDSA P-256 key pair used in tests to produce +// valid (or deliberately invalid) cosign-compatible signatures. +type testKeyPair struct { + publicKeyPEM []byte + privateKey *ecdsa.PrivateKey +} + +// newTestKeyPair generates a fresh ECDSA P-256 key pair for a test. +func newTestKeyPair(t *testing.T) testKeyPair { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("newTestKeyPair: generate: %v", err) + } + pubBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("newTestKeyPair: marshal public key: %v", err) + } + return testKeyPair{ + publicKeyPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}), + privateKey: key, + } +} + +// sign produces a base64-encoded ECDSA P-256 signature over the SHA-256 hash +// of content, matching the format written by "cosign sign-blob --output-signature". +func (kp testKeyPair) sign(t *testing.T, content []byte) []byte { + t.Helper() + digest := sha256.Sum256(content) + sig, err := ecdsa.SignASN1(rand.Reader, kp.privateKey, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return []byte(base64.StdEncoding.EncodeToString(sig)) +} + type stubReleaseClient struct { release githubrelease.Release latestErr error @@ -39,6 +82,7 @@ func (stub *stubReleaseClient) Download(_ context.Context, assetURL string) ([]b } func TestRunnerDryRunPlansUpdateWithoutWritingBinary(t *testing.T) { + kp := newTestKeyPair(t) archive := buildTarGzArchive(t, "bb", []byte("new-binary")) checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz") @@ -49,21 +93,24 @@ func TestRunnerDryRunPlansUpdateWithoutWritingBinary(t *testing.T) { Assets: []githubrelease.Asset{ {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.test/bb_1.2.0_linux_amd64.tar.gz"}, {Name: "sha256sums.txt", BrowserDownloadURL: "https://example.test/sha256sums.txt"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "https://example.test/sha256sums.txt.sig"}, }, }, downloads: map[string][]byte{ - "https://example.test/sha256sums.txt": []byte(checksum), + "https://example.test/sha256sums.txt": []byte(checksum), + "https://example.test/sha256sums.txt.sig": kp.sign(t, []byte(checksum)), }, } written := false runner := NewRunner(Dependencies{ - Releases: client, - RepositoryOwner: "vriesdemichael", - RepositoryName: "bitbucket-server-cli", - CurrentVersion: func() string { return "v1.1.0" }, - ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, - Platform: func() (string, string) { return "linux", "amd64" }, + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, WriteBinary: func(string, []byte, fs.FileMode) error { written = true return nil @@ -83,12 +130,16 @@ func TestRunnerDryRunPlansUpdateWithoutWritingBinary(t *testing.T) { if !result.ChecksumAvailable || result.ChecksumVerified { t.Fatalf("expected checksum to be available but not verified, got %+v", result) } - if len(client.downloadCalls) != 1 || client.downloadCalls[0] != "https://example.test/sha256sums.txt" { + if result.SignatureAssetName != "sha256sums.txt.sig" || !result.SignatureVerified { + t.Fatalf("expected signature to be verified, got %+v", result) + } + if len(client.downloadCalls) != 2 { t.Fatalf("unexpected dry-run downloads: %+v", client.downloadCalls) } } func TestRunnerAppliesReleaseUpdate(t *testing.T) { + kp := newTestKeyPair(t) targetDir := t.TempDir() targetPath := filepath.Join(targetDir, "bb") if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil { @@ -105,29 +156,32 @@ func TestRunnerAppliesReleaseUpdate(t *testing.T) { Assets: []githubrelease.Asset{ {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.test/bb_1.2.0_linux_amd64.tar.gz"}, {Name: "sha256sums.txt", BrowserDownloadURL: "https://example.test/sha256sums.txt"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "https://example.test/sha256sums.txt.sig"}, }, }, downloads: map[string][]byte{ "https://example.test/sha256sums.txt": []byte(checksum), + "https://example.test/sha256sums.txt.sig": kp.sign(t, []byte(checksum)), "https://example.test/bb_1.2.0_linux_amd64.tar.gz": archive, }, } runner := NewRunner(Dependencies{ - Releases: client, - RepositoryOwner: "vriesdemichael", - RepositoryName: "bitbucket-server-cli", - CurrentVersion: func() string { return "v1.1.0" }, - ExecutablePath: func() (string, error) { return targetPath, nil }, - Platform: func() (string, string) { return "linux", "amd64" }, + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return targetPath, nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, }) result, err := runner.Run(context.Background(), Options{}) if err != nil { t.Fatalf("Run returned error: %v", err) } - if !result.Applied || !result.ChecksumVerified { - t.Fatalf("expected applied verified result, got %+v", result) + if !result.Applied || !result.ChecksumVerified || !result.SignatureVerified { + t.Fatalf("expected applied, checksum-verified, and signature-verified result, got %+v", result) } updated, err := os.ReadFile(targetPath) if err != nil { @@ -136,8 +190,8 @@ func TestRunnerAppliesReleaseUpdate(t *testing.T) { if string(updated) != "new-binary" { t.Fatalf("expected updated binary contents, got %q", string(updated)) } - if len(client.downloadCalls) != 2 { - t.Fatalf("expected two downloads, got %+v", client.downloadCalls) + if len(client.downloadCalls) != 3 { + t.Fatalf("expected three downloads, got %+v", client.downloadCalls) } } @@ -292,15 +346,32 @@ func TestRunnerValidationAndErrorPaths(t *testing.T) { } func TestRunnerUpdateErrorCases(t *testing.T) { + kp := newTestKeyPair(t) + baseRelease := githubrelease.Release{ TagName: "v1.2.0", - Assets: []githubrelease.Asset{{Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}, {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}}, + Assets: []githubrelease.Asset{ + {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}, + {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "sig"}, + }, + } + + newRunner := func(client *stubReleaseClient) *Runner { + return NewRunner(Dependencies{ + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, + }) } t.Run("missing archive asset", func(t *testing.T) { client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{{Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}}}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindNotFound) { t.Fatalf("expected not found error, got %v", err) } @@ -308,17 +379,30 @@ func TestRunnerUpdateErrorCases(t *testing.T) { t.Run("missing checksum asset", func(t *testing.T) { client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{{Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}}}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindNotFound) { t.Fatalf("expected not found error, got %v", err) } }) + t.Run("missing signature asset", func(t *testing.T) { + client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{ + {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}, + {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}, + }}} + _, err := newRunner(client).Run(context.Background(), Options{}) + if !apperrors.IsKind(err, apperrors.KindNotFound) { + t.Fatalf("expected not found error for missing signature, got %v", err) + } + }) + t.Run("missing checksum entry", func(t *testing.T) { - client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{"checksums": []byte("deadbeef other.tar.gz\n")}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + checksumContent := []byte("deadbeef other.tar.gz\n") + client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": kp.sign(t, checksumContent), + }} + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindPermanent) { t.Fatalf("expected permanent error, got %v", err) } @@ -326,18 +410,68 @@ func TestRunnerUpdateErrorCases(t *testing.T) { t.Run("checksum download failure", func(t *testing.T) { client := &stubReleaseClient{release: baseRelease, downloadErrs: map[string]error{"checksums": apperrors.New(apperrors.KindTransient, "download failed", nil)}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindTransient) { t.Fatalf("expected transient error, got %v", err) } }) + t.Run("signature download failure", func(t *testing.T) { + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") + client := &stubReleaseClient{ + release: baseRelease, + downloads: map[string][]byte{ + "checksums": checksumContent, + }, + downloadErrs: map[string]error{"sig": apperrors.New(apperrors.KindTransient, "download failed", nil)}, + } + _, err := newRunner(client).Run(context.Background(), Options{}) + if !apperrors.IsKind(err, apperrors.KindTransient) { + t.Fatalf("expected transient error for signature download failure, got %v", err) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + otherKP := newTestKeyPair(t) + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") + // Sign with a different key than the runner expects. + client := &stubReleaseClient{ + release: baseRelease, + downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": otherKP.sign(t, checksumContent), + }, + } + _, err := newRunner(client).Run(context.Background(), Options{}) + if !apperrors.IsKind(err, apperrors.KindPermanent) { + t.Fatalf("expected permanent error for invalid signature, got %v", err) + } + }) + + t.Run("malformed signature", func(t *testing.T) { + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") + client := &stubReleaseClient{ + release: baseRelease, + downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": []byte("not-valid-base64!!!"), + }, + } + _, err := newRunner(client).Run(context.Background(), Options{}) + if !apperrors.IsKind(err, apperrors.KindPermanent) { + t.Fatalf("expected permanent error for malformed signature, got %v", err) + } + }) + t.Run("checksum mismatch", func(t *testing.T) { + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") archive := buildTarGzArchive(t, "bb", []byte("new-binary")) - client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{"checksums": []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n"), "archive": archive}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": kp.sign(t, checksumContent), + "archive": archive, + }} + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindPermanent) { t.Fatalf("expected permanent error, got %v", err) } @@ -345,10 +479,13 @@ func TestRunnerUpdateErrorCases(t *testing.T) { t.Run("archive download failure", func(t *testing.T) { archive := buildTarGzArchive(t, "bb", []byte("new-binary")) - checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz") - client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{"checksums": []byte(checksum)}, downloadErrs: map[string]error{"archive": apperrors.New(apperrors.KindTransient, "download failed", nil)}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + checksumContent := []byte(fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz")) + client := &stubReleaseClient{ + release: baseRelease, + downloads: map[string][]byte{"checksums": checksumContent, "sig": kp.sign(t, checksumContent)}, + downloadErrs: map[string]error{"archive": apperrors.New(apperrors.KindTransient, "download failed", nil)}, + } + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindTransient) { t.Fatalf("expected transient error, got %v", err) } @@ -356,10 +493,13 @@ func TestRunnerUpdateErrorCases(t *testing.T) { t.Run("archive extraction failure", func(t *testing.T) { archive := []byte("not-an-archive") - checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz") - client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{"checksums": []byte(checksum), "archive": archive}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) - _, err := runner.Run(context.Background(), Options{}) + checksumContent := []byte(fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz")) + client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": kp.sign(t, checksumContent), + "archive": archive, + }} + _, err := newRunner(client).Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindPermanent) { t.Fatalf("expected permanent error, got %v", err) } @@ -367,9 +507,22 @@ func TestRunnerUpdateErrorCases(t *testing.T) { t.Run("write binary error", func(t *testing.T) { archive := buildTarGzArchive(t, "bb", []byte("new-binary")) - checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz") - client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{"checksums": []byte(checksum), "archive": archive}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }, WriteBinary: func(string, []byte, fs.FileMode) error { return apperrors.New(apperrors.KindInternal, "write failed", nil) }}) + checksumContent := []byte(fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_linux_amd64.tar.gz")) + client := &stubReleaseClient{release: baseRelease, downloads: map[string][]byte{ + "checksums": checksumContent, + "sig": kp.sign(t, checksumContent), + "archive": archive, + }} + runner := NewRunner(Dependencies{ + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, + WriteBinary: func(string, []byte, fs.FileMode) error { return apperrors.New(apperrors.KindInternal, "write failed", nil) }, + }) _, err := runner.Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindInternal) { t.Fatalf("expected internal error, got %v", err) @@ -378,15 +531,32 @@ func TestRunnerUpdateErrorCases(t *testing.T) { } func TestRunnerWindowsAndVersionComparisonPaths(t *testing.T) { + kp := newTestKeyPair(t) + t.Run("windows zip update", func(t *testing.T) { archive := buildZipArchive(t, "bb.exe", []byte("windows-binary")) - checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_windows_amd64.zip") - client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{{Name: "bb_1.2.0_windows_amd64.zip", BrowserDownloadURL: "archive"}, {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}}}, downloads: map[string][]byte{"checksums": []byte(checksum), "archive": archive}} + checksumContent := []byte(fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_windows_amd64.zip")) + client := &stubReleaseClient{ + release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{ + {Name: "bb_1.2.0_windows_amd64.zip", BrowserDownloadURL: "archive"}, + {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "sig"}, + }}, + downloads: map[string][]byte{"checksums": checksumContent, "sig": kp.sign(t, checksumContent), "archive": archive}, + } targetPath := filepath.Join(t.TempDir(), "bb.exe") if err := os.WriteFile(targetPath, []byte("old"), 0o755); err != nil { t.Fatalf("seed target: %v", err) } - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return targetPath, nil }, Platform: func() (string, string) { return "windows", "amd64" }}) + runner := NewRunner(Dependencies{ + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return targetPath, nil }, + Platform: func() (string, string) { return "windows", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, + }) result, err := runner.Run(context.Background(), Options{DryRun: true}) if err != nil { t.Fatalf("Run returned error: %v", err) @@ -398,9 +568,24 @@ func TestRunnerWindowsAndVersionComparisonPaths(t *testing.T) { t.Run("windows apply returns manual replacement error", func(t *testing.T) { archive := buildZipArchive(t, "bb.exe", []byte("windows-binary")) - checksum := fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_windows_amd64.zip") - client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{{Name: "bb_1.2.0_windows_amd64.zip", BrowserDownloadURL: "archive"}, {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}}}, downloads: map[string][]byte{"checksums": []byte(checksum), "archive": archive}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "v1.1.0" }, ExecutablePath: func() (string, error) { return "/tmp/bb.exe", nil }, Platform: func() (string, string) { return "windows", "amd64" }}) + checksumContent := []byte(fmt.Sprintf("%s %s\n", sha256Hex(archive), "bb_1.2.0_windows_amd64.zip")) + client := &stubReleaseClient{ + release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{ + {Name: "bb_1.2.0_windows_amd64.zip", BrowserDownloadURL: "archive"}, + {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "sig"}, + }}, + downloads: map[string][]byte{"checksums": checksumContent, "sig": kp.sign(t, checksumContent), "archive": archive}, + } + runner := NewRunner(Dependencies{ + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "v1.1.0" }, + ExecutablePath: func() (string, error) { return "/tmp/bb.exe", nil }, + Platform: func() (string, string) { return "windows", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, + }) _, err := runner.Run(context.Background(), Options{}) if !apperrors.IsKind(err, apperrors.KindPermanent) { t.Fatalf("expected permanent windows replacement error, got %v", err) @@ -420,8 +605,24 @@ func TestRunnerWindowsAndVersionComparisonPaths(t *testing.T) { }) t.Run("unknown current version", func(t *testing.T) { - client := &stubReleaseClient{release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{{Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}, {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}}}, downloads: map[string][]byte{"checksums": []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n")}} - runner := NewRunner(Dependencies{Releases: client, RepositoryOwner: "vriesdemichael", RepositoryName: "bitbucket-server-cli", CurrentVersion: func() string { return "dev" }, ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, Platform: func() (string, string) { return "linux", "amd64" }}) + checksumContent := []byte("deadbeef bb_1.2.0_linux_amd64.tar.gz\n") + client := &stubReleaseClient{ + release: githubrelease.Release{TagName: "v1.2.0", Assets: []githubrelease.Asset{ + {Name: "bb_1.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "archive"}, + {Name: "sha256sums.txt", BrowserDownloadURL: "checksums"}, + {Name: "sha256sums.txt.sig", BrowserDownloadURL: "sig"}, + }}, + downloads: map[string][]byte{"checksums": checksumContent, "sig": kp.sign(t, checksumContent)}, + } + runner := NewRunner(Dependencies{ + Releases: client, + RepositoryOwner: "vriesdemichael", + RepositoryName: "bitbucket-server-cli", + CurrentVersion: func() string { return "dev" }, + ExecutablePath: func() (string, error) { return "/tmp/bb", nil }, + Platform: func() (string, string) { return "linux", "amd64" }, + ChecksumPublicKeyPEM: kp.publicKeyPEM, + }) result, err := runner.Run(context.Background(), Options{DryRun: true}) if err != nil { t.Fatalf("Run returned error: %v", err) @@ -558,6 +759,51 @@ func TestUpdateHelpers(t *testing.T) { } } +func TestVerifyChecksumSignature(t *testing.T) { + kp := newTestKeyPair(t) + content := []byte("abc123 file.tar.gz\n") + sig := kp.sign(t, content) + + t.Run("valid signature", func(t *testing.T) { + if err := verifyChecksumSignature(kp.publicKeyPEM, content, sig); err != nil { + t.Fatalf("expected valid signature to pass, got %v", err) + } + }) + + t.Run("wrong key", func(t *testing.T) { + other := newTestKeyPair(t) + if err := verifyChecksumSignature(other.publicKeyPEM, content, sig); !apperrors.IsKind(err, apperrors.KindPermanent) { + t.Fatalf("expected permanent error for wrong key, got %v", err) + } + }) + + t.Run("tampered content", func(t *testing.T) { + if err := verifyChecksumSignature(kp.publicKeyPEM, []byte("tampered"), sig); !apperrors.IsKind(err, apperrors.KindPermanent) { + t.Fatalf("expected permanent error for tampered content, got %v", err) + } + }) + + t.Run("malformed base64", func(t *testing.T) { + if err := verifyChecksumSignature(kp.publicKeyPEM, content, []byte("!!!not-base64")); !apperrors.IsKind(err, apperrors.KindPermanent) { + t.Fatalf("expected permanent error for malformed base64, got %v", err) + } + }) + + t.Run("invalid public key PEM", func(t *testing.T) { + if err := verifyChecksumSignature([]byte("not-a-pem"), content, sig); !apperrors.IsKind(err, apperrors.KindInternal) { + t.Fatalf("expected internal error for invalid PEM, got %v", err) + } + }) + + t.Run("non-ecdsa public key PEM", func(t *testing.T) { + // Construct a PEM block with invalid DER to force x509 parse failure. + badPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("not-der")}) + if err := verifyChecksumSignature(badPEM, content, sig); !apperrors.IsKind(err, apperrors.KindInternal) { + t.Fatalf("expected internal error for unparseable key, got %v", err) + } + }) +} + func TestReplaceBinary(t *testing.T) { t.Run("validation", func(t *testing.T) { if err := replaceBinary("", []byte("payload"), 0o755); !apperrors.IsKind(err, apperrors.KindValidation) {