arborist/aborist/mesh/crypto.py
russell@unturf.com aa8caeeece
mesh: cryptographic foundation, off by default
Phase 1 of the federation/gossip layer fox sketched as the natural
extension of v9.8 admissibility's content-addressed identity. Two
peers ingesting the same dump already compute identical document_roots
and identical 8-dim cache_keys; the mesh layer is the wire-and-trust
plumbing that lets them dedup answers, exchange Merkle proofs, and
cleanly distrust an evicted member without a hard fork.

Cryptography (cryptography lib, audited):
  Ed25519       — every membership mutation + (future) gossip envelope
                  is signed by the actor's pubkey.
  X25519 ECDH   — wraps each epoch's symmetric mesh secret to every
                  current member's DH pubkey via HKDF-derived AEAD key.
  ChaCha20-P1305— AEAD for envelope payloads + per-member secret wrap.

State machine:
  mesh_identity   — singleton; this peer's keys + group name
  mesh_roster     — per-epoch (member_id, sign_pub, dh_pub, role)
  mesh_epochs     — epoch_id -> {started_at, started_event_hash,
                                  secret_envelope JSON, reason}
  meta:mesh.enabled flag — off by default; gates everything

Eviction works by rotating to a new epoch whose envelope omits the
kicked member. Their prior signatures stay verifiable (the older
roster row is retained), but any gossip from epoch+1 onward is
opaque to them — the secret was never shared with their pubkey.

Authority gate: only roster members with role='admin' can add or
kick. Self-kick is rejected explicitly. The last admin can't be
kicked. Schedule-rotate (refresh secret, no roster change) is open
to any current member as a session-hygiene op.

Audit-chain integration: every mesh state mutation writes an audit
event (mesh_init, mesh_enable/disable, mesh_epoch_rotate). The
epoch's started_event_hash backfills into mesh_epochs after the
audit row commits, giving each epoch a tamper-evident pin into the
ledger.

CLI subcommands: mesh init, mesh status, mesh enable, mesh disable,
mesh members, mesh add, mesh kick, mesh rotate. All read-only or
local-state-only — no network code paths in this commit.

The HTTP gossip wire (`mesh sync`, `mesh serve`) is the next phase.
Schema, cryptography, and roster state machine are all in place to
support it without further migration.
2026-04-27 19:00:24 -04:00

105 lines
4.1 KiB
Python

"""Cryptographic primitives for the mesh layer.
Ed25519 for signing (membership events, gossip envelopes).
X25519 for key agreement (wrap epoch secrets per-member).
ChaCha20-Poly1305 for AEAD (optional payload encryption).
All key material is bytes (raw 32-byte forms) so the storage layer can
keep keys in BLOB columns without serialization. The `cryptography`
library is the audited backend; this module is a thin wrapper that
hides the import surface and enforces consistent error handling.
"""
from __future__ import annotations
from cryptography.exceptions import InvalidSignature, InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# -----------------------------------------------------------------------------
# Ed25519 — signing
# -----------------------------------------------------------------------------
def generate_signing_keypair() -> tuple[bytes, bytes]:
"""Return (priv_bytes, pub_bytes) for a fresh Ed25519 keypair."""
priv = ed25519.Ed25519PrivateKey.generate()
priv_bytes = priv.private_bytes_raw()
pub_bytes = priv.public_key().public_bytes_raw()
return priv_bytes, pub_bytes
def sign(priv_bytes: bytes, message: bytes) -> bytes:
"""Ed25519 sign. 64-byte signature."""
priv = ed25519.Ed25519PrivateKey.from_private_bytes(priv_bytes)
return priv.sign(message)
def verify(pub_bytes: bytes, signature: bytes, message: bytes) -> bool:
"""Ed25519 verify. Returns True/False — never raises."""
try:
pub = ed25519.Ed25519PublicKey.from_public_bytes(pub_bytes)
pub.verify(signature, message)
return True
except (InvalidSignature, ValueError):
return False
# -----------------------------------------------------------------------------
# X25519 — key agreement for wrapping epoch secrets per-member
# -----------------------------------------------------------------------------
def generate_dh_keypair() -> tuple[bytes, bytes]:
"""Return (priv_bytes, pub_bytes) for a fresh X25519 keypair."""
priv = x25519.X25519PrivateKey.generate()
priv_bytes = priv.private_bytes_raw()
pub_bytes = priv.public_key().public_bytes_raw()
return priv_bytes, pub_bytes
def ecdh_shared_secret(priv_bytes: bytes, peer_pub_bytes: bytes) -> bytes:
"""X25519 ECDH -> 32-byte shared secret (HKDF-extracted)."""
priv = x25519.X25519PrivateKey.from_private_bytes(priv_bytes)
peer = x25519.X25519PublicKey.from_public_bytes(peer_pub_bytes)
raw = priv.exchange(peer)
# HKDF-Extract+Expand to a 32-byte AEAD key. The salt is fixed; the
# info string distinguishes this key from any other use of ECDH on
# the same peer-pair (e.g. if we ever add another protocol layer).
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b"aborist.mesh.epoch.v1",
info=b"epoch-secret-wrap",
).derive(raw)
# -----------------------------------------------------------------------------
# AEAD — ChaCha20-Poly1305 for envelope payloads + secret wrapping
# -----------------------------------------------------------------------------
def aead_encrypt(key: bytes, nonce: bytes, plaintext: bytes, aad: bytes = b"") -> bytes:
"""Encrypt + authenticate. Returns ciphertext||tag."""
if len(key) != 32:
raise ValueError("AEAD key must be 32 bytes")
if len(nonce) != 12:
raise ValueError("ChaCha20-Poly1305 nonce must be 12 bytes")
cipher = ChaCha20Poly1305(key)
return cipher.encrypt(nonce, plaintext, aad)
def aead_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes = b"") -> bytes:
"""Decrypt + verify. Raises ValueError on tag mismatch (no plaintext leak)."""
if len(key) != 32:
raise ValueError("AEAD key must be 32 bytes")
if len(nonce) != 12:
raise ValueError("ChaCha20-Poly1305 nonce must be 12 bytes")
cipher = ChaCha20Poly1305(key)
try:
return cipher.decrypt(nonce, ciphertext, aad)
except InvalidTag as e:
raise ValueError("AEAD authentication failed") from e