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