diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 11:54:56 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-02 11:54:56 +0200 |
| commit | d7b7f1049d95e45e6316ac419cb434088c15bd5e (patch) | |
| tree | 77da46e2fe3cb70af5fb2eebcbb1d69dad410b88 /docs/MESHBAY_NODE_PROTOCOL.md | |
| parent | 56a8cf9167e8c7b0f2df15afed88031608adf782 (diff) | |
| parent | 754387590fa1754436b4648f969915888c6f6c9e (diff) | |
| download | meshbay-d7b7f1049d95e45e6316ac419cb434088c15bd5e.tar.gz | |
Diffstat (limited to 'docs/MESHBAY_NODE_PROTOCOL.md')
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 97 |
1 files changed, 47 insertions, 50 deletions
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index e7fce90..0b5213c 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -1,6 +1,6 @@ # MeshBay Node Protocol (MNP) -**Wire version:** `5.0` — `meshbay_common/__init__.py` (`MNP_VERSION`) +**Wire version:** `6.0` — `meshbay_common/__init__.py` (`MNP_VERSION`) **Oldest peer accepted:** `4.0` — `handshake.py` (`MNP_MIN_SUPPORTED`) **Normative implementation:** `meshbay-common` (`protocol.py`, `handshake.py`, `groupbox.py`, `chatbox.py`, `adminop.py`, `join.py`, `device.py`, `crypto.py`, @@ -162,7 +162,7 @@ Codes in use: | Upload | `upload_not_sealed`, `no_group_key`, `lease_not_granted`, `upload_incomplete`, `bad_chunk_encoding`, `bad_chunk_index`, `invalid_filename`, `no_roots`, `no_such_root`, `no_writable_root`, `root_read_only`, `root_unavailable`, `no_such_directory`, `already_exists`, `not_started`, `too_large` (§11.4) | | Directories | `root_read_only`, `root_unavailable` (§11.5) | | Chat | `chat_too_large`, `chat_rate_limited` (§11.7) | -| Operator controls | `not_operator`, `too_many_pending`, `too_large` (§10.4) | +| Operator controls | `too_many_pending`, `too_large` (§10.4) | | Metadata | `transcode_not_applicable`, `tmdb_search_rate_limited` (§11.9) | | Moderation | `content_blocked` — a file the hub's content blocklist names, in a public group (§11.3) | @@ -1156,8 +1156,6 @@ broadcast, every connected peer in the group learns the change without reconnect | `invite_link_create` | `link:<group_id>`, the session's group | operator only | `invite_link_result{code, invite_id, expires_at, group_id}` | no — the code is shown once | | `invite_cancel` | `invite_id` (32 hex) | operator only | `ack{detail: "invite_cancelled", invite_id}` | no | | `member_revoke` | `user_id` | operator | `member_revoke_ack` | no | -| `member_unpin` | `user_id` | operator | `member_unpin_ack{user_id}` | no | -| `gek_rotate` | `group_id` | operator | `gek_rotate_ack{group_id, authorized_members, note}` | no | | `apps_enabled` | the app set | operator | `apps_enabled_ack{apps}` | yes | | `set_scan_settings` | the interval/debounce pair | operator | `set_scan_settings_ack{...}` | yes | | `tmdb_config` | `{token, language}` — `token` is `null` (unchanged), `""` (clear) or `sha256:<hex>` of the token, never the token | operator | `tmdb_config_ack{token_customized, language}` | yes (never the token) | @@ -1165,24 +1163,33 @@ broadcast, every connected peer in the group learns the change without reconnect | `tmdb_override` | `file_id=..,tmdb_id=..,media_type=..` | operator | `tmdb_override_ack{file_id, tmdb_id, media_type}` | yes | | `tmdb_rematch` | `file_id=..` | operator | `tmdb_rematch_ack{file_id}` | yes | | `musicbrainz_enabled` | `enabled` | operator | `musicbrainz_enabled_ack{enabled}` | yes | -| `root_add` | `{path, name, kind, writable, removable}` | operator | `root_add_ack` | no | | `root_remove` | the root name | operator | `root_remove_ack` | no | -| `root_update` | `<root>:rw=on\|off,rem=on\|off` | operator | `root_update_ack` | yes | | `root_eject`, `root_plug` | the root name | operator | `root_eject_ack` / `root_plug_ack` | yes | | `app_directories` | `<app>:<dir>,<dir>,...` | operator | `app_directories_ack{app, dirs}` | yes | | `chat_directory` | the path | operator | `chat_directory_ack{path}` | yes | | `chat_link_preview` | `on\|off` | operator | `chat_link_preview_ack{enabled}` | yes | | `search_listed` | `on\|off` | operator | `search_listed_ack{listed}` | yes | -| `transfer_limits` | `d=<n>,u=<n>` | operator | `transfer_limits_ack{limits}` | yes | | `chat_epoch` | `group_id` | operator | `chat_epoch_ack{epoch}` | yes | -| `group_attach` | `{name, shared_dir, writable}` | operator | `group_attach_ack` | no | -| `group_detach` | the group name | operator | `group_detach_ack` | no | **Upload policy is not in this table**, and that is the design: whether a member may -write is a property of each root (`root_update`), not a switch over the group. A single +write is a property of each root (its `writable` flag), not a switch over the group. A single group-wide flag cannot express "this library is published read-only and that folder is a drop box", which is the ordinary arrangement. +**Nothing in this table widens what the node shares**, and that is the design too. +Adding a directory to a group, hosting a new group over a directory, and switching +a root's `writable` or `removable` flag are done on the node's own machine — the +desktop application over the loopback API, or the CLI — and never over MNP. Until 6.0 +they were the signed ops `root_add`, `group_attach` and `root_update`. A signature +proves that the operator's key signed, not that the operator meant it: in a browser +that key is driven by code the hub serves (T3), and in the desktop application by a +renderer that parses content from nodes. Either could have shared any folder on the +operator's machine, writable, from anywhere. What is left here narrows (`root_remove`, +`root_eject`) or restores what the operator already shared (`root_plug`). The +operator still sees the table from any browser: it rides in `index_sync` and +`index_delta`, which is also how a change made on the node's machine reaches every +open page. + `app_directories` is the **only** way an application's folders are set: one message for every application, keyed by the app's own registry name, so adding an application adds no message type, no signed op and no handler. @@ -1201,18 +1208,14 @@ Their *storage* keys survive on the node — `Roster.LEGACY_DIR_KEYS` still read operator's disk rather than on the wire, and a node upgraded into this has to find its own configuration. -A second family of operator messages is **not** signed: `node_status`, `roster_read`, -`denylist_read`, `denylist_clear`, `node_settings_set`, `node_reload`. These are gated -by `_operator_device()`, which asks two things: the authenticated session's `user_id` -is the account the node records as its own (`node_user_id`, `is_node_admin()`), **and** -the device on this connection has proved, with `device_hello` (§9.4), a key the roster -holds as an operator. Anything else is refused with code `not_operator`. The first -alone would be a claim in a token the hub issued, and a hub that can name the operator -is a hub that can be one; the second is what it cannot forge, since it holds no user -keys. Three of them only read; the other three run through the same `ops` entry points -as the CLI and the loopback admin API. The distinction from the signed table above: -a signed op proves possession of an operator key for *this* operation, while these -prove it once per connection, through the device the connection identified. +**The node's own controls are not MNP messages.** Its status (every group, with each +root's absolute path), settings, roster, denylist and reload; rotating a group key; +forgetting a pinned identity; the per-member transfer caps; no longer hosting a group — +the Node page in the desktop application and the CLI do these over the loopback API, on +the operator's own machine. Until 6.0 MNP carried them too (`node_status`, +`node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear`, `node_reload`, +`gek_rotate`, `member_unpin`, `transfer_limits`, `group_detach`), and no client ever +sent one: a door nobody calls is an untested way in (§10.5). **The subject covers everything the node acts on.** The signature covers `op`, the node, the group, the subject, the nonce and the time — nothing else of the request — @@ -1231,16 +1234,14 @@ operations (`too_many_pending` beyond), each at most 64 KiB of subject and paylo Rules that hold across the table: * **No MNP message can activate a group key.** The rule (I2) targets key material - arriving from outside, not the instruction: `gek_rotate` and `chat_epoch` are allowed - precisely because the node generates the new key itself with its own CSPRNG. Initial - `gek-init` stays local — with no group key there is no completed session to carry a - signed op anyway. + arriving from outside, not the instruction: `chat_epoch` is allowed precisely + because the node generates the new key itself with its own CSPRNG. Initialising and + rotating the group key stay local (loopback, CLI). * **There is no operation by which key material reaches the node** (§12). The node wraps for a key the recipient has proved possession of, so no such message is needed — and a path that does not exist cannot be mis-authorized, which is I10 applied to a message instead of a version. -* An operator cannot revoke or unpin **themselves** over the connection their pin - authorizes. +* An operator cannot revoke **themselves** over the connection their pin authorizes. * Rotation is what actually removes a revoked member's access. Revocation stops the node serving the *next* key; the ex-member still holds the current one, and content they already downloaded stays readable. The ack says so in words. @@ -1408,7 +1409,7 @@ The control plane is not covered either — see §14.2. **Nonce collision, since `upload` is the first purpose with volume.** One subkey per purpose and a fresh 96-bit random nonce per message: at one message per 48 KiB chunk, 2³² chunks is 200 TB uploaded under a single GEK before the collision probability -reaches 2⁻³², and `gek_rotate` exists. Deriving the nonce from the payload instead +reaches 2⁻³², and the group key can be rotated. Deriving the nonce from the payload instead would be worse, not better — two chunks of identical bytes are ordinary in a file. **Failure is fatal, never degraded** (I8). A client that cannot open an index message @@ -1466,9 +1467,9 @@ The **lease** is that object, and every transfer runs under one. **Caps.** Node-wide, 8 concurrent per kind by default; per member per group, 2 by default. A group with no value of its own gets the default, never "unlimited": reading an absent setting as no limit would leave the node-wide cap as the only control, which -is the situation leases exist to end. The per-group value is a signed operator -operation (`transfer_limits`, §10.4, bounded to 1–32; zero is refused, because a member -who may not transfer at all is a member the operator revokes). The node-wide values are +is the situation leases exist to end. The per-group value is set on the node's machine +(`ops.set_transfer_limits`, loopback and CLI; bounded to 1–32, zero is refused, because +a member who may not transfer at all is a member the operator revokes). The node-wide values are daemon settings. A member's own cap rides on every `transfer_state`, so the interface can say "2 of your 2 slots are busy" rather than draw a spinner that explains nothing. @@ -1935,7 +1936,7 @@ collide immediately. The design degrades correctly into the deployment that exis chain-based one would not have. **Epochs.** A new epoch is opened when the set of devices that may read *future* -messages shrinks — member revoke, member unpin, device revoke, `gek_rotate` — and by +messages shrinks — member revoke, member unpin, device revoke — and by hand with the signed `chat_epoch` op (§10.4). Old epochs are kept and still delivered to current members, which is what keeps history readable to the people who could already read it. The epoch key is wrapped under the group key **at delivery**, never stored @@ -2121,16 +2122,12 @@ it back (§3.5). | `admin_response` | C→N | auth | the operator's signature over that transcript | | `client_diag` | C→N | auth | the video player's own view of a stream, written to the node's log beside its own (a stream event at INFO, the periodic state at DEBUG); the node acts on none of it and sends no reply | | `member_revoke` / `_ack` | C→N / N→C | signed | stop serving the key to someone | -| `member_unpin` / `_ack` | C→N / N→C | signed | forget a pinned identity | -| `transfer_limits` / `_ack` | C→N / N⇒C | signed | per-member transfer caps for this group | | `chat_epoch` / `_ack` | C→N / N⇒C | signed | open a new chat epoch by hand | | `app_directories` / `_ack` | C→N / N⇒C | signed | one application's folders, keyed by app name | | `chat_directory` / `_ack` | C→N / N⇒C | signed | where chat attachments are written | | `chat_link_preview` / `_ack` | C→N / N⇒C | signed | whether the node unfurls posted links | | `search_listed` / `_ack` | C→N / N⇒C | signed | whether members' cross-group Search lists this group | -| `root_update` / `_ack` | C→N / N⇒C | signed | a root's `writable` / `removable` flags | | `root_eject` / `_ack`, `root_plug` / `_ack` | C→N / N⇒C | signed | take a removable root offline, put it back | -| `gek_rotate` / `_ack` | C→N / N→C | signed | node generates a new group key | | `apps_enabled` / `_ack` | C→N / N⇒C | signed | which group apps are shown | | `set_scan_settings` / `_ack` | C→N / N⇒C | signed | reconcile interval and debounce | | `tmdb_config` / `_ack` | C→N / N⇒C | signed | node-wide TMDB token and language | @@ -2138,14 +2135,7 @@ it back (§3.5). | `tmdb_override` / `_ack` | C→N / N⇒C | signed | correct a wrong automatic match | | `tmdb_rematch` / `_ack` | C→N / N⇒C | signed | drop one file's cached match | | `musicbrainz_enabled` / `_ack` | C→N / N⇒C | signed | per-group MusicBrainz on/off | -| `root_add` / `_ack`, `root_remove` / `_ack` | C→N / N→C | signed | add or remove a shared directory | -| `group_attach` / `_ack`, `group_detach` / `_ack` | C→N / N→C | signed | start or stop hosting a group | -| `node_status` / `_ack` | C→N / N→C | auth (operator) | all groups, roots, daemon state | -| `roster_read` / `_ack` | C→N / N→C | auth (operator) | pinned identities and members | -| `denylist_read` / `_ack` | C→N / N→C | auth (operator) | current refusals | -| `denylist_clear` / `_ack` | C→N / N→C | auth (operator) | remove entries | -| `node_settings_set` / `_ack` | C→N / N→C | auth (operator) | change daemon settings | -| `node_reload` / `_ack` | C→N / N→C | auth (operator) | re-read `node.toml` | +| `root_remove` / `_ack` | C→N / N→C | signed | remove a shared directory | | `error` | N→C | any | refusal, with `detail` and optionally `code`, `req_id`, and the `upload_id` / `tr` / `file_id` it is about | | `ack` | N→C | auth | generic acknowledgement (chat, keypair bundle store) | @@ -2182,7 +2172,7 @@ message: ## 13. Versioning and compatibility -MNP versions independently of the package version. Current: **`5.0`**; oldest peer +MNP versions independently of the package version. Current: **`6.0`**; oldest peer accepted: **`4.0`**. 4.0 is the floor: a member presents a short-lived node-audience token bound to one node @@ -2197,6 +2187,14 @@ selection, per-account blobs. four fail with a refusal and everything else works; no node accepts the old subjects, so nothing is left unsigned on either side. That is why the floor did not move. +6.0 is a MAJOR that removes three signed operations — `root_add`, `root_update`, +`group_attach` — rather than changing any (§10.4: nothing over MNP widens what a node +shares). A 5.x client that sends one gets no answer, as for any unknown type, and +everything else it does still works; the floor stays at 4.0. The same version removes +ten operator messages no client ever sent (§10.4, "The node's own controls"). The desktop application +also refuses to sign them, so a node older than 6.0 cannot be driven into them by a +script in its page either. + The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks; `MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about whether an older peer can still do anything useful: @@ -2336,9 +2334,8 @@ walks through the gate meant to stop it. file chunks, stream segments, uploads, the chat keys and the group roster. It does not cover the admin and configuration acks (`app_directories_ack`, `root_*_ack` and the rest), which carry the same folder names the sealed index carries; the media-metadata replies (`media_meta_resp`, `music_meta_resp`, - `link_preview_resp`), which carry titles, artists and synopses; `node_status_ack`, - which carries absolute paths on the operator's disk to an operator session; the - identity replies (`roster_read_ack`, `device_list_result`); or `invite_result` and + `link_preview_resp`), which carry titles, artists and synopses; the identity reply + `device_list_result`; or `invite_result` and `invite_link_result`, which carry a pairing code. All are inside DTLS/TLS and none reaches the hub, but none is behind the group key. * **Transfer messages are in clear on purpose**, and that is a deliberate line rather @@ -2414,7 +2411,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions | Constant | Value | Source | |---|---|---| -| `MNP_VERSION` | `5.0` | `meshbay_common/__init__.py` | +| `MNP_VERSION` | `6.0` | `meshbay_common/__init__.py` | | `MNP_MIN_SUPPORTED` | `4.0` | `handshake.py` | | `MNP_AUD` / `HUB_API_AUD` | `meshbay:mnp` / `meshbay:hub-api` | `tokens.py` | | MNP token lifetime | 900 s | `meshbay-hub/auth.py` (`issue_mnp_token`) | |