Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,21 @@ _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, 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 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 jetkvm_app.pub
@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"; \
Expand Down Expand Up @@ -365,11 +380,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" \
Expand Down
27 changes: 26 additions & 1 deletion internal/ota/gpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions internal/ota/gpg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
213 changes: 213 additions & 0 deletions internal/ota/offline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package ota

import (
"archive/tar"
"compress/gzip"
"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
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"`
}

// 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 = 4 // binary + .sha256 + .sig + .pub

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: "<hex> <filename>" or just "<hex>"
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")

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

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)
}
if len(bundle.PublicKeyData) == 0 {
return nil, fmt.Errorf("archive missing required public key file: %s.pub", 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. 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
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 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")
}

if err := gpgVerifier.VerifySignatureFromFileWithKey(bundle.Signature, bundle.BinaryPath, bundle.PublicKeyData); err != nil {
return nil, fmt.Errorf("GPG signature verification failed: %w", err)
}

result.SignatureOK = true
l.Info().Msg("GPG signature verified")
return result, nil
}

// 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
}
Loading