summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/src/meshbay_node/daemon.py
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py103
1 files changed, 100 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index f9e992c..e270b6c 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -415,6 +415,11 @@ class NodeDaemon:
# Whether the node unfurls links members post here.
"chat_link_preview": await self._roster.chat_link_preview(
group_cfg.id) if self._roster else True,
+ # Which chat epoch key is current. Opened here if the
+ # group has none, because chat is always encrypted (MNP
+ # 2.0) and a group with no epoch is a group nobody can
+ # speak in — the node cannot wait for an operator to notice.
+ "chat_epoch": await self._ensure_chat_epoch(group_cfg.id),
# Whether TMDB lookups run for this group at all —
# per-group (2026-08-24, used to be node-wide), same
# "read once, kept current in place by the signed op"
@@ -633,6 +638,10 @@ class NodeDaemon:
self._state["bundle_store"] = self._bundle_store
self._state["roster"] = self._roster
self._state["node_user_id"] = session.user_id
+ # The node's own Ed25519 key. Needed by `encrypt_chat_history`,
+ # which seals migrated messages under a synthetic device of the
+ # node's rather than pretending to hold a member's signing key.
+ self._state["sk_node"] = keys.sk_ed25519
self._state["webrtc"] = self._webrtc
self._state["quic_server"] = self._quic_server
self._state["hub"] = hub
@@ -869,6 +878,7 @@ class NodeDaemon:
"chat_link_preview": (
await self._roster.chat_link_preview(group_cfg.id)
if self._roster else True),
+ "chat_epoch": await self._ensure_chat_epoch(group_cfg.id),
"tmdb_enabled": (
await self._roster.tmdb_enabled(group_cfg.id)
if self._roster else True),
@@ -1007,6 +1017,35 @@ class NodeDaemon:
log.warning("No unwrappable GEK bundle found for group %s", group_id[:8])
return None
+ async def _ensure_chat_epoch(self, group_id: str) -> int:
+ """
+ The group's current chat epoch, opening the first one if it has none.
+
+ Chat is always encrypted, so a group with no epoch key is a group in
+ which nobody can say anything. Attaching one is the node's job and
+ happens here rather than on the first message: a failure at start-up is
+ in the log the operator is already reading, and a failure on someone's
+ first message is a chat that mysteriously refuses them.
+
+ Never fatal. A group whose epoch cannot be opened keeps every other
+ function — files, video, the index — and only its chat is unusable,
+ which is strictly better than refusing to host the group at all.
+ """
+ if not self._bundle_store:
+ return 0
+ try:
+ epoch = await self._bundle_store.latest_chat_epoch(group_id)
+ if epoch:
+ return epoch
+ from meshbay_node import ops
+
+ return (await ops.open_chat_epoch(self._state, group_id))["epoch"]
+ except Exception as e:
+ log.error("chat: no epoch key for group %s (%s) — chat is "
+ "unusable in this group until this is fixed",
+ group_id[:8], e)
+ return 0
+
async def _progress_pusher(self, indexer: DirectoryIndexer,
interval: float = 2.0) -> None:
"""
@@ -1759,7 +1798,8 @@ def main() -> None:
parser.add_argument("command", nargs="?",
choices=["init", "reset", "status", "gek-init",
"gek", "operator", "member", "group", "root",
- "file", "video", "denylist", "stun", "reload",
+ "file", "video", "chat", "denylist", "stun",
+ "reload",
"restart-daemon", "autostart", "service",
"calibrate-argon2"],
help="init: provision config + keystore | reset: erase all "
@@ -1770,7 +1810,9 @@ def main() -> None:
"| root list|add|remove|set|eject|plug "
"| gek init|rotate | file list|rm "
"| video rematch: re-resolve TMDB matches for a group's "
- "videos | denylist show|clear "
+ "videos "
+ "| chat status|rotate|encrypt-history|prune "
+ "| denylist show|clear "
"| stun list|add|remove|reset "
"| reload: re-read node.toml (hot; systemd or the "
"loopback API) | restart-daemon: restart the node "
@@ -1827,7 +1869,7 @@ def main() -> None:
# Query commands print a report; library logging would interleave with it.
quiet = args.command in ("status", "gek-init", "gek", "operator",
- "member", "group", "root", "file", "video",
+ "member", "group", "root", "file", "video", "chat",
"denylist", "stun", "reload", "restart-daemon",
"reset")
logging.basicConfig(
@@ -2370,6 +2412,61 @@ def main() -> None:
sys.exit(1)
return
+ if args.command == "chat":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "status"
+ group_id = _resolve_group(cfg, args.group)
+
+ if sub == "status":
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/chat")
+ print(f"encryption always on (MNP {MNP_VERSION})")
+ print(f"epoch {out.get('epoch', 0)}")
+ print(f"messages {out.get('encrypted_messages', 0)} encrypted, "
+ f"{out.get('plaintext_messages', 0)} in the clear")
+ if out.get("plaintext_messages"):
+ print("\nThose messages were written before this node spoke MNP "
+ "2.0 and are\nstill readable off this disk. "
+ "`chat encrypt-history` converts them.")
+ return
+
+ if sub == "rotate":
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/epoch",
+ method="POST")
+ print(f"chat epoch {out['epoch']} opened")
+ print("Everyone still in the group keeps reading the history; "
+ "whoever left\ncannot read what is written from now on.")
+ return
+
+ if sub == "encrypt-history":
+ if not args.yes:
+ print("This rewrites the only copy of this group's older "
+ "messages.")
+ print("A backup of chat.db is taken first, beside it.")
+ if input("re-encrypt now? [y/N] ").strip().lower() not in ("y", "yes"):
+ print("cancelled")
+ return
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/encrypt-history",
+ method="POST", timeout=300)
+ print(f"re-encrypted {out['converted']} message(s) under epoch "
+ f"{out['epoch']}")
+ print(f"backup {out['backup']}")
+ return
+
+ if sub == "prune":
+ days = int(args.target or 0)
+ if days < 1:
+ print("usage: meshbay-node chat prune <days> [--group G]")
+ sys.exit(1)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/chat/prune?max_age_days={days}",
+ method="POST")
+ print(f"removed {out['removed']} message(s) older than {days} day(s)")
+ return
+
+ print("usage: meshbay-node chat "
+ "status|rotate|encrypt-history|prune [--group G]")
+ sys.exit(1)
+
if args.command == "denylist":
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
sub = args.subcommand or "show"