utils/crdt-sync/crdt_sync/_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

203 lines
6.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""XChaCha20-Poly1305 blob cipher and per-record envelope.
Byte-compatible with ``crdt_sync_dart``'s ``E2eCipher`` and
``sealLogText``/``openLogText``: a blob is base64 ``[version][nonce][ct][tag]``
with ``[version] + utf8(path)`` as AAD; an envelope keeps record ids, the
tombstone and the newest HLC clear and seals each record's fields.
"""
from __future__ import annotations
import base64
import binascii
import json
import secrets
from typing import cast
from nacl.bindings import (
crypto_aead_xchacha20poly1305_ietf_decrypt,
crypto_aead_xchacha20poly1305_ietf_encrypt,
)
from nacl.exceptions import CryptoError
from crdt_sync._hlc import Hlc
E2E_FORMAT_VERSION = 1
_NONCE = 24
_TAG = 16
_KEY = 32
class E2eDecryptError(ValueError):
"""A blob that is tampered, under another key, or in an unknown format.
A ``ValueError`` so the pull loop skips the peer instead of aborting.
"""
def is_plaintext(stored: str) -> bool:
"""Return whether ``stored`` is legacy JSON rather than a sealed blob."""
return stored.lstrip().startswith(("{", "["))
def _aad(version: int, path: str) -> bytes:
return bytes([version]) + path.encode()
class E2eCipher:
"""Seals and opens blobs bound to their remote path."""
def __init__(self, key: bytes, *, accept_plaintext: bool = True) -> None:
"""Wrap a 32-byte ``key``; ``accept_plaintext`` reads old peers."""
if len(key) != _KEY:
msg = "key must be 32 bytes"
raise ValueError(msg)
self._key = bytes(key)
self.accept_plaintext = accept_plaintext
def seal(self, plaintext: str, *, path: str) -> str:
"""Return ``plaintext`` sealed for ``path``."""
nonce = secrets.token_bytes(_NONCE)
box = crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext.encode(), _aad(E2E_FORMAT_VERSION, path), nonce, self._key
)
return base64.b64encode(bytes([E2E_FORMAT_VERSION]) + nonce + box).decode()
def open(self, stored: str, *, path: str) -> str:
"""Return the plaintext of ``stored``; raise :class:`E2eDecryptError`."""
if is_plaintext(stored):
if self.accept_plaintext:
return stored
msg = f"plaintext blob at {path} after migration"
raise E2eDecryptError(msg)
try:
raw = base64.b64decode(stored, validate=True)
except binascii.Error as exc:
msg = f"blob at {path} is neither JSON nor base64"
raise E2eDecryptError(msg) from exc
if not raw:
msg = f"empty blob at {path}"
raise E2eDecryptError(msg)
if raw[0] != E2E_FORMAT_VERSION:
msg = f"blob at {path} uses format {raw[0]}; update the app"
raise E2eDecryptError(msg)
if len(raw) < 1 + _NONCE + _TAG:
msg = f"blob at {path} is truncated"
raise E2eDecryptError(msg)
try:
clear = crypto_aead_xchacha20poly1305_ietf_decrypt(
raw[1 + _NONCE :], _aad(raw[0], path), raw[1 : 1 + _NONCE], self._key
)
except CryptoError as exc:
msg = f"blob at {path} failed authentication"
raise E2eDecryptError(msg) from exc
return clear.decode()
def is_e2e_envelope(text: str) -> bool:
"""Return whether ``text`` is an envelope (integer ``"v"``)."""
try:
data = json.loads(text)
except ValueError:
return False
version = data.get("v") if isinstance(data, dict) else None
return isinstance(version, int) and not isinstance(version, bool)
def _record_aad(
path: str, rid: str, deleted: object, deleted_hlc: object, newest: object
) -> str:
flag = 1 if deleted is True else 0
return f"{path}#{rid}#{flag}#{deleted_hlc or ''}#{newest or ''}"
def _as_log(text: str) -> dict[str, dict[str, object]] | None:
try:
raw = json.loads(text)
except ValueError:
return None
if not isinstance(raw, dict):
return None
for rid, rec in raw.items():
if not isinstance(rec, dict) or rec.get("id") != rid:
return None
if not isinstance(rec.get("fields"), dict):
return None
return cast("dict[str, dict[str, object]]", raw)
def _newest(fields: dict[str, list[object]]) -> str | None:
clocks = [Hlc.from_str(str(pair[1])) for pair in fields.values()]
return max(clocks).to_str() if clocks else None
def seal_log_text(text: str, cipher: E2eCipher, *, path: str) -> str:
"""Seal a log (``dump_log`` shape) as an envelope; anything else whole."""
log = _as_log(text)
if log is None:
return json.dumps(
{"v": E2E_FORMAT_VERSION, "b": cipher.seal(text, path=path)},
separators=(",", ":"),
)
records: dict[str, object] = {}
for rid, rec in log.items():
fields = cast("dict[str, list[object]]", rec["fields"])
deleted = rec.get("deleted") is True
deleted_hlc = rec.get("deleted_hlc")
newest = _newest(fields)
records[rid] = {
"d": deleted,
"dh": deleted_hlc,
"h": newest,
"p": cipher.seal(
json.dumps(fields, separators=(",", ":"), ensure_ascii=False),
path=_record_aad(path, rid, deleted, deleted_hlc, newest),
),
}
return json.dumps(
{"v": E2E_FORMAT_VERSION, "r": records},
separators=(",", ":"),
ensure_ascii=False,
)
def open_log_text(text: str, cipher: E2eCipher, *, path: str) -> str:
"""Open an envelope back to the ``dump_log`` shape."""
if not is_e2e_envelope(text):
if cipher.accept_plaintext:
return text
msg = f"plaintext blob at {path} after migration"
raise E2eDecryptError(msg)
data = json.loads(text)
if data["v"] != E2E_FORMAT_VERSION:
msg = f"blob at {path} uses format {data['v']}; update the app"
raise E2eDecryptError(msg)
whole = data.get("b")
if isinstance(whole, str):
return cipher.open(whole, path=path)
records = data.get("r")
if not isinstance(records, dict):
msg = f"envelope at {path} is mis-shaped"
raise E2eDecryptError(msg)
out: dict[str, object] = {}
for rid, rec in records.items():
if (
not isinstance(rec, dict)
or not isinstance(rec.get("p"), str)
or not isinstance(rec.get("d"), bool)
):
msg = f"record {rid} at {path} is mis-shaped"
raise E2eDecryptError(msg)
clear = cipher.open(
rec["p"],
path=_record_aad(path, rid, rec["d"], rec.get("dh"), rec.get("h")),
)
out[rid] = {
"id": rid,
"fields": json.loads(clear),
"deleted": rec["d"],
"deleted_hlc": rec.get("dh"),
}
return json.dumps(out, separators=(",", ":"), ensure_ascii=False)