summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 14:30:51 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 14:30:51 +0200
commit8c5227365118383540a5e77b1885aef7e62bf6ec (patch)
treec288158f6f7cf36de8e4fa185c060402bca65ec4
parent146a6759fa73386e9b59570956aeedd7e1cfd978 (diff)
downloadmeshbay-8c5227365118383540a5e77b1885aef7e62bf6ec.tar.gz
fix(hub): require proof of possession on node announce — closes M8
Phase 11.5.10. POST /v1/nodes/announce accepted any pk_node with no proof the announcer held the matching private key, so a user could register a node record carrying someone else's node key, and records accumulated without limit. The announcer now signs a domain-separated message binding the key to their account — meshbay:node_announce:{user_id}:{pk_node}:{timestamp} — reusing the shape already proven by /v1/nodes/auth, so a signature for one can never satisfy the other. Same 60-second window. Re-announcing the same key now updates the existing record in place instead of creating a new row. Three test helpers had to be taught to sign, which is the useful part: nothing in the suite had ever exercised announce with an attacker's key. The new tests cover the missing proof, a foreign key, a stale timestamp, and idempotence. Note for the record: the node key is independent of the user's identity key. Two hub tests asserted the announced pk_node equalled the user's pk_ed, which happened to be true only because the daemon announces its keystore key. They now assert against the announced key itself. Tests: 157 hub+common, node suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js48
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py45
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py98
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py10
5 files changed, 232 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 7738875..6582a86 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -84,6 +84,8 @@ async def node_auth(
class NodeAnnounceRequest(BaseModel):
pk_node: str
endpoint_hint: str | None = None
+ timestamp: int | None = None # unix seconds
+ signature: str | None = None # base64 Ed25519 over the announce message
@router.post("/announce", status_code=201)
@@ -93,6 +95,48 @@ async def announce_node(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """
+ Register a node record.
+
+ Finding M8: this accepted any pk_node with no proof the announcer held the
+ matching private key, so a user could announce a record carrying someone
+ else's node key — useful for muddying node identity, and records accumulated
+ without limit. The announcer must now sign a domain-separated message binding
+ the key to their account, the same pattern already used by /v1/nodes/auth.
+ """
+ if body.timestamp is None or not body.signature:
+ raise HTTPException(
+ status_code=400,
+ detail="announce requires timestamp and signature (proof of possession)")
+
+ now = int(time.time())
+ if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW:
+ raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead")
+
+ message = (f"meshbay:node_announce:{current_user.id}:"
+ f"{body.pk_node}:{body.timestamp}").encode()
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_node))
+ pk.verify(base64.b64decode(body.signature), message)
+ except Exception:
+ db.add(IPLog(user_id=current_user.id, event="node_announce_fail",
+ ip_address=client_ip(request), detail=body.pk_node[:16]))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid node key proof of possession")
+
+ # One active record per key per account — announcing again updates in place
+ # instead of accumulating rows.
+ existing = await db.execute(
+ select(Node).where(Node.user_id == current_user.id,
+ Node.pk_node == body.pk_node))
+ node = existing.scalar_one_or_none()
+ if node is not None:
+ node.endpoint_hint = body.endpoint_hint
+ db.add(IPLog(user_id=current_user.id, event="node_announce",
+ ip_address=client_ip(request), detail=body.endpoint_hint))
+ await db.commit()
+ return {"node_id": node.id}
+
node = Node(
user_id=current_user.id,
pk_node=body.pk_node,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 18faea1..50304f3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -232,6 +232,11 @@ class MeshBayTransport {
|| !await C.verifyNodeSignature(ack.node_pk, ack.sig, transcript)) {
throw new Error('Node signature invalid — refusing connection');
}
+ // Trust On First Use (11.5.8). With C6 closed, a substituted node already
+ // fails the GEK proof — this covers the case where an attacker HAS the GEK
+ // (an ex-member, or a leaked key) and swaps the node underneath.
+ // Strict refusal: a warning users can click through is decorative.
+ _checkNodePin(nodeId, ack.node_pk);
this.nodePk = ack.node_pk;
return ack;
@@ -692,5 +697,48 @@ function _extractDtlsFingerprint(sdp) {
return bytes;
}
+// ── Node identity pinning (11.5.8) ───────────────────────────────────────────
+
+const NODE_PIN_PREFIX = 'mb_nodepin_';
+
+function _checkNodePin(nodeId, nodePk) {
+ if (!nodeId || !nodePk) return;
+ const key = NODE_PIN_PREFIX + nodeId;
+
+ let pinned = null;
+ try { pinned = localStorage.getItem(key); } catch { return; }
+
+ if (pinned === null) {
+ try { localStorage.setItem(key, nodePk); } catch {}
+ return;
+ }
+ if (pinned !== nodePk) {
+ throw new Error(
+ 'This node\'s identity key has changed. That is expected only if its ' +
+ 'operator reinstalled the node — otherwise someone may be impersonating ' +
+ 'it. Verify with the operator out of band, then clear the pin in ' +
+ 'Settings to accept the new key.');
+ }
+}
+
+/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */
+function clearNodePin(nodeId) {
+ try {
+ if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId);
+ else {
+ for (const k of Object.keys(localStorage))
+ if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k);
+ }
+ } catch {}
+}
+
+function pinnedNodeCount() {
+ try {
+ return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length;
+ } catch { return 0; }
+}
+
// Export
+MeshBayTransport.clearNodePin = clearNodePin;
+MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
window.MeshBayTransport = MeshBayTransport;
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index a8232c1..7b75fd1 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -24,6 +24,35 @@ def _gen_user_keys():
)
+
+async def _announce_signed(client, token: str) -> tuple[str, str]:
+ """
+ Announce a node with proof of possession (M8).
+
+ The node key is independent of the user's identity key, so this mints a fresh
+ one and signs the domain-separated announce message with it.
+ """
+ import base64 as _b64, time as _t
+
+ me = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"})
+ user_id = me.json()["user_id"]
+
+ sk_node = Ed25519PrivateKey.generate()
+ pk_node = pk_to_b64(sk_node.public_key())
+ ts = _t.time().__trunc__()
+ msg = f"meshbay:node_announce:{user_id}:{pk_node}:{ts}".encode()
+
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk_node,
+ "endpoint_hint": "1.2.3.4:19000",
+ "timestamp": ts,
+ "signature": _b64.b64encode(sk_node.sign(msg)).decode(),
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"], pk_node
+
+
# ── Hub info ──────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@@ -195,15 +224,12 @@ async def test_announce_and_get_node(client):
token = login.json()["access_token"]
hdrs = {"Authorization": f"Bearer {token}"}
- r = await client.post("/v1/nodes/announce",
- json={"pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
- headers=hdrs)
- assert r.status_code == 201
- node_id = r.json()["node_id"]
+ node_id, pk_node = await _announce_signed(client, token)
r2 = await client.get(f"/v1/nodes/{node_id}", headers=hdrs)
assert r2.status_code == 200
- assert r2.json()["pk_node"] == pk_ed
+ # The node key is independent of the user identity key (M8).
+ assert r2.json()["pk_node"] == pk_node
assert r2.json()["endpoint_hint"] == "1.2.3.4:19000"
@@ -409,10 +435,7 @@ async def test_group_online_nodes(client):
json={"username": "gn_user", "password": "gnpass999"})).json()["access_token"]
# Announce a node
- r = await client.post("/v1/nodes/announce", json={
- "pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
- headers={"Authorization": f"Bearer {token}"})
- node_id = r.json()["node_id"]
+ node_id, pk_node = await _announce_signed(client, token)
# No nodes online yet
r = await client.get(f"/v1/groups/{group_id}/nodes",
@@ -433,7 +456,7 @@ async def test_group_online_nodes(client):
nodes = r.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["node_id"] == node_id
- assert nodes[0]["pk_node"] == pk_ed
+ assert nodes[0]["pk_node"] == pk_node
finally:
_connected_nodes.pop(node_id, None)
_node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 9ec251d..def5e66 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -40,13 +40,22 @@ async def _make_user(client, username: str) -> dict:
"auth_key": base64.b64encode(b"k" * 32).decode(),
})
assert r.status_code == 200, r.text
- return {"user_id": user_id, "token": r.json()["access_token"], "pk_ed": pk_ed}
+ return {"user_id": user_id, "token": r.json()["access_token"],
+ "pk_ed": pk_ed, "sk_ed": sk_ed}
async def _announce_node(client, user: dict) -> str:
+ # Announce now requires proof of possession of the node key (M8).
+ import time as _t
+ ts = int(_t.time())
+ msg = f"meshbay:node_announce:{user['user_id']}:{user['pk_ed']}:{ts}".encode()
r = await client.post(
"/v1/nodes/announce",
- json={"pk_node": user["pk_ed"], "endpoint_hint": "test"},
+ json={
+ "pk_node": user["pk_ed"], "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(user["sk_ed"].sign(msg)).decode(),
+ },
headers={"Authorization": f"Bearer {user['token']}"},
)
assert r.status_code == 201, r.text
@@ -250,3 +259,88 @@ async def test_ws_node_may_narrow_its_group_set(client):
_node_token(user), node_id, [created[0]])
assert resolved == node_id
assert groups == [created[0]]
+
+
+# ── M8: announce proof of possession ─────────────────────────────────────────
+
+def _announce_payload(user_id: str, sk, pk_b64: str, ts: int | None = None):
+ import time as _t
+ ts = ts if ts is not None else int(_t.time())
+ msg = f"meshbay:node_announce:{user_id}:{pk_b64}:{ts}".encode()
+ return {
+ "pk_node": pk_b64,
+ "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode(),
+ }
+
+
+@pytest.mark.asyncio
+async def test_announce_requires_proof_of_possession(client):
+ """
+ M8: /v1/nodes/announce accepted any pk_node with no proof the announcer held
+ the private key, so a user could announce a record carrying someone else's
+ node key.
+ """
+ user = await _make_user(client, "ann1")
+ r = await client.post(
+ "/v1/nodes/announce",
+ json={"pk_node": user["pk_ed"], "endpoint_hint": "test"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 400, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_foreign_key(client):
+ """M8: announcing someone else's public key must fail — no matching private key."""
+ user = await _make_user(client, "ann2")
+ victim_sk = Ed25519PrivateKey.generate()
+ victim_pk = pk_to_b64(victim_sk.public_key())
+
+ attacker_sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(user["user_id"], attacker_sk, victim_pk)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_stale_timestamp(client):
+ """M8: a captured announce must not be replayable later."""
+ import time as _t
+ user = await _make_user(client, "ann3")
+ sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(
+ user["user_id"], sk, pk_to_b64(sk.public_key()), ts=int(_t.time()) - 3600)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
+ """The legitimate path works, and re-announcing updates rather than piling up rows."""
+ user = await _make_user(client, "ann4")
+ sk = Ed25519PrivateKey.generate()
+ pk_b64 = pk_to_b64(sk.public_key())
+
+ first = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert first.status_code == 201, first.text
+
+ second = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert second.status_code == 201, second.text
+ assert second.json()["node_id"] == first.json()["node_id"], (
+ "re-announcing the same key must not create a second node record (M8)")
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 432af0a..a379b68 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -163,9 +163,19 @@ class HubClient:
raise RuntimeError("Not logged in")
await self.ensure_fresh_token()
+ # Proof of possession of the node key (M8) — same domain-separated shape
+ # as node_auth, so a signature for one can never satisfy the other.
+ timestamp = int(time.time())
+ message = (f"meshbay:node_announce:{self._session.user_id}:"
+ f"{self._keys.pk_ed25519_b64}:{timestamp}").encode()
+ signature = base64.b64encode(
+ self._keys.sk_ed25519.sign(message)).decode()
+
r = await self._http.post("/v1/nodes/announce", json={
"pk_node": self._keys.pk_ed25519_b64,
"endpoint_hint": endpoint_hint,
+ "timestamp": timestamp,
+ "signature": signature,
}, headers=self._session.auth_headers)
r.raise_for_status()
node_id = r.json()["node_id"]