diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index f554e93..2f1a019 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -276,11 +276,118 @@ var secretsDeleteCmd = &cobra.Command{ }, } +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) + } + + // 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) + } + + // Re-encrypt each namespace. On any failure, rollback. + rolled := make(map[string][]string) // ns -> old encrypted (for rollback) + for _, ns := range namespaces { + _, lines, err := loadMasterAndNSSecrets(ns) + if err != nil { + // Rollback already-processed namespaces. + rollbackRotation(rolled, oldKey) + return fmt.Errorf("load secrets for ns %s: %w", ns, err) + } + // 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 { + rollbackRotation(rolled, oldKey) + return fmt.Errorf("derive new ns key for %s: %w", ns, err) + } + enc, err := secrets.EncryptEnvFile(newNSKey, lines) + if err != nil { + rollbackRotation(rolled, oldKey) + return fmt.Errorf("re-encrypt ns %s: %w", ns, err) + } + if err := writeAtomicFile(secPath, []byte(enc), 0o600); err != nil { + rollbackRotation(rolled, oldKey) + return fmt.Errorf("write ns %s: %w", ns, err) + } + } + + // 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) + } + + slog.Info("secrets rotate-master", "namespaces", len(namespaces)) + if jsonOutput { + return printJSON(map[string]any{"rotated": true, "namespaces": namespaces}) + } + 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) }