summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
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 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js48
1 files changed, 48 insertions, 0 deletions
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;