# Database encryption at rest > Status: Complete (awaiting production deployment) > Tier: 2 (Security / Infrastructure) ## Goal The entire Berrypod shop is a single encrypted SQLite file. Portable, private, encrypted. Copy the file, set your encryption key, and host anywhere. ## Why 1. **True encryption at rest** — not just sensitive fields, the entire database 2. **Safe backups** — can store backup files anywhere without additional encryption 3. **Simple migration** — copy file + set env var = working shop on new server 4. **Privacy by design** — even if someone gets the file, data is protected ## Current state - Standard SQLite 3.51.1 (no encryption) - Sensitive fields (API keys, TOTP secrets) encrypted with Cloak.Ecto using `SECRET_KEY_BASE` - exqlite 0.34.0 compiled without SQLCipher ## Target state - SQLCipher-encrypted database file - Encryption key via `SECRET_KEY_DB` environment variable - Existing Cloak encryption remains (defence in depth for secrets) - Safe backup via `VACUUM INTO` works on encrypted database - Admin backup page with database stats and restore --- ## Security model Two independent secrets, defence in depth: | Secret | Purpose | Protects against | |--------|---------|------------------| | `SECRET_KEY_BASE` | Phoenix sessions, Cloak field encryption | SQL access without app secret | | `SECRET_KEY_DB` | SQLCipher whole-database encryption | File access without DB key | Both are required for production. If one is compromised, the other layer still protects. **SQLCipher spec:** - AES-256 in CBC mode - HMAC-SHA512 per page (tamper detection) - PBKDF2 key derivation (256,000 iterations) - Each page independently encrypted --- ## Implementation ### Phase 1: Install SQLCipher and recompile exqlite **Dev machine (Debian/Ubuntu):** ```bash # Debian bookworm: use backports for SQLCipher 4.6.1 (stable has 3.4.1 which is too old) sudo apt install -t bookworm-backports libsqlcipher-dev # Ubuntu 24.04+: standard repos have a recent enough version sudo apt install libsqlcipher-dev ``` **Dev machine (macOS):** ```bash brew install sqlcipher ``` **Set build environment and recompile:** ```bash # Tell exqlite to use system SQLCipher instead of bundled SQLite export EXQLITE_USE_SYSTEM=1 export EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher" export EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher" # Force recompile mix deps.clean exqlite --build mix deps.compile exqlite ``` **Verify SQLCipher is active:** ```elixir {:ok, conn} = Exqlite.Basic.open(":memory:") {:ok, _q, result, _c} = Exqlite.Basic.exec(conn, "PRAGMA cipher_version;") # Should return [["4.x.x"]] — if empty, SQLCipher not linked ``` ### Phase 2: Configure encryption key **Generate keys:** ```bash mix phx.gen.secret # → SECRET_KEY_BASE mix phx.gen.secret # → SECRET_KEY_DB ``` **Configure exqlite (runtime.exs):** ```elixir # config/runtime.exs # Database encryption (optional for dev, required for production) db_key = System.get_env("SECRET_KEY_DB") config :berrypod, Berrypod.Repo, database: database_path, key: db_key # nil = unencrypted, string = SQLCipher encryption ``` The `:key` option is native to exqlite — it handles the `PRAGMA key` automatically on connection. **Dev mode:** No `SECRET_KEY_DB` set = unencrypted database (easier local development). **Production mode:** `SECRET_KEY_DB` required = encrypted database. ### Phase 3: Fresh database with encryption Since we're starting fresh (no migration needed): ```bash # Delete old unencrypted database rm berrypod_dev.db berrypod_dev.db-shm berrypod_dev.db-wal # Start with encryption enabled SECRET_KEY_DB="$(mix phx.gen.secret)" mix ecto.create SECRET_KEY_DB="your-key" mix ecto.migrate SECRET_KEY_DB="your-key" mix phx.server ``` ### Phase 4: Fly.io deployment **Update Dockerfile:** ```dockerfile # Install SQLCipher RUN apt-get update -y && \ apt-get install -y libsqlcipher-dev && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Build exqlite with system SQLCipher ENV EXQLITE_USE_SYSTEM=1 ENV EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher" ENV EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher" ``` **Set the secret:** ```bash fly secrets set SECRET_KEY_DB="$(mix phx.gen.secret)" ``` **Deploy:** ```bash fly deploy ``` --- ## Admin backup page Route: `/admin/backup` ### Database stats display Show useful context before backup/restore: **Overview section:** - Total database size (formatted: "12.3 MB") - Encryption status (SQLCipher version or "Unencrypted") - Database created date - Last backup date (if tracked) **Table breakdown:** | Table | Rows | Size | |-------|------|------| | products | 16 | 45 KB | | product_variants | 142 | 28 KB | | product_images | 89 | 12 KB | | orders | 23 | 18 KB | | images | 156 | 8.2 MB | | settings | 42 | 4 KB | | ... | | | **Key counts:** - Products: 16 - Orders: 23 - Media files: 156 - Newsletter subscribers: 89 **Queries for stats:** ```sql -- Total database size SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size(); -- Row counts per table SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'; -- Then COUNT(*) each -- Table sizes (via dbstat virtual table) SELECT name, SUM(pgsize) as size FROM dbstat GROUP BY name ORDER BY size DESC; -- SQLCipher version PRAGMA cipher_version; ``` ### Download backup Use SQLite's `VACUUM INTO` for a safe, consistent backup: ```elixir def create_backup do timestamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S") backup_path = Path.join(System.tmp_dir!(), "berrypod-backup-#{timestamp}.db") Ecto.Adapters.SQL.query!(Repo, "VACUUM INTO ?", [backup_path]) {:ok, backup_path} end ``` The backup file is encrypted with the same key — portable to any server with that key. **UI:** - "Download backup" button - Shows estimated file size - Filename: `berrypod-backup-YYYYMMDD-HHMMSS.db` ### Restore backup 1. Upload encrypted backup file 2. Validate it opens with the current key 3. Show comparison: current vs uploaded (row counts, size) 4. Confirm with explicit action ("Replace current database") 5. Stop accepting requests (maintenance mode) 6. Replace database file 7. Restart application **UI:** - File upload dropzone - Validation feedback (valid/invalid/wrong key) - Side-by-side comparison before restore - Confirmation modal with warnings --- ## Task breakdown | # | Task | Est | Notes | |---|------|-----|-------| | 1 | ~~Install SQLCipher on dev machine~~ | ✓ | `apt install -t bookworm-backports libsqlcipher-dev` (4.6.1) | | 2 | ~~Set build flags, recompile exqlite~~ | ✓ | Env vars, `mix deps.clean/compile` | | 3 | ~~Verify SQLCipher with `PRAGMA cipher_version`~~ | ✓ | Returns "4.6.1 community" | | 4 | ~~Add `:key` config to runtime.exs~~ | ✓ | Required in prod, optional in dev | | 5 | ~~Test fresh encrypted database~~ | ✓ | Verified encryption works | | 6 | ~~Update Dockerfile for Fly.io~~ | ✓ | Install package, set build flags | | 7 | Deploy encrypted to Fly.io | 15m | Set secret, deploy, verify | | 8 | ~~Database stats context module~~ | ✓ | `Berrypod.Backup` with sizes, counts, encryption status | | 9 | ~~Admin backup page — stats display~~ | ✓ | `/admin/backup` LiveView | | 10 | ~~Admin backup page — download~~ | ✓ | VACUUM INTO, JS download hook | | 11 | ~~Admin backup page — restore upload~~ | ✓ | Upload, validation, comparison | | 12 | ~~Admin backup page — restore action~~ | ✓ | Maintenance mode, swap, restart | | 13 | ~~Update README with key management~~ | ✓ | Document backup procedures | **Total: ~8-9 hours** --- ## Security notes - **Key length:** 256-bit minimum. `mix phx.gen.secret` produces 512-bit which is fine. - **Key storage:** Environment variables only. Never commit to code. - **Key rotation:** Requires re-encrypting entire database. Rare operation. - **Lost key = lost data:** No recovery possible. Document key backup procedures clearly. - **Defence in depth:** Keep Cloak encryption for API keys even with DB encryption. --- ## Dev workflow For convenience, add to `.envrc` (direnv) or shell profile: ```bash # Build flags (needed once per machine after installing SQLCipher) export EXQLITE_USE_SYSTEM=1 export EXQLITE_SYSTEM_CFLAGS="-I/usr/include/sqlcipher" export EXQLITE_SYSTEM_LDFLAGS="-lsqlcipher" # Optional: dev database encryption (or omit for unencrypted dev) # export SECRET_KEY_DB="dev-only-key-not-for-production" ``` ### Encrypted dev database If you want to test encryption locally: ```bash export SECRET_KEY_DB="dev-test-key-12345" mix ecto.reset # recreates with encryption mix phx.server ``` ### Unencrypted dev database For simpler local development, just don't set `SECRET_KEY_DB`. The database will be unencrypted but otherwise identical. --- ## Compatibility - **Litestream:** Works with SQLCipher. Replicates encrypted bytes to S3. - **sqlite3 CLI:** Use `sqlcipher` CLI to open encrypted databases. - **DB Browser for SQLite:** Supports SQLCipher — enter key when opening. - **Tests:** Run unencrypted (faster) unless specifically testing encryption. --- ## Verification checklist After implementation, verify: ```bash # 1. SQLCipher is linked mix run -e '{:ok, c} = Exqlite.Basic.open(":memory:"); {:ok, _, r, _} = Exqlite.Basic.exec(c, "PRAGMA cipher_version;"); IO.inspect(r.rows)' # Should print [["4.x.x"]] # 2. Encrypted database is unreadable without key file berrypod_prod.db # Should show "data" not "SQLite 3.x database" # 3. Encrypted database opens with key SECRET_KEY_DB="your-key" mix run -e 'Berrypod.Repo.query!("SELECT 1")' # Should succeed # 4. Encrypted database fails without key mix run -e 'Berrypod.Repo.query!("SELECT 1")' # Should fail with "file is not a database" ``` --- ## Files to modify | File | Change | |------|--------| | `config/runtime.exs` | Add `:key` option to Repo config | | `Dockerfile` | Install SQLCipher, set build env vars | | `fly.toml` | (no change, key via secrets) | | `lib/berrypod/backup.ex` | New — backup/restore context | | `lib/berrypod_web/live/admin/backup_live.ex` | New — backup admin page | | `lib/berrypod_web/router.ex` | Add `/admin/backup` route | | `README.md` | Document key management |