diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 178 |
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', |