aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit28c88908b88c58cba4de08710f6bb268ede02d57 (patch)
tree7e55f276ee0a8a25f6175f9ef81ebb845d1c218d /packages/meshbay-node/src
parent51705056a578d2649a989bf14a056fbcfae58a6b (diff)
downloadmeshbay-28c88908b88c58cba4de08710f6bb268ede02d57.tar.gz
feat(node): accept a device link request before the proof (MNP 6.3)
A new device of a known account holds no identity the node pinned, so it cannot complete a handshake until a pinned device approves it: it now files device_add_request in the pre-proof window, as the desktop app's case already required. The request stays inert until countersigned; at most 10 are pending per account, the oldest dropped. A 6.2 node answers it "Handshake required", which a client reads as "cannot link here". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py6
3 files changed, 34 insertions, 10 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 9336e38..9123763 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -327,6 +327,7 @@ class Roster:
# keypair bundle can add one — so the answer to "how many" is visibility and
# a ceiling, not cryptography.
MAX_DEVICES_PER_USER = 5
+ MAX_PENDING_DEVICE_REQUESTS = 10
async def pin_identity(
self,
@@ -522,6 +523,18 @@ class Roster:
"""
assert self._db
expires = _iso_in(ttl)
+ # Filing needs only an authorized token (it is served before the proof),
+ # so the table is bounded per account: expired requests go, and past the
+ # cap the oldest makes room. A person files one per new device, five at
+ # most, and a retry or two each; a request pushed out costs a new code.
+ await self._db.execute(
+ "DELETE FROM device_requests WHERE user_id = ? AND expires_at <= ?",
+ (user_id, _now()))
+ await self._db.execute(
+ "DELETE FROM device_requests WHERE code_hash IN ("
+ " SELECT code_hash FROM device_requests WHERE user_id = ?"
+ " ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)",
+ (user_id, self.MAX_PENDING_DEVICE_REQUESTS - 1))
await self._db.execute(
"INSERT OR REPLACE INTO device_requests "
"(code_hash, user_id, username, pk_ed25519, pk_x25519, created_at, "
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
index fd9c756..baa8001 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
@@ -404,12 +404,16 @@ class AdmissionMixin:
"""
A new device files itself as pending, bound to a code it displays.
- Served in the pre-proof window: by construction the caller holds no key
- this node knows, so there is nothing yet to prove. Filing is inert —
- nothing is admitted until an existing device countersigns.
+ Served in the pre-proof window as well as after it: by construction the
+ caller holds no key this node knows, so it cannot prove the group key —
+ filing is what will give it one. The account is the token's, which the
+ node authorized before issuing the challenge. Filing is inert — nothing
+ is admitted until an existing device countersigns.
"""
roster = self._ctx.get("roster")
- if roster is None or not self._user_id or not self._nonce_node:
+ user_id = self._user_id or getattr(self, "_pending_sub", None)
+ username = self._username or getattr(self, "_pending_username", "") or ""
+ if roster is None or not user_id or not self._nonce_node:
self._send({"type": "error", "detail": "Not ready for a device request"})
return
@@ -426,7 +430,7 @@ class AdmissionMixin:
# The account must already be known here. Anti-spam rather than a
# security boundary: the filing key is unpinned by construction, so this
# bounds the table, not the trust.
- existing = await roster.list_devices(self._user_id)
+ existing = await roster.list_devices(user_id)
if not existing:
self._send({"type": "error",
"detail": "This account has no device on this node yet — "
@@ -444,7 +448,7 @@ class AdmissionMixin:
return
transcript = device_request_transcript(
- node_pk_b64=self._node_pk_b64(), user_id=self._user_id,
+ node_pk_b64=self._node_pk_b64(), user_id=user_id,
pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
code_hash=code_hash, nonce_node=self._nonce_node, ts=ts)
try:
@@ -461,11 +465,11 @@ class AdmissionMixin:
ttl = int(self._ctx.get("device_request_ttl") or 3600)
expires = await roster.file_device_request(
- user_id=self._user_id, username=self._username or "",
+ user_id=user_id, username=username,
pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64,
code_hash=code_hash, ttl=ttl)
- self._audit("device_request", f"{pk_ed_b64[:16]}")
- log.info("Device request filed for %s (%s)", self._user_id[:8],
+ self._audit_join("device_request", f"{pk_ed_b64[:16]}")
+ log.info("Device request filed for %s (%s)", user_id[:8],
pk_ed_b64[:16])
self._send({"type": MNP.DEVICE_REQUEST_ACK, "v": MNP_VERSION,
"expires_at": expires})
@@ -761,7 +765,8 @@ class AdmissionMixin:
"""
self._device_attempts = getattr(self, "_device_attempts", 0) + 1
if self._device_attempts > 5:
- self._audit("device_attempts_exceeded", str(self._device_attempts))
+ # _audit_join: a device request may be filed before the proof.
+ self._audit_join("device_attempts_exceeded", str(self._device_attempts))
self._send({"type": "error",
"detail": "Too many device attempts on this connection"})
return False
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
index 0ceefb5..7452f42 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/dispatch.py
@@ -120,6 +120,12 @@ class DispatchMixin:
self._spawn(self._do_gek_bundle_fetch())
else:
self._spawn(self._do_keypair_bundle_fetch())
+ elif mtype == MNP.DEVICE_REQUEST and self._gek_challenge is not None:
+ # A machine with no key on this node cannot prove the group key —
+ # filing is how it gets one. Inert: nothing is admitted until a
+ # device already pinned countersigns, the code is bound to the
+ # keys, and the device budget bounds it (MNP 6.3).
+ self._spawn(self._do_device_request(msg))
elif mtype == MNP.JOIN_REQUEST and self._nonce_node:
# Valid both before the GEK proof (a new member has no GEK to prove
# with) and after it (an operator pairing a browser is already