Skip to content

Commit 38828f6

Browse files
fix(security): ship real offline license + policy signing keys (SEC-H1) (#701)
* fix(security): ship real offline license + policy signing keys (SEC-H1) The embedded "current" trust anchors for license JWTs and admin-policy envelopes were byte-for-byte the public halves of the committed test private keys (internal/{license,policy}/testdata). Anyone with repo access could forge licenses of any tier/feature or sign admin policies. The verification code was correct; the trust root was the defect. - Replace internal/license/keys/license-pubkey-current.pem and internal/policy/keys/policy-pubkey-current.pem with the real Ed25519 public keys generated offline (private keys live only in the operator vault, never in the repo or CI). - Add a regression guard in each package (keys_guard_test.go) that fails the build if an embedded current public key equals the testdata key. Verified to fail loudly on the test key and pass on the real key. - Decouple the license validator tests from the embedded key: mustRing now builds the ring from the testdata key (what signJWT signs with), so verification-logic tests are independent of the shipped production key. The embedded key is covered separately by the new guard. - Spec it: system-license-validation C-09/AC-14 and system-policy C-10/AC-13 (both bumped to 1.1.0); annotation coverage stays 100%. Follow-ups (not in this change): cmd/owlicgen still defaults to the test private key for dev minting (production must pass --key <vault>); a production policy-signing tool does not exist yet. * test(security): decouple license/policy tests from the embedded key Shipping the real signing keys broke every test that signs an artifact with the testdata key and verifies it against the embedded trust anchor (it used to be that same test key). These are DB-touching tests, so they skipped locally without OPENWATCH_TEST_DSN and only failed in CI. Add an internal/-scoped SetVerificationKeyForTesting(pub) to both the license and policy packages (returns a restore func; never on a production path) and install the testdata public key as the active verifier from the helpers that sign with the testdata private key: - policy: setupKeys (covers the 6 loader tests) - server: mintTestLicenseJWT (4 license/premium tests), mintSignedAlertThresholds (signoff) So the binary embeds the real offline key while tests verify their own test-signed artifacts. Verified with OPENWATCH_TEST_DSN: full internal/{server,policy,license} suites pass.
1 parent 3d6efe1 commit 38828f6

12 files changed

Lines changed: 154 additions & 9 deletions

internal/license/keys.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,18 @@ func parsePEMPublicKey(path string) (ed25519.PublicKey, error) {
6868
}
6969
return ed, nil
7070
}
71+
72+
// SetVerificationKeyForTesting installs pub as the sole active license
73+
// verification key and returns a function that restores the prior keyring.
74+
//
75+
// It exists so tests (including in dependent packages such as internal/server)
76+
// can verify JWTs signed with the testdata key while the shipped binary embeds
77+
// the real, offline-generated key. internal/-scoped; never used on a production
78+
// code path. That the embedded trust anchor is NOT the testdata key is asserted
79+
// by TestEmbeddedKey_NotTestKey (system-license-validation AC-14).
80+
func SetVerificationKeyForTesting(pub ed25519.PublicKey) (restore func()) {
81+
_ = Init()
82+
prev := activeKeyring()
83+
setKeyring(&publicKeyRing{current: pub})
84+
return func() { setKeyring(prev) }
85+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
-----BEGIN PUBLIC KEY-----
2-
MCowBQYDK2VwAyEAvhDPBqXoiNYumcZSyaHc9VxR0jEvrgdxupcxFSrIyKI=
2+
MCowBQYDK2VwAyEAYhnjz9tqHsRrDXHrCGE3d42TazqjOPJr/tLnxEwUI7c=
33
-----END PUBLIC KEY-----
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// @spec system-license-validation
2+
//
3+
// AC traceability:
4+
// @ac AC-14 (TestEmbeddedKey_NotTestKey)
5+
6+
package license
7+
8+
import (
9+
"crypto/ed25519"
10+
"crypto/x509"
11+
"encoding/pem"
12+
"os"
13+
"path/filepath"
14+
"testing"
15+
)
16+
17+
// @ac AC-14
18+
// AC-14: the embedded current license public key MUST NOT be the testdata
19+
// signing key. This guards against the SEC-H1 regression where the shipped
20+
// trust anchor is the committed test key, which would let anyone with repo
21+
// access forge license JWTs of any tier/feature.
22+
func TestEmbeddedKey_NotTestKey(t *testing.T) {
23+
t.Run("system-license-validation/AC-14", func(t *testing.T) {
24+
embedded, err := parsePEMPublicKey("keys/license-pubkey-current.pem")
25+
if err != nil {
26+
t.Fatalf("parse embedded current key: %v", err)
27+
}
28+
if embedded.Equal(testKeyPublic(t, "license-privkey-test.pem")) {
29+
t.Fatal("SEC-H1 regression: embedded keys/license-pubkey-current.pem is the testdata " +
30+
"key. Ship the real offline-generated public key (private key stays in the vault).")
31+
}
32+
})
33+
}
34+
35+
// testKeyPublic derives the Ed25519 public half of a PKCS#8 test private key
36+
// under testdata/.
37+
func testKeyPublic(t *testing.T, name string) ed25519.PublicKey {
38+
t.Helper()
39+
raw, err := os.ReadFile(filepath.Join("testdata", name))
40+
if err != nil {
41+
t.Fatalf("read testdata/%s: %v", name, err)
42+
}
43+
block, _ := pem.Decode(raw)
44+
if block == nil {
45+
t.Fatalf("no PEM block in testdata/%s", name)
46+
}
47+
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
48+
if err != nil {
49+
t.Fatalf("parse testdata/%s: %v", name, err)
50+
}
51+
priv, ok := keyAny.(ed25519.PrivateKey)
52+
if !ok {
53+
t.Fatalf("testdata/%s is not an Ed25519 private key", name)
54+
}
55+
return priv.Public().(ed25519.PublicKey)
56+
}

internal/license/validator_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@ func validClaims() claims {
7272
}
7373
}
7474

75+
// mustRing builds a key ring from the testdata signing key — the same key
76+
// signJWT signs with — so the verification-logic tests are independent of
77+
// whichever real production key is embedded in keys/license-pubkey-current.pem.
78+
// That the embedded key is NOT this test key is covered separately by
79+
// TestEmbeddedKey_NotTestKey (AC-14).
7580
func mustRing(t *testing.T) *publicKeyRing {
7681
t.Helper()
77-
ring, err := loadEmbeddedKeys()
78-
if err != nil {
79-
t.Fatalf("loadEmbeddedKeys: %v", err)
80-
}
81-
return ring
82+
return &publicKeyRing{current: testKeyPublic(t, "license-privkey-test.pem")}
8283
}
8384

8485
// @ac AC-01 (Valid JWT validates and returns a populated License.)

internal/policy/keys.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,17 @@ func parseEmbeddedKey(path string) (ed25519.PublicKey, error) {
5959
func activeKeyring() *keyring {
6060
return activeKeys.Load()
6161
}
62+
63+
// SetVerificationKeyForTesting installs pub as the sole active admin
64+
// verification key and returns a function that restores the prior keyring.
65+
//
66+
// It exists so tests (including in dependent packages such as internal/server)
67+
// can verify policy envelopes signed with the testdata key while the shipped
68+
// binary embeds the real, offline-generated key. internal/-scoped; never used
69+
// on a production code path. That the embedded trust anchor is NOT the testdata
70+
// key is asserted by TestEmbeddedPolicyKey_NotTestKey (system-policy AC-13).
71+
func SetVerificationKeyForTesting(pub ed25519.PublicKey) (restore func()) {
72+
prev := activeKeys.Load()
73+
activeKeys.Store(&keyring{current: pub})
74+
return func() { activeKeys.Store(prev) }
75+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
-----BEGIN PUBLIC KEY-----
2-
MCowBQYDK2VwAyEAkktBggG/cH9xDqeteHtdvA4aNF4ZkGd4Icsz5PbR0PM=
2+
MCowBQYDK2VwAyEAZ2fs2msAyt75C3P953cuWKAom960QalQ6S84zEwY+YU=
33
-----END PUBLIC KEY-----

internal/policy/keys_guard_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// @spec system-policy
2+
//
3+
// AC traceability:
4+
// @ac AC-13 (TestEmbeddedPolicyKey_NotTestKey)
5+
6+
package policy
7+
8+
import (
9+
"crypto/ed25519"
10+
"testing"
11+
)
12+
13+
// @ac AC-13
14+
// AC-13: the embedded current admin-policy public key MUST NOT be the testdata
15+
// signing key. This guards against the SEC-H1 regression where the shipped
16+
// trust anchor is the committed test key, which would let anyone with repo
17+
// access sign forged admin-policy envelopes.
18+
func TestEmbeddedPolicyKey_NotTestKey(t *testing.T) {
19+
t.Run("system-policy/AC-13", func(t *testing.T) {
20+
embedded, err := parseEmbeddedKey("keys/policy-pubkey-current.pem")
21+
if err != nil {
22+
t.Fatalf("parse embedded current key: %v", err)
23+
}
24+
testPub := loadTestPrivKey(t).Public().(ed25519.PublicKey)
25+
if embedded.Equal(testPub) {
26+
t.Fatal("SEC-H1 regression: embedded keys/policy-pubkey-current.pem is the testdata " +
27+
"key. Ship the real offline-generated public key (private key stays in the vault).")
28+
}
29+
})
30+
}

internal/policy/loader_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ func setupKeys(t *testing.T) {
4949
if err := InitKeys(); err != nil {
5050
t.Fatalf("InitKeys: %v", err)
5151
}
52+
// The binary embeds the real offline admin key, but these tests sign with
53+
// the testdata key — install it as the active verifier (restored on
54+
// cleanup). The embedded key is asserted real by TestEmbeddedPolicyKey_NotTestKey.
55+
testPub := loadTestPrivKey(t).Public().(ed25519.PublicKey)
56+
t.Cleanup(SetVerificationKeyForTesting(testPub))
5257
}
5358

5459
// signAlertThresholds builds a signed policy envelope for the

internal/server/api_license_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func mintTestLicenseJWT(t *testing.T, features []string) string {
3939
if !ok {
4040
t.Fatalf("not ed25519 key: %T", keyAny)
4141
}
42+
// The server verifies against the real embedded key, but this JWT is signed
43+
// with the testdata key — install it as the active verifier (restored on
44+
// cleanup). The embedded key is asserted real by license AC-14's guard test.
45+
t.Cleanup(license.SetVerificationKeyForTesting(priv.Public().(ed25519.PublicKey)))
4246
now := time.Now().Add(-1 * time.Minute)
4347
mc := jwt.MapClaims{
4448
"iss": "hanalyx-openwatch-licensing",

internal/server/api_signoff_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,10 @@ func mintSignedAlertThresholds(t *testing.T, version string, critBelow, highBelo
431431
if !ok {
432432
t.Fatalf("not ed25519 priv key")
433433
}
434+
// The server verifies against the real embedded key, but this envelope is
435+
// signed with the testdata key — install it as the active verifier (after
436+
// InitKeys above, restored on cleanup). Embedded key asserted real by AC-13.
437+
t.Cleanup(policy.SetVerificationKeyForTesting(priv.Public().(ed25519.PublicKey)))
434438
env := policy.Envelope{
435439
PolicyType: policy.TypeAlertThresholds,
436440
Version: version,

0 commit comments

Comments
 (0)