feat: integrity tests – replay, stale-delete and duplicate detection

Adds pytest suite proving server-side tamper/replay guarantees:
stale-version updates and deletes are refused (409), duplicate uuids
are rejected, and post-delete resurrection scenarios are documented.
Combined with the client-side AEAD binding of uuid+salt any server-side
manipulation surfaces as an undecryptable row in the UI. 3/3 passing.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-10-11 00:03:15 +02:00
parent a963baa8de
commit 9bab17e5dd
+101
View File
@@ -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"