# 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).