Skip to content
Draft
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
38 changes: 35 additions & 3 deletions internal/ota/gpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ota
import (
"bytes"
"context"
_ "embed"
"encoding/hex"
"fmt"
"io"
Expand Down Expand Up @@ -35,6 +36,17 @@ const (
keyFetchTimeout = 30 * time.Second
)

// embeddedRootKey is the JetKVM release root public key compiled into the
// binary. The trust anchor is rootKeyFingerprint (also compiled in), so the
// key bytes are effectively constant — embedding them lets us verify updates
// without a network round-trip to third-party keyservers on the update path,
// and without leaking the device's IP to those keyservers. The keyservers
// remain a fallback (see FetchPublicKey) in case the embedded key is ever
// missing or fails to load.
//
//go:embed root_key.asc
var embeddedRootKey []byte

// GPGVerifier handles GPG signature verification for OTA updates
type GPGVerifier struct {
mu sync.RWMutex
Expand All @@ -44,14 +56,18 @@ type GPGVerifier struct {
logger *zerolog.Logger
httpClient func() HttpClient
rootKeyFP string
// embeddedKey is the public key compiled into the binary, tried before any
// keyserver. Defaults to embeddedRootKey; tests may override or clear it.
embeddedKey []byte
}

// NewGPGVerifier creates a new GPG verifier instance
func NewGPGVerifier(logger *zerolog.Logger, httpClient func() HttpClient) *GPGVerifier {
return &GPGVerifier{
logger: logger,
httpClient: httpClient,
rootKeyFP: rootKeyFingerprint,
logger: logger,
httpClient: httpClient,
rootKeyFP: rootKeyFingerprint,
embeddedKey: embeddedRootKey,
}
}

Expand Down Expand Up @@ -79,6 +95,22 @@ func (g *GPGVerifier) FetchPublicKey(ctx context.Context) ([]byte, error) {
}
g.mu.RUnlock()

// Prefer the key embedded in the binary. It is validated against the pinned
// fingerprint exactly like a fetched key, so it is no less trusted — and it
// avoids depending on third-party keyservers being reachable on the update
// path. Keyservers are only used as a fallback below.
if len(g.embeddedKey) > 0 {
keyring, err := g.parseAndValidateKeyring(g.embeddedKey)
if err == nil {
g.updateMemoryCache(g.embeddedKey, keyring)
g.logger.Debug().Msg("using public key embedded in binary")
key := make([]byte, len(g.embeddedKey))
copy(key, g.embeddedKey)
return key, nil
}
g.logger.Warn().Err(err).Msg("embedded public key failed to load, falling back to keyservers")
}

// Fetch from keyservers (returns already-validated keyring)
key, keyring, err := g.fetchFromKeyservers(ctx, g.rootKeyFP)
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions internal/ota/gpg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ func TestFetchPublicKey_AllKeyserversFail(t *testing.T) {
callCount := &atomic.Int32{}
mock := &failingHTTPClient{callCount: callCount}
v := newGPGVerifierWithMock(t, func() HttpClient { return mock })
// Clear the embedded key so the keyserver path is actually exercised;
// otherwise the embedded key satisfies the request without any network.
v.embeddedKey = nil

ctx := context.Background()
key, err := v.FetchPublicKey(ctx)
Expand All @@ -265,6 +268,41 @@ func TestFetchPublicKey_AllKeyserversFail(t *testing.T) {
assert.Equal(t, int32(1), callCount.Load(), "should have tried the one test keyserver")
}

// TestEmbeddedRootKey_MatchesPinnedFingerprint guarantees that the key bytes
// compiled into the binary actually match the pinned fingerprint. If someone
// replaces root_key.asc with the wrong key, this fails at test time rather than
// silently shipping a key that can't verify any real release.
func TestEmbeddedRootKey_MatchesPinnedFingerprint(t *testing.T) {
require.NotEmpty(t, embeddedRootKey, "a root key must be embedded in the binary")

v := newTestGPGVerifier() // rootKeyFP == rootKeyFingerprint
keyring, err := v.parseAndValidateKeyring(embeddedRootKey)
require.NoError(t, err, "embedded key must parse and match the pinned fingerprint %s", rootKeyFingerprint)
require.NotEmpty(t, keyring)

fp := extractFingerprintFromArmoredKey(t, embeddedRootKey)
assert.Equal(t, rootKeyFingerprint, fp, "embedded key fingerprint must equal the pinned fingerprint")
}

// TestFetchPublicKey_PrefersEmbeddedKey verifies the embedded key satisfies a
// fetch without any keyserver request, even when every keyserver would fail.
func TestFetchPublicKey_PrefersEmbeddedKey(t *testing.T) {
callCount := &atomic.Int32{}
mock := &failingHTTPClient{callCount: callCount}
logger := zerolog.New(os.Stdout).Level(zerolog.WarnLevel)
v := NewGPGVerifier(&logger, func() HttpClient { return mock })

ctx := context.Background()
key, err := v.FetchPublicKey(ctx)
require.NoError(t, err, "embedded key should satisfy the request")
require.NotEmpty(t, key)
assert.Equal(t, int32(0), callCount.Load(), "no keyserver request should be made when the embedded key is valid")

keyring, err := v.getKeyring(ctx)
require.NoError(t, err)
require.NotEmpty(t, keyring)
}

func TestFetchPublicKey_CachedKeyIsValid(t *testing.T) {
// Generate a test key pair (keep the entity so we can sign with the private key)
entity, err := openpgp.NewEntity("Test", "", "test@example.com", nil)
Expand Down
24 changes: 24 additions & 0 deletions internal/ota/root_key.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: AF5A 36A9 93D8 28FE FE7C 18C2 D1B9 856C 26A7 9E95
Comment: JetKVM Release Root <release@jetkvm.com>

xjMEaYSxSRYJKwYBBAHaRw8BAQdAz2ujErSeJQHLSY0oiX8kHEwb6E5fdCBinAGb
24Fp2b7NKEpldEtWTSBSZWxlYXNlIFJvb3QgPHJlbGVhc2VAamV0a3ZtLmNvbT7C
mQQTFgoAQRYhBK9aNqmT2Cj+/nwYwtG5hWwmp56VBQJphLFJAhsBBQkFo5qABQsJ
CAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJENG5hWwmp56VYAgBAKLWN//PP5ey
Msdv/Yz1/6gHvZHVVa+tDJYD8I5yZpjdAQDt4gRc/uamaO/slB5KLYJUWIumC1oz
WcdNBWDjyPxKDM4zBGmF/EYWCSsGAQQB2kcPAQEHQCeB6jwY7Ea2NPw3hsnyO2Mp
8dBGbucvOQW/XE0AbKuVwsA1BBgWCgAmFiEEr1o2qZPYKP7+fBjC0bmFbCannpUF
AmmF/EYCGwIFCQHhM4AAgQkQ0bmFbCannpV2IAQZFgoAHRYhBBLCIFfD7z8lz9QJ
9yMnQkbppUQwBQJphfxGAAoJECMnQkbppUQwW6YA/iZmm7TGE9Em8t+GgDrXpzBv
suJGDRnmp9XGKpC6o8uDAP9eTpAjup+XxAdHqggGcg/RBHA1jGdqTxgCcv6v9/b1
AGiTAQD//8K2J+Gxj3FcivSG5yk/zavA3BkaZ2N/lk5ZcC8CCgD/YS+5nJkN+Kg8
Vlvmcu5DzIqL3AdLuQd9BBt7oFllwA7OMwRphLIQFgkrBgEEAdpHDwEBB0DRhORl
LMpg0MNzhVmMsCYmNGcoxzsFrc+h3O7XxCqoccLANQQYFgoAJhYhBK9aNqmT2Cj+
/nwYwtG5hWwmp56VBQJphLIQAhsCBQkB4TOAAIEJENG5hWwmp56VdiAEGRYKAB0W
IQQIWpPw/jNpgXtMiOpnglUREKkYDQUCaYSyEAAKCRBnglUREKkYDQUbAQDYr9oL
lTn06fuvo6yLaKbdBvMq9Xag+O7Eftm5Rx3X7AD/XRs8rSAg5CrOs0whvTHAsZ59
2IQE98FN3jaFfQkgswKJEgEA8oi4ZHnETdZDnkN172WWe1qYgfUf6bZDRDKdUyeh
sTwA/0xHBf/adWBhqEkpcBi2D620vIQrKOY9tmUGb2AqVtAA
=jJYn
-----END PGP PUBLIC KEY BLOCK-----
Loading