aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md2
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md7
-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
11 files changed, 95 insertions, 9 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index baf8e1e..0284dbe 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1131,7 +1131,7 @@ token (E10).** A member hands whatever it presents here to the node operator,
who is in the threat model, so the credential must open nothing at the hub. The
hub signs two audiences with its one key: a session token (`aud` = the hub API)
for `hubFetch` and signaling, and a short-lived **MNP token** (`aud = MNP_AUD`,
-from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `jti` and **the
+from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `username`, `jti` and **the
one group the connection is for** — never the member's other groups, which the
operator it is handed to has no business learning — and is the only thing
presented in the handshake. The node binds `MNP_AUD`
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 0b5213c..0045f78 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -489,8 +489,11 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
| *(after the proof)* the roster admits `sub` for `group_id` — an active member row, or the node-wide operator row | `This node has not admitted you to this group`, code `not_authorized_for_group` | the key proves possession and the token the hub's view; the node's own answer is the roster. Someone revoked here but still a hub member, holding the key, is refused a session |
`AuthorizedPeer` carries `user_id`, `group_id`, `username`, `jti` — and deliberately
-**no user public key**. `username` is read from a `username` claim that neither the MNP
-token nor the hub session token carries, so it is empty in practice. A key arriving in a token would be a key the hub chose, and the
+**no user public key**. `username` is the MNP token's `username` claim, the account's
+hub name (cut to 64 characters). It is a label, never authority: after the handshake the
+node writes it into the roster for an account whose pinned name is empty — admission
+by link, by device or into an open group carries no name — and an invitation's name is
+never overwritten. A key arriving in a token would be a key the hub chose, and the
node records the uploader's key in order to decide who may later delete a file: that
would let whoever issues tokens decide it instead. Identity keys are pinned by the
node's roster. The hub certifies accounts, not keys.
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")