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.
105 lines
4.1 KiB
Python
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
|