From 2ccb6653e8841d4d6f3ab933f84746cce4e2fe2b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 26 Sep 2026 02:03:50 +0200 Subject: feat(protocol): bind the MNP token to the node it is for (E10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audience split stopped a member's node credential from opening the hub API. It did not stop the credential being *replayed to another node*: the MNP token carried the member's whole group set and named no node, so a token handed to node A's operator could be presented to node B the member also belongs to. That does not read content on B — the handshake still requires proving node B's group key, which the operator lacks — but it reaches B's pre-proof window and fetches the member's *encrypted* keypair bundle for B (offline-attackable, bounded, audited): a disclosure §2.4 says should not follow from hosting a member on A. The token now names the node it is minted for (a `node` claim = that node's Ed25519 key), and authorize_token refuses one that names a different key. The client already knows the target node's key (from /v1/groups/{id}/nodes) and asks for a token bound to it: POST /v1/nodes/mnp-token takes node_pk, and transport.connect threads it (group-page, the connection pool and rewrap pass n.pk_node; reconnect preserves it). A token that names no node is still accepted, because the hub only mints one for the authenticated requester, so an unbound token grants nothing across accounts — which also keeps non-binding callers working with no churn. Done before deploy, so it folds into the MNP 4.0 flag day rather than needing its own. Docs: §5.2, register E10, MESHBAY_NODE_PROTOCOL.md §6.3. test_handshake.py and test_mnp_token.py hold the binding (a token for node A is refused by node B, accepted by node A; an unbound token still works); red before, green after. common/node/hub suites green. Co-Authored-By: Claude Opus 4.8 --- packages/meshbay-hub/src/meshbay_hub/api/nodes.py | 24 ++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6205180..403f450 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -21,10 +21,18 @@ from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) +class MnpTokenRequest(BaseModel): + # The base64 Ed25519 key of the node this token is for. The token is bound + # to it (E10), so it cannot be replayed to another node. The client knows it + # from `/v1/groups/{id}/nodes` before it connects. + node_pk: str = "" + + @router.post("/mnp-token") @limiter.limit("60/minute") async def mnp_token( request: Request, + body: MnpTokenRequest | None = None, current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): @@ -32,16 +40,22 @@ async def mnp_token( Asked for with the member's own session token (require_user_scope, so a node daemon token cannot mint one). The result carries the member's current group - membership and `aud=MNP_AUD`, so it authorises the member to a node and is - refused by the hub API. Short-lived on purpose; the client refetches it for a - new connection or a reconnect, and it is checked only at the handshake, so a - film already playing is never interrupted by its expiry. + membership, `aud=MNP_AUD` and the target node's key, so it authorises the + member to **that** node only and is refused by the hub API and by any other + node. Short-lived on purpose; the client refetches it for a new connection or + a reconnect, and it is checked only at the handshake, so a film already + playing is never interrupted by its expiry. + + The hub does not verify the node key it is handed — binding the token to it + only *restricts* the token to whatever node holds that key, which is the one + the client is connecting to; a wrong key yields a token no node will accept. """ rows = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) group_ids = [gid for (gid,) in rows.all()] return { - "mnp_token": issue_mnp_token(current_user.id, groups=group_ids), + "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + node_pk=(body.node_pk if body else "")), "expires_in": 900, } -- cgit v1.2.3