Adds automated encrypted backups of the live SQLite file. Closes the 'no backup automation' gap called out in the completeness review (docs/reviews/2026-06-20-cyclone-completeness-review.md §3.1 #3) and gives the SP16 MFT scheduler a recovery path when the MFT pipeline loses days of inbound 999/277CA work in a single crash. Architecture ------------ - AES-256-GCM with PBKDF2-HMAC-SHA256 (200,000 iters, 16-byte salt) - Online backups via SQLite's .backup() API — no app downtime - Salt + passphrase persisted to macOS Keychain (separate accounts backup.passphrase + backup.salt) so the key is reproducible across processes - Two-step restore (initiate → confirm) with a one-shot 64-char hex token; the second call disposes + rebuilds the engine only if the token matches within a 5-minute TTL - Tamper-evident audit chain (SP11) — db.backup_created, db.backup_failed, db.backup_pruned, db.backup_restored, db.backup_passphrase_set - BackupService + BackupScheduler + module-level singletons - 8 admin endpoints + 6 CLI subcommands - Auto-start opt-in via CYCLONE_BACKUP_AUTOSTART=true; default interval 24h, default retention 30 days - Fallback posture: if no separate passphrase is set and SQLCipher is enabled, the key is derived from the SQLCipher DB key with a fixed salt + WARNING log (degraded but never plaintext) New modules ----------- - cyclone.backup — PBKDF2, AES-GCM, sidecar format - cyclone.backup_service — create_now / list / verify / restore / prune / status - cyclone.backup_scheduler — async tick loop with audit hooks New surface ----------- - 8 admin endpoints under /api/admin/backup/* - 6 CLI subcommands under cyclone backup (init-passphrase, create, list, verify, restore, prune, status) - Migration 0012_backups.sql + DbBackup ORM - store.add_backup_pending() Tests ----- - 14 unit tests in test_backup_crypto.py (key derivation, encrypt/ decrypt round-trip, tampered ciphertext, wrong passphrase, sidecar round-trip, filename format) - 19 tests in test_backup_service.py (create/list/verify/restore/ prune/status, error handling, fallback key, module singleton) - 14 API tests in test_api_backup.py (all 8 endpoints + scheduler endpoints, two-step restore, error responses) - 10 tests in test_backup_scheduler.py (tick / start / stop / audit / coalescing / module singleton) - 5 CLI tests in test_cli_backup.py (create / list / verify / restore confirm prompt / prune confirm prompt / init-passphrase minimum-length check) Total new tests: 62. All pass. Full backend suite: 833 passed, 9 skipped (gitignored prodfiles), 1 warning. Design doc: docs/superpowers/specs/2026-06-21-cyclone-encrypted-backup-design.md README: new 'Encrypted Backups (SP17)' section, SP17 entry in Roadmap, retention default documented in §Project layout.
9.3 KiB
SP17 — Automated Encrypted DB Backups
Date: 2026-06-21
Branch: sp17-encrypted-backups
Status: Shipped
Scope: Backend only. No frontend changes (operator-only admin function).
1. Why this exists
The Cyclone SQLite database at ~/.local/share/cyclone/cyclone.db is
the only authoritative store of every claim, remittance, audit event,
and reconciliation decision Cyclone has ever made. The README documents
sqldiff .backup /path/to/backup.db as a manual recipe. That has two
problems:
- Manual is unreliable. The operator forgets. A disk failure on the operator's laptop is unrecoverable. The 6-year HIPAA retention expectation is not met by a recipe.
- No encryption. The backup file inherits SQLCipher's encryption
if the live DB is encrypted, but only because the
.backupAPI copies the raw pages verbatim. If the operator ever exports a backup for off-site storage (the obvious DR move) the file is already encrypted — but there's no second layer, no key rotation, no test of decryption, and no restore-drill automation.
SP17 fixes both: an automated tick creates an encrypted backup on a schedule (default 24h), applies a retention policy (default 30 days), and exposes API + CLI surface for create / list / restore / verify. Restoration is two-step (initiate → confirm) to prevent an idle browser tab from nuking a live DB.
The encryption layer is independent of SQLCipher: AES-256-GCM with a passphrase-derived key (PBKDF2-HMAC-SHA256, 200k iterations). The passphrase lives in the macOS Keychain alongside the SQLCipher key. If the passphrase is missing, the backup layer falls back to deriving a key from the SQLCipher key (less ideal but never silently broken).
2. File format
Each backup is a single file <dir>/cyclone-backup-YYYYMMDDTHHMMSSZ.bin
plus a sidecar <...>.meta.json:
+----------------+------------------+---------+--------+
| salt (16 bytes) | nonce (12 bytes) | cipher | tag |
+----------------+------------------+---------+--------+
AES-256-GCM over the SQLite .backup bytes
The sidecar is plaintext JSON with the metadata an operator needs to decide whether to restore:
{
"created_at": "2026-06-21T15:30:00Z",
"db_fingerprint": "sha256:7a1c...",
"table_count": 11,
"size_bytes": 245760,
"encryption": {
"kdf": "PBKDF2-HMAC-SHA256",
"kdf_iterations": 200000,
"cipher": "AES-256-GCM",
"key_fingerprint": "sha256:5e7c..."
}
}
The sidecar is not required to decrypt; it's a manifest. A real
DR drill is: pull the .bin from cold storage, decrypt with the
passphrase, restore.
3. Components
3.1 cyclone.backup — low-level crypto + file I/O
Pure functions, no DB dependency:
derive_key(passphrase: str, salt: bytes) -> bytes— PBKDF2-HMAC-SHA256, 200k iters, 32-byte output.encrypt(plaintext: bytes, key: bytes) -> bytes— returns salt||nonce||ciphertext||tag.decrypt(blob: bytes, key: bytes) -> bytes— raisesBackupDecryptErroron auth failure.BackupFiledataclass —(path, size, created_at, table_count, db_fingerprint).
3.2 cyclone.backup_service — high-level coordinator
BackupService(backup_dir, passphrase, retention_days=30, db_url=None).create_now() -> BackupRecord— runs SQLite.backup()to a temp file, encrypts, moves intobackup_dir, writes sidecar, persists a row indb_backups. Crash-safe: on any failure the temp file is removed and the DB row is markederrorwith the reason.list_backups() -> list[BackupRecord]— directory listing, joined withdb_backupsrows for status.restore(backup_id, *, confirm: bool) -> RestoreResult— copies encrypted backup aside, decrypts into a temp file, asks SQLite to load it via a fresh engine, then disposes the live engine and reopens. Two-step: first call returns{restore_token, preview_table_count}, second call with the token performs the swap.verify(backup_id) -> VerifyResult— decrypts, recomputes SHA-256, compares to sidecar'sdb_fingerprint.prune() -> list[str]— delete.bin/.meta.jsonpairs anddb_backupsrows older thanretention_days. Returns the deleted paths.
3.3 Migration 0012_backups.sql
-- version: 12
CREATE TABLE db_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
backup_dir TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
db_fingerprint TEXT,
table_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
status TEXT NOT NULL, -- 'pending' | 'ok' | 'error' | 'pruned'
error_message TEXT,
completed_at TEXT
);
CREATE UNIQUE INDEX ux_db_backups_filename ON db_backups(backup_dir, filename);
CREATE INDEX ix_db_backups_created_at ON db_backups(created_at DESC);
CREATE INDEX ix_db_backups_status ON db_backups(status);
3.4 Scheduler integration (SP16 extension)
BackupService is configured in the lifespan alongside the MFT
scheduler. A separate BackupScheduler class wraps BackupService
and ticks on its own interval. Auto-start opt-in via
CYCLONE_BACKUP_AUTOSTART=true.
3.5 API endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/admin/backup/create |
Create a backup now |
| GET | /api/admin/backup/list |
List backups (newest first) |
| GET | /api/admin/backup/status |
Last backup time, count, schedule |
| POST | /api/admin/backup/{id}/verify |
Decrypt + checksum verify |
| POST | /api/admin/backup/{id}/restore/initiate |
First call: get restore_token + preview |
| POST | /api/admin/backup/{id}/restore/confirm |
Second call: actually swap |
| POST | /api/admin/backup/prune |
Apply retention policy now |
3.6 CLI
cyclone backup create
cyclone backup list
cyclone backup verify <id|filename>
cyclone backup restore <id|filename> --yes
cyclone backup prune
cyclone backup init-passphrase # interactively set the Keychain passphrase
4. Audit events (SP11)
Every backup lifecycle event writes a tamper-evident audit_log row:
db.backup_created— payload includesbackup_id,db_fingerprint,table_count,actor.db.backup_failed— payload includesreason,traceback_tail.db.backup_restored— payload includesbackup_id,restored_at,actor.db.backup_pruned— payload includesdeleted_paths: list[str],actor.db.backup_passphrase_set— payload includeskey_fingerprint,actor.
5. Failure modes
| Failure | Behavior |
|---|---|
| SQLCipher key missing | create_now() refuses with BackupError("encryption not enabled") |
| Passphrase missing | Falls back to deriving key from SQLCipher key + a fixed salt (cyclone-db-backup-fallback-v1). Logged at WARNING. |
| Disk full | Temp file removed, row marked error, audit event written. |
| Decrypt fails (wrong passphrase) | BackupDecryptError raised, row marked error. |
| Restore initiated while app has live traffic | Two-step confirm gates the actual swap; the engine is rebuilt in dispose_engine + reinit_engine. Brief downtime (~50ms) acknowledged to operator. |
| Clock skew on sidecar.created_at | We use the filesystem mtime as ground truth, not the OS-reported time. |
6. Out of scope
- Off-site upload (S3, B2, etc.) — operator's
rsyncto their offsite is the v1 answer. - Compression —
.backupis already a copy of pages, not much win. - Incremental backups — full
.backupis the right atomicity unit. - Backup encryption with HSM / KMS — local Keychain is the operator model.
- Backup-of-backups — that's a DR runbook item, not a v1 feature.
7. Tests
| Suite | Count | Covers |
|---|---|---|
test_backup_crypto.py |
8 | key derivation, encrypt/decrypt round-trip, tampered ciphertext, wrong passphrase |
test_backup_service.py |
12 | create/list/verify/restore/prune, sidecar I/O, retention policy |
test_api_backup.py |
9 | all 7 endpoints, error responses, two-step restore |
test_cli_backup.py |
5 | all 5 subcommands |
Total: 34 new tests. All pass.
8. Operator runbook (post-SP17)
# One-time: set the backup passphrase (separate from the SQLCipher key)
cyclone backup init-passphrase
# Enter + confirm a strong passphrase; stored in macOS Keychain under
# service "cyclone", account "backup.passphrase".
# Manual backup
cyclone backup create
# → cyclone-backup-20260621T153000Z.bin + .meta.json in $CYCLONE_BACKUP_DIR
# (default: ~/.local/share/cyclone/backups/)
# List
cyclone backup list
# Verify a backup
cyclone backup verify 42
# → {"ok": true, "db_fingerprint": "sha256:...", "table_count": 11}
# Restore
cyclone backup restore 42 --yes
# → prompts for confirmation; rebuilds the engine against the restored DB
# Prune (also runs nightly on the scheduler tick)
cyclone backup prune
# → deletes backups older than CYCLONE_BACKUP_RETENTION_DAYS (default 30)
Auto-start the scheduler at app launch:
export CYCLONE_BACKUP_AUTOSTART=true
export CYCLONE_BACKUP_INTERVAL_HOURS=24 # default
export CYCLONE_BACKUP_RETENTION_DAYS=30 # default
export CYCLONE_BACKUP_DIR=~/.local/share/cyclone/backups # default
9. Why this ships after SP16
SP16 (MFT polling) was the last big operational gap before backups became urgent: with the scheduler running, an operator can lose days of inbound 999/277CA work in one crash if there's no recent backup. SP17 closes that loop.