fix: restrict OpenBao unseal secret #24

Merged
eding merged 1 commit from fix/openbao-sops-recovery into main 2026-09-05 21:14:30 +02:00
3 changed files with 62 additions and 17 deletions

View file

@ -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")

View file

@ -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")
}
}

View file

@ -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)
}
}