354 lines
10 KiB
Markdown
354 lines
10 KiB
Markdown
|
|
# 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 |
|