Move a bunch of update-specific code out of the generic lib.go file and into the update command

This commit is contained in:
David Dworken 2023-09-13 22:45:49 -07:00
parent f3e4a4e1e6
commit 6d6a1a5e12
No known key found for this signature in database
3 changed files with 245 additions and 235 deletions

View File

@ -1,8 +1,21 @@
package cmd
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"runtime"
"syscall"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/client/hctx"
"github.com/ddworken/hishtory/client/lib"
"github.com/ddworken/hishtory/shared"
"github.com/spf13/cobra"
)
@ -10,10 +23,232 @@ var updateCmd = &cobra.Command{
Use: "update",
Short: "Securely update hishtory to the latest version",
Run: func(cmd *cobra.Command, args []string) {
lib.CheckFatalError(lib.Update(hctx.MakeContext()))
lib.CheckFatalError(update(hctx.MakeContext()))
},
}
func update(ctx context.Context) error {
// Download the binary
downloadData, err := lib.GetDownloadData()
if err != nil {
return err
}
if downloadData.Version == "v0."+lib.Version {
fmt.Printf("Latest version (v0.%s) is already installed\n", lib.Version)
return nil
}
err = downloadFiles(downloadData)
if err != nil {
return err
}
// Verify the SLSA attestation
var slsaError error
if runtime.GOOS == "darwin" {
slsaError = verifyBinaryMac(ctx, getTmpClientPath(), downloadData)
} else {
slsaError = lib.VerifyBinary(ctx, getTmpClientPath(), getTmpClientPath()+".intoto.jsonl", downloadData.Version)
}
if slsaError != nil {
err = lib.HandleSlsaFailure(slsaError)
if err != nil {
return err
}
}
// Unlink the existing binary so we can overwrite it even though it is still running
if runtime.GOOS == "linux" {
homedir := hctx.GetHome(ctx)
err = syscall.Unlink(path.Join(homedir, data.GetHishtoryPath(), "hishtory"))
if err != nil {
return fmt.Errorf("failed to unlink %s for update: %w", path.Join(homedir, data.GetHishtoryPath(), "hishtory"), err)
}
}
// Install the new one
cmd := exec.Command("chmod", "+x", getTmpClientPath())
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to chmod +x the update (stdout=%#v, stderr=%#v): %w", stdout.String(), stderr.String(), err)
}
cmd = exec.Command(getTmpClientPath(), "install")
cmd.Stdout = os.Stdout
stderr = bytes.Buffer{}
cmd.Stdin = os.Stdin
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to install update (stderr=%#v), is %s in a noexec directory? (if so, set the TMPDIR environment variable): %w", stderr.String(), getTmpClientPath(), err)
}
fmt.Printf("Successfully updated hishtory from v0.%s to %s\n", lib.Version, downloadData.Version)
return nil
}
func verifyBinaryMac(ctx context.Context, binaryPath string, downloadData shared.UpdateInfo) error {
// On Mac, binary verification is a bit more complicated since mac binaries are code
// signed. To verify a signed binary, we:
// 1. Download the unsigned binary
// 2. Strip the real signature from the signed binary and the ad-hoc signature from the unsigned binary
// 3. Assert that those binaries match
// 4. Use SLSA to verify the unsigned binary (pre-strip)
// Yes, this is complicated. But AFAICT, it is the only solution here.
// Step 1: Download the "unsigned" binary that actually has an ad-hoc signature from the
// go compiler.
unsignedBinaryPath := binaryPath + "-unsigned"
var err error = nil
if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
err = downloadFile(unsignedBinaryPath, downloadData.DarwinAmd64UnsignedUrl)
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
err = downloadFile(unsignedBinaryPath, downloadData.DarwinArm64UnsignedUrl)
} else {
err = fmt.Errorf("verifyBinaryMac() called for the unhandled branch GOOS=%s, GOARCH=%s", runtime.GOOS, runtime.GOARCH)
}
if err != nil {
return err
}
// Step 2: Create the .nosig files that have no signatures whatsoever
noSigSuffix := ".nosig"
err = stripCodeSignature(binaryPath, binaryPath+noSigSuffix)
if err != nil {
return err
}
err = stripCodeSignature(unsignedBinaryPath, unsignedBinaryPath+noSigSuffix)
if err != nil {
return err
}
// Step 3: Compare the binaries
err = assertIdenticalBinaries(binaryPath+noSigSuffix, unsignedBinaryPath+noSigSuffix)
if err != nil {
return err
}
// Step 4: Use SLSA to verify the unsigned binary
return lib.VerifyBinary(ctx, unsignedBinaryPath, getTmpClientPath()+".intoto.jsonl", downloadData.Version)
}
func assertIdenticalBinaries(bin1Path, bin2Path string) error {
bin1, err := os.ReadFile(bin1Path)
if err != nil {
return err
}
bin2, err := os.ReadFile(bin2Path)
if err != nil {
return err
}
if len(bin1) != len(bin2) {
return fmt.Errorf("unsigned binaries have different lengths (len(%s)=%d, len(%s)=%d)", bin1Path, len(bin1), bin2Path, len(bin2))
}
differences := make([]string, 0)
for i := range bin1 {
b1 := bin1[i]
b2 := bin2[i]
if b1 != b2 {
differences = append(differences, fmt.Sprintf("diff at index %d: %s[%d]=%x, %s[%d]=%x", i, bin1Path, i, b1, bin2Path, i, b2))
}
}
for _, d := range differences {
hctx.GetLogger().Infof("comparing binaries: %#v\n", d)
}
if len(differences) > 5 {
return fmt.Errorf("found %d differences in the binary", len(differences))
}
return nil
}
func stripCodeSignature(inPath, outPath string) error {
_, err := exec.LookPath("codesign_allocate")
if err != nil {
return fmt.Errorf("your system is missing the codesign_allocate tool, so we can't verify the SLSA attestation (you can bypass this by setting `export HISHTORY_DISABLE_SLSA_ATTESTATION=true` in your shell)")
}
cmd := exec.Command("codesign_allocate", "-i", inPath, "-o", outPath, "-r")
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to use codesign_allocate to strip signatures on binary=%v (stdout=%#v, stderr%#v): %w", inPath, stdout.String(), stderr.String(), err)
}
return nil
}
func downloadFiles(updateInfo shared.UpdateInfo) error {
clientUrl := ""
clientProvenanceUrl := ""
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
clientUrl = updateInfo.LinuxAmd64Url
clientProvenanceUrl = updateInfo.LinuxAmd64AttestationUrl
} else if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
clientUrl = updateInfo.LinuxArm64Url
clientProvenanceUrl = updateInfo.LinuxArm64AttestationUrl
} else if runtime.GOOS == "linux" && runtime.GOARCH == "arm" {
clientUrl = updateInfo.LinuxArm7Url
clientProvenanceUrl = updateInfo.LinuxArm7AttestationUrl
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
clientUrl = updateInfo.DarwinAmd64Url
clientProvenanceUrl = updateInfo.DarwinAmd64AttestationUrl
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
clientUrl = updateInfo.DarwinArm64Url
clientProvenanceUrl = updateInfo.DarwinArm64AttestationUrl
} else {
return fmt.Errorf("no update info found for GOOS=%s, GOARCH=%s", runtime.GOOS, runtime.GOARCH)
}
err := downloadFile(getTmpClientPath(), clientUrl)
if err != nil {
return err
}
err = downloadFile(getTmpClientPath()+".intoto.jsonl", clientProvenanceUrl)
if err != nil {
return err
}
return nil
}
func getTmpClientPath() string {
tmpDir := "/tmp/"
if os.Getenv("TMPDIR") != "" {
tmpDir = os.Getenv("TMPDIR")
}
return path.Join(tmpDir, "hishtory-client")
}
func downloadFile(filename, url string) error {
// Download the data
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download file at %s to %s: %w", url, filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failed to download file at %s due to resp_code=%d", url, resp.StatusCode)
}
// Delete the file if it already exists. This is necessary due to https://openradar.appspot.com/FB8735191
if _, err := os.Stat(filename); err == nil {
err = os.Remove(filename)
if err != nil {
return fmt.Errorf("failed to delete file %v when trying to download a new version", filename)
}
}
// Create the file
out, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to save file to %s: %w", filename, err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func init() {
rootCmd.AddCommand(updateCmd)
}

View File

@ -12,14 +12,11 @@ import (
"math/rand"
"net/http"
"os"
"os/exec"
"os/user"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"time"
_ "embed" // for embedding config.sh
@ -383,6 +380,13 @@ func readFileToArray(path string) ([]string, error) {
return lines, nil
}
func getServerHostname() string {
if server := os.Getenv("HISHTORY_SERVER"); server != "" {
return server
}
return "https://api.hishtory.dev"
}
func GetDownloadData() (shared.UpdateInfo, error) {
respBody, err := ApiGet("/api/v1/download")
if err != nil {
@ -396,235 +400,6 @@ func GetDownloadData() (shared.UpdateInfo, error) {
return downloadData, nil
}
func getTmpClientPath() string {
tmpDir := "/tmp/"
if os.Getenv("TMPDIR") != "" {
tmpDir = os.Getenv("TMPDIR")
}
return path.Join(tmpDir, "hishtory-client")
}
func Update(ctx context.Context) error {
// Download the binary
downloadData, err := GetDownloadData()
if err != nil {
return err
}
if downloadData.Version == "v0."+Version {
fmt.Printf("Latest version (v0.%s) is already installed\n", Version)
return nil
}
err = downloadFiles(downloadData)
if err != nil {
return err
}
// Verify the SLSA attestation
var slsaError error
if runtime.GOOS == "darwin" {
slsaError = verifyBinaryMac(ctx, getTmpClientPath(), downloadData)
} else {
slsaError = verifyBinary(ctx, getTmpClientPath(), getTmpClientPath()+".intoto.jsonl", downloadData.Version)
}
if slsaError != nil {
err = handleSlsaFailure(slsaError)
if err != nil {
return err
}
}
// Unlink the existing binary so we can overwrite it even though it is still running
if runtime.GOOS == "linux" {
homedir := hctx.GetHome(ctx)
err = syscall.Unlink(path.Join(homedir, data.GetHishtoryPath(), "hishtory"))
if err != nil {
return fmt.Errorf("failed to unlink %s for update: %w", path.Join(homedir, data.GetHishtoryPath(), "hishtory"), err)
}
}
// Install the new one
cmd := exec.Command("chmod", "+x", getTmpClientPath())
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to chmod +x the update (stdout=%#v, stderr=%#v): %w", stdout.String(), stderr.String(), err)
}
cmd = exec.Command(getTmpClientPath(), "install")
cmd.Stdout = os.Stdout
stderr = bytes.Buffer{}
cmd.Stdin = os.Stdin
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to install update (stderr=%#v), is %s in a noexec directory? (if so, set the TMPDIR environment variable): %w", stderr.String(), getTmpClientPath(), err)
}
fmt.Printf("Successfully updated hishtory from v0.%s to %s\n", Version, downloadData.Version)
return nil
}
func verifyBinaryMac(ctx context.Context, binaryPath string, downloadData shared.UpdateInfo) error {
// On Mac, binary verification is a bit more complicated since mac binaries are code
// signed. To verify a signed binary, we:
// 1. Download the unsigned binary
// 2. Strip the real signature from the signed binary and the ad-hoc signature from the unsigned binary
// 3. Assert that those binaries match
// 4. Use SLSA to verify the unsigned binary (pre-strip)
// Yes, this is complicated. But AFAICT, it is the only solution here.
// Step 1: Download the "unsigned" binary that actually has an ad-hoc signature from the
// go compiler.
unsignedBinaryPath := binaryPath + "-unsigned"
var err error = nil
if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
err = downloadFile(unsignedBinaryPath, downloadData.DarwinAmd64UnsignedUrl)
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
err = downloadFile(unsignedBinaryPath, downloadData.DarwinArm64UnsignedUrl)
} else {
err = fmt.Errorf("verifyBinaryMac() called for the unhandled branch GOOS=%s, GOARCH=%s", runtime.GOOS, runtime.GOARCH)
}
if err != nil {
return err
}
// Step 2: Create the .nosig files that have no signatures whatsoever
noSigSuffix := ".nosig"
err = stripCodeSignature(binaryPath, binaryPath+noSigSuffix)
if err != nil {
return err
}
err = stripCodeSignature(unsignedBinaryPath, unsignedBinaryPath+noSigSuffix)
if err != nil {
return err
}
// Step 3: Compare the binaries
err = assertIdenticalBinaries(binaryPath+noSigSuffix, unsignedBinaryPath+noSigSuffix)
if err != nil {
return err
}
// Step 4: Use SLSA to verify the unsigned binary
return verifyBinary(ctx, unsignedBinaryPath, getTmpClientPath()+".intoto.jsonl", downloadData.Version)
}
func assertIdenticalBinaries(bin1Path, bin2Path string) error {
bin1, err := os.ReadFile(bin1Path)
if err != nil {
return err
}
bin2, err := os.ReadFile(bin2Path)
if err != nil {
return err
}
if len(bin1) != len(bin2) {
return fmt.Errorf("unsigned binaries have different lengths (len(%s)=%d, len(%s)=%d)", bin1Path, len(bin1), bin2Path, len(bin2))
}
differences := make([]string, 0)
for i := range bin1 {
b1 := bin1[i]
b2 := bin2[i]
if b1 != b2 {
differences = append(differences, fmt.Sprintf("diff at index %d: %s[%d]=%x, %s[%d]=%x", i, bin1Path, i, b1, bin2Path, i, b2))
}
}
for _, d := range differences {
hctx.GetLogger().Infof("comparing binaries: %#v\n", d)
}
if len(differences) > 5 {
return fmt.Errorf("found %d differences in the binary", len(differences))
}
return nil
}
func stripCodeSignature(inPath, outPath string) error {
_, err := exec.LookPath("codesign_allocate")
if err != nil {
return fmt.Errorf("your system is missing the codesign_allocate tool, so we can't verify the SLSA attestation (you can bypass this by setting `export HISHTORY_DISABLE_SLSA_ATTESTATION=true` in your shell)")
}
cmd := exec.Command("codesign_allocate", "-i", inPath, "-o", outPath, "-r")
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to use codesign_allocate to strip signatures on binary=%v (stdout=%#v, stderr%#v): %w", inPath, stdout.String(), stderr.String(), err)
}
return nil
}
func downloadFiles(updateInfo shared.UpdateInfo) error {
clientUrl := ""
clientProvenanceUrl := ""
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
clientUrl = updateInfo.LinuxAmd64Url
clientProvenanceUrl = updateInfo.LinuxAmd64AttestationUrl
} else if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
clientUrl = updateInfo.LinuxArm64Url
clientProvenanceUrl = updateInfo.LinuxArm64AttestationUrl
} else if runtime.GOOS == "linux" && runtime.GOARCH == "arm" {
clientUrl = updateInfo.LinuxArm7Url
clientProvenanceUrl = updateInfo.LinuxArm7AttestationUrl
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
clientUrl = updateInfo.DarwinAmd64Url
clientProvenanceUrl = updateInfo.DarwinAmd64AttestationUrl
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
clientUrl = updateInfo.DarwinArm64Url
clientProvenanceUrl = updateInfo.DarwinArm64AttestationUrl
} else {
return fmt.Errorf("no update info found for GOOS=%s, GOARCH=%s", runtime.GOOS, runtime.GOARCH)
}
err := downloadFile(getTmpClientPath(), clientUrl)
if err != nil {
return err
}
err = downloadFile(getTmpClientPath()+".intoto.jsonl", clientProvenanceUrl)
if err != nil {
return err
}
return nil
}
func downloadFile(filename, url string) error {
// Download the data
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download file at %s to %s: %w", url, filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failed to download file at %s due to resp_code=%d", url, resp.StatusCode)
}
// Delete the file if it already exists. This is necessary due to https://openradar.appspot.com/FB8735191
if _, err := os.Stat(filename); err == nil {
err = os.Remove(filename)
if err != nil {
return fmt.Errorf("failed to delete file %v when trying to download a new version", filename)
}
}
// Create the file
out, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to save file to %s: %w", filename, err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func getServerHostname() string {
if server := os.Getenv("HISHTORY_SERVER"); server != "" {
return server
}
return "https://api.hishtory.dev"
}
func httpClient() *http.Client {
return &http.Client{}
}

View File

@ -42,7 +42,7 @@ func checkForDowngrade(currentVersionS, newVersionS string) error {
return nil
}
func verifyBinary(ctx context.Context, binaryPath, attestationPath, versionTag string) error {
func VerifyBinary(ctx context.Context, binaryPath, attestationPath, versionTag string) error {
if os.Getenv("HISHTORY_DISABLE_SLSA_ATTESTATION") == "true" {
return nil
}
@ -87,7 +87,7 @@ func getFileHash(binaryPath string) (string, error) {
return hash, nil
}
func handleSlsaFailure(srcErr error) error {
func HandleSlsaFailure(srcErr error) error {
fmt.Printf("\nFailed to verify SLSA provenance! This is likely due to a SLSA bug (SLSA is a brand new standard, and like all new things, has bugs). Ignoring this failure means falling back to the way most software does updates. Do you want to ignore this failure and update anyways? [y/N]")
reader := bufio.NewReader(os.Stdin)
resp, err := reader.ReadString('\n')