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

8.7 KiB

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 DEKaccount.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):

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