package cli import ( "bufio" "fmt" "log/slog" "os" "strings" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/identity" "git.cloudinit.dev/coreci/orca/internal/paths" "git.cloudinit.dev/coreci/orca/internal/seal" "git.cloudinit.dev/coreci/orca/internal/secrets" "git.cloudinit.dev/coreci/orca/internal/security" ) var clusterCmd = &cobra.Command{ Use: "cluster", Short: "Cluster-wide operations (cutover, rotate-lead, compat-check, seal/unseal)", Long: `Cluster-wide operations: daemon cutover, lead rotation, mixed-version compatibility checks, and master-key seal/unseal (REQ-147, D-241, C-35).`, } // sealedBlobPath returns the on-disk path for the sealed master key: // ClusterDir()/master.key.sealed (0600). func sealedBlobPath() string { return paths.ClusterDir() + "/master.key.sealed" } // caFingerprintForSeal resolves the cluster CA fingerprint used as the // seal key for the mTLS-only offline path (D-241). Returns the // SHA-256 hex fingerprint of the on-disk CA cert, or an error if the // CA cannot be loaded. func caFingerprintForSeal() (string, error) { caCertPath := certpaths.CACertPath() fp, err := security.Fingerprint(caCertPath) if err != nil { return "", fmt.Errorf("seal: read CA fingerprint: %w", err) } return fp, nil } // sealMode determines which seal path to use: // - "oidc" if valid OIDC credentials are present (Subject non-empty). // - "ca" otherwise (mTLS-only offline path, D-241). func sealMode() (mode string, oidcSub string, caFingerprint string, err error) { creds, credErr := identity.LoadCredentials() if credErr == nil && creds.Subject != "" { return "oidc", creds.Subject, "", nil } // No OIDC credentials (or load failed) — fall back to CA-derived // seal key for the mTLS-only offline path. fp, fpErr := caFingerprintForSeal() if fpErr != nil { return "", "", "", fmt.Errorf("seal: no OIDC credentials and %w", fpErr) } return "ca", "", fp, nil } // clusterSealCmd implements `orca cluster seal`. var clusterSealCmd = &cobra.Command{ Use: "seal", Short: "Seal the master key (encrypt to OIDC/CA, print Shamir shards)", Long: `Seal the cluster master key (REQ-147, D-241, C-35). The raw master key at ClusterDir()/master.key is encrypted with a key derived from either: - the OIDC ID token subject (if ` + "`orca auth login`" + ` has been run), or - the cluster CA fingerprint (mTLS-only offline path, D-241). The sealed blob is written to ClusterDir()/master.key.sealed (0600). Five Shamir shards (3-of-5 recovery) are printed to stdout — store them offline. The raw master key is then deleted from disk so that the cluster is sealed at rest. Recovery: if the IdP is permanently lost, use ` + "`orca cluster unseal --recovery`" + ` with any 3 of the 5 shards.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { mkPath := paths.MasterKeyPath() masterKey, err := secrets.LoadMasterKey(mkPath) if err != nil { return fmt.Errorf("seal: load master key: %w", err) } // P05 T6: zero the raw master key when done. defer secrets.ZeroKey(masterKey) sealedPath := sealedBlobPath() // Refuse to seal if already sealed (avoid clobbering an existing // sealed blob — operator must unseal + re-seal explicitly). if _, err := os.Stat(sealedPath); err == nil { return fmt.Errorf("seal: %s already exists — unseal first, then re-seal", sealedPath) } mode, oidcSub, caFp, err := sealMode() if err != nil { return err } var blob *seal.SealedBlob var shards [][]byte switch mode { case "oidc": issuer := "" if creds, _ := identity.LoadCredentials(); creds != nil { issuer = creds.Issuer } blob, shards, err = seal.Seal(masterKey, oidcSub, issuer) if err != nil { return fmt.Errorf("seal (oidc): %w", err) } case "ca": blob, err = seal.SealWithCA(masterKey, caFp) if err != nil { return fmt.Errorf("seal (ca): %w", err) } // CA-mode does not produce Shamir shards via SealWithCA; // generate them separately so the recovery path is // available regardless of seal mode. shards, err = seal.ShamirSplit(masterKey, 5, 3) if err != nil { return fmt.Errorf("seal: shamir split: %w", err) } default: return fmt.Errorf("seal: unknown mode %q", mode) } if err := seal.SaveSealed(sealedPath, blob); err != nil { return fmt.Errorf("seal: save sealed blob: %w", err) } if err := os.Chmod(sealedPath, 0o600); err != nil { return fmt.Errorf("seal: chmod sealed blob: %w", err) } // Delete the raw master key — the cluster is now sealed at rest. if err := os.Remove(mkPath); err != nil { // Non-fatal: warn but don't fail (the sealed blob is // already written). Operator should manually remove the // raw key. slog.Warn("seal: failed to remove raw master key — remove manually", "path", mkPath, "error", err) } slog.Info("cluster sealed", "mode", mode, "sealed_path", sealedPath) out := cmd.OutOrStdout() fmt.Fprintf(out, "✓ Master key sealed (mode=%s) → %s\n", mode, sealedPath) fmt.Fprintf(out, "\nShamir recovery shards (3-of-5 — store offline):\n") for i, s := range shards { fmt.Fprintf(out, " shard %d: %s\n", i+1, seal.EncodeShard(s)) } fmt.Fprintln(out, "\nRaw master key deleted from disk. Cluster is sealed at rest.") fmt.Fprintln(out, "Use `orca cluster unseal` to unseal, or `orca cluster unseal --recovery` with 3 shards.") return nil }, } // clusterUnsealCmd implements `orca cluster unseal` (and --recovery). var clusterUnsealRecovery bool var clusterUnsealCmd = &cobra.Command{ Use: "unseal", Short: "Unseal the master key (OIDC/CA unwrap, or Shamir recovery)", Long: `Unseal the cluster master key (REQ-147, D-241, C-35). Reads the sealed blob at ClusterDir()/master.key.sealed and unwraps the master key using either: - the OIDC ID token subject (if credentials are present), or - the cluster CA fingerprint (mTLS-only offline path). The unwrapped master key is written back to ClusterDir()/master.key (0600) so that other commands (secrets, backup, etc.) can use it. The raw key is zeroed from memory on process exit. With --recovery, the operator is prompted for 3 of the 5 Shamir shards printed at seal time; the master key is reconstructed from the quorum and written to disk. Use this when the IdP is permanently lost.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { sealedPath := sealedBlobPath() blob, err := seal.LoadSealed(sealedPath) if err != nil { return fmt.Errorf("unseal: load sealed blob: %w", err) } mkPath := paths.MasterKeyPath() var masterKey []byte if clusterUnsealRecovery { // Shamir recovery path: prompt for 3 shards from stdin. masterKey, err = unsealViaShamirRecovery(cmd, blob) if err != nil { return err } } else { // Normal unseal path: OIDC or CA-derived key. switch blob.Mode { case "oidc": creds, credErr := identity.LoadCredentials() if credErr != nil { return fmt.Errorf("unseal (oidc): no credentials — run `orca auth login` first, or use --recovery: %w", credErr) } if creds.Subject == "" { return fmt.Errorf("unseal (oidc): credentials have empty subject — re-login or use --recovery") } masterKey, err = seal.Unseal(blob, creds.Subject) if err != nil { return fmt.Errorf("unseal (oidc): %w", err) } case "ca": caFp, fpErr := caFingerprintForSeal() if fpErr != nil { return fmt.Errorf("unseal (ca): %w", fpErr) } masterKey, err = seal.UnsealWithCA(blob, caFp) if err != nil { return fmt.Errorf("unseal (ca): %w", err) } default: return fmt.Errorf("unseal: unknown seal mode %q", blob.Mode) } } // P05 T6: zero the raw master key when the process exits. defer secrets.ZeroKey(masterKey) // Persist the unwrapped master key so other commands can use // it (mode 0600). if err := secrets.SaveMasterKey(mkPath, masterKey); err != nil { return fmt.Errorf("unseal: save master key: %w", err) } mode := blob.Mode if clusterUnsealRecovery { mode = "shamir-recovery" } slog.Info("cluster unsealed", "mode", mode) fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key unsealed (mode=%s) → %s\n", mode, mkPath) fmt.Fprintln(cmd.OutOrStdout(), "Cluster is now unsealed. The raw master key will be zeroed from memory on process exit.") return nil }, } // unsealViaShamirRecovery prompts the operator for 3 Shamir shards via // stdin, decodes them, and combines them to reconstruct the master key. // The sealed blob is only used to confirm the recovered key length. func unsealViaShamirRecovery(cmd *cobra.Command, blob *seal.SealedBlob) ([]byte, error) { in := bufio.NewReader(cmd.InOrStdin()) var shards [][]byte needed := 3 for i := 0; i < needed; i++ { fmt.Fprintf(cmd.OutOrStdout(), "Shard %d of %d: ", i+1, needed) line, err := in.ReadString('\n') if err != nil { return nil, fmt.Errorf("recovery: read shard %d: %w", i+1, err) } line = strings.TrimSpace(line) if line == "" { return nil, fmt.Errorf("recovery: shard %d is empty", i+1) } shard, err := seal.DecodeShard(line) if err != nil { return nil, fmt.Errorf("recovery: shard %d decode: %w", i+1, err) } shards = append(shards, shard) } masterKey, err := seal.UnsealWithShamir(blob, shards) if err != nil { return nil, fmt.Errorf("recovery: %w", err) } return masterKey, nil } // clusterIsSealed reports whether the cluster is currently in sealed // mode (i.e. a master.key.sealed blob exists on disk). Used by // `secrets rotate-master` (P05 T5) to decide whether to re-seal the // newly-rotated master key or leave the raw key on disk (backward // compat for unsealed clusters). func clusterIsSealed() bool { _, err := os.Stat(sealedBlobPath()) return err == nil } // resealMasterKey re-seals the given (newly-rotated) master key into // the existing sealed blob, preserving the seal mode (oidc or ca) from // the prior sealed blob. The raw master key at mkPath is removed after // re-sealing. Used by `secrets rotate-master` (P05 T5) so that a // master-key rotation on a sealed cluster does NOT leave the raw key // on disk. // // If the sealed blob does not exist (cluster is not sealed), this is a // no-op and the caller is expected to have left the raw key in place. func resealMasterKey(mkPath string, newKey []byte) error { sealedPath := sealedBlobPath() existing, err := seal.LoadSealed(sealedPath) if err != nil { return fmt.Errorf("re-seal: load existing sealed blob: %w", err) } var blob *seal.SealedBlob switch existing.Mode { case "oidc": creds, credErr := identity.LoadCredentials() if credErr != nil { return fmt.Errorf("re-seal (oidc): no credentials: %w", credErr) } if creds.Subject == "" { return fmt.Errorf("re-seal (oidc): credentials have empty subject") } blob, _, err = seal.Seal(newKey, creds.Subject, creds.Issuer) if err != nil { return fmt.Errorf("re-seal (oidc): %w", err) } case "ca": caFp, fpErr := caFingerprintForSeal() if fpErr != nil { return fmt.Errorf("re-seal (ca): %w", fpErr) } blob, err = seal.SealWithCA(newKey, caFp) if err != nil { return fmt.Errorf("re-seal (ca): %w", err) } default: return fmt.Errorf("re-seal: unknown existing seal mode %q", existing.Mode) } if err := seal.SaveSealed(sealedPath, blob); err != nil { return fmt.Errorf("re-seal: save sealed blob: %w", err) } if err := os.Chmod(sealedPath, 0o600); err != nil { return fmt.Errorf("re-seal: chmod sealed blob: %w", err) } // Remove the raw master key — the cluster is sealed at rest again. if err := os.Remove(mkPath); err != nil { slog.Warn("re-seal: failed to remove raw master key — remove manually", "path", mkPath, "error", err) } slog.Info("re-sealed rotated master key", "mode", existing.Mode, "sealed_path", sealedPath) return nil } func init() { clusterUnsealCmd.Flags().BoolVar(&clusterUnsealRecovery, "recovery", false, "unseal via 3-of-5 Shamir shard quorum (C-35)") clusterCmd.AddCommand(clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, clusterSealCmd, clusterUnsealCmd) rootCmd.AddCommand(clusterCmd) }