aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
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',