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). Conceptually, the AAD for a note content cell binds the table, column, and row ID together. Roughly:
notes + content + <row-uuid>(The on-the-wire form is a null-separated binary blob,
<table>\0<column>\0<16-byte-uuid>, not the readable string above;
the readable version is shown for illustration.)
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.