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.
135 lines
4.3 KiB
Python
135 lines
4.3 KiB
Python
"""High-level mesh workflows: join, kick, rotate.
|
|
|
|
These call into `state` (which handles DB writes + audit chain) and
|
|
verify authority before mutating roster. An admin-role member is
|
|
required to add or kick. Self-rotate (refresh secret without changing
|
|
roster) is open to any current member; this is a session hygiene op.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
from aborist.mesh.state import (
|
|
MeshRosterEntry,
|
|
current_epoch,
|
|
load_identity,
|
|
roster_at,
|
|
rotate_epoch,
|
|
)
|
|
|
|
|
|
def add_member(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
member_id: str,
|
|
sign_pub: bytes,
|
|
dh_pub: bytes,
|
|
role: str = "member",
|
|
) -> int:
|
|
"""Admin-only. Add a new member, bumping the epoch.
|
|
|
|
The new envelope wraps a fresh secret to every member of the new
|
|
roster — including the new joiner — so they can decrypt all gossip
|
|
from this epoch forward. Prior epochs' secrets remain unrecoverable
|
|
by the joiner unless they can independently reconstruct an earlier
|
|
envelope (they can't, by design).
|
|
"""
|
|
me = load_identity(conn)
|
|
if me is None:
|
|
raise RuntimeError("mesh not initialized")
|
|
epoch = current_epoch(conn)
|
|
if epoch is None:
|
|
raise RuntimeError("no epoch state; call init_identity first")
|
|
_require_admin(conn, epoch, me.member_id)
|
|
|
|
existing = roster_at(conn, epoch)
|
|
if any(m.member_id == member_id for m in existing):
|
|
raise ValueError(f"member {member_id!r} already in roster")
|
|
new_roster = existing + [
|
|
MeshRosterEntry(
|
|
member_id=member_id,
|
|
sign_pub=sign_pub,
|
|
dh_pub=dh_pub,
|
|
role=role if role in ("admin", "member") else "member",
|
|
)
|
|
]
|
|
return rotate_epoch(
|
|
conn,
|
|
new_members=new_roster,
|
|
reason=f"join:{member_id}",
|
|
actor_member_id=me.member_id,
|
|
)
|
|
|
|
|
|
def kick_member(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
member_id: str,
|
|
reason: str,
|
|
) -> int:
|
|
"""Admin-only. Remove a member from the roster, bumping the epoch.
|
|
|
|
The kicked member's prior signatures remain verifiable forever (their
|
|
roster row at older epochs is preserved). They have no entry in the
|
|
new envelope, so they can't decrypt anything from epoch+1 onward.
|
|
"""
|
|
me = load_identity(conn)
|
|
if me is None:
|
|
raise RuntimeError("mesh not initialized")
|
|
epoch = current_epoch(conn)
|
|
if epoch is None:
|
|
raise RuntimeError("no epoch state")
|
|
_require_admin(conn, epoch, me.member_id)
|
|
|
|
if member_id == me.member_id:
|
|
raise ValueError("cannot kick yourself; use 'mesh disable' instead")
|
|
existing = roster_at(conn, epoch)
|
|
if not any(m.member_id == member_id for m in existing):
|
|
raise ValueError(f"no such member: {member_id!r}")
|
|
new_roster = [m for m in existing if m.member_id != member_id]
|
|
if not any(m.role == "admin" for m in new_roster):
|
|
raise ValueError(
|
|
"kick would leave roster with no admin; promote someone first"
|
|
)
|
|
return rotate_epoch(
|
|
conn,
|
|
new_members=new_roster,
|
|
reason=f"kick:{member_id}:{reason}",
|
|
actor_member_id=me.member_id,
|
|
)
|
|
|
|
|
|
def scheduled_rotate(conn: sqlite3.Connection, *, reason: str = "scheduled") -> int:
|
|
"""Refresh the epoch secret without changing the roster.
|
|
|
|
Open to any current member. Useful for periodic rekey hygiene or
|
|
after suspicion of secret compromise without an identifiable bad
|
|
actor (every member's keys still trusted).
|
|
"""
|
|
me = load_identity(conn)
|
|
if me is None:
|
|
raise RuntimeError("mesh not initialized")
|
|
epoch = current_epoch(conn)
|
|
if epoch is None:
|
|
raise RuntimeError("no epoch state")
|
|
if not any(m.member_id == me.member_id for m in roster_at(conn, epoch)):
|
|
raise PermissionError("not a member of the current epoch")
|
|
new_roster = roster_at(conn, epoch)
|
|
return rotate_epoch(
|
|
conn,
|
|
new_members=new_roster,
|
|
reason=f"rotate:{reason}",
|
|
actor_member_id=me.member_id,
|
|
)
|
|
|
|
|
|
def _require_admin(
|
|
conn: sqlite3.Connection, epoch: int, member_id: str
|
|
) -> None:
|
|
rows = roster_at(conn, epoch)
|
|
me = next((m for m in rows if m.member_id == member_id), None)
|
|
if me is None:
|
|
raise PermissionError(f"{member_id} not in epoch {epoch} roster")
|
|
if me.role != "admin":
|
|
raise PermissionError(f"{member_id} is not an admin in epoch {epoch}")
|