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
36 changes: 30 additions & 6 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func getLatestTag() (string, error) {
return info.TagName, nil
}

func getAssetName() (asset string, platform string, err error) {
func getAssetName() (asset string, platform string, archName string, err error) {
osName := osruntime.GOOS
arch := osruntime.GOARCH
var ext string
Expand All @@ -72,9 +72,8 @@ func getAssetName() (asset string, platform string, err error) {
platform = "windows"
ext = ".zip"
default:
return "", "", fmt.Errorf("unsupported OS: %s", osName)
return "", "", "", fmt.Errorf("unsupported OS: %s", osName)
}
var archName string
switch arch {
case "amd64", "x86_64":
archName = "amd64"
Expand All @@ -85,10 +84,10 @@ func getAssetName() (asset string, platform string, err error) {
archName = "arm64"
}
default:
return "", "", fmt.Errorf("unsupported architecture: %s", arch)
return "", "", "", fmt.Errorf("unsupported architecture: %s", arch)
}
asset = fmt.Sprintf("%s_%s_%s%s", cliName, platform, archName, ext)
return asset, platform, nil
return asset, platform, archName, nil
}

func downloadFile(url, dest, message string) error {
Expand Down Expand Up @@ -336,7 +335,7 @@ func Run(currentVersion string) error {
}

// If we're here, an update is needed.
asset, _, err := getAssetName()
asset, platform, archName, err := getAssetName()
if err != nil {
spinner.Stop()
return fmt.Errorf("error determining asset name: %w", err)
Expand Down Expand Up @@ -368,6 +367,24 @@ func Run(currentVersion string) error {
return fmt.Errorf("extraction failed: %w", err)
}

var sigPath string
if platform == "linux" {
sigAsset := getSigAssetName(platform, archName)
sigPath = filepath.Join(tmpDir, sigAsset)
sigURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, sigAsset)
sigDownloadMsg := fmt.Sprintf("Downloading signature for %s...", tag)
if err := downloadFile(sigURL, sigPath, sigDownloadMsg); err != nil {
spinner.Stop()
return fmt.Errorf("signature download failed: %w", err)
}
}

spinner.Update("Verifying release signature...")
if err := verifyReleaseBinary(binPath, sigPath); err != nil {
spinner.Stop()
return fmt.Errorf("release signature verification failed: %w", err)
}

spinner.Update("Installing...")
if err := os.Chmod(binPath, 0755); err != nil {
spinner.Stop()
Expand Down Expand Up @@ -396,6 +413,13 @@ func New(_ *runtime.Context) *cobra.Command { // <-- No longer uses rt
var versionCmd = &cobra.Command{
Use: "update",
Short: "Update the cre CLI to the latest version",
Long: `Update the cre CLI to the latest version

Release signatures are verified using the public key published by the CRE team.

On Linux, the signature is verified using GPG.
On macOS, the signature is verified using codesign.
On Windows, the signature is verified using Authenticode.`,
RunE: func(cmd *cobra.Command, args []string) error {
return Run(version.Version)
},
Expand Down
88 changes: 88 additions & 0 deletions cmd/update/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package update

import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"encoding/json"
"net/http"
"path/filepath"
"testing"

"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/require"
)

func TestRun_abortsWhenSignatureVerificationFails(t *testing.T) {
httpmock.ActivateNonDefault(httpClient)
t.Cleanup(httpmock.DeactivateAndReset)

asset, platform, archName, err := getAssetName()
require.NoError(t, err)

tag := "v99.0.0-test"
httpmock.RegisterResponder("GET", "https://api.github.com/repos/smartcontractkit/cre-cli/releases/latest",
func(_ *http.Request) (*http.Response, error) {
body, _ := json.Marshal(releaseInfo{TagName: tag})
return httpmock.NewBytesResponse(http.StatusOK, body), nil
},
)

archiveBytes := createTestArchiveBytes(t, asset, tag, platform, archName, []byte("#!/bin/sh\necho test\n"))

downloadURL := "https://github.com/smartcontractkit/cre-cli/releases/download/" + tag + "/" + asset
httpmock.RegisterResponder("GET", downloadURL,
func(_ *http.Request) (*http.Response, error) {
return httpmock.NewBytesResponse(http.StatusOK, archiveBytes), nil
},
)

if platform == "linux" {
sigAsset := getSigAssetName(platform, archName)
sigURL := "https://github.com/smartcontractkit/cre-cli/releases/download/" + tag + "/" + sigAsset
httpmock.RegisterResponder("GET", sigURL,
func(_ *http.Request) (*http.Response, error) {
return httpmock.NewBytesResponse(http.StatusOK, []byte("invalid-signature")), nil
},
)
}

err = Run("version v0.0.1")
require.Error(t, err)
require.Contains(t, err.Error(), "release signature verification failed")
}

func createTestArchiveBytes(t *testing.T, asset, tag, platform, archName string, content []byte) []byte {
t.Helper()
binName := "cre_" + tag + "_" + platform + "_" + archName
if platform == "windows" {
binName += ".exe"
}

if filepath.Ext(asset) == ".zip" {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
w, err := zw.Create(binName)
require.NoError(t, err)
_, err = w.Write(content)
require.NoError(t, err)
require.NoError(t, zw.Close())
return buf.Bytes()
}

buf := &bytes.Buffer{}
gz := gzip.NewWriter(buf)
tw := tar.NewWriter(gz)
hdr := &tar.Header{
Name: binName,
Mode: 0755,
Size: int64(len(content)),
}
require.NoError(t, tw.WriteHeader(hdr))
_, err := tw.Write(content)
require.NoError(t, err)
require.NoError(t, tw.Close())
require.NoError(t, gz.Close())
return buf.Bytes()
}
11 changes: 11 additions & 0 deletions cmd/update/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package update

const (
expectedSignerName = "CRE"
expectedSignerEmail = "cre@smartcontract.com"
codesignIdentifier = "com.smartcontract.cre.cli"
)

func getSigAssetName(platform, archName string) string {
return "cre_" + platform + "_" + archName + ".sig"
}
24 changes: 24 additions & 0 deletions cmd/update/verify_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build darwin

package update

import (
"bytes"
"fmt"
"os/exec"
"strings"
)

func verifyReleaseBinary(binPath, _ string) error {
cmd := exec.Command("codesign", "--verify", "--strict", "--identifier", codesignIdentifier, binPath) // #nosec G204 -- fixed args, user-controlled path only
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("codesign verification failed: %s: %w", msg, err)
}
return fmt.Errorf("codesign verification failed: %w", err)
}
return nil
}
93 changes: 93 additions & 0 deletions cmd/update/verify_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//go:build linux

//nolint:staticcheck // SA1019: OpenPGP required to verify KMS GPG release signatures.
package update

import (
"bytes"
"fmt"
"io"
"os"
"strings"

"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"

"github.com/smartcontractkit/cre-cli/install"
)

func verifyReleaseBinary(binPath, sigPath string) error {
if sigPath == "" {
return fmt.Errorf("missing signature file path")
}
return verifyGPGSignature(install.ReleasePublicKey, binPath, sigPath)
}

func verifyGPGSignature(publicKey []byte, binPath, sigPath string) error {
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(publicKey))
if err != nil {
return fmt.Errorf("parse release public key: %w", err)
}

signed, err := os.Open(binPath) // #nosec G703 -- path from controlled temp extraction
if err != nil {
return fmt.Errorf("open binary: %w", err)
}
defer signed.Close()

sigBytes, err := os.ReadFile(sigPath) // #nosec G703 -- path from controlled temp download
if err != nil {
return fmt.Errorf("read signature: %w", err)
}

entity, err := checkDetachedSignature(keyring, signed, sigBytes)
if err != nil {
return fmt.Errorf("GPG signature invalid: %w", err)
}

return validateSignerIdentity(entity)
}

func checkDetachedSignature(keyring openpgp.KeyRing, signed *os.File, sigBytes []byte) (*openpgp.Entity, error) {
if _, err := signed.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("rewind binary: %w", err)
}

sigReader := bytes.NewReader(sigBytes)
if block, _ := armor.Decode(sigReader); block != nil {
if _, err := signed.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("rewind binary: %w", err)
}
entity, err := openpgp.CheckDetachedSignature(keyring, signed, block.Body)
if err != nil {
return nil, err
}
return entity, nil
}

if _, err := signed.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("rewind binary: %w", err)
}
entity, err := openpgp.CheckArmoredDetachedSignature(keyring, signed, bytes.NewReader(sigBytes))
if err != nil {
return nil, err
}
return entity, nil
}

func validateSignerIdentity(entity *openpgp.Entity) error {
if entity == nil {
return fmt.Errorf("missing signer identity")
}
for _, identity := range entity.Identities {
email := ""
if identity.UserId != nil {
email = identity.UserId.Email
}
if strings.Contains(identity.Name, expectedSignerName) &&
strings.EqualFold(email, expectedSignerEmail) {
return nil
}
}
return fmt.Errorf("unexpected signer identity")
}
Loading
Loading