utils/crdt-sync/crdt_sync/tests/test_e2e_cipher.py
Krzysztof kuhy Rudnicki 47f2d239c4
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
crdt-sync 0.10.0: E2E port (Argon2id, XChaCha20)
Byte-compatible with crdt_sync_dart 0.14.0, proven by golden vectors in
both directions. EncryptingStore, -e2e layout, one-time copy, and
open_encrypted_client for the Python daemons. Dev pins bumped.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:47:33 +01:00

147 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""E2eCipher blob errors and the per-record envelope's edge shapes."""
from __future__ import annotations
import base64
import json
import pytest
from crdt_sync import (
E2eCipher,
E2eDecryptError,
is_e2e_envelope,
open_log_text,
seal_log_text,
)
PATH = "app/devices-e2e/pc/log.json"
CLOCK = "2026-01-01T00:00:00.000Z-0000-pc"
def _cipher(*, accept_plaintext: bool = True) -> E2eCipher:
return E2eCipher(bytes(range(32)), accept_plaintext=accept_plaintext)
def _blob(raw: bytes) -> str:
return base64.b64encode(raw).decode()
def _log(**records: object) -> str:
return json.dumps(records)
def _record(rid: str = "a", **extra: object) -> dict[str, object]:
return {
"id": rid,
"fields": {"title": ["x", CLOCK]},
"deleted": False,
"deleted_hlc": None,
**extra,
}
def test_key_must_be_32_bytes() -> None:
with pytest.raises(ValueError, match="32 bytes"):
E2eCipher(b"short")
def test_blob_round_trips() -> None:
cipher = _cipher()
assert cipher.open(cipher.seal("héllo", path=PATH), path=PATH) == "héllo"
def test_plaintext_is_read_only_while_accepted() -> None:
assert _cipher().open('{"a": 1}', path=PATH) == '{"a": 1}'
with pytest.raises(E2eDecryptError, match="after migration"):
_cipher(accept_plaintext=False).open("[1]", path=PATH)
@pytest.mark.parametrize(
("stored", "message"),
[
("!!not base64!!", "neither JSON nor base64"),
("", "empty blob"),
(_blob(b"\x02" + bytes(60)), "uses format 2"),
(_blob(b"\x01" + bytes(10)), "truncated"),
(_blob(b"\x01" + bytes(60)), "failed authentication"),
],
)
def test_bad_blobs_are_refused(stored: str, message: str) -> None:
with pytest.raises(E2eDecryptError, match=message):
_cipher().open(stored, path=PATH)
def test_blob_is_bound_to_its_path() -> None:
sealed = _cipher().seal("secret", path=PATH)
with pytest.raises(E2eDecryptError, match="failed authentication"):
_cipher().open(sealed, path="other")
@pytest.mark.parametrize("text", ["not json", "[1]", '{"v": true}', '{"v": "1"}', "{}"])
def test_non_envelopes_are_detected(text: str) -> None:
assert not is_e2e_envelope(text)
def test_envelope_is_detected() -> None:
assert is_e2e_envelope('{"v": 1}')
@pytest.mark.parametrize(
"text",
[
"not json",
"[1]",
_log(a=[1]),
_log(a=_record("b")),
_log(a={"id": "a", "fields": []}),
],
)
def test_non_log_text_is_sealed_whole(text: str) -> None:
sealed = seal_log_text(text, _cipher(), path=PATH)
assert "b" in json.loads(sealed)
assert open_log_text(sealed, _cipher(), path=PATH) == text
def test_record_without_fields_has_no_newest_clock() -> None:
empty = {"id": "a", "fields": {}, "deleted": True, "deleted_hlc": CLOCK}
sealed = seal_log_text(_log(a=empty), _cipher(), path=PATH)
assert json.loads(sealed)["r"]["a"]["h"] is None
opened = json.loads(open_log_text(sealed, _cipher(), path=PATH))
assert opened == {"a": empty}
def test_plaintext_log_passes_or_fails_by_policy() -> None:
text = _log(a=_record())
assert open_log_text(text, _cipher(), path=PATH) == text
with pytest.raises(E2eDecryptError, match="after migration"):
open_log_text(text, _cipher(accept_plaintext=False), path=PATH)
def test_unknown_envelope_version_is_refused() -> None:
with pytest.raises(E2eDecryptError, match="uses format 2"):
open_log_text('{"v": 2, "r": {}}', _cipher(), path=PATH)
def test_envelope_without_records_is_misshaped() -> None:
with pytest.raises(E2eDecryptError, match="mis-shaped"):
open_log_text('{"v": 1, "b": 5, "r": []}', _cipher(), path=PATH)
@pytest.mark.parametrize(
"record",
[[], {"p": "x", "d": 1}, {"d": False}, {"p": 3, "d": False}],
)
def test_misshaped_record_is_refused(record: object) -> None:
text = json.dumps({"v": 1, "r": {"a": record}})
with pytest.raises(E2eDecryptError, match="record a"):
open_log_text(text, _cipher(), path=PATH)
def test_tampered_record_metadata_fails_authentication() -> None:
sealed = json.loads(seal_log_text(_log(a=_record()), _cipher(), path=PATH))
sealed["r"]["a"]["d"] = True
with pytest.raises(E2eDecryptError, match="failed authentication"):
open_log_text(json.dumps(sealed), _cipher(), path=PATH)