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.
What gets encrypted
Section titled “What gets encrypted”Only secrets the user pastes into Settings. Specifically:
userApiKeys.anthropicuserApiKeys.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.
Generating a key
Section titled “Generating a key”32 bytes, encoded as hex (64 chars) or base64.
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Paste the result into .env.local:
SADIE_ENCRYPTION_KEY=3f9a... # 64 hex charsDevelopment auto-generation
Section titled “Development auto-generation”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.Rotation
Section titled “Rotation”Rotating the key invalidates every previously-encrypted value. There is no on-the-fly re-encryption today.
If you must rotate:
- Generate a new key.
- Clear
sadie_settings.payload.userApiKeysfor every user (users re-paste their keys in Settings). - Swap
SADIE_ENCRYPTION_KEYto the new value. - 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.
Storage format detail
Section titled “Storage format detail”Wire format, in order:
enc:v1:. Version prefix. A futurev2: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.
Threat model
Section titled “Threat model”The encryption protects against:
- Database backups leaking to a party that does not also have the deployment’s environment.
- Someone reading the raw
sadie_settingstable without the key.
It does not protect against:
- A compromised running app instance.
SADIE_ENCRYPTION_KEYlives inprocess.envthere. - An attacker with both a database dump and the encryption key. Use separate storage for each; never commit
.env.local.