Security
Security invariants
These rules are non-negotiable and apply to the entire codebase. They are enforced by importlinter contracts, integration tests, and the architecture itself — not just by convention.
- Loopback-only binding. The HTTP API binds exclusively to
127.0.0.1. Any public-reachable surface runs as a separate process limited to signed callback paths — concretely, the SeaTalk callback listener (see Channels & the public-reachable surface). - Secret plaintext never persists. Secrets are stored only as Fernet ciphertext in the
credentialstable; configuration stores credential references, not values. Plaintext exists in memory solely between decrypt and the spawn/header injection that consumes it — never in SQLite as plaintext, logs, audit, or any structured event. The Fernet master key is managed exclusively byinfrastructure/credentials/, the only place permitted to importkeyring. - Token + CORS on the REST API. Every management API call requires the
X-Coffer-Tokenheader. The daemon token lives in~/.coffer/daemon.jsonat mode0600. - Outbound HTTP has real paths today; SSRF-guarding is still scoped to the HTTP-transport MCP client. The daemon makes outbound calls now — embeddings to OpenAI-compatible providers,
git pushfor sync, and the Telegram/SeaTalk APIs (raw httpx to fixed hosts). The constitution requires that outbound HTTP go through a SSRF-guarded client; the gap that remains is the HTTP-transport MCP client, which still uses the MCP SDK's httpx client with no IP filtering. A hardened SSRF-guarded wrapper is planned for that client. Public-reachable surfaces run as a separate process limited to signed callback paths.
Threat model and trust boundaries
Coffer is a single-user, local-first tool. The trust model is correspondingly simple:
Trusted: the local user. Coffer assumes the person running the daemon is the owner of the machine. There is no multi-tenant model, no role-based access control, and no concept of an untrusted user sharing the same machine.
Defended against: two classes of attacker that exist even in a single-user local deployment:
A stray local process. Another process on the same machine — a malicious package installed in a project's
node_modules, a browser extension with local HTTP access — could attempt to read Coffer's database, call its management API, or exfiltrate registered secrets. The loopback binding plus token authentication raises the bar: a process must guess or steal the 256-bit random token to call any mutating endpoint. The token is not in any environment variable; it lives only in~/.coffer/daemon.json, which has mode0600(readable only by the owner).A malicious upstream MCP server configuration. A server registered with a carefully crafted
commandorurlmight attempt to reach internal network services (SSRF), exfiltrate credentials through environment variables, or write outside its working directory. The credential ref model (no literal secrets in config) and the staticenvreject-on-secret-regex check (which rejects anyenvvalue that looks like a token) are the current defences. A SSRF-guarded outbound HTTP client is a planned hardening per the constitution's invariant.
What Coffer does not defend against (in the default configuration): a privileged attacker who can read ~/.coffer/ directly, or a malicious Coffer binary. In the default mode the master key sits in a 0600 file beside the encrypted database — the same ~/.coffer/ boundary that already excluded a reader of that directory — so envelope encryption does not change this line. The opt-in keychain mode raises it: with the master key in the OS keychain, the ciphertext in ~/.coffer/coffer.db is useless to an attacker who exfiltrates the directory's contents without also unlocking the keychain. A compromised OS keychain (in keychain mode) and a malicious Coffer binary remain out of scope for a local-first developer tool.
Loopback-only HTTP binding
The daemon's FastAPI application binds its HTTP server to 127.0.0.1, not 0.0.0.0. This is a constitutional requirement, not a configuration option.
The practical effect: no request originating outside the local machine can reach the management API or the MCP protocol endpoint. A remote attacker who cannot first compromise the machine has no network path to Coffer. This makes the daemon safe to run persistently without a firewall rule — the OS rejects out-of-machine connections before they reach the application.
The one intentional unauthenticated endpoint is GET /api/v1/daemon/status. It is loopback-only and returns only lifecycle phase, version, port, and an aggregate upstream health summary — no secrets, no per-resource details, no audit data. It exists to let the CLI and the shim probe for daemon readiness before they have read the token from daemon.json.
Credentials: the encrypted store
Secrets are stored as Fernet ciphertext in the credentials table of ~/.coffer/coffer.db (envelope encryption). The plaintext value lives only in memory, between decrypt and the spawn/header injection that consumes it. The mechanism:
- Store: the user calls
POST /api/v1/credentialswith a ref and a secret value. The daemon Fernet-encrypts the value with the master key and writes only the ciphertext into thecredentialstable, recording acredential_setaudit event (no secret value in the details). The plaintext is never written anywhere on disk. - Reference: when registering an MCP server, the user specifies
credential_refs: { "SOME_ENV_VAR": "my-secret-ref" }in the config. This mapping — from env var name to a ref into the encrypted credential store — is stored inconfig_jsonin the database. The secret itself is not. - Materialise: at upstream-spawn time, the daemon reads the ciphertext for each
credential_refsentry, decrypts it with the master key, injects the plaintext into the subprocess environment (or request headers, for HTTP transport), and then spawns the process. The plaintext is in memory only for the duration of the spawn call; it is never written to a log, an audit entry, or a database column. - Delete: the user calls
DELETE /api/v1/credentials/{ref}. The daemon removes the row and records acredential_deletedaudit event (without the secret value in the details).
The master key
Envelope encryption means there is exactly one piece of secret material outside the database: the Fernet master key. It lives in exactly one of two places:
~/.coffer/master.key— a0600file beside the database. This is the default: zero keychain prompts. (Why: macOS pins keychain ACLs to the binary's cdhash, so every rebuild of the unsigned daemon re-prompted for every secret. A file-backed master key removes the prompts entirely. See ADR-015.)- The OS keychain (service
coffer, refmaster-key) — opt-in hardening via Settings → Security orcoffer credentials storage --set keychain. macOS may prompt once per daemon start. This is the mode that defends against offline exfiltration of~/.coffer/.
Resolution is file-first: the daemon looks for the file before the keychain. This makes relocation crash-safe — relocate moves only the master key and removes the old copy last, so an interrupted move always resolves back to a working state. The ciphertext in the credentials table is never touched by a relocation (the key moves; the data stays); switching storage modes does not re-encrypt.
A brand-new master key is generated only when the credentials table is empty. Ciphertext present with no resolvable key is a fatal, actionable startup error (MasterKeyMissing) — Coffer refuses to start rather than silently lose access to existing secrets.
Backup caveat
coffer.db now contains ciphertext. Restoring a backup of it requires the matching master.key file (or the keychain entry, in keychain mode). Back up the master key alongside the database, or a restored coffer.db is unreadable.
Legacy keychain migration
Before envelope encryption, secrets lived directly in the OS keychain. On startup the daemon runs a one-time, best-effort migration: legacy keychain secrets whose refs are cited by registered resources are read, encrypted into the credentials table, and audited per ref as credential_migrated. If the keychain is locked, the migration is skipped and retried on the next startup.
Absolute constraint
infrastructure/credentials/ is the only location in the entire codebase permitted to import keyring, and it does so only for the master key (keychain mode) and the legacy migration. This is enforced by an importlinter contract (Contract 4 in backend/pyproject.toml). Any PR that adds an import keyring anywhere else fails CI.
The StdioTransport config schema has a second layer of defence: its env field runs a regex check on every static environment variable value and rejects any value that looks like a token or secret (matches the token-detection regex). This catches cases where a user accidentally pastes a secret literal into the static env map instead of using credential_refs.
Channels & the public-reachable surface
The loopback-only invariant says a public-reachable surface runs as a separate process limited to signed callback paths. The SeaTalk callback listener is the concrete instantiation of that rule (spec 009, ADR-014). SeaTalk delivers events only by public webhook, so it is the one place Coffer accepts inbound traffic that originated off the machine — and it does so through a process the daemon never lets the network reach.
- Separate process, never the daemon. The listener is a daemon-spawned child that runs only while a SeaTalk channel is enabled. It serves exactly one route,
POST /seatalk/{channel}, on a loopback port (default8787, overridable viaCOFFER_CALLBACK_PORT). It holds no other state and can reach nothing but the daemon. The daemon itself stays loopback-only — it is never exposed. - Signature verification. Every callback POST carries a
Signatureheader that SeaTalk computes assha256(raw_body + signing_secret)(lowercase hex). The listener recomputes the same digest from the raw body and the channel's signing secret and compares it in constant time (hmac.compare_digest). An empty secret or empty signature never verifies — an empty secret would collapse the MAC tosha256(body), computable by anyone. The platform'sevent_verificationchallenge is answered inline; every other valid event is forwarded to the daemon over loopback carrying the daemon token. - The tunnel is the user's, the exposure is the listener's. The user points a tunnel (cloudflared/ngrok) at the listener's loopback port. Only that one signed callback path is reachable from the internet; the management API and MCP endpoint are not on the tunnel at all.
- Single-owner binding via pairing code. A channel is bound to exactly one owner through an 8-character single-use pairing code (unambiguous alphabet, 1-hour TTL, bounded guesses, memory-only) issued from the UI/CLI and sent to the bot from the owner's account. Everyone else is ignored silently; re-pairing replaces the binding. There is no user-id-entry path — pairing also proves the transport round-trip.
Secrets reach the listener the same way upstream MCP subprocesses get theirs: the signing secrets, the daemon URL, and the daemon token are injected into the child's environment at spawn, never written to disk. The spawn is recorded in the upstream-pids directory so a daemon crash leaves nothing behind — the startup orphan sweep reaps it. A daemon-token rotation respawns the listener (the token is baked into the child's env).
Sync security
Multi-machine sync (ADR-016) moves vault state between machines through a git repository the user owns. Its security rests on keeping the secret material out of that medium entirely:
- Ciphertext only over the medium. Credentials are exported as Fernet ciphertext blobs and travel through git as ciphertext — the git repo only ever holds undecryptable data. Even if the remote is a hosted GitHub repo, the vendor only ever holds ciphertext.
- The master key never enters the medium. The Fernet master key is bootstrapped onto each machine out-of-band, via
coffer sync key export/import— never committed, never pushed. This is what keeps the constitutional argument clean: the sync medium is not a system of record for any decryptable secret. credentials_lockeduntil the key is present. A machine that has pulled ciphertext but does not yet have the matching master key reportscredentials_lockedand refuses to spawn the affected resources. It never silently fails decryption.- Git runs as a subprocess with ambient credentials. Outbound git is a real network egress, but it respects the loopback-only posture: git runs as a subprocess using the user's own ambient git credentials, not Coffer's HTTP client. Coffer never injects or stores the git remote's credentials.
Token authentication
At daemon startup, the daemon generates a 256-bit URL-safe random token (secrets.token_urlsafe(32)), writes {"pid": ..., "port": ..., "token": "<token>"} to ~/.coffer/daemon.json with mode 0600, and sets the active token via a FastAPI dependency (require_token) that is applied per-router.
Every route under /api/v1/* — including the MCP protocol endpoint at /mcp — requires this header. The require_token dependency rejects requests with a missing or incorrect token with HTTP 401. There is no fallback authentication method: no session cookie, no Basic auth, no API key with a different header name.
The token can be rotated via POST /api/v1/daemon/rotate-token. After rotation, the old token is immediately rejected and the new token is written to daemon.json. The rotation event is recorded in the audit log as token_rotated.
Clients (CLI, shim, desktop shell) all read the token from daemon.json before their first authenticated call. Because daemon.json is 0600, only the process owner can read it — which is the entire access-control story for remote-process defence.
CORS configuration
The daemon configures CORS to reject cross-origin requests from browser contexts. Because the HTTP API binds to loopback, the main risk is a malicious web page (open in a browser on the same machine) making requests to http://127.0.0.1:<port>/api/v1/… using the browser's fetch() API. CORS headers block this: only origins that match the configured allowlist are permitted to include credentials or read response bodies.
In production, the allowed origins are the Tauri desktop shell's tauri://localhost and http://tauri.localhost. The Vite dev-server origins (http://localhost:5173 and http://127.0.0.1:5173) are added only when COFFER_DEV_CORS=1. The entire list can be overridden via COFFER_CORS_ORIGINS. Credentials are never allowed (allow_credentials=False) — auth is the X-Coffer-Token header alone. Origins not in the allowlist receive a CORS rejection from the browser before the token check even runs — defence in depth against the browser-based attack vector.
Outbound HTTP: real paths and planned hardening
The daemon makes outbound HTTP calls today. The real outbound paths are:
- Embeddings to OpenAI-compatible providers (an
AsyncOpenAIclient with thebase_urlswapped per provider) when building the knowledge index. git pushfor sync — but this runs as a git subprocess with the user's ambient git credentials, not through Coffer's HTTP client (see Sync security).- The Telegram and SeaTalk APIs for channels — raw httpx to fixed, well-known hosts.
- HTTP-transport MCP servers, via the MCP SDK's
create_mcp_http_client(backed byhttpx).
The constitution requires outbound HTTP to go through a SSRF-guarded client. The channel and provider clients reach fixed, well-known hosts; the open gap is the HTTP-transport MCP client, where the target host comes from user-registered config and the MCP SDK's httpx client applies no IP-range filtering. Implementing that guard for the MCP client — rejecting connections to loopback, RFC 1918 private ranges, and link-local addresses after DNS resolution — is planned hardening, not yet shipped.
For stdio-transport MCP servers, there is no outbound HTTP at all — the daemon spawns a subprocess and communicates over the process's stdin/stdout. The subprocess's environment is controlled (no secret literals) and its working directory is pinned by the cwd config field.
See also
- Constitution reference — Local-First, Credentials, and Network defaults invariants
- Architecture reference — Cross-cutting concerns table and the credentials module location