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>
203 lines
6.8 KiB
Python
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)
|