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