9e832387c6
New CLI commands: - orca cluster seal: OIDC/CA-derived seal + Shamir 3-of-5 shards - orca cluster unseal: OIDC/CA unseal + --recovery Shamir path - orca doctor audit: VerifyChain + chain head report - orca doctor modes: EnforceFileModes across ORCA_HOME Fixes: - audit hash-chain race: Append uses BEGIN IMMEDIATE transaction (concurrent appends no longer corrupt tamper-evidence) - secrets rotate-master: re-seals to OIDC on sealed clusters (was writing raw key, docstring claimed re-seal) - key zeroing: ZeroKey helper + defer after master/namespace key use (defense-in-depth against pprof heap extraction) - store.Open: busy_timeout(5000) pragma (concurrent writers wait) Tests: 18 new test functions (seal round-trip, Shamir recovery, doctor audit tamper detection, doctor modes 0644 rejection, concurrent append chain integrity, rotate-master re-seal, key zeroing). ---ci--- project: orca phase: 5 milestone: v0.13 status: complete requirements: covered: [154] ---/ci---
345 lines
12 KiB
Go
345 lines
12 KiB
Go
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)
|
|
}
|