Skip to content

Encryption at rest

Sadie encrypts per-user API keys (sadie_settings.payload.userApiKeys) at rest with AES-256-GCM. The master key lives in SADIE_ENCRYPTION_KEY.

Only secrets the user pastes into Settings. Specifically:

  • userApiKeys.anthropic
  • userApiKeys.openai

Everything else (wiki entries, sources, Soul, conversation text) is stored as plain rows. Row-level encryption is deliberately not used for those; the threat model is “someone dumps your database backup,” not “your Postgres instance is hostile.”

The wire format is self-describing:

enc:v1:<base64(iv)>:<base64(ciphertext)>:<base64(tag)>

Anything stored without the enc:v1: prefix is treated as legacy plaintext and returned as-is on read, then re-encrypted on next save. That lets you migrate existing databases incrementally.

32 bytes, encoded as hex (64 chars) or base64.

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Paste the result into .env.local:

Terminal window
SADIE_ENCRYPTION_KEY=3f9a... # 64 hex chars

On first boot in dev, if SADIE_ENCRYPTION_KEY is missing, packages/db/src/crypto.ts generates a fresh key, appends it to .env.local, and prints a warning asking you to restart so the key is loaded into process.env.

This is a convenience, not a default. It only fires when NODE_ENV !== "production". In production, a missing key throws on the first encryption or decryption call:

SADIE_ENCRYPTION_KEY is required in production.

Rotating the key invalidates every previously-encrypted value. There is no on-the-fly re-encryption today.

If you must rotate:

  1. Generate a new key.
  2. Clear sadie_settings.payload.userApiKeys for every user (users re-paste their keys in Settings).
  3. Swap SADIE_ENCRYPTION_KEY to the new value.
  4. Redeploy.

Clearing is a simple SQL update:

UPDATE sadie_settings
SET payload = payload - 'userApiKeys'
WHERE payload ? 'userApiKeys';

For a single-user deployment this is a thirty-second interruption. For multi-user hosting, plan for it.

Wire format, in order:

  • enc:v1:. Version prefix. A future v2: lets us migrate algorithms without touching old rows.
  • base64(iv), 12 bytes. GCM standard IV length. Freshly randomized per encryption, never reused.
  • base64(ciphertext). Variable-length AES-256-GCM ciphertext.
  • base64(auth tag), 16 bytes. GCM standard authentication tag.

See packages/db/src/crypto.ts for the implementation. Public exports are re-surfaced from @repo/db/crypto.

The encryption protects against:

  • Database backups leaking to a party that does not also have the deployment’s environment.
  • Someone reading the raw sadie_settings table without the key.

It does not protect against:

  • A compromised running app instance. SADIE_ENCRYPTION_KEY lives in process.env there.
  • An attacker with both a database dump and the encryption key. Use separate storage for each; never commit .env.local.