aboutsummaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py2
-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
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json140
-rw-r--r--packages/meshbay-node/tests/test_device_linking.py78
6 files changed, 168 insertions, 96 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b86dec2..75fa2ac 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -282,5 +282,5 @@ __version__ = "0.19.0"
# the roots table carries `paused`. Additive — a 6.1 node answers nothing to
# either, and a client that ignores `paused` shows a paused root as an ordinary
# one. The floor stays at 4.0.
-MNP_VERSION = "6.2"
+MNP_VERSION = "6.3"
MHP_VERSION = "0.1"
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
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index 384c38f..f478ee0 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -2036,7 +2036,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2359,7 +2359,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2378,7 +2378,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2397,7 +2397,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2416,7 +2416,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2686,7 +2686,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2700,7 +2700,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2714,7 +2714,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2728,7 +2728,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2742,7 +2742,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2756,7 +2756,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2770,7 +2770,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -2784,7 +2784,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -3999,7 +3999,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -4018,7 +4018,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -4037,7 +4037,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -4056,7 +4056,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -4700,50 +4700,34 @@
"device_add_request | challenged | bare": {
"audit": [],
"log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
+ "sent": [],
+ "spawned": [
+ "_do_device_request"
+ ]
},
"device_add_request | challenged | lists": {
"audit": [],
"log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
+ "sent": [],
+ "spawned": [
+ "_do_device_request"
+ ]
},
"device_add_request | challenged | numbers": {
"audit": [],
"log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
+ "sent": [],
+ "spawned": [
+ "_do_device_request"
+ ]
},
"device_add_request | challenged | strings": {
"audit": [],
"log": [],
- "sent": [
- {
- "detail": "Handshake required",
- "req_id": 4242,
- "type": "error"
- }
- ],
- "spawned": []
+ "sent": [],
+ "spawned": [
+ "_do_device_request"
+ ]
},
"device_add_request | fresh | bare": {
"audit": [],
@@ -10175,7 +10159,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -12274,7 +12258,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -12293,7 +12277,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -12312,7 +12296,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13413,7 +13397,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13428,7 +13412,7 @@
"x"
],
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13441,7 +13425,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13454,7 +13438,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13467,7 +13451,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13482,7 +13466,7 @@
"x"
],
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13495,7 +13479,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13508,7 +13492,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13843,7 +13827,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13862,7 +13846,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -13881,7 +13865,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14216,7 +14200,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14235,7 +14219,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14254,7 +14238,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14589,7 +14573,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14608,7 +14592,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14627,7 +14611,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14962,7 +14946,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -14981,7 +14965,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -15000,7 +14984,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -15335,7 +15319,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -15354,7 +15338,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -15373,7 +15357,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -18000,7 +17984,7 @@
"subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
@@ -18043,7 +18027,7 @@
"subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "6.2"
+ "v": "6.3"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py
index 344c252..7f67b66 100644
--- a/packages/meshbay-node/tests/test_device_linking.py
+++ b/packages/meshbay-node/tests/test_device_linking.py
@@ -76,6 +76,7 @@ async def _session(tmp_path: Path, roster, user_id: str = "alice"):
session._pinned_pk = ""
session._uploads = {}
session._nonce_node = NONCE
+ session._gek_challenge = None
session._remote_ip = ""
session.sent = []
session._send = session.sent.append
@@ -85,19 +86,18 @@ async def _session(tmp_path: Path, roster, user_id: str = "alice"):
async def test_device_messages_need_an_authenticated_session(tmp_path, roster):
"""
- Filing a device is not a pre-proof message, and must not become one.
+ Every device message but the request is answered on an authenticated session
+ only, and the request needs at least a challenge outstanding.
- The pre-proof window exists for what a peer needs *in order to* prove
- possession of the group key, and adding a device is not that: the request is
- countersigned later by a device already pinned, so nothing is lost by
- requiring the caller to finish its own handshake first. Left in the window it
- would be a second thing a hub that forges a JWT could reach.
+ Looking up, approving, listing, revoking and saying hello are things a device
+ already pinned does, so it can finish its own handshake first and nothing is
+ lost. Filing is the exception (`test_a_new_device_files_before_the_proof`).
Driven through the real dispatcher, because this is a property of the order
of its branches and of nothing else.
"""
session = await _session(tmp_path, roster)
- session._user_id = None # challenged, has not proved anything yet
+ session._user_id = None # has not proved anything yet
_sk, pk_ed, pk_x = _keys()
for mtype in ("device_add_request", "device_hello", "device_lookup",
@@ -108,6 +108,70 @@ async def test_device_messages_need_an_authenticated_session(tmp_path, roster):
assert _last(session) == {"type": "error",
"detail": "Handshake required"}, mtype
+ session._gek_challenge = b"challenge outstanding"
+ for mtype in ("device_hello", "device_lookup", "device_add", "device_list",
+ "device_revoke"):
+ session.sent.clear()
+ session._dispatch_message({"type": mtype, "pk_ed25519": pk_ed,
+ "pk_x25519": pk_x})
+ assert _last(session) == {"type": "error",
+ "detail": "Handshake required"}, mtype
+
+
+async def test_a_new_device_files_before_the_proof(tmp_path, roster):
+ """
+ A machine with no key on this node cannot prove the group key — the key is
+ wrapped for a pinned identity, and it has none — so the request that will
+ give it one is served in the pre-proof window, for the account the token
+ names. Filing admits nothing: the approval below is what does.
+ """
+ import asyncio
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ session._user_id = None
+ session._username = None
+ session._pending_sub = "alice"
+ session._pending_username = "alice"
+ session._gek_challenge = b"challenge outstanding"
+ spawned = []
+ session._spawn = lambda coro: spawned.append(asyncio.ensure_future(coro))
+ session._audit_join = lambda *a, **k: None
+
+ code = generate_code()
+ code_hash = device_code_hash(normalize_code(code), pk_new_ed, pk_new_x)
+ ts = int(time.time())
+ transcript = device_request_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice", pk_ed25519_b64=pk_new_ed,
+ pk_x25519_b64=pk_new_x, code_hash=code_hash, nonce_node=NONCE, ts=ts)
+ session._dispatch_message({
+ "type": "device_add_request", "pk_ed25519": pk_new_ed, "pk_x25519": pk_new_x,
+ "code_hash": code_hash, "ts": ts,
+ "sig": base64.b64encode(sk_new.sign(transcript)).decode()})
+ await asyncio.gather(*spawned)
+ assert _last(session)["type"] == MNP.DEVICE_REQUEST_ACK
+
+ # Inert until a pinned device countersigns.
+ assert await roster.find_device("alice", pk_new_ed) is None
+ pending = await roster.list_device_requests("alice")
+ assert [r["pk_ed25519"] for r in pending] == [pk_new_ed]
+
+
+async def test_pending_device_requests_are_bounded_per_account(tmp_path, roster):
+ """Filing needs only a token, so the table it writes has a ceiling."""
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ filed = []
+ for i in range(roster.MAX_PENDING_DEVICE_REQUESTS + 3):
+ _sk, pk_ed, pk_x = _keys()
+ await roster.file_device_request("alice", "alice", pk_ed, pk_x, f"hash-{i:02d}")
+ filed.append(f"hash-{i:02d}")
+ kept = {r["code_hash"] for r in await roster.list_device_requests("alice")}
+ # The oldest make room — filed within the same second, so by filing order.
+ assert kept == set(filed[-roster.MAX_PENDING_DEVICE_REQUESTS:])
+
def _last(session):
return session.sent[-1] if session.sent else {}