diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 10 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 111 |
2 files changed, 92 insertions, 29 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index d2e19b7..4d26c6d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -111,8 +111,9 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { // ── Sealing a payload under the group key ──────────────────────────────────── // -// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta` and the -// `handshake_ack` config payload travel sealed under a GEK-derived subkey; the +// Mirrors meshbay_common/groupbox.py. `index_sync`, `index_delta`, the +// `handshake_ack` config payload and both halves of an upload travel sealed +// under a GEK-derived subkey; the // routing fields (type, v, group_id) and the ack's own authentication (node_pk, // proof, sig) stay in clear, because a receiver must route, version-check and // *authenticate* before it would trust a decryption. @@ -126,6 +127,11 @@ async function decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct) { const GROUPBOX_INFO = { index: new TextEncoder().encode('meshbay:index:v1'), ack: new TextEncoder().encode('meshbay:ack:v1'), + // MNP 2.0: `file_upload` and `file_upload_ack`. This is the one purpose that + // seals *towards* the node — it holds the GEK for its own group — and the one + // with real message volume, one per 48 KB chunk. groupbox.py carries the + // nonce-collision arithmetic that makes a random 96-bit nonce fine at that rate. + upload: new TextEncoder().encode('meshbay:upload:v1'), }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 2668e02..681a9ba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -248,7 +248,7 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '1.1'; +const MNP_V = '2.0'; const MNP_V_MIN = '1.0'; // Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's @@ -301,10 +301,17 @@ class MeshBayTransport { this._onStreamEnd = null; this._onStreamError = null; this._onIndexSync = null; - // filename → the uploader waiting on it. Keyed rather than FIFO because - // several uploads may be in flight at once and their acks interleave; the - // node names the file in every one. + // upload_id → the uploader waiting on it. Keyed rather than FIFO because + // several uploads may be in flight at once and their acks interleave. + // + // It was keyed by filename until MNP 2.0, which is no longer possible: the + // name is sealed under the group key, and echoing it in clear so the two + // sides could match on it would give back precisely what the seal is for. + // `upload_id` is drawn per upload here and is opaque to the node. this._uploaders = new Map(); + // Names, not ids: the "already being uploaded" guard is about the file the + // caller passed, and two `uploadFile` calls for one file draw two ids. + this._inFlightUploads = new Set(); // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -381,6 +388,19 @@ class MeshBayTransport { if (!m) return false; return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1); } + /** + * Whether the node opens a sealed upload (MNP 2.0). + * + * A 1.x node reads `filename` and `data` off the message itself, finds + * neither — they are inside the seal — and answers "Missing filename or + * data", an error about the wrong thing that names no upload_id and so fails + * every upload in flight. Asked before sending rather than discovered after, + * for the same reason `supportsAppOps` is. + */ + get supportsSealedUpload() { + const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || ''); + return !!m && Number(m[1]) >= 2; + } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } @@ -1812,10 +1832,21 @@ class MeshBayTransport { */ async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) { // The same file twice at once would confuse the node, which keys its own - // upload state by name — and would race for the same destination. - if (this._uploaders.has(file.name)) { + // upload state by name — and would race for the same destination. The guard + // is by name for that reason, even though the map below is keyed by id. + if (this._inFlightUploads.has(file.name)) { throw new Error(`${file.name} is already being uploaded`); } + if (!this._gekRaw) throw new Error('This group has no key on this device'); + if (!this.supportsSealedUpload) { + throw new Error( + 'This node is running an older MeshBay and cannot accept an upload ' + + 'from this page. Its operator has to update it.'); + } + const C = window.MeshBayCrypto; + const groupId = (this._connectArgs && this._connectArgs.groupId) || ''; + this._inFlightUploads.add(file.name); + const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16))); const size = chunkSize || UPLOAD_CHUNK_SIZE; const total = Math.max(1, Math.ceil(file.size / size)); let acked = 0; @@ -1823,16 +1854,32 @@ class MeshBayTransport { let failure = null; const acks = []; - this._uploaders.set(file.name, (msg) => { - if (msg.type === 'error') { - failure = new Error(msg.detail || 'Upload refused'); - } else if (msg.stored_as) { - stored = msg; - } + const wake = () => { acked += 1; if (onProgress) onProgress(Math.min(file.size, acked * size), file.size); const waiter = acks.shift(); if (waiter) waiter(); + }; + this._uploaders.set(uploadId, (msg) => { + if (msg.type === 'error') { + failure = new Error(msg.detail || 'Upload refused'); + wake(); + return; + } + // The ack is sealed too — `stored_as` and the folder it landed in name + // the operator's content. Opening it is what makes the result usable, so + // a failure here fails the upload rather than being swallowed: a chat + // attachment that cannot learn its stored name would point at nothing. + C.openGroup(this._gekRaw, 'upload', 'file_upload_ack', groupId, msg) + .then((plain) => { + const payload = msgpack_decode(plain); + if (payload.stored_as) stored = payload; + }) + .catch((e) => { + failure = new Error( + `The node's upload reply did not open under the group key (${e.message})`); + }) + .finally(wake); }); const nextAck = () => new Promise(r => acks.push(r)); @@ -1854,15 +1901,20 @@ class MeshBayTransport { const buf = new Uint8Array( await file.slice(i * size, (i + 1) * size).arrayBuffer()); + // The name, the destination and the bytes go inside the seal together. + // Mirrors `file_upload_wire` in meshbay_common/protocol.py; only the + // fields the node routes on stay outside it. + const sealed = await C.sealGroup( + this._gekRaw, 'upload', 'file_upload', groupId, + msgpack_encode({ filename: file.name, data: buf, + dir: dir || '', root: root || '' })); this._send({ type: 'file_upload', v: '0.1', - filename: file.name, + upload_id: uploadId, chunk_index: i, total_chunks: total, - data: buf, - ...(root ? { root } : {}), - ...(dir ? { dir } : {}), + ...sealed, }); } while (acked < total) { @@ -1870,7 +1922,8 @@ class MeshBayTransport { if (failure) throw failure; } } finally { - this._uploaders.delete(file.name); + this._uploaders.delete(uploadId); + this._inFlightUploads.delete(file.name); } return stored || {}; } @@ -2427,21 +2480,21 @@ class MeshBayTransport { // While an upload is in flight the acks are its own, and there are many of // them: they must not be handed to whatever request happens to be oldest in // the pending map. - if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.filename)) { - this._uploaders.get(msg.filename)(msg); + if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.upload_id)) { + this._uploaders.get(msg.upload_id)(msg); return; } - // An upload refusal names the file it is about, so only that upload fails. - // It did not use to, and there was no way to tell whose error it was, so - // every upload in flight was failed together — send a second file whose - // name the node dislikes and both died. The broadcast is kept for a node - // that does not name it, where guessing wrong is worse than stopping. + // An upload refusal names the upload it is about, so only that upload + // fails. It did not use to, and there was no way to tell whose error it + // was, so every upload in flight was failed together — send a second file + // whose name the node dislikes and both died. The broadcast is kept for a + // refusal that names none, where guessing wrong is worse than stopping. if (msg.type === 'error' && this._uploaders.size) { - if (msg.filename && this._uploaders.has(msg.filename)) { - this._uploaders.get(msg.filename)(msg); + if (msg.upload_id && this._uploaders.has(msg.upload_id)) { + this._uploaders.get(msg.upload_id)(msg); return; } - if (!msg.filename) { + if (!msg.upload_id) { for (const handler of [...this._uploaders.values()]) handler(msg); return; } @@ -3017,6 +3070,10 @@ function _decodeMap(buf, view, offset, count) { return [obj, offset]; } +function _hex(bytes) { + return [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); +} + function _extractDtlsFingerprint(sdp) { const match = sdp.match(/a=fingerprint:sha-256 ([0-9A-Fa-f:]+)/); if (!match) return new Uint8Array(0); |