diff --git a/internal/ota/gpg.go b/internal/ota/gpg.go index ea5ca845f..2cee5438d 100644 --- a/internal/ota/gpg.go +++ b/internal/ota/gpg.go @@ -3,6 +3,7 @@ package ota import ( "bytes" "context" + _ "embed" "encoding/hex" "fmt" "io" @@ -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 @@ -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, } } @@ -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 { diff --git a/internal/ota/gpg_test.go b/internal/ota/gpg_test.go index 2d2e26fb6..fe6923159 100644 --- a/internal/ota/gpg_test.go +++ b/internal/ota/gpg_test.go @@ -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) @@ -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) diff --git a/internal/ota/root_key.asc b/internal/ota/root_key.asc new file mode 100644 index 000000000..614173e69 --- /dev/null +++ b/internal/ota/root_key.asc @@ -0,0 +1,24 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: AF5A 36A9 93D8 28FE FE7C 18C2 D1B9 856C 26A7 9E95 +Comment: JetKVM Release Root + +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-----