mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 03:50: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>
192 lines
5.9 KiB
Python
192 lines
5.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Argon2id key from the shared crdt-sync password, plus HKDF subkeys.
|
|
|
|
Byte-compatible with ``crdt_sync_dart``'s ``E2eKey``/``unlockE2e``: the clear
|
|
header at ``e2e_path`` holds salt, Argon2id cost and a check MAC, never a key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
from typing import TYPE_CHECKING
|
|
|
|
from argon2.low_level import Type, hash_secret_raw
|
|
|
|
from crdt_sync._e2e_cipher import E2eCipher
|
|
|
|
if TYPE_CHECKING:
|
|
from crdt_sync._remote import RemoteStore
|
|
|
|
_SALT = 16
|
|
_MASTER = 32
|
|
_HEADER_VERSION = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Argon2Params:
|
|
"""Argon2id cost, stored in the header so it can be raised later."""
|
|
|
|
memory_kib: int = 19456
|
|
iterations: int = 2
|
|
parallelism: int = 1
|
|
|
|
|
|
class E2eHeaderError(ValueError):
|
|
"""The header exists but cannot be parsed; it is never overwritten."""
|
|
|
|
|
|
class E2eWrongPassphraseError(Exception):
|
|
"""The passphrase does not match the header's check."""
|
|
|
|
|
|
def default_e2e_path(path_prefix: str) -> str:
|
|
"""Return the header path: a sibling of the device dirs, like ``revs``."""
|
|
head, sep, _ = path_prefix.rpartition("/")
|
|
return f"{head}/e2e" if sep else f"{path_prefix}/e2e"
|
|
|
|
|
|
def _hkdf(master: bytes, label: str) -> bytes:
|
|
prk = hmac.new(b"", master, hashlib.sha256).digest()
|
|
info = f"crdt_sync e2e {label}".encode()
|
|
return hmac.new(prk, info + b"\x01", hashlib.sha256).digest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class E2eKey:
|
|
"""A 32-byte master key and its subkeys. Never written to the remote."""
|
|
|
|
master: bytes
|
|
enc: bytes
|
|
rev: bytes
|
|
check: bytes
|
|
|
|
@classmethod
|
|
def from_bytes(cls, master: bytes) -> E2eKey:
|
|
"""Expand a cached 32-byte master key."""
|
|
if len(master) != _MASTER:
|
|
msg = "master key must be 32 bytes"
|
|
raise ValueError(msg)
|
|
return cls(
|
|
master, _hkdf(master, "enc"), _hkdf(master, "rev"), _hkdf(master, "check")
|
|
)
|
|
|
|
def cipher(self, *, accept_plaintext: bool = True) -> E2eCipher:
|
|
"""Return the blob cipher for this key."""
|
|
return E2eCipher(self.enc, accept_plaintext=accept_plaintext)
|
|
|
|
def revision(self, text: str) -> str:
|
|
"""Return the keyed revision (hex HMAC-SHA256) of ``text``."""
|
|
return hmac.new(self.rev, text.encode(), hashlib.sha256).hexdigest()
|
|
|
|
def check_for(self, salt: bytes) -> str:
|
|
"""Return the header check value for ``salt``."""
|
|
mac = hmac.new(self.check, salt, hashlib.sha256).digest()
|
|
return base64.b64encode(mac).decode()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Header:
|
|
salt: bytes
|
|
params: Argon2Params
|
|
check: str
|
|
|
|
@classmethod
|
|
def parse(cls, text: str) -> _Header:
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError as exc:
|
|
msg = "e2e header is not JSON"
|
|
raise E2eHeaderError(msg) from exc
|
|
if not isinstance(data, dict) or data.get("v") != _HEADER_VERSION:
|
|
msg = "e2e header is not a known version"
|
|
raise E2eHeaderError(msg)
|
|
if data.get("kdf") != "argon2id":
|
|
msg = "e2e header is malformed"
|
|
raise E2eHeaderError(msg)
|
|
try:
|
|
return cls(
|
|
base64.b64decode(data["salt"]),
|
|
Argon2Params(int(data["m"]), int(data["t"]), int(data["p"])),
|
|
str(data["check"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
msg = "e2e header is malformed"
|
|
raise E2eHeaderError(msg) from exc
|
|
|
|
def encode(self) -> str:
|
|
return json.dumps(
|
|
{
|
|
"v": _HEADER_VERSION,
|
|
"kdf": "argon2id",
|
|
"salt": base64.b64encode(self.salt).decode(),
|
|
"m": self.params.memory_kib,
|
|
"t": self.params.iterations,
|
|
"p": self.params.parallelism,
|
|
"check": self.check,
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
|
|
def derive_key(passphrase: str, salt: bytes, params: Argon2Params) -> E2eKey:
|
|
"""Run Argon2id (v1.3) and expand the subkeys."""
|
|
master = hash_secret_raw(
|
|
secret=passphrase.encode(),
|
|
salt=salt,
|
|
time_cost=params.iterations,
|
|
memory_cost=params.memory_kib,
|
|
parallelism=params.parallelism,
|
|
hash_len=32,
|
|
type=Type.ID,
|
|
)
|
|
return E2eKey.from_bytes(master)
|
|
|
|
|
|
def read_header(store: RemoteStore, path: str) -> _Header | None:
|
|
"""Return the parsed header at ``path``, or None when absent."""
|
|
text = store.get_file_text(path)
|
|
return None if text is None else _Header.parse(text)
|
|
|
|
|
|
def key_matches(key: E2eKey, store: RemoteStore, path: str) -> bool:
|
|
"""Return whether ``key`` matches the header at ``path``."""
|
|
header = read_header(store, path)
|
|
return header is not None and hmac.compare_digest(
|
|
key.check_for(header.salt), header.check
|
|
)
|
|
|
|
|
|
def unlock_e2e(
|
|
store: RemoteStore,
|
|
*,
|
|
path_prefix: str,
|
|
passphrase: str,
|
|
params: Argon2Params | None = None,
|
|
e2e_path: str | None = None,
|
|
) -> E2eKey:
|
|
"""Derive the key from ``passphrase``, creating the header on first use."""
|
|
if not passphrase:
|
|
msg = "passphrase must not be empty"
|
|
raise ValueError(msg)
|
|
path = e2e_path or default_e2e_path(path_prefix)
|
|
header = read_header(store, path)
|
|
if header is None:
|
|
salt = secrets.token_bytes(_SALT)
|
|
cost = params or Argon2Params()
|
|
key = derive_key(passphrase, salt, cost)
|
|
encoded = _Header(salt, cost, key.check_for(salt)).encode()
|
|
store.put_file_text(path, encoded, message=path)
|
|
stored = store.get_file_text(path) or encoded
|
|
if stored == encoded:
|
|
return key
|
|
header = _Header.parse(stored)
|
|
key = derive_key(passphrase, header.salt, header.params)
|
|
if not hmac.compare_digest(key.check_for(header.salt), header.check):
|
|
raise E2eWrongPassphraseError
|
|
return key
|