Merge pull request 'feat: render OpenBao unseal secret' (#7) from feat/render-openbao-unseal-secret into main
Reviewed-on: #7
This commit is contained in:
commit
1057cfa261
|
|
@ -45,6 +45,12 @@ type operationalSecrets struct {
|
||||||
|
|
||||||
var initializeOpenBao = openbao.Initialize
|
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 {
|
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)
|
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)
|
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 {
|
if _, err := os.Stat(r.Config.SOPS.OperationalSecretsPath); err != nil {
|
||||||
return fmt.Errorf("preflight operational SOPS secrets: %w", err)
|
return fmt.Errorf("preflight operational SOPS secrets: %w", err)
|
||||||
}
|
}
|
||||||
if r.Config.SOPS.RecoveryRecipient == "" || r.Config.SOPS.RecoveryIdentityPath == "" || r.Config.SOPS.RecoveryBundlePath == "" {
|
if r.Config.SOPS.RecoveryRecipient == "" {
|
||||||
return errors.New("SOPS recoveryRecipient, recoveryIdentityPath, and recoveryBundlePath are required")
|
return errors.New("SOPS recoveryRecipient is required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Mode == "" {
|
if r.Mode == "" {
|
||||||
|
|
@ -100,6 +106,15 @@ func (r Runner) Run() error {
|
||||||
if r.RegisterWebhook {
|
if r.RegisterWebhook {
|
||||||
return r.reconcileWebhook(filepath.Join(r.Config.Git.CloneParent, r.Config.Talos.RepoDirName, r.Config.Talos.GeneratedDir))
|
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
|
workspace := r.Config.WorkspaceDir
|
||||||
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
if err := EnsureTemplateRevisions(r.Config); err != nil {
|
||||||
return err
|
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 {
|
if err := writeDemocraticCSISecret(filepath.Join(dir, "base", "democratic-csi", "secret.sops.yaml"), r.Config.DemocraticCSI, r.Config.SOPS.AgeKeyPath); err != nil {
|
||||||
return err
|
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 {
|
if err := ensureClusterKustomizations(clusterDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +347,96 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe
|
||||||
return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
|
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 {
|
func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error {
|
||||||
plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets})
|
plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import (
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderCiliumConfig(t *testing.T) {
|
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) {
|
func TestNewWebhookAuthorization(t *testing.T) {
|
||||||
authorization, err := NewWebhookAuthorization()
|
authorization, err := NewWebhookAuthorization()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ package openbao
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -20,7 +22,7 @@ type status struct {
|
||||||
Sealed bool `json:"sealed"`
|
Sealed bool `json:"sealed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recovery struct {
|
type RecoveryMaterial struct {
|
||||||
UnsealKeysB64 []string `json:"unseal_keys_b64"`
|
UnsealKeysB64 []string `json:"unseal_keys_b64"`
|
||||||
UnsealThreshold int `json:"unseal_threshold"`
|
UnsealThreshold int `json:"unseal_threshold"`
|
||||||
RootToken string `json:"root_token"`
|
RootToken string `json:"root_token"`
|
||||||
|
|
@ -30,6 +32,15 @@ type operationalSecrets struct {
|
||||||
Secrets map[string]map[string]string `yaml:"secrets"`
|
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) {
|
func EnsureRecoveryIdentity(identityPath string) (string, error) {
|
||||||
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
|
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
|
||||||
if err := os.MkdirAll(filepath.Dir(identityPath), 0700); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var material recovery
|
var material RecoveryMaterial
|
||||||
if !current.Initialized {
|
if !current.Initialized {
|
||||||
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
|
output, err := execInPod(kubeconfig, nil, "bao", "operator", "init", "-format=json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("initialize OpenBao: %w", err)
|
return nil, fmt.Errorf("initialize OpenBao: %w", err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(output, &material); err != nil {
|
material, err = parseRecoveryMaterial(output)
|
||||||
return nil, fmt.Errorf("parse OpenBao recovery material: %w", err)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := encryptRecovery(recipient, bundlePath, output); err != nil {
|
if err := encryptRecovery(recipient, bundlePath, output); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
output, err := decryptRecovery(identityPath, bundlePath)
|
material, err = ReadRecoveryMaterial(identityPath, bundlePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 current.Sealed {
|
||||||
if err := unseal(kubeconfig, material); err != nil {
|
if err := unseal(kubeconfig, material); err != nil {
|
||||||
|
|
@ -146,6 +155,46 @@ func validateRecoveryRecipient(recipient, bundlePath string) error {
|
||||||
return nil
|
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 {
|
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 {
|
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
|
||||||
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
||||||
|
|
@ -200,15 +249,11 @@ func getStatus(kubeconfig string) (status, error) {
|
||||||
return current, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func unseal(kubeconfig string, material recovery) error {
|
func unseal(kubeconfig string, material RecoveryMaterial) error {
|
||||||
threshold := material.UnsealThreshold
|
if material.UnsealThreshold < 1 || len(material.UnsealKeysB64) < material.UnsealThreshold {
|
||||||
if threshold == 0 {
|
return fmt.Errorf("OpenBao recovery material has insufficient unseal keys")
|
||||||
threshold = 3
|
|
||||||
}
|
}
|
||||||
if len(material.UnsealKeysB64) < threshold {
|
for _, key := range material.UnsealKeysB64[:material.UnsealThreshold] {
|
||||||
return fmt.Errorf("OpenBao recovery bundle contains fewer than %d unseal keys", threshold)
|
|
||||||
}
|
|
||||||
for _, key := range material.UnsealKeysB64[:threshold] {
|
|
||||||
if _, err := execInPod(kubeconfig, []byte(key+"\n"), "sh", "-ec", "read -r key; bao operator unseal \"$key\" >/dev/null"); err != nil {
|
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)
|
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 := exec.Command("age", "-r", recipient, "-o", bundlePath)
|
||||||
cmd.Stdin = bytes.NewReader(plaintext)
|
cmd.Stdin = bytes.NewReader(plaintext)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if _, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("encrypt OpenBao recovery material: %w: %s", err, bytes.TrimSpace(output))
|
return fmt.Errorf("encrypt OpenBao recovery material: %w", err)
|
||||||
}
|
}
|
||||||
return os.Chmod(bundlePath, 0600)
|
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) {
|
func execInPod(kubeconfig string, input []byte, args ...string) ([]byte, error) {
|
||||||
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
command := append([]string{"--kubeconfig", kubeconfig, "-n", "openbao", "exec", "-i", "openbao-0", "--"}, args...)
|
||||||
cmd := exec.Command("kubectl", command...)
|
cmd := exec.Command("kubectl", command...)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package openbao
|
package openbao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -19,3 +20,36 @@ func TestEnsureRecoveryIdentity(t *testing.T) {
|
||||||
t.Fatalf("invalid recovery recipient")
|
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