diff options
7 files changed, 185 insertions, 105 deletions
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 207d640..8372ee7 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -625,20 +625,21 @@ operator's real choice (I8). ## 7. The pre-proof window Between `handshake_challenge` and a valid `handshake_response` the peer is -*authorized* but not *authenticated*. Three message families are served there, each +*authorized* but not *authenticated*. Four message families are served there, each because the peer provably cannot compute a proof without it. **Everything else is answered `Handshake required`**: the dispatcher's authenticated branch begins -immediately after these three, so the table below is the window, exhaustively. +immediately after these four, so the table below is the window, exhaustively. | Message | Why it must precede the proof | Bound | |---|---|---| | `keypair_bundle_fetch` | the client's own identity keys for this node live in an encrypted bundle stored on it | counts against `MAX_PRE_PROOF_FETCHES` = 4; audited | | `gek_bundle_fetch` | the wrapped group key is what the proof is computed with | same counter | | `join_request` | a first-time member holds no group key at all. Accepted after the proof as well — an operator pairing a browser is already connected — because its authority comes from the pairing code and the signature, never from the session state | 5 attempts per connection; wrong codes: 5 per account and 20 node-wide per 600 s, consulted only when a code is tried | +| `device_add_request` | a new device of a known account holds no identity this node pinned, so the node wraps it no group key (§9). Inert: it admits nothing until a pinned device countersigns. Filed for the account the token names. Accepted after the proof as well | the device budget, 5 attempts per connection; 10 pending requests per account, the oldest dropped; audited | -Device linking (§9) is **not** in this window. `device_add_request` and every message -after it are answered only on an authenticated session, and the device budget of 5 -attempts per connection applies there. +The rest of device linking (§9) is **not** in this window: `device_lookup`, +`device_add`, `device_list`, `device_revoke` and `device_hello` are answered only on +an authenticated session, by the device budget of 5 attempts per connection. Exceeding the fetch budget is audited as `pre-proof fetch flood` and answered `Too many requests`. Every fetch in this window is written to the audit log with the @@ -960,9 +961,12 @@ two keys on the same node. A second device is admitted by **a key the node alrea pinned** — never by the hub, which stores no user keys and therefore cannot countersign anything. -Every message in this section is answered on an **authenticated** session only (§7): -both the device filing a request and the device approving it have completed a handshake -on their own connection. +The new device files its request in the **pre-proof window** (§7): it holds no +identity the node pinned, so it cannot complete a handshake until the request is +approved. Every other message in this section is answered on an **authenticated** +session only — the approving device has completed a handshake on its own connection. +Once approved, the new device's next `join_request`, with no code, is recognised and +answered with the group key, like any pinned device's. ### 9.1 Exchange @@ -2187,7 +2191,7 @@ message: ## 13. Versioning and compatibility -MNP versions independently of the package version. Current: **`6.2`**; oldest peer +MNP versions independently of the package version. Current: **`6.3`**; oldest peer accepted: **`4.0`**. 4.0 is the floor: a member presents a short-lived node-audience token bound to one node @@ -2217,6 +2221,10 @@ answer, as for any unknown type, and nothing else changes. the roots table, additively: a 6.1 node gives no answer to either message, and a client that ignores `paused` shows a paused root as an ordinary one. +6.3 serves `device_add_request` in the pre-proof window (§7, §9), additively: a 6.2 +node answers it `Handshake required` there, which a client reads as "this node cannot +link a new device", and nothing else changes. + The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks; `MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about whether an older peer can still do anything useful: 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 {} |