Encryption Model
Engram encrypts all sensitive data at rest. The design is operator-friendly without being naive about how encryption fails in practice.
The two-key hierarchy
Section titled “The two-key hierarchy”Engram uses a two-key hierarchy:
master key (env) → wraps → per-user DEK (db) → encrypts → user data- Master key lives in
ENCRYPTION_MASTER_KEY(env var, never on disk in plaintext). Used only to wrap/unwrap DEKs in memory. - Per-user DEK is a random AES-256 key generated at user creation. It encrypts that user’s notes, attachments, search payloads, and metadata. Stored in the database wrapped by the master key.
The split lets master-key rotation re-wrap every DEK without re-encrypting user data, and lets a single user’s DEK rotate without touching anyone else’s.
The same model gives multi-tenancy for free: a bug that leaked one user’s bytes still wouldn’t decrypt without that user’s DEK.
What’s encrypted
Section titled “What’s encrypted”| Data | Encrypted | Notes |
|---|---|---|
| Note content | ✅ | AES-256-GCM with per-row AAD |
| Note title | ✅ | Same |
| Note paths | HMAC | Stored as HMAC for indexing; plaintext not in DB |
| Folders/tags | HMAC | Same |
| Attachments (S3 blobs) | ✅ | Encrypted client-side before S3 PUT |
| Qdrant payloads | ✅ | Search-index metadata encrypted per-chunk |
| User email | ⚠ | Plaintext (needed for login lookup; redacted in logs but stored unencrypted in the users table) |
| User passwords | ✅ | bcrypt’d (separate from DEK chain) |
| Wrapped DEKs | ✅ | AES-256-GCM wrap with master key |
The wire format is versioned:
<version_byte, nonce, ciphertext, auth_tag>. The version byte lets
the read path support old and new formats simultaneously during a
migration.
AAD binding (the “no-rowswap” guarantee)
Section titled “AAD binding (the “no-rowswap” guarantee)”Every encrypted column also commits to its row identity via AAD (Additional Authenticated Data). Specifically, the AAD for a note content cell is:
notes:content:<row_id>Decrypt fails if the AAD doesn’t match — so if an attacker tries to copy ciphertext from one row to another, the decrypt fails closed. Encryption isn’t just confidentiality; it’s also a row-binding integrity check.
Boot canary
Section titled “Boot canary”On every app start, Engram tries to decrypt a known sentinel value
stored in the system_canaries table. If decrypt fails, the master
key is wrong — and the app refuses to boot. This means you can’t
accidentally run with a half-rotated key and silently corrupt new
writes.
Why this matters for operators
Section titled “Why this matters for operators”- The master key is the keystone. Lose it → lose all user data. Back it up offline.
- Database leaks are bounded. A stolen DB dump without the master key is useless ciphertext.
- Tampering is detected. AAD binding + auth tags mean a partial DB write or a malicious modification fails closed at read time.
Operational runbooks for rotation, half-state recovery, and key backup live in Self-Host → Encryption Setup.