From 9d946b1b03e719e8b1aed8965e495fddc9d65e8d Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 22:59:16 +0100 Subject: [PATCH] feat(cli): two-key role routing + init bootstrap openStore(role) selects the DEK wrap slot; admin commands require EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both slots from both keys. Test helpers seed the wrap slots. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/admin.go | 8 +++--- internal/cli/admin_test.go | 31 ++++++++++++++++++----- internal/cli/agent_test.go | 5 +++- internal/cli/doctor_test.go | 5 +++- internal/cli/interactive.go | 26 +++++++++++++++++--- internal/cli/role_test.go | 46 ++++++++++++++++++++++++++++++++++ internal/cli/run.go | 49 ++++++++++++++++++++++++++++++------- internal/cli/run_test.go | 10 ++++++-- internal/cli/send_test.go | 5 +++- 9 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 internal/cli/role_test.go diff --git a/internal/cli/admin.go b/internal/cli/admin.go index e387c18..eb96a8e 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -21,7 +21,7 @@ func runAccount(args []string, out, errOut io.Writer) int { return 2 } sub, rest := args[0], args[1:] - st, err := openStore() + st, err := openStore(store.RoleAdmin) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 @@ -204,7 +204,7 @@ func runConfig(args []string, out, errOut io.Writer) int { return 2 } sub, key := args[0], args[1] - st, err := openStore() + st, err := openStore(store.RoleAdmin) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 @@ -262,7 +262,7 @@ func runAudit(args []string, out, errOut io.Writer) int { if err := fs.Parse(args[1:]); err != nil { return 2 } - st, err := openStore() + st, err := openStore(store.RoleAdmin) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 @@ -301,7 +301,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int { fmt.Fprintln(errOut, "--account is required") return 2 } - st, err := openStore() + st, err := openStore(store.RoleAdmin) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 15eee21..1a2f7f9 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -7,15 +7,28 @@ import ( "testing" "time" + "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" ) -// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path. +// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB. func adminEnv(t *testing.T) string { t.Helper() db := filepath.Join(t.TempDir(), "emcli.db") - t.Setenv("EMCLI_KEY", b64Key()) + t.Setenv("EMCLI_ADMIN_KEY", b64Key()) + t.Setenv("EMCLI_KEY", b64AgentKey()) t.Setenv("EMCLI_DB", db) + + st, err := store.Open(db) + if err != nil { + t.Fatalf("Open: %v", err) + } + adminKey, _ := crypto.AdminKeyFromEnv() + agentKey, _ := crypto.AgentKeyFromEnv() + if err := st.InitKeys(adminKey, agentKey); err != nil { + t.Fatalf("InitKeys: %v", err) + } + st.Close() return db } @@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) { "--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 { t.Fatalf("edit failed: %s", e) } - st, err := store.Open(db, mustKey()) + st, err := store.Open(db) if err != nil { t.Fatalf("open: %v", err) } defer st.Close() + adminKey, _ := crypto.AdminKeyFromEnv() + agentKey, _ := crypto.AgentKeyFromEnv() + if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil { + t.Fatalf("Unlock: %v", err) + } got, err := st.GetAccount("ed") if err != nil { t.Fatalf("GetAccount: %v", err) @@ -93,11 +111,14 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) { } func TestAuditListCoreRenders(t *testing.T) { - st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) + st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("open: %v", err) } defer st.Close() + if err := st.InitKeys(testKey(), testKey()); err != nil { + t.Fatalf("InitKeys: %v", err) + } now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"}) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"}) @@ -111,5 +132,3 @@ func TestAuditListCoreRenders(t *testing.T) { } } -// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen. -func mustKey() []byte { return make([]byte, 32) } diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go index 1ae7766..f1b4582 100644 --- a/internal/cli/agent_test.go +++ b/internal/cli/agent_test.go @@ -58,10 +58,13 @@ func testKey() []byte { func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) { t.Helper() - st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) + st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("store: %v", err) } + if err := st.InitKeys(testKey(), testKey()); err != nil { + t.Fatalf("InitKeys: %v", err) + } t.Cleanup(func() { st.Close() }) _, err = st.AddAccount(store.Account{ Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index c421e50..032eade 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -11,10 +11,13 @@ import ( func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) { t.Helper() - st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) + st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("store: %v", err) } + if err := st.InitKeys(testKey(), testKey()); err != nil { + t.Fatalf("InitKeys: %v", err) + } t.Cleanup(func() { st.Close() }) for _, a := range accounts { if _, err := st.AddAccount(a); err != nil { diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index acd6012..b087544 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -6,6 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/tui" ) @@ -70,19 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int { return 0 } -// runInit creates/opens the DB and adds the first account via the TUI form, -// seeding a default audit retention if unset. +// runInit creates/opens the DB, writes both DEK wrap slots, and adds the first +// account via the TUI form, seeding a default audit retention if unset. func runInit(args []string, out, errOut io.Writer) int { if len(args) > 0 && helpRequested(args[0]) { printCmdUsage(out, "init") return 0 } - st, err := openStore() + adminKey, err := crypto.AdminKeyFromEnv() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + agentKey, err := crypto.AgentKeyFromEnv() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + path, err := store.DefaultDBPath() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + st, err := store.Open(path) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() + if err := st.InitKeys(adminKey, agentKey); err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } if _, err := st.GetSetting("audit_retention_days"); err != nil { _ = st.SetSetting("audit_retention_days", "90") diff --git a/internal/cli/role_test.go b/internal/cli/role_test.go new file mode 100644 index 0000000..647bf93 --- /dev/null +++ b/internal/cli/role_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "path/filepath" + "testing" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +func TestCommandRole(t *testing.T) { + admin := []string{"account", "whitelist", "config", "audit"} + agent := []string{"list", "get", "search", "ack", "send", "doctor"} + for _, c := range admin { + if commandRole(c) != store.RoleAdmin { + t.Errorf("%s should be admin", c) + } + } + for _, c := range agent { + if commandRole(c) != store.RoleAgent { + t.Errorf("%s should be agent", c) + } + } +} + +func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) { + // A human holding only the admin key can still run agent commands + // (admin is a superset → agent-role unlock falls back to the admin slot). + db := filepath.Join(t.TempDir(), "emcli.db") + t.Setenv("EMCLI_ADMIN_KEY", b64Key()) + t.Setenv("EMCLI_KEY", b64AgentKey()) + t.Setenv("EMCLI_DB", db) + st, _ := store.Open(db) + ak, _ := crypto.AdminKeyFromEnv() + gk, _ := crypto.AgentKeyFromEnv() + st.InitKeys(ak, gk) + st.Close() + + // Only the admin key now; agent command must still open the store. + t.Setenv("EMCLI_KEY", "") + s2, err := openStore(store.RoleAgent) + if err != nil { + t.Fatalf("agent role with only admin key should open: %v", err) + } + s2.Close() +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 0d24352..8fd7af0 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -25,17 +25,48 @@ func realMailer(acc store.Account) (Mailer, error) { return c, nil } -// openStore loads the key and opens the DB, returning a human-readable error string. -func openStore() (*store.Store, error) { - key, err := crypto.KeyFromEnv() - if err != nil { - return nil, err +// commandRole maps a command to the privilege it requires. Admin commands +// mutate configuration or expose oversight data; everything else is agent. +func commandRole(cmd string) store.Role { + switch cmd { + case "account", "whitelist", "config", "audit": + return store.RoleAdmin + default: // list, get, search, ack, send, doctor + return store.RoleAgent } +} + +// openStore resolves the keys for the role, opens the DB, and unlocks the DEK. +// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent +// commands use EMCLI_KEY (falling back to the admin key if that is all there is). +func openStore(role store.Role) (*store.Store, error) { + adminKey, adminErr := crypto.AdminKeyFromEnv() + agentKey, agentErr := crypto.AgentKeyFromEnv() + + switch role { + case store.RoleAdmin: + if adminErr != nil { + return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)") + } + case store.RoleAgent: + if agentErr != nil && adminErr != nil { + return nil, agentErr // "EMCLI_KEY is not set" + } + } + path, err := store.DefaultDBPath() if err != nil { return nil, err } - return store.Open(path, key) + st, err := store.Open(path) + if err != nil { + return nil, err + } + if err := st.Unlock(role, adminKey, agentKey); err != nil { + st.Close() + return nil, err + } + return st, nil } func realSender(acc store.Account, m mail.OutgoingMessage) error { @@ -79,7 +110,7 @@ func runDoctor(args []string, out, errOut io.Writer) int { } return 2 } - st, err := openStore() + st, err := openStore(store.RoleAgent) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 @@ -159,7 +190,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int { _ = Failure(CodeUsage, "--account is required").Write(out) return 2 } - st, err := openStore() + st, err := openStore(store.RoleAgent) if err != nil { _ = Failure(CodeConfig, err.Error()).Write(out) return 1 @@ -249,7 +280,7 @@ func runSend(args []string, out, errOut io.Writer) int { _ = Failure(CodeUsage, "--account is required").Write(out) return 2 } - st, err := openStore() + st, err := openStore(store.RoleAgent) if err != nil { _ = Failure(CodeConfig, err.Error()).Write(out) return 1 diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 7bdbba1..f9815d3 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -23,12 +23,13 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { // proving the key check happens before any DB work. var out, errOut bytes.Buffer t.Setenv("EMCLI_KEY", "") + t.Setenv("EMCLI_ADMIN_KEY", "") code := Run([]string{"account", "list"}, &out, &errOut) if code == 0 { t.Fatal("missing EMCLI_KEY must fail") } - if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") { - t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String()) + if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") { + t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String()) } } @@ -54,3 +55,8 @@ func b64Key() string { // 32 zero bytes, base64. return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } + +func b64AgentKey() string { + // 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface. + return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" +} diff --git a/internal/cli/send_test.go b/internal/cli/send_test.go index 1f4f7fd..2356aca 100644 --- a/internal/cli/send_test.go +++ b/internal/cli/send_test.go @@ -13,10 +13,13 @@ import ( // mailer (for reply-to). The named account is created per the supplied template. func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) { t.Helper() - st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) + st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("store: %v", err) } + if err := st.InitKeys(testKey(), testKey()); err != nil { + t.Fatalf("InitKeys: %v", err) + } t.Cleanup(func() { st.Close() }) if _, err := st.AddAccount(acc); err != nil { t.Fatalf("AddAccount: %v", err)