From 3a5c2d02380e75a03b152f2151ada95e0bc3b174 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 2 Aug 2026 14:58:53 +0200 Subject: [PATCH] feat: render OpenBao unseal secret --- internal/bootstrap/bootstrap.go | 116 +++++++++++++++++++++++++- internal/bootstrap/bootstrap_test.go | 117 +++++++++++++++++++++++++++ internal/openbao/bootstrap.go | 90 ++++++++++++++------- internal/openbao/bootstrap_test.go | 34 ++++++++ 4 files changed, 328 insertions(+), 29 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 910dd44..2f0bec5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -45,6 +45,12 @@ type operationalSecrets struct { var initializeOpenBao = openbao.Initialize +var readOpenBaoRecovery = openbao.ReadRecoveryMaterial + +var decryptGeneratedSOPS = decryptSOPSFile + +var writeGeneratedSOPS = writeSOPSEncryptedFile + var ensureForgejoWebhook = func(cfg config.Config, repo, webhookURL, authorization string) error { manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, cfg.Flux.ManifestsRepo, cfg.Flux.RepoName, cfg.Flux.Branch, "maidn/bootstrap-"+cfg.ClusterID) return manager.EnsureWebhook(repo, webhookURL, authorization) @@ -84,8 +90,8 @@ func (r Runner) Run() error { if _, err := os.Stat(r.Config.SOPS.OperationalSecretsPath); err != nil { return fmt.Errorf("preflight operational SOPS secrets: %w", err) } - if r.Config.SOPS.RecoveryRecipient == "" || r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" { - return errors.New("SOPS recoveryRecipient, recoveryIdentityPath, and recoveryBundlePath are required") + if r.Config.SOPS.RecoveryRecipient == "" { + return errors.New("SOPS recoveryRecipient is required") } } if r.Mode == "" { @@ -100,6 +106,15 @@ func (r Runner) Run() error { if r.RegisterWebhook { return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir)) } + if r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" { + return errors.New("SOPS recoveryIdentityPath and recoveryBundlePath are required") + } + if _, err := os.Stat(r.Config.SOPS.RecoveryIdentityPath); err != nil { + return fmt.Errorf("preflight OpenBao recovery identity: %w", err) + } + if _, err := os.Stat(r.Config.SOPS.RecoveryBundlePath); err != nil { + return fmt.Errorf("preflight OpenBao recovery bundle: %w", err) + } workspace := r.Config.WorkspaceDir if err := EnsureTemplateRevisions(r.Config); err != nil { return err @@ -167,6 +182,13 @@ func (r Runner) Run() error { if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil { return err } + openbaoDir := filepath.Join(dir, "base", "openbao") + if err := writeOpenBaoUnsealSecret(filepath.Join(openbaoDir, "unseal.sops.yaml"), r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath); err != nil { + return err + } + if err := ensureOpenBaoUnsealKustomization(filepath.Join(openbaoDir, "kustomization.yaml")); err != nil { + return err + } if err := ensureClusterKustomizations(clusterDir); err != nil { return err } @@ -325,6 +347,96 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) } +func writeOpenBaoUnsealSecret(path, recoveryIdentityPath, recoveryBundlePath, ageKeyPath string) error { + material, err := readOpenBaoRecovery(recoveryIdentityPath, recoveryBundlePath) + if err != nil { + return fmt.Errorf("read OpenBao recovery material: %w", err) + } + plaintext, err := renderOpenBaoUnsealSecret(material) + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + existing, err := decryptGeneratedSOPS(path, ageKeyPath) + if err != nil { + return fmt.Errorf("decrypt existing OpenBao unseal secret: %w", err) + } + if sameYAML(existing, plaintext) { + return nil + } + } else if !os.IsNotExist(err) { + return err + } + return writeGeneratedSOPS(path, ageKeyPath, plaintext) +} + +func renderOpenBaoUnsealSecret(material openbao.RecoveryMaterial) ([]byte, error) { + if material.RootToken == "" || 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} + for index, share := range material.UnsealKeysB64 { + if share == "" { + return nil, errors.New("OpenBao recovery material contains an invalid unseal key") + } + data[fmt.Sprintf("unseal-%d", index+1)] = share + } + return yaml.Marshal(struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]string `yaml:"metadata"` + Type string `yaml:"type"` + StringData map[string]string `yaml:"stringData"` + }{ + APIVersion: "v1", + Kind: "Secret", + Metadata: map[string]string{"name": "openbao-unseal", "namespace": "openbao"}, + Type: "Opaque", + StringData: data, + }) +} + +func ensureOpenBaoUnsealKustomization(path string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + var document struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Resources []string `yaml:"resources"` + } + decoder := yaml.NewDecoder(bytes.NewReader(content)) + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("parse OpenBao Kustomization: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("OpenBao Kustomization must contain one YAML document") + } + if document.APIVersion != "kustomize.config.k8s.io/v1beta1" || document.Kind != "Kustomization" || len(document.Resources) == 0 { + return errors.New("OpenBao Kustomization must define resources") + } + count := 0 + for _, resource := range document.Resources { + if resource == "" { + return errors.New("OpenBao Kustomization contains an empty resource") + } + if resource == "unseal.sops.yaml" { + count++ + } + } + if count > 1 { + return errors.New("OpenBao Kustomization references unseal.sops.yaml more than once") + } + if count == 1 { + return nil + } + if !bytes.HasSuffix(content, []byte("\n")) { + content = append(content, '\n') + } + return os.WriteFile(path, append(content, []byte(" - unseal.sops.yaml\n")...), 0644) +} + func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error { plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets}) if err != nil { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index d970873..c859d8b 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -15,6 +15,8 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" + "github.com/Pingu-Studio/MaidnCLI/internal/openbao" + "gopkg.in/yaml.v3" ) func TestRenderCiliumConfig(t *testing.T) { @@ -180,6 +182,121 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) { } } +func TestWriteOpenBaoUnsealSecretRendersRecoveryMaterial(t *testing.T) { + originalRead := readOpenBaoRecovery + originalWrite := writeGeneratedSOPS + t.Cleanup(func() { + readOpenBaoRecovery = originalRead + writeGeneratedSOPS = originalWrite + }) + readOpenBaoRecovery = func(identityPath, bundlePath string) (openbao.RecoveryMaterial, error) { + if identityPath != "recovery-identity" || bundlePath != "recovery-bundle" { + t.Fatal("OpenBao recovery paths were not passed to the decryption boundary") + } + 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) + } + + 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"` + } + 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") + } +} + +func TestWriteOpenBaoUnsealSecretIsIdempotent(t *testing.T) { + originalRead := readOpenBaoRecovery + originalDecrypt := decryptGeneratedSOPS + originalWrite := writeGeneratedSOPS + t.Cleanup(func() { + readOpenBaoRecovery = originalRead + decryptGeneratedSOPS = originalDecrypt + writeGeneratedSOPS = originalWrite + }) + material := openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 2, UnsealKeysB64: []string{"test-share-1", "test-share-2"}} + plaintext, err := renderOpenBaoUnsealSecret(material) + if err != nil { + t.Fatal(err) + } + readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) { return material, nil } + decryptGeneratedSOPS = func(string, string) ([]byte, error) { return plaintext, nil } + writes := 0 + writeGeneratedSOPS = func(string, string, []byte) error { + writes++ + return nil + } + path := filepath.Join(t.TempDir(), "unseal.sops.yaml") + if err := os.WriteFile(path, []byte("sops: {}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", "flux-age-identity"); err != nil { + t.Fatal(err) + } + if writes != 0 { + t.Fatal("matching OpenBao unseal Secret was re-encrypted") + } +} + +func TestWriteOpenBaoUnsealSecretRejectsUnreadableExistingSecret(t *testing.T) { + originalRead := readOpenBaoRecovery + originalDecrypt := decryptGeneratedSOPS + originalWrite := writeGeneratedSOPS + t.Cleanup(func() { + readOpenBaoRecovery = originalRead + decryptGeneratedSOPS = originalDecrypt + writeGeneratedSOPS = originalWrite + }) + readOpenBaoRecovery = func(string, string) (openbao.RecoveryMaterial, error) { + return openbao.RecoveryMaterial{RootToken: "test-root", UnsealThreshold: 1, UnsealKeysB64: []string{"test-share"}}, nil + } + decryptGeneratedSOPS = func(string, string) ([]byte, error) { return nil, errors.New("unavailable") } + writes := 0 + writeGeneratedSOPS = func(string, string, []byte) error { + writes++ + return nil + } + path := filepath.Join(t.TempDir(), "unseal.sops.yaml") + if err := os.WriteFile(path, []byte("sops: {}\n"), 0600); err != nil { + t.Fatal(err) + } + if err := writeOpenBaoUnsealSecret(path, "recovery-identity", "recovery-bundle", "flux-age-identity"); err == nil || writes != 0 { + t.Fatal("unreadable OpenBao unseal Secret was overwritten") + } +} + +func TestEnsureOpenBaoUnsealKustomizationIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "kustomization.yaml") + if err := os.WriteFile(path, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - namespace.yaml\n"), 0644); err != nil { + t.Fatal(err) + } + if err := ensureOpenBaoUnsealKustomization(path); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := ensureOpenBaoUnsealKustomization(path); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(first), " - unseal.sops.yaml\n") || string(first) != string(second) { + t.Fatal("OpenBao Kustomization was not updated idempotently") + } +} + func TestNewWebhookAuthorization(t *testing.T) { authorization, err := NewWebhookAuthorization() if err != nil { diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index a9c173b..aaacc8b 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -3,7 +3,9 @@ package openbao import ( "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -20,7 +22,7 @@ type status struct { Sealed bool `json:"sealed"` } -type recovery struct { +type RecoveryMaterial struct { UnsealKeysB64 []string `json:"unseal_keys_b64"` UnsealThreshold int `json:"unseal_threshold"` RootToken string `json:"root_token"` @@ -30,6 +32,15 @@ type operationalSecrets struct { Secrets map[string]map[string]string `yaml:"secrets"` } +var decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) { + cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err) + } + return output, nil +} + func EnsureRecoveryIdentity(identityPath string) (string, error) { if _, err := os.Stat(identityPath); os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil { @@ -62,26 +73,24 @@ func Initialize(kubeconfig, recipient, identityPath, bundlePath, ageKeyPath, ope if err != nil { return nil, err } - var material recovery + var material RecoveryMaterial if !current.Initialized { output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json") if err != nil { return nil, fmt.Errorf("initialize OpenBao: %w", err) } - if err := json.Unmarshal(output, &material); err != nil { - return nil, fmt.Errorf("parse OpenBao recovery material: %w", err) + material, err = parseRecoveryMaterial(output) + if err != nil { + return nil, err } if err := encryptRecovery(recipient, bundlePath, output); err != nil { return nil, err } } else { - output, err := decryptRecovery(identityPath, bundlePath) + material, err = ReadRecoveryMaterial(identityPath, bundlePath) if err != nil { return nil, err } - if err := json.Unmarshal(output, &material); err != nil { - return nil, fmt.Errorf("parse encrypted OpenBao recovery material: %w", err) - } } if current.Sealed { if err := unseal(kubeconfig, material); err != nil { @@ -146,6 +155,46 @@ func validateRecoveryRecipient(recipient, bundlePath string) error { return nil } +// ReadRecoveryMaterial decrypts and validates the local OpenBao recovery bundle. +func ReadRecoveryMaterial(identityPath, bundlePath string) (RecoveryMaterial, error) { + plaintext, err := decryptRecovery(identityPath, bundlePath) + if err != nil { + return RecoveryMaterial{}, err + } + return parseRecoveryMaterial(plaintext) +} + +func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) { + var material RecoveryMaterial + decoder := json.NewDecoder(bytes.NewReader(plaintext)) + if err := decoder.Decode(&material); err != nil { + return RecoveryMaterial{}, fmt.Errorf("parse OpenBao recovery material: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material must contain one JSON document") + } + if material.RootToken == "" || strings.TrimSpace(material.RootToken) != material.RootToken { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a root token") + } + if material.UnsealThreshold < 1 { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material requires a positive unseal threshold") + } + if len(material.UnsealKeysB64) < material.UnsealThreshold { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains fewer than %d unseal keys", material.UnsealThreshold) + } + seen := make(map[string]bool, len(material.UnsealKeysB64)) + for _, key := range material.UnsealKeysB64 { + if key == "" || strings.TrimSpace(key) != key { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains an invalid unseal key") + } + if seen[key] { + return RecoveryMaterial{}, fmt.Errorf("OpenBao recovery material contains duplicate unseal keys") + } + seen[key] = true + } + return material, nil +} + func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error { if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 { return fmt.Errorf("invalid OpenBao secret path %q", secretPath) @@ -200,15 +249,11 @@ func getStatus(kubeconfig string) (status, error) { return current, nil } -func unseal(kubeconfig string, material recovery) error { - threshold := material.UnsealThreshold - if threshold == 0 { - threshold = 3 +func unseal(kubeconfig string, material RecoveryMaterial) error { + if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold { + return fmt.Errorf("OpenBao recovery material has insufficient unseal keys") } - if len(material.UnsealKeysB64) < threshold { - return fmt.Errorf("OpenBao recovery bundle contains fewer than %d unseal keys", threshold) - } - for _, key := range material.UnsealKeysB64[:threshold] { + for _, key := range material.UnsealKeysB64[:material.UnsealThreshold] { if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil { return fmt.Errorf("unseal OpenBao: %w", err) } @@ -245,21 +290,12 @@ func encryptRecovery(recipient, bundlePath string, plaintext []byte) error { } cmd := exec.Command("age", "-r", recipient, "-o", bundlePath) cmd.Stdin = bytes.NewReader(plaintext) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("encrypt OpenBao recovery material: %w: %s", err, bytes.TrimSpace(output)) + if _, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("encrypt OpenBao recovery material: %w", err) } return os.Chmod(bundlePath, 0600) } -func decryptRecovery(identityPath, bundlePath string) ([]byte, error) { - cmd := exec.Command("age", "-d", "-i", identityPath, bundlePath) - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("decrypt OpenBao recovery material: %w", err) - } - return output, nil -} - func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) { command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...) cmd := exec.Command("kubectl", command...) diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index b175ff6..dd6caf5 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -1,6 +1,7 @@ package openbao import ( + "errors" "os/exec" "path/filepath" "strings" @@ -19,3 +20,36 @@ func TestEnsureRecoveryIdentity(t *testing.T) { t.Fatalf("invalid recovery recipient") } } + +func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) { + original := decryptRecovery + t.Cleanup(func() { decryptRecovery = original }) + called := false + decryptRecovery = func(identityPath, bundlePath string) ([]byte, error) { + called = identityPath == "recovery-identity" && bundlePath == "recovery-bundle" + return []byte(`{"unseal_keys_b64":["test-share-1","test-share-2"],"unseal_threshold":2,"root_token":"test-root"}`), nil + } + + material, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle") + if err != nil || !called || material.UnsealThreshold != 2 || len(material.UnsealKeysB64) != 2 || material.RootToken == "" { + t.Fatal("valid OpenBao recovery material was not read") + } +} + +func TestReadRecoveryMaterialRejectsMalformedOrInsufficientBundle(t *testing.T) { + original := decryptRecovery + t.Cleanup(func() { decryptRecovery = original }) + for _, plaintext := range [][]byte{ + []byte(`{"unseal_keys_b64":`), + []byte(`{"unseal_keys_b64":["test-share"],"unseal_threshold":2,"root_token":"test-root"}`), + } { + decryptRecovery = func(string, string) ([]byte, error) { return plaintext, nil } + if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil { + t.Fatal("invalid OpenBao recovery material was accepted") + } + } + decryptRecovery = func(string, string) ([]byte, error) { return nil, errors.New("unavailable") } + if _, err := ReadRecoveryMaterial("recovery-identity", "recovery-bundle"); err == nil { + t.Fatal("recovery decryption failure was accepted") + } +} -- 2.43.7