aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py64
1 files changed, 62 insertions, 2 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 4e4a23f..dfabe9b 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -99,7 +99,12 @@ from meshbay_common.device import (
device_hello_transcript,
device_request_transcript,
)
-from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_CHAT_KEYS, seal
+from meshbay_common.groupbox import (
+ PURPOSE_ACK,
+ PURPOSE_CHAT_KEYS,
+ PURPOSE_ROSTER,
+ seal,
+)
from meshbay_common.join import (
JOIN_TTL,
ROLE_MEMBER,
@@ -571,6 +576,8 @@ class WebRTCPeerSession:
self._do_chat_epoch(msg)
elif mtype == MNP.CHAT_KEYS_REQ:
self._spawn(self._do_chat_keys_req(msg))
+ elif mtype == MNP.GROUP_ROSTER_REQ:
+ self._spawn(self._do_group_roster_req(msg))
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
elif mtype == MNP.SEASON_META_REQ:
@@ -1456,10 +1463,23 @@ class WebRTCPeerSession:
"detail": "That request is no longer pending"})
return
+ # The countersignature is **kept**, with the two fields needed to rebuild
+ # what it signed. Until 2026-09-07 it was verified here and thrown away,
+ # leaving only `added_by_pk` — which says *which* key approved and
+ # proves nothing to anyone else. `device_add_transcript` binds
+ # `nonce_node`, this connection's handshake nonce, so a stored signature
+ # without it is still unverifiable; that is why all three go in.
+ #
+ # This is what lets another member check for themselves that this device
+ # belongs to an account whose earlier device they have already pinned,
+ # instead of taking the node's word (Tier 2, desktop-client-v1.md §4.8).
await roster.pin_identity(
user_id=self._user_id, username=self._username or "",
pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device",
- label=str(msg.get("label", ""))[:64], added_by_pk=signer)
+ label=str(msg.get("label", ""))[:64], added_by_pk=signer,
+ add_sig=str(msg.get("sig", "")),
+ add_nonce=base64.b64encode(self._nonce_node).decode(),
+ add_ts=ts)
self._audit("device_added", f"{pk_ed_b64[:16]} by {signer[:16]}")
log.info("Device added for %s: %s (approved by %s)",
self._user_id[:8], pk_ed_b64[:16], signer[:16])
@@ -2522,6 +2542,46 @@ class WebRTCPeerSession:
self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK,
"v": MNP_VERSION, "epoch": result["epoch"]})
+ async def _do_group_roster_req(self, msg: dict) -> None:
+ """
+ Who is in this group, and which device keys they hold.
+
+ Answers **any member**, not only the operator — that is the whole point.
+ A member verifies for themselves that a message came from a device
+ belonging to the account it claims, instead of taking the node's
+ `sender_id` on trust. What makes that possible is relayed here: each
+ device's key, which already-pinned key countersigned it, and the
+ signature plus the nonce and timestamp needed to rebuild what was
+ signed.
+
+ Sealed under a GEK-derived subkey, for the same reason the index is: it
+ is the group's membership, and a peer that has not completed the
+ handshake has no business reading it.
+
+ What this deliberately does not do is *decide* anything. The node hands
+ over evidence; the client checks the chain and keeps its own pins. A
+ node that lies here is caught by a client that has seen the account
+ before, which is the property Tier 2 buys and the reason the node is not
+ asked to assert trust.
+ """
+ gctx = self._group_ctx()
+ gek = gctx.get("gek")
+ roster = self._ctx.get("roster")
+ if not gek:
+ self._send({"type": "error", "detail": "Group encryption not initialized"})
+ return
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ devices = await roster.group_devices(self._group_id or "")
+ payload = {"devices": devices,
+ "node_pk": self._node_pk_b64()}
+ sealed = seal(gek, PURPOSE_ROSTER, MNP.GROUP_ROSTER_RESP,
+ self._group_id or "", payload)
+ self._send({"type": MNP.GROUP_ROSTER_RESP, "v": MNP_VERSION,
+ "group_id": self._group_id or "", **sealed})
+
async def _do_chat_keys_req(self, msg: dict) -> None:
"""
Hand this member every chat epoch key the group has, sealed.