feat(install): validate file signature before installation

This commit is contained in:
Jan De Dobbeleer 2024-07-04 16:57:45 +02:00 committed by Jan De Dobbeleer
parent 0829afb478
commit b6729ff414
11 changed files with 261 additions and 58 deletions

View file

@ -2,9 +2,11 @@ package cli
import (
"fmt"
"os"
stdruntime "runtime"
"slices"
"github.com/jandedobbeleer/oh-my-posh/src/build"
"github.com/jandedobbeleer/oh-my-posh/src/config"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
"github.com/jandedobbeleer/oh-my-posh/src/terminal"
@ -32,9 +34,7 @@ var upgradeCmd = &cobra.Command{
return
}
env := &runtime.Terminal{
CmdFlags: &runtime.Flags{},
}
env := &runtime.Terminal{}
env.Init()
defer env.Close()
@ -43,24 +43,43 @@ var upgradeCmd = &cobra.Command{
defer fmt.Print(terminal.StopProgress())
latest, err := upgrade.Latest(env)
if err != nil {
fmt.Printf("\n❌ %s\n\n", err)
os.Exit(1)
return
}
if force {
upgrade.Run(env)
executeUpgrade(latest)
return
}
cfg := config.Load(env)
if _, hasNotice := upgrade.Notice(env, true); !hasNotice {
version := fmt.Sprintf("v%s", build.Version)
if version == latest {
if !cfg.DisableNotice {
fmt.Print("\n✅ no new version available\n\n")
fmt.Print("\n✅ no new version available\n\n")
}
return
}
upgrade.Run(env)
executeUpgrade(latest)
},
}
func executeUpgrade(latest string) {
err := upgrade.Run(latest)
if err == nil {
return
}
fmt.Printf("\n❌ %s\n\n", err)
os.Exit(1)
}
func init() {
upgradeCmd.Flags().BoolVarP(&force, "force", "f", false, "force the upgrade even if the version is up to date")
RootCmd.AddCommand(upgradeCmd)

View file

@ -0,0 +1 @@
4ad2e70db23f6c0441df61a07370d352081e93fab32867e797d6dfa3f45814ce posh-windows-arm64.exe

View file

@ -0,0 +1,4 @@
<EFBFBD>Ίv<EFBFBD>3.*
ςΖΐ«¦k”WbJ.ό΄ΚsGueeΏ§5σΰά¨[lΦτ<CEA6>—{γοΨ…ΑR‡-ΝυXη‡_ηΙΫ Eο7`£ί5 Σ}—-<2D>W”.Q³S"ΨΒ} «C]³<>'FΒΈPΧhYΚ©Ι―δ©7n<37>πΐDτ0γlΈ8<CE88>O><3E>}•.<2E><, ς<EFBFBD>†½·<C2BD>«ο–+<2B>»%ΒFPΎ<50>
¨Ό ±^‹ξ
‡KΡ"Z=mx8pΰy―08[―|z&yδ­Ρξ<14>Ψlηu<CEB7> Q$„@Τ‚½£¶χ>„τςατ'νΰnΝW8«ΞM½.¦ΐρtΦ¦

View file

@ -0,0 +1 @@
œ3Jà¦ÝSDòÊ»9IbêfC(ñ6c¬œªŒUž®F¿Ý,îoš«g«ïPåHÊ Üi†ØÕùfèF‡_ˆ¥

View file

@ -2,13 +2,11 @@ package upgrade
import (
"fmt"
"os"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/jandedobbeleer/oh-my-posh/src/build"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
)
var (
@ -24,10 +22,15 @@ type state int
const (
validating state = iota
downloading
verifying
installing
)
func setState(message state) {
if program == nil {
return
}
program.Send(stateMsg(message))
}
@ -37,25 +40,28 @@ type model struct {
spinner spinner.Model
message string
state state
error error
tag string
}
func initialModel() *model {
func initialModel(tag string) *model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("170"))
return &model{spinner: s}
return &model{spinner: s, tag: tag}
}
func (m *model) Init() tea.Cmd {
defer func() {
go func() {
if err := install(); err != nil {
message := fmt.Sprintf("⚠️ %s", err)
program.Send(resultMsg(message))
if err := install(m.tag); err != nil {
m.error = err
program.Quit()
return
}
program.Send(resultMsg("🚀 Upgrade successful, restart your shell to take full advantage of the new functionality."))
program.Send(resultMsg("🚀 Upgrade successful, restart your shell to take full advantage of the new functionality."))
}()
}()
@ -101,6 +107,9 @@ func (m *model) View() string {
case downloading:
m.spinner.Spinner = spinner.Globe
message = "Downloading latest version"
case verifying:
m.spinner.Spinner = spinner.Moon
message = "Verifying download"
case installing:
message = "Installing"
}
@ -108,20 +117,29 @@ func (m *model) View() string {
return title + textStyle.Render(fmt.Sprintf("%s %s", m.spinner.View(), message))
}
func Run(env runtime.Environment) {
func Run(latest string) error {
titleStyle := lipgloss.NewStyle().Margin(1, 0, 1, 0)
title = "📦 Upgrading Oh My Posh"
title = "📦 Upgrading Oh My Posh"
version, err := Latest(env)
if err == nil {
title = fmt.Sprintf("%s from %s to %s", title, build.Version, version)
current := build.Version
if len(current) == 0 {
current = "dev"
}
title = fmt.Sprintf("%s from %s to %s", title, current, latest)
title = titleStyle.Render(title)
program = tea.NewProgram(initialModel())
if _, err := program.Run(); err != nil {
fmt.Println(err)
os.Exit(1)
program = tea.NewProgram(initialModel(latest))
resultModel, err := program.Run()
if err != nil {
return err
}
programModel, OK := resultModel.(*model)
if !OK {
return nil
}
return programModel.error
}

39
src/upgrade/github.go Normal file
View file

@ -0,0 +1,39 @@
package upgrade
import (
"context"
"fmt"
"io"
httplib "net/http"
"github.com/jandedobbeleer/oh-my-posh/src/runtime/http"
)
func downloadReleaseAsset(tag, asset string) ([]byte, error) {
url := fmt.Sprintf("https://github.com/JanDeDobbeleer/oh-my-posh/releases/download/%s/%s", tag, asset)
req, err := httplib.NewRequestWithContext(context.Background(), "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", "oh-my-posh")
resp, err := http.HTTPClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != httplib.StatusOK {
return nil, fmt.Errorf("failed to download asset: %s", url)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return data, nil
}

View file

@ -2,19 +2,13 @@ package upgrade
import (
"bytes"
"context"
"fmt"
"io"
httplib "net/http"
"os"
"path/filepath"
stdruntime "runtime"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
"github.com/jandedobbeleer/oh-my-posh/src/runtime/http"
)
func install() error {
func install(tag string) error {
setState(validating)
executable, err := os.Executable()
@ -22,34 +16,9 @@ func install() error {
return err
}
extension := ""
if stdruntime.GOOS == runtime.WINDOWS {
extension = ".exe"
}
asset := fmt.Sprintf("posh-%s-%s%s", stdruntime.GOOS, stdruntime.GOARCH, extension)
setState(downloading)
url := fmt.Sprintf("https://github.com/JanDeDobbeleer/oh-my-posh/releases/latest/download/%s", asset)
req, err := httplib.NewRequestWithContext(context.Background(), "GET", url, nil)
if err != nil {
return err
}
resp, err := http.HTTPClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode != httplib.StatusOK {
return fmt.Errorf("failed to download installer: %s", url)
}
defer resp.Body.Close()
newBytes, err := io.ReadAll(resp.Body)
data, err := downloadAndVerify(tag)
if err != nil {
return err
}
@ -65,7 +34,7 @@ func install() error {
defer fp.Close()
_, err = io.Copy(fp, bytes.NewReader(newBytes))
_, err = io.Copy(fp, bytes.NewReader(data))
if err != nil {
return err
}

View file

@ -49,6 +49,11 @@ func Latest(env runtime.Environment) (string, error) {
var release release
// this can't fail
_ = json.Unmarshal(body, &release)
if len(release.TagName) == 0 {
return "", fmt.Errorf("failed to get latest release")
}
return release.TagName, nil
}

View file

@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA98lHhNau5x0JtjSuwiWLuC2yKO6NA6/0bH2gE8tAq4c=
-----END PUBLIC KEY-----

114
src/upgrade/verify.go Normal file
View file

@ -0,0 +1,114 @@
package upgrade
import (
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
_ "embed"
"encoding/pem"
"fmt"
stdruntime "runtime"
"strings"
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
)
//go:embed public_key.pem
var publicKey []byte
func downloadAndVerify(tag string) ([]byte, error) {
extension := ""
if stdruntime.GOOS == runtime.WINDOWS {
extension = ".exe"
}
asset := fmt.Sprintf("posh-%s-%s%s", stdruntime.GOOS, stdruntime.GOARCH, extension)
data, err := downloadReleaseAsset(tag, asset)
if err != nil {
return nil, err
}
setState(verifying)
err = verify(tag, asset, data)
if err != nil {
return nil, err
}
return data, nil
}
func verify(tag, asset string, binary []byte) error {
checksums, err := downloadReleaseAsset(tag, "checksums.txt")
if err != nil {
return err
}
signature, err := downloadReleaseAsset(tag, "checksums.txt.sig")
if err != nil {
return err
}
OK := validateSignature(checksums, signature)
if !OK {
return fmt.Errorf("failed to verify checksums signature")
}
return validateChecksum(asset, checksums, binary)
}
func validateSignature(data, signature []byte) bool {
ed25519PublicKey, err := loadPublicKey()
if err != nil {
return false
}
return ed25519.Verify(*ed25519PublicKey, data, signature)
}
func loadPublicKey() (*ed25519.PublicKey, error) {
block, _ := pem.Decode(publicKey)
if block == nil {
return nil, fmt.Errorf("error parsing PEM block: key not found")
}
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("error parsing public key: %v", err)
}
ed25519PubKey, ok := pubKey.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("invalid public key format: %v", err)
}
return &ed25519PubKey, nil
}
func validateChecksum(asset string, sha256sums, binary []byte) error {
var assetChecksum string
checksums := strings.Split(string(sha256sums), "\n")
for _, line := range checksums {
if !strings.HasSuffix(line, asset) {
continue
}
assetChecksum = strings.Fields(line)[0]
break
}
if len(assetChecksum) == 0 {
return fmt.Errorf("failed to find checksum for asset")
}
// calculate the checksum of the binary
binaryChecksum := fmt.Sprintf("%x", sha256.Sum256(binary))
if assetChecksum != binaryChecksum {
return fmt.Errorf("checksum mismatch")
}
return nil
}

View file

@ -0,0 +1,30 @@
package upgrade
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestVerify(t *testing.T) {
checksum, err := os.ReadFile("../test/signing/checksums.txt")
assert.NoError(t, err)
signature, err := os.ReadFile("../test/signing/checksums.txt.sig")
assert.NoError(t, err)
OK := validateSignature(checksum, signature)
assert.True(t, OK)
}
func TestVerifyFail(t *testing.T) {
checksum, err := os.ReadFile("../test/signing/checksums.txt")
assert.NoError(t, err)
signature, err := os.ReadFile("../test/signing/checksums.txt.invalid.sig")
assert.NoError(t, err)
OK := validateSignature(checksum, signature)
assert.False(t, OK)
}