diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:48:51 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:48:51 +0200 |
| commit | 8926f163dad9d32dc06c3a142658a4e11d9c12c1 (patch) | |
| tree | 4d36d1c18154cb46e6e80c59ef6c607972caa81e /packages | |
| parent | 8d96cf2314b45e0737f932998b5422c27a2ae72e (diff) | |
| download | meshbay-8926f163dad9d32dc06c3a142658a4e11d9c12c1.tar.gz | |
refactor(hub): the transport holds an identity, never a private key
Two public keys, sign() and shared(); the apps take transport.signFn. What
holds the keys (this page, or the desktop main process) is the identity's
business alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
13 files changed, 140 insertions, 99 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 0ca8ee5..27e97d3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -278,17 +278,17 @@ async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) { // ── GEK unwrapping (ECIES) ───────────────────────────────────────────────────── -async function unwrapGEK(bundle, skXPkcs8, pkXRaw) { +/** + * Unwrap a group key wrapped for our X25519 key. `shared(pkEphRaw)` is the + * agreement with the ephemeral key — done by whatever holds the private key + * (the identity object, transport.js), so this never sees one. + */ +async function unwrapGEK(bundle, shared, pkXRaw) { const pkEphRaw = b64decode(bundle.pk_eph_b64); const nonce = b64decode(bundle.nonce_b64); const wrapped = b64decode(bundle.wrapped_b64); - const skX = await crypto.subtle.importKey( - 'pkcs8', skXPkcs8, { name: 'X25519' }, false, ['deriveBits']); - const pkEph = await crypto.subtle.importKey( - 'raw', pkEphRaw, { name: 'X25519' }, false, []); - const sharedBits = await crypto.subtle.deriveBits( - { name: 'X25519', public: pkEph }, skX, 256); + const sharedBits = await shared(pkEphRaw); const sharedKey = await crypto.subtle.importKey( 'raw', sharedBits, 'HKDF', false, ['deriveKey']); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index cda34b5..38157b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -332,10 +332,7 @@ function FilesPanel({ const transport = transportRef.current; if (!transport || !transport.connected) return; try { - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; await transport.deleteDirectory(dir, signFn); applyIndex(await transport.fetchIndex()); } catch (err) { @@ -351,10 +348,7 @@ function FilesPanel({ // the node — see MeshBayCrypto.adminTranscript and finding H5. // Signed with the identity this node pinned for us — the only one it // will accept, and the only one we hold here. - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; await transport.deleteFile(entry.id, signFn); applyIndex(await transport.fetchIndex()); } catch (err) { @@ -845,10 +839,7 @@ function FilesPanel({ ev.stopPropagation(); const transport = transportRef.current; if (!transport) return; - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; const fn = isEjected ? () => transport.plugRoot(groupId, d, signFn) : () => transport.ejectRoot(groupId, d, signFn); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index b18c811..fcb905b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -380,7 +380,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // No keys are carried in: the transport fetches this node's identity // from the node, or creates one there on a first join. - const sessionKeys = null; + const identity = null; setStatus('connecting'); // Renewed here rather than taken from the prop. This effect no longer @@ -433,7 +433,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }; try { ack = await transport.connect( - n.node_id, live, groupId, null, sessionKeys, session.bundleKey, + n.node_id, live, groupId, null, identity, session.bundleKey, username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node); break; } catch (e) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index f16bdf8..bde218b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -450,12 +450,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // every render is a new prop identity every render, and the callbacks that // close over it in the table below are memoised on it. const adminSignFn = useCallback((transcript) => { - const sk = transportRef.current && transportRef.current.sessionKeys - && transportRef.current.sessionKeys.skEdB64; - if (!sk || !window.MeshBayKeys) { - throw new Error(t('node.root_no_signing_key')); - } - return window.MeshBayKeys.signBytes(sk, transcript); + const sign = transportRef.current && transportRef.current.signFn; + if (!sign) throw new Error(t('node.root_no_signing_key')); + return sign(transcript); }, [transportRef]); const loadNodeInfo = useCallback(async () => { @@ -608,10 +605,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; await transport.setAppsEnabled(next, signFn); if (onEnabledApps) onEnabledApps(next); } catch (err) { @@ -648,10 +642,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; await transport.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn); const applied = { reconcile_interval_secs: reconcileMinutes * 60, @@ -779,10 +770,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, await platform.node.op('revokeMember', { userId: member.user_id, groupId }); } catch { /* best effort — node may not host this group */ } } else if (transport && transport.connected && operatorPaired) { - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; try { await transport.revokeMember(member.user_id, signFn); } catch (err) { nodeError = err.message; } @@ -878,10 +866,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Signed with the identity this node pinned for us — the only one it // will accept, and the only one we hold here. - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + const signFn = transport.signFn; const result = await transport.createInvite( account.user_id, groupId, username, signFn); @@ -939,10 +924,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const linkSignFn = useCallback(() => { const transport = transportRef && transportRef.current; - const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; - return (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + return (transport && transport.signFn) || null; }, [transportRef]); // A redeemed link is not shown: whoever used it is in the members list above, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js index c7c3f47..d5226fc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js @@ -17,7 +17,7 @@ extendTransport(class { 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) { + if (!this._identity) { throw new Error('Identity keys unavailable in this browser — sign in again'); } if (!this._nonceNode || !this.nodePk) { @@ -28,14 +28,13 @@ extendTransport(class { // 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 { pkEdB64, pkXB64 } = this._identity; 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 sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'join_request', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js index 270c76e..f0604ce 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -157,7 +157,7 @@ extendTransport(class { const epoch = this.chatEpoch || keys.current; const epochKey = keys.byEpoch.get(epoch); if (!epochKey) throw new Error('No chat key for this group — reconnect'); - if (!this.devicePk || !this._sessionKeys || !this._sessionKeys.skEdB64) { + if (!this.devicePk || !this._identity) { throw new Error('This device is not identified to the node — reconnect'); } @@ -172,8 +172,7 @@ extendTransport(class { const { nonce, ct } = await C.sealChat( epochKey, gid, epoch, this.devicePk, plaintext); const device = C.b64decode(this.devicePk); - const sig = C.b64decode(await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, + const sig = C.b64decode(await this._identity.sign( C.chatSigningTranscript(gid, epoch, device, nonce, ct))); const msg = await this._sendAndWait({ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js index 199b6a2..f17b1b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js @@ -94,7 +94,7 @@ extendTransport(class { * 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) { + if (!this._identity) { throw new Error('Identity keys unavailable in this browser — sign in again'); } if (!this._nonceNode || !this.nodePk) { @@ -102,13 +102,12 @@ extendTransport(class { } const C = window.MeshBayCrypto; - const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); - const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + const { pkEdB64, pkXB64 } = this._identity; 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 sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'join_request', @@ -133,9 +132,8 @@ extendTransport(class { // 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); + const gekRaw = await C.unwrapGEK(resp, (pk) => this._identity.shared(pk), myPkX); this._gekRaw = gekRaw; // What the node's roster says this identity is, which is not what the hub // says: `operator` here means this browser's key was paired with the node, @@ -154,15 +152,14 @@ extendTransport(class { * device cannot be handed a substituted key and sign for it by mistake. */ async requestDeviceAdd(userId) { - if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + if (!this._identity) { 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 { pkEdB64, pkXB64 } = this._identity; // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code // so it reads and types the same way. @@ -176,8 +173,7 @@ extendTransport(class { const ts = Math.floor(Date.now() / 1000); const transcript = C.deviceRequestTranscript( this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, transcript); + const sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'device_add_request', v: '0.1', @@ -196,7 +192,7 @@ extendTransport(class { * nothing to sign and nothing for a person to misread. */ async approveDevice(userId, code) { - if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + if (!this._identity) { throw new Error('Identity keys unavailable in this browser — sign in again'); } if (!this._nonceNode || !this.nodePk) { @@ -231,8 +227,7 @@ extendTransport(class { const ts = Math.floor(Date.now() / 1000); const transcript = C.deviceAddTranscript( this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, transcript); + const sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'device_add', v: '0.1', pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, @@ -253,8 +248,7 @@ extendTransport(class { const ts = Math.floor(Date.now() / 1000); const transcript = C.deviceAddTranscript( this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, transcript); + const sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig, }); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js index a9229dc..e0ae0fe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -110,7 +110,7 @@ async function rewrapAllNodes(o) { anyOk = true; continue; } - const sk = tp.sessionKeys; + const sk = tp.identity && tp.identity.raw; if (!sk) { lastErr = new Error('identity not recovered'); continue; } const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0)); const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0)); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index b504a28..3586cb7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -39,6 +39,33 @@ async function _pkEdFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } +/** + * This account's identity on one node, as the rest of the transport sees it: + * two public keys, a signature and an X25519 agreement — never a private key. + * + * In a browser the keys are in this page, and this wraps them. In the desktop + * application they stay in the main process, which signs and agrees on the + * page's behalf (`platform.keys`), and the object has the same shape — so + * nothing below knows or cares where the keys are. `raw` exists only for keys + * held here, for the one thing that needs them: re-sealing a bundle during a + * passphrase change. + */ +async function _identityFromKeys(skEdB64, skXB64) { + const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); + return { + pkEdB64: await _pkEdFromSk(skEdB64), + pkXB64: await _pkFromSk(skXB64), + sign: (bytes) => window.MeshBayKeys.signBytes(skEdB64, bytes), + async shared(peerPkRaw) { + const sk = await crypto.subtle.importKey( + 'pkcs8', skXRaw, { name: 'X25519' }, false, ['deriveBits']); + const pk = await crypto.subtle.importKey('raw', peerPkRaw, { name: 'X25519' }, false, []); + return crypto.subtle.deriveBits({ name: 'X25519', public: pk }, sk, 256); + }, + raw: { skEdB64, skXB64 }, + }; +} + // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -506,7 +533,7 @@ class MeshBayTransport { // is being torn down. this._closed = false; // The arguments connect() was last given, minus the token (refreshed at - // reconnect time — see onNeedToken) and sessionKeys (kept live on `this`, + // reconnect time — see onNeedToken) and the identity (kept live on `this`, // since a reconnect must reuse the identity connect() settled on, not // whatever the very first caller passed in — see _reconnectLoop). this._connectArgs = null; @@ -613,7 +640,12 @@ class MeshBayTransport { // refresh the hub session token (see group-page.js's ensureFreshToken). set onNeedToken(fn) { this._onNeedToken = fn; } - get sessionKeys() { return this._sessionKeys; } + get identity() { return this._identity; } + /** Signs with this node's identity, or null when there is none yet. */ + get signFn() { + const id = this._identity; + return id ? (transcript) => id.sign(transcript) : null; + } /** Set on a first join: the identity created for this node, still to be left with it. */ get newNodeBundle() { return this._newNodeBundle || null; } @@ -662,7 +694,7 @@ class MeshBayTransport { return (await r.json()).mnp_token; } - async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, + async connect(nodeId, jwtToken, groupId, gekRaw, identity, bundleKey, username, userId, joinCode, recoveryKey, joinNodePk, nodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() @@ -683,7 +715,7 @@ class MeshBayTransport { // never actually trying the fresh token connect() had just been handed. this._accessToken = jwtToken; this._gekRaw = gekRaw || null; - this._sessionKeys = sessionKeys || null; + this._identity = identity || null; this._bundleKey = bundleKey || null; this._recoveryKey = recoveryKey || null; this._username = username || null; @@ -965,7 +997,7 @@ class MeshBayTransport { // them — and an operator who cracks the copy on their own disk gets a key // that opens nothing anywhere else. let fresh = false; - if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) { + if (!this._identity && this._bundleKey && window.MeshBayKeys) { const K = window.MeshBayKeys; // A bundle is sealed for this account on this node — the key the node // just proved above, and no other. @@ -1021,8 +1053,7 @@ class MeshBayTransport { } if (keys) { - const pkXB64 = await _pkFromSk(keys.skX); - this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + this._identity = await _identityFromKeys(keys.skEd, keys.skX); } else { // Either the node has never seen us, or it holds a stale bundle we // cannot open (wrapped under a passphrase we no longer use, with no @@ -1032,9 +1063,7 @@ class MeshBayTransport { // copy is left too when a recovery key is in hand (§4.3). const id = await K.generateNodeIdentity( this._bundleKey, this._recoveryKey, sealedFor); - this._sessionKeys = { - skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, - }; + this._identity = await _identityFromKeys(id.skEdB64, id.skXB64); this._newNodeBundle = id.bundleEnc; this._newNodeBundleRecovery = id.bundleEncRecovery || null; fresh = true; @@ -1043,15 +1072,16 @@ class MeshBayTransport { // An identity this node already knows still needs its group key, which the // node wraps on every connection. - if (!gekRaw && this._sessionKeys && !fresh) { + if (!gekRaw && this._identity && !fresh) { const bundleResp = await this._sendAndWait({ type: 'gek_bundle_fetch', v: '0.1', }); if (bundleResp.type === 'gek_bundle_resp' && bundleResp.found) { - const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); - const myPkX = Uint8Array.from(atob(this._sessionKeys.pkXB64), c => c.charCodeAt(0)); + const id = this._identity; + const myPkX = Uint8Array.from(atob(id.pkXB64), c => c.charCodeAt(0)); try { - gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); + gekRaw = await window.MeshBayCrypto.unwrapGEK( + bundleResp, (pk) => id.shared(pk), myPkX); this._gekRaw = gekRaw; } catch (e) { console.warn('[MeshBay] stored GEK bundle did not open; joining instead'); @@ -1071,7 +1101,7 @@ class MeshBayTransport { const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk); if (linkRefusal) { this._joinError = linkRefusal; - } else if (!gekRaw && this._sessionKeys && userId) { + } else if (!gekRaw && this._identity && userId) { try { gekRaw = await this.joinGroup(userId, groupId, joinCode); } catch (e) { @@ -1080,7 +1110,7 @@ class MeshBayTransport { } } - if (!gekRaw && !this._sessionKeys) { + if (!gekRaw && !this._identity) { // No key in this browser to sign or unwrap with — `bundleKey` was null. // The caller (group-page.js) shows a passphrase prompt on this reason // and retries; a code prompt would be useless, since a code proves who @@ -1237,7 +1267,7 @@ class MeshBayTransport { * hangs, the textbox is dead" report. Every exit below names itself. */ async _announceDevice() { - if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + if (!this._identity) { return this._setDevicePk('', 'no identity key in this session'); } if (!this._nonceNode || !this.nodePk || !this._userId) { @@ -1248,13 +1278,12 @@ class MeshBayTransport { // Derived from our own secret key, never read back from anywhere — the same // rule as pairOperator: signing a public key someone handed us is the // substitution this mechanism exists to close. - const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkEdB64 = this._identity.pkEdB64; const ts = Math.floor(Date.now() / 1000); const transcript = C.deviceHelloTranscript( this.nodePk, this._groupId || '', this._userId, pkEdB64, this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, transcript); + const sig = await this._identity.sign(transcript); const resp = await this._sendAndWait({ type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, @@ -1325,7 +1354,7 @@ class MeshBayTransport { let ack; try { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, - this._sessionKeys, args.bundleKey, args.username, + this._identity, args.bundleKey, args.username, args.userId, args.joinCode, undefined, args.joinNodePk, args.nodePk); } finally { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 9e2db1c..4757395 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -493,10 +493,7 @@ function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { // each caller builds one from the connection it already has. function buildSignFn(transportRef) { const transport = transportRef.current; - const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; - return (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; + return (transport && transport.signFn) || null; } function TmdbSearchOverlay({ diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index 7569628..e5cfc8b 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -171,7 +171,8 @@ function makeTransport(name, chatReply) { tp._groupId = '__GROUP_ID__'; tp._gekRaw = hex('__GEK_HEX__'); tp.chatEpoch = 1; - tp._sessionKeys = { skEdB64: SK_ED_B64 }; + tp._identity = { pkEdB64: DEVICE_PK_B64, + sign: (bytes) => window.MeshBayKeys.signBytes(SK_ED_B64, bytes) }; tp.devicePk = DEVICE_PK_B64; tp._send = (obj) => { log.push('sent ' + obj.type); @@ -314,7 +315,7 @@ async function runScenario(name, chatReply, duringSession) { // connect() drops it before it touches the network. Nothing about this // step is simulated, and the clear is not poked in by the test. await tp.connect('node-1', 'token', tp._groupId, tp._gekRaw, - tp._sessionKeys, null, 'me', 'user-me').then( + tp._identity, null, 'me', 'user-me').then( () => log.push('reconnect: connect() unexpectedly succeeded'), (e) => log.push('reconnect: connect() stopped at signaling, as expected: ' + (e && e.message || e))); diff --git a/packages/meshbay-hub/tests/test_identity_seam.py b/packages/meshbay-hub/tests/test_identity_seam.py new file mode 100644 index 0000000..15db6d8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_identity_seam.py @@ -0,0 +1,49 @@ +""" +Nothing in the interface reads an identity private key except the identity. + +The transport sees an identity on a node as two public keys, a signature and an +X25519 agreement (`transport.js`, `_identityFromKeys`). In a browser that object +wraps keys held in the page; in the desktop application the keys stay in the +main process and the same object asks it to sign. That only holds if no other +code reaches past the object for a key — which is what every admin op, device +link, chat message and app used to do, twenty times over, and is what this +test refuses. +""" + +import re + +from spa_source import STATIC + +# Where a private key may be named: minted and sealed (keyderive.js), wrapped +# into an identity (transport.js), re-sealed during a passphrase change in a +# browser (transport-rewrap.js). +ALLOWED = {"keyderive.js", "transport.js", "transport-rewrap.js"} + + +def test_only_the_identity_touches_a_private_key(): + offenders = [] + for path in STATIC.glob("*.js"): + if path.name in ALLOWED: + continue + text = path.read_text(encoding="utf-8") + if re.search(r"skEdB64|skXB64|sessionKeys|signBytes\(", text): + offenders.append(path.name) + assert not offenders, f"these read a private key directly: {offenders}" + + +def test_in_the_transport_only_the_identity_factory_signs_with_a_raw_key(): + text = (STATIC / "transport.js").read_text(encoding="utf-8") + factory = text[text.index("async function _identityFromKeys"):] + factory = factory[:factory.index("\n}\n")] + rest = text.replace(factory, "") + assert "signBytes(" in factory + assert "signBytes(" not in rest, "the transport signs with a raw key outside the identity" + assert "skXB64" not in rest.replace("id.skXB64", ""), \ + "the transport reads the X25519 private key outside the identity" + + +def test_a_group_key_is_unwrapped_through_the_identity(): + crypto = (STATIC / "crypto.js").read_text(encoding="utf-8") + unwrap = crypto[crypto.index("async function unwrapGEK"):] + unwrap = unwrap[:unwrap.index("\n}\n")] + assert "shared(pkEphRaw)" in unwrap and "pkcs8" not in unwrap diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index 79a5bf5..9e93140 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -74,7 +74,7 @@ T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) { rewrapOnlySeen.push(this._rewrapOnly === true); const s = NODES[nodeId] || {}; if (s.throws) throw new Error(s.throws); - this._sessionKeys = s.sessionKeys || null; + this._identity = s.sessionKeys ? { raw: s.sessionKeys } : null; this._newNodeBundle = s.newNodeBundle || null; return { ok: true }; }; |