diff --git a/docs/guides/config-distribution.mdx b/docs/guides/config-distribution.mdx index 284a0bf..ba00ed6 100644 --- a/docs/guides/config-distribution.mdx +++ b/docs/guides/config-distribution.mdx @@ -71,4 +71,8 @@ Despite being called a "public" key, the distribution key also acts as the share Nylon polls for updates every 10 seconds and applies them. - \ No newline at end of file + + +## Key rotation + +When `dist` is configured, `dist.key` must be set to a nonzero public key. To rotate it, put the new public key in `central.yaml` and sign that bundle with the old private key. After nodes apply the bundle, sign subsequent updates with the new private key. diff --git a/e2e/distribution_test.go b/e2e/distribution_test.go index a256f0f..bc24b58 100644 --- a/e2e/distribution_test.go +++ b/e2e/distribution_test.go @@ -178,6 +178,48 @@ func TestDistribution(t *testing.T) { t.Logf("Successfully updated to timestamp %d.", verifyCfg.Timestamp) } +func TestDistributionKeyRotation(t *testing.T) { + t.Parallel() + h, repoContainer, runDir, oldPrivateKey, _, nodeId, originalTimestamp := startDistributedSingleNode(t) + ctx := context.Background() + newPrivateKey := state.GenerateKey() + newPublicKey := newPrivateKey.Pubkey() + + transitionCfg := readCentralConfig(t, h, nodeId) + transitionCfg.Dist.Key = newPublicKey + time.Sleep(time.Second) + transitionPath, transitionTimestamp := writeBundle( + t, runDir, "bundle-key-rotation", transitionCfg, oldPrivateKey, + ) + if transitionTimestamp <= originalTimestamp { + t.Fatalf("transition timestamp %d must be newer than original timestamp %d", transitionTimestamp, originalTimestamp) + } + if err := repoContainer.CopyFileToContainer(ctx, transitionPath, "/data/bundle", 0644); err != nil { + t.Fatal(err) + } + + appliedTransition := waitForCentralTimestamp(t, h, nodeId, transitionTimestamp) + if appliedTransition.Dist == nil || appliedTransition.Dist.Key != newPublicKey { + t.Fatal("transition bundle did not install the new distribution key") + } + + time.Sleep(time.Second) + rotatedPath, rotatedTimestamp := writeBundle( + t, runDir, "bundle-after-key-rotation", appliedTransition, newPrivateKey, + ) + if rotatedTimestamp <= transitionTimestamp { + t.Fatalf("rotated timestamp %d must be newer than transition timestamp %d", rotatedTimestamp, transitionTimestamp) + } + if err := repoContainer.CopyFileToContainer(ctx, rotatedPath, "/data/bundle", 0644); err != nil { + t.Fatal(err) + } + + appliedRotated := waitForCentralTimestamp(t, h, nodeId, rotatedTimestamp) + if appliedRotated.Dist == nil || appliedRotated.Dist.Key != newPublicKey { + t.Fatal("bundle signed with the rotated key did not preserve the new distribution key") + } +} + func TestDistributionRejectsLocalNodeRemoval(t *testing.T) { t.Parallel() h, repoContainer, runDir, privKey, pubKey, nodeId, originalTimestamp := startDistributedSingleNode(t) @@ -360,6 +402,11 @@ func assertCentralTimestampStays(t *testing.T, h *Harness, nodeId state.NodeId, } func readCentralTimestamp(t *testing.T, h *Harness, nodeId state.NodeId) int64 { + t.Helper() + return readCentralConfig(t, h, nodeId).Timestamp +} + +func readCentralConfig(t *testing.T, h *Harness, nodeId state.NodeId) state.CentralCfg { t.Helper() stdout, _, err := h.Exec(string(nodeId), []string{"cat", "/app/config/central.yaml"}) if err != nil { @@ -369,5 +416,20 @@ func readCentralTimestamp(t *testing.T, h *Harness, nodeId state.NodeId) int64 { if err := yaml.Unmarshal([]byte(stdout), &cfg); err != nil { t.Fatalf("Failed to parse config from node: %v", err) } - return cfg.Timestamp + return cfg +} + +func waitForCentralTimestamp(t *testing.T, h *Harness, nodeId state.NodeId, expected int64) state.CentralCfg { + t.Helper() + deadline := time.Now().Add(WaitTimeout) + for { + cfg := readCentralConfig(t, h, nodeId) + if cfg.Timestamp == expected { + return cfg + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for central config timestamp %d; got %d", expected, cfg.Timestamp) + } + time.Sleep(250 * time.Millisecond) + } } diff --git a/state/distribution.go b/state/distribution.go index 9b56d20..e8d81fc 100644 --- a/state/distribution.go +++ b/state/distribution.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "errors" + "log/slog" "time" "github.com/goccy/go-yaml" @@ -75,6 +76,9 @@ func BundleConfig(config string, rootKey NyPrivateKey) (string, error) { if err != nil { return "", err } + if cfg.Dist != nil && cfg.Dist.Key != rootKey.Pubkey() { + slog.Warn("bundled public key differs from the signing key, check if this is intended!") + } cfg.Timestamp = time.Now().UnixNano() plainText, err := yaml.Marshal(cfg) diff --git a/state/distribution_test.go b/state/distribution_test.go index 0dcef8e..94b6208 100644 --- a/state/distribution_test.go +++ b/state/distribution_test.go @@ -1,9 +1,11 @@ package state import ( + "bytes" "crypto" "crypto/rand" "encoding/base64" + "log/slog" "net/netip" "testing" "time" @@ -14,6 +16,27 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) +func TestBundleConfigWarnsWhenDistributionKeyDiffers(t *testing.T) { + signingKey := GenerateKey() + cfg := CentralCfg{ + Dist: &DistributionCfg{ + Key: GenerateKey().Pubkey(), + Repos: []string{"https://example.com/bundle"}, + }, + } + txt, err := yaml.Marshal(cfg) + assert.NoError(t, err) + + var logs bytes.Buffer + originalLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(originalLogger) }) + + _, err = BundleConfig(string(txt), signingKey) + assert.NoError(t, err) + assert.Contains(t, logs.String(), "bundled public key differs from the signing key") +} + func TestBundleUnbundle(t *testing.T) { root := GenerateKey() cfg := CentralCfg{ diff --git a/state/validation.go b/state/validation.go index 79e5bbb..f8abd53 100644 --- a/state/validation.go +++ b/state/validation.go @@ -47,6 +47,9 @@ func NodeConfigValidator(central *CentralCfg, node *LocalCfg) error { } } if node.Dist != nil { + if node.Dist.Key == (NyPublicKey{}) { + return fmt.Errorf("dist.key must not be empty") + } _, err := url.Parse(node.Dist.Url) if err != nil { return err @@ -144,6 +147,9 @@ func CentralConfigValidator(cfg *CentralCfg) error { } if cfg.Dist != nil { + if cfg.Dist.Key == (NyPublicKey{}) { + return fmt.Errorf("dist.key must not be empty") + } // validate repos for _, repo := range cfg.Dist.Repos { _, err := url.Parse(repo) diff --git a/state/validation_test.go b/state/validation_test.go index 256d9ee..8362cac 100644 --- a/state/validation_test.go +++ b/state/validation_test.go @@ -55,6 +55,23 @@ func TestNodeConfigValidator_DnsResolver(t *testing.T) { })) } +func TestNodeConfigValidator_RejectsEmptyDistributionKey(t *testing.T) { + err := NodeConfigValidator(nil, &LocalCfg{ + Id: "valid-node", + Port: 5, + Key: [32]byte{1}, + Dist: &LocalDistributionCfg{Url: "https://example.com/bundle"}, + }) + assert.ErrorContains(t, err, "dist.key must not be empty") +} + +func TestCentralConfigValidator_RejectsEmptyDistributionKey(t *testing.T) { + err := CentralConfigValidator(&CentralCfg{ + Dist: &DistributionCfg{Repos: []string{"https://example.com/bundle"}}, + }) + assert.ErrorContains(t, err, "dist.key must not be empty") +} + func TestNodeConfigValidator_TunlessMode(t *testing.T) { base := LocalCfg{ Id: "relay",