aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
commitf15efd23f66c521ca9206789482bb38e7326eeb4 (patch)
treef069b741d3fe0114c3b5889c02dc0392c5201f68 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parentaab4bc98a3361d9f23e048a52705baa2f4a4a078 (diff)
downloadmeshbay-f15efd23f66c521ca9206789482bb38e7326eeb4.tar.gz
feat(node)!: the node wraps the group key — closes H3 and M3
The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md 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.js178
1 files changed, 165 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 50304f3..0bffeae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -26,6 +26,31 @@ async function _pkFromSk(skPkcs8B64) {
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
+async function _pkEdFromSk(skPkcs8B64) {
+ const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0));
+ const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'Ed25519' }, true, ['sign']);
+ const jwk = await crypto.subtle.exportKey('jwk', sk);
+ const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = b64.length % 4;
+ return pad ? b64 + '='.repeat(4 - pad) : b64;
+}
+
+const JOIN_REFUSALS = {
+ code_required: 'This node does not know this browser yet. Ask the node operator '
+ + 'for a pairing code (meshbay-node operator pair).',
+ code_invalid: 'That pairing code is not valid — it may be mistyped, expired, '
+ + 'already used, or issued for a different account.',
+ key_changed: 'This account is already paired with a different key on this node. '
+ + 'If you reset your keys, the operator must unpin you before pairing again.',
+ not_authorized_for_group: 'The node does not list you as a member of this group. '
+ + 'Being a member on the hub is not enough — ask the operator for an invite.',
+ no_gek: 'This group has no key yet. The node operator must run '
+ + '`meshbay-node gek-init` for it.',
+ signature_invalid: 'The node rejected the signature over your keys.',
+ stale_request: 'Your clock is too far from the node\'s — check the system time.',
+ group_mismatch: 'The node refused a request naming a different group.',
+};
+
class MeshBayTransport {
constructor(hubUrl, accessToken) {
this._hubUrl = hubUrl;
@@ -53,11 +78,14 @@ class MeshBayTransport {
get sessionKeys() { return this._sessionKeys; }
- async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username) {
+ async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
+ userId, joinCode) {
this._gekRaw = gekRaw || null;
this._sessionKeys = sessionKeys || null;
this._bundleKey = bundleKey || null;
this._username = username || null;
+ this._userId = userId || null;
+ this._joinError = null;
this._pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
@@ -191,8 +219,22 @@ class MeshBayTransport {
}
}
+ // No stored bundle: ask the node to recognise us and wrap the key itself.
+ // This is the normal path for anyone who joined after the invite redesign —
+ // no bundle is pre-stored for members any more. A code is needed only the
+ // first time this node sees this account.
+ if (!gekRaw && this._sessionKeys && userId) {
+ try {
+ gekRaw = await this.joinGroup(userId, groupId, joinCode);
+ } catch (e) {
+ // The UI turns this into "ask the operator for an invite code".
+ this._joinError = e;
+ }
+ }
+
if (!gekRaw) {
- throw new Error('Node requires GEK proof but no GEK available');
+ throw this._joinError
+ || new Error('Node requires GEK proof but no GEK available');
}
const C = window.MeshBayCrypto;
@@ -203,6 +245,9 @@ class MeshBayTransport {
_extractDtlsFingerprint(this._rawAnswerSdp),
);
const nonceNode = C.b64decode(reply.nonce);
+ // Kept for the life of the connection: a join_request is signed over it,
+ // which is what stops one being lifted onto another connection.
+ this._nonceNode = nonceNode;
const gid = groupId || '';
const proof = await C.handshakeProof(
@@ -249,6 +294,59 @@ class MeshBayTransport {
'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
}
+ /**
+ * Pair this browser with the node using a one-time code (M3, and the same
+ * substitution as H3).
+ *
+ * The node has no way to know which key belongs to its operator unless someone
+ * tells it locally — asking the hub would let the hub name itself node
+ * administrator. The code comes from `meshbay-node operator pair`, over SSH, and
+ * the hub never sees it.
+ */
+ async pairOperator(userId, code) {
+ if (!this._connected) throw new Error('Not connected to the node');
+ if (!userId) throw new Error('Missing user id');
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ // Both public keys are derived from OUR OWN secret keys, never read back from
+ // the hub: signing a public key the directory handed us would reintroduce the
+ // substitution this whole mechanism exists to close.
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ // group_id is empty: operator authority is node-wide, not per group.
+ const transcript = C.joinTranscript(
+ this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused');
+ if (resp.type !== 'join_result' || !resp.ok) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`);
+ err.reason = reason;
+ throw err;
+ }
+ return resp;
+ }
+
async fetchIndex() {
const msg = await this._sendAndWait({ type: 'index_sync', v: '0.1' });
if (msg.type === 'error') throw new Error(msg.detail);
@@ -364,30 +462,84 @@ class MeshBayTransport {
}
/**
- * Store a wrapped GEK bundle on the node for a member.
+ * Ask the node for a one-time pairing code admitting `userId` to this group.
*
- * Node-operator operation: the node answers with an admin challenge and only the
- * pinned operator key is accepted. Any member used to be able to write bundles —
- * including one addressed to the operator, which the node then auto-adopted as the
- * live group key (finding C5b).
+ * This replaces wrapping the group key in the browser. We no longer fetch the
+ * invitee's public key from the hub, so the hub can no longer answer with its own
+ * and be handed the group key (H3). The node wraps the key later, itself, for a
+ * key the invitee proves possession of.
+ *
+ * Returns {code, expires_at} — the code is displayed once and passed to the
+ * invitee out of band.
*/
- async storeGekBundle(userId, groupId, bundle, signFn) {
+ async createInvite(userId, groupId, username, signFn) {
const msg = await this._sendAndWait({
- type: 'gek_bundle_store',
+ type: 'invite_create',
v: '0.1',
user_id: userId,
group_id: groupId,
- pk_eph_b64: bundle.pk_eph_b64,
- nonce_b64: bundle.nonce_b64,
- wrapped_b64: bundle.wrapped_b64,
+ username: username || '',
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'gek_bundle_store', userId, signFn);
+ return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
}
return msg;
}
+ /**
+ * Ask the node to recognise us and hand over the group key.
+ *
+ * Sent when we hold no GEK for a group. `code` is needed only the first time
+ * this node sees this account (and not at all in an open-join group).
+ */
+ async joinGroup(userId, groupId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ const transcript = C.joinTranscript(
+ this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: groupId || '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
+ if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
+ // The UI reacts to `code_required` by asking for one; everything else is
+ // shown as-is.
+ err.reason = reason;
+ throw err;
+ }
+
+ // Unwrap with our own secret key — the node wrapped for the public key we
+ // just proved we hold, so nobody else can open this.
+ const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
+ const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
+ const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
+ this._gekRaw = gekRaw;
+ return gekRaw;
+ }
+
async storeKeypairBundle(bundleEnc) {
const msg = await this._sendAndWait({
type: 'keypair_bundle_store',