Files
cyclone/backend/tests/test_backup_crypto.py
Tyler f003c1f73a 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.
2026-06-21 09:43:51 -06:00

166 lines
5.3 KiB
Python

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