aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 15:48:51 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 15:48:51 +0200
commit8926f163dad9d32dc06c3a142658a4e11d9c12c1 (patch)
tree4d36d1c18154cb46e6e80c59ef6c607972caa81e /packages
parent8d96cf2314b45e0737f932998b5422c27a2ae72e (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js5
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py5
-rw-r--r--packages/meshbay-hub/tests/test_identity_seam.py49
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py2
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 };
};