aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/bundle_store.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/bundle_store.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/bundle_store.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py73
1 files changed, 73 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py
index 7f03caa..86b8de4 100644
--- a/packages/meshbay-node/src/meshbay_node/bundle_store.py
+++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py
@@ -30,6 +30,30 @@ CREATE TABLE IF NOT EXISTS gek_bundles (
);
"""
+# Chat epoch keys. Wrapped to the node's own X25519 key, exactly as the node's
+# copy of the group key is — never stored raw.
+#
+# That is the whole basis of the claim chat encryption makes: "unreadable to
+# someone who obtains the node's storage without the keystore password". A
+# plaintext table beside chat.db would collapse it to nothing, silently, and it
+# is the obvious thing to write. `test_chat_key_storage.py` reads the file back
+# and refuses to find the live key in it.
+#
+# Rows are kept, never replaced: opening a new epoch must not make the history
+# of the old one unreadable to the members who could already read it, which is
+# the difference between an epoch and a rotation.
+_SCHEMA_CHAT_EPOCHS = """\
+CREATE TABLE IF NOT EXISTS chat_epochs (
+ group_id TEXT NOT NULL,
+ epoch INTEGER NOT NULL,
+ pk_eph_b64 TEXT NOT NULL,
+ nonce_b64 TEXT NOT NULL,
+ wrapped_b64 TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (group_id, epoch)
+);
+"""
+
_SCHEMA_KEYPAIR = """\
CREATE TABLE IF NOT EXISTS keypair_bundles (
user_id TEXT PRIMARY KEY,
@@ -50,6 +74,7 @@ class BundleStore:
self._db = await aiosqlite.connect(str(self._db_path))
await self._db.execute(_SCHEMA_GEK)
await self._db.execute(_SCHEMA_KEYPAIR)
+ await self._db.execute(_SCHEMA_CHAT_EPOCHS)
await self._migrate_keypair_recovery()
await self._db.commit()
@@ -155,6 +180,54 @@ class BundleStore:
await self._db.commit()
return cur.rowcount > 0
+ # ── Chat epoch keys ──────────────────────────────────────────────────
+
+ async def store_chat_epoch(
+ self,
+ group_id: str,
+ epoch: int,
+ pk_eph_b64: str,
+ nonce_b64: str,
+ wrapped_b64: str,
+ ) -> None:
+ """
+ Record one epoch key, wrapped to the node's own key.
+
+ `INSERT OR IGNORE`, not `REPLACE`: an epoch's key is written once and is
+ then the only way to read the messages sent under it. Overwriting one —
+ which a retry, or two callers racing to open the same epoch, would do —
+ would destroy that history with no error anywhere.
+ """
+ assert self._db
+ await self._db.execute(
+ "INSERT OR IGNORE INTO chat_epochs "
+ "(group_id, epoch, pk_eph_b64, nonce_b64, wrapped_b64) "
+ "VALUES (?, ?, ?, ?, ?)",
+ (group_id, epoch, pk_eph_b64, nonce_b64, wrapped_b64),
+ )
+ await self._db.commit()
+
+ async def fetch_chat_epochs(self, group_id: str) -> list[dict]:
+ """Every epoch this group has had, oldest first."""
+ assert self._db
+ async with self._db.execute(
+ "SELECT epoch, pk_eph_b64, nonce_b64, wrapped_b64 FROM chat_epochs "
+ "WHERE group_id = ? ORDER BY epoch", (group_id,)
+ ) as cur:
+ rows = await cur.fetchall()
+ return [{"epoch": r[0], "pk_eph_b64": r[1], "nonce_b64": r[2],
+ "wrapped_b64": r[3]} for r in rows]
+
+ async def latest_chat_epoch(self, group_id: str) -> int:
+ """The highest epoch number, or 0 when the group has none yet."""
+ assert self._db
+ async with self._db.execute(
+ "SELECT MAX(epoch) FROM chat_epochs WHERE group_id = ?",
+ (group_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ return int(row[0] or 0)
+
async def close(self) -> None:
if self._db:
await self._db.close()