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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:59:16 +01:00
parent cb0425f18d
commit 9d946b1b03
9 changed files with 158 additions and 27 deletions
+4 -4
View File
@@ -21,7 +21,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
return 2 return 2
} }
sub, rest := args[0], args[1:] sub, rest := args[0], args[1:]
st, err := openStore() st, err := openStore(store.RoleAdmin)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -204,7 +204,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
return 2 return 2
} }
sub, key := args[0], args[1] sub, key := args[0], args[1]
st, err := openStore() st, err := openStore(store.RoleAdmin)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -262,7 +262,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
if err := fs.Parse(args[1:]); err != nil { if err := fs.Parse(args[1:]); err != nil {
return 2 return 2
} }
st, err := openStore() st, err := openStore(store.RoleAdmin)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -301,7 +301,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
fmt.Fprintln(errOut, "--account is required") fmt.Fprintln(errOut, "--account is required")
return 2 return 2
} }
st, err := openStore() st, err := openStore(store.RoleAdmin)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
+25 -6
View File
@@ -7,15 +7,28 @@ import (
"testing" "testing"
"time" "time"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store" "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 { func adminEnv(t *testing.T) string {
t.Helper() t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db") 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) 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 return db
} }
@@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 { "--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e) t.Fatalf("edit failed: %s", e)
} }
st, err := store.Open(db, mustKey()) st, err := store.Open(db)
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
defer st.Close() 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") got, err := st.GetAccount("ed")
if err != nil { if err != nil {
t.Fatalf("GetAccount: %v", err) t.Fatalf("GetAccount: %v", err)
@@ -93,11 +111,14 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
} }
func TestAuditListCoreRenders(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 { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
defer st.Close() 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) 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: "list", Target: "INBOX", Result: "allowed"})
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"}) _ = 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) }
+4 -1
View File
@@ -58,10 +58,13 @@ func testKey() []byte {
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) { func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
t.Helper() 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 { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
_, err = st.AddAccount(store.Account{ _, err = st.AddAccount(store.Account{
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
+4 -1
View File
@@ -11,10 +11,13 @@ import (
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) { func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper() 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 { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
for _, a := range accounts { for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil { if _, err := st.AddAccount(a); err != nil {
+23 -3
View File
@@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea" 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/store"
"git.dcglab.co.uk/steve/emcli/internal/tui" "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 return 0
} }
// runInit creates/opens the DB and adds the first account via the TUI form, // runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
// seeding a default audit retention if unset. // account via the TUI form, seeding a default audit retention if unset.
func runInit(args []string, out, errOut io.Writer) int { func runInit(args []string, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) { if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init") printCmdUsage(out, "init")
return 0 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 { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
} }
defer st.Close() 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 { if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90") _ = st.SetSetting("audit_retention_days", "90")
+46
View File
@@ -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()
}
+40 -9
View File
@@ -25,17 +25,48 @@ func realMailer(acc store.Account) (Mailer, error) {
return c, nil return c, nil
} }
// openStore loads the key and opens the DB, returning a human-readable error string. // commandRole maps a command to the privilege it requires. Admin commands
func openStore() (*store.Store, error) { // mutate configuration or expose oversight data; everything else is agent.
key, err := crypto.KeyFromEnv() func commandRole(cmd string) store.Role {
if err != nil { switch cmd {
return nil, err 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() path, err := store.DefaultDBPath()
if err != nil { if err != nil {
return nil, err 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 { func realSender(acc store.Account, m mail.OutgoingMessage) error {
@@ -79,7 +110,7 @@ func runDoctor(args []string, out, errOut io.Writer) int {
} }
return 2 return 2
} }
st, err := openStore() st, err := openStore(store.RoleAgent)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -159,7 +190,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out) _ = Failure(CodeUsage, "--account is required").Write(out)
return 2 return 2
} }
st, err := openStore() st, err := openStore(store.RoleAgent)
if err != nil { if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out) _ = Failure(CodeConfig, err.Error()).Write(out)
return 1 return 1
@@ -249,7 +280,7 @@ func runSend(args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out) _ = Failure(CodeUsage, "--account is required").Write(out)
return 2 return 2
} }
st, err := openStore() st, err := openStore(store.RoleAgent)
if err != nil { if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out) _ = Failure(CodeConfig, err.Error()).Write(out)
return 1 return 1
+8 -2
View File
@@ -23,12 +23,13 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
// proving the key check happens before any DB work. // proving the key check happens before any DB work.
var out, errOut bytes.Buffer var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut) code := Run([]string{"account", "list"}, &out, &errOut)
if code == 0 { if code == 0 {
t.Fatal("missing EMCLI_KEY must fail") t.Fatal("missing EMCLI_KEY must fail")
} }
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") { if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String()) 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. // 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
} }
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
+4 -1
View File
@@ -13,10 +13,13 @@ import (
// mailer (for reply-to). The named account is created per the supplied template. // 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) { func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
t.Helper() 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 { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
if _, err := st.AddAccount(acc); err != nil { if _, err := st.AddAccount(acc); err != nil {
t.Fatalf("AddAccount: %v", err) t.Fatalf("AddAccount: %v", err)