From 92a4a2e1ec58dbec8bd75196a80cd3a3f7b35682 Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Sun, 22 Mar 2026 15:25:45 -0700 Subject: [PATCH 1/6] fix(ota): return only the matched entity from parseAndValidateKeyring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseAndValidateKeyring validates that at least one entity in a fetched keyring matches the pinned root key fingerprint (rootKeyFingerprint, gpg.go:21). On match, it returns the entire keyring — including any additional entities the keyserver included in its response. This is a problem because openpgp.CheckDetachedSignature iterates every key in the provided keyring and accepts a signature from any of them. A compromised or malicious keyserver could return a response containing the legitimate JetKVM release key (satisfying the fingerprint check) alongside an attacker-controlled key. A binary signed with the attacker key would then pass verification in both VerifySignature and VerifySignatureFromFile, since both pass the cached keyring directly to CheckDetachedSignature. The fix is a single-line change: return openpgp.EntityList{entity} instead of the full keyring when the fingerprint matches. This ensures only the trusted key is ever used for signature verification regardless of what a keyserver returns. TestParseAndValidateKeyring_FiltersRogueKeys exercises this by constructing a two-entity armored keyring (trusted + rogue), passing it through parseAndValidateKeyring, asserting the returned keyring contains exactly one entity with the correct fingerprint, and confirming that CheckDetachedSignature rejects a signature produced by the rogue key. Reported-by: equinox0815 Signed-off-by: Alex Howells --- internal/ota/gpg.go | 2 +- internal/ota/gpg_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/ota/gpg.go b/internal/ota/gpg.go index ebc47025f..ea5ca845f 100644 --- a/internal/ota/gpg.go +++ b/internal/ota/gpg.go @@ -195,7 +195,7 @@ func (g *GPGVerifier) parseAndValidateKeyring(key []byte) (openpgp.EntityList, e fp := normalizeFingerprint(hex.EncodeToString(entity.PrimaryKey.Fingerprint[:])) if fp == expected { - return keyring, nil + return openpgp.EntityList{entity}, nil } } diff --git a/internal/ota/gpg_test.go b/internal/ota/gpg_test.go index 5303e3487..902f1ce5a 100644 --- a/internal/ota/gpg_test.go +++ b/internal/ota/gpg_test.go @@ -302,6 +302,43 @@ func TestFetchPublicKey_CachedKeyIsValid(t *testing.T) { assert.Equal(t, int32(1), callCount.Load(), "VerifySignature should use cached key") } +func TestParseAndValidateKeyring_FiltersRogueKeys(t *testing.T) { + // Generate the trusted key and a rogue key + trustedEntity, err := openpgp.NewEntity("Trusted", "", "trusted@example.com", nil) + require.NoError(t, err) + rogueEntity, err := openpgp.NewEntity("Rogue", "", "rogue@example.com", nil) + require.NoError(t, err) + + // Armor both keys into a single keyring (as a malicious keyserver would) + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + require.NoError(t, trustedEntity.Serialize(w)) + require.NoError(t, rogueEntity.Serialize(w)) + require.NoError(t, w.Close()) + + trustedFP := strings.ToUpper(hex.EncodeToString(trustedEntity.PrimaryKey.Fingerprint[:])) + + v := newTestGPGVerifier() + v.rootKeyFP = trustedFP + + keyring, err := v.parseAndValidateKeyring(buf.Bytes()) + require.NoError(t, err) + require.Len(t, keyring, 1, "keyring must contain only the trusted key, not the rogue key") + + returnedFP := strings.ToUpper(hex.EncodeToString(keyring[0].PrimaryKey.Fingerprint[:])) + assert.Equal(t, trustedFP, returnedFP) + + // Sign data with the rogue key — verification must fail + testData := []byte("payload") + var sigBuf bytes.Buffer + err = openpgp.DetachSign(&sigBuf, rogueEntity, bytes.NewReader(testData), nil) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring, bytes.NewReader(testData), bytes.NewReader(sigBuf.Bytes()), nil) + assert.Error(t, err, "signature from rogue key must not verify against filtered keyring") +} + func TestFetchPublicKey_RejectsFingerprintMismatch(t *testing.T) { expectedKey := generateTestArmoredKey(t) servedKey := generateTestArmoredKey(t) From 86fb0e7374a2643e1641af3b5eb950c77f23b1f5 Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Tue, 17 Mar 2026 11:28:49 -0700 Subject: [PATCH 2/6] feat(ota): add offline update archive extraction and verification Introduce internal/ota/offline.go with two primary functions: - ExtractOfflineArchive() reads a .tar.gz upload, validates that it contains exactly the expected files (binary + .sha256 + .sig), rejects path traversal and unexpected entries, strips leading directory prefixes (for archives created with tar czf wrapper dirs), and returns an OfflineBundle struct. - VerifyOfflineBundle() runs SHA256 verification against the hash from the archive (hard reject on mismatch), then attempts GPG signature verification via the existing GPGVerifier. When keyservers are unreachable (air-gapped device), returns KeyFetchFailed=true instead of rejecting, allowing the caller to prompt the user for bypass confirmation. Bad signatures (key available, sig invalid) are always fatal. Refactor updateSystem() to extract applySystemImage() as a reusable function for running rk_ota on a staged system tar. Add ApplyOfflineUpdate() to State for the offline apply flow, plus GPGVerifier() and ComponentUpdatePath() accessors. Table-driven tests in offline_test.go cover extraction (valid app/system archives, missing hash, missing sig, missing binary, unexpected files, path traversal, corrupt gzip, nested directories) and verification (valid signature, hash mismatch, invalid signature, wrong signing key, empty signature, key fetch failure for air-gapped devices, truncated signature, corrupted binary on disk). Signed-off-by: Alex Howells --- internal/ota/offline.go | 222 ++++++++++++++ internal/ota/offline_test.go | 567 +++++++++++++++++++++++++++++++++++ internal/ota/state.go | 81 ++++- internal/ota/sys.go | 34 ++- 4 files changed, 892 insertions(+), 12 deletions(-) create mode 100644 internal/ota/offline.go create mode 100644 internal/ota/offline_test.go diff --git a/internal/ota/offline.go b/internal/ota/offline.go new file mode 100644 index 000000000..6d30d2b95 --- /dev/null +++ b/internal/ota/offline.go @@ -0,0 +1,222 @@ +package ota + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog" +) + +// OfflineBundle represents a validated offline update archive that has been +// extracted and is ready for verification. +type OfflineBundle struct { + BinaryPath string // absolute path to the extracted binary + ExpectedHash string // SHA256 hex digest read from the .sha256 file + Signature []byte // raw GPG signature bytes (nil if no .sig was present) + Component string // "app" or "system" +} + +// OfflineVerifyResult captures the outcome of offline bundle verification. +type OfflineVerifyResult struct { + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` + SignatureError string `json:"signatureError,omitempty"` + KeyFetchFailed bool `json:"keyFetchFailed"` +} + +// expectedBinaryNames maps component names to the binary filename expected +// inside the offline update archive. +var expectedBinaryNames = map[string]string{ + "app": "jetkvm_app", + "system": "update_system.tar", +} + +// ExtractOfflineArchive reads a gzipped tar archive from r and extracts it +// into destDir. It validates the archive structure: exactly one binary +// matching the component, one .sha256 hash file, and optionally one .sig +// file. Path traversal attempts are rejected. +func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zerolog.Logger) (*OfflineBundle, error) { + binaryName, ok := expectedBinaryNames[component] + if !ok { + return nil, fmt.Errorf("unknown component: %s", component) + } + + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + + bundle := &OfflineBundle{Component: component} + fileCount := 0 + const maxFiles = 3 // binary + .sha256 + .sig + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("error reading tar: %w", err) + } + + name := filepath.Clean(header.Name) + + // strip leading directory component if the archive was created with one + name = filepath.Base(name) + + if strings.Contains(name, "..") || filepath.IsAbs(name) { + return nil, fmt.Errorf("path traversal detected in archive: %s", header.Name) + } + + // skip directories + if header.Typeflag == tar.TypeDir { + continue + } + + fileCount++ + if fileCount > maxFiles { + return nil, fmt.Errorf("archive contains more than %d files", maxFiles) + } + + destPath := filepath.Join(destDir, name) + + switch { + case name == binaryName: + if err := extractFileFromTar(tr, destPath, header.Mode); err != nil { + return nil, fmt.Errorf("error extracting binary: %w", err) + } + bundle.BinaryPath = destPath + l.Debug().Str("path", destPath).Msg("extracted binary") + + case name == binaryName+".sha256": + hashBytes, err := io.ReadAll(io.LimitReader(tr, 256)) + if err != nil { + return nil, fmt.Errorf("error reading hash file: %w", err) + } + // hash file format: " " or just "" + hashStr := strings.TrimSpace(string(hashBytes)) + if idx := strings.IndexByte(hashStr, ' '); idx > 0 { + hashStr = hashStr[:idx] + } + bundle.ExpectedHash = strings.ToLower(hashStr) + l.Debug().Str("hash", bundle.ExpectedHash).Msg("read expected hash") + + case name == binaryName+".sig": + sig, err := io.ReadAll(io.LimitReader(tr, 8192)) + if err != nil { + return nil, fmt.Errorf("error reading signature file: %w", err) + } + bundle.Signature = sig + l.Debug().Int("bytes", len(sig)).Msg("read signature") + + default: + return nil, fmt.Errorf("unexpected file in archive: %s", name) + } + } + + if bundle.BinaryPath == "" { + return nil, fmt.Errorf("archive missing required binary: %s", binaryName) + } + if bundle.ExpectedHash == "" { + return nil, fmt.Errorf("archive missing required hash file: %s.sha256", binaryName) + } + if len(bundle.Signature) == 0 { + return nil, fmt.Errorf("archive missing required signature file: %s.sig", binaryName) + } + + return bundle, nil +} + +// extractFileFromTar writes a tar entry to the given destination path. +func extractFileFromTar(tr *tar.Reader, destPath string, mode int64) error { + f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode)|0644) + if err != nil { + return fmt.Errorf("error creating file %s: %w", destPath, err) + } + defer f.Close() + + if _, err := io.Copy(f, tr); err != nil { + return fmt.Errorf("error writing file %s: %w", destPath, err) + } + return nil +} + +// VerifyOfflineBundle checks the SHA256 hash and GPG signature of an +// extracted offline bundle. Hash mismatches are always fatal. Signature +// verification is attempted; if the GPG public key cannot be fetched +// (air-gapped device), KeyFetchFailed is set instead of returning an error. +// A bad signature (key available, verification failed) is always fatal. +func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) { + result := &OfflineVerifyResult{} + + // SHA256 verification + hash, err := hashFile(bundle.BinaryPath) + if err != nil { + return nil, fmt.Errorf("error hashing file: %w", err) + } + + if hash != bundle.ExpectedHash { + return nil, fmt.Errorf("hash mismatch: got %s, expected %s", hash, bundle.ExpectedHash) + } + result.HashOK = true + l.Info().Str("hash", hash).Msg("SHA256 hash verified") + + // GPG signature verification + if len(bundle.Signature) == 0 { + return nil, fmt.Errorf("signature is required for offline updates") + } + + err = gpgVerifier.VerifySignatureFromFile(ctx, bundle.Signature, bundle.BinaryPath) + if err != nil { + errStr := err.Error() + // Distinguish between key-fetch failure (air-gapped) and actual bad signature. + // Key fetch failures contain "keyserver" or "fetch" or "cancelled" in the error chain. + if isKeyFetchError(errStr) { + result.KeyFetchFailed = true + result.SignatureError = errStr + l.Warn().Err(err).Msg("GPG key fetch failed (device may be air-gapped)") + return result, nil + } + return nil, fmt.Errorf("GPG signature verification failed: %w", err) + } + + result.SignatureOK = true + l.Info().Msg("GPG signature verified") + return result, nil +} + +// isKeyFetchError returns true if the error string indicates a key fetch +// failure rather than an actual signature mismatch. +func isKeyFetchError(errStr string) bool { + lower := strings.ToLower(errStr) + return strings.Contains(lower, "keyserver") || + strings.Contains(lower, "fetch") || + strings.Contains(lower, "cancelled") || + strings.Contains(lower, "all keyservers failed") +} + +// hashFile computes the SHA256 hex digest of the file at path. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/ota/offline_test.go b/internal/ota/offline_test.go new file mode 100644 index 000000000..7a97c6de3 --- /dev/null +++ b/internal/ota/offline_test.go @@ -0,0 +1,567 @@ +package ota + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildArchive creates a tar.gz in memory from a map of filename→content. +func buildArchive(t *testing.T, files map[string][]byte) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range files { + err := tw.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0644, + }) + require.NoError(t, err) + _, err = tw.Write(content) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + return &buf +} + +func sha256hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func testLogger() *zerolog.Logger { + l := zerolog.New(io.Discard) + return &l +} + +func TestExtractOfflineArchive_ValidApp(t *testing.T) { + binary := []byte("fake-app-binary") + hash := sha256hex(binary) + sig := []byte("fake-signature-bytes") + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), + "jetkvm_app.sig": sig, + }) + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + require.NoError(t, err) + + assert.Equal(t, "app", bundle.Component) + assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, sig, bundle.Signature) + assert.Equal(t, filepath.Join(destDir, "jetkvm_app"), bundle.BinaryPath) + + // binary should be on disk + content, err := os.ReadFile(bundle.BinaryPath) + require.NoError(t, err) + assert.Equal(t, binary, content) +} + +func TestExtractOfflineArchive_ValidSystem(t *testing.T) { + binary := []byte("fake-system-tar") + hash := sha256hex(binary) + sig := []byte("fake-sig") + + archive := buildArchive(t, map[string][]byte{ + "update_system.tar": binary, + "update_system.tar.sha256": []byte(hash), + "update_system.tar.sig": sig, + }) + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, destDir, "system", testLogger()) + require.NoError(t, err) + + assert.Equal(t, "system", bundle.Component) + assert.Equal(t, hash, bundle.ExpectedHash) +} + +func TestExtractOfflineArchive_HashOnly(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required signature file") +} + +func TestExtractOfflineArchive_MissingHash(t *testing.T) { + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": []byte("binary"), + "jetkvm_app.sig": []byte("sig"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required hash file") +} + +func TestExtractOfflineArchive_MissingBinary(t *testing.T) { + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app.sha256": []byte("abc123"), + "jetkvm_app.sig": []byte("sig"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required binary") +} + +func TestExtractOfflineArchive_UnexpectedFile(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": []byte("sig"), + "malicious.sh": []byte("#!/bin/bash\nrm -rf /"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.Error(t, err) // either "unexpected file" or "more than 3 files" +} + +func TestExtractOfflineArchive_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + _ = tw.WriteHeader(&tar.Header{ + Name: "../../etc/passwd", + Size: 5, + Mode: 0644, + }) + _, _ = tw.Write([]byte("pwned")) + _ = tw.Close() + _ = gw.Close() + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(&buf, destDir, "app", testLogger()) + // Will fail as unexpected file since basename won't match expected names + assert.Error(t, err) +} + +func TestExtractOfflineArchive_UnknownComponent(t *testing.T) { + archive := buildArchive(t, map[string][]byte{}) + _, err := ExtractOfflineArchive(archive, t.TempDir(), "unknown", testLogger()) + assert.ErrorContains(t, err, "unknown component") +} + +func TestExtractOfflineArchive_CorruptGzip(t *testing.T) { + _, err := ExtractOfflineArchive(bytes.NewReader([]byte("not-gzip")), t.TempDir(), "app", testLogger()) + assert.ErrorContains(t, err, "gzip") +} + +func TestExtractOfflineArchive_NestedDirectory(t *testing.T) { + // Archives created with `tar czf` often wrap files in a directory. + // The extractor should strip the leading directory and match by basename. + binary := []byte("app-binary") + hash := sha256hex(binary) + sig := []byte("sig-bytes") + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range map[string][]byte{ + "jetkvm_app_offline_update/jetkvm_app": binary, + "jetkvm_app_offline_update/jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), + "jetkvm_app_offline_update/jetkvm_app.sig": sig, + } { + _ = tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0644}) + _, _ = tw.Write(content) + } + _ = tw.Close() + _ = gw.Close() + + destDir := t.TempDir() + bundle, err := ExtractOfflineArchive(&buf, destDir, "app", testLogger()) + require.NoError(t, err) + assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, sig, bundle.Signature) +} + +func TestHashFile(t *testing.T) { + content := []byte("hello world") + expected := sha256hex(content) + + path := filepath.Join(t.TempDir(), "test") + require.NoError(t, os.WriteFile(path, content, 0644)) + + got, err := hashFile(path) + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestIsKeyFetchError(t *testing.T) { + tests := []struct { + err string + expect bool + }{ + {"all keyservers failed: [err1, err2]", true}, + {"failed to fetch public key: connection refused", true}, + {"key fetch cancelled: context deadline exceeded", true}, + {"signature verification failed: openpgp: invalid signature", false}, + {"hash mismatch: abc != def", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.expect, isKeyFetchError(tt.err), "input: %s", tt.err) + } +} + +// --- VerifyOfflineBundle tests --- + +// newSigningTestFixture generates a GPG key pair and returns: +// - a GPGVerifier wired to a mock keyserver that serves the public key +// - the private entity (for producing signatures) +// - a cleanup function (restores global keyservers) +func newSigningTestFixture(t *testing.T) (*GPGVerifier, *openpgp.Entity) { + t.Helper() + + entity, err := openpgp.NewEntity("Offline Test", "", "offline@test.local", nil) + require.NoError(t, err) + + // Armour the public key + var pubBuf bytes.Buffer + w, err := armor.Encode(&pubBuf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + require.NoError(t, entity.Serialize(w)) + require.NoError(t, w.Close()) + + callCount := &atomic.Int32{} + mock := &keyServingHTTPClient{key: pubBuf.Bytes(), callCount: callCount} + v := newGPGVerifierWithMock(t, func() HttpClient { return mock }) + v.rootKeyFP = extractFingerprintFromArmoredKey(t, pubBuf.Bytes()) + + return v, entity +} + +// signData produces a detached GPG signature over data using entity's private key. +func signData(t *testing.T, entity *openpgp.Entity, data []byte) []byte { + t.Helper() + var sigBuf bytes.Buffer + err := openpgp.DetachSign(&sigBuf, entity, bytes.NewReader(data), nil) + require.NoError(t, err) + return sigBuf.Bytes() +} + +// writeBundle writes a binary to disk and returns an OfflineBundle ready for verification. +func writeBundle(t *testing.T, binary []byte, sig []byte) *OfflineBundle { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "jetkvm_app") + require.NoError(t, os.WriteFile(path, binary, 0644)) + return &OfflineBundle{ + BinaryPath: path, + ExpectedHash: sha256hex(binary), + Signature: sig, + Component: "app", + } +} + +func TestVerifyOfflineBundle_ValidSignature(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("valid-app-binary-content") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig) + + result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK, "hash should pass") + assert.True(t, result.SignatureOK, "signature should pass") + assert.False(t, result.KeyFetchFailed, "key fetch should succeed") + assert.Empty(t, result.SignatureError) +} + +func TestVerifyOfflineBundle_HashMismatch(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("real-binary") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig) + + // Corrupt the expected hash so it won't match the file on disk + bundle.ExpectedHash = "0000000000000000000000000000000000000000000000000000000000000000" + + _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +func TestVerifyOfflineBundle_InvalidSignature(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("the-real-binary") + differentContent := []byte("tampered-binary") + + // Sign the tampered content, but the bundle points at the real binary. + // This means the signature won't match the file being verified. + sig := signData(t, entity, differentContent) + bundle := writeBundle(t, binary, sig) + + _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +func TestVerifyOfflineBundle_WrongKey(t *testing.T) { + // Verifier is wired to key A, but the binary is signed with key B. + gpgVerifier, _ := newSigningTestFixture(t) + + // Generate a completely different key pair for signing + otherEntity, err := openpgp.NewEntity("Attacker", "", "evil@attacker.com", nil) + require.NoError(t, err) + + binary := []byte("innocent-looking-binary") + sig := signData(t, otherEntity, binary) + bundle := writeBundle(t, binary, sig) + + _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +func TestVerifyOfflineBundle_EmptySignature(t *testing.T) { + gpgVerifier, _ := newSigningTestFixture(t) + + binary := []byte("unsigned-binary") + bundle := writeBundle(t, binary, nil) + bundle.Signature = nil + + _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "signature is required") +} + +func TestVerifyOfflineBundle_KeyFetchFailure(t *testing.T) { + // Simulate an air-gapped device: all keyserver requests fail. + callCount := &atomic.Int32{} + mock := &failingHTTPClient{callCount: callCount} + v := newGPGVerifierWithMock(t, func() HttpClient { return mock }) + // rootKeyFP doesn't matter since we'll never get a key to compare it against + + binary := []byte("offline-binary") + sig := []byte("some-signature-bytes") // content irrelevant; key fetch fails first + bundle := writeBundle(t, binary, sig) + + result, err := VerifyOfflineBundle(context.Background(), bundle, v, testLogger()) + require.NoError(t, err, "key fetch failure should not be a hard error") + assert.True(t, result.HashOK, "hash should still pass") + assert.False(t, result.SignatureOK, "signature should not be marked OK") + assert.True(t, result.KeyFetchFailed, "should indicate key fetch failed") + assert.NotEmpty(t, result.SignatureError) +} + +func TestVerifyOfflineBundle_TruncatedSignature(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("binary-with-truncated-sig") + fullSig := signData(t, entity, binary) + + // Truncate the signature to corrupt it + truncatedSig := fullSig[:len(fullSig)/2] + bundle := writeBundle(t, binary, truncatedSig) + + _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +func TestVerifyOfflineBundle_CorruptedBinary(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + originalBinary := []byte("original-binary-content") + sig := signData(t, entity, originalBinary) + + // Write the original, get a valid bundle, then overwrite the file + bundle := writeBundle(t, originalBinary, sig) + require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("corrupted-binary"), 0644)) + + // Hash will mismatch because the file content no longer matches ExpectedHash + _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// --- ComponentUpdatePath tests --- + +func TestComponentUpdatePath_App(t *testing.T) { + path, err := ComponentUpdatePath("app") + require.NoError(t, err) + assert.Equal(t, appUpdatePath, path) +} + +func TestComponentUpdatePath_System(t *testing.T) { + path, err := ComponentUpdatePath("system") + require.NoError(t, err) + assert.Equal(t, systemUpdatePath, path) +} + +func TestComponentUpdatePath_Unknown(t *testing.T) { + _, err := ComponentUpdatePath("unknown") + assert.ErrorContains(t, err, "unknown component") +} + +// --- End-to-end pipeline tests --- + +// TestEndToEnd_ExtractAndVerify_ValidArchive exercises the full pipeline: +// build a tar.gz with a real GPG signature → extract → verify. +func TestEndToEnd_ExtractAndVerify_ValidArchive(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("end-to-end-test-binary-content-here") + hash := sha256hex(binary) + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), + "jetkvm_app.sig": sig, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK) + assert.True(t, result.SignatureOK) + assert.False(t, result.KeyFetchFailed) +} + +// TestEndToEnd_ExtractAndVerify_TamperedBinary builds a valid archive then +// overwrites the extracted binary before verification — simulating +// file-level tampering after extraction. +func TestEndToEnd_ExtractAndVerify_TamperedBinary(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("legitimate-binary") + hash := sha256hex(binary) + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": sig, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + // Tamper with extracted binary on disk + require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("tampered!"), 0644)) + + _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// TestEndToEnd_ExtractAndVerify_WrongSignature builds an archive where +// the signature was produced by a different key than the verifier expects. +func TestEndToEnd_ExtractAndVerify_WrongSignature(t *testing.T) { + gpgVerifier, _ := newSigningTestFixture(t) // verifier expects key A + + attackerEntity, err := openpgp.NewEntity("Attacker", "", "evil@example.com", nil) + require.NoError(t, err) + + binary := []byte("innocuous-binary") + hash := sha256hex(binary) + sig := signData(t, attackerEntity, binary) // signed with key B + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": sig, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + + _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "GPG signature verification failed") +} + +// TestEndToEnd_ExtractAndVerify_HashMismatchInArchive builds an archive +// where the .sha256 file contains the wrong hash for the binary. +func TestEndToEnd_ExtractAndVerify_HashMismatchInArchive(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("real-binary-content") + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(wrongHash), + "jetkvm_app.sig": sig, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) + require.NoError(t, err) + // The extraction succeeds — hash mismatch is caught at verification time + assert.Equal(t, wrongHash, bundle.ExpectedHash) + + _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "hash mismatch") +} + +// TestEndToEnd_SystemArchive verifies the full pipeline works for system +// component archives with the different expected file names. +func TestEndToEnd_SystemArchive(t *testing.T) { + gpgVerifier, entity := newSigningTestFixture(t) + + binary := []byte("system-image-tar-content") + hash := sha256hex(binary) + sig := signData(t, entity, binary) + + archive := buildArchive(t, map[string][]byte{ + "update_system.tar": binary, + "update_system.tar.sha256": []byte(hash), + "update_system.tar.sig": sig, + }) + + extractDir := t.TempDir() + bundle, err := ExtractOfflineArchive(archive, extractDir, "system", testLogger()) + require.NoError(t, err) + assert.Equal(t, "system", bundle.Component) + + result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + require.NoError(t, err) + assert.True(t, result.HashOK) + assert.True(t, result.SignatureOK) +} diff --git a/internal/ota/state.go b/internal/ota/state.go index e2fffbdd6..52cf63bec 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -1,6 +1,8 @@ package ota import ( + "context" + "fmt" "sync" "time" @@ -178,9 +180,82 @@ func (s *State) ToUpdateStatus() *UpdateStatus { return toUpdateStatus(&appUpdate, &systemUpdate, s.error) } -// IsUpdatePending returns true if an update is pending -func (s *State) IsUpdatePending() bool { - return s.updating +// ApplyOfflineUpdate applies a pre-verified, pre-staged offline update for +// the given component. For app updates, this simply triggers a reboot (the +// boot sequence picks up jetkvm_app.update). For system updates, it runs +// rk_ota then reboots. The caller is responsible for ensuring the file has +// been verified and staged at the correct path before calling this. +func (s *State) ApplyOfflineUpdate(ctx context.Context, component string) error { + locked := s.mu.TryLock() + if !locked { + return fmt.Errorf("update already in progress") + } + defer s.mu.Unlock() + + s.updating = true + s.metadataFetchedAt = time.Now() + s.triggerStateUpdate() + + componentStatus := componentUpdateStatus{ + pending: true, + downloadProgress: 1, + downloadFinishedAt: time.Now(), + verificationProgress: 1, + verifiedAt: time.Now(), + } + + s.triggerComponentUpdateState(component, &componentStatus) + + l := s.l.With().Str("component", component).Logger() + + if component == "system" { + if err := s.applySystemImage(&componentStatus); err != nil { + return s.componentUpdateError("Error applying offline system update", err, &l) + } + } else { + // App: the verified binary is already at appUpdatePath; just mark complete. + componentStatus.updateProgress = 1 + componentStatus.updatedAt = time.Now() + s.triggerComponentUpdateState(component, &componentStatus) + } + + s.rebootNeeded = true + + // Disable auto-update: the user explicitly chose an offline update version. + if _, err := s.setAutoUpdate(false); err != nil { + l.Warn().Err(err).Msg("failed to disable auto-update after offline update") + } + + l.Info().Msg("rebooting after offline update") + + postRebootAction := &PostRebootAction{ + HealthCheck: "/device/status", + RedirectTo: "/settings/general/update", + } + + if err := s.reboot(true, postRebootAction, 5*time.Second); err != nil { + return s.componentUpdateError("Error requesting reboot", err, &l) + } + + return nil +} + +// GPGVerifier returns the GPG verifier instance used by this state. +func (s *State) GPGVerifier() *GPGVerifier { + return s.gpgVerifier +} + +// ComponentUpdatePath returns the filesystem path where verified update +// files should be staged for the given component. +func ComponentUpdatePath(component string) (string, error) { + switch component { + case "app": + return appUpdatePath, nil + case "system": + return systemUpdatePath, nil + default: + return "", fmt.Errorf("unknown component: %s", component) + } } // Options represents the options for the OTA state diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 162aa14a8..156c8c767 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -49,6 +49,23 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS l.Info().Msg("System update downloaded") + if err := s.applySystemImage(systemUpdate); err != nil { + return err + } + + s.rebootNeeded = true + systemUpdate.updateProgress = 1 + systemUpdate.updatedAt = verifyFinished + s.triggerComponentUpdateState("system", systemUpdate) + + return nil +} + +// applySystemImage runs rk_ota to flash a verified system tar that is +// already staged at systemUpdatePath. +func (s *State) applySystemImage(systemUpdate *componentUpdateStatus) error { + l := s.l.With().Str("path", systemUpdatePath).Logger() + l.Info().Msg("Starting rk_ota command") cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all") @@ -82,21 +99,20 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS } }() - err = cmd.Wait() + if err := cmd.Wait(); err != nil { + cancel() + rkLogger := s.l.With(). + Str("output", b.String()). + Int("exitCode", cmd.ProcessState.ExitCode()).Logger() + return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) + } cancel() + rkLogger := s.l.With(). Str("output", b.String()). Int("exitCode", cmd.ProcessState.ExitCode()).Logger() - if err != nil { - return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) - } rkLogger.Info().Msg("rk_ota success") - s.rebootNeeded = true - systemUpdate.updateProgress = 1 - systemUpdate.updatedAt = verifyFinished - s.triggerComponentUpdateState("system", systemUpdate) - return nil } From d3893c2be2be48d6ac83ed3a97de5845998a6267 Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Tue, 17 Mar 2026 13:26:18 -0700 Subject: [PATCH 3/6] feat(api): add offline update upload and apply endpoints Introduce two new HTTP endpoints behind protectedMiddleware: - POST /ota/upload: accepts multipart form with a .tar.gz offline update archive and a component field (app or system). Extracts the archive to a temp directory, validates structure via ExtractOfflineArchive(), runs hash and GPG verification via VerifyOfflineBundle(), and stages the verified binary at the standard OTA path (/userdata/jetkvm/jetkvm_app.update or update_system.tar). Returns a JSON response indicating hash, signature, and key-fetch status so the frontend can prompt for signature bypass on air-gapped devices. Enforces a 200MB upload limit and rejects requests when an update is already in progress. - POST /ota/apply: accepts JSON with component and bypassSignature fields. Verifies a staged file exists, then delegates to ApplyOfflineUpdate() which runs rk_ota for system images or triggers reboot for app binaries. Disables auto-update since the user explicitly chose a version. The apply runs asynchronously and the device reboots on completion. The handlers live in ota_offline.go to keep web.go focused on routing. Routes registered in web.go protected group alongside the existing /storage/upload endpoint. Signed-off-by: Alex Howells --- ota_offline.go | 213 +++++++++++++++++++++++++++++++++++++++++++++++++ web.go | 3 + 2 files changed, 216 insertions(+) create mode 100644 ota_offline.go diff --git a/ota_offline.go b/ota_offline.go new file mode 100644 index 000000000..8798985d9 --- /dev/null +++ b/ota_offline.go @@ -0,0 +1,213 @@ +package kvm + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/jetkvm/kvm/internal/ota" +) + +const ( + // maxOfflineUploadSize limits offline update archives to 200MB. + maxOfflineUploadSize = 200 << 20 + // offlineUploadTimeout bounds the entire upload+verify cycle. + offlineUploadTimeout = 10 * time.Minute +) + +// offlineUpdateUploadResponse is returned by POST /ota/upload. +type offlineUpdateUploadResponse struct { + Verified bool `json:"verified"` + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` + KeyFetchFailed bool `json:"keyFetchFailed"` + SignatureError string `json:"signatureError,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleOfflineUpdateUpload handles POST /ota/upload. +// Accepts a multipart form with fields: +// - component: "app" or "system" +// - file: the .tar.gz offline update archive +// +// Extracts, validates structure, verifies hash + GPG signature, and stages +// the verified binary at the standard OTA path. +func handleOfflineUpdateUpload(c *gin.Context) { + if otaState.IsUpdatePending() { + c.JSON(http.StatusConflict, offlineUpdateUploadResponse{ + Error: "an update is already in progress", + }) + return + } + + component := c.PostForm("component") + if component != "app" && component != "system" { + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: "component must be 'app' or 'system'", + }) + return + } + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: "missing or invalid file upload", + }) + return + } + + if file.Size > maxOfflineUploadSize { + c.JSON(http.StatusRequestEntityTooLarge, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("file exceeds maximum size of %d MB", maxOfflineUploadSize>>20), + }) + return + } + + f, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to read uploaded file", + }) + return + } + defer f.Close() + + // Extract to a temp directory + extractDir, err := os.MkdirTemp("", "jetkvm-offline-update-*") + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to create temporary directory", + }) + return + } + defer os.RemoveAll(extractDir) + + l := otaLogger.With().Str("component", component).Logger() + + bundle, err := ota.ExtractOfflineArchive(f, extractDir, component, &l) + if err != nil { + l.Warn().Err(err).Msg("offline archive extraction failed") + c.JSON(http.StatusBadRequest, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("invalid archive: %v", err), + }) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), offlineUploadTimeout) + defer cancel() + + result, err := ota.VerifyOfflineBundle(ctx, bundle, otaState.GPGVerifier(), &l) + if err != nil { + l.Warn().Err(err).Msg("offline bundle verification failed") + c.JSON(http.StatusUnprocessableEntity, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("verification failed: %v", err), + }) + return + } + + // Stage the verified binary at the standard OTA path + destPath, err := ota.ComponentUpdatePath(component) + if err != nil { + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: fmt.Sprintf("internal error: %v", err), + }) + return + } + + if err := os.Rename(bundle.BinaryPath, destPath); err != nil { + // Rename may fail across filesystems; fall back to copy + if err := copyFile(bundle.BinaryPath, destPath); err != nil { + l.Error().Err(err).Msg("failed to stage verified binary") + c.JSON(http.StatusInternalServerError, offlineUpdateUploadResponse{ + Error: "failed to stage verified update file", + }) + return + } + } + + if err := os.Chmod(destPath, 0755); err != nil { + l.Warn().Err(err).Msg("failed to set permissions on staged file") + } + + l.Info(). + Bool("hashOK", result.HashOK). + Bool("signatureOK", result.SignatureOK). + Bool("keyFetchFailed", result.KeyFetchFailed). + Msg("offline update uploaded and verified") + + c.JSON(http.StatusOK, offlineUpdateUploadResponse{ + Verified: result.HashOK && (result.SignatureOK || result.KeyFetchFailed), + HashOK: result.HashOK, + SignatureOK: result.SignatureOK, + KeyFetchFailed: result.KeyFetchFailed, + SignatureError: result.SignatureError, + }) +} + +// offlineUpdateApplyRequest is the body for POST /ota/apply. +type offlineUpdateApplyRequest struct { + Component string `json:"component" binding:"required"` + BypassSignature bool `json:"bypassSignature"` +} + +// handleOfflineUpdateApply handles POST /ota/apply. +// Applies a previously uploaded and verified offline update. +func handleOfflineUpdateApply(c *gin.Context) { + var req offlineUpdateApplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Component != "app" && req.Component != "system" { + c.JSON(http.StatusBadRequest, gin.H{"error": "component must be 'app' or 'system'"}) + return + } + + // Verify the staged file exists + destPath, err := ota.ComponentUpdatePath(req.Component) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("internal error: %v", err)}) + return + } + + if _, err := os.Stat(destPath); os.IsNotExist(err) { + c.JSON(http.StatusBadRequest, gin.H{"error": "no staged update found; upload first"}) + return + } + + l := otaLogger.With().Str("component", req.Component).Logger() + l.Info().Bool("bypassSignature", req.BypassSignature).Msg("applying offline update") + + // Apply asynchronously — the device will reboot + go func() { + if err := otaState.ApplyOfflineUpdate(context.Background(), req.Component); err != nil { + l.Error().Err(err).Msg("offline update apply failed") + } + }() + + c.JSON(http.StatusOK, gin.H{"message": "update is being applied; device will reboot"}) +} + +// copyFile copies src to dst, used when os.Rename fails across filesystems. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := out.ReadFrom(in); err != nil { + return err + } + return out.Sync() +} diff --git a/web.go b/web.go index bff35de19..7b205c27d 100644 --- a/web.go +++ b/web.go @@ -218,6 +218,9 @@ func setupRouter() *gin.Engine { protected.DELETE("/auth/local-password", handleDeletePassword) protected.POST("/storage/upload", handleUploadHttp) + protected.POST("/ota/upload", handleOfflineUpdateUpload) + protected.POST("/ota/apply", handleOfflineUpdateApply) + protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket) protected.GET("/diagnostics", handleDiagnosticsDownload) From 7958d23b280b207778b7f8dc8ab7be14998511e4 Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Tue, 17 Mar 2026 15:56:00 -0700 Subject: [PATCH 4/6] feat(ui): add offline update upload to settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OfflineUpdateCard component with two independent file upload sections (app and system). Each section provides: - File input accepting .tar.gz archives - Upload progress bar using XMLHttpRequest progress events - Verification status display (hash ✓, signature ✓) - Signature bypass prompt when the device cannot reach GPG keyservers (air-gapped networks): amber warning card explaining the situation with explicit 'Apply Without Signature Verification' confirmation - Error display with retry option The component communicates with POST /ota/upload for the upload+verify phase and POST /ota/apply for the apply+reboot phase. Rendered on the general settings page between auto-update toggle and reboot, making offline updates discoverable without cluttering the existing online update dialog flow. Adds 12 i18n keys to en.json and regenerates paraglide output. Signed-off-by: Alex Howells --- ui/localization/messages/en.json | 19 +- ui/src/components/OfflineUpdateCard.tsx | 267 ++++++++++++++++++ .../devices.$id.settings.general._index.tsx | 2 + 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/OfflineUpdateCard.tsx diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 4f566e5e8..b7ad09ff1 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -973,5 +973,22 @@ "wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "welcome_to_jetkvm": "Welcome to JetKVM", - "welcome_to_jetkvm_description": "Control any computer remotely" + "welcome_to_jetkvm_description": "Control any computer remotely", + "offline_update_title": "Offline Update", + "offline_update_description": "Upload an offline update archive (.tar.gz) downloaded from JetKVM releases.", + "offline_update_app_label": "App Update", + "offline_update_system_label": "System Update", + "offline_update_select_file": "Select File", + "offline_update_uploading": "Uploading…", + "offline_update_verifying": "Verifying…", + "offline_update_verified": "Verified", + "offline_update_applying": "Applying update…", + "offline_update_apply": "Apply Update", + "offline_update_hash_ok": "Hash verified", + "offline_update_signature_ok": "Signature verified", + "offline_update_signature_bypass_title": "Signature Verification Unavailable", + "offline_update_signature_bypass_description": "This device cannot reach GPG keyservers to verify the update signature. This is expected on air-gapped networks. You may proceed without signature verification if you trust the source of this update file.", + "offline_update_signature_bypass_confirm": "Apply Without Signature Verification", + "offline_update_error": "Upload failed: {error}", + "offline_update_invalid_file": "Please select a .tar.gz archive" } diff --git a/ui/src/components/OfflineUpdateCard.tsx b/ui/src/components/OfflineUpdateCard.tsx new file mode 100644 index 000000000..e46b47410 --- /dev/null +++ b/ui/src/components/OfflineUpdateCard.tsx @@ -0,0 +1,267 @@ +import { useCallback, useRef, useState } from "react"; +import { LuUpload, LuCheck } from "react-icons/lu"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; + +import { Button } from "@components/Button"; +import { SettingsItem } from "@components/SettingsItem"; +import { DEVICE_API } from "@/ui.config"; +import { m } from "@localizations/messages.js"; + +interface UploadResult { + verified: boolean; + hashOK: boolean; + signatureOK: boolean; + keyFetchFailed: boolean; + signatureError?: string; + error?: string; +} + +type UploadState = "idle" | "uploading" | "verifying" | "verified" | "applying" | "error"; + +interface ComponentUploadState { + state: UploadState; + progress: number; + result: UploadResult | null; + error: string | null; +} + +const initialState: ComponentUploadState = { + state: "idle", + progress: 0, + result: null, + error: null, +}; + +function ComponentUpload({ component, label }: { component: string; label: string }) { + const [upload, setUpload] = useState(initialState); + const [showBypassPrompt, setShowBypassPrompt] = useState(false); + const fileInputRef = useRef(null); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) { + setUpload({ + ...initialState, + state: "error", + error: m.offline_update_invalid_file(), + }); + return; + } + + const formData = new FormData(); + formData.append("component", component); + formData.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${DEVICE_API}/ota/upload`, true); + + setUpload({ ...initialState, state: "uploading" }); + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + const pct = Math.round((event.loaded / event.total) * 100); + setUpload(prev => ({ ...prev, progress: pct })); + if (pct >= 100) { + setUpload(prev => ({ ...prev, state: "verifying" })); + } + } + }; + + xhr.onload = () => { + try { + const result: UploadResult = JSON.parse(xhr.responseText); + if (xhr.status === 200 && result.verified) { + setUpload({ + state: "verified", + progress: 100, + result, + error: null, + }); + if (result.keyFetchFailed) { + setShowBypassPrompt(true); + } + } else { + setUpload({ + state: "error", + progress: 0, + result: null, + error: result.error || `HTTP ${xhr.status}`, + }); + } + } catch { + setUpload({ + state: "error", + progress: 0, + result: null, + error: xhr.statusText || "Unknown error", + }); + } + }; + + xhr.onerror = () => { + setUpload({ + state: "error", + progress: 0, + result: null, + error: "Network error", + }); + }; + + xhr.send(formData); + + // Reset input so re-selecting the same file works + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [component], + ); + + const handleApply = useCallback( + (bypassSignature: boolean) => { + setUpload(prev => ({ ...prev, state: "applying" })); + setShowBypassPrompt(false); + + fetch(`${DEVICE_API}/ota/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ component, bypassSignature }), + }).catch(() => { + // Expected: the device reboots, dropping the connection + }); + }, + [component], + ); + + const reset = useCallback(() => { + setUpload(initialState); + setShowBypassPrompt(false); + }, []); + + return ( +
+

{label}

+ + {upload.state === "idle" && ( +
+
+ )} + + {(upload.state === "uploading" || upload.state === "verifying") && ( +
+
+ + {upload.state === "uploading" + ? m.offline_update_uploading() + : m.offline_update_verifying()} + + + {upload.progress}% + +
+
+
+
+
+ )} + + {upload.state === "verified" && upload.result && !showBypassPrompt && ( +
+
+ + {m.offline_update_hash_ok()} +
+ {upload.result.signatureOK && ( +
+ + {m.offline_update_signature_ok()} +
+ )} +
+
+
+ )} + + {showBypassPrompt && ( +
+
+ +
+

+ {m.offline_update_signature_bypass_title()} +

+

+ {m.offline_update_signature_bypass_description()} +

+
+
+
+
+
+ )} + + {upload.state === "applying" && ( +

+ {m.offline_update_applying()} +

+ )} + + {upload.state === "error" && upload.error && ( +
+

+ {m.offline_update_error({ error: upload.error })} +

+
+ )} +
+ ); +} + +export default function OfflineUpdateCard() { + return ( +
+ +
+ +
+ +
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index 78d790e67..a60b83c2e 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -5,6 +5,7 @@ import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { useDeviceStore } from "@hooks/stores"; import { Button } from "@components/Button"; import Checkbox from "@components/Checkbox"; +import OfflineUpdateCard from "@components/OfflineUpdateCard"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; @@ -136,6 +137,7 @@ export default function SettingsGeneralRoute() { />
+
Date: Tue, 17 Mar 2026 15:58:25 -0700 Subject: [PATCH 5/6] ci: generate offline update archives in release pipeline Add offline_archive_app Makefile target that packages jetkvm_app, jetkvm_app.sha256, and jetkvm_app.sig into a single jetkvm_app_offline_update.tar.gz archive. The archive is the upload format expected by POST /ota/upload. Wire the target into the production release flow: after signing and uploading individual artefacts to Cloudflare R2 (the existing OTA update hosting bucket), the offline archive is built and uploaded alongside them. The GitHub Release draft now includes the offline archive as an additional download. The system offline archive target is deferred until the system repo has GPG signing in its build pipeline. Signed-off-by: Alex Howells --- Makefile | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 75467a798..c758b48ac 100644 --- a/Makefile +++ b/Makefile @@ -313,6 +313,20 @@ _build_release_inner: build_native $(GO_RELEASE_BUILD_ARGS) \ -o $(BIN_DIR)/jetkvm_app cmd/main.go +# Package a signed app binary into an offline update archive. +# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, and bin/jetkvm_app.sig +# to already exist (produced by the signing step in release/test_production_release). +offline_archive_app: + @echo "Creating offline update archive for app..." + @for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig; do \ + if [ ! -f "$(BIN_DIR)/$$f" ]; then \ + echo "Error: $(BIN_DIR)/$$f not found. Run signing step first."; exit 1; \ + fi; \ + done + tar czf $(BIN_DIR)/jetkvm_app_offline_update.tar.gz \ + -C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig + @echo "✓ Created $(BIN_DIR)/jetkvm_app_offline_update.tar.gz" + release: git_check_dev check_r2 @if [ -z "$(SIGNING_KEY_FPR)" ]; then \ echo "Error: SIGNING_KEY_FPR is required for releases"; \ @@ -365,11 +379,13 @@ release: git_check_dev check_r2 rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256 rclone copyto bin/jetkvm_app.sig r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sig + $(MAKE) offline_archive_app + rclone copyto bin/jetkvm_app_offline_update.tar.gz r2://jetkvm-update/app/$(VERSION)/jetkvm_app_offline_update.tar.gz ./scripts/deploy_cloud_app.sh -v $(VERSION) --set-as-default --skip-confirmation @git tag release/$(VERSION) @git push origin release/$(VERSION) prev_prod=$$(gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName'); \ - gh release create release/$(VERSION) bin/jetkvm_app bin/jetkvm_app.sha256 bin/jetkvm_app.sig \ + gh release create release/$(VERSION) bin/jetkvm_app bin/jetkvm_app.sha256 bin/jetkvm_app.sig bin/jetkvm_app_offline_update.tar.gz \ --title "$(VERSION)" \ --generate-notes \ --notes-start-tag "$$prev_prod" \ From cf6e30824280923c607efe8f6ac5c474635deb40 Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Sun, 22 Mar 2026 16:07:31 -0700 Subject: [PATCH 6/6] refactor(ota): use bundled public key for offline signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline update path previously fell back to a "warn and bypass" prompt when the device could not reach GPG keyservers to fetch the signing key. This defeated the purpose of offline updates, which exist precisely for air-gapped devices without internet access. The archive format now includes a .pub file (armored GPG public key) alongside the binary, .sha256, and .sig. On upload, the bundled key is validated against the pinned root fingerprint in gpg.go:21 via parseAndValidateKeyring — the same trust anchor used by the online update path. If the fingerprint matches, the key is used to verify the signature locally. No keyserver call is made. Verification is now binary: it passes or it fails. There is no third "key fetch failed" state and no bypass option. Backend (internal/ota/offline.go, ota_offline.go): - OfflineBundle gains PublicKeyData []byte field - ExtractOfflineArchive requires .pub (4 files, up from 3) - VerifyOfflineBundle calls new VerifySignatureFromFileWithKey method on GPGVerifier instead of VerifySignatureFromFile - Removed isKeyFetchError(), KeyFetchFailed from OfflineVerifyResult, BypassSignature from offlineUpdateApplyRequest, offlineUploadTimeout - VerifyOfflineBundle no longer takes a context parameter New GPG method (internal/ota/gpg.go): - VerifySignatureFromFileWithKey accepts raw armored key bytes, validates the fingerprint via parseAndValidateKeyring, then calls CheckDetachedSignature with the resulting single-entity keyring. No keyserver interaction, no cache mutation. Frontend (ui/src/components/OfflineUpdateCard.tsx): - Removed bypass confirmation dialog, showBypassPrompt state, keyFetchFailed from UploadResult, bypassSignature from apply request, ExclamationTriangleIcon import - Signature OK indicator now always shows on verified state Localisation (ui/localization/messages/en.json): - Removed offline_update_signature_bypass_title, offline_update_signature_bypass_description, offline_update_signature_bypass_confirm Tests (internal/ota/offline_test.go): - All archives now include .pub files - newOfflineSigningFixture replaces newSigningTestFixture for offline tests — no mock HTTP client needed - Added TestExtractOfflineArchive_MissingPub, TestVerifyOfflineBundle_EmptyPublicKey, TestVerifyOfflineBundle_WrongKey (bundled key fingerprint mismatch) - Removed TestIsKeyFetchError, TestVerifyOfflineBundle_KeyFetchFailure - End-to-end tests updated for 4-file archive format Makefile: - offline_archive_app target includes jetkvm_app.pub Key rotation is not addressed here. rootKeyFP is a single string; expanding to []string for the v1→v2→v3→v4 key rollover model is a separate change. Signed-off-by: Alex Howells --- Makefile | 9 +- internal/ota/gpg.go | 25 +++ internal/ota/offline.go | 65 +++--- internal/ota/offline_test.go | 251 +++++++++++++----------- internal/ota/state.go | 5 + ota_offline.go | 32 +-- ui/localization/messages/en.json | 3 - ui/src/components/OfflineUpdateCard.tsx | 52 +---- 8 files changed, 213 insertions(+), 229 deletions(-) diff --git a/Makefile b/Makefile index c758b48ac..d1b5a06a5 100644 --- a/Makefile +++ b/Makefile @@ -314,17 +314,18 @@ _build_release_inner: build_native -o $(BIN_DIR)/jetkvm_app cmd/main.go # Package a signed app binary into an offline update archive. -# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, and bin/jetkvm_app.sig -# to already exist (produced by the signing step in release/test_production_release). +# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, bin/jetkvm_app.sig, +# and bin/jetkvm_app.pub to already exist (produced by the signing step +# in release/test_production_release). offline_archive_app: @echo "Creating offline update archive for app..." - @for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig; do \ + @for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub; do \ if [ ! -f "$(BIN_DIR)/$$f" ]; then \ echo "Error: $(BIN_DIR)/$$f not found. Run signing step first."; exit 1; \ fi; \ done tar czf $(BIN_DIR)/jetkvm_app_offline_update.tar.gz \ - -C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig + -C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub @echo "✓ Created $(BIN_DIR)/jetkvm_app_offline_update.tar.gz" release: git_check_dev check_r2 diff --git a/internal/ota/gpg.go b/internal/ota/gpg.go index ea5ca845f..f336e25f7 100644 --- a/internal/ota/gpg.go +++ b/internal/ota/gpg.go @@ -256,6 +256,31 @@ func (g *GPGVerifier) VerifySignatureFromFile(ctx context.Context, signature []b return nil } +// VerifySignatureFromFileWithKey verifies a detached GPG signature against a +// file using a caller-supplied armored public key rather than fetching from +// keyservers. The key is validated against the pinned root fingerprint before +// use. This is the verification path for offline updates where keyservers are +// unreachable. +func (g *GPGVerifier) VerifySignatureFromFileWithKey(signature []byte, filePath string, armoredKey []byte) error { + keyring, err := g.parseAndValidateKeyring(armoredKey) + if err != nil { + return fmt.Errorf("bundled public key rejected: %w", err) + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file for verification: %w", err) + } + defer file.Close() + + if _, err := openpgp.CheckDetachedSignature(keyring, file, bytes.NewReader(signature), nil); err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + + g.logger.Info().Str("file", filePath).Msg("offline signature verification successful") + return nil +} + // ClearCache clears the cached public key (useful for testing) func (g *GPGVerifier) ClearCache() { g.mu.Lock() diff --git a/internal/ota/offline.go b/internal/ota/offline.go index 6d30d2b95..b81b85738 100644 --- a/internal/ota/offline.go +++ b/internal/ota/offline.go @@ -3,7 +3,6 @@ package ota import ( "archive/tar" "compress/gzip" - "context" "crypto/sha256" "encoding/hex" "fmt" @@ -18,18 +17,17 @@ import ( // OfflineBundle represents a validated offline update archive that has been // extracted and is ready for verification. type OfflineBundle struct { - BinaryPath string // absolute path to the extracted binary - ExpectedHash string // SHA256 hex digest read from the .sha256 file - Signature []byte // raw GPG signature bytes (nil if no .sig was present) - Component string // "app" or "system" + BinaryPath string // absolute path to the extracted binary + ExpectedHash string // SHA256 hex digest read from the .sha256 file + Signature []byte // raw GPG signature bytes + PublicKeyData []byte // armored GPG public key bytes + Component string // "app" or "system" } // OfflineVerifyResult captures the outcome of offline bundle verification. type OfflineVerifyResult struct { - HashOK bool `json:"hashOK"` - SignatureOK bool `json:"signatureOK"` - SignatureError string `json:"signatureError,omitempty"` - KeyFetchFailed bool `json:"keyFetchFailed"` + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` } // expectedBinaryNames maps component names to the binary filename expected @@ -59,7 +57,7 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer bundle := &OfflineBundle{Component: component} fileCount := 0 - const maxFiles = 3 // binary + .sha256 + .sig + const maxFiles = 4 // binary + .sha256 + .sig + .pub for { header, err := tr.Next() @@ -120,6 +118,14 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer bundle.Signature = sig l.Debug().Int("bytes", len(sig)).Msg("read signature") + case name == binaryName+".pub": + pub, err := io.ReadAll(io.LimitReader(tr, 65536)) + if err != nil { + return nil, fmt.Errorf("error reading public key file: %w", err) + } + bundle.PublicKeyData = pub + l.Debug().Int("bytes", len(pub)).Msg("read public key") + default: return nil, fmt.Errorf("unexpected file in archive: %s", name) } @@ -134,6 +140,9 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer if len(bundle.Signature) == 0 { return nil, fmt.Errorf("archive missing required signature file: %s.sig", binaryName) } + if len(bundle.PublicKeyData) == 0 { + return nil, fmt.Errorf("archive missing required public key file: %s.pub", binaryName) + } return bundle, nil } @@ -153,11 +162,10 @@ func extractFileFromTar(tr *tar.Reader, destPath string, mode int64) error { } // VerifyOfflineBundle checks the SHA256 hash and GPG signature of an -// extracted offline bundle. Hash mismatches are always fatal. Signature -// verification is attempted; if the GPG public key cannot be fetched -// (air-gapped device), KeyFetchFailed is set instead of returning an error. -// A bad signature (key available, verification failed) is always fatal. -func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) { +// extracted offline bundle. The bundled public key is validated against the +// pinned root fingerprint before use — no keyserver access is required. +// Hash mismatches and signature failures are always fatal. +func VerifyOfflineBundle(bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) { result := &OfflineVerifyResult{} // SHA256 verification @@ -172,22 +180,15 @@ func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier result.HashOK = true l.Info().Str("hash", hash).Msg("SHA256 hash verified") - // GPG signature verification + // GPG signature verification using the bundled public key if len(bundle.Signature) == 0 { return nil, fmt.Errorf("signature is required for offline updates") } + if len(bundle.PublicKeyData) == 0 { + return nil, fmt.Errorf("public key is required for offline updates") + } - err = gpgVerifier.VerifySignatureFromFile(ctx, bundle.Signature, bundle.BinaryPath) - if err != nil { - errStr := err.Error() - // Distinguish between key-fetch failure (air-gapped) and actual bad signature. - // Key fetch failures contain "keyserver" or "fetch" or "cancelled" in the error chain. - if isKeyFetchError(errStr) { - result.KeyFetchFailed = true - result.SignatureError = errStr - l.Warn().Err(err).Msg("GPG key fetch failed (device may be air-gapped)") - return result, nil - } + if err := gpgVerifier.VerifySignatureFromFileWithKey(bundle.Signature, bundle.BinaryPath, bundle.PublicKeyData); err != nil { return nil, fmt.Errorf("GPG signature verification failed: %w", err) } @@ -196,16 +197,6 @@ func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier return result, nil } -// isKeyFetchError returns true if the error string indicates a key fetch -// failure rather than an actual signature mismatch. -func isKeyFetchError(errStr string) bool { - lower := strings.ToLower(errStr) - return strings.Contains(lower, "keyserver") || - strings.Contains(lower, "fetch") || - strings.Contains(lower, "cancelled") || - strings.Contains(lower, "all keyservers failed") -} - // hashFile computes the SHA256 hex digest of the file at path. func hashFile(path string) (string, error) { f, err := os.Open(path) diff --git a/internal/ota/offline_test.go b/internal/ota/offline_test.go index 7a97c6de3..960e29658 100644 --- a/internal/ota/offline_test.go +++ b/internal/ota/offline_test.go @@ -4,13 +4,12 @@ import ( "archive/tar" "bytes" "compress/gzip" - "context" "crypto/sha256" "encoding/hex" "io" "os" "path/filepath" - "sync/atomic" + "strings" "testing" "github.com/ProtonMail/go-crypto/openpgp" @@ -52,15 +51,40 @@ func testLogger() *zerolog.Logger { return &l } +// armorPublicKey returns the armored public key bytes for the given entity. +func armorPublicKey(t *testing.T, entity *openpgp.Entity) []byte { + t.Helper() + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + require.NoError(t, err) + require.NoError(t, entity.Serialize(w)) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +// buildAppArchive creates a complete 4-file offline update archive for +// the "app" component using the given binary, signature, and public key. +func buildAppArchive(t *testing.T, binary, sig, pub []byte) *bytes.Buffer { + t.Helper() + return buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(sha256hex(binary) + " jetkvm_app\n"), + "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, + }) +} + func TestExtractOfflineArchive_ValidApp(t *testing.T) { binary := []byte("fake-app-binary") hash := sha256hex(binary) sig := []byte("fake-signature-bytes") + pub := []byte("fake-public-key") archive := buildArchive(t, map[string][]byte{ "jetkvm_app": binary, "jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, }) destDir := t.TempDir() @@ -70,6 +94,7 @@ func TestExtractOfflineArchive_ValidApp(t *testing.T) { assert.Equal(t, "app", bundle.Component) assert.Equal(t, hash, bundle.ExpectedHash) assert.Equal(t, sig, bundle.Signature) + assert.Equal(t, pub, bundle.PublicKeyData) assert.Equal(t, filepath.Join(destDir, "jetkvm_app"), bundle.BinaryPath) // binary should be on disk @@ -82,11 +107,13 @@ func TestExtractOfflineArchive_ValidSystem(t *testing.T) { binary := []byte("fake-system-tar") hash := sha256hex(binary) sig := []byte("fake-sig") + pub := []byte("fake-pub") archive := buildArchive(t, map[string][]byte{ "update_system.tar": binary, "update_system.tar.sha256": []byte(hash), "update_system.tar.sig": sig, + "update_system.tar.pub": pub, }) destDir := t.TempDir() @@ -95,15 +122,17 @@ func TestExtractOfflineArchive_ValidSystem(t *testing.T) { assert.Equal(t, "system", bundle.Component) assert.Equal(t, hash, bundle.ExpectedHash) + assert.Equal(t, pub, bundle.PublicKeyData) } -func TestExtractOfflineArchive_HashOnly(t *testing.T) { +func TestExtractOfflineArchive_MissingSig(t *testing.T) { binary := []byte("binary") hash := sha256hex(binary) archive := buildArchive(t, map[string][]byte{ "jetkvm_app": binary, "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.pub": []byte("pub"), }) destDir := t.TempDir() @@ -111,10 +140,26 @@ func TestExtractOfflineArchive_HashOnly(t *testing.T) { assert.ErrorContains(t, err, "missing required signature file") } +func TestExtractOfflineArchive_MissingPub(t *testing.T) { + binary := []byte("binary") + hash := sha256hex(binary) + + archive := buildArchive(t, map[string][]byte{ + "jetkvm_app": binary, + "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sig": []byte("sig"), + }) + + destDir := t.TempDir() + _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) + assert.ErrorContains(t, err, "missing required public key file") +} + func TestExtractOfflineArchive_MissingHash(t *testing.T) { archive := buildArchive(t, map[string][]byte{ "jetkvm_app": []byte("binary"), "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), }) destDir := t.TempDir() @@ -126,6 +171,7 @@ func TestExtractOfflineArchive_MissingBinary(t *testing.T) { archive := buildArchive(t, map[string][]byte{ "jetkvm_app.sha256": []byte("abc123"), "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), }) destDir := t.TempDir() @@ -141,12 +187,13 @@ func TestExtractOfflineArchive_UnexpectedFile(t *testing.T) { "jetkvm_app": binary, "jetkvm_app.sha256": []byte(hash), "jetkvm_app.sig": []byte("sig"), + "jetkvm_app.pub": []byte("pub"), "malicious.sh": []byte("#!/bin/bash\nrm -rf /"), }) destDir := t.TempDir() _, err := ExtractOfflineArchive(archive, destDir, "app", testLogger()) - assert.Error(t, err) // either "unexpected file" or "more than 3 files" + assert.Error(t, err) // either "unexpected file" or "more than 4 files" } func TestExtractOfflineArchive_PathTraversal(t *testing.T) { @@ -165,7 +212,6 @@ func TestExtractOfflineArchive_PathTraversal(t *testing.T) { destDir := t.TempDir() _, err := ExtractOfflineArchive(&buf, destDir, "app", testLogger()) - // Will fail as unexpected file since basename won't match expected names assert.Error(t, err) } @@ -181,11 +227,10 @@ func TestExtractOfflineArchive_CorruptGzip(t *testing.T) { } func TestExtractOfflineArchive_NestedDirectory(t *testing.T) { - // Archives created with `tar czf` often wrap files in a directory. - // The extractor should strip the leading directory and match by basename. binary := []byte("app-binary") hash := sha256hex(binary) sig := []byte("sig-bytes") + pub := []byte("pub-bytes") var buf bytes.Buffer gw := gzip.NewWriter(&buf) @@ -195,6 +240,7 @@ func TestExtractOfflineArchive_NestedDirectory(t *testing.T) { "jetkvm_app_offline_update/jetkvm_app": binary, "jetkvm_app_offline_update/jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), "jetkvm_app_offline_update/jetkvm_app.sig": sig, + "jetkvm_app_offline_update/jetkvm_app.pub": pub, } { _ = tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0644}) _, _ = tw.Write(content) @@ -207,6 +253,7 @@ func TestExtractOfflineArchive_NestedDirectory(t *testing.T) { require.NoError(t, err) assert.Equal(t, hash, bundle.ExpectedHash) assert.Equal(t, sig, bundle.Signature) + assert.Equal(t, pub, bundle.PublicKeyData) } func TestHashFile(t *testing.T) { @@ -221,45 +268,23 @@ func TestHashFile(t *testing.T) { assert.Equal(t, expected, got) } -func TestIsKeyFetchError(t *testing.T) { - tests := []struct { - err string - expect bool - }{ - {"all keyservers failed: [err1, err2]", true}, - {"failed to fetch public key: connection refused", true}, - {"key fetch cancelled: context deadline exceeded", true}, - {"signature verification failed: openpgp: invalid signature", false}, - {"hash mismatch: abc != def", false}, - } - for _, tt := range tests { - assert.Equal(t, tt.expect, isKeyFetchError(tt.err), "input: %s", tt.err) - } -} - // --- VerifyOfflineBundle tests --- -// newSigningTestFixture generates a GPG key pair and returns: -// - a GPGVerifier wired to a mock keyserver that serves the public key -// - the private entity (for producing signatures) -// - a cleanup function (restores global keyservers) -func newSigningTestFixture(t *testing.T) (*GPGVerifier, *openpgp.Entity) { +// newOfflineSigningFixture generates a GPG key pair and returns a GPGVerifier +// with the correct root fingerprint and the entity for signing. +// Unlike the keyserver-based fixture, this verifier doesn't need a mock HTTP +// client — offline verification uses VerifySignatureFromFileWithKey directly. +func newOfflineSigningFixture(t *testing.T) (*GPGVerifier, *openpgp.Entity) { t.Helper() entity, err := openpgp.NewEntity("Offline Test", "", "offline@test.local", nil) require.NoError(t, err) - // Armour the public key - var pubBuf bytes.Buffer - w, err := armor.Encode(&pubBuf, openpgp.PublicKeyType, nil) - require.NoError(t, err) - require.NoError(t, entity.Serialize(w)) - require.NoError(t, w.Close()) - - callCount := &atomic.Int32{} - mock := &keyServingHTTPClient{key: pubBuf.Bytes(), callCount: callCount} - v := newGPGVerifierWithMock(t, func() HttpClient { return mock }) - v.rootKeyFP = extractFingerprintFromArmoredKey(t, pubBuf.Bytes()) + logger := zerolog.New(os.Stdout).Level(zerolog.WarnLevel) + v := &GPGVerifier{ + logger: &logger, + rootKeyFP: strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint[:])), + } return v, entity } @@ -274,140 +299,134 @@ func signData(t *testing.T, entity *openpgp.Entity, data []byte) []byte { } // writeBundle writes a binary to disk and returns an OfflineBundle ready for verification. -func writeBundle(t *testing.T, binary []byte, sig []byte) *OfflineBundle { +func writeBundle(t *testing.T, binary, sig, pub []byte) *OfflineBundle { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "jetkvm_app") require.NoError(t, os.WriteFile(path, binary, 0644)) return &OfflineBundle{ - BinaryPath: path, - ExpectedHash: sha256hex(binary), - Signature: sig, - Component: "app", + BinaryPath: path, + ExpectedHash: sha256hex(binary), + Signature: sig, + PublicKeyData: pub, + Component: "app", } } func TestVerifyOfflineBundle_ValidSignature(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("valid-app-binary-content") sig := signData(t, entity, binary) - bundle := writeBundle(t, binary, sig) + bundle := writeBundle(t, binary, sig, pub) - result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.NoError(t, err) assert.True(t, result.HashOK, "hash should pass") assert.True(t, result.SignatureOK, "signature should pass") - assert.False(t, result.KeyFetchFailed, "key fetch should succeed") - assert.Empty(t, result.SignatureError) } func TestVerifyOfflineBundle_HashMismatch(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("real-binary") sig := signData(t, entity, binary) - bundle := writeBundle(t, binary, sig) + bundle := writeBundle(t, binary, sig, pub) - // Corrupt the expected hash so it won't match the file on disk bundle.ExpectedHash = "0000000000000000000000000000000000000000000000000000000000000000" - _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "hash mismatch") } func TestVerifyOfflineBundle_InvalidSignature(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("the-real-binary") differentContent := []byte("tampered-binary") - // Sign the tampered content, but the bundle points at the real binary. - // This means the signature won't match the file being verified. sig := signData(t, entity, differentContent) - bundle := writeBundle(t, binary, sig) + bundle := writeBundle(t, binary, sig, pub) - _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "GPG signature verification failed") } func TestVerifyOfflineBundle_WrongKey(t *testing.T) { - // Verifier is wired to key A, but the binary is signed with key B. - gpgVerifier, _ := newSigningTestFixture(t) + gpgVerifier, _ := newOfflineSigningFixture(t) - // Generate a completely different key pair for signing + // Sign with a completely different key pair otherEntity, err := openpgp.NewEntity("Attacker", "", "evil@attacker.com", nil) require.NoError(t, err) + otherPub := armorPublicKey(t, otherEntity) binary := []byte("innocent-looking-binary") sig := signData(t, otherEntity, binary) - bundle := writeBundle(t, binary, sig) + // Bundle includes the attacker's pub key, which won't match the pinned fingerprint + bundle := writeBundle(t, binary, sig, otherPub) - _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) - assert.Contains(t, err.Error(), "GPG signature verification failed") + assert.Contains(t, err.Error(), "bundled public key rejected") } func TestVerifyOfflineBundle_EmptySignature(t *testing.T) { - gpgVerifier, _ := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("unsigned-binary") - bundle := writeBundle(t, binary, nil) + bundle := writeBundle(t, binary, nil, pub) bundle.Signature = nil - _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "signature is required") } -func TestVerifyOfflineBundle_KeyFetchFailure(t *testing.T) { - // Simulate an air-gapped device: all keyserver requests fail. - callCount := &atomic.Int32{} - mock := &failingHTTPClient{callCount: callCount} - v := newGPGVerifierWithMock(t, func() HttpClient { return mock }) - // rootKeyFP doesn't matter since we'll never get a key to compare it against +func TestVerifyOfflineBundle_EmptyPublicKey(t *testing.T) { + gpgVerifier, entity := newOfflineSigningFixture(t) - binary := []byte("offline-binary") - sig := []byte("some-signature-bytes") // content irrelevant; key fetch fails first - bundle := writeBundle(t, binary, sig) + binary := []byte("binary-without-key") + sig := signData(t, entity, binary) + bundle := writeBundle(t, binary, sig, nil) + bundle.PublicKeyData = nil - result, err := VerifyOfflineBundle(context.Background(), bundle, v, testLogger()) - require.NoError(t, err, "key fetch failure should not be a hard error") - assert.True(t, result.HashOK, "hash should still pass") - assert.False(t, result.SignatureOK, "signature should not be marked OK") - assert.True(t, result.KeyFetchFailed, "should indicate key fetch failed") - assert.NotEmpty(t, result.SignatureError) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "public key is required") } func TestVerifyOfflineBundle_TruncatedSignature(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("binary-with-truncated-sig") fullSig := signData(t, entity, binary) - // Truncate the signature to corrupt it truncatedSig := fullSig[:len(fullSig)/2] - bundle := writeBundle(t, binary, truncatedSig) + bundle := writeBundle(t, binary, truncatedSig, pub) - _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "GPG signature verification failed") } func TestVerifyOfflineBundle_CorruptedBinary(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) originalBinary := []byte("original-binary-content") sig := signData(t, entity, originalBinary) - // Write the original, get a valid bundle, then overwrite the file - bundle := writeBundle(t, originalBinary, sig) + bundle := writeBundle(t, originalBinary, sig, pub) require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("corrupted-binary"), 0644)) - // Hash will mismatch because the file content no longer matches ExpectedHash - _, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "hash mismatch") } @@ -436,53 +455,43 @@ func TestComponentUpdatePath_Unknown(t *testing.T) { // TestEndToEnd_ExtractAndVerify_ValidArchive exercises the full pipeline: // build a tar.gz with a real GPG signature → extract → verify. func TestEndToEnd_ExtractAndVerify_ValidArchive(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("end-to-end-test-binary-content-here") - hash := sha256hex(binary) sig := signData(t, entity, binary) - archive := buildArchive(t, map[string][]byte{ - "jetkvm_app": binary, - "jetkvm_app.sha256": []byte(hash + " jetkvm_app\n"), - "jetkvm_app.sig": sig, - }) + archive := buildAppArchive(t, binary, sig, pub) extractDir := t.TempDir() bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) require.NoError(t, err) - result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.NoError(t, err) assert.True(t, result.HashOK) assert.True(t, result.SignatureOK) - assert.False(t, result.KeyFetchFailed) } // TestEndToEnd_ExtractAndVerify_TamperedBinary builds a valid archive then // overwrites the extracted binary before verification — simulating // file-level tampering after extraction. func TestEndToEnd_ExtractAndVerify_TamperedBinary(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("legitimate-binary") - hash := sha256hex(binary) sig := signData(t, entity, binary) - archive := buildArchive(t, map[string][]byte{ - "jetkvm_app": binary, - "jetkvm_app.sha256": []byte(hash), - "jetkvm_app.sig": sig, - }) + archive := buildAppArchive(t, binary, sig, pub) extractDir := t.TempDir() bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) require.NoError(t, err) - // Tamper with extracted binary on disk require.NoError(t, os.WriteFile(bundle.BinaryPath, []byte("tampered!"), 0644)) - _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "hash mismatch") } @@ -490,34 +499,36 @@ func TestEndToEnd_ExtractAndVerify_TamperedBinary(t *testing.T) { // TestEndToEnd_ExtractAndVerify_WrongSignature builds an archive where // the signature was produced by a different key than the verifier expects. func TestEndToEnd_ExtractAndVerify_WrongSignature(t *testing.T) { - gpgVerifier, _ := newSigningTestFixture(t) // verifier expects key A + gpgVerifier, _ := newOfflineSigningFixture(t) // verifier expects key A attackerEntity, err := openpgp.NewEntity("Attacker", "", "evil@example.com", nil) require.NoError(t, err) + attackerPub := armorPublicKey(t, attackerEntity) binary := []byte("innocuous-binary") - hash := sha256hex(binary) sig := signData(t, attackerEntity, binary) // signed with key B archive := buildArchive(t, map[string][]byte{ "jetkvm_app": binary, - "jetkvm_app.sha256": []byte(hash), + "jetkvm_app.sha256": []byte(sha256hex(binary)), "jetkvm_app.sig": sig, + "jetkvm_app.pub": attackerPub, }) extractDir := t.TempDir() bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) require.NoError(t, err) - _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) - assert.Contains(t, err.Error(), "GPG signature verification failed") + assert.Contains(t, err.Error(), "bundled public key rejected") } // TestEndToEnd_ExtractAndVerify_HashMismatchInArchive builds an archive // where the .sha256 file contains the wrong hash for the binary. func TestEndToEnd_ExtractAndVerify_HashMismatchInArchive(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("real-binary-content") wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" @@ -527,15 +538,15 @@ func TestEndToEnd_ExtractAndVerify_HashMismatchInArchive(t *testing.T) { "jetkvm_app": binary, "jetkvm_app.sha256": []byte(wrongHash), "jetkvm_app.sig": sig, + "jetkvm_app.pub": pub, }) extractDir := t.TempDir() bundle, err := ExtractOfflineArchive(archive, extractDir, "app", testLogger()) require.NoError(t, err) - // The extraction succeeds — hash mismatch is caught at verification time assert.Equal(t, wrongHash, bundle.ExpectedHash) - _, err = VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + _, err = VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "hash mismatch") } @@ -543,7 +554,8 @@ func TestEndToEnd_ExtractAndVerify_HashMismatchInArchive(t *testing.T) { // TestEndToEnd_SystemArchive verifies the full pipeline works for system // component archives with the different expected file names. func TestEndToEnd_SystemArchive(t *testing.T) { - gpgVerifier, entity := newSigningTestFixture(t) + gpgVerifier, entity := newOfflineSigningFixture(t) + pub := armorPublicKey(t, entity) binary := []byte("system-image-tar-content") hash := sha256hex(binary) @@ -553,6 +565,7 @@ func TestEndToEnd_SystemArchive(t *testing.T) { "update_system.tar": binary, "update_system.tar.sha256": []byte(hash), "update_system.tar.sig": sig, + "update_system.tar.pub": pub, }) extractDir := t.TempDir() @@ -560,7 +573,7 @@ func TestEndToEnd_SystemArchive(t *testing.T) { require.NoError(t, err) assert.Equal(t, "system", bundle.Component) - result, err := VerifyOfflineBundle(context.Background(), bundle, gpgVerifier, testLogger()) + result, err := VerifyOfflineBundle(bundle, gpgVerifier, testLogger()) require.NoError(t, err) assert.True(t, result.HashOK) assert.True(t, result.SignatureOK) diff --git a/internal/ota/state.go b/internal/ota/state.go index 52cf63bec..5ebb9d776 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -180,6 +180,11 @@ func (s *State) ToUpdateStatus() *UpdateStatus { return toUpdateStatus(&appUpdate, &systemUpdate, s.error) } +// IsUpdatePending returns true if an update is currently in progress. +func (s *State) IsUpdatePending() bool { + return s.updating +} + // ApplyOfflineUpdate applies a pre-verified, pre-staged offline update for // the given component. For app updates, this simply triggers a reboot (the // boot sequence picks up jetkvm_app.update). For system updates, it runs diff --git a/ota_offline.go b/ota_offline.go index 8798985d9..0c3d9ba07 100644 --- a/ota_offline.go +++ b/ota_offline.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "os" - "time" "github.com/gin-gonic/gin" "github.com/jetkvm/kvm/internal/ota" @@ -14,18 +13,14 @@ import ( const ( // maxOfflineUploadSize limits offline update archives to 200MB. maxOfflineUploadSize = 200 << 20 - // offlineUploadTimeout bounds the entire upload+verify cycle. - offlineUploadTimeout = 10 * time.Minute ) // offlineUpdateUploadResponse is returned by POST /ota/upload. type offlineUpdateUploadResponse struct { - Verified bool `json:"verified"` - HashOK bool `json:"hashOK"` - SignatureOK bool `json:"signatureOK"` - KeyFetchFailed bool `json:"keyFetchFailed"` - SignatureError string `json:"signatureError,omitempty"` - Error string `json:"error,omitempty"` + Verified bool `json:"verified"` + HashOK bool `json:"hashOK"` + SignatureOK bool `json:"signatureOK"` + Error string `json:"error,omitempty"` } // handleOfflineUpdateUpload handles POST /ota/upload. @@ -96,10 +91,7 @@ func handleOfflineUpdateUpload(c *gin.Context) { return } - ctx, cancel := context.WithTimeout(c.Request.Context(), offlineUploadTimeout) - defer cancel() - - result, err := ota.VerifyOfflineBundle(ctx, bundle, otaState.GPGVerifier(), &l) + result, err := ota.VerifyOfflineBundle(bundle, otaState.GPGVerifier(), &l) if err != nil { l.Warn().Err(err).Msg("offline bundle verification failed") c.JSON(http.StatusUnprocessableEntity, offlineUpdateUploadResponse{ @@ -135,22 +127,18 @@ func handleOfflineUpdateUpload(c *gin.Context) { l.Info(). Bool("hashOK", result.HashOK). Bool("signatureOK", result.SignatureOK). - Bool("keyFetchFailed", result.KeyFetchFailed). Msg("offline update uploaded and verified") c.JSON(http.StatusOK, offlineUpdateUploadResponse{ - Verified: result.HashOK && (result.SignatureOK || result.KeyFetchFailed), - HashOK: result.HashOK, - SignatureOK: result.SignatureOK, - KeyFetchFailed: result.KeyFetchFailed, - SignatureError: result.SignatureError, + Verified: result.HashOK && result.SignatureOK, + HashOK: result.HashOK, + SignatureOK: result.SignatureOK, }) } // offlineUpdateApplyRequest is the body for POST /ota/apply. type offlineUpdateApplyRequest struct { - Component string `json:"component" binding:"required"` - BypassSignature bool `json:"bypassSignature"` + Component string `json:"component" binding:"required"` } // handleOfflineUpdateApply handles POST /ota/apply. @@ -180,7 +168,7 @@ func handleOfflineUpdateApply(c *gin.Context) { } l := otaLogger.With().Str("component", req.Component).Logger() - l.Info().Bool("bypassSignature", req.BypassSignature).Msg("applying offline update") + l.Info().Msg("applying offline update") // Apply asynchronously — the device will reboot go func() { diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index b7ad09ff1..fc9af1f3d 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -986,9 +986,6 @@ "offline_update_apply": "Apply Update", "offline_update_hash_ok": "Hash verified", "offline_update_signature_ok": "Signature verified", - "offline_update_signature_bypass_title": "Signature Verification Unavailable", - "offline_update_signature_bypass_description": "This device cannot reach GPG keyservers to verify the update signature. This is expected on air-gapped networks. You may proceed without signature verification if you trust the source of this update file.", - "offline_update_signature_bypass_confirm": "Apply Without Signature Verification", "offline_update_error": "Upload failed: {error}", "offline_update_invalid_file": "Please select a .tar.gz archive" } diff --git a/ui/src/components/OfflineUpdateCard.tsx b/ui/src/components/OfflineUpdateCard.tsx index e46b47410..0b3665293 100644 --- a/ui/src/components/OfflineUpdateCard.tsx +++ b/ui/src/components/OfflineUpdateCard.tsx @@ -1,6 +1,5 @@ import { useCallback, useRef, useState } from "react"; import { LuUpload, LuCheck } from "react-icons/lu"; -import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { Button } from "@components/Button"; import { SettingsItem } from "@components/SettingsItem"; @@ -11,8 +10,6 @@ interface UploadResult { verified: boolean; hashOK: boolean; signatureOK: boolean; - keyFetchFailed: boolean; - signatureError?: string; error?: string; } @@ -34,7 +31,6 @@ const initialState: ComponentUploadState = { function ComponentUpload({ component, label }: { component: string; label: string }) { const [upload, setUpload] = useState(initialState); - const [showBypassPrompt, setShowBypassPrompt] = useState(false); const fileInputRef = useRef(null); const handleFileSelect = useCallback( @@ -80,9 +76,6 @@ function ComponentUpload({ component, label }: { component: string; label: strin result, error: null, }); - if (result.keyFetchFailed) { - setShowBypassPrompt(true); - } } else { setUpload({ state: "error", @@ -119,14 +112,13 @@ function ComponentUpload({ component, label }: { component: string; label: strin ); const handleApply = useCallback( - (bypassSignature: boolean) => { + () => { setUpload(prev => ({ ...prev, state: "applying" })); - setShowBypassPrompt(false); fetch(`${DEVICE_API}/ota/apply`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ component, bypassSignature }), + body: JSON.stringify({ component }), }).catch(() => { // Expected: the device reboots, dropping the connection }); @@ -136,7 +128,6 @@ function ComponentUpload({ component, label }: { component: string; label: strin const reset = useCallback(() => { setUpload(initialState); - setShowBypassPrompt(false); }, []); return ( @@ -183,49 +174,22 @@ function ComponentUpload({ component, label }: { component: string; label: strin
)} - {upload.state === "verified" && upload.result && !showBypassPrompt && ( + {upload.state === "verified" && upload.result && (
{m.offline_update_hash_ok()}
- {upload.result.signatureOK && ( -
- - {m.offline_update_signature_ok()} -
- )} +
+ + {m.offline_update_signature_ok()} +
-
- )} - - {showBypassPrompt && ( -
-
- -
-

- {m.offline_update_signature_bypass_title()} -

-

- {m.offline_update_signature_bypass_description()} -

-
-
-
-