diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 10:42:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 10:43:16 +0200 |
| commit | 3ce051e134a432417fcaca4e8b5775d98f614a31 (patch) | |
| tree | c9e72a9a11e71bbf55abd63401041f06d3831fb1 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | ed9fb22ed703db38f9b07c00d17076f90aa4cbc8 (diff) | |
| download | meshbay-3ce051e134a432417fcaca4e8b5775d98f614a31.tar.gz | |
fix(node): group isolation, upload confinement, GEK seizure, admin challenge
Phase 11.5 — findings H1, C5a, H2, C5b, H5 (see second-review.md).
Batched together because the node-side changes share webrtc_server.py and
cannot be separated into working commits.
H1 — cross-group chat leak. chat_store, the peer registry and the display-name
cache were read from the shared transport context, and daemon.py hoisted the
FIRST group's chat store onto it. On a node hosting several groups every
group's messages went to one database, chat_history served them back to members
of every other group, and chat broadcast reached all peers regardless of group.
All three now resolve through _group_ctx().
C5a — upload confinement. Uploads landed in the shared root under a
client-chosen name and overwrote whatever was there. Any member could destroy
the operator's files, and by becoming the recorded uploader of the replaced
file could then delete it through the uploader path, bypassing the Ed25519
admin challenge. Uploads now go to a per-user quarantine (.uploads/{user_id}/),
refuse to overwrite, and enforce chunk ordering, a filename allowlist and a
size cap.
H2 — stored XSS in the node admin UI. Filenames chosen by any group member were
interpolated raw into the localhost UI, which has no authentication, so script
execution there equals control of the node admin API. Now html.escape()
throughout, textContent in the audit table, plus CSP/nosniff/no-referrer. The
CSP contains exfiltration but cannot stop injected inline script — escaping is
the fix.
C5b — group key seizure. gek_bundle_store wrote whatever any member sent and
auto-activated bundles addressed to the node operator. The operator's X25519
public key is public (the node publishes it in handshake_ack), so any member
could wrap a key of their choosing for it and take over the group, locking
every legitimate member out. Storing now requires an operator signature and
_try_activate_gek is removed: nothing arriving over MNP can set a live GEK.
H5 — unbound signing oracle. The node challenged with 32 raw random bytes and
the client signed them blind, so a signature named no operation, subject, node
or time. New meshbay_common/adminop.py defines a length-prefixed,
domain-separated transcript; both sides build it independently and the client
refuses to sign when the announced op/subject do not match its request.
BREAKING: a group admin who does not operate the node can no longer store GEK
bundles on it. Invites must be performed by the node operator.
Adds tests/test_security_regressions.py. Verified against pre-fix source via
git stash. Three pre-existing tests asserted the vulnerable behaviour as a
feature and were inverted: gek auto-activation, and the transport-wide
chat_store in test_daemon.
Tests: 109 node, 132 hub+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
4 files changed, 105 insertions, 19 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index ddff928..dee47ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -979,8 +979,10 @@ function GroupPage({ groupId, group, token, username, userId }) { const transport = transportRef.current; if (!transport || !transport.connected) return; try { + // Signs an explicit transcript built by transport.js, not opaque bytes from + // the node — see MeshBayCrypto.adminTranscript and finding H5. const signFn = (_sessionKeys && window.MeshBayKeys) - ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge) + ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) : null; await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); @@ -1405,9 +1407,16 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) { } const gekBytes = transport.gekRaw; - // Wrap GEK for invitee and store on node via P2P + // 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. + 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); + await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn); // Add member on hub (membership management only) await hubFetch(`/v1/groups/${groupId}/members/${username}`, { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 5ebf624..2346fa2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -230,6 +230,42 @@ function b64encode(bytes) { return btoa(String.fromCharCode(...bytes)); } +// ── Admin operation transcript ─────────────────────────────────────────────── +// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these +// bytes independently; they are never taken off the wire. +// +// Finding H5: the client used to sign 32 raw random bytes chosen by the node — a +// blind signing oracle. It now reconstructs a domain-separated, length-prefixed +// transcript naming the operation, subject, node and group, so the UI can show the +// user what they are authorizing and a signature cannot be reused elsewhere. + +const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1'); + +function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) { + const enc = new TextEncoder(); + const fields = [ + enc.encode(op), + enc.encode(nodePkB64), + enc.encode(groupId), + enc.encode(subject), + b64decode(nonceB64), + enc.encode(String(ts)), + ]; + let total = ADMIN_TRANSCRIPT_PREFIX.length; + for (const f of fields) total += 4 + f.length; + + const out = new Uint8Array(total); + out.set(ADMIN_TRANSCRIPT_PREFIX, 0); + let off = ADMIN_TRANSCRIPT_PREFIX.length; + for (const f of fields) { + new DataView(out.buffer).setUint32(off, f.length, false); + off += 4; + out.set(f, off); + off += f.length; + } + return out; +} + // ── GEK proof (HMAC-SHA256 for handshake challenge) ───────────────────────── async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { @@ -249,5 +285,5 @@ async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, - hmacGEK, + hmacGEK, adminTranscript, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index ff3da33..3d9c3c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -246,16 +246,22 @@ async function regenerateKeys(token, username, password) { }; } -async function signChallenge(skEdPkcs8B64, challengeB64) { +/** + * Sign an explicit byte string with the user's Ed25519 identity key. + * + * Takes bytes rather than a base64 blob from the wire: callers are expected to + * build the message themselves (see MeshBayCrypto.adminTranscript) so that the + * user's identity key is never applied to content the peer chose. Finding H5. + */ +async function signBytes(skEdPkcs8B64, message) { const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey( 'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']); - const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0)); - const sig = await crypto.subtle.sign('Ed25519', sk, challenge); + const sig = await crypto.subtle.sign('Ed25519', sk, message); return btoa(String.fromCharCode(...new Uint8Array(sig))); } window.MeshBayKeys = { - registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge, + registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes, deriveAuthKey, decryptBundleWithKey, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index ca9c60e..d636085 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -266,6 +266,39 @@ class MeshBayTransport { return msg; } + /** + * Authorize a privileged node operation with the user's Ed25519 identity key. + * + * The client rebuilds the signed transcript from the challenge fields and refuses + * to sign unless the operation and subject match what the user actually asked for. + * Previously the node sent 32 opaque random bytes and the client signed them + * blind, which let any peer obtain a signature over content of its choosing + * (finding H5). + */ + async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) { + if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) { + throw new Error( + `Refusing to sign: node asked to authorize "${challenge.op}" on ` + + `"${challenge.subject}", but the requested action was "${expectedOp}" ` + + `on "${expectedSubject}"`); + } + if (!signFn) throw new Error('Admin challenge received but no signing key available'); + + const transcript = window.MeshBayCrypto.adminTranscript( + challenge.op, challenge.node_pk, challenge.group_id, + challenge.subject, challenge.nonce, challenge.ts); + + const signature = await signFn(transcript); + const ack = await this._sendAndWait({ + type: 'admin_response', + v: '0.1', + op_id: challenge.op_id, + signature, + }); + if (ack.type === 'error') throw new Error(ack.detail); + return ack; + } + async deleteFile(fileId, signFn) { const msg = await this._sendAndWait({ type: 'file_delete', @@ -274,16 +307,7 @@ class MeshBayTransport { }); if (msg.type === 'error') throw new Error(msg.detail); if (msg.type === 'admin_challenge') { - if (!signFn) throw new Error('Admin challenge received but no signing key available'); - const signature = await signFn(msg.challenge); - const ack = await this._sendAndWait({ - type: 'admin_response', - v: '0.1', - file_id: fileId, - signature, - }); - if (ack.type === 'error') throw new Error(ack.detail); - return ack; + return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn); } return msg; } @@ -304,7 +328,15 @@ class MeshBayTransport { return msg; } - async storeGekBundle(userId, groupId, bundle) { + /** + * Store a wrapped GEK bundle on the node for a member. + * + * 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). + */ + async storeGekBundle(userId, groupId, bundle, signFn) { const msg = await this._sendAndWait({ type: 'gek_bundle_store', v: '0.1', @@ -315,6 +347,9 @@ class MeshBayTransport { wrapped_b64: bundle.wrapped_b64, }); if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'gek_bundle_store', userId, signFn); + } return msg; } |