feat(ota): add OTA signature verification and public key handling

Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
luckfox-eng29
2026-05-08 11:27:46 +08:00
parent d47bca1940
commit 233e6e9cd6
6 changed files with 626 additions and 118 deletions

View File

@@ -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)

34
cli.go
View File

@@ -410,20 +410,23 @@ var signerKeygenCmd = &cobra.Command{
}
var signerSignCmd = &cobra.Command{
Use: "sign --key <private-key-path> <firmware-file>",
Use: "sign --key <private-key> <firmware-file>",
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 <pubkey-path-or-hex> <firmware-file> [<sig-file>]",
Use: "verify [--pubkey <pubkey-path-or-hex>] <firmware-file> [<sig-file>]",
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)

110
config.go
View File

@@ -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
}

285
ota.go
View File

@@ -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)
}

View File

@@ -38,8 +38,8 @@ const appendStatToMap = <T extends { timestamp: number }>(
};
// 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<RTCState>(set => ({
@@ -201,9 +198,6 @@ export const useRTCStore = create<RTCState>(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<HidState>((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<UpdateState>(set => ({
appUpdatedAt: null,
systemUpdateProgress: 0,
systemUpdatedAt: null,
appSignatureMissing: false,
systemSignatureMissing: false,
appSignatureAbsent: false,
appSignatureInvalid: false,
appNoPublicKey: false,
signatureVerified: false,
},
updateDialogHasBeenMinimized: false,

View File

@@ -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<HTMLDivElement | null>(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() {
}
/>
<SignatureStatusCard
signatureStatus={signatureStatus}
signatureStatusLoading={signatureStatusLoading}
/>
{!isBootFromSD && (
<>
<UpdateSourceSettings
@@ -382,6 +420,7 @@ function UpdateContent({
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={onClose}
versionInfo={versionInfo}
/>
)}
@@ -407,23 +446,58 @@ function LoadingState({
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((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<SystemVersionInfo>((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<SystemVersionInfo>((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<SystemVersionInfo> = {
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 (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
@@ -684,6 +764,50 @@ function SystemUpToDateState({
{$at("Your system is running the latest version. No updates are currently available.")}
</p>
{hasAbsentSig && (
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Missing Signature File")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
</p>
</div>
)}
{hasInvalidSig && (
<div className="mt-4 rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
{$at("Signature Verification Failed")}
</p>
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
</p>
</div>
)}
{hasNoPublicKey && (
<div className="mt-4 rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("No Embedded Public Key")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
</p>
</div>
)}
{versionInfo?.signatureVerified && (
<div className="mt-4 rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{$at("Signature Verified")}
</p>
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
{$at("Firmware signature has been verified and is valid.")}
</p>
</div>
)}
<div className="mt-4 flex gap-x-2">
<AntdButton type="primary" onClick={checkUpdate}>
{$at("Check Again")}
@@ -825,3 +949,101 @@ function UpdateErrorState({
</div>
);
}
function SignatureStatusCard({
signatureStatus,
signatureStatusLoading,
}: {
signatureStatus: {
appSignatureAbsent: boolean;
appSignatureInvalid: boolean;
appNoPublicKey: boolean;
signatureVerified: boolean;
} | null;
signatureStatusLoading: boolean;
}) {
const { $at } = useReactAt();
if (signatureStatusLoading) {
return (
<div className="rounded-md border border-slate-300 bg-slate-50 p-3 dark:border-slate-600 dark:bg-slate-900/30">
<div className="flex items-center gap-x-2">
<LoadingSpinner className="h-4 w-4 text-[rgba(22,152,217,1)] dark:text-[rgba(45,106,229,1)]" />
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
{$at("Verifying signature...")}
</p>
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
{$at("Please wait while verifying firmware signature.")}
</p>
</div>
);
}
if (!signatureStatus) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Signature Status Unavailable")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("Unable to retrieve signature verification status.")}
</p>
</div>
);
}
if (signatureStatus.signatureVerified) {
return (
<div className="rounded-md border border-green-500 bg-green-50 p-3 dark:border-green-600 dark:bg-green-900/30">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
<CheckCircleIcon className="inline h-4 w-4 mr-1" />
{$at("Signature Verified")}
</p>
<p className="mt-1 text-xs text-green-700 dark:text-green-300">
{$at("Firmware signature has been verified and is valid.")}
</p>
</div>
);
}
if (signatureStatus.appSignatureAbsent) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("Missing Signature File")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("The current firmware is missing signature files. Integrity cannot be fully verified.")}
</p>
</div>
);
}
if (signatureStatus.appSignatureInvalid) {
return (
<div className="rounded-md border border-red-500 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/30">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
{$at("Signature Verification Failed")}
</p>
<p className="mt-1 text-xs text-red-700 dark:text-red-300">
{$at("The signature file exists but does not match the firmware. This may indicate tampering.")}
</p>
</div>
);
}
if (signatureStatus.appNoPublicKey) {
return (
<div className="rounded-md border border-yellow-500 bg-yellow-50 p-3 dark:border-yellow-600 dark:bg-yellow-900/30">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$at("No Embedded Public Key")}
</p>
<p className="mt-1 text-xs text-yellow-700 dark:text-yellow-300">
{$at("This build does not have an OTA public key embedded. Signature verification is unavailable.")}
</p>
</div>
);
}
return null;
}