diff options
Diffstat (limited to 'packages')
9 files changed, 89 insertions, 6 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index c3d5b94..dad8cdc 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -343,7 +343,7 @@ def authorize_token( return AuthorizedPeer( user_id=user_id, group_id=group_id, - username=decoded.get("username", ""), + username=str(decoded.get("username") or "")[:64], jti=jti, ) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index b158621..b35f11e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -64,7 +64,8 @@ async def mnp_token( # `not_a_member`, which is the answer that case has always had. "mnp_token": issue_mnp_token(current_user.id, groups=[group_id] if member else [], - node_pk=(body.node_pk if body else "")), + node_pk=(body.node_pk if body else ""), + username=current_user.username), "expires_in": 900, } diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 1d09581..41fb159 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -241,7 +241,8 @@ def issue_access_token( def issue_mnp_token(user_id: str, groups: list[str] | None = None, - node_pk: str | None = None, ttl: int = 900) -> str: + node_pk: str | None = None, ttl: int = 900, + username: str = "") -> str: """Issue the short-lived token a member presents to a node in the handshake. `aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub @@ -254,6 +255,11 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, cannot be replayed to another node the member also belongs to — the node checks it in `authorize_token` (E10). The client knows the target node's key before it connects and asks for a token bound to it. + + `username` is the account's name, so the node can show a person by name in + its audit log and roster. Admission by link, by device or into an open group + carries no name of its own; without this the node knew those members only + by id. It is a label, never authority: the node decides on `sub`. """ if _hub_sk_pem is None: raise RuntimeError("Hub keypair not loaded") @@ -261,6 +267,7 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, payload = { "iss": _hub_id, "sub": user_id, + "username": username, "jti": str(uuid.uuid4()), "iat": now, "exp": now + ttl, diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py index ef6423d..71d7ff6 100644 --- a/packages/meshbay-hub/tests/test_mnp_token.py +++ b/packages/meshbay-hub/tests/test_mnp_token.py @@ -136,3 +136,17 @@ async def test_a_token_must_name_its_group(client): r = await client.post("/v1/nodes/mnp-token", json={}, headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_mnp_token_names_the_account(client): + """A member admitted by link has no name in the node's roster but this one; + without it the node's audit log shows a bare id.""" + from meshbay_hub.auth import hub_public_key_pem + + tok = await _session_token(client, "mnp_named") + gid = await _own_group(client, tok) + mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, + headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"] + peer = authorize_token(mnp, hub_public_key_pem(), group_id=gid) + assert peer.username == "mnp_named" diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index eb8c031..07b9ea6 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -398,6 +398,24 @@ class Roster: (user_id,)) as cur: return [dict(r) for r in await cur.fetchall()] + async def name_identity(self, user_id: str, username: str) -> bool: + """ + Give a nameless account the name its hub token carries. + + Only an empty name is filled: an invitation names the person the + operator meant, and that stays. Links, devices and open groups pin an + account with no name, which left the audit log showing a bare id. + """ + assert self._db + if not username: + return False + cur = await self._db.execute( + "UPDATE identities SET username = ? " + "WHERE user_id = ? AND username = ''", + (username, user_id)) + await self._db.commit() + return cur.rowcount > 0 + async def revoke_device(self, user_id: str, pk_ed25519: str) -> bool: """ Retire one device, leaving the account's others alone. diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py index 9a6cbd1..48734ab 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py @@ -157,6 +157,13 @@ class AdminMixin: if ident and not self._device_confirmed: self._pinned_pk = ident["pk_ed25519"] + async def _name_identity(self) -> None: + """Record the token's username for an account the roster has unnamed.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id or not self._username: + return + await roster.name_identity(self._user_id, self._username) + def _is_node_admin(self) -> bool: """ Whether the **account** on this connection is the one the node belongs to. 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 20ebc79..fd9c756 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py @@ -380,9 +380,8 @@ class AdmissionMixin: return await self._pin_and_admit( - # The name comes from the invitation, not from the token: the hub does - # not put a username claim in a JWT, so pinning from the session alone - # left the roster nameless and `member revoke <name>` unable to match. + # The invitation's name first: it is the person the operator meant. + # The token's name covers an invitation that carries none. roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64, group_id=invite["group_id"], role=invite["role"], approved_by=invite["created_by"], diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py index 7822186..f3f4aee 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py @@ -212,6 +212,7 @@ class HandshakeMixin: self._group_id = self._pending_group self._username = self._pending_username self._spawn(self._load_pinned_pk()) + self._spawn(self._name_identity()) self._register_peer() diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 585a909..5687215 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -1339,3 +1339,39 @@ async def test_an_operator_cannot_revoke_themselves(tmp_path, roster): session._do_member_revoke({"user_id": session._user_id}) assert _last(session).get("detail") == "Cannot revoke yourself" + + +# ── Names from the token ────────────────────────────────────────────────────── + +async def test_link_admission_is_named_from_the_token(roster): + """A link carries no name, so the account was pinned nameless and the audit + log showed it as a bare id. The token's name fills it.""" + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-link", username="", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="link") + assert await roster.name_identity("u-link", "StephISGoD") + assert (await roster.get_identity("u-link"))["username"] == "StephISGoD" + + +async def test_token_name_never_overwrites_the_invitation(roster): + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-code", username="grenet", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="code") + assert not await roster.name_identity("u-code", "someone-else") + assert not await roster.name_identity("u-code", "") + assert (await roster.get_identity("u-code"))["username"] == "grenet" + + +async def test_handshake_records_the_token_name(roster): + _, pk_ed, pk_x = _keypair() + await roster.pin_identity(user_id="u-open", username="", pk_ed25519=pk_ed, + pk_x25519=pk_x, via="tofu") + + class _Session: + _ctx = {"roster": roster} + _user_id = "u-open" + _username = "alice" + + await WebRTCPeerSession._name_identity(_Session()) + assert (await roster.get_identity("u-open"))["username"] == "alice" + assert "self._spawn(self._name_identity())" in session_method("_complete_handshake") |