Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/guides/config-distribution.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</Steps>
</Steps>

## 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.
64 changes: 63 additions & 1 deletion e2e/distribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
4 changes: 4 additions & 0 deletions state/distribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"log/slog"
"time"

"github.com/goccy/go-yaml"
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions state/distribution_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package state

import (
"bytes"
"crypto"
"crypto/rand"
"encoding/base64"
"log/slog"
"net/netip"
"testing"
"time"
Expand All @@ -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{
Expand Down
6 changes: 6 additions & 0 deletions state/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions state/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading