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
|
||||
|
||||
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)",
|
||||
Long: `Cluster-wide operations: daemon cutover, lead rotation, and
|
||||
mixed-version compatibility checks.`,
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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{
|
||||
@@ -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() {
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd)
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd, noOrcaOnServerCmd, doctorNftCmd, doctorAuditCmd, doctorModesCmd)
|
||||
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
|
||||
rotateLeadTo = ""
|
||||
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()
|
||||
// Reset per-command output writers so tests that polluted them
|
||||
// (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,
|
||||
logsCmd,
|
||||
clusterCmd, clusterCutoverCmd, clusterRotateLeadCmd, compatCheckCmd, noOrcaOnServerCmd,
|
||||
clusterSealCmd, clusterUnsealCmd,
|
||||
doctorAuditCmd, doctorModesCmd,
|
||||
} {
|
||||
if c != 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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
newLine := key + "=" + value
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx >= 0 {
|
||||
@@ -228,6 +234,8 @@ old ciphertext copies. The .env.secrets file is rewritten atomically.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("secret %q not found in namespace %q", key, ns)
|
||||
@@ -259,6 +267,8 @@ var secretsDeleteCmd = &cobra.Command{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// P05 T6: zero the namespace sub-key when done.
|
||||
defer secrets.ZeroKey(nsKey)
|
||||
idx := findKeyIndex(lines, key)
|
||||
if idx < 0 {
|
||||
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 {
|
||||
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.
|
||||
root := paths.Root()
|
||||
@@ -322,6 +334,9 @@ automatic rollback to the old key on any failure (C-30).`,
|
||||
if err != nil {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces))
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces})
|
||||
// P05 T5: if the cluster is in sealed mode, re-seal the new
|
||||
// master key into the sealed blob and remove the raw key from
|
||||
// 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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -251,3 +251,17 @@ func VerifySealedKey(blob *SealedBlob, masterKey []byte, oidcSub string) bool {
|
||||
|
||||
// ensure binary import is used (for shard encoding).
|
||||
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
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
||||
// entry, or "" if the table is empty.
|
||||
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
||||
var prevHash string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get last entry hash: %w", err)
|
||||
}
|
||||
return prevHash, nil
|
||||
}
|
||||
|
||||
// Append adds a new audit entry to the log. The read of the previous
|
||||
// entry's hash and the insert of the new row are wrapped in a single
|
||||
// BEGIN IMMEDIATE transaction executed on a single dedicated
|
||||
// connection so concurrent appends serialize: BEGIN IMMEDIATE acquires
|
||||
// a RESERVED write lock immediately, blocking other writers until
|
||||
// COMMIT. Without this, two concurrent Append calls could both read
|
||||
// the same prev_hash and produce two entries with the same prev_hash
|
||||
// link — corrupting the chain (REQ-125, P05 T4).
|
||||
//
|
||||
// We pin a single connection from the pool (db.Conn) and run
|
||||
// BEGIN IMMEDIATE / SELECT / INSERT / COMMIT on it so the transaction
|
||||
// state stays on one connection (database/sql does NOT propagate
|
||||
// transaction state across pooled connections).
|
||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
@@ -78,19 +76,50 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Compute the hash chain (REQ-125, F2).
|
||||
prevHash, err := r.getLastEntryHash(ctx)
|
||||
// Pin a single connection so the transaction state is consistent.
|
||||
conn, err := r.db.Conn(ctx)
|
||||
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))
|
||||
|
||||
_, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -135,6 +164,22 @@ func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
||||
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) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
|
||||
@@ -18,7 +18,7 @@ func Open(path string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user