diff options
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/protocol.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 109 |
1 files changed, 109 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index bb52a51..876f465 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -30,6 +30,7 @@ number — it is a label chosen by the peer, and the only thing it decides is which local promise a reply belongs to. """ +import os from dataclasses import dataclass, field from typing import Any @@ -37,6 +38,7 @@ from typing import Any # second copy here said "0.1" while every message on the wire carried "0.2". # Nothing imported it, which is the only reason it was harmless. from meshbay_common import MNP_VERSION, MHP_VERSION # noqa: F401 (re-export) +from meshbay_common.groupbox import PURPOSE_UPLOAD, seal, unseal from meshbay_common.webcrypto import ( chunk_key_aes, decrypt_chunk_aes, @@ -370,3 +372,110 @@ def file_chunk_plaintext( file_hash = bytes.fromhex(msg["file_id"]) ckey = chunk_key_aes(gek, file_hash, msg["chunk_index"]) return decrypt_chunk_aes(ckey, msg["nonce"], msg["ct"]) + + +# ── Uploads (MNP 2.0) ───────────────────────────────────────────────────────── +# +# The write path, sealed under the group key the way the read path always was. +# One encoder for both directions, here rather than in the client, for the reason +# `file_chunk` has one: two copies of a wire shape with a single consumer each is +# how `index_sync` and `file_chunk` forked (finding C6), and nothing noticed +# until someone went looking. +# +# What stays in clear, and why each has to: +# `type`, `v` — routed and version-checked before anything can be decrypted +# `upload_id` — the correlation key. It replaces `filename`, which used to +# play that role and cannot any more: naming the file in clear +# to match an ack against a request would give back exactly +# what the seal is for. Client-chosen, opaque to the node, +# unique within one connection; never an authorization input. +# `chunk_index` — ordering, which the node enforces before it opens anything +# `total_chunks` — how many to expect +# +# `group_id` is *not* on the message: the session already decided which group it +# is on, and the node uses that as the AAD. A client naming its own group here +# would be choosing which key its bytes are checked against. + +UPLOAD_ID_LEN = 16 # 128 bits of client-chosen correlation, hex on the wire + + +def new_upload_id() -> str: + """A fresh correlation id for one upload.""" + return os.urandom(UPLOAD_ID_LEN).hex() + + +def file_upload_wire( + gek: bytes, + group_id: str, + *, + upload_id: str, + chunk_index: int, + total_chunks: int, + filename: str, + data: bytes, + dir: str = "", + root: str = "", +) -> dict: + """ + One sealed `file_upload` chunk. + + `filename`, `dir` and `root` ride inside the seal with the bytes: sealing the + content and announcing the name beside it would be theatre. They are repeated + on every chunk rather than sent once — a hundred bytes against a 48 KiB chunk + — because a header that arrives once is state the node has to carry, and + upload state that can disagree with the chunk in hand is what `_free_name` and + the chunk-ordering rule exist to prevent. + """ + payload = {"filename": filename, "data": data, "dir": dir, "root": root} + return { + "type": MNP.FILE_UPLOAD, + "v": MNP_VERSION, + "upload_id": upload_id, + "chunk_index": chunk_index, + "total_chunks": total_chunks, + **seal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD, group_id, payload), + } + + +def file_upload_payload(gek: bytes, group_id: str, msg: dict) -> dict: + """ + Open a `file_upload`. Raises on anything that does not open. + + Never a partial result and never a default: a chunk that does not open is not + an empty file with an empty name, it is a peer we cannot talk to. `unseal` + says why at length. + """ + return unseal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD, group_id, msg) + + +def file_upload_ack_wire( + gek: bytes, + group_id: str, + *, + upload_id: str, + chunk_index: int, + filename: str, + stored_as: str, + dir: str = "", +) -> dict: + """ + The node's answer to one chunk, sealed the same way. + + `stored_as` is the name the node settled on — it finds a free one rather than + replacing anything — and `dir` is where it landed. Both name the operator's + content, so both belong inside the seal; only `upload_id` and `chunk_index` + stay out, because the client matches on them. + """ + payload = {"filename": filename, "stored_as": stored_as, "dir": dir} + return { + "type": MNP.FILE_UPLOAD_ACK, + "v": MNP_VERSION, + "upload_id": upload_id, + "chunk_index": chunk_index, + **seal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD_ACK, group_id, payload), + } + + +def file_upload_ack_payload(gek: bytes, group_id: str, msg: dict) -> dict: + """Open a `file_upload_ack`. Raises on anything that does not open.""" + return unseal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD_ACK, group_id, msg) |