Adds an opt-in confidentiality layer to gossip. Senders pass encrypt=True on announce_root / announce_falsification; bodies travel AEAD-encrypted (ChaCha20-Poly1305) under the per-epoch symmetric secret recovered via the existing X25519 wrap. Cleartext stays the default — all existing wire tests pass without modification. WireEnvelope grows an optional encrypted_body field. canonical_bytes omits the field when None (byte-identical to v1 cleartext format) and includes it when set, so the Ed25519 signature binds the ciphertext — a MITM cannot swap payloads. AAD is type|sender|epoch|ts so a captured (nonce, ct) cannot be replayed under different envelope metadata. state.recover_epoch_secret resolves the rotator from the audit chain and re-unwraps this peer's slot. Sender + receiver share one path; no new sender-side cache is required.
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""Mesh state persisted in the standard aborist DB.
|
|
|
|
Three tables (defined in `aborist.store.SCHEMA_SQL`):
|
|
mesh_identity — this peer's keys + group name (singleton)
|
|
mesh_roster — per-epoch (member_id, sign_pub, dh_pub, role) tuples
|
|
mesh_epochs — epoch lifecycle: started_at, audit linkage, secret envelope
|
|
|
|
The `meta.mesh.enabled` flag gates everything. Default off. Mesh-related
|
|
CLI commands and any future network code paths short-circuit when the
|
|
flag is unset / '0'.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
from aborist.mesh.crypto import (
|
|
aead_decrypt,
|
|
aead_encrypt,
|
|
ecdh_shared_secret,
|
|
generate_dh_keypair,
|
|
generate_signing_keypair,
|
|
)
|
|
from aborist.store import (
|
|
append_audit,
|
|
get_meta,
|
|
set_meta,
|
|
transaction,
|
|
)
|
|
|
|
|
|
MESH_ENABLED_KEY = "mesh.enabled"
|
|
|
|
|
|
@dataclass
|
|
class MeshIdentity:
|
|
member_id: str
|
|
sign_priv: bytes
|
|
sign_pub: bytes
|
|
dh_priv: bytes
|
|
dh_pub: bytes
|
|
group_name: str
|
|
created_at: int
|
|
|
|
|
|
@dataclass
|
|
class MeshRosterEntry:
|
|
member_id: str
|
|
sign_pub: bytes
|
|
dh_pub: bytes
|
|
role: str # 'admin' | 'member'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enable / disable flag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def is_enabled(conn: sqlite3.Connection) -> bool:
|
|
return get_meta(conn, MESH_ENABLED_KEY) == "1"
|
|
|
|
|
|
def set_enabled(conn: sqlite3.Connection, enabled: bool) -> None:
|
|
with transaction(conn):
|
|
set_meta(conn, MESH_ENABLED_KEY, "1" if enabled else "0")
|
|
append_audit(
|
|
conn,
|
|
event_type="mesh_enable" if enabled else "mesh_disable",
|
|
body={"enabled": bool(enabled)},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Identity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def init_identity(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
group_name: str,
|
|
member_id: str | None = None,
|
|
) -> MeshIdentity:
|
|
"""Generate this peer's keys and seed epoch 0 with this peer as founding admin.
|
|
|
|
Idempotent on re-call only in the sense that it raises — the schema
|
|
enforces a singleton via PK = 1. Caller is expected to check
|
|
load_identity() first.
|
|
"""
|
|
if load_identity(conn) is not None:
|
|
raise RuntimeError("mesh identity already initialized")
|
|
sign_priv, sign_pub = generate_signing_keypair()
|
|
dh_priv, dh_pub = generate_dh_keypair()
|
|
member_id = member_id or _short_id()
|
|
now = int(time.time())
|
|
|
|
with transaction(conn):
|
|
conn.execute(
|
|
"INSERT INTO mesh_identity "
|
|
"(id, member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, created_at) "
|
|
"VALUES (1, ?, ?, ?, ?, ?, ?, ?)",
|
|
(member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, now),
|
|
)
|
|
# Epoch 0 — founder is sole admin.
|
|
conn.execute(
|
|
"INSERT INTO mesh_roster (epoch_id, member_id, sign_pub, dh_pub, role) "
|
|
"VALUES (0, ?, ?, ?, 'admin')",
|
|
(member_id, sign_pub, dh_pub),
|
|
)
|
|
# Epoch 0 secret: random 32-byte symmetric key, wrapped to founder's
|
|
# own DH key. (One-member envelope is degenerate but the structure
|
|
# is consistent — every later rotate appends a fresh entry.)
|
|
secret = os.urandom(32)
|
|
envelope = _wrap_secret_for_members(
|
|
secret,
|
|
members=[(member_id, dh_pub)],
|
|
sender_dh_priv=dh_priv,
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO mesh_epochs "
|
|
"(epoch_id, started_at, started_event_hash, secret_envelope, reason) "
|
|
"VALUES (0, ?, '', ?, 'genesis')",
|
|
(now, json.dumps(envelope, separators=(",", ":"), sort_keys=True)),
|
|
)
|
|
event_hash = append_audit(
|
|
conn,
|
|
event_type="mesh_init",
|
|
body={
|
|
"group_name": group_name,
|
|
"founder": member_id,
|
|
"sign_pub_hex": sign_pub.hex(),
|
|
"dh_pub_hex": dh_pub.hex(),
|
|
},
|
|
)
|
|
# Backfill the epoch's audit linkage.
|
|
with transaction(conn):
|
|
conn.execute(
|
|
"UPDATE mesh_epochs SET started_event_hash = ? WHERE epoch_id = 0",
|
|
(event_hash,),
|
|
)
|
|
return MeshIdentity(
|
|
member_id=member_id,
|
|
sign_priv=sign_priv,
|
|
sign_pub=sign_pub,
|
|
dh_priv=dh_priv,
|
|
dh_pub=dh_pub,
|
|
group_name=group_name,
|
|
created_at=now,
|
|
)
|
|
|
|
|
|
def load_identity(conn: sqlite3.Connection) -> MeshIdentity | None:
|
|
row = conn.execute(
|
|
"SELECT member_id, sign_priv, sign_pub, dh_priv, dh_pub, group_name, created_at "
|
|
"FROM mesh_identity WHERE id = 1"
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return MeshIdentity(
|
|
member_id=row["member_id"],
|
|
sign_priv=bytes(row["sign_priv"]),
|
|
sign_pub=bytes(row["sign_pub"]),
|
|
dh_priv=bytes(row["dh_priv"]),
|
|
dh_pub=bytes(row["dh_pub"]),
|
|
group_name=row["group_name"],
|
|
created_at=int(row["created_at"]),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Roster + epoch queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def current_epoch(conn: sqlite3.Connection) -> int | None:
|
|
row = conn.execute("SELECT MAX(epoch_id) AS e FROM mesh_epochs").fetchone()
|
|
if row is None or row["e"] is None:
|
|
return None
|
|
return int(row["e"])
|
|
|
|
|
|
def roster_at(conn: sqlite3.Connection, epoch_id: int) -> list[MeshRosterEntry]:
|
|
rows = conn.execute(
|
|
"SELECT member_id, sign_pub, dh_pub, role "
|
|
"FROM mesh_roster WHERE epoch_id = ? ORDER BY member_id",
|
|
(epoch_id,),
|
|
).fetchall()
|
|
return [
|
|
MeshRosterEntry(
|
|
member_id=r["member_id"],
|
|
sign_pub=bytes(r["sign_pub"]),
|
|
dh_pub=bytes(r["dh_pub"]),
|
|
role=r["role"],
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Epoch rotation (also used for join + kick)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def rotate_epoch(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
new_members: list[MeshRosterEntry],
|
|
reason: str,
|
|
actor_member_id: str,
|
|
) -> int:
|
|
"""Create a new epoch with the given roster.
|
|
|
|
`new_members` is the FULL post-rotation roster (not a diff). Eviction =
|
|
omit a member from new_members. Joins = include a new member. The
|
|
secret envelope is regenerated and wrapped to every new member's DH
|
|
pubkey via ECDH from this peer's own DH private key.
|
|
|
|
Caller is responsible for verifying authority (e.g., admin role) before
|
|
calling. Audit chain records the rotation rationale.
|
|
"""
|
|
me = load_identity(conn)
|
|
if me is None:
|
|
raise RuntimeError("no mesh identity; call init_identity first")
|
|
prior = current_epoch(conn)
|
|
if prior is None:
|
|
raise RuntimeError("no prior epoch; call init_identity first")
|
|
new_epoch = prior + 1
|
|
|
|
secret = os.urandom(32)
|
|
envelope = _wrap_secret_for_members(
|
|
secret,
|
|
members=[(m.member_id, m.dh_pub) for m in new_members],
|
|
sender_dh_priv=me.dh_priv,
|
|
)
|
|
|
|
with transaction(conn):
|
|
for m in new_members:
|
|
conn.execute(
|
|
"INSERT INTO mesh_roster (epoch_id, member_id, sign_pub, dh_pub, role) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(new_epoch, m.member_id, m.sign_pub, m.dh_pub, m.role),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO mesh_epochs "
|
|
"(epoch_id, started_at, started_event_hash, secret_envelope, reason) "
|
|
"VALUES (?, ?, '', ?, ?)",
|
|
(
|
|
new_epoch,
|
|
int(time.time()),
|
|
json.dumps(envelope, separators=(",", ":"), sort_keys=True),
|
|
reason,
|
|
),
|
|
)
|
|
event_hash = append_audit(
|
|
conn,
|
|
event_type="mesh_epoch_rotate",
|
|
body={
|
|
"actor": actor_member_id,
|
|
"new_epoch": new_epoch,
|
|
"members": [m.member_id for m in new_members],
|
|
"reason": reason,
|
|
},
|
|
)
|
|
with transaction(conn):
|
|
conn.execute(
|
|
"UPDATE mesh_epochs SET started_event_hash = ? WHERE epoch_id = ?",
|
|
(event_hash, new_epoch),
|
|
)
|
|
return new_epoch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _short_id() -> str:
|
|
"""8-hex-char member id. Collisions across a small mesh are negligible."""
|
|
return uuid.uuid4().hex[:8]
|
|
|
|
|
|
def _wrap_secret_for_members(
|
|
secret: bytes,
|
|
*,
|
|
members: list[tuple[str, bytes]],
|
|
sender_dh_priv: bytes,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Per-member ECDH+AEAD wrap of the epoch secret.
|
|
|
|
Each member gets a dict {nonce_b64, ct_b64}. Decrypt path: derive the
|
|
same shared secret via ECDH(member_dh_priv, sender_dh_pub), then
|
|
ChaCha20-Poly1305 decrypt with the recorded nonce.
|
|
"""
|
|
envelope: dict[str, dict[str, str]] = {}
|
|
for member_id, member_dh_pub in members:
|
|
shared = ecdh_shared_secret(sender_dh_priv, member_dh_pub)
|
|
nonce = os.urandom(12)
|
|
ct = aead_encrypt(shared, nonce, secret, aad=member_id.encode("utf-8"))
|
|
envelope[member_id] = {
|
|
"nonce_b64": base64.b64encode(nonce).decode("ascii"),
|
|
"ct_b64": base64.b64encode(ct).decode("ascii"),
|
|
}
|
|
return envelope
|
|
|
|
|
|
def unwrap_secret_for_self(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
epoch_id: int,
|
|
sender_dh_pub: bytes,
|
|
) -> bytes:
|
|
"""Recover the symmetric epoch secret using this peer's DH private key.
|
|
|
|
`sender_dh_pub` is the wrapping peer's X25519 pubkey (typically the
|
|
epoch's rotator). Raises ValueError if this peer has no entry in the
|
|
epoch's envelope (i.e. they were evicted) or if the AEAD tag mismatches.
|
|
"""
|
|
me = load_identity(conn)
|
|
if me is None:
|
|
raise RuntimeError("no mesh identity")
|
|
row = conn.execute(
|
|
"SELECT secret_envelope FROM mesh_epochs WHERE epoch_id = ?",
|
|
(epoch_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError(f"unknown epoch: {epoch_id}")
|
|
envelope = json.loads(row["secret_envelope"])
|
|
entry = envelope.get(me.member_id)
|
|
if entry is None:
|
|
raise ValueError(
|
|
f"this peer ({me.member_id}) is not in epoch {epoch_id}'s envelope"
|
|
)
|
|
nonce = base64.b64decode(entry["nonce_b64"])
|
|
ct = base64.b64decode(entry["ct_b64"])
|
|
shared = ecdh_shared_secret(me.dh_priv, sender_dh_pub)
|
|
return aead_decrypt(shared, nonce, ct, aad=me.member_id.encode("utf-8"))
|
|
|
|
|
|
def recover_epoch_secret(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
epoch_id: int,
|
|
) -> bytes:
|
|
"""Convenience: locate the rotator's dh_pub locally and unwrap our slot.
|
|
|
|
The rotator is recorded in the epoch's audit event body — `actor` for
|
|
rotations (epoch >= 1), `founder` for the genesis epoch 0. We resolve
|
|
the actor's dh_pub via `mesh_roster` at that epoch (the actor was a
|
|
member of the post-rotation roster by construction). Then we hand off
|
|
to `unwrap_secret_for_self`.
|
|
|
|
This is the path used by both senders (re-unwrapping their own slot
|
|
to AEAD-encrypt outbound gossip) and receivers (decrypting an inbound
|
|
encrypted body). Sender-side it's the simplest answer to "where does
|
|
the sender get the epoch secret" without a new caching layer.
|
|
|
|
Raises:
|
|
RuntimeError if mesh identity not initialized.
|
|
ValueError if the epoch row is missing, the audit event is missing,
|
|
the actor is not in the epoch's roster, this peer has no slot in
|
|
the envelope (evicted), or the AEAD tag mismatches.
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT started_event_hash FROM mesh_epochs WHERE epoch_id = ?",
|
|
(epoch_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError(f"unknown epoch: {epoch_id}")
|
|
started_event_hash = row["started_event_hash"]
|
|
if not started_event_hash:
|
|
raise ValueError(f"epoch {epoch_id} has no audit linkage")
|
|
ev = conn.execute(
|
|
"SELECT body FROM audit_events WHERE event_hash = ?",
|
|
(started_event_hash,),
|
|
).fetchone()
|
|
if ev is None:
|
|
raise ValueError(
|
|
f"epoch {epoch_id} audit event {started_event_hash!r} not found"
|
|
)
|
|
body = json.loads(ev["body"])
|
|
# Genesis writes "founder"; rotations write "actor".
|
|
actor = body.get("actor") or body.get("founder")
|
|
if not actor:
|
|
raise ValueError(
|
|
f"epoch {epoch_id} audit body has no actor/founder field"
|
|
)
|
|
rotator = next(
|
|
(m for m in roster_at(conn, epoch_id) if m.member_id == actor),
|
|
None,
|
|
)
|
|
if rotator is None:
|
|
raise ValueError(
|
|
f"rotator {actor!r} not in epoch {epoch_id} roster"
|
|
)
|
|
return unwrap_secret_for_self(
|
|
conn, epoch_id=epoch_id, sender_dh_pub=rotator.dh_pub
|
|
)
|