Compare commits

...

2 commits

4 changed files with 328 additions and 29 deletions

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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