diff --git a/Makefile b/Makefile index 80a47e9..10d6d54 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,19 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDTS ?= $(shell date -u +%s) REVISION ?= $(shell git rev-parse HEAD) -VERSION_DEV ?= 0.1.2-dev -VERSION ?= 0.1.2 +VERSION_DEV ?= 0.1.3-dev +VERSION ?= 0.1.3 PROMETHEUS_TAG := github.com/prometheus/common/version KVM_PKG_NAME := kvm +# OTA signing key path (Ed25519 private key for auto-signing at build time) +OTA_SIGNING_KEY ?= + +# OTA signing public key (hex-encoded Ed25519 public key, 64 hex chars) +# Default empty = signature verification disabled (backward compatible) +OTA_PUBLIC_KEY ?= + GO_BUILD_ARGS := -tags netgo GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) GO_LDFLAGS := \ @@ -15,7 +22,8 @@ GO_LDFLAGS := \ -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \ -X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \ -X $(PROMETHEUS_TAG).Revision=$(REVISION) \ - -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) + -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) \ + -X $(KVM_PKG_NAME).builtOtaPublicKey=$(OTA_PUBLIC_KEY) GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go BIN_DIR := $(shell pwd)/bin @@ -28,6 +36,12 @@ build_dev: -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \ $(GO_RELEASE_BUILD_ARGS) \ -o $(BIN_DIR)/kvm_app cmd/main.go + @if [ -n "$(OTA_SIGNING_KEY)" ]; then \ + echo "Signing $(BIN_DIR)/kvm_app..."; \ + go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" $(BIN_DIR)/kvm_app; \ + else \ + echo "OTA_SIGNING_KEY not set, skipping signing."; \ + fi frontend: cd ui && npm ci && npm run build:device @@ -38,3 +52,13 @@ build_release: frontend -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \ $(GO_RELEASE_BUILD_ARGS) \ -o bin/kvm_app cmd/main.go + @if [ -n "$(OTA_SIGNING_KEY)" ]; then \ + echo "Signing bin/kvm_app..."; \ + go run cmd/main.go cli signer sign --key "$(OTA_SIGNING_KEY)" bin/kvm_app; \ + else \ + echo "OTA_SIGNING_KEY not set, skipping signing."; \ + fi + +sign: + @echo "Signing firmware files..." + go run cmd/main.go cli signer sign --key $(KEY) $(FILES) diff --git a/cli.go b/cli.go index 8508142..f8b96c4 100644 --- a/cli.go +++ b/cli.go @@ -410,20 +410,23 @@ var signerKeygenCmd = &cobra.Command{ } var signerSignCmd = &cobra.Command{ - Use: "sign --key ", + Use: "sign --key ", Short: "Sign a firmware file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - keyPath, _ := cmd.Flags().GetString("key") + keyArg, _ := cmd.Flags().GetString("key") filePath := args[0] - if keyPath == "" { + if keyArg == "" { return fmt.Errorf("--key is required") } - privateKey, err := os.ReadFile(keyPath) + privateKey, err := os.ReadFile(keyArg) if err != nil { - return fmt.Errorf("reading private key: %w", err) + privateKey, err = hex.DecodeString(keyArg) + if err != nil { + return fmt.Errorf("invalid private key: not a valid file path or hex string") + } } if len(privateKey) != ed25519.PrivateKeySize { @@ -450,9 +453,9 @@ var signerSignCmd = &cobra.Command{ } var signerVerifyCmd = &cobra.Command{ - Use: "verify --pubkey []", + Use: "verify [--pubkey ] []", Short: "Verify firmware signature", - Args: cobra.MinimumNArgs(1), + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { pubKeyArg, _ := cmd.Flags().GetString("pubkey") filePath := args[0] @@ -461,16 +464,23 @@ var signerVerifyCmd = &cobra.Command{ sigPath = args[1] } - if pubKeyArg == "" { - return fmt.Errorf("--pubkey is required") - } - if sigPath == "" { sigPath = filePath + ".sig" } var publicKey ed25519.PublicKey - if _, err := os.Stat(pubKeyArg); err == nil { + + if pubKeyArg == "" { + keyStr := strings.TrimSpace(builtOtaPublicKey) + if keyStr == "" { + return fmt.Errorf("no --pubkey provided and no OTA public key embedded in binary") + } + keyBytes, err := hex.DecodeString(keyStr) + if err != nil { + return fmt.Errorf("decoding embedded public key hex: %w", err) + } + publicKey = ed25519.PublicKey(keyBytes) + } else if _, err := os.Stat(pubKeyArg); err == nil { keyBytes, err := os.ReadFile(pubKeyArg) if err != nil { return fmt.Errorf("reading public key file: %w", err) diff --git a/config.go b/config.go index d67568f..f76f8b8 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package kvm import ( "bufio" + "bytes" "encoding/json" "fmt" "io" @@ -136,6 +137,7 @@ type Config struct { WireguardConfig WireguardConfig `json:"wireguard_config"` NpuAppEnabled bool `json:"npu_app_enabled"` Firewall *FirewallConfig `json:"firewall"` + APIKey string `json:"api_key"` } type FirewallConfig struct { @@ -190,6 +192,10 @@ type WireguardConfig struct { const configPath = "/userdata/kvm_config.json" const sdConfigPath = "/mnt/sdcard/kvm_config.json" +// builtOtaPublicKey is the hex-encoded Ed25519 public key for OTA signature verification, +// injected via -ldflags at build time. Empty string disables signature verification. +var builtOtaPublicKey = "" + var defaultConfig = &Config{ STUN: "stun:stun.l.google.com:19302", TurnServers: []TurnServer{}, @@ -252,6 +258,75 @@ var ( configLock = &sync.Mutex{} ) +type ConfigMigrationFunc func(raw json.RawMessage) (json.RawMessage, error) + +var configMigrations = []ConfigMigrationFunc{ + migrateLocalAuthMode, +} + +func migrateLocalAuthMode(raw json.RawMessage) (json.RawMessage, error) { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawMap); err != nil { + return nil, fmt.Errorf("migrateLocalAuthMode: failed to parse config JSON: %w", err) + } + + if authModeRaw, exists := rawMap["localAuthMode"]; exists { + var authMode string + if err := json.Unmarshal(authModeRaw, &authMode); err == nil { + if authMode != "" { + validModes := map[string]bool{"password": true, "noPassword": true} + if !validModes[authMode] { + rawMap["localAuthMode"] = json.RawMessage(`"password"`) + } + } + } else { + delete(rawMap, "localAuthMode") + } + } + + result, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("migrateLocalAuthMode: failed to marshal migrated config: %w", err) + } + return result, nil +} + +func runMigrations(raw json.RawMessage) (json.RawMessage, bool, error) { + current := raw + didMigrate := false + for i, migration := range configMigrations { + migrated, err := migration(current) + if err != nil { + return current, didMigrate, fmt.Errorf("migration %d failed: %w", i, err) + } + if string(migrated) != string(current) { + didMigrate = true + logger.Info().Int("migration", i).Msg("config migrated") + } + current = migrated + } + return current, didMigrate, nil +} + +func writeRawConfig(path string, raw json.RawMessage) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer file.Close() + + var indented bytes.Buffer + if err := json.Indent(&indented, raw, "", " "); err != nil { + return fmt.Errorf("failed to indent config JSON: %w", err) + } + + if _, err := indented.WriteTo(file); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + func LoadConfig() { configLock.Lock() defer configLock.Unlock() @@ -261,7 +336,6 @@ func LoadConfig() { return } - // load the default config if defaultConfig.UsbConfig.SerialNumber == "" { serialNumber, err := extractSerialNumber() if err != nil { @@ -273,24 +347,38 @@ func LoadConfig() { loadedConfig := *defaultConfig config = &loadedConfig - file, err := os.Open(configPath) + rawData, err := os.ReadFile(configPath) if err != nil { - logger.Debug().Msg("default config file doesn't exist, using default") + logger.Debug().Msg("config file does not exist, using default") return } - defer file.Close() - // load and merge the default config with the user config - if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { - logger.Warn().Err(err).Msg("config file JSON parsing failed") - os.Remove(configPath) - if _, err := os.Stat(sdConfigPath); err == nil { - os.Remove(sdConfigPath) + migrated, didMigrate, err := runMigrations(json.RawMessage(rawData)) + if err != nil { + logger.Warn().Err(err).Msg("config migration failed, preserving corrupt file") + corruptPath := configPath + ".corrupt" + _ = os.Rename(configPath, corruptPath) + logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis") + return + } + + if didMigrate { + if writeErr := writeRawConfig(configPath, migrated); writeErr != nil { + logger.Warn().Err(writeErr).Msg("failed to write migrated config, continuing with in-memory version") + } else { + logger.Info().Msg("migrated config saved to disk") + SyncConfigSD(false) } + } + + if err := json.Unmarshal(migrated, &loadedConfig); err != nil { + logger.Warn().Err(err).Msg("config file JSON parsing failed, preserving corrupt file") + corruptPath := configPath + ".corrupt" + _ = os.Rename(configPath, corruptPath) + logger.Info().Str("corrupt_path", corruptPath).Msg("corrupt config preserved for diagnosis") return } - // merge the user config with the default config if loadedConfig.UsbConfig == nil { loadedConfig.UsbConfig = defaultConfig.UsbConfig } diff --git a/ota.go b/ota.go index 817b119..0b9b65e 100644 --- a/ota.go +++ b/ota.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "crypto/ed25519" "crypto/sha256" "crypto/tls" "encoding/hex" @@ -42,8 +43,10 @@ type RemoteMetadata struct { AppVersion string `json:"appVersion"` AppUrl string `json:"appUrl"` AppHash string `json:"appHash"` + AppSigUrl string `json:"appSigUrl,omitempty"` SystemUrl string `json:"systemUrl"` SystemHash string `json:"systemHash,omitempty"` + SystemSigUrl string `json:"systemSigUrl,omitempty"` SystemVersion string `json:"systemVersion"` } @@ -53,6 +56,8 @@ type UpdateStatus struct { Remote *RemoteMetadata `json:"remote"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"` + AppSignatureMissing bool `json:"appSignatureMissing,omitempty"` + SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"` // for backwards compatibility Error string `json:"error,omitempty"` @@ -89,10 +94,12 @@ var UpdateGiteeSystemZipUrls = []string{ const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/" -var builtAppVersion = "0.1.2+dev" +var builtAppVersion = "0.1.3+dev" -var updateSource = "github" -var customUpdateBaseURL string +var ( + updateSource = "github" + customUpdateBaseURL string +) const ( updateSourceGithub = "github" @@ -144,12 +151,12 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease _, _ = deviceId, includePreRelease - appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx) + appVersionRemote, appURL, appSha256, appSigURL, err := fetchKvmAppLatestRelease(ctx) if err != nil { return nil, err } - systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx) + systemVersionRemote, systemZipURL, systemSigURL, err := fetchKvmSystemLatestRelease(ctx) if err != nil { return nil, err } @@ -158,12 +165,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease AppUrl: appURL, AppVersion: appVersionRemote, AppHash: appSha256, + AppSigUrl: appSigURL, SystemUrl: systemZipURL, SystemVersion: systemVersionRemote, + SystemSigUrl: systemSigURL, }, nil } -func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) { +func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, sigURL string, err error) { apiURLs := UpdateGithubAppReleaseUrls fallbackToGithub := false if updateSource == updateSourceGitee { @@ -171,7 +180,7 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri fallbackToGithub = true } - tryFetch := func(urls []string) (string, string, string, error) { + tryFetch := func(urls []string) (string, string, string, string, error) { var lastErr error for _, apiURL := range urls { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) @@ -218,44 +227,54 @@ func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL stri continue } - var downloadURL string - var sha256 string - if len(release.Assets) > 0 { - downloadURL = release.Assets[0].BrowserDownloadURL - sha256 = release.Assets[0].Digest + var downloadURL, sha256, sigURL string + for _, asset := range release.Assets { + name := strings.ToLower(strings.TrimSpace(asset.Name)) + u := strings.TrimSpace(asset.BrowserDownloadURL) + if strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".sha2565") { + if strings.HasSuffix(name, ".sig") && sigURL == "" { + sigURL = u + } + continue + } + if downloadURL == "" { + downloadURL = u + sha256 = strings.TrimPrefix(strings.TrimSpace(asset.Digest), "sha256:") + } } - sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:") if strings.TrimSpace(downloadURL) == "" { lastErr = fmt.Errorf("empty app download url from %s", apiURL) continue } - return tag, downloadURL, sha256, nil + return tag, downloadURL, sha256, sigURL, nil } if lastErr == nil { lastErr = fmt.Errorf("no app release API URLs configured") } - return "", "", "", lastErr + return "", "", "", "", lastErr } var lastErr error - tag, downloadURL, sha256, err = tryFetch(apiURLs) + tag, downloadURL, sha256, sigURL, err = tryFetch(apiURLs) if err == nil { - return tag, downloadURL, sha256, nil + return tag, downloadURL, sha256, sigURL, nil } lastErr = err if updateSource == updateSourceGitee && fallbackToGithub { - tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls) + var ghSigURL string + tag, downloadURL, sha256, ghSigURL, err = tryFetch(UpdateGithubAppReleaseUrls) if err == nil { downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1) - return tag, downloadURL, sha256, nil + ghSigURL = strings.Replace(ghSigURL, "github.com", "gitee.com", 1) + return tag, downloadURL, sha256, ghSigURL, nil } lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err) } - return "", "", "", lastErr + return "", "", "", "", lastErr } type releaseAsset struct { @@ -281,7 +300,7 @@ func pickZipAssetURL(assets []releaseAsset) string { return "" } -func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) { +func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, sigURL string, err error) { apiURLs := UpdateGithubSystemReleaseUrls fallbackToGithub := false if updateSource == updateSourceGitee { @@ -336,11 +355,18 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string continue } + var sysSigURL string + for _, asset := range release.Assets { + name := strings.ToLower(strings.TrimSpace(asset.Name)) + if strings.HasSuffix(name, ".sig") && sysSigURL == "" { + sysSigURL = strings.TrimSpace(asset.BrowserDownloadURL) + } + } if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" { - return tag, strings.TrimSpace(u), nil + return tag, strings.TrimSpace(u), sysSigURL, nil } if strings.TrimSpace(release.ZipballURL) != "" { - return tag, strings.TrimSpace(release.ZipballURL), nil + return tag, strings.TrimSpace(release.ZipballURL), sysSigURL, nil } lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL) @@ -355,22 +381,22 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string var githubTag string var githubZipURL string for i, apiURL := range UpdateGithubSystemReleaseUrls { - githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) { + githubTag, githubZipURL, _, githubErr = func(apiURL string) (string, string, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { - return "", "", fmt.Errorf("error creating system release request: %w", err) + return "", "", "", fmt.Errorf("error creating system release request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { - return "", "", fmt.Errorf("error fetching system release: %w", err) + return "", "", "", fmt.Errorf("error fetching system release: %w", err) } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { - return "", "", fmt.Errorf("error reading system release response: %w", readErr) + return "", "", "", fmt.Errorf("error reading system release response: %w", readErr) } if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf( + return "", "", "", fmt.Errorf( "unexpected status code fetching system release from %s: %d, %s", apiURL, resp.StatusCode, @@ -383,19 +409,26 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string Assets []releaseAsset `json:"assets"` } if err := json.Unmarshal(body, &release); err != nil { - return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err) + return "", "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err) } tag := strings.TrimSpace(release.TagName) if tag == "" { - return "", "", fmt.Errorf("empty system tag_name from %s", apiURL) + return "", "", "", fmt.Errorf("empty system tag_name from %s", apiURL) + } + var sigURL string + for _, asset := range release.Assets { + name := strings.ToLower(strings.TrimSpace(asset.Name)) + if strings.HasSuffix(name, ".sig") && sigURL == "" { + sigURL = strings.TrimSpace(asset.BrowserDownloadURL) + } } if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" { - return tag, strings.TrimSpace(u), nil + return tag, strings.TrimSpace(u), sigURL, nil } if strings.TrimSpace(release.ZipballURL) != "" { - return tag, strings.TrimSpace(release.ZipballURL), nil + return tag, strings.TrimSpace(release.ZipballURL), sigURL, nil } - return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL) + return "", "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL) }(apiURL) if githubErr == nil && strings.TrimSpace(githubTag) != "" { _ = githubZipURL @@ -414,15 +447,15 @@ func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string zipTag = strings.TrimPrefix(zipTag, "V") } zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip" - return githubTag, zipURL, nil + return githubTag, zipURL, "", nil } githubErr = fmt.Errorf("no gitee system zip urls configured") break } } - return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr) + return "", "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr) } - return "", "", lastErr + return "", "", "", lastErr } func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) { @@ -496,13 +529,21 @@ func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*Remot } } + appSigURL, _ := resolveURL(baseURL, "kvm_app.sig") + systemSigURL, _ := resolveURL(baseURL, "update_system.zip.sig") + if strings.HasSuffix(systemURL, ".tar") { + systemSigURL, _ = resolveURL(baseURL, "update_system.tar.sig") + } + return &RemoteMetadata{ AppVersion: appVersion, AppUrl: appURL, AppHash: appHash, + AppSigUrl: appSigURL, SystemVersion: systemVersion, SystemUrl: systemURL, SystemHash: systemHash, + SystemSigUrl: systemSigURL, }, nil } @@ -885,7 +926,7 @@ func downloadFile( } // Clear the filesystem caches to force a read from disk - err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644) + err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0o644) if err != nil { otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches") } @@ -909,6 +950,8 @@ func prepareSystemUpdateTarFromKvmSystemZip( downloadProgress *float32, downloadSpeedBps *float32, verificationProgress *float32, + sigURL string, + expectedHash string, scopedLogger *zerolog.Logger, ) error { if scopedLogger == nil { @@ -920,14 +963,14 @@ func prepareSystemUpdateTarFromKvmSystemZip( extractDir := filepath.Join(workDir, "extract") zipPath := filepath.Join(workDir, "master.zip") - if err := os.MkdirAll(workDir, 0755); err != nil { + if err := os.MkdirAll(workDir, 0o755); err != nil { return fmt.Errorf("error creating work dir: %w", err) } if err := os.RemoveAll(extractDir); err != nil { return fmt.Errorf("error cleaning extract dir: %w", err) } - if err := os.MkdirAll(extractDir, 0755); err != nil { + if err := os.MkdirAll(extractDir, 0o755); err != nil { return fmt.Errorf("error creating extract dir: %w", err) } @@ -955,19 +998,26 @@ func prepareSystemUpdateTarFromKvmSystemZip( zipUnverifiedPath := zipPath + ".unverified" if _, err := os.Stat(zipUnverifiedPath); err != nil { lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err) - } else { - if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil { + } else if sigURL != "" || expectedHash != "" { + if err := verifyFile(ctx, zipPath, expectedHash, sigURL, verificationProgress, scopedLogger); err != nil { + lastErr = fmt.Errorf("system zip verification failed: %w", err) + } else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil { lastErr = err } else { lastErr = nil break } + } else if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil { + lastErr = err + } else { + lastErr = nil + break } } _ = os.Remove(zipPath + ".unverified") _ = os.RemoveAll(extractDir) - _ = os.MkdirAll(extractDir, 0755) + _ = os.MkdirAll(extractDir, 0o755) if attempt < maxAttempts { time.Sleep(time.Duration(attempt*2) * time.Second) } @@ -999,7 +1049,7 @@ func prepareSystemUpdateTarFromKvmSystemZip( if _, err := os.Stat(scriptPath); err != nil { return fmt.Errorf("split_and_check_md5.sh not found: %w", err) } - if err := os.Chmod(scriptPath, 0755); err != nil { + if err := os.Chmod(scriptPath, 0o755); err != nil { return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err) } @@ -1064,13 +1114,13 @@ func unzipArchive(zipPath string, destDir string) error { } if file.FileInfo().IsDir() { - if err := os.MkdirAll(cleanTargetPath, 0755); err != nil { + if err := os.MkdirAll(cleanTargetPath, 0o755); err != nil { return fmt.Errorf("error creating dir: %w", err) } continue } - if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0o755); err != nil { return fmt.Errorf("error creating dir: %w", err) } @@ -1079,7 +1129,7 @@ func unzipArchive(zipPath string, destDir string) error { return fmt.Errorf("error opening zipped file: %w", err) } - outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { rc.Close() return fmt.Errorf("error creating file: %w", err) @@ -1102,17 +1152,42 @@ func unzipArchive(zipPath string, destDir string) error { return nil } -func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { +func verifyFile(ctx context.Context, path string, expectedHash string, sigURL string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { if scopedLogger == nil { scopedLogger = otaLogger } unverifiedPath := path + ".unverified" + + if strings.TrimSpace(sigURL) == "" && strings.TrimSpace(expectedHash) == "" { + return fmt.Errorf("refusing to flash unverified firmware: no signature URL and no hash provided") + } + + if strings.TrimSpace(sigURL) != "" { + sigBasePath := path + ".sig" + sigDownloadErr := downloadFile(ctx, sigBasePath, sigURL, nil, nil) + sigPath := sigBasePath + ".unverified" + if sigDownloadErr != nil { + scopedLogger.Warn().Err(sigDownloadErr).Str("sigURL", sigURL).Msg("failed to download signature file, falling back to hash-only verification") + } else { + sigPresent, sigErr := verifyFileSignature(unverifiedPath, sigPath, scopedLogger) + _ = os.Remove(sigPath) + if sigPresent && sigErr != nil { + return fmt.Errorf("signature verification failed: %w", sigErr) + } + if sigPresent && sigErr == nil { + scopedLogger.Info().Str("path", path).Msg("firmware signature verified, proceeding to hash check") + } + } + } else { + scopedLogger.Info().Str("path", path).Msg("no signature URL provided, skipping signature verification") + } + if strings.TrimSpace(expectedHash) == "" { if err := os.Rename(unverifiedPath, path); err != nil { return fmt.Errorf("error renaming file: %w", err) } - if err := os.Chmod(path, 0755); err != nil { + if err := os.Chmod(path, 0o755); err != nil { return fmt.Errorf("error making file executable: %w", err) } return nil @@ -1170,7 +1245,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope return fmt.Errorf("error renaming file: %w", err) } - if err := os.Chmod(path, 0755); err != nil { + if err := os.Chmod(path, 0o755); err != nil { return fmt.Errorf("error making file executable: %w", err) } @@ -1183,19 +1258,23 @@ type OTAState struct { MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` AppUpdatePending bool `json:"appUpdatePending"` SystemUpdatePending bool `json:"systemUpdatePending"` - AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar + AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` // TODO: implement for progress bar AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"` AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` - SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar + SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` // TODO: implement for progress bar SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"` SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` - AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar + AppSignatureVerified bool `json:"appSignatureVerified,omitempty"` + SystemSignatureVerified bool `json:"systemSignatureVerified,omitempty"` + AppSignatureMissing bool `json:"appSignatureMissing,omitempty"` + SystemSignatureMissing bool `json:"systemSignatureMissing,omitempty"` + AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` // TODO: implement for progress bar AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` - SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement + SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` // TODO: port rk_ota, then implement SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` } @@ -1214,7 +1293,11 @@ func triggerOTAStateUpdate() { func cleanupUpdateTempFiles(logger *zerolog.Logger) { paths := []string{ "/userdata/picokvm/bin/kvm_app.unverified", + "/userdata/picokvm/bin/kvm_app.sig.unverified", + "/userdata/picokvm/update_system.zip.unverified", + "/userdata/picokvm/update_system.zip.sig.unverified", "/userdata/picokvm/update_system.tar.unverified", + "/userdata/picokvm/update_system.tar.sig.unverified", "/userdata/picokvm/update_system.tar", "/userdata/picokvm/kvm_system_work", } @@ -1298,8 +1381,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err triggerOTAStateUpdate() err = verifyFile( + ctx, "/userdata/picokvm/bin/kvm_app", remote.AppHash, + remote.AppSigUrl, &otaState.AppVerificationProgress, &scopedLogger, ) @@ -1312,6 +1397,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err verifyFinished := time.Now() otaState.AppVerifiedAt = &verifyFinished otaState.AppVerificationProgress = 1 + otaState.AppSignatureVerified = strings.TrimSpace(remote.AppSigUrl) != "" otaState.AppUpdatedAt = &verifyFinished otaState.AppUpdateProgress = 1 triggerOTAStateUpdate() @@ -1337,6 +1423,8 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err &otaState.SystemDownloadProgress, &otaState.SystemDownloadSpeedBps, &otaState.SystemVerificationProgress, + remote.SystemSigUrl, + remote.SystemHash, &scopedLogger, ) if err != nil { @@ -1361,7 +1449,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err return err } - err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger) + err = verifyFile(ctx, systemZipPath, remote.SystemHash, remote.SystemSigUrl, &otaState.SystemVerificationProgress, &scopedLogger) if err != nil { otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err) scopedLogger.Error().Err(err).Msg("Error preparing system update archive") @@ -1385,6 +1473,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err verifyFinished := time.Now() otaState.SystemVerifiedAt = &verifyFinished otaState.SystemVerificationProgress = 1 + otaState.SystemSignatureVerified = strings.TrimSpace(remote.SystemSigUrl) != "" triggerOTAStateUpdate() scopedLogger.Info().Msg("Starting rk_ota command") @@ -1454,13 +1543,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err } if rebootNeeded { - configPath := "/userdata/kvm_config.json" - if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { - scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot") - } else { - scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot") - } - scopedLogger.Info().Msg("System Rebooting in 10s") time.Sleep(10 * time.Second) cmd := exec.Command("reboot") @@ -1521,6 +1603,9 @@ func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease boo updateStatus.AppUpdateAvailable = false } + updateStatus.AppSignatureMissing = strings.TrimSpace(remoteMetadata.AppSigUrl) == "" + updateStatus.SystemSignatureMissing = strings.TrimSpace(remoteMetadata.SystemSigUrl) == "" + return updateStatus, nil } @@ -1535,3 +1620,81 @@ func confirmCurrentSystem() { logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") } } + +func getOTAPublicKey() ed25519.PublicKey { + keyStr := strings.TrimSpace(builtOtaPublicKey) + if keyStr == "" { + return nil + } + keyBytes, err := hex.DecodeString(keyStr) + if err != nil { + otaLogger.Warn().Err(err).Msg("invalid OTA public key hex in binary") + return nil + } + if len(keyBytes) != ed25519.PublicKeySize { + otaLogger.Warn().Int("size", len(keyBytes)).Msg("OTA public key wrong size, expected 32 bytes") + return nil + } + return ed25519.PublicKey(keyBytes) +} + +func verifyFileSignature( + unverifiedPath string, + sigPath string, + scopedLogger *zerolog.Logger, +) (signaturePresent bool, err error) { + if scopedLogger == nil { + scopedLogger = otaLogger + } + + if _, err := os.Stat(sigPath); os.IsNotExist(err) { + scopedLogger.Info().Str("path", sigPath).Msg("signature file not found, skipping signature verification") + return false, nil + } + + sigBytes, err := os.ReadFile(sigPath) + if err != nil { + return true, fmt.Errorf("error reading signature file: %w", err) + } + + if len(sigBytes) != ed25519.SignatureSize { + return true, fmt.Errorf("invalid signature file size: got %d bytes, expected %d", len(sigBytes), ed25519.SignatureSize) + } + + publicKey := getOTAPublicKey() + if publicKey == nil { + return true, fmt.Errorf("signature present but no public key embedded in binary") + } + + fileBytes, err := os.ReadFile(unverifiedPath) + if err != nil { + return true, fmt.Errorf("error reading file for signature verification: %w", err) + } + + if !ed25519.Verify(publicKey, fileBytes, sigBytes) { + return true, fmt.Errorf("Ed25519 signature verification failed for %s", unverifiedPath) + } + + scopedLogger.Info().Str("path", unverifiedPath).Msg("Ed25519 signature verification passed") + return true, nil +} + +func isSigFileAbsent(sigPath string) bool { + _, err := os.Stat(sigPath) + return os.IsNotExist(err) +} + +func verifyLocalFileSignature(filePath string, sigPath string, publicKey ed25519.PublicKey) bool { + sigBytes, err := os.ReadFile(sigPath) + if err != nil { + return false + } + if len(sigBytes) != ed25519.SignatureSize { + return false + } + fileBytes, err := os.ReadFile(filePath) + if err != nil { + return false + } + return ed25519.Verify(publicKey, fileBytes, sigBytes) +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 7a198d9..819ce37 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -38,8 +38,8 @@ const appendStatToMap = ( }; // Constants and types -export type AvailableSidebarViews ="ConsoleLogViewer"|"MacroMoreList"|"Fullscreen"|"TerminalTabsMobile"|"SettingsModal"|"ClipboardMobile"|"KeyboardPanel"|"MousePanel"|"SettingsVideo" - |"connection-stats"|"Clipboard"|"PowerControl"|"Macros"|"VirtualMedia"|"SharedFolders"|null; +export type AvailableSidebarViews = "ConsoleLogViewer" | "MacroMoreList" | "Fullscreen" | "TerminalTabsMobile" | "SettingsModal" | "ClipboardMobile" | "KeyboardPanel" | "MousePanel" | "SettingsVideo" + | "connection-stats" | "Clipboard" | "PowerControl" | "Macros" | "VirtualMedia" | "SharedFolders" | "UsbEpModeSelect" | "UsbStatusPanel" | null; export type AvailableTerminalTypes = "kvm" | "serial" | "none"; export interface User { @@ -189,9 +189,6 @@ interface RTCState { serialConsole: RTCDataChannel | null; setSerialConsole: (channel: RTCDataChannel | null) => void; - - hidChannel: RTCDataChannel | null; - setHidChannel: (channel: RTCDataChannel | null) => void; } export const useRTCStore = create(set => ({ @@ -201,9 +198,6 @@ export const useRTCStore = create(set => ({ rpcDataChannel: null, setRpcDataChannel: channel => set({ rpcDataChannel: channel }), - hidChannel: null, - setHidChannel: channel => set({ hidChannel: channel }), - transceiver: null, setTransceiver: transceiver => set({ transceiver }), @@ -592,9 +586,6 @@ export interface HidState { keyboardLedStateSyncAvailable: boolean; setKeyboardLedStateSyncAvailable: (available: boolean) => void; - rpcHidReady: boolean; - setRpcHidReady: (ready: boolean) => void; - keysDownState?: { modifier: number; keys: number[] }; setKeysDownState: (state: { modifier: number; keys: number[] }) => void; @@ -656,9 +647,6 @@ export const useHidStore = create((set, get) => ({ set({ keyboardLedState }); }, - rpcHidReady: false, - setRpcHidReady: ready => set({ rpcHidReady: ready }), - keysDownState: undefined, setKeysDownState: state => set({ keysDownState: state }), @@ -741,6 +729,13 @@ export interface UpdateState { systemUpdateProgress: number; systemUpdatedAt: string | null; + + appSignatureMissing: boolean; + systemSignatureMissing: boolean; + appSignatureAbsent: boolean; + appSignatureInvalid: boolean; + appNoPublicKey: boolean; + signatureVerified: boolean; }; setOtaState: (state: UpdateState["otaState"]) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; @@ -789,6 +784,12 @@ export const useUpdateStore = create(set => ({ appUpdatedAt: null, systemUpdateProgress: 0, systemUpdatedAt: null, + appSignatureMissing: false, + systemSignatureMissing: false, + appSignatureAbsent: false, + appSignatureInvalid: false, + appNoPublicKey: false, + signatureVerified: false, }, updateDialogHasBeenMinimized: false, diff --git a/ui/src/layout/components_setting/version/VersionContent.tsx b/ui/src/layout/components_setting/version/VersionContent.tsx index f4291a9..9e567c1 100644 --- a/ui/src/layout/components_setting/version/VersionContent.tsx +++ b/ui/src/layout/components_setting/version/VersionContent.tsx @@ -22,6 +22,15 @@ export interface SystemVersionInfo { remote?: { appVersion: string; systemVersion: string }; systemUpdateAvailable: boolean; appUpdateAvailable: boolean; + appSignatureMissing?: boolean; + systemSignatureMissing?: boolean; + appSignatureAbsent?: boolean; + systemSignatureAbsent?: boolean; + appSignatureInvalid?: boolean; + systemSignatureInvalid?: boolean; + appNoPublicKey?: boolean; + systemNoPublicKey?: boolean; + signatureVerified?: boolean; error?: string; } @@ -38,6 +47,13 @@ export default function SettingsVersion() { const { bootStorageType } = useBootStorageType(); const isBootFromSD = bootStorageType === "sd"; const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + const [signatureStatusLoading, setSignatureStatusLoading] = useState(true); + const [signatureStatus, setSignatureStatus] = useState<{ + appSignatureAbsent: boolean; + appSignatureInvalid: boolean; + appNoPublicKey: boolean; + signatureVerified: boolean; + } | null>(null); const updatePanelRef = useRef(null); const [updateSource, setUpdateSource] = useState("github"); const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState(""); @@ -82,6 +98,23 @@ export default function SettingsVersion() { }); }; + useEffect(() => { + setSignatureStatusLoading(true); + send("getSelfSignatureStatus", {}, resp => { + setSignatureStatusLoading(false); + if ("error" in resp) return; + const sigStatus = resp.result as { + appSignatureAbsent: boolean; + appSignatureInvalid: boolean; + appNoPublicKey: boolean; + }; + const hasSigFiles = !sigStatus.appSignatureAbsent; + const noPublicKey = sigStatus.appNoPublicKey; + const signatureVerified = hasSigFiles && !noPublicKey && !sigStatus.appSignatureInvalid; + setSignatureStatus({ ...sigStatus, signatureVerified }); + }); + }, [send]); + const applyUpdateSource = useCallback( (source: string) => { send("setUpdateSource", { source }, resp => { @@ -205,6 +238,11 @@ export default function SettingsVersion() { } /> + + {!isBootFromSD && ( <> setModalView("loading")} onClose={onClose} + versionInfo={versionInfo} /> )} @@ -407,23 +446,58 @@ function LoadingState({ const getVersionInfo = useCallback(() => { return new Promise((resolve, reject) => { - send("getUpdateStatus", {}, async resp => { - if ("error" in resp) { - notifications.error(`Failed to check for updates: ${resp.error}`); - reject(new Error("Failed to check for updates")); - } else { - const result = resp.result as SystemVersionInfo; - setAppVersion(result.local.appVersion); - setSystemVersion(result.local.systemVersion); - - if (result.error) { - notifications.error(`Failed to check for updates: ${result.error}`); - reject(new Error("Failed to check for updates")); - } else { - resolve(result); - } - } - }); + Promise.all([ + new Promise((res, rej) => { + send("getUpdateStatus", {}, resp => { + if ("error" in resp) { + notifications.error(`Failed to check for updates: ${resp.error}`); + rej(new Error("Failed to check for updates")); + } else { + const result = resp.result as SystemVersionInfo; + setAppVersion(result.local.appVersion); + setSystemVersion(result.local.systemVersion); + if (result.error) { + notifications.error(`Failed to check for updates: ${result.error}`); + rej(new Error("Failed to check for updates")); + } else { + res(result); + } + } + }); + }), + new Promise((res, rej) => { + send("getSelfSignatureStatus", {}, resp => { + if ("error" in resp) { + rej(new Error("Failed to get signature status")); + } else { + const sigStatus = resp.result as { + appSignatureAbsent: boolean; + appSignatureInvalid: boolean; + appNoPublicKey: boolean; + }; + const hasSigFiles = !sigStatus.appSignatureAbsent; + const signatureVerified = hasSigFiles && !sigStatus.appNoPublicKey && !sigStatus.appSignatureInvalid; + const partial: Partial = { + appSignatureAbsent: sigStatus.appSignatureAbsent, + appSignatureInvalid: sigStatus.appSignatureInvalid, + appNoPublicKey: sigStatus.appNoPublicKey, + signatureVerified, + }; + res(partial as SystemVersionInfo); + } + }); + }), + ]) + .then(([versionResult, sigResult]) => { + resolve({ + ...versionResult, + appSignatureAbsent: sigResult.appSignatureAbsent, + appSignatureInvalid: sigResult.appSignatureInvalid, + appNoPublicKey: sigResult.appNoPublicKey, + signatureVerified: sigResult.signatureVerified, + }); + }) + .catch(reject); }); }, [send, setAppVersion, setSystemVersion]); @@ -669,11 +743,17 @@ function UpdatingDeviceState({ function SystemUpToDateState({ checkUpdate, onClose, + versionInfo, }: { checkUpdate: () => void; onClose: () => void; + versionInfo: SystemVersionInfo | null; }) { const { $at } = useReactAt(); + const hasAbsentSig = versionInfo?.appSignatureAbsent; + const hasInvalidSig = versionInfo?.appSignatureInvalid; + const hasNoPublicKey = versionInfo?.appNoPublicKey; + return (
@@ -684,6 +764,50 @@ function SystemUpToDateState({ {$at("Your system is running the latest version. No updates are currently available.")}

+ {hasAbsentSig && ( +
+

+ {$at("Missing Signature File")} +

+

+ {$at("The current firmware is missing signature files. Integrity cannot be fully verified.")} +

+
+ )} + + {hasInvalidSig && ( +
+

+ {$at("Signature Verification Failed")} +

+

+ {$at("The signature file exists but does not match the firmware. This may indicate tampering.")} +

+
+ )} + + {hasNoPublicKey && ( +
+

+ {$at("No Embedded Public Key")} +

+

+ {$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")} +

+
+ )} + + {versionInfo?.signatureVerified && ( +
+

+ {$at("Signature Verified")} +

+

+ {$at("Firmware signature has been verified and is valid.")} +

+
+ )} +
{$at("Check Again")} @@ -825,3 +949,101 @@ function UpdateErrorState({
); } + +function SignatureStatusCard({ + signatureStatus, + signatureStatusLoading, +}: { + signatureStatus: { + appSignatureAbsent: boolean; + appSignatureInvalid: boolean; + appNoPublicKey: boolean; + signatureVerified: boolean; + } | null; + signatureStatusLoading: boolean; +}) { + const { $at } = useReactAt(); + if (signatureStatusLoading) { + return ( +
+
+ +

+ {$at("Verifying signature...")} +

+
+

+ {$at("Please wait while verifying firmware signature.")} +

+
+ ); + } + + if (!signatureStatus) { + return ( +
+

+ {$at("Signature Status Unavailable")} +

+

+ {$at("Unable to retrieve signature verification status.")} +

+
+ ); + } + + if (signatureStatus.signatureVerified) { + return ( +
+

+ + {$at("Signature Verified")} +

+

+ {$at("Firmware signature has been verified and is valid.")} +

+
+ ); + } + + if (signatureStatus.appSignatureAbsent) { + return ( +
+

+ {$at("Missing Signature File")} +

+

+ {$at("The current firmware is missing signature files. Integrity cannot be fully verified.")} +

+
+ ); + } + + if (signatureStatus.appSignatureInvalid) { + return ( +
+

+ {$at("Signature Verification Failed")} +

+

+ {$at("The signature file exists but does not match the firmware. This may indicate tampering.")} +

+
+ ); + } + + if (signatureStatus.appNoPublicKey) { + return ( +
+

+ {$at("No Embedded Public Key")} +

+

+ {$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")} +

+
+ ); + } + + return null; +}