summaryrefslogtreecommitdiffstats
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/static/app.js137
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js178
5 files changed, 336 insertions, 37 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a006dfe..1ef878a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -68,6 +68,9 @@ async function getAllCachedIndexes() {
let _sessionKeys = null;
let _bundleKey = null;
let _pendingBundlePush = null;
+// A one-time pairing code the user just typed, consumed by the next connection
+// attempt. Deliberately not persisted: it is single-use and short-lived.
+let _pendingJoinCode = null;
function _openKeyDB() {
return new Promise((resolve, reject) => {
@@ -778,9 +781,23 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [uploading, setUploading] = useState(false);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
+ const [needsCode, setNeedsCode] = useState(false);
+ const [codeInput, setCodeInput] = useState('');
+ const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ const submitJoinCode = useCallback((e) => {
+ e.preventDefault();
+ const code = codeInput.trim();
+ if (!code) return;
+ _pendingJoinCode = code;
+ setCodeInput('');
+ setNeedsCode(false);
+ setError('');
+ setRetryKey(k => k + 1);
+ }, [codeInput]);
+
useEffect(() => {
if (menuOpen === null) return;
const close = () => setMenuOpen(null);
@@ -812,9 +829,12 @@ function GroupPage({ groupId, group, token, username, userId }) {
return;
}
- // Session keys for P2P GEK bundle fetch (node delivers wrapped GEK)
+ // Session keys for the P2P key exchange. skEdB64 belongs here too: the
+ // node identifies us by the Ed25519 identity, and join_request signs both
+ // public keys with it — without it we can neither join nor pair.
const sessionKeys = _sessionKeys ? {
skXB64: _sessionKeys.skXB64,
+ skEdB64: _sessionKeys.skEdB64,
pkXB64: _sessionKeys.pkXB64,
} : null;
@@ -824,7 +844,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = transport;
const ack = await transport.connect(
- nodeId, token, groupId, null, sessionKeys, _bundleKey, username);
+ nodeId, token, groupId, null, sessionKeys, _bundleKey, username,
+ userId, _pendingJoinCode);
+ _pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
@@ -881,6 +903,10 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
if (!cancelled) {
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
setError(err.message);
setStatus('error');
}
@@ -902,7 +928,7 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = null;
}
};
- }, [groupId, token]);
+ }, [groupId, token, retryKey]);
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
@@ -1079,6 +1105,17 @@ function GroupPage({ groupId, group, token, username, userId }) {
`}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${needsCode && html`
+ <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
+ <h4>${t('group.join_code_title')}</h4>
+ <p class="settings-hint">${t('group.join_code_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
+ </div>
+ </form>
+ `}
${dlState && html`
<div class="dl-bar">
<span class="dl-name">${dlState.name}</span>
@@ -1224,7 +1261,8 @@ function GroupPage({ groupId, group, token, username, userId }) {
${tab === 'members' && html`
<${MembersPanel} groupId=${groupId} group=${group} token=${token}
- transportRef=${transportRef} gekRef=${gekRef} />
+ transportRef=${transportRef} gekRef=${gekRef}
+ isNodeAdmin=${isNodeAdmin} userId=${userId} />
`}
`}
${status === 'offline' && html`
@@ -1365,13 +1403,41 @@ function _b64ToU8(b64) {
// ── Members Panel ────────────────────────────────────────────────────────
-function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
+function MembersPanel({ groupId, group, token, transportRef, gekRef,
+ isNodeAdmin, userId }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
+ const [inviteCode, setInviteCode] = useState(null);
+ const [pairCode, setPairCode] = useState('');
+ const [pairStatus, setPairStatus] = useState('');
+ const [pairing, setPairing] = useState(false);
+
+ // Pairing lives here rather than in Settings because this is where a live
+ // connection to the node exists — and it is offered only when the node itself
+ // says this account is its operator (is_node_admin comes from the authenticated
+ // handshake_ack, not from the hub).
+ const doPair = useCallback(async (e) => {
+ e.preventDefault();
+ const code = pairCode.trim();
+ if (!code) return;
+ setPairing(true);
+ setPairStatus('');
+ try {
+ const transport = transportRef && transportRef.current;
+ if (!transport || !transport.connected) throw new Error('Not connected to the node');
+ await transport.pairOperator(userId, code);
+ setPairCode('');
+ setPairStatus('paired');
+ } catch (err) {
+ setPairStatus(err.message);
+ } finally {
+ setPairing(false);
+ }
+ }, [pairCode, transportRef, userId]);
const loadMembers = useCallback(() => {
setLoading(true);
@@ -1393,36 +1459,34 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
if (!inviteUser.trim()) return;
setInviting(true);
setError('');
+ setInviteCode(null);
try {
const transport = transportRef && transportRef.current;
const username = inviteUser.trim();
-
- // Fetch invitee's public keys (hub = public key directory)
- const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0));
-
- // Get raw GEK from the active transport connection
- if (!transport || !transport.connected || !transport.gekRaw) {
- throw new Error('Not connected to node or no GEK available');
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node — it must be online to invite');
}
- const gekBytes = transport.gekRaw;
- // Wrap GEK for invitee and store on node via P2P.
- // The node requires the operator's Ed25519 signature to accept the bundle
- // (C5b), so inviting from a browser that is not the node operator's will be
- // refused by the node — deliberately: only the operator decides what is
- // stored on their machine.
+ // The hub is asked for the account id, and nothing else. It is no longer
+ // asked for the invitee's public key: the node wraps the group key itself,
+ // for a key the invitee proves possession of when they connect (H3). A hub
+ // that answered with the wrong account here would produce an invite whose
+ // code it never learns — the code goes to a human, out of band.
+ const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
+
const signFn = (_sessionKeys && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
: null;
- const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes);
- await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn);
+ const result = await transport.createInvite(
+ account.user_id, groupId, username, signFn);
- // Add member on hub (membership management only)
+ // Membership on the hub is what lets them reach the node at all; the code
+ // is what gets them the key.
await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
method: 'POST', token, body: {},
});
+ setInviteCode({ username, code: result.code, expires: result.expires_at });
setInviteUser('');
loadMembers();
} catch (err) {
@@ -1430,7 +1494,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
} finally {
setInviting(false);
}
- }, [groupId, token, inviteUser, loadMembers]);
+ }, [groupId, token, inviteUser, loadMembers, transportRef]);
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
@@ -1461,6 +1525,15 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
<form class="invite-form" onSubmit=${doInvite}>
<h4>${t('members.invite_title')}</h4>
${error && html`<p class="error-msg">${error}</p>`}
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0">
+ ${inviteCode.code}
+ </p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
<div style="display:flex;gap:8px">
<input type="text" placeholder="${t('members.username_placeholder')}"
value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
@@ -1470,6 +1543,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
</div>
</form>
`}
+ ${isNodeAdmin && html`
+ <form class="invite-form" onSubmit=${doPair}>
+ <h4>${t('members.pair_title')}</h4>
+ <p class="settings-hint">${t('members.pair_hint')}</p>
+ ${pairStatus && html`
+ <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
+ </p>
+ `}
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${pairing}>
+ ${pairing ? '...' : t('members.pair_btn')}
+ </button>
+ </div>
+ </form>
+ `}
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index d18eeae..21bf05d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -314,6 +314,31 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin
return new Uint8Array(sig);
}
+// ── Join / pairing transcript ───────────────────────────────────────────────
+
+// Mirrors meshbay_common/join.py. Signing both of our public keys together binds
+// the X25519 key to the Ed25519 identity the node pins, so the node can safely
+// wrap the group key for a key that came over the wire instead of one fetched
+// from the hub's directory (H3). nonce_node ties it to this connection.
+const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1');
+
+function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(userId),
+ enc.encode(pkEdB64),
+ enc.encode(pkXB64),
+ nonceNode,
+ enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(JOIN_PREFIX.length + body.length);
+ out.set(JOIN_PREFIX, 0);
+ out.set(body, JOIN_PREFIX.length);
+ return out;
+}
+
function constantTimeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
@@ -333,5 +358,5 @@ window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
- verifyNodeSignature, constantTimeEqual,
+ joinTranscript, verifyNodeSignature, constantTimeEqual,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index b0de4b9..c279fd1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -240,6 +240,22 @@ const en = {
'members.invite_title': 'Invite member',
'members.username_placeholder': 'Username',
'members.invite_btn': 'Invite',
+ 'members.pair_title': 'Pair this browser with your node',
+ 'members.pair_hint': 'Your node only accepts operator actions — invites, file '
+ + 'deletion — from a browser it has been paired with. Run '
+ + '`meshbay-node operator pair` on the node and type the code here. The code '
+ + 'never passes through the hub, which is what stops the hub from claiming to '
+ + 'be you.',
+ 'members.pair_btn': 'Pair',
+ 'members.pair_success': 'This browser is now paired with the node.',
+ 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way '
+ + 'you normally talk. It works once, and it never passes through the hub.',
+ 'members.invite_code_hint': 'They enter it the first time they open this group. '
+ + 'You do not need to be online then.',
+ 'group.join_code_title': 'This node needs to recognise you',
+ 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter '
+ + 'it here. After that this browser is recognised and you will not be asked again.',
+ 'group.join_code_btn': 'Join',
'notif.title': 'Notifications',
'notif.empty': 'No notifications',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index e430201..81c4130 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -316,6 +316,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: 0.85em;
}
+.success-msg {
+ background: #16a34a20;
+ color: var(--success);
+ border: 1px solid var(--success);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.85em;
+}
+
+.settings-hint {
+ font-size: 0.85em;
+ color: var(--text-dim);
+ margin-bottom: 8px;
+}
+
/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */
.group-grid {
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',