mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 00:30:58 +01:00
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
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>
147 lines
4.4 KiB
Python
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)
|