Files
emcli/docs/superpowers/specs/2026-06-22-two-key-privilege-design.md
T
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

193 lines
8.7 KiB
Markdown

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