aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py9
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py14
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py18
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py1
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py36
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")