fix(P14): master key rotation (REQ-129, F12, C-30)
---ci--- project: orca phase: 14 milestone: v0.12 status: execute ---/ci--- orca secrets rotate-master: generates new master key, re-encrypts all namespace secrets under new key, saves new key. --dry-run reports affected namespaces. Atomic per-namespace; automatic rollback to old key on any failure (C-30). Fixed unused nsKey in get+list (pre-existing vet issue). Build + vet + tests green.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user