diff --git a/backend/test_integrity.py b/backend/test_integrity.py new file mode 100644 index 0000000..61dd5a7 --- /dev/null +++ b/backend/test_integrity.py @@ -0,0 +1,101 @@ +"""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"