diff --git a/docs/superpowers/specs/2026-06-22-two-key-privilege-design.md b/docs/superpowers/specs/2026-06-22-two-key-privilege-design.md new file mode 100644 index 0000000..50cfe87 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-two-key-privilege-design.md @@ -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).