diff options
Diffstat (limited to 'packages/meshbay-common/src')
4 files changed, 86 insertions, 3 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b64a6f2..1aaa269 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -1,6 +1,6 @@ """MeshBay common — shared crypto primitives and protocol types.""" -__version__ = "0.12.0" +__version__ = "0.13.0" # 0.2: added PING/PONG, and `before`/`has_more` on chat history. Both are # additive — an 0.1 peer sends no `before` and gets the newest page, which is # what it wanted — so this is a MINOR bump, not a MAJOR one. @@ -133,5 +133,37 @@ __version__ = "0.12.0" # `index_progress` (counters only — see daemon.py `_push_index_progress`), the # admin and configuration acks, and the media-metadata replies. The index at # rest and file content on the operator's disk are unchanged. -MNP_VERSION = "2.0" +# **3.0 (2026-09-09): a transfer needs a lease, and a peer that cannot ask for +# one is refused at the handshake.** +# +# `transfer_open` / `transfer_close` / `transfer_state` carry the lease a +# download or an upload runs under; `file_req` gains an optional `tr` and +# `file_upload` gains one beside the `upload_id` already in clear. All three are +# in clear, like `index_progress` and for the same stated reason: `tr` is +# opaque, `bytes` and `chunks` are numbers, and there is no filename and no path +# anywhere in them. Putting one there to make a log line prettier is exactly the +# trade `groupbox.py` exists to refuse. +# +# **The messages are additive; the requirement is not, and that is what makes +# this MAJOR.** A 2.0 client sends no `tr`, so it is a leaseless reader — and a +# leaseless reader is either refused as soon as it opens a third file, or it is +# not refused and transfers outside every cap the operator set. An opt-in switch +# ("enforce leases only for clients that speak 3.0") leaves that branch +# reachable on every node, which is finding C6's lesson — a transport that +# accepted a bare JWT — one feature later. It was already refused once, for chat +# encryption, on 2026-09-07. +# +# Browsing is deliberately **not** leased and never will be: not the poster +# grid, not the covers, not opening a photo to look at it. That exemption is +# bounded rather than open (`transfers.LeaselessReads`, two files in flight per +# session), because an exemption with no bound is the leaseless branch under +# another name. +# +# **What it costs, stated plainly.** The SPA is served by the hub, so a browser +# picks up the new client on reload. The desktop client ships its own UI, so an +# un-updated one is locked out — which is why `GET /v1/hub/version` carries +# `client.minimum` and the client checks it *before* connecting, and says "this +# version can no longer connect" rather than showing a handshake refusal nobody +# can act on. +MNP_VERSION = "3.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index ed6e940..5c7345b 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -61,6 +61,12 @@ OP_APPS_ENABLED = "apps_enabled" # security property in itself, but the pattern (every operator setting is # signed) is what keeps the authorization model simple to reason about. OP_SET_SCAN_SETTINGS = "set_scan_settings" +# How many transfers one member may run at once in this group. Signed like the +# rest: an unsigned cap is one any member can raise for themselves, which makes +# the control a suggestion. The subject is "d=2,u=2" so what the operator is +# shown before signing names the outcome and not the operation -- the same rule +# member_upload's on/off subject follows. +OP_TRANSFER_LIMITS = "transfer_limits" # Whether the node uses the operator's own API token/language instead of the # shipped default — node-wide (docs/mediacenter.md §5.5), one credential # shared by every group. Signed like the rest: it turns on outbound diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 188a8aa..65b4e85 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -78,7 +78,12 @@ HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" # handshake and then discovering that every message it sends is rejected and # every message it receives is unreadable. A stated refusal is a bug report; a # chat that quietly does not work is a support case. -MNP_MIN_SUPPORTED = "2.0" +# 3.0 (2026-09-09): a transfer runs under a lease, and a 2.x peer cannot ask for +# one. Admitting it would mean either refusing it later, per file, in a way it +# has no vocabulary to understand — or serving it outside every cap the operator +# set, which makes the caps decoration. Neither is honest, so it is refused +# here, with a code and a sentence. +MNP_MIN_SUPPORTED = "3.0" ROLE_CLIENT = "client" ROLE_NODE = "node" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index e092353..5bd2903 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -96,6 +96,20 @@ class MNP: # never serve the GEK in plaintext. Members obtain it by unwrapping their own # ECIES bundle. The constants lingered after the handlers were deleted, leaving # the wire contract looking as though the endpoint still existed. + # Transfer slots. A download is otherwise invisible to the node -- a series + # of independent file_req messages, with nothing saying one started or + # ended -- so there is nothing to count and nothing to cap. The lease is + # that missing object: `tr` is drawn by the client like `upload_id`, covers + # a job rather than a file, and dies with the connection. + # + # One reply type with a state field, not four: a client that must switch on + # the message type to discover it is still waiting is a client that will get + # one branch wrong. Carries no filename and no path -- `tr` is opaque, + # `bytes` and `chunks` are numbers -- so it stays in clear like + # INDEX_PROGRESS, for the same stated reason. + TRANSFER_OPEN = "transfer_open" # client -> node: I want a slot + TRANSFER_CLOSE = "transfer_close" # client -> node: I am done with it + TRANSFER_STATE = "transfer_state" # node -> client: granted/queued/closed FILE_UPLOAD = "file_upload" # client pushes file chunk to node FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt DIR_CREATE = "dir_create" # client → node: make a directory @@ -138,6 +152,8 @@ class MNP: MEMBER_UPLOAD_ACK = "member_upload_ack" APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show APPS_ENABLED_ACK = "apps_enabled_ack" + TRANSFER_LIMITS = "transfer_limits" # operator → node: per-member caps for this group + TRANSFER_LIMITS_ACK = "transfer_limits_ack" # node → this group: the new caps SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack" MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path @@ -485,6 +501,22 @@ def file_upload_payload(gek: bytes, group_id: str, msg: dict) -> dict: return unseal(gek, PURPOSE_UPLOAD, MNP.FILE_UPLOAD, group_id, msg) +# "Where am I?", asked as an ordinary sealed upload chunk rather than as a new +# message. +# +# The node identifies an upload by (member, directory, filename), so a client +# resuming one has to name the file — and `transfer_open`, the obvious place to +# ask, travels in clear. Naming it there would undo exactly what sealing the +# upload path bought: before MNP 2.0 the same file was ciphertext leaving a node +# and plaintext arriving at one. +# +# So the question is asked inside the seal that already exists, as a chunk with +# no bytes and this index. The node writes nothing, changes nothing, and answers +# with `resume_from`. A node that predates this refuses the index, which the +# client reads as "start from the beginning" — the behaviour it had anyway. +UPLOAD_PROBE_INDEX = -1 + + def file_upload_ack_wire( gek: bytes, group_id: str, @@ -494,6 +526,7 @@ def file_upload_ack_wire( filename: str, stored_as: str, dir: str = "", + resume_from: int | None = None, ) -> dict: """ The node's answer to one chunk, sealed the same way. @@ -502,8 +535,15 @@ def file_upload_ack_wire( 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. + + `resume_from` answers the probe chunk (`UPLOAD_PROBE_INDEX`): how many + chunks of this file the node already holds. Inside the seal like the rest — + it is a fact about the operator's disk — and absent from an ordinary ack, so + a client can tell the two apart without looking at `chunk_index`. """ payload = {"filename": filename, "stored_as": stored_as, "dir": dir} + if resume_from is not None: + payload["resume_from"] = int(resume_from) return { "type": MNP.FILE_UPLOAD_ACK, "v": MNP_VERSION, |