9 Commits

Author SHA1 Message Date
steve 76ada04442 refactor(cli): wire commandRole into dispatch; doc + comment cleanup
Resolve final-review findings: commandRole is now the single source of
truth (Run resolves role once and threads it to handlers, replacing
hardcoded openStore roles). Tighten crypto/SKILL/SPEC/USER-MANUAL wording
and document init's agent-key-on-first-init-only semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:18:27 +01:00
steve add9515b5c docs: document two-key privilege model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:11:18 +01:00
steve 456e15a2f8 test(cli): check setup errors + report all admin refusals
Address review: fail fast on store.Open/key-loader errors in test setup;
use t.Errorf+continue so every admin command is checked, not just the first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:06:47 +01:00
steve 5c7dd252db test(cli): prove agent key cannot run admin commands
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:03:17 +01:00
steve 9d946b1b03 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>
2026-06-22 22:59:16 +01:00
steve cb0425f18d feat(store): envelope DEK with admin/agent wrap slots
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
Unlock loads it from the role's slot (admin slot has no agent fallback).
s.key becomes the DEK, so account/mail crypto is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:52:21 +01:00
steve c52f30898b feat(crypto): named-var key loaders (admin/agent) + NewDEK
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
as DEK wrap/unwrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:47:05 +01:00
steve 77ba5a146f docs(plan): two-key privilege separation implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:43:16 +01:00
steve 2bc2c1b50e docs(spec): two-key privilege separation design
Enforce the agent/admin trust boundary with two env keys (EMCLI_ADMIN_KEY,
EMCLI_KEY) via envelope encryption: one DEK wrapped per role. Admin commands
unwrap the admin slot only (no agent fallback), so a forced agent holding
EMCLI_KEY cannot authorize config changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:34:26 +01:00
23 changed files with 1730 additions and 142 deletions
+6 -3
View File
@@ -9,11 +9,14 @@ it isn't permitted to see or send mail to people it isn't permitted to contact.
## Getting started
```bash
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # one-time: generate & save a key
emcli init # create the DB, add your first account
emcli doctor # confirm it connects and authenticates
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
```
`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it.
## Documentation
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
+82 -31
View File
@@ -13,7 +13,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## Contents
1. [Key concepts](#1-key-concepts)
2. [Setup: the encryption key and database](#2-setup-the-encryption-key-and-database)
2. [Setup: encryption keys and database](#2-setup-encryption-keys-and-database)
- [Privilege model](#2a-privilege-model)
3. [Quick start](#3-quick-start)
4. [Adding accounts](#4-adding-accounts)
- [Gmail (app password)](#gmail-app-password)
@@ -32,10 +33,13 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts
**Two kinds of commands.**
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`, `doctor`) are for *you*,
the human. They print human-readable text or open an interactive form.
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`) are for the *agent*. They print one
line of JSON and nothing else, so a program can consume them reliably.
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
and are for *you*, the human. They print human-readable text or open an interactive form.
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
as a superset, so either key suffices for agent commands.)
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
sees its password.
@@ -61,50 +65,91 @@ acking is a deliberate, separate step.
---
## 2. Setup: the encryption key and database
## 2. Setup: encryption keys and database
`emcli` reads two environment variables:
`emcli` reads three environment variables:
| Variable | Purpose | Default |
|---|---|---|
| `EMCLI_KEY` | **Required.** Base64-encoded 32-byte key (AES-256) used to encrypt passwords at rest. | none — commands fail without it |
| `EMCLI_ADMIN_KEY` | **Required for admin.** Base64-encoded 32-byte key (AES-256). Authorises ALL commands. | none — admin commands fail without it |
| `EMCLI_KEY` | **Required for agents.** Base64-encoded 32-byte key (AES-256). Authorises agent commands only. | none — agent commands fail without it |
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
**Generate a key once** and keep it safe (store it the way the program/orchestrator that launches
`emcli` expects — e.g. a secrets manager or your shell profile):
**Generate both keys once** and keep them safe:
```bash
head -c 32 /dev/urandom | base64
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
```
Set it in your environment before running any command:
```bash
export EMCLI_KEY='paste-the-base64-key-here'
```
> **Important:** the key encrypts your account passwords. If you lose it, the stored passwords
> can't be decrypted and you'll have to re-add accounts. If you change it, the same applies.
> `emcli` never falls back to plaintext — a missing or wrong key makes every command fail safely.
> **Important:** the keys protect your account passwords via envelope encryption (see "Privilege
> model" below). If you lose `EMCLI_ADMIN_KEY`, account secrets can't be decrypted and you'll have
> to re-add accounts. `emcli` never falls back to plaintext — a missing or wrong key makes every
> command fail safely.
Account passwords are stored **encrypted**; they never appear in command output, error messages,
or the audit log.
---
## 2a. Privilege model
`emcli` enforces a two-role privilege split so a process holding only the agent key cannot
reconfigure accounts, whitelists, or audit settings.
### The two keys
| Key | Holder | Authorises |
|---|---|---|
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) |
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
(admin privilege)` if it attempts an admin command.
### Envelope encryption (DEK)
`emcli init` generates a random data-encryption key (DEK) that seals all account secrets. The DEK
is stored in the `settings` table in two sealed copies:
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY`.
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY`.
The DEK is never written in cleartext. Admin commands unwrap the DEK from the admin slot only; they
have no fallback to the agent slot. This means a process holding only `EMCLI_KEY` cannot unlock the
DEK for an admin command, even if it somehow knows the agent key.
### Command → role table
| Command | Role required |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
| `init` | Both keys required (writes both wrap slots) |
### Agent launcher guidance
Configure your agent's orchestrator with **only `EMCLI_KEY`**. Never give the orchestrator
`EMCLI_ADMIN_KEY`. If the agent tries to run an admin command — even by mistake — `emcli` will
refuse it at the key level, not just by convention.
---
## 3. Quick start
```bash
# 1. Set your key (see section 2)
export EMCLI_KEY='…'
# 1. Generate and export both keys (see section 2)
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # keep this yourself
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # give only this to the agent
# 2. Create the database and add your first account (interactive form)
emcli init
# 3. Check it connects and authenticates
# 3. Check it connects and authenticates (agent key is enough for doctor)
emcli doctor
# 4. The agent can now read
# 4. The agent can now read (needs only EMCLI_KEY)
emcli list --account gmail --folder INBOX --limit 10
```
@@ -491,10 +536,16 @@ emcli config get audit_retention_days
## 11. Troubleshooting
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
base64-encoded 32-byte key (section 2). Every command that touches the database needs it.
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`,
`doctor`) need this key.
**A command fails to decrypt / "wrong EMCLI_KEY?".** The key doesn't match the one used when the
account was added. Restore the original key, or re-add the account with the current key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY`
alone is not enough for them.
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
the DEK if one already exists) with both correct keys, then re-add any accounts if needed.
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
@@ -528,7 +579,7 @@ running non-interactively.
emcli # or: emcli help / emcli --help — list all commands
emcli <command> --help # usage and flags for one command
# Admin
# Admin (requires EMCLI_ADMIN_KEY)
emcli init # create DB + add first account (form)
emcli account add [flags | none for form] # add an account
emcli account list # list accounts (no secrets)
@@ -537,10 +588,10 @@ emcli account remove --name N --yes # delete an account
emcli whitelist in|out add|remove|list --account N [--address A]
emcli config set|get <key> [value] # e.g. audit_retention_days
emcli audit list [--account N] [--limit K]
emcli doctor [--account N] # connectivity/auth check
emcli version
# Agent (one line of JSON each)
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
emcli doctor [--account N] # connectivity/auth check
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
emcli get --account N [--folder F] --uid U
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
@@ -548,4 +599,4 @@ emcli ack --account N [--folder F] --uid-list U1,U2,U3
emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]]
```
Environment: `EMCLI_KEY` (required, base64 32-byte key), `EMCLI_DB` (optional DB path).
Environment: `EMCLI_ADMIN_KEY` (required for admin commands, base64 32-byte key), `EMCLI_KEY` (required for agent commands, base64 32-byte key), `EMCLI_DB` (optional DB path).
@@ -0,0 +1,855 @@
# Two-Key Privilege Separation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands.
**Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched.
**Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI.
## Global Constraints
- Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`.
- Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else.
- Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`.
- Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log.
- Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON).
- DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`).
- No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec).
**Command → role mapping (single source of truth, implemented in Task 3):**
| Command | Role |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
---
## Task 1: crypto — named-var key loaders + DEK generation
**Files:**
- Modify: `internal/crypto/crypto.go`
- Test: `internal/crypto/crypto_test.go`
**Interfaces:**
- Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap).
- Produces:
- `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`.
- `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`.
- `NewDEK() ([]byte, error)` — fresh random 32-byte key.
- Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3).
- [ ] **Step 1: Replace the env/error section of `crypto.go`**
Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~1430) with:
```go
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
// named environment variable. Errors name the variable so callers get a
// role-appropriate message.
func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
if raw == "" {
return nil, fmt.Errorf("%s is not set", varName)
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
}
return key, nil
}
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
```
Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else.
- [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`**
Replace `TestKeyFromEnv` (lines ~5369) with:
```go
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
good := base64.StdEncoding.EncodeToString(testKey())
t.Setenv("EMCLI_KEY", good)
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := AdminKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := AgentKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
}
}
```
Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported).
- [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition**
Run: `go test ./internal/crypto/...`
Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.)
- [ ] **Step 4: Commit**
```bash
git add internal/crypto/crypto.go internal/crypto/crypto_test.go
git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
as DEK wrap/unwrap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
> Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass.
---
## Task 2: store — unlock/init split + DEK wrap slots
**Files:**
- Modify: `internal/store/store.go`
- Create: `internal/store/keys.go`
- Test: `internal/store/keys_test.go`
**Interfaces:**
- Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`.
- Produces:
- `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`.
- `store.Open(path string) (*Store, error)`**signature change**: drops the `key` param; store opens locked.
- `(*Store).InitKeys(adminKey, agentKey []byte) error`.
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`.
- `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`.
- Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail.
- [ ] **Step 1: Change `store.Open` to open locked (no key param)**
In `internal/store/store.go`, change the signature and the struct construction:
```go
// Open opens (creating if needed) the DB at path and applies the schema.
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
```
and replace `s := &Store{db: db, key: key}` with:
```go
s := &Store{db: db}
```
Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`).
- [ ] **Step 2: Write the failing test for InitKeys + Unlock**
Create `internal/store/keys_test.go`:
```go
package store
import (
"bytes"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
_ = bytes.Equal // keep import if unused elsewhere
}
```
- [ ] **Step 3: Run the test to verify it fails (compile error)**
Run: `go test ./internal/store/ -run TestInitKeys -v`
Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined).
- [ ] **Step 4: Implement `internal/store/keys.go`**
```go
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
```
- [ ] **Step 5: Run the store tests, expect PASS**
Run: `go test ./internal/store/... -v`
Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.)
- [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature**
Run: `git grep -n "store.Open(\|Open(path," internal/store`
For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green.
- [ ] **Step 7: Commit**
```bash
git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go
git commit -m "feat(store): envelope DEK with admin/agent wrap slots
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
Unlock loads it from the role's slot (admin slot has no agent fallback).
s.key becomes the DEK, so account/mail crypto is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: cli — role routing, openStore(role), init bootstrap
**Files:**
- Modify: `internal/cli/run.go` (openStore, command routing, agent call sites)
- Modify: `internal/cli/admin.go` (4 `openStore()` call sites)
- Modify: `internal/cli/interactive.go` (`runInit` bootstrap)
- Modify: `internal/cli/admin_test.go` (`adminEnv` helper)
- Modify: `internal/cli/run_test.go` (b64 helpers, gating test)
**Interfaces:**
- Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2).
- Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`.
- [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`**
Replace the current `openStore` (lines ~2839) with:
```go
// 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
}
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
}
```
Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present).
- [ ] **Step 2: Update the three agent call sites in `run.go`**
- `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)`
- `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)`
- `runSend` (line ~252): `st, err := openStore(store.RoleAgent)`
- [ ] **Step 3: Update the four admin call sites in `admin.go`**
In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it).
- [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`**
Replace the body of `runInit` (lines ~7597) with:
```go
func runInit(args []string, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init")
return 0
}
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")
}
accs, _ := st.ListAccounts()
if len(accs) > 0 {
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
} else {
fmt.Fprintln(out, "Initializing emcli — add your first account.")
}
return addInteractive(st, tui.Fields{}, out, errOut)
}
```
Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported).
- [ ] **Step 5: Update test helpers in `admin_test.go`**
Replace `adminEnv` (lines ~1420) with a version that sets both keys AND seeds the wrap slots:
```go
// 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_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
}
```
Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported).
- [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`**
Add next to `b64Key` (line ~53):
```go
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
```
In `TestRunVersionIsJSONForAgentButTextHere` (lines ~2133), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell:
```go
t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut)
```
The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`.
- [ ] **Step 7: Build and run the full cli suite**
Run: `go build ./... && go test ./internal/cli/... -race`
Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it.
- [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`**
```go
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()
}
```
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \
internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go
git commit -m "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>"
```
---
## Task 4: headline security-invariant test
**Files:**
- Create: `internal/cli/security_invariant_test.go`
**Interfaces:**
- Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 23).
- [ ] **Step 1: Write the invariant test**
```go
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
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()
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
```
- [ ] **Step 2: Run it**
Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race`
Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.)
- [ ] **Step 3: Full suite + vet**
Run: `go vet ./... && go test ./... -race`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/cli/security_invariant_test.go
git commit -m "test(cli): prove agent key cannot run admin commands
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: documentation
**Files:**
- Modify: `README.md`
- Modify: `USER-MANUAL.md`
- Modify: `specifications/SPEC.md`
- Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup)
**Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read.
- [ ] **Step 1: README "Getting started" — two keys**
Replace the single-key export block with:
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
```
Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."*
- [ ] **Step 2: USER-MANUAL — key model + role table**
Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow.
- [ ] **Step 3: SPEC §4/§5 — enforced trust boundary**
In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`).
- [ ] **Step 4: skill docs — agent gets only EMCLI_KEY**
In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists.
- [ ] **Step 5: Verify and commit**
Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/`
Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then:
```bash
git add README.md USER-MANUAL.md specifications/SPEC.md skills/
git commit -m "docs: document two-key privilege model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review (completed by plan author)
**Spec coverage:**
- Two env vars / role model → Tasks 1, 3. ✓
- Envelope DEK + two wrap slots → Task 2. ✓
- `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓
- Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓
- Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓
- Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓
- `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓
- No migration / no version gate → no such code added. ✓
- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓
- Existing-test fallout (helpers) → Task 3 Steps 56. ✓
- Headline invariant test → Task 4. ✓
- Docs → Task 5. ✓
**Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓
**Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓
@@ -0,0 +1,192 @@
# Two-key privilege separation — design
**Date:** 2026-06-22
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
Today `EMCLI_KEY` does double duty: it is both the AES-256 key that decrypts the
stored mail-account passwords *and* the only gate for every command. The agent
process is launched with `EMCLI_KEY` so it can read and send mail, but that same
key authorizes the admin commands (`account`, `whitelist`, `config`, `audit`,
`init`) too. SPEC §4's "trust boundary" — *the agent invokes only agent
commands* — is convention, not enforcement. A prompt-injected / "forced" agent
holding `EMCLI_KEY` can run `account add`, edit a whitelist, or flip `config`,
dismantling the very guardrails emcli exists to enforce.
## Goal
Split privilege into two environment keys so that the boundary is *enforced*,
not merely conventional:
- `EMCLI_ADMIN_KEY` — authorizes **all** commands.
- `EMCLI_KEY` — authorizes **agent** (non-admin) commands only.
The agent's launch environment is given **only** `EMCLI_KEY`. Because the admin
secret is simply absent from that environment, no instruction can make the agent
perform an admin action — the binary refuses, and there is no key present that
could authorize it.
## Constraints / decisions
These were settled during brainstorming:
1. **Two distinct env vars** (`EMCLI_ADMIN_KEY`, `EMCLI_KEY`) — role is named and
readable in configs/skills, not inferred from a single value.
2. **Both keys must be able to decrypt account passwords.** The agent decrypts to
talk to IMAP/SMTP; admin's `account add` / `doctor` need it too. So the keys
differ in *authorization*, not in crypto capability. This forces envelope
encryption (one data key, wrapped per role).
3. **Agent-key commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`.
**Admin-only:** `account`, `whitelist`, `config`, `audit`, `init`.
4. **No migration, no schema-version gate.** No third party uses this DB and the
data is being scrapped anyway. `init` writes the new wrap slots into a fresh
DB; existing DBs are simply re-created by re-running `init`.
## Approach: envelope encryption (one DEK, wrapped per role)
### Key model
- `EMCLI_ADMIN_KEY` and `EMCLI_KEY` are base64 32-byte **key-encryption keys
(KEKs)**. Neither directly encrypts account secrets.
- `emcli init` generates a random 32-byte **data-encryption key (DEK)**. All
account secrets (`enc_password`, `enc_oauth_client_id`,
`enc_oauth_client_secret`, `enc_oauth_refresh_token`) are sealed under the DEK,
exactly as they are sealed under the raw key today.
- The DEK is stored in the `settings` table, sealed twice:
- `dek_wrap_admin = Seal(adminKey, DEK)`
- `dek_wrap_agent = Seal(agentKey, DEK)`
- The DEK never touches disk in cleartext.
### Why this enforces the boundary
The store already isolates all secret crypto behind one `s.key` field
(`account.go` calls `crypto.Seal(s.key, …)` / `crypto.Open(s.key, …)`). In the
new model **`s.key` simply becomes the DEK** — `account.go`, `send.go`, etc. do
not change. The entire change lives in *how the DEK is obtained*:
- **Admin command** → require `EMCLI_ADMIN_KEY`; unwrap the DEK from
`dek_wrap_admin` **only**. If the var is unset or fails to unwrap → hard error,
**with no fallback to the agent slot.** This is the enforcement linchpin.
- **Agent command** → prefer `EMCLI_KEY`, unwrap from `dek_wrap_agent`. If
`EMCLI_KEY` is unset but `EMCLI_ADMIN_KEY` is present, fall back to the admin
slot (admin is a superset; a human holding only the admin key can still run
`list` / `send`).
The agent process holds `EMCLI_KEY` only. Admin commands refuse to unwrap from
anything but `dek_wrap_admin`, which `EMCLI_KEY` cannot open. The agent cannot
hold a secret that authorizes config changes — it is absent from its
environment, not merely gated by a flag.
## Components
### `internal/crypto`
- Generalize `KeyFromEnv` to read a named variable; add `AdminKeyFromEnv()` and
`AgentKeyFromEnv()` thin wrappers.
- DEK wrap/unwrap **reuses** the existing `Seal` / `Open` — no new primitive.
- A helper to generate a random 32-byte DEK.
### `internal/store`
Split *unlock* from *open* so the DEK can be read from a wrap slot after the DB
is open (settings rows are plaintext, so no key is needed to read them):
- `store.Open(path) (*Store, error)` — opens/creates the DB, applies the schema,
key still **locked** (`s.key == nil`).
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error` — reads the slot
for the role, unwraps the DEK, sets `s.key`. Missing slot or wrong key → clear
error.
- `(*Store).InitKeys(adminKey, agentKey []byte) error` — generates a random DEK,
seals it under both KEKs, writes `dek_wrap_admin` + `dek_wrap_agent`, sets
`s.key`.
No table changes; two new rows in the existing `settings` table.
`account.go` / mail crypto is untouched (still `crypto.Seal(s.key, …)`).
### `internal/cli`
- One `commandRole(cmd string) Role` function in `run.go` — the single source of
truth for the classification table above.
- `openStore` gains a `role` parameter and performs slot selection via
`Open` + `Unlock`.
- Each `run*` helper passes its role. `init` uses the bootstrap path
(`Open` + `InitKeys`, or `Unlock(admin)` if already initialized).
### Command classification
| Command | Role |
|-------------------------------------------|-----------|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
## `init` & key generation UX
`init` requires **both** keys present (it writes both wrap slots). If either is
missing it errors with what's needed plus the generation hint.
Idempotency: if the wrap slots already exist, `init` does **not** regenerate the
DEK (that would orphan existing sealed passwords) — it unlocks via the admin slot
and keeps today's "already initialized; adding another account" behavior. Fresh
DB → generate DEK, then add the first account.
Documented flow (README + USER-MANUAL):
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # human keeps this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # agent launcher gets only this
emcli init
```
The agent's orchestrator is configured with **only** `EMCLI_KEY`.
## Error handling
- Agent key on an admin command → exit non-zero, stderr:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(Admin commands print human-readable output, not JSON.)
- Admin command, neither key set → `EMCLI_ADMIN_KEY is not set`.
- Agent command, neither key set → existing `EMCLI_KEY is not set` JSON envelope
(`CodeConfig`).
- Key present but does not unwrap its slot → `wrong key for this DB`, not a raw
GCM auth-tag error.
## Testing
- **crypto:** wrap/unwrap round-trip; an admin-sealed DEK is *not* openable with
the agent key.
- **store:** after `InitKeys`, both `Unlock(admin)` and `Unlock(agent)` recover
the *same* DEK and decrypt an account password; `Unlock` with a wrong key fails
cleanly.
- **routing:** table test for `commandRole`; agent key on an admin command →
refused, non-zero, correct message; agent command works with the agent key;
agent command works with the admin key (superset fallback); `init` refused
unless both keys are set.
- **Headline security invariant:** initialize a DB, then with **only `EMCLI_KEY`**
set, attempt every admin command (`account add/list`, `whitelist …`,
`config set`, `audit`) and assert each is refused *and the DB is byte-for-byte
unchanged*. This is the test that proves the bug is fixed.
- **Existing-test fallout:** the `adminEnv` / `b64Key` helpers in `admin_test.go`
(and `run_test.go`) set `EMCLI_KEY` against a raw-key store — update them to
provision both wrap slots via `InitKeys`. Audit all `t.Setenv("EMCLI_KEY", …)`
sites.
## Documentation updates
- README "Getting started" — two-key generation flow.
- USER-MANUAL — key model, role/command table, agent-launcher configuration
(only `EMCLI_KEY`), updated `init`.
- `skills/emcli` SKILL.md / AGENTIC-MANUAL.md — the agent is given only
`EMCLI_KEY`; admin commands are not available to it.
- SPEC §4 (trust boundary) and §5 (secrets) — describe enforced two-key model.
## Out of scope (possible follow-ups)
- Key rotation commands (re-wrap the DEK under a new admin or agent key without
re-encrypting passwords — the envelope design supports this cheaply, but it is
not built now).
- A distinct read-only vs read-write *agent* tier (orthogonal; per-account
RO/RW already exists).
+8 -8
View File
@@ -11,7 +11,7 @@ import (
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
func runAccount(args []string, out, errOut io.Writer) int {
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "account")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
@@ -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(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -191,7 +191,7 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
}
// runConfig handles `config set <key> <value>` and `config get <key>`.
func runConfig(args []string, out, errOut io.Writer) int {
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "config")
if len(args) > 0 {
@@ -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(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -246,7 +246,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
func runAudit(args []string, out, errOut io.Writer) int {
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "audit")
return 0
@@ -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(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -276,7 +276,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int {
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "whitelist")
if len(args) > 0 {
@@ -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(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
+25 -6
View File
@@ -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) }
+4 -1
View File
@@ -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",
+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) {
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 {
+23 -3
View File
@@ -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")
+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()
}
+51 -19
View File
@@ -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 {
@@ -68,7 +99,7 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
}
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
func runDoctor(args []string, out, errOut io.Writer) int {
func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "doctor", errOut)
@@ -79,7 +110,7 @@ func runDoctor(args []string, out, errOut io.Writer) int {
}
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -104,21 +135,22 @@ func Run(args []string, out, errOut io.Writer) int {
return 0
}
cmd, rest := args[0], args[1:]
role := commandRole(cmd)
switch cmd {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, out, errOut)
return runAgent(cmd, rest, role, out, errOut)
case "send":
return runSend(rest, out, errOut)
return runSend(rest, role, out, errOut)
case "account":
return runAccount(rest, out, errOut)
return runAccount(rest, role, out, errOut)
case "whitelist":
return runWhitelist(rest, out, errOut)
return runWhitelist(rest, role, out, errOut)
case "config":
return runConfig(rest, out, errOut)
return runConfig(rest, role, out, errOut)
case "audit":
return runAudit(rest, out, errOut)
return runAudit(rest, role, out, errOut)
case "doctor":
return runDoctor(rest, out, errOut)
return runDoctor(rest, role, out, errOut)
case "init":
return runInit(rest, out, errOut)
default:
@@ -128,7 +160,7 @@ func Run(args []string, out, errOut io.Writer) int {
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, cmd, errOut)
@@ -159,7 +191,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(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
@@ -224,7 +256,7 @@ func (s *stringSlice) Set(v string) error {
}
// runSend handles the `send` agent command (JSON envelope output).
func runSend(args []string, out, errOut io.Writer) int {
func runSend(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("send", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "send", errOut)
@@ -249,7 +281,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(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
+8 -2
View File
@@ -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="
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
db := filepath.Join(t.TempDir(), "emcli.db")
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("store.Open: %v", err)
}
ak, err := crypto.AdminKeyFromEnv()
if err != nil {
t.Fatalf("AdminKeyFromEnv: %v", err)
}
gk, err := crypto.AgentKeyFromEnv()
if err != nil {
t.Fatalf("AgentKeyFromEnv: %v", err)
}
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Errorf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
continue
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
+4 -1
View File
@@ -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)
+24 -11
View File
@@ -1,4 +1,4 @@
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
// Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
package crypto
import (
@@ -7,28 +7,41 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)
var (
ErrNoKey = errors.New("EMCLI_KEY is not set")
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes")
)
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
func KeyFromEnv() ([]byte, error) {
raw := os.Getenv("EMCLI_KEY")
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
// named environment variable. Errors name the variable so callers get a
// role-appropriate message.
func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
if raw == "" {
return nil, ErrNoKey
return nil, fmt.Errorf("%s is not set", varName)
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, ErrBadKey
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
}
return key, nil
}
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
+29 -10
View File
@@ -3,6 +3,7 @@ package crypto
import (
"bytes"
"encoding/base64"
"strings"
"testing"
)
@@ -50,20 +51,38 @@ func TestOpenWrongKeyFails(t *testing.T) {
}
}
func TestKeyFromEnv(t *testing.T) {
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey()))
k, err := KeyFromEnv()
if err != nil || len(k) != 32 {
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err)
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
good := base64.StdEncoding.EncodeToString(testKey())
t.Setenv("EMCLI_KEY", good)
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_KEY", "")
if _, err := KeyFromEnv(); err != ErrNoKey {
t.Fatalf("empty key: want ErrNoKey, got %v", err)
t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := AdminKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := KeyFromEnv(); err != ErrBadKey {
t.Fatalf("short key: want ErrBadKey, got %v", err)
if _, err := AgentKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
}
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
// Already initialised: the DEK and both wrap slots already exist, so the
// agent key is not consumed here. Only the admin key is used to unlock the
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"path/filepath"
"testing"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
}
+4 -2
View File
@@ -19,7 +19,9 @@ type Store struct {
}
// Open opens (creating if needed) the DB at path and applies the schema.
func Open(path string, key []byte) (*Store, error) {
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
@@ -39,7 +41,7 @@ func Open(path string, key []byte) (*Store, error) {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
s := &Store{db: db, key: key}
s := &Store{db: db}
if _, err := s.GetSetting("schema_version"); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close()
+8 -12
View File
@@ -5,29 +5,25 @@ import (
"testing"
)
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
// openTemp opens a fresh store in a temp dir.
// openTemp opens a fresh store in a temp dir and initialises keys so that
// account tests (which do crypto) work without needing their own setup.
func openTemp(t *testing.T) *Store {
t.Helper()
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
s, err := Open(p)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
s, err := Open(p)
if err != nil {
t.Fatalf("first Open: %v", err)
}
@@ -38,7 +34,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
s.Close()
// Re-open: must not error or duplicate.
s2, err := Open(p, testKey())
s2, err := Open(p)
if err != nil {
t.Fatalf("second Open: %v", err)
}
+13 -9
View File
@@ -65,32 +65,36 @@ git clone https://gitea.dcglab.co.uk/steve/emcli && cd emcli
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
```
## 3. Confirm the encryption key is present
## 3. Confirm the agent key is present
emcli needs `EMCLI_KEY` (a base64-encoded 32-byte AES key) to touch its database. For agent use,
**the orchestrator that launched you provides it** in the environment.
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
`list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
which the human holds — attempting admin commands with only `EMCLI_KEY` is refused by `emcli`.
For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
- **Never** read, print, log, pass as an argument, or generate this value.
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
your orchestrator; I can't read or create it for you."
(For a human setting emcli up the first time: generate one with `head -c 32 /dev/urandom | base64`
and store it securely. Account creation and other admin is the human's job — see the project's
(For a human setting emcli up the first time: generate both keys with
`head -c 32 /dev/urandom | base64` (once per key) and store them securely; then run `emcli init`
with both keys exported. Account creation and other admin is the human's job — see the project's
`USER-MANUAL.md`.)
## 4. Find the account(s)
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use. If the
user permits running admin commands, `emcli doctor` lists the configured accounts and checks that
each one connects and authenticates:
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
configured accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Otherwise, just take the account name from the user and start with the workflow in `SKILL.md`.
Just take the account name from the user and start with the workflow in `SKILL.md`.
## You're set up
+10 -6
View File
@@ -17,10 +17,12 @@ sets its exit code to match.
## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`. Account setup,
passwords, whitelists, and config are the **user's** job (admin commands) — do not run or suggest
running `account`, `whitelist`, `config`, `init`, or `doctor` unless the user explicitly asks you
to help administer.
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
with a privilege error.
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
missing, stop and tell the user (see "Files & first run").
@@ -43,7 +45,8 @@ https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
empty, tell the user their orchestrator must provide it. Then get the account name from the user.
empty, tell the user their orchestrator must provide `EMCLI_KEY` (the agent key). Then get the
account name from the user.
## How to read every result
@@ -141,5 +144,6 @@ The user configures these; you cannot change them and shouldn't try.
- ✅ `get` to read, then `ack` only after you've truly processed a message.
- ✅ Ask the user for the account name; keep bodies plain text.
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`init`) unless asked to help set up.
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
+53 -17
View File
@@ -62,23 +62,50 @@ The binary is organized into independently testable packages:
human-readable/TUI).
### Trust boundary
- The agent invokes only the **agent commands** (Section 7.1).
- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as
an argument the agent constructs. The agent has no command that reveals secret values.
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has
no other path to the mail servers.
Two keys enforce a hard privilege split — this is not convention; it is structurally enforced by
the DEK-wrapping scheme:
- **`EMCLI_ADMIN_KEY`** — base64-encoded 32-byte key held by the human operator. Authorises ALL
commands. Admin commands unwrap the DEK from the `dek_wrap_admin` slot only; there is no fallback
to the agent slot. A process holding only `EMCLI_KEY` cannot run admin commands.
- **`EMCLI_KEY`** — base64-encoded 32-byte key supplied to the agent orchestrator. Authorises agent
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
a process with only the admin key can also run agent commands.
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
constructs. The agent has no command that reveals secret values.
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
path to the mail servers.
## 5. Configuration & secrets
- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If
absent or malformed, every command that touches the DB fails closed with an error
envelope; no plaintext fallback.
- **Admin key:** `EMCLI_ADMIN_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for
admin commands and for `init`. If absent or malformed when an admin command is attempted, the
command fails with `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
- **Agent key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for agent
commands. If absent or malformed, every agent command fails closed with a `config` error envelope;
no plaintext fallback. `EMCLI_ADMIN_KEY` is accepted as a fallback for agent commands when
`EMCLI_KEY` is not set.
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
(`%AppData%\emcli\emcli.db` on Windows).
- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains
plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is
surfaced as an error, never silently ignored.
- **Envelope encryption (DEK):** `emcli init` generates a random data-encryption key (DEK) that
protects all account secrets. The DEK is stored in the `settings` table sealed under both keys:
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY` (AES-256-GCM).
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY` (AES-256-GCM).
The DEK is never written in cleartext. Admin commands unwrap from `dek_wrap_admin` only; agent
commands unwrap from `dek_wrap_agent` (or `dek_wrap_admin` if only the admin key is present).
There is no cross-slot fallback for admin commands — a holder of only `EMCLI_KEY` cannot unwrap
the admin DEK slot.
- **`init` idempotency:** re-running `emcli init` does not regenerate the DEK; the existing wrapped
DEK rows are preserved.
- **Field-level encryption:** secret columns are encrypted with the DEK using AES-256-GCM with a
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains plaintext for
debuggability. Decryption with the wrong key fails (GCM auth tag) and is surfaced as an error,
never silently ignored.
Secret columns: account password, OAuth client secret, OAuth refresh token.
@@ -142,7 +169,9 @@ audit_log
settings
key TEXT PK
value TEXT
-- includes: audit_retention_days, schema_version
-- includes: audit_retention_days, schema_version,
-- dek_wrap_admin (DEK sealed under EMCLI_ADMIN_KEY),
-- dek_wrap_agent (DEK sealed under EMCLI_KEY)
```
Notes:
@@ -216,8 +245,11 @@ command that advances read state is `ack`.
### 7.2 Admin commands (human-readable / TUI)
- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account,
and runs OAuth consent if the account is OAuth2.
Require `EMCLI_ADMIN_KEY`.
- **`emcli init`** — TUI flow: creates the DB (generating schema + DEK), adds the first account,
and runs OAuth consent if the account is OAuth2. Requires both `EMCLI_ADMIN_KEY` and `EMCLI_KEY`
(writes both DEK wrap slots). Idempotent — re-running does not regenerate the DEK.
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
@@ -225,8 +257,12 @@ command that advances read state is `ack`.
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each
account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics.
### 7.2a `doctor` — agent-role diagnostics
`doctor` is authorised by `EMCLI_KEY` (or `EMCLI_ADMIN_KEY`). It verifies the key is present and
valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Prints
human-readable diagnostics. Can be run by the agent or by a human; does not require admin privilege.
### 7.3 Defaults & limits
- `list --limit` default: 50; maximum: 500.