// Package cli: secrets.go implements the `orca secrets` subcommand // family (P03, REQ-080, gate C-19). Subcommands: // // orca secrets set — encrypt and add/update a secret // orca secrets get — decrypt and print a single value // orca secrets list — list secret KEYS (not values) // orca secrets rotate — re-encrypt with a fresh nonce // orca secrets delete — 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 //.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 ", Short: "Encrypt and add/update a secret in a namespace", Long: `Encrypt KEY=value and add or update it in /.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 ", Short: "Decrypt and print a single secret value (stdout only)", Long: `Decrypt the secret named KEY from /.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 ", Short: "List secret KEYS (not values) in a namespace", Long: `List the keys of all secrets stored in /.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 ", 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 ", Short: "Remove a secret from a namespace", Long: `Remove the secret named KEY from /.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) }