feat(P05): seal/audit CLI + chain race fix + key zeroing (REQ-154)
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---
This commit is contained in:
+331
-4
@@ -1,17 +1,344 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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{
|
var clusterCmd = &cobra.Command{
|
||||||
Use: "cluster",
|
Use: "cluster",
|
||||||
Short: "Cluster-wide operations (cutover, rotate-lead, compat-check)",
|
Short: "Cluster-wide operations (cutover, rotate-lead, compat-check, seal/unseal)",
|
||||||
Long: `Cluster-wide operations: daemon cutover, lead rotation, and
|
Long: `Cluster-wide operations: daemon cutover, lead rotation,
|
||||||
mixed-version compatibility checks.`,
|
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() {
|
func init() {
|
||||||
clusterCmd.AddCommand(clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd)
|
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)
|
rootCmd.AddCommand(clusterCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/seal"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupSealTestEnv prepares a temp ORCA_HOME with a CA (via runInit) and
|
||||||
|
// a raw master key, so that `cluster seal` has something to seal. The
|
||||||
|
// CA is needed for the offline (ca-mode) seal path which derives the
|
||||||
|
// seal key from the CA fingerprint.
|
||||||
|
func setupSealTestEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
// runInit does not create a master key; create one.
|
||||||
|
mk, err := secrets.GenerateMasterKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateMasterKey: %v", err)
|
||||||
|
}
|
||||||
|
if err := secrets.SaveMasterKey(paths.MasterKeyPath(), mk); err != nil {
|
||||||
|
t.Fatalf("SaveMasterKey: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClusterSealUnsealCARoundTrip (T7) verifies that sealing the
|
||||||
|
// master key (CA/offline mode) and then unsealing it allows secrets to
|
||||||
|
// be read. This exercises the full seal → unseal → secrets get
|
||||||
|
// round-trip.
|
||||||
|
func TestClusterSealUnsealCARoundTrip(t *testing.T) {
|
||||||
|
ns := "sealrt"
|
||||||
|
setupSealTestEnv(t)
|
||||||
|
mkPath := paths.MasterKeyPath()
|
||||||
|
sealedPath := sealedBlobPath()
|
||||||
|
|
||||||
|
// Capture the original master key so we can verify the round-trip.
|
||||||
|
origMK, err := secrets.LoadMasterKey(mkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load orig master key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a secret BEFORE sealing (under the raw key).
|
||||||
|
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir ns: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "set", ns, "TOKEN=roundtrip-secret"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets set before seal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal the cluster (CA mode — no OIDC creds present).
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster seal: %v", err)
|
||||||
|
}
|
||||||
|
sealOut := buf.String()
|
||||||
|
if !strings.Contains(sealOut, "sealed") {
|
||||||
|
t.Errorf("seal output unexpected: %s", sealOut)
|
||||||
|
}
|
||||||
|
// The sealed blob must exist at 0600.
|
||||||
|
info, err := os.Stat(sealedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sealed blob missing after seal: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("sealed blob mode = %04o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
// The raw master key MUST be deleted.
|
||||||
|
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("raw master key still exists after seal (expected deleted): %v", err)
|
||||||
|
}
|
||||||
|
// The seal output must print 5 shards.
|
||||||
|
if !strings.Contains(sealOut, "shard 1:") || !strings.Contains(sealOut, "shard 5:") {
|
||||||
|
t.Errorf("seal output missing shards: %s", sealOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal the cluster (CA mode — derives key from CA fingerprint).
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster unseal: %v", err)
|
||||||
|
}
|
||||||
|
unsealOut := buf.String()
|
||||||
|
if !strings.Contains(unsealOut, "unsealed") {
|
||||||
|
t.Errorf("unseal output unexpected: %s", unsealOut)
|
||||||
|
}
|
||||||
|
// The raw master key must be restored.
|
||||||
|
restoredMK, err := secrets.LoadMasterKey(mkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load restored master key: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(restoredMK, origMK) {
|
||||||
|
t.Error("restored master key != original (round-trip failed)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// secrets get MUST work after unseal (the round-trip assertion).
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "get", ns, "TOKEN"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets get after unseal: %v", err)
|
||||||
|
}
|
||||||
|
if buf.String() != "roundtrip-secret" {
|
||||||
|
t.Errorf("secrets get after unseal = %q, want %q", buf.String(), "roundtrip-secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClusterSealShamirRecovery (T7 recovery path) verifies the
|
||||||
|
// --recovery unseal path: seal, collect 3 shards, recover via stdin.
|
||||||
|
func TestClusterSealShamirRecovery(t *testing.T) {
|
||||||
|
setupSealTestEnv(t)
|
||||||
|
mkPath := paths.MasterKeyPath()
|
||||||
|
origMK, err := secrets.LoadMasterKey(mkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load orig master key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal and capture the shards from stdout.
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster seal: %v", err)
|
||||||
|
}
|
||||||
|
// Parse the 5 shards from the output.
|
||||||
|
shards := parseShardsFromOutput(t, buf.String())
|
||||||
|
if len(shards) != 5 {
|
||||||
|
t.Fatalf("expected 5 shards, got %d", len(shards))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal via recovery using the first 3 shards via stdin.
|
||||||
|
// Build the stdin input: 3 shard lines.
|
||||||
|
var stdin bytes.Buffer
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
stdin.WriteString(shards[i])
|
||||||
|
stdin.WriteString("\n")
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
buf.Reset()
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetIn(&stdin)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "unseal", "--recovery"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster unseal --recovery: %v", err)
|
||||||
|
}
|
||||||
|
restoredMK, err := secrets.LoadMasterKey(mkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load restored master key: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(restoredMK, origMK) {
|
||||||
|
t.Error("recovered master key != original (Shamir recovery failed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseShardsFromOutput extracts the 5 base64 shard strings from the
|
||||||
|
// `cluster seal` stdout (lines like " shard 1: <base64>").
|
||||||
|
func parseShardsFromOutput(t *testing.T, out string) []string {
|
||||||
|
t.Helper()
|
||||||
|
var shards []string
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "shard ") {
|
||||||
|
idx := strings.IndexByte(line, ':')
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(line[idx+1:])
|
||||||
|
if s != "" {
|
||||||
|
shards = append(shards, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shards
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClusterSealIdempotencyRefuse verifies that sealing twice (without
|
||||||
|
// unsealing) is refused — the operator must unseal first.
|
||||||
|
func TestClusterSealIdempotencyRefuse(t *testing.T) {
|
||||||
|
setupSealTestEnv(t)
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("first seal: %v", err)
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||||
|
if err := rootCmd.Execute(); err == nil {
|
||||||
|
t.Error("second seal should fail (sealed blob already exists)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSealPackageShamirRecoveryRoundTrip verifies the seal-package
|
||||||
|
// Shamir recovery path directly (UnsealWithShamir) as a unit-level
|
||||||
|
// backstop for the CLI integration test above.
|
||||||
|
func TestSealPackageShamirRecoveryRoundTrip(t *testing.T) {
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i + 7)
|
||||||
|
}
|
||||||
|
blob, shards, err := seal.Seal(masterKey, "test-sub", "https://idp.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Seal: %v", err)
|
||||||
|
}
|
||||||
|
recovered, err := seal.UnsealWithShamir(blob, shards[:3])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnsealWithShamir: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(recovered, masterKey) {
|
||||||
|
t.Error("Shamir-recovered key != original")
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
-1
@@ -1,11 +1,18 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/doctor"
|
"git.cloudinit.dev/coreci/orca/internal/doctor"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
var doctorCmd = &cobra.Command{
|
||||||
@@ -97,7 +104,193 @@ var doctorProxmoxCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doctorAuditCmd implements `orca doctor audit` (REQ-125, P05 T2).
|
||||||
|
// Opens the audit DB, calls AuditRepo.VerifyChain, reports the chain
|
||||||
|
// head hash + any tamper detection. Exits 0 if the chain is intact,
|
||||||
|
// exits 1 (via returned error) if tamper is detected.
|
||||||
|
var doctorAuditCmd = &cobra.Command{
|
||||||
|
Use: "audit",
|
||||||
|
Short: "Verify the audit log hash chain (tamper-evidence check)",
|
||||||
|
Long: `Verify the audit log hash chain (REQ-125).
|
||||||
|
|
||||||
|
Opens the orca SQLite DB, recomputes the hash chain from the first
|
||||||
|
audit entry, and reports the chain head hash. If any entry's
|
||||||
|
entry_hash or prev_hash link does not match the recomputed value, the
|
||||||
|
chain has been tampered with and the command exits non-zero.
|
||||||
|
|
||||||
|
This is the operator-facing tamper-evidence check: run it after any
|
||||||
|
suspected intrusion or as part of a regular audit cadence.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db, closer, err := openDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("doctor audit: open db: %w", err)
|
||||||
|
}
|
||||||
|
defer closer()
|
||||||
|
|
||||||
|
repo := store.NewAuditRepo(db)
|
||||||
|
head, err := repo.ChainHead(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("doctor audit: chain head: %w", err)
|
||||||
|
}
|
||||||
|
verifyErr := repo.VerifyChain(ctx)
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]any{
|
||||||
|
"chain_head": head,
|
||||||
|
"intact": verifyErr == nil,
|
||||||
|
}
|
||||||
|
if verifyErr != nil {
|
||||||
|
result["error"] = verifyErr.Error()
|
||||||
|
}
|
||||||
|
return printJSON(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
if head == "" {
|
||||||
|
fmt.Fprintln(out, "audit chain: empty (no entries)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "audit chain head: %s\n", head)
|
||||||
|
if verifyErr != nil {
|
||||||
|
fmt.Fprintf(out, "FAIL: audit chain tamper detected: %v\n", verifyErr)
|
||||||
|
return fmt.Errorf("doctor audit: %w", verifyErr)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out, "PASS: audit chain intact (no tamper detected)")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeReport describes one file checked by `orca doctor modes`.
|
||||||
|
type modeReport struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Mode os.FileMode `json:"mode"`
|
||||||
|
Want os.FileMode `json:"want"`
|
||||||
|
Status string `json:"status"` // "ok", "violation", "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// doctorModesCmd implements `orca doctor modes` (REQ-033/130, P05 T3).
|
||||||
|
// Runs security.EnforceFileModes across ORCA_HOME directories and
|
||||||
|
// reports each file's mode. Exits 0 if all correct, exits 1 if any
|
||||||
|
// violation.
|
||||||
|
var doctorModesCmd = &cobra.Command{
|
||||||
|
Use: "modes",
|
||||||
|
Short: "Verify security-sensitive file permissions (REQ-033/130)",
|
||||||
|
Long: `Verify file modes on security-sensitive files across ORCA_HOME
|
||||||
|
(REQ-033, REQ-130, F13).
|
||||||
|
|
||||||
|
Checks the cluster directory and the ORCA_HOME root for the known
|
||||||
|
security-sensitive file set with the required permissions:
|
||||||
|
- private keys / secrets: 0600
|
||||||
|
- certs / public keys: 0644
|
||||||
|
|
||||||
|
Exits 0 if all files have correct modes; exits 1 if any violation is
|
||||||
|
found. Missing files are not counted as violations (they may not
|
||||||
|
exist yet — e.g. before init or after migration).`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// EnforceFileModes scans a single directory for the known file
|
||||||
|
// set; invoke it on both the cluster dir (v0.9 layout) and the
|
||||||
|
// ORCA_HOME root (v0.8 flat layout) to cover both.
|
||||||
|
dirs := []string{
|
||||||
|
paths.ClusterDir(),
|
||||||
|
paths.Root(),
|
||||||
|
}
|
||||||
|
// Deduplicate (ClusterDir and Root may overlap in some layouts).
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var uniqueDirs []string
|
||||||
|
for _, d := range dirs {
|
||||||
|
if !seen[d] {
|
||||||
|
seen[d] = true
|
||||||
|
uniqueDirs = append(uniqueDirs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files that must be 0600 (secrets/keys) and 0644 (public).
|
||||||
|
secretFiles := []string{
|
||||||
|
security.CAKeyFile,
|
||||||
|
"orca_ssh_key",
|
||||||
|
"known_hosts",
|
||||||
|
"master.key",
|
||||||
|
"master.key.sealed",
|
||||||
|
"server.key",
|
||||||
|
}
|
||||||
|
publicFiles := []string{
|
||||||
|
security.CACertFile,
|
||||||
|
"orca_ssh_key.pub",
|
||||||
|
"server.crt",
|
||||||
|
}
|
||||||
|
|
||||||
|
var reports []modeReport
|
||||||
|
var violations int
|
||||||
|
for _, dir := range uniqueDirs {
|
||||||
|
for _, name := range secretFiles {
|
||||||
|
r := checkMode(filepath.Join(dir, name), 0o600)
|
||||||
|
reports = append(reports, r)
|
||||||
|
if r.Status == "violation" {
|
||||||
|
violations++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range publicFiles {
|
||||||
|
r := checkMode(filepath.Join(dir, name), 0o644)
|
||||||
|
reports = append(reports, r)
|
||||||
|
if r.Status == "violation" {
|
||||||
|
violations++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check via EnforceFileModes on each dir (it returns an
|
||||||
|
// error on the first violation). The per-file report above is
|
||||||
|
// the user-facing output; this ensures parity with the
|
||||||
|
// daemon's startup mode enforcement.
|
||||||
|
for _, dir := range uniqueDirs {
|
||||||
|
_ = security.EnforceFileModes(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]any{
|
||||||
|
"reports": reports,
|
||||||
|
"violations": violations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
for _, r := range reports {
|
||||||
|
switch r.Status {
|
||||||
|
case "ok":
|
||||||
|
fmt.Fprintf(out, " ok %04o %s\n", r.Mode, r.Path)
|
||||||
|
case "violation":
|
||||||
|
fmt.Fprintf(out, " FAIL %04o (want %04o) %s\n", r.Mode, r.Want, r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if violations > 0 {
|
||||||
|
fmt.Fprintf(out, "\n%d file mode violation(s) found (REQ-033/130)\n", violations)
|
||||||
|
return fmt.Errorf("doctor modes: %d violation(s)", violations)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out, "\n✓ all security-sensitive file modes correct")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMode reports the mode of a single file relative to the wanted
|
||||||
|
// mode. Missing files are reported as "missing" (not a violation).
|
||||||
|
func checkMode(path string, want os.FileMode) modeReport {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return modeReport{Path: path, Status: "missing"}
|
||||||
|
}
|
||||||
|
got := info.Mode().Perm()
|
||||||
|
if got != want {
|
||||||
|
return modeReport{Path: path, Mode: got, Want: want, Status: "violation"}
|
||||||
|
}
|
||||||
|
return modeReport{Path: path, Mode: got, Want: want, Status: "ok"}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd)
|
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd, doctorAuditCmd, doctorModesCmd)
|
||||||
rootCmd.AddCommand(doctorCmd)
|
rootCmd.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDoctorAuditIntact (T8) verifies `orca doctor audit` reports
|
||||||
|
// PASS on a clean audit chain.
|
||||||
|
func TestDoctorAuditIntact(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
// Insert a few audit entries.
|
||||||
|
db, err := store.Open(certpaths.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewAuditRepo(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := repo.Append(ctx, &store.AuditEntry{
|
||||||
|
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("append %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("doctor audit (intact): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "PASS") {
|
||||||
|
t.Errorf("doctor audit intact output missing PASS: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "chain head:") {
|
||||||
|
t.Errorf("doctor audit output missing chain head: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorAuditTamperDetected (T8) verifies `orca doctor audit`
|
||||||
|
// detects a tampered chain and exits non-zero. We bypass the
|
||||||
|
// append-only trigger by dropping the trigger via raw SQL (simulating
|
||||||
|
// an attacker with direct DB access), then modifying a row.
|
||||||
|
func TestDoctorAuditTamperDetected(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(certpaths.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewAuditRepo(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := repo.Append(ctx, &store.AuditEntry{
|
||||||
|
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("append %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verify the chain is intact before tampering.
|
||||||
|
if err := repo.VerifyChain(ctx); err != nil {
|
||||||
|
t.Fatalf("VerifyChain before tamper: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate an attacker with direct DB access: drop the append-only
|
||||||
|
// triggers, then modify an entry's action (this changes the
|
||||||
|
// recomputed hash but NOT the stored entry_hash, so VerifyChain
|
||||||
|
// detects the mismatch).
|
||||||
|
if _, err := db.ExecContext(ctx, `DROP TRIGGER IF EXISTS audit_log_no_update`); err != nil {
|
||||||
|
t.Fatalf("drop update trigger: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `DROP TRIGGER IF EXISTS audit_log_no_delete`); err != nil {
|
||||||
|
t.Fatalf("drop delete trigger: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE audit_log SET action='tampered' WHERE id=1`); err != nil {
|
||||||
|
t.Fatalf("tamper update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyChain (direct) must now fail.
|
||||||
|
if err := repo.VerifyChain(ctx); err == nil {
|
||||||
|
t.Fatal("VerifyChain should fail after tamper")
|
||||||
|
}
|
||||||
|
|
||||||
|
// `orca doctor audit` must detect the tamper and exit non-zero.
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("doctor audit should exit non-zero on tamper")
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "FAIL") {
|
||||||
|
t.Errorf("doctor audit tamper output missing FAIL: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "tamper") {
|
||||||
|
t.Errorf("doctor audit tamper output missing 'tamper': %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorAuditJSONIntact (T8 json) verifies the --json output for
|
||||||
|
// an intact chain.
|
||||||
|
func TestDoctorAuditJSONIntact(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(certpaths.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewAuditRepo(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repo.Append(ctx, &store.AuditEntry{
|
||||||
|
Actor: "test", Action: "test.action", Resource: "res", Result: "success",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "audit", "--json"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("doctor audit --json: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if result["intact"] != true {
|
||||||
|
t.Errorf("doctor audit --json intact = %v, want true", result["intact"])
|
||||||
|
}
|
||||||
|
if result["chain_head"] == "" {
|
||||||
|
t.Error("doctor audit --json missing chain_head")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorAuditEmpty verifies `orca doctor audit` on an empty audit
|
||||||
|
// log reports the empty state and exits 0.
|
||||||
|
func TestDoctorAuditEmpty(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "audit"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("doctor audit (empty): %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "empty") {
|
||||||
|
t.Errorf("doctor audit empty output unexpected: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorModesAllCorrect (T9) verifies `orca doctor modes` reports
|
||||||
|
// all-correct after a fresh init (the CA files are created at the
|
||||||
|
// correct modes by CAInit).
|
||||||
|
func TestDoctorModesAllCorrect(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "modes"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("doctor modes (all correct): %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "ok") {
|
||||||
|
t.Errorf("doctor modes output missing ok: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorModesRejects0644Key (T9) verifies `orca doctor modes`
|
||||||
|
// rejects a private key file with mode 0644 (should be 0600) and
|
||||||
|
// exits non-zero.
|
||||||
|
func TestDoctorModesRejects0644Key(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
// Create a fake master.key with the WRONG mode (0644 instead of
|
||||||
|
// 0600) in the cluster dir.
|
||||||
|
clusterDir := filepath.Dir(certpaths.CACertPath())
|
||||||
|
// Use the v0.8 layout: runInit creates the CA in paths.Root().
|
||||||
|
// Place a master.key at the cluster dir path that doctor modes
|
||||||
|
// checks.
|
||||||
|
keyPath := filepath.Join(clusterDir, "master.key")
|
||||||
|
if err := os.WriteFile(keyPath, []byte("0123456789abcdef0123456789abcdef"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write master.key: %v", err)
|
||||||
|
}
|
||||||
|
// Ensure it actually landed at 0644 (umask may interfere).
|
||||||
|
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||||
|
t.Fatalf("chmod master.key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "modes"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("doctor modes should exit non-zero on 0644 key")
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "FAIL") {
|
||||||
|
t.Errorf("doctor modes output missing FAIL on 0644 key: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "master.key") {
|
||||||
|
t.Errorf("doctor modes output missing master.key: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoctorModesJSON verifies the --json output of `doctor modes`.
|
||||||
|
func TestDoctorModesJSON(t *testing.T) {
|
||||||
|
_, cleanup := initTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
if err := runInit(discardWriter{}); err != nil {
|
||||||
|
t.Fatalf("init: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"doctor", "modes", "--json"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("doctor modes --json: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if result["violations"] == nil {
|
||||||
|
t.Error("doctor modes --json missing violations field")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,10 @@ func resetCommandFlags() {
|
|||||||
cutoverTimeout = 5 * time.Minute
|
cutoverTimeout = 5 * time.Minute
|
||||||
rotateLeadTo = ""
|
rotateLeadTo = ""
|
||||||
rotateLeadForce = false
|
rotateLeadForce = false
|
||||||
|
// P05: reset seal/doctor/secrets flag-bound vars so tests don't
|
||||||
|
// leak state (e.g. --recovery persisting across tests).
|
||||||
|
clusterUnsealRecovery = false
|
||||||
|
secretsRotateMasterDryRun = false
|
||||||
resetNSFlags()
|
resetNSFlags()
|
||||||
// Reset per-command output writers so tests that polluted them
|
// Reset per-command output writers so tests that polluted them
|
||||||
// (e.g. daemon tests calling cmd.SetOut(&buf)) don't leak into
|
// (e.g. daemon tests calling cmd.SetOut(&buf)) don't leak into
|
||||||
@@ -84,6 +88,8 @@ func resetCommandFlags() {
|
|||||||
jobCmd, jobMigrateCmd, jobRunCmd, jobListCmd, jobStopCmd, jobLogsCmd, jobLintCmd, jobVerifyCmd,
|
jobCmd, jobMigrateCmd, jobRunCmd, jobListCmd, jobStopCmd, jobLogsCmd, jobLintCmd, jobVerifyCmd,
|
||||||
logsCmd,
|
logsCmd,
|
||||||
clusterCmd, clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, noOrcaOnServerCmd,
|
clusterCmd, clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, noOrcaOnServerCmd,
|
||||||
|
clusterSealCmd, clusterUnsealCmd,
|
||||||
|
doctorAuditCmd, doctorModesCmd,
|
||||||
} {
|
} {
|
||||||
if c != nil {
|
if c != nil {
|
||||||
c.SetOut(nil)
|
c.SetOut(nil)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRotateMasterResealsOnSealedCluster (T5) verifies that
|
||||||
|
// `secrets rotate-master` on a sealed cluster re-seals the new master
|
||||||
|
// key and removes the raw key from disk (instead of leaving the raw
|
||||||
|
// key written).
|
||||||
|
func TestRotateMasterResealsOnSealedCluster(t *testing.T) {
|
||||||
|
ns := "rotens"
|
||||||
|
setupSealTestEnv(t)
|
||||||
|
mkPath := paths.MasterKeyPath()
|
||||||
|
sealedPath := sealedBlobPath()
|
||||||
|
|
||||||
|
// Set a secret under the original key.
|
||||||
|
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir ns: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "set", ns, "KEY=val1"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal the cluster (CA mode).
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "seal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster seal: %v", err)
|
||||||
|
}
|
||||||
|
// Now the cluster is sealed: raw key deleted, sealed blob exists.
|
||||||
|
if _, err := os.Stat(sealedPath); err != nil {
|
||||||
|
t.Fatalf("sealed blob missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("raw master key should be deleted after seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal so rotate-master can load the current key.
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster unseal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run rotate-master. Because the sealed blob exists, this should
|
||||||
|
// re-seal the new key and remove the raw key.
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "rotate-master"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets rotate-master: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "re-sealed") {
|
||||||
|
t.Errorf("rotate-master output should mention re-sealed: %s", out)
|
||||||
|
}
|
||||||
|
// The raw master key MUST be removed (re-sealed).
|
||||||
|
if _, err := os.Stat(mkPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("raw master key should be removed after rotate-master on sealed cluster")
|
||||||
|
}
|
||||||
|
// The sealed blob must still exist.
|
||||||
|
if _, err := os.Stat(sealedPath); err != nil {
|
||||||
|
t.Errorf("sealed blob missing after rotate-master: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal again and verify the secret is still readable under the
|
||||||
|
// new key.
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"cluster", "unseal"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("cluster unseal after rotate: %v", err)
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "get", ns, "KEY"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets get after rotate: %v", err)
|
||||||
|
}
|
||||||
|
if buf.String() != "val1" {
|
||||||
|
t.Errorf("secrets get after rotate = %q, want %q", buf.String(), "val1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRotateMasterNoResealOnUnsealedCluster (T5 backward-compat)
|
||||||
|
// verifies that `secrets rotate-master` on an UNsealed cluster (no
|
||||||
|
// sealed blob) leaves the raw key on disk (the legacy behavior).
|
||||||
|
func TestRotateMasterNoResealOnUnsealedCluster(t *testing.T) {
|
||||||
|
ns := "rotplain"
|
||||||
|
setupSealTestEnv(t)
|
||||||
|
mkPath := paths.MasterKeyPath()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(paths.NamespaceDir(ns), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir ns: %v", err)
|
||||||
|
}
|
||||||
|
resetRootFlags(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "set", ns, "KEY=val1"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No sealing — cluster is unsealed (raw key on disk, no sealed blob).
|
||||||
|
buf.Reset()
|
||||||
|
resetRootFlags(t)
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"secrets", "rotate-master"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("secrets rotate-master: %v", err)
|
||||||
|
}
|
||||||
|
// The raw master key MUST still exist (no re-seal on unsealed).
|
||||||
|
if _, err := os.Stat(mkPath); err != nil {
|
||||||
|
t.Errorf("raw master key missing after rotate-master on unsealed cluster: %v", err)
|
||||||
|
}
|
||||||
|
// Verify it's a valid key.
|
||||||
|
if _, err := secrets.LoadMasterKey(mkPath); err != nil {
|
||||||
|
t.Errorf("LoadMasterKey after rotate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-4
@@ -53,6 +53,10 @@ func loadMasterAndNSSecrets(namespace string) (nsKey []byte, lines []string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("load master key: %w", err)
|
return nil, nil, fmt.Errorf("load master key: %w", err)
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the raw master key once the namespace sub-key has
|
||||||
|
// been derived. The sub-key is what's used downstream; the master
|
||||||
|
// key is no longer needed in this process.
|
||||||
|
defer secrets.ZeroKey(mk)
|
||||||
nsKey, err = secrets.DeriveNamespaceKey(mk, namespace)
|
nsKey, err = secrets.DeriveNamespaceKey(mk, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("derive namespace key: %w", err)
|
return nil, nil, fmt.Errorf("derive namespace key: %w", err)
|
||||||
@@ -136,6 +140,8 @@ is appended. The .env.secrets file is rewritten atomically.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the namespace sub-key when done.
|
||||||
|
defer secrets.ZeroKey(nsKey)
|
||||||
newLine := key + "=" + value
|
newLine := key + "=" + value
|
||||||
idx := findKeyIndex(lines, key)
|
idx := findKeyIndex(lines, key)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
@@ -228,6 +234,8 @@ old ciphertext copies. The .env.secrets file is rewritten atomically.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the namespace sub-key when done.
|
||||||
|
defer secrets.ZeroKey(nsKey)
|
||||||
idx := findKeyIndex(lines, key)
|
idx := findKeyIndex(lines, key)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
||||||
@@ -259,6 +267,8 @@ var secretsDeleteCmd = &cobra.Command{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the namespace sub-key when done.
|
||||||
|
defer secrets.ZeroKey(nsKey)
|
||||||
idx := findKeyIndex(lines, key)
|
idx := findKeyIndex(lines, key)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
||||||
@@ -292,6 +302,8 @@ automatic rollback to the old key on any failure (C-30).`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load current master key: %w", err)
|
return fmt.Errorf("load current master key: %w", err)
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the old master key when done (defense-in-depth).
|
||||||
|
defer secrets.ZeroKey(oldKey)
|
||||||
|
|
||||||
// Find all namespaces with .env.secrets files.
|
// Find all namespaces with .env.secrets files.
|
||||||
root := paths.Root()
|
root := paths.Root()
|
||||||
@@ -322,6 +334,9 @@ automatic rollback to the old key on any failure (C-30).`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate new master key: %w", err)
|
return fmt.Errorf("generate new master key: %w", err)
|
||||||
}
|
}
|
||||||
|
// P05 T6: zero the new master key when done (it has been
|
||||||
|
// persisted to disk or re-sealed by this point).
|
||||||
|
defer secrets.ZeroKey(newKey)
|
||||||
|
|
||||||
// Re-encrypt each namespace. On any failure, rollback.
|
// Re-encrypt each namespace. On any failure, rollback.
|
||||||
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
||||||
@@ -360,11 +375,34 @@ automatic rollback to the old key on any failure (C-30).`,
|
|||||||
return fmt.Errorf("save new master key (rolled back): %w", err)
|
return fmt.Errorf("save new master key (rolled back): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces))
|
// P05 T5: if the cluster is in sealed mode, re-seal the new
|
||||||
if jsonOutput {
|
// master key into the sealed blob and remove the raw key from
|
||||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces})
|
// disk. A master-key rotation on a sealed cluster must NOT
|
||||||
|
// leave the raw key at rest. If the cluster is NOT sealed (no
|
||||||
|
// sealed blob exists), the raw key stays on disk (backward
|
||||||
|
// compat for unsealed clusters).
|
||||||
|
resealed := false
|
||||||
|
if clusterIsSealed() {
|
||||||
|
if err := resealMasterKey(mkPath, newKey); err != nil {
|
||||||
|
// Re-sealing failed — the raw key is still on disk
|
||||||
|
// (saved above). This is not a rollback scenario
|
||||||
|
// (the namespace secrets are already re-encrypted
|
||||||
|
// under the new key); surface the error so the
|
||||||
|
// operator can re-seal manually.
|
||||||
|
return fmt.Errorf("save new master key ok, but re-seal failed (raw key still on disk — re-seal manually): %w", err)
|
||||||
|
}
|
||||||
|
resealed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("secrets rotate-master", "namespaces", len(namespaces), "resealed", resealed)
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces, "resealed": resealed})
|
||||||
|
}
|
||||||
|
if resealed {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted; re-sealed to OIDC/CA\n", len(namespaces))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,3 +251,17 @@ func VerifySealedKey(blob *SealedBlob, masterKey []byte, oidcSub string) bool {
|
|||||||
|
|
||||||
// ensure binary import is used (for shard encoding).
|
// ensure binary import is used (for shard encoding).
|
||||||
var _ = binary.BigEndian
|
var _ = binary.BigEndian
|
||||||
|
|
||||||
|
// ZeroKey overwrites the byte slice with zeros. Defense-in-depth against
|
||||||
|
// heap-extraction of the unsealed master key (P05 T6, REQ-147). Callers
|
||||||
|
// of Unseal/UnsealWithCA/UnsealWithShamir MUST call this once the raw
|
||||||
|
// master key is no longer needed (e.g. after deriving namespace sub-keys
|
||||||
|
// or re-sealing). Best-effort under Go's GC but raises the bar against
|
||||||
|
// pprof heap scraping.
|
||||||
|
//
|
||||||
|
// ZeroKey is safe to call on nil or empty slices (no-op).
|
||||||
|
func ZeroKey(b []byte) {
|
||||||
|
for i := range b {
|
||||||
|
b[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package seal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestZeroKey verifies that ZeroKey overwrites every byte of the slice
|
||||||
|
// with zeros (P05 T6, REQ-147).
|
||||||
|
func TestZeroKey(t *testing.T) {
|
||||||
|
key := []byte{255, 255, 255, 255, 0, 1, 2, 3, 4, 5}
|
||||||
|
ZeroKey(key)
|
||||||
|
want := make([]byte, len(key))
|
||||||
|
if !bytes.Equal(key, want) {
|
||||||
|
t.Errorf("ZeroKey did not zero: got %v, want %v", key, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroKey_NilAndEmpty verifies ZeroKey is safe on nil/empty slices.
|
||||||
|
func TestZeroKey_NilAndEmpty(t *testing.T) {
|
||||||
|
ZeroKey(nil)
|
||||||
|
ZeroKey([]byte{})
|
||||||
|
}
|
||||||
@@ -294,3 +294,17 @@ func hmacSHA256(key, msg []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ = hmacSHA256
|
var _ = hmacSHA256
|
||||||
|
|
||||||
|
// ZeroKey overwrites the byte slice with zeros. This is defense-in-depth
|
||||||
|
// against heap-extraction attacks (e.g. via pprof): Go's GC makes this
|
||||||
|
// best-effort (the runtime may copy slices), but it raises the bar
|
||||||
|
// against memory scraping of master keys, namespace sub-keys, and SVID
|
||||||
|
// private keys. Callers MUST call this once the key is no longer needed
|
||||||
|
// (P05 T6, REQ-147).
|
||||||
|
//
|
||||||
|
// ZeroKey is safe to call on nil or empty slices (no-op).
|
||||||
|
func ZeroKey(b []byte) {
|
||||||
|
for i := range b {
|
||||||
|
b[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestZeroKey verifies that ZeroKey overwrites every byte of the slice
|
||||||
|
// with zeros (P05 T6, REQ-147).
|
||||||
|
func TestZeroKey(t *testing.T) {
|
||||||
|
key := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||||
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}
|
||||||
|
ZeroKey(key)
|
||||||
|
want := make([]byte, 32)
|
||||||
|
if !bytes.Equal(key, want) {
|
||||||
|
t.Errorf("ZeroKey did not zero the slice: got %v, want %v", key, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroKey_NilAndEmpty verifies ZeroKey is safe on nil/empty slices.
|
||||||
|
func TestZeroKey_NilAndEmpty(t *testing.T) {
|
||||||
|
ZeroKey(nil) // must not panic
|
||||||
|
ZeroKey([]byte{}) // must not panic
|
||||||
|
ZeroKey([]byte{}) // must not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroKey_PartialFill verifies zeroing works on a slice with a
|
||||||
|
// specific non-zero pattern across all bytes.
|
||||||
|
func TestZeroKey_PartialFill(t *testing.T) {
|
||||||
|
key := make([]byte, 64)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = 0xFF
|
||||||
|
}
|
||||||
|
ZeroKey(key)
|
||||||
|
for i, b := range key {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("byte %d = 0x%02x, want 0x00", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAuditRepo_ConcurrentAppend verifies that 10 concurrent Append
|
||||||
|
// calls produce a valid, intact hash chain (P05 T4). Before the
|
||||||
|
// transaction fix, concurrent appends could both read the same
|
||||||
|
// prev_hash and produce two entries with the same prev_hash link,
|
||||||
|
// corrupting the chain.
|
||||||
|
func TestAuditRepo_ConcurrentAppend(t *testing.T) {
|
||||||
|
repo, cleanup := openAuditTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const n = 10
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
err := repo.Append(ctx, &AuditEntry{
|
||||||
|
Actor: "concurrent",
|
||||||
|
Action: fmt.Sprintf("test.action.%d", i),
|
||||||
|
Resource: fmt.Sprintf("res-%d", i),
|
||||||
|
Result: "success",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errs <- fmt.Errorf("append[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
t.Fatalf("concurrent append failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all 10 entries landed.
|
||||||
|
entries, err := repo.List(ctx, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != n {
|
||||||
|
t.Errorf("expected %d entries, got %d", n, len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The critical assertion: the hash chain must be intact despite
|
||||||
|
// concurrent appends.
|
||||||
|
if err := repo.VerifyChain(ctx); err != nil {
|
||||||
|
t.Fatalf("VerifyChain after concurrent appends: %v (hash chain race not fixed)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChainHead must be non-empty and match the last entry's hash.
|
||||||
|
head, err := repo.ChainHead(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChainHead: %v", err)
|
||||||
|
}
|
||||||
|
if head == "" {
|
||||||
|
t.Error("ChainHead is empty after appends")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,21 +53,19 @@ func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errM
|
|||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
// Append adds a new audit entry to the log. The read of the previous
|
||||||
// entry, or "" if the table is empty.
|
// entry's hash and the insert of the new row are wrapped in a single
|
||||||
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
// BEGIN IMMEDIATE transaction executed on a single dedicated
|
||||||
var prevHash string
|
// connection so concurrent appends serialize: BEGIN IMMEDIATE acquires
|
||||||
err := r.db.QueryRowContext(ctx,
|
// a RESERVED write lock immediately, blocking other writers until
|
||||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
// COMMIT. Without this, two concurrent Append calls could both read
|
||||||
if err == sql.ErrNoRows {
|
// the same prev_hash and produce two entries with the same prev_hash
|
||||||
return "", nil
|
// link — corrupting the chain (REQ-125, P05 T4).
|
||||||
}
|
//
|
||||||
if err != nil {
|
// We pin a single connection from the pool (db.Conn) and run
|
||||||
return "", fmt.Errorf("get last entry hash: %w", err)
|
// BEGIN IMMEDIATE / SELECT / INSERT / COMMIT on it so the transaction
|
||||||
}
|
// state stays on one connection (database/sql does NOT propagate
|
||||||
return prevHash, nil
|
// transaction state across pooled connections).
|
||||||
}
|
|
||||||
|
|
||||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||||
if e.Timestamp.IsZero() {
|
if e.Timestamp.IsZero() {
|
||||||
e.Timestamp = time.Now().UTC()
|
e.Timestamp = time.Now().UTC()
|
||||||
@@ -78,19 +76,50 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
|||||||
metaJSON, _ := json.Marshal(e.Metadata)
|
metaJSON, _ := json.Marshal(e.Metadata)
|
||||||
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||||
|
|
||||||
// Compute the hash chain (REQ-125, F2).
|
// Pin a single connection so the transaction state is consistent.
|
||||||
prevHash, err := r.getLastEntryHash(ctx)
|
conn, err := r.db.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("audit hash chain: %w", err)
|
return fmt.Errorf("audit append: acquire conn: %w", err)
|
||||||
}
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// BEGIN IMMEDIATE acquires a RESERVED lock right away, serializing
|
||||||
|
// concurrent writers. Other BEGIN IMMEDIATE callers block (with
|
||||||
|
// the configured busy_timeout) until we COMMIT.
|
||||||
|
if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
||||||
|
return fmt.Errorf("audit append: begin immediate: %w", err)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
_, _ = conn.ExecContext(ctx, "ROLLBACK")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read the chain head (last entry's hash) within the transaction.
|
||||||
|
var prevHash string
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
prevHash = ""
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("audit append: get last entry hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the new entry hash (REQ-125, F2).
|
||||||
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||||
|
|
||||||
_, err = r.db.ExecContext(ctx,
|
_, err = conn.ExecContext(ctx,
|
||||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert audit: %w", err)
|
return fmt.Errorf("insert audit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil {
|
||||||
|
return fmt.Errorf("audit append: commit: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +164,22 @@ func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChainHead returns the entry_hash of the most recent audit_log entry,
|
||||||
|
// or "" if the table is empty. Used by `orca doctor audit` to report
|
||||||
|
// the chain head hash (REQ-125, P05 T2).
|
||||||
|
func (r *AuditRepo) ChainHead(ctx context.Context) (string, error) {
|
||||||
|
var head string
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&head)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("chain head: %w", err)
|
||||||
|
}
|
||||||
|
return head, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("create db dir: %w", err)
|
return nil, fmt.Errorf("create db dir: %w", err)
|
||||||
}
|
}
|
||||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
|
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user