aboutsummaryrefslogtreecommitdiffstats
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/crypto.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js111
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);