0358efe95b
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs - secrets file flock (concurrent set on same ns no longer loses data) - upgrade lock file (refuse concurrent orca upgrade) - backup lock file (refuse concurrent backup) - cache invalidation by writes (read-after-write consistency) - Executor.Run mutex scope fix (hold only for DB inserts) - ns create/inherit/set-constraint atomic writeNSMdAtomic - writeCurrentLead + rotateSSHKeys atomic - consolidate 3 writeAtomic impls onto security.WriteAtomic - WebAuthn session stores guarded with sync.Mutex Tests: concurrent secrets set, upgrade lock rejection, cache read-after-write, WebAuthn session thread-safety (pass under -race). ---ci--- project: orca phase: 7 milestone: v0.13 status: complete requirements: covered: [156] ---/ci---
485 lines
17 KiB
Go
485 lines
17 KiB
Go
// Package cli: secrets.go implements the `orca secrets` subcommand
|
|
// family (P03, REQ-080, gate C-19). Subcommands:
|
|
//
|
|
// orca secrets set <ns> <KEY=value> — encrypt and add/update a secret
|
|
// orca secrets get <ns> <KEY> — decrypt and print a single value
|
|
// orca secrets list <ns> — list secret KEYS (not values)
|
|
// orca secrets rotate <ns> <KEY> — re-encrypt with a fresh nonce
|
|
// orca secrets delete <ns> <KEY> — remove a secret
|
|
//
|
|
// All commands load the master key from paths.MasterKeyPath() and derive
|
|
// a per-namespace sub-key via HKDF-SHA256. The .env.secrets file lives at
|
|
// paths.NSSecrets(ns). Writes are atomic (temp + rename). The master key
|
|
// file MUST be mode 0600; LoadMasterKey refuses looser permissions.
|
|
//
|
|
// `get` writes ONLY the secret value to stdout (no logging of the value,
|
|
// no trailing newline beyond the value itself). This makes it safe to
|
|
// pipe into a credential consumer.
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
|
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
|
)
|
|
|
|
var secretsCmd = &cobra.Command{
|
|
Use: "secrets",
|
|
Short: "Manage encrypted .env.secrets per namespace",
|
|
Long: `Manage encrypted .env.secrets per namespace (REQ-080).
|
|
|
|
Each namespace has a .env.secrets file at <ORCA_HOME>/<ns>/.env.secrets
|
|
containing one base64(nonce||ciphertext||tag) blob per line. Encryption
|
|
is AES-256-GCM with a per-namespace HKDF-SHA256 sub-key derived from the
|
|
cluster master.key (mode 0600). The AAD is the 1-based line number,
|
|
defeating line-swap attacks.`,
|
|
}
|
|
|
|
// loadMasterAndNSSecrets reads the master key and the namespace's
|
|
// current .env.secrets (if present), returning the ns sub-key and the
|
|
// current plaintext lines. If the file does not exist, an empty slice
|
|
// is returned (no error).
|
|
func loadMasterAndNSSecrets(namespace string) (nsKey []byte, lines []string, err error) {
|
|
mkPath := paths.MasterKeyPath()
|
|
mk, err := secrets.LoadMasterKey(mkPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("load master key: %w", err)
|
|
}
|
|
// P05 T6: zero the raw master key once the namespace sub-key has
|
|
// been derived. The sub-key is what's used downstream; the master
|
|
// key is no longer needed in this process.
|
|
defer secrets.ZeroKey(mk)
|
|
nsKey, err = secrets.DeriveNamespaceKey(mk, namespace)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("derive namespace key: %w", err)
|
|
}
|
|
secPath := paths.NSSecrets(namespace)
|
|
body, readErr := os.ReadFile(secPath)
|
|
if readErr != nil {
|
|
if os.IsNotExist(readErr) {
|
|
return nsKey, nil, nil
|
|
}
|
|
return nil, nil, fmt.Errorf("read %s: %w", secPath, readErr)
|
|
}
|
|
lines, err = secrets.DecryptEnvFile(nsKey, string(body))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("decrypt %s: %w", secPath, err)
|
|
}
|
|
return nsKey, lines, nil
|
|
}
|
|
|
|
// saveNSSecrets encrypts the lines and writes them atomically to the
|
|
// namespace's .env.secrets path.
|
|
func saveNSSecrets(namespace string, nsKey []byte, lines []string) error {
|
|
enc, err := secrets.EncryptEnvFile(nsKey, lines)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt secrets: %w", err)
|
|
}
|
|
secPath := paths.NSSecrets(namespace)
|
|
if err := os.MkdirAll(filepath.Dir(secPath), 0o755); err != nil {
|
|
return fmt.Errorf("create ns dir: %w", err)
|
|
}
|
|
if err := writeAtomicFile(secPath, []byte(enc), 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", secPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lockNSSecrets acquires an exclusive advisory lock on the namespace's
|
|
// .env.secrets file (REQ-156, P07 T2). The lock file is
|
|
// paths.NSSecrets(ns) + ".lock". Returns a release function that MUST
|
|
// be deferred. Used by set/rotate/delete/rotate-master to prevent
|
|
// concurrent read-modify-write races: two operators running
|
|
// `orca secrets set` simultaneously against the same namespace would
|
|
// otherwise each load-then-save and the second write would clobber the
|
|
// first (losing a key). The flock is advisory; the parent dir is
|
|
// created first so Flock's O_CREATE does not fail on a missing dir.
|
|
func lockNSSecrets(namespace string) (func(), error) {
|
|
secPath := paths.NSSecrets(namespace)
|
|
if err := os.MkdirAll(filepath.Dir(secPath), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create ns dir for lock: %w", err)
|
|
}
|
|
return security.Flock(secPath + ".lock")
|
|
}
|
|
|
|
// parseKV splits a "KEY=value" argument. The value may contain '='.
|
|
func parseKV(arg string) (key, value string, err error) {
|
|
idx := strings.IndexByte(arg, '=')
|
|
if idx <= 0 {
|
|
return "", "", fmt.Errorf("expected KEY=value, got %q", arg)
|
|
}
|
|
return arg[:idx], arg[idx+1:], nil
|
|
}
|
|
|
|
// findKeyIndex returns the index of the line whose KEY matches the
|
|
// given key, or -1 if not found.
|
|
func findKeyIndex(lines []string, key string) int {
|
|
for i, line := range lines {
|
|
if k, _, ok := splitKV(line); ok && k == key {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// splitKV splits a plaintext "KEY=value" line. ok is false if the line
|
|
// is not in KEY=value form.
|
|
func splitKV(line string) (key, value string, ok bool) {
|
|
idx := strings.IndexByte(line, '=')
|
|
if idx <= 0 {
|
|
return "", "", false
|
|
}
|
|
return line[:idx], line[idx+1:], true
|
|
}
|
|
|
|
var secretsSetCmd = &cobra.Command{
|
|
Use: "set <namespace> <KEY=value>",
|
|
Short: "Encrypt and add/update a secret in a namespace",
|
|
Long: `Encrypt KEY=value and add or update it in <namespace>/.env.secrets.
|
|
If the key already exists, its value is replaced; otherwise a new line
|
|
is appended. The .env.secrets file is rewritten atomically.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ns := args[0]
|
|
key, value, err := parseKV(args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// REQ-156 / P07 T2: flock around load+save so concurrent
|
|
// `orca secrets set` on the same namespace don't clobber
|
|
// each other (the second write would lose the first's key).
|
|
release, err := lockNSSecrets(ns)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire secrets lock: %w", err)
|
|
}
|
|
defer release()
|
|
nsKey, lines, err := loadMasterAndNSSecrets(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// P05 T6: zero the namespace sub-key when done.
|
|
defer secrets.ZeroKey(nsKey)
|
|
newLine := key + "=" + value
|
|
idx := findKeyIndex(lines, key)
|
|
if idx >= 0 {
|
|
lines[idx] = newLine
|
|
} else {
|
|
lines = append(lines, newLine)
|
|
}
|
|
if err := saveNSSecrets(ns, nsKey, lines); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("secrets set", "namespace", ns, "key", key, "action", "update")
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{"namespace": ns, "key": key, "action": map[string]string{"set": "ok"}})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s=%s set in namespace %q\n", key, strings.Repeat("*", len(value)), ns)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var secretsGetCmd = &cobra.Command{
|
|
Use: "get <namespace> <KEY>",
|
|
Short: "Decrypt and print a single secret value (stdout only)",
|
|
Long: `Decrypt the secret named KEY from <namespace>/.env.secrets and print
|
|
its value to stdout. The value is printed with NO trailing newline
|
|
added beyond what the secret itself contained. The value is NEVER
|
|
logged via slog.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ns := args[0]
|
|
key := args[1]
|
|
_, lines, err := loadMasterAndNSSecrets(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx := findKeyIndex(lines, key)
|
|
if idx < 0 {
|
|
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
|
}
|
|
_, value, _ := splitKV(lines[idx])
|
|
slog.Info("secrets get", "namespace", ns, "key", key)
|
|
fmt.Fprint(cmd.OutOrStdout(), value)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var secretsListCmd = &cobra.Command{
|
|
Use: "list <namespace>",
|
|
Short: "List secret KEYS (not values) in a namespace",
|
|
Long: `List the keys of all secrets stored in <namespace>/.env.secrets. Values are never printed.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ns := args[0]
|
|
_, lines, err := loadMasterAndNSSecrets(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keys := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if k, _, ok := splitKV(line); ok {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
slog.Info("secrets list", "namespace", ns, "count", len(keys))
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{"namespace": ns, "keys": keys})
|
|
}
|
|
if len(keys) == 0 {
|
|
fmt.Fprintln(cmd.OutOrStdout(), "No secrets found.")
|
|
return nil
|
|
}
|
|
for _, k := range keys {
|
|
fmt.Fprintln(cmd.OutOrStdout(), k)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var secretsRotateCmd = &cobra.Command{
|
|
Use: "rotate <namespace> <KEY>",
|
|
Short: "Re-encrypt a secret with a fresh nonce",
|
|
Long: `Re-encrypt the secret named KEY with a fresh nonce. The plaintext
|
|
value is unchanged. Useful after a master key rotation or to invalidate
|
|
old ciphertext copies. The .env.secrets file is rewritten atomically.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ns := args[0]
|
|
key := args[1]
|
|
// REQ-156 / P07 T2: flock around load+save (re-encryption is a
|
|
// read-modify-write of the whole .env.secrets file).
|
|
release, err := lockNSSecrets(ns)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire secrets lock: %w", err)
|
|
}
|
|
defer release()
|
|
nsKey, lines, err := loadMasterAndNSSecrets(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// P05 T6: zero the namespace sub-key when done.
|
|
defer secrets.ZeroKey(nsKey)
|
|
idx := findKeyIndex(lines, key)
|
|
if idx < 0 {
|
|
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
|
}
|
|
_, value, _ := splitKV(lines[idx])
|
|
lines[idx] = key + "=" + value
|
|
if err := saveNSSecrets(ns, nsKey, lines); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("secrets rotate", "namespace", ns, "key", key)
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{"namespace": ns, "key": key, "rotated": true})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s rotated in namespace %q\n", key, ns)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var secretsDeleteCmd = &cobra.Command{
|
|
Use: "delete <namespace> <KEY>",
|
|
Short: "Remove a secret from a namespace",
|
|
Long: `Remove the secret named KEY from <namespace>/.env.secrets. The
|
|
.env.secrets file is rewritten atomically.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ns := args[0]
|
|
key := args[1]
|
|
// REQ-156 / P07 T2: flock around load+save (delete rewrites
|
|
// the whole file).
|
|
release, err := lockNSSecrets(ns)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire secrets lock: %w", err)
|
|
}
|
|
defer release()
|
|
nsKey, lines, err := loadMasterAndNSSecrets(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// P05 T6: zero the namespace sub-key when done.
|
|
defer secrets.ZeroKey(nsKey)
|
|
idx := findKeyIndex(lines, key)
|
|
if idx < 0 {
|
|
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
|
}
|
|
lines = append(lines[:idx], lines[idx+1:]...)
|
|
if err := saveNSSecrets(ns, nsKey, lines); err != nil {
|
|
return err
|
|
}
|
|
slog.Info("secrets delete", "namespace", ns, "key", key)
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{"namespace": ns, "key": key, "deleted": true})
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s deleted from namespace %q\n", key, ns)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var secretsRotateMasterDryRun bool
|
|
|
|
var secretsRotateMasterCmd = &cobra.Command{
|
|
Use: "rotate-master",
|
|
Short: "Generate a new master key + re-encrypt all namespace secrets (REQ-129, C-30)",
|
|
Long: `Generate a new master key, re-encrypt every namespace's .env.secrets
|
|
under the new key, and re-seal the master key to OIDC. With --dry-run,
|
|
reports the affected namespaces without writing. Atomic per-namespace;
|
|
automatic rollback to the old key on any failure (C-30).`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
mkPath := paths.MasterKeyPath()
|
|
oldKey, err := secrets.LoadMasterKey(mkPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load current master key: %w", err)
|
|
}
|
|
// P05 T6: zero the old master key when done (defense-in-depth).
|
|
defer secrets.ZeroKey(oldKey)
|
|
|
|
// Find all namespaces with .env.secrets files.
|
|
root := paths.Root()
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return fmt.Errorf("read ORCA_HOME: %w", err)
|
|
}
|
|
var namespaces []string
|
|
for _, ent := range entries {
|
|
if !ent.IsDir() || ent.Name() == "cluster" {
|
|
continue
|
|
}
|
|
secPath := paths.NSSecrets(ent.Name())
|
|
if _, err := os.Stat(secPath); err == nil {
|
|
namespaces = append(namespaces, ent.Name())
|
|
}
|
|
}
|
|
if secretsRotateMasterDryRun {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "dry-run: would re-encrypt %d namespace(s) under a new master key:\n", len(namespaces))
|
|
for _, ns := range namespaces {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", ns)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Generate new master key.
|
|
newKey, err := secrets.GenerateMasterKey()
|
|
if err != nil {
|
|
return fmt.Errorf("generate new master key: %w", err)
|
|
}
|
|
// P05 T6: zero the new master key when done (it has been
|
|
// persisted to disk or re-sealed by this point).
|
|
defer secrets.ZeroKey(newKey)
|
|
|
|
// Re-encrypt each namespace. On any failure, rollback.
|
|
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
|
for _, ns := range namespaces {
|
|
// REQ-156 / P07 T2: lock each namespace while we re-encrypt
|
|
// it so a concurrent `secrets set` cannot interleave a write
|
|
// under the OLD key after we have already rotated.
|
|
release, err := lockNSSecrets(ns)
|
|
if err != nil {
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("acquire secrets lock for ns %s: %w", ns, err)
|
|
}
|
|
_, lines, loadErr := loadMasterAndNSSecrets(ns)
|
|
if loadErr != nil {
|
|
release()
|
|
// Rollback already-processed namespaces.
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("load secrets for ns %s: %w", ns, loadErr)
|
|
}
|
|
// Save the old encrypted content for rollback.
|
|
secPath := paths.NSSecrets(ns)
|
|
oldEnc, _ := os.ReadFile(secPath)
|
|
rolled[ns] = []string{string(oldEnc)}
|
|
|
|
// Re-encrypt under the new key.
|
|
newNSKey, err := secrets.DeriveNamespaceKey(newKey, ns)
|
|
if err != nil {
|
|
release()
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("derive new ns key for %s: %w", ns, err)
|
|
}
|
|
enc, err := secrets.EncryptEnvFile(newNSKey, lines)
|
|
if err != nil {
|
|
release()
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("re-encrypt ns %s: %w", ns, err)
|
|
}
|
|
if err := writeAtomicFile(secPath, []byte(enc), 0o600); err != nil {
|
|
release()
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("write ns %s: %w", ns, err)
|
|
}
|
|
release()
|
|
}
|
|
|
|
// Save the new master key.
|
|
if err := secrets.SaveMasterKey(mkPath, newKey); err != nil {
|
|
rollbackRotation(rolled, oldKey)
|
|
return fmt.Errorf("save new master key (rolled back): %w", err)
|
|
}
|
|
|
|
// P05 T5: if the cluster is in sealed mode, re-seal the new
|
|
// master key into the sealed blob and remove the raw key from
|
|
// disk. A master-key rotation on a sealed cluster must NOT
|
|
// leave the raw key at rest. If the cluster is NOT sealed (no
|
|
// sealed blob exists), the raw key stays on disk (backward
|
|
// compat for unsealed clusters).
|
|
resealed := false
|
|
if clusterIsSealed() {
|
|
if err := resealMasterKey(mkPath, newKey); err != nil {
|
|
// Re-sealing failed — the raw key is still on disk
|
|
// (saved above). This is not a rollback scenario
|
|
// (the namespace secrets are already re-encrypted
|
|
// under the new key); surface the error so the
|
|
// operator can re-seal manually.
|
|
return fmt.Errorf("save new master key ok, but re-seal failed (raw key still on disk — re-seal manually): %w", err)
|
|
}
|
|
resealed = true
|
|
}
|
|
|
|
slog.Info("secrets rotate-master", "namespaces", len(namespaces), "resealed", resealed)
|
|
if jsonOutput {
|
|
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces, "resealed": resealed})
|
|
}
|
|
if resealed {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted; re-sealed to OIDC/CA\n", len(namespaces))
|
|
} else {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// rollbackRotation restores old encrypted secrets for already-processed
|
|
// namespaces (C-30: automatic rollback on failure).
|
|
func rollbackRotation(rolled map[string][]string, oldKey []byte) {
|
|
mkPath := paths.MasterKeyPath()
|
|
_ = secrets.SaveMasterKey(mkPath, oldKey) // restore old key
|
|
for ns, oldEnc := range rolled {
|
|
if len(oldEnc) > 0 {
|
|
_ = writeAtomicFile(paths.NSSecrets(ns), []byte(oldEnc[0]), 0o600)
|
|
}
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
secretsCmd.AddCommand(secretsSetCmd)
|
|
secretsCmd.AddCommand(secretsGetCmd)
|
|
secretsCmd.AddCommand(secretsListCmd)
|
|
secretsCmd.AddCommand(secretsRotateCmd)
|
|
secretsCmd.AddCommand(secretsDeleteCmd)
|
|
secretsRotateMasterCmd.Flags().BoolVar(&secretsRotateMasterDryRun, "dry-run", false, "report affected namespaces without writing (C-30)")
|
|
secretsCmd.AddCommand(secretsRotateMasterCmd)
|
|
rootCmd.AddCommand(secretsCmd)
|
|
}
|