SHA256
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>
102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""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"
|