Skip to content

Secrets and encryption

Trimus holds three different "secret" concepts. Knowing which is which prevents the most common operator mistakes.

Concept What it is Where it lives
TRIMUS_ENCRYPTION_KEY (KEK) The master key that wraps the data keys. Environment variable on the server — never in the database.
Company secrets Encrypted credentials a company exposes to its agents (third‑party tokens, deploy keys, …). company_secret_versions in Postgres, encrypted under the active DEK.
Provider API keys Per‑company LLM provider credentials + custom headers. company_providers in Postgres, encrypted under the active DEK.

This page covers all three, plus the envelope‑encryption model that ties them together.

The KEK/DEK vault

Trimus uses envelope encryption (AES‑256‑GCM throughout):

  • KEK — the Key Encryption Key. The 32‑byte key you set as TRIMUS_ENCRYPTION_KEY. Its only job is to wrap the DEKs at rest; it never touches the database.
  • DEK — the Data Encryption Key. A 32‑byte key that actually encrypts your data. DEKs live in the data_encryption_keys table (migration 083), each row wrapped under the KEK. One active DEK encrypts new writes; older DEKs stay retired so prior ciphertext still decrypts.

Ciphertext is stored as a versioned envelope:

enc:v2:<dek_id>:<base64 AES‑256‑GCM blob>

The <dek_id> records which DEK encrypted the value, so a row written under a retired DEK still decrypts after rotation. (Legacy values use the enc:v1: prefix.)

On boot, crypto.InitDefault loads the vault from TRIMUS_ENCRYPTION_KEY and bootstraps an initial active DEK on first run. If any wrapped DEK was wrapped under a different KEK than the one loaded, the vault refuses to start with a KEK fingerprint mismatch — see key-rotation.md.

TRIMUS_ENCRYPTION_KEY (the KEK)

A 32‑byte AES‑256 key, base64‑encoded:

openssl rand -base64 32
# k6vG7L0RR/r8fSZxhzd8kPZUwUnGw9jqLqZ4F7+5q8E=

Set it on the trimus-server (and CLI) environment:

TRIMUS_ENCRYPTION_KEY="k6vG7L0RR/r8fSZxhzd8kPZUwUnGw9jqLqZ4F7+5q8E="

What the vault encrypts:

  • Company secrets (company_secret_versions.encrypted_value).
  • Provider API keys + custom headers (company_providers.api_key, company_providers.custom_headers).
  • OAuth tokens and per‑trigger HMAC webhook secrets.

What it does NOT encrypt (delegate to Postgres / volume encryption):

  • Issue bodies, comments, activity log, run transcripts — application rows in Postgres.
  • File attachments under TRIMUS_STORAGE_DIR and agent files under TRIMUS_HOME.

If you don't set it (SEC‑010)

Encrypted writes are REFUSED — not stored in plaintext

When TRIMUS_ENCRYPTION_KEY is unset the vault is not initialised. The server still boots so read‑only paths and non‑secret features keep working, but every encrypted‑write endpoint returns 503 until you provide a key. You cannot create or rotate a company secret, a provider API key, an OAuth token, or a signed‑trigger secret. There is no "plaintext fallback".

The warning surfaces on three channels so it cannot be missed:

⚠ TRIMUS_ENCRYPTION_KEY not configured: secret writes (secrets, OAuth
tokens, signed triggers) will be REFUSED until a base64-encoded 32-byte
key is provided in this environment variable.

…on stderr (catches docker compose / local‑dev), as a structured WARN with finding=SEC-010 (catches ops aggregators), and as a banner on the secrets‑create form (catches the end user about to hit the 503).

Key format checks

Validated at startup: must be valid base64 decoding to exactly 32 bytes. Anything else and the server exits with a clear error — there is no best‑effort decode.

If you lose the KEK

There is no recovery. Every DEK is wrapped under it; without it the database is opaque ciphertext:

  • All company secrets become unrecoverable — re‑enter them.
  • All provider API keys fail to decrypt on the next agent run — re‑paste them under Providers.
  • Trigger HMAC secrets become unverifiable — re‑issue per trigger.

Treat the KEK like a TLS private key: back it up to your secrets manager (1Password, Vault, AWS Secrets Manager), share it among operators out‑of‑band, never commit it to git.

Migrating legacy plaintext (SEC‑010 CLI)

If you ran an older build that stored secrets or provider keys before encryption was enforced, migrate them in bulk. Both commands are idempotent and support --dry-run:

# Encrypt any legacy unencrypted company secret versions.
DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli encrypt-secrets --dry-run
DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli encrypt-secrets

# Encrypt any legacy unencrypted provider api_key / custom_headers.
DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli encrypt-providers

Company secrets

The Secrets sidebar entry (admin only). Each row is a name + value scoped to a company, versioned in company_secret_versions:

DEPLOY_PRIVATE_KEY   -----BEGIN OPENSSH PRIVATE KEY-----...
SLACK_WEBHOOK_URL    https://hooks.slack.com/...

Agents in the company read them via the API:

GET /api/companies/{companyId}/secrets   # list (names only, no values)
GET /api/secrets/{id}                     # one secret, decrypted on the fly

The GET /api/secrets/{id} handler loads the envelope, decrypts under the recorded DEK, and returns plaintext to an authorised agent.

No per‑secret ACL

Any agent in the company can read all of that company's secrets. There is no per‑secret access control — scope companies accordingly.

Reveal is audited (SEC‑017). A human revealing a secret value in the UI (/secrets/{id}/reveal) writes an audit row visible at /secrets/audit and in the consolidated audit dashboard.

Adding a secret

UI: Secrets → + Add Secret (admin). API:

curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"SLACK_WEBHOOK_URL","value":"https://...","description":"Used by routines"}' \
  "$BASE/api/companies/$COMPANY/secrets"

Rotating a secret

curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/secrets/$ID/rotate"

Rotation writes a new version while keeping the same name and id, so agents that fetch by id don't break. In‑flight runs holding the old value recover on their next heartbeat.

The CLI can also list / reveal / rotate (reveal + rotate are audited as actor __cli__):

DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli secret list   --company=<id>
DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli secret reveal --company=<id> ...
DATABASE_URL= TRIMUS_ENCRYPTION_KEY= trimus-cli secret rotate --company=<id> ...

Deleting

DELETE /api/secrets/{id} — hard delete, no trash.

Provider API keys

Same vault, different table. When you create or edit a provider under Providers → + Add Provider, the api_key and any custom_headers are encrypted under the active DEK before insert and decrypted on every agent heartbeat. There is no dedicated "rotate provider key" endpoint — edit the provider and paste the new key; agents pick it up on their next heartbeat. See providers.md.

Rotating the keys

KEK rotation does not re‑encrypt your data. It re‑wraps the handful of DEK rows under the new master key; every ciphertext in company_secret_versions / company_providers is untouched:

TRIMUS_ENCRYPTION_KEY=$OLD_KEK trimus-cli vault-status
TRIMUS_ENCRYPTION_KEY=$OLD_KEK trimus-cli rotate-kek --new-key=$NEW_KEK
# update TRIMUS_ENCRYPTION_KEY=$NEW_KEK in the deployment, restart

DEK rotation (incident response — you suspect a DEK leaked) mints a new active DEK and optionally migrates every row to it:

trimus-cli rotate-dek     # new active DEK; prior becomes retired
trimus-cli rewrap-all     # re-encrypt every encrypted column under the active DEK

rotate-kek and rotate-dek are single‑transaction and atomic — a crash rolls back; re‑run after fixing the cause. The full runbook, including the fingerprint sanity‑check and common gotchas, is in key-rotation.md.

What's NOT encrypted by Trimus

Thing Where it lives Encryption story
Issue titles / bodies / comments Postgres tables Postgres TLS in‑transit; at‑rest only if you configured it (or volume‑level).
Activity log Postgres activity same
Heartbeat run transcripts Postgres run‑event tables same
Attachments / agent files TRIMUS_STORAGE_DIR, TRIMUS_HOME filesystem‑level only — use encrypted volumes (LUKS / encrypted EBS).
Session cookies client‑side opaque random token; stored hashed (token_hash) in user_sessions and validated against the DB on every request — no client‑side signing, no TRIMUS_SESSION_SECRET.
API keys (raw form) nowhere — only SHA‑256 hashes are stored irreversible.

If you have a regulatory requirement (e.g. PHI) for at‑rest encryption beyond what the vault covers, enforce it at the Postgres / filesystem layer.

Where to next