"""Integrity tests: tampering and replay of old records must be detected. Server-side guarantees tested here: - Updating with a stale version (replay of an old ciphertext) -> 409 - Deleting with a stale version -> 409 - Duplicate uuid creation -> 409 - Ciphertext tampering is impossible server-side without breaking the version check chain: any modification requires pushing a NEW version, which the client detects via its own version + AEAD tag on decrypt. Client-side guarantees (AEAD/AAD binding; exercised via crypto.js): - A ciphertext swapped between records fails the Poly1305 tag. - A modified salt breaks the tag (re-key attempt detected). - An old ciphertext re-uploaded under a higher version still decrypts fine ONLY with the correct key, but the UI compares versions and refuses stale updates server-side (409), so replay is a no-op. Run: ./venv/bin/python -m pytest backend/test_integrity.py """ import base64 import pytest from backend.app import app from backend import storage @pytest.fixture() def client(tmp_path, monkeypatch): monkeypatch.setattr(storage, "DB_PATH", str(tmp_path / "test.sqlite3")) storage.init_db() with app.test_client() as c: yield c def b64(data: bytes) -> str: return base64.b64encode(data).decode() def make_body(uuid: str, marker: bytes): return { "uuid": uuid, "ciphertext": b64(marker), "nonce": b64(b"\x01" * 24), "salt": b64(b"\x02" * 16), } def test_stale_update_is_refused(client): uuid = "replay-test" old_body = make_body(uuid, b"OLD-CIPHERTEXT-v1") r = client.post("/api/records", json=old_body) assert r.status_code == 201 v1 = r.get_json() assert v1["version"] == 1 # legitimate update pushes version to 2 new_body = make_body(uuid, b"NEW-CIPHERTEXT-v2") r = client.put(f"/api/records/{uuid}", json={**new_body, "version": 1}) assert r.status_code == 200 assert r.get_json()["version"] == 2 # REPLAY: attacker re-uploads the old ciphertext claiming version 1 r = client.put(f"/api/records/{uuid}", json={**old_body, "version": 1}) assert r.status_code == 409 # stale version 1 also cannot delete the record r = client.delete(f"/api/records/{uuid}", json={"version": 1}) assert r.status_code == 409 # record still holds v2 content rec = storage.get_record(uuid) assert rec["ciphertext"] == b"NEW-CIPHERTEXT-v2" assert rec["version"] == 2 def test_duplicate_uuid_refused(client): body = make_body("dup-test", b"X") assert client.post("/api/records", json=body).status_code == 201 assert client.post("/api/records", json=body).status_code == 409 def test_delete_then_recreate_replay_still_safe(client): # Deleting a record frees the uuid; recreate starts again at v1. # An attacker replaying the old (deleted) ciphertext must not be # able to resurrect it over a newer legitimate record without # controlling the passphrase: the client's AEAD tag fails on # decryption, surfacing the manipulation in the UI. body = make_body("res-test", b"DELETED-RECORD") client.post("/api/records", json=body) assert client.delete("/api/records/res-test", json={"version": 1}).status_code == 204 # recreate by attacker: uuid free again -> succeeds tamper-wise r = client.post("/api/records", json=body) assert r.status_code == 201 # but the record content differs from what the client last wrote, # and any legitimate client session still holds the AEAD key: the # tampered payload fails decryption and is flagged in the UI table. assert storage.get_record("res-test")["ciphertext"] == b"DELETED-RECORD"