Skip to content

Commit a770adf

Browse files
committed
refactor(ota): use bundled public key for offline signature verification
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 <alex@howells.me>
1 parent 9cfcfc7 commit a770adf

7 files changed

Lines changed: 208 additions & 229 deletions

File tree

Makefile

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,18 @@ _build_release_inner: build_native
314314
-o $(BIN_DIR)/jetkvm_app cmd/main.go
315315

316316
# Package a signed app binary into an offline update archive.
317-
# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, and bin/jetkvm_app.sig
318-
# to already exist (produced by the signing step in release/test_production_release).
317+
# Expects bin/jetkvm_app, bin/jetkvm_app.sha256, bin/jetkvm_app.sig,
318+
# and bin/jetkvm_app.pub to already exist (produced by the signing step
319+
# in release/test_production_release).
319320
offline_archive_app:
320321
@echo "Creating offline update archive for app..."
321-
@for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig; do \
322+
@for f in jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub; do \
322323
if [ ! -f "$(BIN_DIR)/$$f" ]; then \
323324
echo "Error: $(BIN_DIR)/$$f not found. Run signing step first."; exit 1; \
324325
fi; \
325326
done
326327
tar czf $(BIN_DIR)/jetkvm_app_offline_update.tar.gz \
327-
-C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig
328+
-C $(BIN_DIR) jetkvm_app jetkvm_app.sha256 jetkvm_app.sig jetkvm_app.pub
328329
@echo "✓ Created $(BIN_DIR)/jetkvm_app_offline_update.tar.gz"
329330

330331
release: git_check_dev check_r2

internal/ota/gpg.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,31 @@ func (g *GPGVerifier) VerifySignatureFromFile(ctx context.Context, signature []b
256256
return nil
257257
}
258258

259+
// VerifySignatureFromFileWithKey verifies a detached GPG signature against a
260+
// file using a caller-supplied armored public key rather than fetching from
261+
// keyservers. The key is validated against the pinned root fingerprint before
262+
// use. This is the verification path for offline updates where keyservers are
263+
// unreachable.
264+
func (g *GPGVerifier) VerifySignatureFromFileWithKey(signature []byte, filePath string, armoredKey []byte) error {
265+
keyring, err := g.parseAndValidateKeyring(armoredKey)
266+
if err != nil {
267+
return fmt.Errorf("bundled public key rejected: %w", err)
268+
}
269+
270+
file, err := os.Open(filePath)
271+
if err != nil {
272+
return fmt.Errorf("failed to open file for verification: %w", err)
273+
}
274+
defer file.Close()
275+
276+
if _, err := openpgp.CheckDetachedSignature(keyring, file, bytes.NewReader(signature), nil); err != nil {
277+
return fmt.Errorf("signature verification failed: %w", err)
278+
}
279+
280+
g.logger.Info().Str("file", filePath).Msg("offline signature verification successful")
281+
return nil
282+
}
283+
259284
// ClearCache clears the cached public key (useful for testing)
260285
func (g *GPGVerifier) ClearCache() {
261286
g.mu.Lock()

internal/ota/offline.go

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package ota
33
import (
44
"archive/tar"
55
"compress/gzip"
6-
"context"
76
"crypto/sha256"
87
"encoding/hex"
98
"fmt"
@@ -18,18 +17,17 @@ import (
1817
// OfflineBundle represents a validated offline update archive that has been
1918
// extracted and is ready for verification.
2019
type OfflineBundle struct {
21-
BinaryPath string // absolute path to the extracted binary
22-
ExpectedHash string // SHA256 hex digest read from the .sha256 file
23-
Signature []byte // raw GPG signature bytes (nil if no .sig was present)
24-
Component string // "app" or "system"
20+
BinaryPath string // absolute path to the extracted binary
21+
ExpectedHash string // SHA256 hex digest read from the .sha256 file
22+
Signature []byte // raw GPG signature bytes
23+
PublicKeyData []byte // armored GPG public key bytes
24+
Component string // "app" or "system"
2525
}
2626

2727
// OfflineVerifyResult captures the outcome of offline bundle verification.
2828
type OfflineVerifyResult struct {
29-
HashOK bool `json:"hashOK"`
30-
SignatureOK bool `json:"signatureOK"`
31-
SignatureError string `json:"signatureError,omitempty"`
32-
KeyFetchFailed bool `json:"keyFetchFailed"`
29+
HashOK bool `json:"hashOK"`
30+
SignatureOK bool `json:"signatureOK"`
3331
}
3432

3533
// expectedBinaryNames maps component names to the binary filename expected
@@ -59,7 +57,7 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer
5957

6058
bundle := &OfflineBundle{Component: component}
6159
fileCount := 0
62-
const maxFiles = 3 // binary + .sha256 + .sig
60+
const maxFiles = 4 // binary + .sha256 + .sig + .pub
6361

6462
for {
6563
header, err := tr.Next()
@@ -120,6 +118,14 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer
120118
bundle.Signature = sig
121119
l.Debug().Int("bytes", len(sig)).Msg("read signature")
122120

121+
case name == binaryName+".pub":
122+
pub, err := io.ReadAll(io.LimitReader(tr, 65536))
123+
if err != nil {
124+
return nil, fmt.Errorf("error reading public key file: %w", err)
125+
}
126+
bundle.PublicKeyData = pub
127+
l.Debug().Int("bytes", len(pub)).Msg("read public key")
128+
123129
default:
124130
return nil, fmt.Errorf("unexpected file in archive: %s", name)
125131
}
@@ -134,6 +140,9 @@ func ExtractOfflineArchive(r io.Reader, destDir string, component string, l *zer
134140
if len(bundle.Signature) == 0 {
135141
return nil, fmt.Errorf("archive missing required signature file: %s.sig", binaryName)
136142
}
143+
if len(bundle.PublicKeyData) == 0 {
144+
return nil, fmt.Errorf("archive missing required public key file: %s.pub", binaryName)
145+
}
137146

138147
return bundle, nil
139148
}
@@ -153,11 +162,10 @@ func extractFileFromTar(tr *tar.Reader, destPath string, mode int64) error {
153162
}
154163

155164
// VerifyOfflineBundle checks the SHA256 hash and GPG signature of an
156-
// extracted offline bundle. Hash mismatches are always fatal. Signature
157-
// verification is attempted; if the GPG public key cannot be fetched
158-
// (air-gapped device), KeyFetchFailed is set instead of returning an error.
159-
// A bad signature (key available, verification failed) is always fatal.
160-
func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) {
165+
// extracted offline bundle. The bundled public key is validated against the
166+
// pinned root fingerprint before use — no keyserver access is required.
167+
// Hash mismatches and signature failures are always fatal.
168+
func VerifyOfflineBundle(bundle *OfflineBundle, gpgVerifier *GPGVerifier, l *zerolog.Logger) (*OfflineVerifyResult, error) {
161169
result := &OfflineVerifyResult{}
162170

163171
// SHA256 verification
@@ -172,22 +180,15 @@ func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier
172180
result.HashOK = true
173181
l.Info().Str("hash", hash).Msg("SHA256 hash verified")
174182

175-
// GPG signature verification
183+
// GPG signature verification using the bundled public key
176184
if len(bundle.Signature) == 0 {
177185
return nil, fmt.Errorf("signature is required for offline updates")
178186
}
187+
if len(bundle.PublicKeyData) == 0 {
188+
return nil, fmt.Errorf("public key is required for offline updates")
189+
}
179190

180-
err = gpgVerifier.VerifySignatureFromFile(ctx, bundle.Signature, bundle.BinaryPath)
181-
if err != nil {
182-
errStr := err.Error()
183-
// Distinguish between key-fetch failure (air-gapped) and actual bad signature.
184-
// Key fetch failures contain "keyserver" or "fetch" or "cancelled" in the error chain.
185-
if isKeyFetchError(errStr) {
186-
result.KeyFetchFailed = true
187-
result.SignatureError = errStr
188-
l.Warn().Err(err).Msg("GPG key fetch failed (device may be air-gapped)")
189-
return result, nil
190-
}
191+
if err := gpgVerifier.VerifySignatureFromFileWithKey(bundle.Signature, bundle.BinaryPath, bundle.PublicKeyData); err != nil {
191192
return nil, fmt.Errorf("GPG signature verification failed: %w", err)
192193
}
193194

@@ -196,16 +197,6 @@ func VerifyOfflineBundle(ctx context.Context, bundle *OfflineBundle, gpgVerifier
196197
return result, nil
197198
}
198199

199-
// isKeyFetchError returns true if the error string indicates a key fetch
200-
// failure rather than an actual signature mismatch.
201-
func isKeyFetchError(errStr string) bool {
202-
lower := strings.ToLower(errStr)
203-
return strings.Contains(lower, "keyserver") ||
204-
strings.Contains(lower, "fetch") ||
205-
strings.Contains(lower, "cancelled") ||
206-
strings.Contains(lower, "all keyservers failed")
207-
}
208-
209200
// hashFile computes the SHA256 hex digest of the file at path.
210201
func hashFile(path string) (string, error) {
211202
f, err := os.Open(path)

0 commit comments

Comments
 (0)