aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js48
2 files changed, 92 insertions, 0 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;