diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/src/meshbay_node/bundle_store.py | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-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.py | 73 |
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() |