berrypod/docs/plans/database-encryption.md
jamey 09f55dfe67
Some checks failed
deploy / deploy (push) Has been cancelled
add database backup and restore admin page
- SQLCipher-encrypted backup creation via VACUUM INTO
- Backup history with auto-pruning (keeps last 5)
- Pre-restore automatic backup for safety
- Restore from history or uploaded file
- Stats display with table breakdown
- Download hook for client-side file download
- SECRET_KEY_DB config for encryption at rest

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-13 13:33:29 +00:00

10 KiB

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):

# 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):

brew install sqlcipher

Set build environment and recompile:

# 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:

{: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:

mix phx.gen.secret  # → SECRET_KEY_BASE
mix phx.gen.secret  # → SECRET_KEY_DB

Configure exqlite (runtime.exs):

# 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):

# 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:

# 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:

fly secrets set SECRET_KEY_DB="$(mix phx.gen.secret)"

Deploy:

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:

-- 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:

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:

# 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:

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:

# 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