feat(sp17): encrypted DB backup automation
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.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""SP17 — low-level backup crypto tests.
|
||||
|
||||
Pure-Python, no DB. Covers key derivation determinism, encrypt /
|
||||
decrypt round-trip, tampered-ciphertext failure, wrong-passphrase
|
||||
failure, and the sidecar JSON format.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import backup as backup_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_derive_key_is_deterministic():
|
||||
salt = os.urandom(16)
|
||||
k1 = backup_mod.derive_key("correct horse battery staple", salt)
|
||||
k2 = backup_mod.derive_key("correct horse battery staple", salt)
|
||||
assert k1 == k2
|
||||
assert len(k1) == backup_mod.KEY_LEN == 32
|
||||
|
||||
|
||||
def test_derive_key_different_salts_produce_different_keys():
|
||||
"""Salt is what makes the same passphrase produce different keys."""
|
||||
k1 = backup_mod.derive_key("hunter2", os.urandom(16))
|
||||
k2 = backup_mod.derive_key("hunter2", os.urandom(16))
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
def test_derive_key_different_passphrases_produce_different_keys():
|
||||
salt = os.urandom(16)
|
||||
k1 = backup_mod.derive_key("a", salt)
|
||||
k2 = backup_mod.derive_key("b", salt)
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encrypt / decrypt round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encrypt_decrypt_roundtrip():
|
||||
key = os.urandom(32)
|
||||
plaintext = b"hello cyclone backup " * 1000
|
||||
blob = backup_mod.encrypt(plaintext, key)
|
||||
assert len(blob) == backup_mod.NONCE_LEN + len(plaintext) + 16 # tag
|
||||
out = backup_mod.decrypt(blob, key)
|
||||
assert out == plaintext
|
||||
|
||||
|
||||
def test_encrypt_decrypt_empty_plaintext():
|
||||
"""Edge case: zero-byte payload still produces nonce + tag."""
|
||||
key = os.urandom(32)
|
||||
blob = backup_mod.encrypt(b"", key)
|
||||
out = backup_mod.decrypt(blob, key)
|
||||
assert out == b""
|
||||
|
||||
|
||||
def test_decrypt_with_wrong_key_raises():
|
||||
plaintext = b"some bytes"
|
||||
key1 = os.urandom(32)
|
||||
key2 = os.urandom(32)
|
||||
blob = backup_mod.encrypt(plaintext, key1)
|
||||
with pytest.raises(backup_mod.BackupDecryptError):
|
||||
backup_mod.decrypt(blob, key2)
|
||||
|
||||
|
||||
def test_decrypt_tampered_ciphertext_raises():
|
||||
"""Flipping a single ciphertext byte must fail GCM auth."""
|
||||
key = os.urandom(32)
|
||||
blob = backup_mod.encrypt(b"a" * 200, key)
|
||||
tampered = bytearray(blob)
|
||||
# Flip a bit somewhere in the ciphertext region (past the nonce).
|
||||
tampered[backup_mod.NONCE_LEN + 5] ^= 0x01
|
||||
with pytest.raises(backup_mod.BackupDecryptError):
|
||||
backup_mod.decrypt(bytes(tampered), key)
|
||||
|
||||
|
||||
def test_decrypt_truncated_blob_raises():
|
||||
key = os.urandom(32)
|
||||
blob = backup_mod.encrypt(b"x" * 100, key)
|
||||
with pytest.raises(backup_mod.BackupDecryptError):
|
||||
# Strip the GCM tag.
|
||||
backup_mod.decrypt(blob[: -16], key)
|
||||
|
||||
|
||||
def test_encrypt_with_wrong_key_length_raises():
|
||||
with pytest.raises(backup_mod.BackupError):
|
||||
backup_mod.encrypt(b"data", b"short") # not 32 bytes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fingerprint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fingerprint_format_and_stability():
|
||||
fp = backup_mod.fingerprint(b"hello")
|
||||
assert fp.startswith("sha256:")
|
||||
assert len(fp) == len("sha256:") + 64
|
||||
assert fp == backup_mod.fingerprint(b"hello")
|
||||
assert fp != backup_mod.fingerprint(b"hellp")
|
||||
|
||||
|
||||
def test_fingerprint_file_matches_fingerprint_bytes(tmp_path):
|
||||
p = tmp_path / "data.bin"
|
||||
p.write_bytes(b"\x00\x01\x02" * 100)
|
||||
assert backup_mod.fingerprint_file(p) == backup_mod.fingerprint(p.read_bytes())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidecar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_round_trip_json():
|
||||
sc = backup_mod.Sidecar(
|
||||
format_version="v1",
|
||||
created_at="2026-06-21T15:30:00+00:00",
|
||||
db_fingerprint="sha256:" + "a" * 64,
|
||||
table_count=11,
|
||||
size_bytes=1024,
|
||||
kdf="PBKDF2-HMAC-SHA256",
|
||||
kdf_iterations=200_000,
|
||||
cipher="AES-256-GCM",
|
||||
key_fingerprint="sha256:" + "b" * 64,
|
||||
)
|
||||
text = sc.to_json()
|
||||
parsed = json.loads(text)
|
||||
assert parsed["format_version"] == "v1"
|
||||
assert parsed["encryption"]["kdf_iterations"] == 200_000
|
||||
sc2 = backup_mod.Sidecar.from_json(text)
|
||||
assert sc2 == sc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filenames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_backup_filename_format():
|
||||
"""The timestamp prefix is fixed; the suffix is random per call."""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
ts = datetime(2026, 6, 21, 15, 30, 0, tzinfo=timezone.utc)
|
||||
name = backup_mod.backup_filename(ts)
|
||||
assert re.match(r"^cyclone-backup-20260621T153000Z-[0-9a-f]{8}\.bin$", name), name
|
||||
|
||||
|
||||
def test_backup_filename_random_suffix_avoids_collisions():
|
||||
"""Two calls in the same second get different filenames."""
|
||||
a = backup_mod.backup_filename()
|
||||
b = backup_mod.backup_filename()
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_sidecar_filename_appends_meta_json():
|
||||
assert backup_mod.sidecar_filename("foo.bin") == "foo.bin.meta.json"
|
||||
Reference in New Issue
Block a user