From 63398273b31ca56c3479eef2c271e955bddedf9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=96=E4=BD=B3=E6=9D=83?= <1259103745@qq.com> Date: Fri, 4 Sep 2026 10:51:51 +0800 Subject: [PATCH 1/2] fix: use local dist key for bundle verification in config update polling checkForConfigUpdates was reading n.CentralCfg.Dist.Key, which may be a zero-value placeholder (AAAAAAAAAAAAAAAAAAAAAA==). This happens because BundleConfig re-marshals the CentralCfg after unmarshalling the source central.yaml, serializing the zero-value DistributionCfg.Key field when it was not explicitly set. Using this zero-value key for bundle decryption always fails with chacha20poly1305: message authentication failed. Fix: prefer the local (node-level) dist key from node.yaml, falling back to the central key for backward compatibility, and returning an error if neither key is valid. Fixes #NNN --- core/nylon_distribution.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/nylon_distribution.go b/core/nylon_distribution.go index 7f2ef4e..74c48ba 100644 --- a/core/nylon_distribution.go +++ b/core/nylon_distribution.go @@ -64,7 +64,20 @@ func checkForConfigUpdates(n *Nylon) error { if n.CentralCfg.Dist == nil { return errors.New("nylon is not configured for automatic config distribution") } + // Prefer the local (node-level) dist key from node.yaml. + // The central config's Dist.Key may be a zero-value placeholder + // (AAAAAAAAAAAAAAAAAAAAAA==) because BundleConfig re-marshals the + // CentralCfg, serializing the zero-value Key field when it was not + // explicitly set in the source central.yaml. Using it for bundle + // verification always fails with chacha20poly1305: message authentication + // failed. key := n.CentralCfg.Dist.Key + if n.LocalCfg.Dist != nil && n.LocalCfg.Dist.Key != (state.NyPublicKey{}) { + key = n.LocalCfg.Dist.Key + } + if key == (state.NyPublicKey{}) { + return errors.New("no valid dist key configured for bundle verification") + } currentTimestamp := n.Timestamp repos := slices.Clone(n.CentralCfg.Dist.Repos) for _, repoStr := range repos { From df81f2cd9b68830ba17be0902d9ef10d6dfd875d Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sat, 5 Sep 2026 13:48:13 +0000 Subject: [PATCH 2/2] feat(distribution): improve checks for dist pubkey --- core/nylon_distribution.go | 13 ------ docs/guides/config-distribution.mdx | 6 ++- e2e/distribution_test.go | 64 ++++++++++++++++++++++++++++- state/distribution.go | 4 ++ state/distribution_test.go | 23 +++++++++++ state/validation.go | 6 +++ state/validation_test.go | 17 ++++++++ 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/core/nylon_distribution.go b/core/nylon_distribution.go index 74c48ba..7f2ef4e 100644 --- a/core/nylon_distribution.go +++ b/core/nylon_distribution.go @@ -64,20 +64,7 @@ func checkForConfigUpdates(n *Nylon) error { if n.CentralCfg.Dist == nil { return errors.New("nylon is not configured for automatic config distribution") } - // Prefer the local (node-level) dist key from node.yaml. - // The central config's Dist.Key may be a zero-value placeholder - // (AAAAAAAAAAAAAAAAAAAAAA==) because BundleConfig re-marshals the - // CentralCfg, serializing the zero-value Key field when it was not - // explicitly set in the source central.yaml. Using it for bundle - // verification always fails with chacha20poly1305: message authentication - // failed. key := n.CentralCfg.Dist.Key - if n.LocalCfg.Dist != nil && n.LocalCfg.Dist.Key != (state.NyPublicKey{}) { - key = n.LocalCfg.Dist.Key - } - if key == (state.NyPublicKey{}) { - return errors.New("no valid dist key configured for bundle verification") - } currentTimestamp := n.Timestamp repos := slices.Clone(n.CentralCfg.Dist.Repos) for _, repoStr := range repos { 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",