// 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" ) 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) } 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 } // 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 } nsKey, lines, err := loadMasterAndNSSecrets(ns) if err != nil { return err } 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] nsKey, 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]) 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] nsKey, 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) } 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 }, } func init() { secretsCmd.AddCommand(secretsSetCmd) secretsCmd.AddCommand(secretsGetCmd) secretsCmd.AddCommand(secretsListCmd) secretsCmd.AddCommand(secretsRotateCmd) secretsCmd.AddCommand(secretsDeleteCmd) rootCmd.AddCommand(secretsCmd) }