diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 76 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 97 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 9 | ||||
| -rw-r--r-- | docs/transfers-v1.md | 5 |
4 files changed, 106 insertions, 81 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 2419916..189dbc5 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -16,7 +16,7 @@ > them — it names the invariant that holds today, not the incident that produced > it. §13 is the register of those labels. > -> Wire versions at the time of writing: **MNP 5.0** (oldest peer accepted 4.0), +> Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0), > **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is > `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol > serves, not its byte layout. @@ -99,7 +99,7 @@ opens them. │ └─────────┘ MHP 0.1 │ signalling (SDP/ICE, <1 KB), presence, revocation push MHP │ - ┌────┴────┐ MNP 5.0 ┌──────────┐ + ┌────┴────┐ MNP 6.0 ┌──────────┐ │ node │◄──────── WebRTC DataChannel / QUIC ──────────►│ client │ └─────────┘ index, file chunks, streams, chat, admin └──────────┘ holds the files browser SPA or desktop @@ -786,10 +786,10 @@ Three properties are why this shape: The node produces every copy of the key itself, from its own CSPRNG. **Nothing arriving over MNP can activate a group key** (**C5b**). Read that precisely: it -targets *key material arriving from outside*, not the instruction. An -operator-signed `gek_rotate` where the node generates the key is a different shape -and is allowed. The initial `gek-init` stays local, because with no key there is -no completed session to carry a signed op. +targets *key material arriving from outside*, not the instruction: a key the node +generates itself on an operator's instruction is a different shape. Initialising +and rotating the group key are local (loopback API, CLI); the signed `chat_epoch` +is the MNP instance of that shape. ### 4.3 On-the-fly encryption @@ -898,7 +898,7 @@ signature refuses it. **Epochs.** A new epoch is opened when, and only when, the set of devices that may read *future* messages shrinks: `member revoke`, `member unpin`, `revoke_device`, -`gek_rotate`, or an explicit `chat rotate`. Epoch 1 is opened at group load — a +or an explicit `chat rotate`. Epoch 1 is opened at group load — a group with no epoch is a group nobody can speak in. **Old epochs are kept and still delivered.** That is what keeps history readable @@ -1233,9 +1233,9 @@ from anything in the response. | `dir_delete` | the operator alone, and only on an empty directory | | `invite_create` | the operator (or a delegate, when delegation ships) | | `invite_link_create`, `invite_cancel` | the operator | -| `gek_rotate` | operator-signed; the node generates the key itself | -| initial `gek-init` | **local admin API or CLI only** | -| root add/remove/update/eject/plug, `apps_enabled`, app directories, transfer limits | operator-signed | +| group key init and rotation | **local admin API or CLI only**; the node generates the key itself | +| root add, root `writable`/`removable`, hosting a group, transfer limits | **local admin API or CLI only** (MNP 6.0) | +| root remove/eject/plug, `apps_enabled`, app directories | operator-signed | | ~~`gek_bundle_store`~~ | **the message does not exist.** No member ever hands the node key material | `gek_bundle_store` was deleted rather than gated. The operator's X25519 public key @@ -1451,8 +1451,8 @@ checks the version its peer declared and **branches on none of it**. **The floor is not necessarily the current version, and what is added above it is why.** It is `MNP_MIN_SUPPORTED` in `handshake.py`, and it is the last MAJOR that had to refuse at the handshake: 3.1–3.4 were added above the 3.0 floor without moving it, -and 5.0, a MAJOR confined to four signed operations that a peer across the break -refuses to sign, sits above the 4.0 floor. So a +and 5.0 and 6.0 — MAJORs confined to a few signed operations, four whose subjects +changed and then three removed — sit above the 4.0 floor. So a peer can be reachable and still not do something the current version can, and the client has to cope with that — **by reading the peer's own answer, never by comparing version numbers**. @@ -1541,7 +1541,16 @@ Five consequences, none optional: - `writable = true` means any group member may upload there. Several roots may be writable and none need be — a fully read-only group is valid. -The operator toggles this with a signed op. + +**What widens the sharing is decided on the node's own machine.** Adding a root, +hosting a group over a directory, and switching `writable` or `removable` go through +the loopback API (the desktop application) or the CLI — never over MNP, since 6.0. +A signed op proves that the operator's key signed, not that they 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 otherwise +have shared any folder on the machine, writable, from anywhere. The operator still +sees every root and its flags from any browser; removing, ejecting and plugging stay +signed ops, because they narrow what is shared or restore what already was. > **There is one answer to "may this member write", and it is the root.** A single > flag over the group cannot express "this library is published read-only and that @@ -1890,26 +1899,24 @@ issues invitations and reads the audit log. There is no server-rendered dashboar the desktop client's Node page and the CLI are the two consumers, and each operation endpoint is one `_op(...)` line onto `ops` (§5.4). -**Over MNP, the node's own controls need a proved operator device.** The -node-wide surface — `node_status`, which lists every group on the machine with -each root's absolute path, plus `node_settings_set`, `roster_read`, -`denylist_read`, `denylist_clear` and `node_reload` — is reachable when two -things hold: the account is the one the node belongs to, *and* the device on the -connection has proved (`device_hello`, §3.3) a key the roster holds as an -operator. The first alone is a claim in a token the hub issued, and **NS4** does -not allow it to be authority: 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 and -cannot countersign a device — the same property device linking rests on. A -browser that has never been paired therefore reads nothing here, exactly as it -can already sign nothing (§5.4). +**The node's own controls are not on MNP.** Its status — which lists every group +on the machine with each root's absolute path — settings, roster, denylist and +reload were MNP messages gated on a proved operator device (`device_hello`, §3.3), +because the account id in a token is the hub's to choose (**NS4**). No client ever +sent them, and MNP 6.0 removed them with the four signed ops in the same position +(`gek_rotate`, `member_unpin`, `transfer_limits`, `group_detach`): the desktop +client's Node page and the CLI do this work over loopback. A door nobody calls is +an untested way in, and one that does not exist needs no gate. **The accepted cost, recorded as a choice:** on a headless server the only admin path is the CLI. The CLI covers every operation, so this is acceptable — but it is a real capability reduction, not an oversight. -> **MNP is the path that must exist; loopback is the fallback.** The operator of a -> node is not necessarily sitting at it. Any operator-facing control needs its MNP -> route first, or it renders for nobody on the web. +> **What the operator must see needs an MNP route; what widens the node does not +> get one.** The operator of a node is not necessarily sitting at it, so a view that +> only loopback can fill renders for nobody on the web — which is why the roots +> table rides in the index. But sharing a folder, opening it to writes and the +> node's own controls are decided at the node (§6.2, MNP 6.0). ### 6.8 Node settings @@ -3352,6 +3359,13 @@ any LAN renderer can play; the relay is device-agnostic. Chromecast discovery an control ship. DLNA/UPnP is designed and not built: it is a second device backend beside the first, not a second relay. +**Discovery lists receivers as they answer.** The mDNS scan runs six seconds, +because a receiver coming back from a reset can take that long, but most answer +within two. The main process holds what the scan has found and the page polls it +(`cast:scan`, then `cast:devices`) for as long as the picker is open, rather than +waiting on one call for the whole scan; a poll uses the same checked `handle()` +door as every other call, where a pushed event would be a second one. + **Subtitles are rebased onto the relay's clock before they are sent.** The node extracts a track whole, so its cues carry the film's timeline, and the player can use them unchanged because its SourceBuffer is given `timestampOffset = @@ -3605,7 +3619,7 @@ had already been asked. | **AV15** | **A hash is checked for shape before it is a key lookup**, on every blocklist endpoint, the administrator's included | | **AV20** | **Chat is bounded in size and in rate, like every other member-supplied write** (§6.6). A message is a row on the operator's disk that nothing expires, a relayed copy for every connected member and a notification for every member of the group; the only ceiling was the frame size. Uploads had carried four protections and a cap since C5a because somebody asked what one member costs the others on that path, and nobody had asked it on this one | | **AV21** | **A lease is what the node granted, not what the client called it** (§5.5). `tr` was read as a boolean, so any non-empty string skipped the leaseless ceiling and every cap behind it, and a queued transfer was held back only by the honesty of the client waiting in the queue | -| **AV22** | **The node's own controls take no authority from a hub token** (§6.7). `node_status`, `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload` were gated on the account id in the JWT, which is the hub's to choose — NS4 and M3 with the check written the other way round. The gate is a proved operator device, which a hub holding no user keys cannot produce | +| **AV22** | **The node's own controls take no authority from a hub token** (§6.7). `node_status`, `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload` were gated on the account id in the JWT, which is the hub's to choose — NS4 and M3 with the check written the other way round. The gate became a proved operator device, which a hub holding no user keys cannot produce; since MNP 6.0 the messages are gone and these controls are loopback and CLI only | | **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent | | **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work | | **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever | @@ -3791,10 +3805,12 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` | | **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run | | **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so | -| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP admin handlers doing the same work audit `member_revoke` and `member_unpin`. So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, an `auth_failed` ("not admitted by the roster") with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded | +| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP `member_revoke` handler doing the same work audits it (and `member_unpin` did, until it left MNP in 6.0). So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, an `auth_failed` ("not admitted by the roster") with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded | | **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) | | **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Link Node** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node | | **Listing a group's folders walks every root on the event loop** | `index_sync_message` (`transport/wire.py`) builds its `dirs` field with `list_dirs`, an `rglob("*")` over every root, and nothing sends it off the loop: the WebRTC `index_sync` handler, the daemon's index push and QUIC all call it inline. So each index request from any member is a directory walk of the whole library that every other peer on the node waits behind. `test_disk_io_off_loop.py` never saw it, because it reads the transport's own modules and the walk is one call away in `wire.py`. Found by widening what that test reads, not by a symptom | +| **A loopback eject or plug reaches open pages late** | `ops.eject_root` and `ops.plug_root` flip the live set and tell nobody; over MNP the broadcast `root_eject_ack` / `root_plug_ack` is what moves every open table. So an eject made from the desktop application or the CLI shows on members' pages only with the next index push — for a plug, the end of its rescan; for an eject, whatever changes next. `ops.update_root` had the same silence and now calls `DirectoryIndexer.publish_roots`; the same call belongs in these two. Found while moving the `writable`/`removable` switches to the loopback door (MNP 6.0) | +| **A group key rotated from the Node page leaves the chat key where it was** | `ops.set_gek(rotate=True)` replaces the group key and opens no chat epoch; the MNP `gek_rotate` handler opened one itself (`_new_chat_epoch`), and it was the only door that did — but no client ever sent it, and it is gone since 6.0. The removals that matter (revoke, unpin, device revoke) open an epoch in `ops` for every door, so what is missing is the follow-through for an operator who rotates by hand: §4.5's "rotate after a removal" means it for chat too. The fix is the `_after_removal` shape — `open_chat_epoch` inside `ops.set_gek` when `rotated`. Found while removing the MNP message | | **The transcoded-seek test passes without transcoding** | `test_a_transcoded_video_keeps_accurate_seeking` (`test_stream_seek_audio_alignment.py`) forces the re-encode branch by swapping the module's `BROWSER_INCOMPATIBLE_VIDEO_CODECS`, then checks only that the result has no audio gap. The copy path also leaves no gap on that clip, so pointing the swap at a module the streaming code does not read still passes: the test cannot tell that the branch it is named after never ran. It should assert the re-encode happened (the `re-encoding` log line, or the encoder in the ffmpeg argv). Found by breaking the swap on purpose while moving the streaming code | --- 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`) | diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index e92f6bc..d631804 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -394,7 +394,9 @@ resume. **Desktop application only.** A film playing in the application can be sent to a cast-capable TV or dongle on the same network: *Cast to device* in the player, -pick one from the list. +pick one from the list. Devices appear as they answer, usually within a couple +of seconds; the search carries on for a few more, for a TV that is still +waking up. The application decrypts the film and relays it to the TV itself, over your LAN. The TV is not a group member and holds no key — which is also why the relay's @@ -435,6 +437,11 @@ list you build yourself with invitation codes. Two things follow from that: | **The CLI**, over SSH | no browser needed, and it works while the daemon is stopped | | **A paired browser** | the group's Settings and Members tabs, and the Node page in the desktop application | +Sharing is decided at the node. Adding a directory, and switching it read-write +or removable, work only on the machine running the node — the desktop +application, or `meshbay-node root`. From any other browser the group's +Settings tab still lists the directories and their switches, read-only. + On a headless server the CLI is the only path, and it covers everything you need to run a group. One gap is known: removing **one** device of one member is only doable from the interface (§7). Start with: diff --git a/docs/transfers-v1.md b/docs/transfers-v1.md index a07d7d6..b87e07c 100644 --- a/docs/transfers-v1.md +++ b/docs/transfers-v1.md @@ -607,6 +607,11 @@ signs names the outcome. `_do_transfer_limits` + `transfer_limits_ack` to the group's peers, surfaced in the group Settings tab as a section beside the scan settings. +> **Since MNP 6.0 the signed op and its ack are gone.** No client ever sent it — +> the Settings section was not built — so the value is set on the node's machine: +> `PUT /api/groups/{id}/transfer-limits` on loopback, and +> `meshbay-node transfers set`. `ops.set_transfer_limits` is unchanged. + **Absent means the default (2), not unlimited.** Deliberately unlike `member_upload`'s "absent means allowed": a group that predates the setting and came back unlimited would leave the node-wide cap as the only control, which is |