From 0bef3b5f03d9be5a5b81955274625f533b787a36 Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 5 Sep 2026 20:28:18 +0200 Subject: [PATCH] fix: restrict OpenBao unseal secret --- internal/bootstrap/bootstrap.go | 4 +- internal/bootstrap/bootstrap_test.go | 60 +++++++++++++++++++++++----- internal/openbao/bootstrap_test.go | 15 ++++--- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5622db5..6b82153 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -789,10 +789,10 @@ func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ag } func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) { - if material.RootToken == "" || material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold { + if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold { return nil, errors.New("OpenBao recovery material is incomplete") } - data := map[string]string{"root-token": material.RootToken} + data := make(map[string]string, len(material.UnsealKeysB64)) for index, share := range material.UnsealKeysB64 { if share == "" { return nil, errors.New("OpenBao recovery material contains an invalid unseal key") diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 7c6fdd5..b0843c6 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -397,7 +397,7 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) { } } -func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) { +func TestWriteOpenBaoUnsealSecretRendersOnlyUnsealShares(t *testing.T) { originalRead := readOpenBaoRecovery originalWrite := writeGeneratedSOPS t.Cleanup(func() { @@ -410,24 +410,66 @@ func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) { } return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil } - var rendered []byte writeGeneratedSOPS = func(path, ageKeyPath string, plaintext []byte) error { if ageKeyPath != "flux-age-identity" { t.Fatal("OpenBao unseal secret used the wrong Flux age identity") } - rendered = append([]byte(nil), plaintext...) - return os.WriteFile(path, []byte("sops: {}\n"), 0600) + var secret struct { + Metadata map[string]string `yaml:"metadata"` + StringData map[string]string `yaml:"stringData"` + } + if err := yaml.Unmarshal(plaintext, &secret); err != nil { + t.Fatal(err) + } + if secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 3 { + t.Fatal("OpenBao unseal Secret was not rendered") + } + if _, ok := secret.StringData["root-token"]; ok { + t.Fatal("OpenBao unseal Secret contains a root token") + } + for _, name := range []string{"unseal-1", "unseal-2", "unseal-3"} { + if _, ok := secret.StringData[name]; !ok { + t.Fatalf("OpenBao unseal Secret is missing %s", name) + } + } + return nil } if err := writeOpenBaoUnsealSecret(filepath.Join(t.TempDir(), "unseal.sops.yaml"), "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil { t.Fatal(err) } - var secret struct { - Metadata map[string]string `yaml:"metadata"` - StringData map[string]string `yaml:"stringData"` +} + +func TestWriteOpenBaoUnsealSecretEncryptsSharesWithoutRootToken(t *testing.T) { + if _, err := exec.LookPath("age-keygen"); err != nil { + t.Skip("age-keygen is required for bootstrap encryption") } - if err := yaml.Unmarshal(rendered, &secret); err != nil || secret.Metadata["name"] != "openbao-unseal" || secret.Metadata["namespace"] != "openbao" || len(secret.StringData) != 4 || secret.StringData["root-token"] == "" || secret.StringData["unseal-3"] == "" { - t.Fatal("OpenBao unseal Secret was not rendered") + if _, err := exec.LookPath("sops"); err != nil { + t.Skip("sops is required for bootstrap encryption") + } + originalRead := readOpenBaoRecovery + t.Cleanup(func() { readOpenBaoRecovery = originalRead }) + readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) { + return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2", "test-share-3"}}, nil + } + dir := t.TempDir() + identity := filepath.Join(dir, "age-key.txt") + if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "unseal.sops.yaml") + if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", identity); err != nil { + t.Fatal(err) + } + encrypted, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encrypted), "sops:") { + t.Fatal("OpenBao unseal Secret was not SOPS encrypted") + } + if strings.Contains(string(encrypted), "root-token") { + t.Fatal("SOPS-encrypted OpenBao unseal Secret contains a root token") } } diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index 40e78ee..4bc7f90 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -22,17 +22,20 @@ func TestEnsureRecoveryIdentity(t *testing.T) { } } -func TestUnsealSubmitsAllSharesAndVerifiesResult(t *testing.T) { +func TestUnsealUsesPromptedStdinOnly(t *testing.T) { originalExec, originalStatus := execInPod, openBaoStatus t.Cleanup(func() { execInPod, openBaoStatus = originalExec, originalStatus }) - var shares []string - execInPod = func(_ string, input []byte, _ ...string) ([]byte, error) { - shares = append(shares, strings.TrimSpace(string(input))) + calls := 0 + execInPod = func(_ string, input []byte, args ...string) ([]byte, error) { + if len(input) == 0 || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" || args[2] != "read -r key; bao operator unseal \"$key\" >/dev/null" { + t.Fatal("unseal share was not submitted through prompted stdin") + } + calls++ return nil, nil } openBaoStatus = func(string) (status, error) { return status{Initialized: true}, nil } - if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || strings.Join(shares, ",") != "share-1,share-2,share-3" { - t.Fatalf("unseal = shares:%q err:%v", shares, err) + if err := unseal("kubeconfig", RecoveryMaterial{UnsealKeysB64: []string{"share-1", "share-2", "share-3"}, UnsealThreshold: 2}); err != nil || calls != 3 { + t.Fatalf("unseal calls:%d err:%v", calls, err) } }