Compare commits
15 Commits
v0.4.0
...
76ada04442
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ada04442 | |||
| add9515b5c | |||
| 456e15a2f8 | |||
| 5c7dd252db | |||
| 9d946b1b03 | |||
| cb0425f18d | |||
| c52f30898b | |||
| 77ba5a146f | |||
| 2bc2c1b50e | |||
| c946516d01 | |||
| b3390a0a20 | |||
| 1b2fe99055 | |||
| 7087533644 | |||
| 93dbebb982 | |||
| 68a29ad5c7 |
@@ -4,8 +4,8 @@
|
||||
#
|
||||
# Requires: Gitea Actions enabled with a runner that has Go, make, curl, and jq
|
||||
# (the actions/checkout + actions/setup-go steps need the instance's Actions proxy).
|
||||
# This workflow has not been exercised against this repo's runners yet; if a step
|
||||
# is unavailable on your runner, the same result comes from `make release && make
|
||||
# Verified: this workflow published v0.4.0 on this instance. If a step is ever
|
||||
# unavailable on your runner, the same result comes from `make release && make
|
||||
# publish` locally (see RELEASING.md).
|
||||
name: release
|
||||
on:
|
||||
|
||||
@@ -9,11 +9,14 @@ it isn't permitted to see or send mail to people it isn't permitted to contact.
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # one-time: generate & save a key
|
||||
emcli init # create the DB, add your first account
|
||||
emcli doctor # confirm it connects and authenticates
|
||||
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
|
||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
|
||||
emcli init # writes both wrap slots
|
||||
emcli doctor # confirm connect/auth (agent key is enough)
|
||||
```
|
||||
|
||||
`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it.
|
||||
|
||||
## Documentation
|
||||
|
||||
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
|
||||
|
||||
+9
-5
@@ -39,14 +39,18 @@ git push origin v0.4.0 # (push via the tokenized HTTPS URL this repo uses)
|
||||
```
|
||||
|
||||
The workflow runs `make release` and uploads the assets to the release via the Gitea API. It needs
|
||||
Gitea Actions enabled with a runner that provides Go, make, curl, and jq. It hasn't been exercised
|
||||
against this repo's runners yet — if it doesn't fit your runner setup, fall back to Option A.
|
||||
Gitea Actions enabled with a runner that provides Go, make, curl, and jq. This is how v0.4.0 was
|
||||
published. If it ever doesn't fit your runner setup, fall back to Option A.
|
||||
|
||||
> Note: release asset downloads are anonymous, so the repository (or at least its releases) must be
|
||||
> public for `skills/emcli/scripts/install.sh` to fetch binaries without a token. A private repo
|
||||
> returns 404 to unauthenticated downloads.
|
||||
|
||||
## After a release
|
||||
|
||||
The skill installer defaults to `EMCLI_VERSION=v0.4.0`. When you cut a different version, either
|
||||
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the note in
|
||||
`skills/emcli/references/install.md`).
|
||||
The skill installer defaults to `EMCLI_VERSION=v0.4.1`. When you cut a different version, either
|
||||
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the options
|
||||
table in `skills/emcli/AGENTIC-MANUAL.md`).
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
+86
-31
@@ -13,7 +13,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
||||
## Contents
|
||||
|
||||
1. [Key concepts](#1-key-concepts)
|
||||
2. [Setup: the encryption key and database](#2-setup-the-encryption-key-and-database)
|
||||
2. [Setup: encryption keys and database](#2-setup-encryption-keys-and-database)
|
||||
- [Privilege model](#2a-privilege-model)
|
||||
3. [Quick start](#3-quick-start)
|
||||
4. [Adding accounts](#4-adding-accounts)
|
||||
- [Gmail (app password)](#gmail-app-password)
|
||||
@@ -32,10 +33,13 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
||||
## 1. Key concepts
|
||||
|
||||
**Two kinds of commands.**
|
||||
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`, `doctor`) are for *you*,
|
||||
the human. They print human-readable text or open an interactive form.
|
||||
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`) are for the *agent*. They print one
|
||||
line of JSON and nothing else, so a program can consume them reliably.
|
||||
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
|
||||
and are for *you*, the human. They print human-readable text or open an interactive form.
|
||||
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
|
||||
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
|
||||
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
|
||||
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
|
||||
as a superset, so either key suffices for agent commands.)
|
||||
|
||||
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
|
||||
sees its password.
|
||||
@@ -61,50 +65,91 @@ acking is a deliberate, separate step.
|
||||
|
||||
---
|
||||
|
||||
## 2. Setup: the encryption key and database
|
||||
## 2. Setup: encryption keys and database
|
||||
|
||||
`emcli` reads two environment variables:
|
||||
`emcli` reads three environment variables:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `EMCLI_KEY` | **Required.** Base64-encoded 32-byte key (AES-256) used to encrypt passwords at rest. | none — commands fail without it |
|
||||
| `EMCLI_ADMIN_KEY` | **Required for admin.** Base64-encoded 32-byte key (AES-256). Authorises ALL commands. | none — admin commands fail without it |
|
||||
| `EMCLI_KEY` | **Required for agents.** Base64-encoded 32-byte key (AES-256). Authorises agent commands only. | none — agent commands fail without it |
|
||||
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
|
||||
|
||||
**Generate a key once** and keep it safe (store it the way the program/orchestrator that launches
|
||||
`emcli` expects — e.g. a secrets manager or your shell profile):
|
||||
**Generate both keys once** and keep them safe:
|
||||
|
||||
```bash
|
||||
head -c 32 /dev/urandom | base64
|
||||
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
|
||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
|
||||
```
|
||||
|
||||
Set it in your environment before running any command:
|
||||
|
||||
```bash
|
||||
export EMCLI_KEY='paste-the-base64-key-here'
|
||||
```
|
||||
|
||||
> **Important:** the key encrypts your account passwords. If you lose it, the stored passwords
|
||||
> can't be decrypted and you'll have to re-add accounts. If you change it, the same applies.
|
||||
> `emcli` never falls back to plaintext — a missing or wrong key makes every command fail safely.
|
||||
> **Important:** the keys protect your account passwords via envelope encryption (see "Privilege
|
||||
> model" below). If you lose `EMCLI_ADMIN_KEY`, account secrets can't be decrypted and you'll have
|
||||
> to re-add accounts. `emcli` never falls back to plaintext — a missing or wrong key makes every
|
||||
> command fail safely.
|
||||
|
||||
Account passwords are stored **encrypted**; they never appear in command output, error messages,
|
||||
or the audit log.
|
||||
|
||||
---
|
||||
|
||||
## 2a. Privilege model
|
||||
|
||||
`emcli` enforces a two-role privilege split so a process holding only the agent key cannot
|
||||
reconfigure accounts, whitelists, or audit settings.
|
||||
|
||||
### The two keys
|
||||
|
||||
| Key | Holder | Authorises |
|
||||
|---|---|---|
|
||||
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
|
||||
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) |
|
||||
|
||||
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
|
||||
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
|
||||
(admin privilege)` if it attempts an admin command.
|
||||
|
||||
### Envelope encryption (DEK)
|
||||
|
||||
`emcli init` generates a random data-encryption key (DEK) that seals all account secrets. The DEK
|
||||
is stored in the `settings` table in two sealed copies:
|
||||
|
||||
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY`.
|
||||
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY`.
|
||||
|
||||
The DEK is never written in cleartext. Admin commands unwrap the DEK from the admin slot only; they
|
||||
have no fallback to the agent slot. This means a process holding only `EMCLI_KEY` cannot unlock the
|
||||
DEK for an admin command, even if it somehow knows the agent key.
|
||||
|
||||
### Command → role table
|
||||
|
||||
| Command | Role required |
|
||||
|---|---|
|
||||
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
||||
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
||||
| `init` | Both keys required (writes both wrap slots) |
|
||||
|
||||
### Agent launcher guidance
|
||||
|
||||
Configure your agent's orchestrator with **only `EMCLI_KEY`**. Never give the orchestrator
|
||||
`EMCLI_ADMIN_KEY`. If the agent tries to run an admin command — even by mistake — `emcli` will
|
||||
refuse it at the key level, not just by convention.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quick start
|
||||
|
||||
```bash
|
||||
# 1. Set your key (see section 2)
|
||||
export EMCLI_KEY='…'
|
||||
# 1. Generate and export both keys (see section 2)
|
||||
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # keep this yourself
|
||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # give only this to the agent
|
||||
|
||||
# 2. Create the database and add your first account (interactive form)
|
||||
emcli init
|
||||
|
||||
# 3. Check it connects and authenticates
|
||||
# 3. Check it connects and authenticates (agent key is enough for doctor)
|
||||
emcli doctor
|
||||
|
||||
# 4. The agent can now read
|
||||
# 4. The agent can now read (needs only EMCLI_KEY)
|
||||
emcli list --account gmail --folder INBOX --limit 10
|
||||
```
|
||||
|
||||
@@ -491,10 +536,16 @@ emcli config get audit_retention_days
|
||||
## 11. Troubleshooting
|
||||
|
||||
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
|
||||
base64-encoded 32-byte key (section 2). Every command that touches the database needs it.
|
||||
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`,
|
||||
`doctor`) need this key.
|
||||
|
||||
**A command fails to decrypt / "wrong EMCLI_KEY?".** The key doesn't match the one used when the
|
||||
account was added. Restore the original key, or re-add the account with the current key.
|
||||
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
|
||||
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY`
|
||||
alone is not enough for them.
|
||||
|
||||
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
|
||||
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
|
||||
the DEK if one already exists) with both correct keys, then re-add any accounts if needed.
|
||||
|
||||
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
|
||||
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
|
||||
@@ -524,7 +575,11 @@ running non-interactively.
|
||||
## 12. Command cheat sheet
|
||||
|
||||
```
|
||||
# Admin
|
||||
# Help
|
||||
emcli # or: emcli help / emcli --help — list all commands
|
||||
emcli <command> --help # usage and flags for one command
|
||||
|
||||
# Admin (requires EMCLI_ADMIN_KEY)
|
||||
emcli init # create DB + add first account (form)
|
||||
emcli account add [flags | none for form] # add an account
|
||||
emcli account list # list accounts (no secrets)
|
||||
@@ -533,10 +588,10 @@ emcli account remove --name N --yes # delete an account
|
||||
emcli whitelist in|out add|remove|list --account N [--address A]
|
||||
emcli config set|get <key> [value] # e.g. audit_retention_days
|
||||
emcli audit list [--account N] [--limit K]
|
||||
emcli doctor [--account N] # connectivity/auth check
|
||||
emcli version
|
||||
|
||||
# Agent (one line of JSON each)
|
||||
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
|
||||
emcli doctor [--account N] # connectivity/auth check
|
||||
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
|
||||
emcli get --account N [--folder F] --uid U
|
||||
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
|
||||
@@ -544,4 +599,4 @@ emcli ack --account N [--folder F] --uid-list U1,U2,U3
|
||||
emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]]
|
||||
```
|
||||
|
||||
Environment: `EMCLI_KEY` (required, base64 32-byte key), `EMCLI_DB` (optional DB path).
|
||||
Environment: `EMCLI_ADMIN_KEY` (required for admin commands, base64 32-byte key), `EMCLI_KEY` (required for agent commands, base64 32-byte key), `EMCLI_DB` (optional DB path).
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
# Two-Key Privilege Separation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands.
|
||||
|
||||
**Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched.
|
||||
|
||||
**Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`.
|
||||
- Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else.
|
||||
- Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`.
|
||||
- Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log.
|
||||
- Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON).
|
||||
- DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`).
|
||||
- No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec).
|
||||
|
||||
**Command → role mapping (single source of truth, implemented in Task 3):**
|
||||
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: crypto — named-var key loaders + DEK generation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/crypto/crypto.go`
|
||||
- Test: `internal/crypto/crypto_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap).
|
||||
- Produces:
|
||||
- `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`.
|
||||
- `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`.
|
||||
- `NewDEK() ([]byte, error)` — fresh random 32-byte key.
|
||||
- Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3).
|
||||
|
||||
- [ ] **Step 1: Replace the env/error section of `crypto.go`**
|
||||
|
||||
Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~14–30) with:
|
||||
|
||||
```go
|
||||
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
|
||||
// named environment variable. Errors name the variable so callers get a
|
||||
// role-appropriate message.
|
||||
func keyFromEnv(varName string) ([]byte, error) {
|
||||
raw := os.Getenv(varName)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("%s is not set", varName)
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
|
||||
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
|
||||
|
||||
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
|
||||
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
|
||||
|
||||
// NewDEK returns a fresh random 32-byte data-encryption key.
|
||||
func NewDEK() ([]byte, error) {
|
||||
dek := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dek, nil
|
||||
}
|
||||
```
|
||||
|
||||
Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else.
|
||||
|
||||
- [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`**
|
||||
|
||||
Replace `TestKeyFromEnv` (lines ~53–69) with:
|
||||
|
||||
```go
|
||||
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
|
||||
good := base64.StdEncoding.EncodeToString(testKey())
|
||||
|
||||
t.Setenv("EMCLI_KEY", good)
|
||||
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
|
||||
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
|
||||
}
|
||||
t.Setenv("EMCLI_ADMIN_KEY", good)
|
||||
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
|
||||
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
|
||||
}
|
||||
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
if _, err := AdminKeyFromEnv(); err == nil ||
|
||||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
|
||||
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
|
||||
if _, err := AgentKeyFromEnv(); err == nil ||
|
||||
!strings.Contains(err.Error(), "32 bytes") {
|
||||
t.Fatalf("short key: want length error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDEKIsRandom32(t *testing.T) {
|
||||
a, err := NewDEK()
|
||||
if err != nil || len(a) != 32 {
|
||||
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
|
||||
}
|
||||
b, _ := NewDEK()
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two DEKs must differ")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported).
|
||||
|
||||
- [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition**
|
||||
|
||||
Run: `go test ./internal/crypto/...`
|
||||
Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/crypto/crypto.go internal/crypto/crypto_test.go
|
||||
git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK
|
||||
|
||||
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
|
||||
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
|
||||
as DEK wrap/unwrap.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
> Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: store — unlock/init split + DEK wrap slots
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/store/store.go`
|
||||
- Create: `internal/store/keys.go`
|
||||
- Test: `internal/store/keys_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`.
|
||||
- Produces:
|
||||
- `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`.
|
||||
- `store.Open(path string) (*Store, error)` — **signature change**: drops the `key` param; store opens locked.
|
||||
- `(*Store).InitKeys(adminKey, agentKey []byte) error`.
|
||||
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`.
|
||||
- `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`.
|
||||
- Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail.
|
||||
|
||||
- [ ] **Step 1: Change `store.Open` to open locked (no key param)**
|
||||
|
||||
In `internal/store/store.go`, change the signature and the struct construction:
|
||||
|
||||
```go
|
||||
// Open opens (creating if needed) the DB at path and applies the schema.
|
||||
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
|
||||
// secret read/write.
|
||||
func Open(path string) (*Store, error) {
|
||||
```
|
||||
|
||||
and replace `s := &Store{db: db, key: key}` with:
|
||||
|
||||
```go
|
||||
s := &Store{db: db}
|
||||
```
|
||||
|
||||
Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`).
|
||||
|
||||
- [ ] **Step 2: Write the failing test for InitKeys + Unlock**
|
||||
|
||||
Create `internal/store/keys_test.go`:
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
)
|
||||
|
||||
func k(b byte) []byte {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = b
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func tempStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
st := tempStore(t)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
// Seal a password under the DEK that InitKeys set.
|
||||
if _, err := st.AddAccount(Account{
|
||||
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
|
||||
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddAccount: %v", err)
|
||||
}
|
||||
|
||||
// Re-open and unlock via the AGENT slot.
|
||||
path := st.dbPath()
|
||||
st.Close()
|
||||
st2, _ := Open(path)
|
||||
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
|
||||
t.Fatalf("Unlock(agent): %v", err)
|
||||
}
|
||||
got, err := st2.GetAccount("a")
|
||||
if err != nil || got.Password != "pw" {
|
||||
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
|
||||
}
|
||||
st2.Close()
|
||||
|
||||
// Unlock via the ADMIN slot recovers the same DEK.
|
||||
st3, _ := Open(path)
|
||||
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
|
||||
t.Fatalf("Unlock(admin): %v", err)
|
||||
}
|
||||
got3, err := st3.GetAccount("a")
|
||||
if err != nil || got3.Password != "pw" {
|
||||
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
|
||||
}
|
||||
st3.Close()
|
||||
}
|
||||
|
||||
func TestUnlockWrongKeyFails(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := st.dbPath()
|
||||
st.Close()
|
||||
st2, _ := Open(path)
|
||||
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
|
||||
t.Fatal("Unlock with wrong admin key must fail")
|
||||
}
|
||||
st2.Close()
|
||||
}
|
||||
|
||||
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// RoleAdmin must use the admin slot; passing the agent key as the admin
|
||||
// key must fail — there is no fallback to the agent slot.
|
||||
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
|
||||
t.Fatal("agent key must not unlock the admin slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.AddAccount(Account{
|
||||
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
|
||||
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
|
||||
})
|
||||
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatalf("re-InitKeys: %v", err)
|
||||
}
|
||||
got, err := st.GetAccount("a")
|
||||
if err != nil || got.Password != "pw" {
|
||||
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
|
||||
}
|
||||
_ = bytes.Equal // keep import if unused elsewhere
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test to verify it fails (compile error)**
|
||||
|
||||
Run: `go test ./internal/store/ -run TestInitKeys -v`
|
||||
Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined).
|
||||
|
||||
- [ ] **Step 4: Implement `internal/store/keys.go`**
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
)
|
||||
|
||||
// Role selects which DEK wrap slot a command may unlock.
|
||||
type Role int
|
||||
|
||||
const (
|
||||
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
|
||||
RoleAdmin // all commands; uses dek_wrap_admin ONLY
|
||||
)
|
||||
|
||||
const (
|
||||
settingDEKWrapAdmin = "dek_wrap_admin"
|
||||
settingDEKWrapAgent = "dek_wrap_agent"
|
||||
)
|
||||
|
||||
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
|
||||
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
|
||||
|
||||
// dbPath returns the file path SQLite opened (used by tests to re-open).
|
||||
func (s *Store) dbPath() string {
|
||||
var p string
|
||||
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
|
||||
return p
|
||||
}
|
||||
|
||||
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
|
||||
// both wrap slots, and unlocks the store. If the slots already exist it does
|
||||
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
|
||||
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
|
||||
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
|
||||
return s.Unlock(RoleAdmin, adminKey, nil)
|
||||
}
|
||||
dek, err := crypto.NewDEK()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapAdmin, err := crypto.Seal(adminKey, dek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapAgent, err := crypto.Seal(agentKey, dek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.key = dek
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
|
||||
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
|
||||
// falls back to the admin slot only when no agent key is supplied.
|
||||
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
|
||||
switch role {
|
||||
case RoleAdmin:
|
||||
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
||||
case RoleAgent:
|
||||
if len(agentKey) > 0 {
|
||||
return s.unlockSlot(settingDEKWrapAgent, agentKey)
|
||||
}
|
||||
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
||||
default:
|
||||
return fmt.Errorf("unknown role %d", role)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
|
||||
if len(kek) == 0 {
|
||||
return ErrLocked
|
||||
}
|
||||
enc, err := s.GetSetting(settingKey)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrLocked
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
|
||||
}
|
||||
dek, err := crypto.Open(kek, blob)
|
||||
if err != nil {
|
||||
return errors.New("wrong key for this DB")
|
||||
}
|
||||
s.key = dek
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the store tests, expect PASS**
|
||||
|
||||
Run: `go test ./internal/store/... -v`
|
||||
Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.)
|
||||
|
||||
- [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature**
|
||||
|
||||
Run: `git grep -n "store.Open(\|Open(path," internal/store`
|
||||
For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go
|
||||
git commit -m "feat(store): envelope DEK with admin/agent wrap slots
|
||||
|
||||
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
|
||||
Unlock loads it from the role's slot (admin slot has no agent fallback).
|
||||
s.key becomes the DEK, so account/mail crypto is unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: cli — role routing, openStore(role), init bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/run.go` (openStore, command routing, agent call sites)
|
||||
- Modify: `internal/cli/admin.go` (4 `openStore()` call sites)
|
||||
- Modify: `internal/cli/interactive.go` (`runInit` bootstrap)
|
||||
- Modify: `internal/cli/admin_test.go` (`adminEnv` helper)
|
||||
- Modify: `internal/cli/run_test.go` (b64 helpers, gating test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2).
|
||||
- Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`.
|
||||
|
||||
- [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`**
|
||||
|
||||
Replace the current `openStore` (lines ~28–39) with:
|
||||
|
||||
```go
|
||||
// commandRole maps a command to the privilege it requires. Admin commands
|
||||
// mutate configuration or expose oversight data; everything else is agent.
|
||||
func commandRole(cmd string) store.Role {
|
||||
switch cmd {
|
||||
case "account", "whitelist", "config", "audit":
|
||||
return store.RoleAdmin
|
||||
default: // list, get, search, ack, send, doctor
|
||||
return store.RoleAgent
|
||||
}
|
||||
}
|
||||
|
||||
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
|
||||
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
|
||||
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
|
||||
func openStore(role store.Role) (*store.Store, error) {
|
||||
adminKey, adminErr := crypto.AdminKeyFromEnv()
|
||||
agentKey, agentErr := crypto.AgentKeyFromEnv()
|
||||
|
||||
switch role {
|
||||
case store.RoleAdmin:
|
||||
if adminErr != nil {
|
||||
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
|
||||
}
|
||||
case store.RoleAgent:
|
||||
if agentErr != nil && adminErr != nil {
|
||||
return nil, agentErr // "EMCLI_KEY is not set"
|
||||
}
|
||||
}
|
||||
|
||||
path, err := store.DefaultDBPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.Unlock(role, adminKey, agentKey); err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
```
|
||||
|
||||
Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present).
|
||||
|
||||
- [ ] **Step 2: Update the three agent call sites in `run.go`**
|
||||
|
||||
- `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)`
|
||||
- `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)`
|
||||
- `runSend` (line ~252): `st, err := openStore(store.RoleAgent)`
|
||||
|
||||
- [ ] **Step 3: Update the four admin call sites in `admin.go`**
|
||||
|
||||
In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it).
|
||||
|
||||
- [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`**
|
||||
|
||||
Replace the body of `runInit` (lines ~75–97) with:
|
||||
|
||||
```go
|
||||
func runInit(args []string, out, errOut io.Writer) int {
|
||||
if len(args) > 0 && helpRequested(args[0]) {
|
||||
printCmdUsage(out, "init")
|
||||
return 0
|
||||
}
|
||||
adminKey, err := crypto.AdminKeyFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
agentKey, err := crypto.AgentKeyFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
path, err := store.DefaultDBPath()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
||||
_ = st.SetSetting("audit_retention_days", "90")
|
||||
}
|
||||
accs, _ := st.ListAccounts()
|
||||
if len(accs) > 0 {
|
||||
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
|
||||
} else {
|
||||
fmt.Fprintln(out, "Initializing emcli — add your first account.")
|
||||
}
|
||||
return addInteractive(st, tui.Fields{}, out, errOut)
|
||||
}
|
||||
```
|
||||
|
||||
Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported).
|
||||
|
||||
- [ ] **Step 5: Update test helpers in `admin_test.go`**
|
||||
|
||||
Replace `adminEnv` (lines ~14–20) with a version that sets both keys AND seeds the wrap slots:
|
||||
|
||||
```go
|
||||
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
|
||||
func adminEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
|
||||
st, err := store.Open(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported).
|
||||
|
||||
- [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`**
|
||||
|
||||
Add next to `b64Key` (line ~53):
|
||||
|
||||
```go
|
||||
func b64AgentKey() string {
|
||||
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
|
||||
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||
}
|
||||
```
|
||||
|
||||
In `TestRunVersionIsJSONForAgentButTextHere` (lines ~21–33), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell:
|
||||
|
||||
```go
|
||||
t.Setenv("EMCLI_KEY", "")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
code := Run([]string{"account", "list"}, &out, &errOut)
|
||||
```
|
||||
|
||||
The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`.
|
||||
|
||||
- [ ] **Step 7: Build and run the full cli suite**
|
||||
|
||||
Run: `go build ./... && go test ./internal/cli/... -race`
|
||||
Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it.
|
||||
|
||||
- [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`**
|
||||
|
||||
```go
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
func TestCommandRole(t *testing.T) {
|
||||
admin := []string{"account", "whitelist", "config", "audit"}
|
||||
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
|
||||
for _, c := range admin {
|
||||
if commandRole(c) != store.RoleAdmin {
|
||||
t.Errorf("%s should be admin", c)
|
||||
}
|
||||
}
|
||||
for _, c := range agent {
|
||||
if commandRole(c) != store.RoleAgent {
|
||||
t.Errorf("%s should be agent", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
|
||||
// A human holding only the admin key can still run agent commands
|
||||
// (admin is a superset → agent-role unlock falls back to the admin slot).
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
st, _ := store.Open(db)
|
||||
ak, _ := crypto.AdminKeyFromEnv()
|
||||
gk, _ := crypto.AgentKeyFromEnv()
|
||||
st.InitKeys(ak, gk)
|
||||
st.Close()
|
||||
|
||||
// Only the admin key now; agent command must still open the store.
|
||||
t.Setenv("EMCLI_KEY", "")
|
||||
s2, err := openStore(store.RoleAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("agent role with only admin key should open: %v", err)
|
||||
}
|
||||
s2.Close()
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \
|
||||
internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go
|
||||
git commit -m "feat(cli): two-key role routing + init bootstrap
|
||||
|
||||
openStore(role) selects the DEK wrap slot; admin commands require
|
||||
EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both
|
||||
slots from both keys. Test helpers seed the wrap slots.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: headline security-invariant test
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/cli/security_invariant_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 2–3).
|
||||
|
||||
- [ ] **Step 1: Write the invariant test**
|
||||
|
||||
```go
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
func dbBytes(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read db: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
|
||||
// command, and the DB must be unchanged after it tries.
|
||||
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
|
||||
st, _ := store.Open(db)
|
||||
ak, _ := crypto.AdminKeyFromEnv()
|
||||
gk, _ := crypto.AgentKeyFromEnv()
|
||||
if err := st.InitKeys(ak, gk); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
|
||||
// Simulate the agent's environment: admin key absent.
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
|
||||
before := dbBytes(t, db)
|
||||
adminAttempts := [][]string{
|
||||
{"account", "list"},
|
||||
{"config", "set", "audit_retention_days", "30"},
|
||||
{"audit"},
|
||||
}
|
||||
for _, args := range adminAttempts {
|
||||
code, out, errOut := run(t, args...)
|
||||
if code == 0 {
|
||||
t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(before, dbBytes(t, db)) {
|
||||
t.Fatal("DB changed despite all admin commands being refused")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it**
|
||||
|
||||
Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race`
|
||||
Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.)
|
||||
|
||||
- [ ] **Step 3: Full suite + vet**
|
||||
|
||||
Run: `go vet ./... && go test ./... -race`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/cli/security_invariant_test.go
|
||||
git commit -m "test(cli): prove agent key cannot run admin commands
|
||||
|
||||
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
|
||||
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `USER-MANUAL.md`
|
||||
- Modify: `specifications/SPEC.md`
|
||||
- Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup)
|
||||
|
||||
**Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read.
|
||||
|
||||
- [ ] **Step 1: README "Getting started" — two keys**
|
||||
|
||||
Replace the single-key export block with:
|
||||
|
||||
```bash
|
||||
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
|
||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
|
||||
emcli init # writes both wrap slots
|
||||
emcli doctor # confirm connect/auth (agent key is enough)
|
||||
```
|
||||
|
||||
Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."*
|
||||
|
||||
- [ ] **Step 2: USER-MANUAL — key model + role table**
|
||||
|
||||
Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow.
|
||||
|
||||
- [ ] **Step 3: SPEC §4/§5 — enforced trust boundary**
|
||||
|
||||
In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`).
|
||||
|
||||
- [ ] **Step 4: skill docs — agent gets only EMCLI_KEY**
|
||||
|
||||
In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/`
|
||||
Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then:
|
||||
|
||||
```bash
|
||||
git add README.md USER-MANUAL.md specifications/SPEC.md skills/
|
||||
git commit -m "docs: document two-key privilege model
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed by plan author)
|
||||
|
||||
**Spec coverage:**
|
||||
- Two env vars / role model → Tasks 1, 3. ✓
|
||||
- Envelope DEK + two wrap slots → Task 2. ✓
|
||||
- `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓
|
||||
- Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓
|
||||
- Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓
|
||||
- Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓
|
||||
- `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓
|
||||
- No migration / no version gate → no such code added. ✓
|
||||
- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓
|
||||
- Existing-test fallout (helpers) → Task 3 Steps 5–6. ✓
|
||||
- Headline invariant test → Task 4. ✓
|
||||
- Docs → Task 5. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓
|
||||
|
||||
**Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓
|
||||
@@ -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).
|
||||
+32
-10
@@ -11,13 +11,17 @@ import (
|
||||
)
|
||||
|
||||
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
||||
func runAccount(args []string, out, errOut io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(errOut, "usage: emcli account <add|list>")
|
||||
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
printCmdUsage(out, "account")
|
||||
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
||||
if len(args) > 0 {
|
||||
return 0 // explicit --help
|
||||
}
|
||||
return 2
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
@@ -187,13 +191,20 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
|
||||
}
|
||||
|
||||
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
||||
func runConfig(args []string, out, errOut io.Writer) int {
|
||||
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
printCmdUsage(out, "config")
|
||||
if len(args) > 0 {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
|
||||
return 2
|
||||
}
|
||||
sub, key := args[0], args[1]
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
@@ -235,7 +246,11 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
|
||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
||||
func runAudit(args []string, out, errOut io.Writer) int {
|
||||
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
if len(args) > 0 && helpRequested(args[0]) {
|
||||
printCmdUsage(out, "audit")
|
||||
return 0
|
||||
}
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
|
||||
return 2
|
||||
@@ -247,7 +262,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
@@ -261,7 +276,14 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
|
||||
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
||||
func runWhitelist(args []string, out, errOut io.Writer) int {
|
||||
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
printCmdUsage(out, "whitelist")
|
||||
if len(args) > 0 {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||
return 2
|
||||
@@ -279,7 +301,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
||||
fmt.Fprintln(errOut, "--account is required")
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -7,15 +7,28 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
|
||||
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
|
||||
func adminEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_KEY", b64Key())
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
|
||||
st, err := store.Open(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
||||
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
|
||||
t.Fatalf("edit failed: %s", e)
|
||||
}
|
||||
st, err := store.Open(db, mustKey())
|
||||
st, err := store.Open(db)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||
if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil {
|
||||
t.Fatalf("Unlock: %v", err)
|
||||
}
|
||||
got, err := st.GetAccount("ed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
@@ -93,11 +111,14 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuditListCoreRenders(t *testing.T) {
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
|
||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
|
||||
@@ -111,5 +132,3 @@ func TestAuditListCoreRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
|
||||
func mustKey() []byte { return make([]byte, 32) }
|
||||
|
||||
@@ -58,10 +58,13 @@ func testKey() []byte {
|
||||
|
||||
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
_, err = st.AddAccount(store.Account{
|
||||
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
|
||||
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
for _, a := range accounts {
|
||||
if _, err := st.AddAccount(a); err != nil {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type cmdHelp struct {
|
||||
name string
|
||||
synopsis string
|
||||
summary string
|
||||
}
|
||||
|
||||
// agentCmds emit machine-readable JSON; adminCmds are human-readable.
|
||||
var agentCmds = []cmdHelp{
|
||||
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
|
||||
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
|
||||
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
|
||||
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
|
||||
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
|
||||
}
|
||||
|
||||
var adminCmds = []cmdHelp{
|
||||
{"init", "init", "Create the database and add the first account (interactive)."},
|
||||
{"account", "account <add|edit|remove|list> [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."},
|
||||
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."},
|
||||
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."},
|
||||
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."},
|
||||
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."},
|
||||
{"version", "version", "Print the emcli version."},
|
||||
{"help", "help [command]", "Show this help, or detailed usage for one command."},
|
||||
}
|
||||
|
||||
func helpIndex() map[string]cmdHelp {
|
||||
m := make(map[string]cmdHelp, len(agentCmds)+len(adminCmds))
|
||||
for _, c := range append(append([]cmdHelp{}, agentCmds...), adminCmds...) {
|
||||
m[c.name] = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// helpRequested reports whether an argument is a help flag/word.
|
||||
func helpRequested(s string) bool {
|
||||
return s == "help" || s == "-h" || s == "--help"
|
||||
}
|
||||
|
||||
// printMainHelp writes the top-level command catalogue.
|
||||
func printMainHelp(w io.Writer) {
|
||||
fmt.Fprint(w, "emcli — guard-railed email gateway for agents\n\n")
|
||||
fmt.Fprint(w, "Usage:\n emcli <command> [flags]\n\n")
|
||||
fmt.Fprint(w, "Agent commands (machine-readable JSON on stdout):\n")
|
||||
for _, c := range agentCmds {
|
||||
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nAdmin commands (human-readable):\n")
|
||||
for _, c := range adminCmds {
|
||||
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n")
|
||||
fmt.Fprint(w, "\nEnvironment:\n")
|
||||
fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n")
|
||||
fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n")
|
||||
}
|
||||
|
||||
// printCmdUsage writes "Usage: emcli <synopsis>" and the summary for one command.
|
||||
func printCmdUsage(w io.Writer, name string) {
|
||||
if h, ok := helpIndex()[name]; ok {
|
||||
fmt.Fprintf(w, "Usage: emcli %s\n\n%s\n", h.synopsis, h.summary)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Usage: emcli %s\n", name)
|
||||
}
|
||||
|
||||
// usageFlags makes a flag set print the command's synopsis/summary followed by
|
||||
// its flags whenever flag prints usage (on -h/--help or a flag error).
|
||||
func usageFlags(fs *flag.FlagSet, name string, w io.Writer) {
|
||||
fs.Usage = func() {
|
||||
printCmdUsage(w, name)
|
||||
fmt.Fprintln(w, "\nFlags:")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainHelpListsAllCommands(t *testing.T) {
|
||||
// help / --help / -h / no-args all print the command catalogue, exit 0,
|
||||
// and require no EMCLI_KEY (help must work before any DB access).
|
||||
for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}, {}} {
|
||||
code, out, errOut := run(t, args...)
|
||||
text := out + errOut
|
||||
if code != 0 {
|
||||
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Usage", "list", "get", "search", "ack", "send",
|
||||
"account", "whitelist", "config", "audit", "doctor", "version",
|
||||
"EMCLI_KEY", "EMCLI_DB",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("%v: help missing %q\n%s", args, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpForSpecificCommand(t *testing.T) {
|
||||
code, out, errOut := run(t, "help", "send")
|
||||
text := out + errOut
|
||||
if code != 0 {
|
||||
t.Fatalf("help send exit=%d", code)
|
||||
}
|
||||
if !strings.Contains(text, "Usage: emcli send") || !strings.Contains(text, "--to") {
|
||||
t.Fatalf("help send missing synopsis:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHelpDoesNotEmitJSON(t *testing.T) {
|
||||
// `list --help` must NOT print a JSON envelope on stdout (an agent parses
|
||||
// stdout) and must exit 0 — even with no EMCLI_KEY set.
|
||||
code, out, errOut := run(t, "list", "--help")
|
||||
if code != 0 {
|
||||
t.Fatalf("list --help exit=%d (out=%q err=%q)", code, out, errOut)
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
t.Fatalf("agent help must keep stdout clean, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(errOut, "Usage: emcli list") || !strings.Contains(errOut, "--account") {
|
||||
t.Fatalf("list --help should print usage+flags on stderr:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendHelpExitsZero(t *testing.T) {
|
||||
code, _, errOut := run(t, "send", "--help")
|
||||
if code != 0 || !strings.Contains(errOut, "--to") {
|
||||
t.Fatalf("send --help: code=%d err=%q", code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCommandHelpExitsZero(t *testing.T) {
|
||||
for _, c := range []string{"account", "whitelist", "config", "audit", "doctor"} {
|
||||
code, out, errOut := run(t, c, "--help")
|
||||
text := out + errOut
|
||||
if code != 0 {
|
||||
t.Fatalf("%s --help exit=%d\n%s", c, code, text)
|
||||
}
|
||||
if !strings.Contains(text, "Usage: emcli "+c) {
|
||||
t.Fatalf("%s --help missing usage line:\n%s", c, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||
)
|
||||
@@ -70,15 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// runInit creates/opens the DB and adds the first account via the TUI form,
|
||||
// seeding a default audit retention if unset.
|
||||
// runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
|
||||
// account via the TUI form, seeding a default audit retention if unset.
|
||||
func runInit(args []string, out, errOut io.Writer) int {
|
||||
st, err := openStore()
|
||||
if len(args) > 0 && helpRequested(args[0]) {
|
||||
printCmdUsage(out, "init")
|
||||
return 0
|
||||
}
|
||||
adminKey, err := crypto.AdminKeyFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
agentKey, err := crypto.AgentKeyFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
path, err := store.DefaultDBPath()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
||||
_ = st.SetSetting("audit_retention_days", "90")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
func TestCommandRole(t *testing.T) {
|
||||
admin := []string{"account", "whitelist", "config", "audit"}
|
||||
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
|
||||
for _, c := range admin {
|
||||
if commandRole(c) != store.RoleAdmin {
|
||||
t.Errorf("%s should be admin", c)
|
||||
}
|
||||
}
|
||||
for _, c := range agent {
|
||||
if commandRole(c) != store.RoleAgent {
|
||||
t.Errorf("%s should be agent", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
|
||||
// A human holding only the admin key can still run agent commands
|
||||
// (admin is a superset → agent-role unlock falls back to the admin slot).
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
st, _ := store.Open(db)
|
||||
ak, _ := crypto.AdminKeyFromEnv()
|
||||
gk, _ := crypto.AgentKeyFromEnv()
|
||||
st.InitKeys(ak, gk)
|
||||
st.Close()
|
||||
|
||||
// Only the admin key now; agent command must still open the store.
|
||||
t.Setenv("EMCLI_KEY", "")
|
||||
s2, err := openStore(store.RoleAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("agent role with only admin key should open: %v", err)
|
||||
}
|
||||
s2.Close()
|
||||
}
|
||||
+72
-22
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -24,17 +25,48 @@ func realMailer(acc store.Account) (Mailer, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// openStore loads the key and opens the DB, returning a human-readable error string.
|
||||
func openStore() (*store.Store, error) {
|
||||
key, err := crypto.KeyFromEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// commandRole maps a command to the privilege it requires. Admin commands
|
||||
// mutate configuration or expose oversight data; everything else is agent.
|
||||
func commandRole(cmd string) store.Role {
|
||||
switch cmd {
|
||||
case "account", "whitelist", "config", "audit":
|
||||
return store.RoleAdmin
|
||||
default: // list, get, search, ack, send, doctor
|
||||
return store.RoleAgent
|
||||
}
|
||||
}
|
||||
|
||||
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
|
||||
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
|
||||
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
|
||||
func openStore(role store.Role) (*store.Store, error) {
|
||||
adminKey, adminErr := crypto.AdminKeyFromEnv()
|
||||
agentKey, agentErr := crypto.AgentKeyFromEnv()
|
||||
|
||||
switch role {
|
||||
case store.RoleAdmin:
|
||||
if adminErr != nil {
|
||||
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
|
||||
}
|
||||
case store.RoleAgent:
|
||||
if agentErr != nil && adminErr != nil {
|
||||
return nil, agentErr // "EMCLI_KEY is not set"
|
||||
}
|
||||
}
|
||||
|
||||
path, err := store.DefaultDBPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.Open(path, key)
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.Unlock(role, adminKey, agentKey); err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func realSender(acc store.Account, m mail.OutgoingMessage) error {
|
||||
@@ -67,14 +99,18 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
|
||||
}
|
||||
|
||||
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
|
||||
func runDoctor(args []string, out, errOut io.Writer) int {
|
||||
func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, "doctor", errOut)
|
||||
account := fs.String("account", "", "check only this account")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
@@ -89,26 +125,32 @@ func runDoctor(args []string, out, errOut io.Writer) int {
|
||||
|
||||
// Run routes a command line and returns an exit code.
|
||||
func Run(args []string, out, errOut io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(errOut, "emcli: no command given")
|
||||
return 2
|
||||
if len(args) == 0 || helpRequested(args[0]) {
|
||||
// `emcli`, `emcli help`, `emcli -h`, `emcli --help`, and `emcli help <cmd>`.
|
||||
if len(args) >= 2 {
|
||||
printCmdUsage(out, args[1])
|
||||
} else {
|
||||
printMainHelp(out)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
cmd, rest := args[0], args[1:]
|
||||
role := commandRole(cmd)
|
||||
switch cmd {
|
||||
case "list", "get", "search", "ack":
|
||||
return runAgent(cmd, rest, out, errOut)
|
||||
return runAgent(cmd, rest, role, out, errOut)
|
||||
case "send":
|
||||
return runSend(rest, out, errOut)
|
||||
return runSend(rest, role, out, errOut)
|
||||
case "account":
|
||||
return runAccount(rest, out, errOut)
|
||||
return runAccount(rest, role, out, errOut)
|
||||
case "whitelist":
|
||||
return runWhitelist(rest, out, errOut)
|
||||
return runWhitelist(rest, role, out, errOut)
|
||||
case "config":
|
||||
return runConfig(rest, out, errOut)
|
||||
return runConfig(rest, role, out, errOut)
|
||||
case "audit":
|
||||
return runAudit(rest, out, errOut)
|
||||
return runAudit(rest, role, out, errOut)
|
||||
case "doctor":
|
||||
return runDoctor(rest, out, errOut)
|
||||
return runDoctor(rest, role, out, errOut)
|
||||
case "init":
|
||||
return runInit(rest, out, errOut)
|
||||
default:
|
||||
@@ -118,9 +160,10 @@ func Run(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
|
||||
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
|
||||
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
||||
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
|
||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, cmd, errOut)
|
||||
account := fs.String("account", "", "account name")
|
||||
folder := fs.String("folder", "INBOX", "folder/mailbox")
|
||||
onlyNew := fs.Bool("new", false, "only new (unacked) messages")
|
||||
@@ -135,6 +178,9 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
||||
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
|
||||
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0 // usage already printed to stderr; help isn't an error
|
||||
}
|
||||
_ = Failure(CodeUsage, err.Error()).Write(out)
|
||||
return 2
|
||||
}
|
||||
@@ -145,7 +191,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||
return 1
|
||||
@@ -210,9 +256,10 @@ func (s *stringSlice) Set(v string) error {
|
||||
}
|
||||
|
||||
// runSend handles the `send` agent command (JSON envelope output).
|
||||
func runSend(args []string, out, errOut io.Writer) int {
|
||||
func runSend(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
usageFlags(fs, "send", errOut)
|
||||
account := fs.String("account", "", "account name")
|
||||
var to, cc, bcc, attach stringSlice
|
||||
fs.Var(&to, "to", "recipient (repeatable / comma-separated)")
|
||||
@@ -224,6 +271,9 @@ func runSend(args []string, out, errOut io.Writer) int {
|
||||
replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)")
|
||||
folder := fs.String("folder", "INBOX", "folder of the reply source")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
_ = Failure(CodeUsage, err.Error()).Write(out)
|
||||
return 2
|
||||
}
|
||||
@@ -231,7 +281,7 @@ func runSend(args []string, out, errOut io.Writer) int {
|
||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||
return 1
|
||||
|
||||
@@ -23,12 +23,13 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
|
||||
// proving the key check happens before any DB work.
|
||||
var out, errOut bytes.Buffer
|
||||
t.Setenv("EMCLI_KEY", "")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
code := Run([]string{"account", "list"}, &out, &errOut)
|
||||
if code == 0 {
|
||||
t.Fatal("missing EMCLI_KEY must fail")
|
||||
}
|
||||
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") {
|
||||
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String())
|
||||
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
|
||||
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +55,8 @@ func b64Key() string {
|
||||
// 32 zero bytes, base64.
|
||||
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
}
|
||||
|
||||
func b64AgentKey() string {
|
||||
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
|
||||
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
func dbBytes(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read db: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
|
||||
// command, and the DB must be unchanged after it tries.
|
||||
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
|
||||
st, err := store.Open(db)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
ak, err := crypto.AdminKeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminKeyFromEnv: %v", err)
|
||||
}
|
||||
gk, err := crypto.AgentKeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("AgentKeyFromEnv: %v", err)
|
||||
}
|
||||
if err := st.InitKeys(ak, gk); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
|
||||
// Simulate the agent's environment: admin key absent.
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
|
||||
before := dbBytes(t, db)
|
||||
adminAttempts := [][]string{
|
||||
{"account", "list"},
|
||||
{"config", "set", "audit_retention_days", "30"},
|
||||
{"audit"},
|
||||
}
|
||||
for _, args := range adminAttempts {
|
||||
code, out, errOut := run(t, args...)
|
||||
if code == 0 {
|
||||
t.Errorf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(before, dbBytes(t, db)) {
|
||||
t.Fatal("DB changed despite all admin commands being refused")
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,13 @@ import (
|
||||
// mailer (for reply-to). The named account is created per the supplied template.
|
||||
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if _, err := st.AddAccount(acc); err != nil {
|
||||
t.Fatalf("AddAccount: %v", err)
|
||||
|
||||
+24
-11
@@ -1,4 +1,4 @@
|
||||
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
|
||||
// Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
|
||||
package crypto
|
||||
|
||||
import (
|
||||
@@ -7,28 +7,41 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoKey = errors.New("EMCLI_KEY is not set")
|
||||
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes")
|
||||
)
|
||||
|
||||
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
|
||||
func KeyFromEnv() ([]byte, error) {
|
||||
raw := os.Getenv("EMCLI_KEY")
|
||||
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
|
||||
// named environment variable. Errors name the variable so callers get a
|
||||
// role-appropriate message.
|
||||
func keyFromEnv(varName string) ([]byte, error) {
|
||||
raw := os.Getenv(varName)
|
||||
if raw == "" {
|
||||
return nil, ErrNoKey
|
||||
return nil, fmt.Errorf("%s is not set", varName)
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, ErrBadKey
|
||||
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
|
||||
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
|
||||
|
||||
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
|
||||
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
|
||||
|
||||
// NewDEK returns a fresh random 32-byte data-encryption key.
|
||||
func NewDEK() ([]byte, error) {
|
||||
dek := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dek, nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package crypto
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -50,20 +51,38 @@ func TestOpenWrongKeyFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnv(t *testing.T) {
|
||||
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey()))
|
||||
k, err := KeyFromEnv()
|
||||
if err != nil || len(k) != 32 {
|
||||
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err)
|
||||
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
|
||||
good := base64.StdEncoding.EncodeToString(testKey())
|
||||
|
||||
t.Setenv("EMCLI_KEY", good)
|
||||
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
|
||||
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
|
||||
}
|
||||
t.Setenv("EMCLI_ADMIN_KEY", good)
|
||||
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
|
||||
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
|
||||
}
|
||||
|
||||
t.Setenv("EMCLI_KEY", "")
|
||||
if _, err := KeyFromEnv(); err != ErrNoKey {
|
||||
t.Fatalf("empty key: want ErrNoKey, got %v", err)
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
if _, err := AdminKeyFromEnv(); err == nil ||
|
||||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
|
||||
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
|
||||
if _, err := KeyFromEnv(); err != ErrBadKey {
|
||||
t.Fatalf("short key: want ErrBadKey, got %v", err)
|
||||
if _, err := AgentKeyFromEnv(); err == nil ||
|
||||
!strings.Contains(err.Error(), "32 bytes") {
|
||||
t.Fatalf("short key: want length error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDEKIsRandom32(t *testing.T) {
|
||||
a, err := NewDEK()
|
||||
if err != nil || len(a) != 32 {
|
||||
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
|
||||
}
|
||||
b, _ := NewDEK()
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two DEKs must differ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
)
|
||||
|
||||
// Role selects which DEK wrap slot a command may unlock.
|
||||
type Role int
|
||||
|
||||
const (
|
||||
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
|
||||
RoleAdmin // all commands; uses dek_wrap_admin ONLY
|
||||
)
|
||||
|
||||
const (
|
||||
settingDEKWrapAdmin = "dek_wrap_admin"
|
||||
settingDEKWrapAgent = "dek_wrap_agent"
|
||||
)
|
||||
|
||||
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
|
||||
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
|
||||
|
||||
// dbPath returns the file path SQLite opened (used by tests to re-open).
|
||||
func (s *Store) dbPath() string {
|
||||
var p string
|
||||
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
|
||||
return p
|
||||
}
|
||||
|
||||
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
|
||||
// both wrap slots, and unlocks the store. If the slots already exist it does
|
||||
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
|
||||
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
|
||||
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
|
||||
// Already initialised: the DEK and both wrap slots already exist, so the
|
||||
// agent key is not consumed here. Only the admin key is used to unlock the
|
||||
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
|
||||
return s.Unlock(RoleAdmin, adminKey, nil)
|
||||
}
|
||||
dek, err := crypto.NewDEK()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapAdmin, err := crypto.Seal(adminKey, dek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapAgent, err := crypto.Seal(agentKey, dek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.key = dek
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
|
||||
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
|
||||
// falls back to the admin slot only when no agent key is supplied.
|
||||
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
|
||||
switch role {
|
||||
case RoleAdmin:
|
||||
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
||||
case RoleAgent:
|
||||
if len(agentKey) > 0 {
|
||||
return s.unlockSlot(settingDEKWrapAgent, agentKey)
|
||||
}
|
||||
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
||||
default:
|
||||
return fmt.Errorf("unknown role %d", role)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
|
||||
if len(kek) == 0 {
|
||||
return ErrLocked
|
||||
}
|
||||
enc, err := s.GetSetting(settingKey)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrLocked
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
|
||||
}
|
||||
dek, err := crypto.Open(kek, blob)
|
||||
if err != nil {
|
||||
return errors.New("wrong key for this DB")
|
||||
}
|
||||
s.key = dek
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func k(b byte) []byte {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = b
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func tempStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
st := tempStore(t)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
// Seal a password under the DEK that InitKeys set.
|
||||
if _, err := st.AddAccount(Account{
|
||||
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
|
||||
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddAccount: %v", err)
|
||||
}
|
||||
|
||||
// Re-open and unlock via the AGENT slot.
|
||||
path := st.dbPath()
|
||||
st.Close()
|
||||
st2, _ := Open(path)
|
||||
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
|
||||
t.Fatalf("Unlock(agent): %v", err)
|
||||
}
|
||||
got, err := st2.GetAccount("a")
|
||||
if err != nil || got.Password != "pw" {
|
||||
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
|
||||
}
|
||||
st2.Close()
|
||||
|
||||
// Unlock via the ADMIN slot recovers the same DEK.
|
||||
st3, _ := Open(path)
|
||||
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
|
||||
t.Fatalf("Unlock(admin): %v", err)
|
||||
}
|
||||
got3, err := st3.GetAccount("a")
|
||||
if err != nil || got3.Password != "pw" {
|
||||
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
|
||||
}
|
||||
st3.Close()
|
||||
}
|
||||
|
||||
func TestUnlockWrongKeyFails(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := st.dbPath()
|
||||
st.Close()
|
||||
st2, _ := Open(path)
|
||||
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
|
||||
t.Fatal("Unlock with wrong admin key must fail")
|
||||
}
|
||||
st2.Close()
|
||||
}
|
||||
|
||||
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// RoleAdmin must use the admin slot; passing the agent key as the admin
|
||||
// key must fail — there is no fallback to the agent slot.
|
||||
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
|
||||
t.Fatal("agent key must not unlock the admin slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
|
||||
st := tempStore(t)
|
||||
admin, agent := k(0xAA), k(0xBB)
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.AddAccount(Account{
|
||||
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
|
||||
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
|
||||
})
|
||||
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
|
||||
if err := st.InitKeys(admin, agent); err != nil {
|
||||
t.Fatalf("re-InitKeys: %v", err)
|
||||
}
|
||||
got, err := st.GetAccount("a")
|
||||
if err != nil || got.Password != "pw" {
|
||||
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ type Store struct {
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the DB at path and applies the schema.
|
||||
func Open(path string, key []byte) (*Store, error) {
|
||||
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
|
||||
// secret read/write.
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
@@ -39,7 +41,7 @@ func Open(path string, key []byte) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
s := &Store{db: db, key: key}
|
||||
s := &Store{db: db}
|
||||
if _, err := s.GetSetting("schema_version"); err != nil {
|
||||
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
|
||||
db.Close()
|
||||
|
||||
@@ -5,29 +5,25 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKey() []byte {
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = byte(i)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// openTemp opens a fresh store in a temp dir.
|
||||
// openTemp opens a fresh store in a temp dir and initialises keys so that
|
||||
// account tests (which do crypto) work without needing their own setup.
|
||||
func openTemp(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||
s, err := Open(p, testKey())
|
||||
s, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil {
|
||||
t.Fatalf("InitKeys: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||
s, err := Open(p, testKey())
|
||||
s, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatalf("first Open: %v", err)
|
||||
}
|
||||
@@ -38,7 +34,7 @@ func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||
s.Close()
|
||||
|
||||
// Re-open: must not error or duplicate.
|
||||
s2, err := Open(p, testKey())
|
||||
s2, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open: %v", err)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ func NewAccountForm(initial Fields, editing bool) AccountForm {
|
||||
if initial.IMAPSecurity == "" {
|
||||
initial.IMAPSecurity = "tls"
|
||||
}
|
||||
if initial.SMTPPort == "" {
|
||||
initial.SMTPPort = "465"
|
||||
}
|
||||
if initial.SMTPSecurity == "" {
|
||||
initial.SMTPSecurity = "tls"
|
||||
}
|
||||
|
||||
@@ -111,6 +111,17 @@ func TestFieldsFromAccountRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAccountFormDefaultsSMTPPort(t *testing.T) {
|
||||
f := NewAccountForm(Fields{}, false).collect()
|
||||
if f.SMTPPort != "465" {
|
||||
t.Fatalf("SMTP port should default to 465, got %q", f.SMTPPort)
|
||||
}
|
||||
// The other prefilled defaults must remain intact.
|
||||
if f.IMAPPort != "993" || f.Mode != "RO" || f.IMAPSecurity != "tls" || f.SMTPSecurity != "tls" {
|
||||
t.Fatalf("existing defaults regressed: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountFormSubmitValid(t *testing.T) {
|
||||
m := NewAccountForm(validFields(), false)
|
||||
// Enter submits; with valid fields the form completes.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# emcli — setup manual (for agents)
|
||||
|
||||
Read this **once**, when emcli isn't set up yet (the binary isn't installed, or you only have
|
||||
`SKILL.md` from being pointed at the repo). The recurring "how to read and send mail" lives in
|
||||
`SKILL.md` — don't reload this file for normal use.
|
||||
|
||||
## 1. Get this skill's files
|
||||
|
||||
This skill is a folder, not a single file: it ships `AGENTIC-MANUAL.md` (this file), a `scripts/`
|
||||
directory, and a `references/` directory. If you were pointed at the repository and have only
|
||||
`SKILL.md` in context, fetch what you need before proceeding.
|
||||
|
||||
Every `scripts/…` and `references/…` path mentioned anywhere in this skill is **relative to the
|
||||
skill directory**, whose raw base URL is:
|
||||
|
||||
```
|
||||
https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
|
||||
```
|
||||
|
||||
So a file that isn't already on disk can be fetched from `<base>/<path>`. The files are:
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | The lean, everyday usage guide |
|
||||
| `AGENTIC-MANUAL.md` | This setup guide (one-time) |
|
||||
| `scripts/install.sh` | Installs the emcli binary |
|
||||
| `references/commands.md` | Full agent command reference (flags, JSON shapes, error codes) |
|
||||
|
||||
To enumerate the directory programmatically (then recurse into `scripts` and `references`), use the
|
||||
Gitea contents API: `https://gitea.dcglab.co.uk/api/v1/repos/steve/emcli/contents/skills/emcli`.
|
||||
|
||||
## 2. Install the binary
|
||||
|
||||
First check whether it's already installed: `emcli version`. If that prints a version, skip to
|
||||
step 3.
|
||||
|
||||
If you have the skill files locally:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
If you only have this manual (pointed at the repo), fetch the installer first, then run it:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/scripts/install.sh -o emcli-install.sh
|
||||
bash emcli-install.sh
|
||||
```
|
||||
|
||||
`install.sh` detects your OS/arch, downloads the matching release binary, verifies its SHA-256
|
||||
checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), and confirms it runs.
|
||||
|
||||
**Installer options** (environment variables):
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `EMCLI_VERSION` | `v0.4.1` | Release tag to fetch |
|
||||
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
||||
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
||||
|
||||
**Build from source instead** (needs Go; the binary is CGO-free):
|
||||
|
||||
```bash
|
||||
git clone https://gitea.dcglab.co.uk/steve/emcli && cd emcli
|
||||
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
|
||||
```
|
||||
|
||||
## 3. Confirm the agent key is present
|
||||
|
||||
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
|
||||
`list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
|
||||
which the human holds — attempting admin commands with only `EMCLI_KEY` is refused by `emcli`.
|
||||
|
||||
For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
|
||||
|
||||
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
|
||||
- **Never** read, print, log, pass as an argument, or generate this value.
|
||||
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
|
||||
your orchestrator; I can't read or create it for you."
|
||||
|
||||
(For a human setting emcli up the first time: generate both keys with
|
||||
`head -c 32 /dev/urandom | base64` (once per key) and store them securely; then run `emcli init`
|
||||
with both keys exported. Account creation and other admin is the human's job — see the project's
|
||||
`USER-MANUAL.md`.)
|
||||
|
||||
## 4. Find the account(s)
|
||||
|
||||
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
|
||||
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
|
||||
configured accounts connect and authenticate:
|
||||
|
||||
```bash
|
||||
emcli doctor # all accounts
|
||||
emcli doctor --account gmail
|
||||
```
|
||||
|
||||
Just take the account name from the user and start with the workflow in `SKILL.md`.
|
||||
|
||||
## You're set up
|
||||
|
||||
Installed, key present, account name in hand → switch to `SKILL.md` for the everyday `list` / `get`
|
||||
/ `search` / `ack` / `send` workflow. You shouldn't need this manual again unless the binary goes
|
||||
missing.
|
||||
+24
-21
@@ -17,35 +17,36 @@ sets its exit code to match.
|
||||
|
||||
## Security model — read this first
|
||||
|
||||
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`. Account setup,
|
||||
passwords, whitelists, and config are the **user's** job (admin commands) — do not run or suggest
|
||||
running `account`, `whitelist`, `config`, `init`, or `doctor` unless the user explicitly asks you
|
||||
to help administer.
|
||||
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
|
||||
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
|
||||
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
|
||||
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
|
||||
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
|
||||
with a privilege error.
|
||||
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
|
||||
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
|
||||
missing, stop and tell the user (see Setup).
|
||||
missing, stop and tell the user (see "Files & first run").
|
||||
- **Some mail is intentionally invisible.** The user may restrict which senders you can see and who
|
||||
you can email. Blocked or filtered results are normal — handle them, don't try to work around
|
||||
them (see Enforcement).
|
||||
|
||||
## Setup (do this once per session, before the first command)
|
||||
## Files & first run
|
||||
|
||||
1. **Check the binary is available.** Run `emcli version`. If the command is not found, install it:
|
||||
This skill ships more than this file. Paths like `AGENTIC-MANUAL.md` and `references/commands.md`
|
||||
are relative to this skill's directory; if one isn't on disk, fetch it from the raw base URL + path:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
```
|
||||
```
|
||||
https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
|
||||
```
|
||||
|
||||
This downloads the binary from the project's releases and puts it on your PATH
|
||||
(`~/.local/bin` by default). See [references/install.md](references/install.md) for options.
|
||||
- **First-time setup** — installing the binary, the `EMCLI_KEY`, finding accounts: read
|
||||
**`AGENTIC-MANUAL.md`**. Only needed when emcli isn't set up yet.
|
||||
- **Full command detail** — every flag, JSON shapes, error codes: `references/commands.md`.
|
||||
|
||||
2. **Check the key is present.** Confirm the `EMCLI_KEY` environment variable is set (e.g.
|
||||
`test -n "$EMCLI_KEY"`). **Do not print its value.** If it is empty, do not proceed — tell the
|
||||
user: "emcli needs the EMCLI_KEY environment variable set by your orchestrator; I can't read or
|
||||
create it for you."
|
||||
|
||||
3. **Find out which account(s) exist.** Ask the user for the account name (e.g. `gmail`, `work`),
|
||||
or, if permitted, run `emcli doctor` once to see configured accounts and that they connect.
|
||||
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
|
||||
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
|
||||
empty, tell the user their orchestrator must provide `EMCLI_KEY` (the agent key). Then get the
|
||||
account name from the user.
|
||||
|
||||
## How to read every result
|
||||
|
||||
@@ -124,7 +125,8 @@ Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
|
||||
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
|
||||
|
||||
**Full reference** (every flag, exact JSON shapes for each command, attachment encoding, error
|
||||
codes, and the enforcement rules): [references/commands.md](references/commands.md).
|
||||
codes, and the enforcement rules): `references/commands.md` — read it from disk, or fetch it from
|
||||
the raw base URL in "Files & first run" above if you don't have it locally.
|
||||
|
||||
## Enforcement awareness — work *with* the rules
|
||||
|
||||
@@ -142,5 +144,6 @@ The user configures these; you cannot change them and shouldn't try.
|
||||
- ✅ `get` to read, then `ack` only after you've truly processed a message.
|
||||
- ✅ Ask the user for the account name; keep bodies plain text.
|
||||
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
|
||||
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`init`) unless asked to help set up.
|
||||
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
|
||||
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
|
||||
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Installing the emcli binary
|
||||
|
||||
The skill's `scripts/install.sh` downloads a prebuilt binary from the project's release assets.
|
||||
|
||||
## Quick install
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
It detects your OS (`linux`/`darwin`/`windows`) and architecture (`amd64`/`arm64`), downloads the
|
||||
matching asset, verifies its SHA-256 checksum when a `checksums.txt` is published, makes it
|
||||
executable, and confirms it runs.
|
||||
|
||||
## Options (environment variables)
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `EMCLI_VERSION` | `v0.4.0` | Release tag to fetch |
|
||||
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
||||
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
||||
|
||||
Example — install a specific version to a system directory:
|
||||
|
||||
```bash
|
||||
EMCLI_VERSION=v0.4.0 EMCLI_INSTALL_DIR=/usr/local/bin bash scripts/install.sh
|
||||
```
|
||||
|
||||
## Release asset naming
|
||||
|
||||
The release publishes one binary per platform plus a checksum file:
|
||||
|
||||
```
|
||||
emcli_0.4.0_linux_amd64
|
||||
emcli_0.4.0_linux_arm64
|
||||
emcli_0.4.0_darwin_amd64
|
||||
emcli_0.4.0_darwin_arm64
|
||||
emcli_0.4.0_windows_amd64.exe
|
||||
checksums.txt # sha256, one "<sum> <asset>" line per asset
|
||||
```
|
||||
|
||||
> `v0.4.0` and these assets are placeholders until the first tagged release exists. Update
|
||||
> `EMCLI_VERSION` (or the default in `install.sh`) once a real release is cut.
|
||||
|
||||
## Building from source instead
|
||||
|
||||
If you have Go and prefer to build rather than download:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.dcglab.co.uk/steve/emcli
|
||||
cd emcli
|
||||
CGO_ENABLED=0 go build -o emcli ./cmd/emcli
|
||||
# then move ./emcli onto your PATH
|
||||
```
|
||||
|
||||
## After installing
|
||||
|
||||
`emcli` needs the `EMCLI_KEY` environment variable (a base64-encoded 32-byte AES key) to touch its
|
||||
database. For agent use, the **orchestrator provides this** — the agent should not generate or read
|
||||
it. A human setting up emcli for the first time generates one with
|
||||
`head -c 32 /dev/urandom | base64` and saves it securely. See the project User Manual for full admin
|
||||
setup.
|
||||
@@ -7,18 +7,17 @@
|
||||
# bash install.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# EMCLI_VERSION release tag to fetch (default: v0.4.0)
|
||||
# EMCLI_VERSION release tag to fetch (default: v0.4.1)
|
||||
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
||||
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
||||
#
|
||||
# NOTE: v0.4.0 and its release assets are placeholders until the first tagged
|
||||
# release is published. The asset naming below is the scheme the release will use:
|
||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.0_linux_amd64
|
||||
# Release assets follow this naming scheme:
|
||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.1_linux_amd64
|
||||
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${EMCLI_VERSION:-v0.4.0}"
|
||||
VERSION="${EMCLI_VERSION:-v0.4.1}"
|
||||
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
||||
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
|
||||
+53
-17
@@ -62,23 +62,50 @@ The binary is organized into independently testable packages:
|
||||
human-readable/TUI).
|
||||
|
||||
### Trust boundary
|
||||
- The agent invokes only the **agent commands** (Section 7.1).
|
||||
- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as
|
||||
an argument the agent constructs. The agent has no command that reveals secret values.
|
||||
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has
|
||||
no other path to the mail servers.
|
||||
|
||||
Two keys enforce a hard privilege split — this is not convention; it is structurally enforced by
|
||||
the DEK-wrapping scheme:
|
||||
|
||||
- **`EMCLI_ADMIN_KEY`** — base64-encoded 32-byte key held by the human operator. Authorises ALL
|
||||
commands. Admin commands unwrap the DEK from the `dek_wrap_admin` slot only; there is no fallback
|
||||
to the agent slot. A process holding only `EMCLI_KEY` cannot run admin commands.
|
||||
- **`EMCLI_KEY`** — base64-encoded 32-byte key supplied to the agent orchestrator. Authorises agent
|
||||
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
|
||||
a process with only the admin key can also run agent commands.
|
||||
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
|
||||
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
|
||||
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
||||
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
|
||||
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
|
||||
constructs. The agent has no command that reveals secret values.
|
||||
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
|
||||
path to the mail servers.
|
||||
|
||||
## 5. Configuration & secrets
|
||||
|
||||
- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If
|
||||
absent or malformed, every command that touches the DB fails closed with an error
|
||||
envelope; no plaintext fallback.
|
||||
- **Admin key:** `EMCLI_ADMIN_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for
|
||||
admin commands and for `init`. If absent or malformed when an admin command is attempted, the
|
||||
command fails with `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
||||
- **Agent key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for agent
|
||||
commands. If absent or malformed, every agent command fails closed with a `config` error envelope;
|
||||
no plaintext fallback. `EMCLI_ADMIN_KEY` is accepted as a fallback for agent commands when
|
||||
`EMCLI_KEY` is not set.
|
||||
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
|
||||
(`%AppData%\emcli\emcli.db` on Windows).
|
||||
- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a
|
||||
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains
|
||||
plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is
|
||||
surfaced as an error, never silently ignored.
|
||||
- **Envelope encryption (DEK):** `emcli init` generates a random data-encryption key (DEK) that
|
||||
protects all account secrets. The DEK is stored in the `settings` table sealed under both keys:
|
||||
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY` (AES-256-GCM).
|
||||
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY` (AES-256-GCM).
|
||||
The DEK is never written in cleartext. Admin commands unwrap from `dek_wrap_admin` only; agent
|
||||
commands unwrap from `dek_wrap_agent` (or `dek_wrap_admin` if only the admin key is present).
|
||||
There is no cross-slot fallback for admin commands — a holder of only `EMCLI_KEY` cannot unwrap
|
||||
the admin DEK slot.
|
||||
- **`init` idempotency:** re-running `emcli init` does not regenerate the DEK; the existing wrapped
|
||||
DEK rows are preserved.
|
||||
- **Field-level encryption:** secret columns are encrypted with the DEK using AES-256-GCM with a
|
||||
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains plaintext for
|
||||
debuggability. Decryption with the wrong key fails (GCM auth tag) and is surfaced as an error,
|
||||
never silently ignored.
|
||||
|
||||
Secret columns: account password, OAuth client secret, OAuth refresh token.
|
||||
|
||||
@@ -142,7 +169,9 @@ audit_log
|
||||
settings
|
||||
key TEXT PK
|
||||
value TEXT
|
||||
-- includes: audit_retention_days, schema_version
|
||||
-- includes: audit_retention_days, schema_version,
|
||||
-- dek_wrap_admin (DEK sealed under EMCLI_ADMIN_KEY),
|
||||
-- dek_wrap_agent (DEK sealed under EMCLI_KEY)
|
||||
```
|
||||
|
||||
Notes:
|
||||
@@ -216,8 +245,11 @@ command that advances read state is `ack`.
|
||||
|
||||
### 7.2 Admin commands (human-readable / TUI)
|
||||
|
||||
- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account,
|
||||
and runs OAuth consent if the account is OAuth2.
|
||||
Require `EMCLI_ADMIN_KEY`.
|
||||
|
||||
- **`emcli init`** — TUI flow: creates the DB (generating schema + DEK), adds the first account,
|
||||
and runs OAuth consent if the account is OAuth2. Requires both `EMCLI_ADMIN_KEY` and `EMCLI_KEY`
|
||||
(writes both DEK wrap slots). Idempotent — re-running does not regenerate the DEK.
|
||||
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
|
||||
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
|
||||
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
|
||||
@@ -225,8 +257,12 @@ command that advances read state is `ack`.
|
||||
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
|
||||
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
|
||||
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
|
||||
- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each
|
||||
account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics.
|
||||
|
||||
### 7.2a `doctor` — agent-role diagnostics
|
||||
|
||||
`doctor` is authorised by `EMCLI_KEY` (or `EMCLI_ADMIN_KEY`). It verifies the key is present and
|
||||
valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Prints
|
||||
human-readable diagnostics. Can be run by the agent or by a human; does not require admin privilege.
|
||||
|
||||
### 7.3 Defaults & limits
|
||||
- `list --limit` default: 50; maximum: 500.
|
||||
|
||||
Reference in New Issue
Block a user