Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
91 changes: 75 additions & 16 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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,
})
}

Expand All @@ -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) {
Expand All @@ -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())
}
})
Expand Down
121 changes: 100 additions & 21 deletions internal/workflows/update/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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"`
Expand All @@ -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 {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -167,16 +197,32 @@ 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)
if err != nil {
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
Expand Down Expand Up @@ -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[:])
Expand Down
Loading