feat: render OpenBao unseal secret #7
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue