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>
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:
- Two distinct env vars (
EMCLI_ADMIN_KEY,EMCLI_KEY) — role is named and readable in configs/skills, not inferred from a single value. - Both keys must be able to decrypt account passwords. The agent decrypts to
talk to IMAP/SMTP; admin's
account add/doctorneed it too. So the keys differ in authorization, not in crypto capability. This forces envelope encryption (one data key, wrapped per role). - Agent-key commands:
list,get,search,ack,send,doctor. Admin-only:account,whitelist,config,audit,init. - No migration, no schema-version gate. No third party uses this DB and the
data is being scrapped anyway.
initwrites the new wrap slots into a fresh DB; existing DBs are simply re-created by re-runninginit.
Approach: envelope encryption (one DEK, wrapped per role)
Key model
EMCLI_ADMIN_KEYandEMCLI_KEYare base64 32-byte key-encryption keys (KEKs). Neither directly encrypts account secrets.emcli initgenerates 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
settingstable, 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 fromdek_wrap_adminonly. 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 fromdek_wrap_agent. IfEMCLI_KEYis unset butEMCLI_ADMIN_KEYis present, fall back to the admin slot (admin is a superset; a human holding only the admin key can still runlist/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
KeyFromEnvto read a named variable; addAdminKeyFromEnv()andAgentKeyFromEnv()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, setss.key. Missing slot or wrong key → clear error.(*Store).InitKeys(adminKey, agentKey []byte) error— generates a random DEK, seals it under both KEKs, writesdek_wrap_admin+dek_wrap_agent, setss.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) Rolefunction inrun.go— the single source of truth for the classification table above. openStoregains aroleparameter and performs slot selection viaOpen+Unlock.- Each
run*helper passes its role.inituses the bootstrap path (Open+InitKeys, orUnlock(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 setJSON 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, bothUnlock(admin)andUnlock(agent)recover the same DEK and decrypt an account password;Unlockwith 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);initrefused unless both keys are set. - Headline security invariant: initialize a DB, then with only
EMCLI_KEYset, 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/b64Keyhelpers inadmin_test.go(andrun_test.go) setEMCLI_KEYagainst a raw-key store — update them to provision both wrap slots viaInitKeys. Audit allt.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), updatedinit. skills/emcliSKILL.md / AGENTIC-MANUAL.md — the agent is given onlyEMCLI_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).