aboutsummaryrefslogtreecommitdiffstats
path: root/docs/MESHBAY_NODE_PROTOCOL.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/MESHBAY_NODE_PROTOCOL.md')
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md236
1 files changed, 157 insertions, 79 deletions
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 8de731e..ce99a40 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1,14 +1,14 @@
# MeshBay Node Protocol (MNP)
-**Wire version:** `4.0` — `meshbay_common/__init__.py` (`MNP_VERSION`)
-**Oldest peer accepted:** `3.0` — `handshake.py` (`MNP_MIN_SUPPORTED`)
+**Wire version:** `5.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`,
-`webcrypto.py`), `meshbay-node` (`transport/wire.py`, `transport/webrtc_server.py`
+`webcrypto.py`, `tokens.py`), `meshbay-node` (`transport/wire.py`, `transport/webrtc_server.py`
and `transport/webrtc/`, `transport/quic_server.py`, `transfers.py`, `uploads.py`), browser client
(`meshbay-hub/static/transport.js`, `static/crypto.js`).
**Document status:** descriptive specification of the protocol as implemented on
-2026-09-10. It describes the protocol as it stands. Where the code and this document
+2026-09-28. It describes the protocol as it stands. Where the code and this document
disagree, the code is authoritative and this document is the thing to fix.
**It is self-contained.** Every rule below is given with the reason it exists, in
@@ -152,10 +152,14 @@ Codes in use:
| Family | Codes |
|---|---|
-| Handshake | `not_a_member` (§6.3); `version_too_old`, `version_too_new`, `version_unreadable` (§13.1) |
-| Transfers | `transfer_required`, `bad_transfer_id`, `bad_transfer_size`, `bad_kind`, `not_your_transfer`, `too_many_queued` (§11.2) |
-| Upload | `upload_not_sealed`, `no_group_key`, `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) |
+| Handshake | `not_a_member`, `not_hosted`, `wrong_node` (§6.3); `version_too_old`, `version_too_new`, `version_unreadable` (§13.1) |
+| Transfers | `transfer_required`, `lease_not_granted`, `bad_transfer_id`, `bad_transfer_size`, `bad_kind`, `not_your_transfer`, `too_many_queued` (§11.2) |
+| 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) |
+| 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) |
Everything else refuses with `detail` alone. A code is added when a client has a
different thing to *do* about the refusal — retry, re-authenticate, offer an update —
@@ -305,7 +309,9 @@ whatever it has (host candidates are enough on a LAN).
| | authorize: shared active group,
| | or an open-join group when public
| | groups are enabled; <=16 KiB SDP;
- | | <=3 pending per user; 30/min
+ | | <=32 pending per account; per
+ | | account and node: burst 120,
+ | | refill 2/s; 600/min per address
| | |
| |--- ws {webrtc_offer, |
| | peer_id, user_id, |
@@ -375,7 +381,11 @@ fails if a transport skips a step.
| else error{code} |
| authorize_token() |
| - EdDSA verify vs hub |
+ | - aud == MNP_AUD, |
+ | exp/sub/scope present|
| - scope == "user" |
+ | - node claim, if set, |
+ | == this node's key |
| - sub non-empty |
| - group_id non-empty |
| - denylist(user,jti,gp)|
@@ -463,8 +473,8 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
|---|---|---|
| JWT verifies under the hub's Ed25519 public key (`EdDSA`) | `Invalid JWT: ...` | |
| `aud == MNP_AUD`, and `exp`/`sub`/`scope` present (MNP 4.0) | `Invalid JWT: ...` | the member presents a short-lived **node-audience** token (`POST /v1/nodes/mnp-token`), not its hub session token — the operator holds whatever is presented, and the session token opens the hub API. The two audience strings are in `meshbay_common/tokens.py` |
-| `node` claim, when set, equals this node's key (MNP 4.0) | `Token is not for this node`, code `wrong_node` | the token names the node it was minted for, so one captured by node A's operator cannot be replayed to node B (E10). A token naming no node is accepted — the hub mints an unbound one only for the requester |
| `scope == "user"` | `Wrong token scope` | a node-scoped daemon token must not be usable as a client token |
+| `node` claim, when set, equals this node's key (MNP 4.0) | `Token is not for this node`, code `wrong_node` | the token names the node it was minted for, so one captured by node A's operator cannot be replayed to node B (E10). A token naming no node is accepted — the hub mints an unbound one only for the requester |
| `sub` non-empty | `Token has no subject` | |
| `group_id` non-empty | `group_id is required` | an absent group means no membership check to make; there is no default group, and a node's first group is not one |
| not on the denylist for `user_id`, `jti` **or** `group_id` | `Token revoked` | all three targets, and persisted to disk: a revocation that a restart forgets is not one |
@@ -472,14 +482,17 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
| `group_id ∈ node.hosted_groups` | `Group not hosted on this node`, code `not_hosted` | the hub may hand a client several nodes for one group, and only some of them host it |
`AuthorizedPeer` carries `user_id`, `group_id`, `username`, `jti` — and deliberately
-**no user public key**. A key arriving in a token would be a key the hub chose, and the
+**no user public key**. `username` is read from a `username` claim that neither the MNP
+token nor the hub session token carries, so it is empty in practice. A key arriving in a token would be a key the hub chose, and the
node records the uploader's key in order to decide who may later delete a file: that
would let whoever issues tokens decide it instead. Identity keys are pinned by the
node's roster. The hub certifies accounts, not keys.
-`not_a_member` is almost always a token minted before the person was added to the
-group (`groups` is baked in at login and the hub pushes no updates), so the client
-refreshes once and retries on that code rather than telling a member they are not one.
+`not_a_member` means the hub did not count this account a member of the group when it
+minted the token. The MNP token is minted for each connection, from the membership the
+hub holds at that moment, so a stale `groups` claim is no longer the usual cause; the
+client still refreshes its session once and retries on that code before telling someone
+who was just invited that they are not a member.
`not_hosted` is the client's signal to try the **next** node the hub offered for the
group rather than to report a failure. `/v1/groups/{id}/nodes` returns every node
@@ -537,10 +550,12 @@ this is the key the client wanted. A client with no expectation learns nothing m
than before, and the ack remains the proof of GEK possession.
Client rule, from the node's answer and never from its version (§13): a `sig` that
-does not verify is a refusal (`Node challenge signature invalid`); no `sig` is an
-older node, whose key is proved only at the ack. A node signs whenever it has a
-binding, and a node with none sends no signature rather than an unbound one — the
-proof would be refused on that connection anyway.
+does not verify is a refusal (`Node challenge signature invalid`), and so is a
+challenge with no `sig` at all. A node signs whenever it has a binding, and a node
+with none sends no signature rather than an unbound one — its proof would be refused
+on that connection anyway, so refusing the unsigned challenge only says so earlier.
+With the floor at 4.0 every peer a client can reach signs, and there is no "older
+node" case to tolerate.
### 6.6 `handshake_ack` fields
@@ -564,10 +579,9 @@ at all — including a decryption.
| `chat_link_preview` | whether the node unfurls links posted here. Absent means on |
| `search_listed` | whether the reader's cross-group Search lists this group. Presentation only — the index is served identically either way. Absent means listed |
| `chat_epoch` | the chat epoch a client must seal under right now (§11.7). There is no `chat_encrypted` beside it, because there is no switch |
-| `transfer_limits` | `{download, upload}` — this member's own caps in this group, so the interface can say "2 of your 2 slots are busy" instead of drawing a bare spinner. Absent reads as "no limit known" and the hint is not drawn; never as "unlimited", which would have the interface contradicting the node (§11.2) |
| `tmdb_enabled`, `musicbrainz_enabled` | per-group metadata lookups |
| `tmdb_token_customized`, `tmdb_language` | node-wide TMDB config; the token itself is never sent |
-| `indexing` | `{scanning, scanned_bytes, total_bytes}` so a client connecting mid-scan shows progress immediately. Never a path or filename |
+| `indexing` | the `index_progress` counters (§11.1) so a client connecting mid-scan shows progress immediately. Never a path, a filename or a root name |
| `scan_settings` | `{reconcile_interval_secs, debounce_secs}` — displayed, not enforced from here |
Everything after `is_node_admin` is presentation state. It rides on the ack so a client
@@ -649,6 +663,10 @@ nodes.
* Identity keys are **per node**. There is nothing to carry between nodes, and an
operator who cracks the copy on their own disk gets a key that opens nothing
anywhere else.
+* `keypair_bundle_delete` is **reserved for `device_policy`** (`MESHBAY_DESIGN.md`
+ §3.7, open item O3): the node honours it, and no interface sends it yet. Withdrawing
+ the bundle is only safe once the account has chosen not to need it from a browser —
+ a lone button would strand the next browser that signs in.
### 7.1a Per-account blobs (MNP 3.1)
@@ -714,9 +732,9 @@ wrap_key = HKDF-SHA256(shared, salt = pk_eph, info = "meshbay:gek_wrap:v
wrapped = AES-256-GCM(wrap_key).encrypt(nonce_96, GEK, aad = pk_recipient)
```
-AES-GCM because WebCrypto has no ChaCha20-Poly1305; a `chacha20-poly1305` variant with
-`info = "meshbay:gek_wrap:v1"` exists for native clients. The recipient's public key is
-the AEAD's associated data, so a bundle cannot be re-addressed.
+AES-GCM because WebCrypto has no ChaCha20-Poly1305, and one cipher serves every
+client. The recipient's public key is the AEAD's associated data, so a bundle cannot be
+re-addressed.
`found: false` is the normal answer: per-member bundles are not stored, and the key is
produced on demand by the join path (§8). The node keeps one stored bundle of its own
@@ -1045,8 +1063,8 @@ effect immediately.
| | now - ts <= 120 s
| | rebuild A from STORED state
| | verify vs roster operator keys
- | | (file_delete also accepts the
- | | uploader's recorded key)
+ | | (file_delete also accepts any
+ | | live device of the uploader)
| | execute via ops
|<- <op>_ack {op-specific fields} --------------|
| |
@@ -1083,9 +1101,9 @@ broadcast, every connected peer in the group learns the change without reconnect
| Op | Subject | Authority | Ack | Broadcast |
|---|---|---|---|---|
-| `file_delete` | `file_id` | operator **or** the file's recorded `uploader_pk` | `file_delete_ack{file_id}` | no |
+| `file_delete` | `file_id` | operator **or** any non-revoked device of the uploading account (`uploader_id`, looked up in the roster); the recorded `uploader_pk` only when the roster cannot answer | `file_delete_ack{file_id}` | no |
| `dir_delete` | path relative to the root | operator | `dir_delete_ack{dir}` | no |
-| `invite_create` | invitee `user_id` | operator only (delegation designed, deferred) | `invite_result{code, expires_at, user_id, username}` | no — the code is shown once |
+| `invite_create` | `{user_id, username}` (canonical JSON, see below) | operator only (delegation designed, deferred) | `invite_result{code, expires_at, user_id, username}` | no — the code is shown once |
| `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 |
@@ -1093,12 +1111,13 @@ broadcast, every connected peer in the group learns the change without reconnect
| `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` | `custom_token=yes\|no,language=...` | operator | `tmdb_config_ack{token_customized, language}` | yes (never the token) |
+| `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) |
| `tmdb_enabled` | `enabled` | operator | `tmdb_enabled_ack{enabled}` | yes |
| `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`, `root_remove` | the root | operator | `root_add_ack` / `root_remove_ack` | no |
+| `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 |
@@ -1107,7 +1126,8 @@ broadcast, every connected peer in the group learns the change without reconnect
| `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`, `group_detach` | `group_id` | operator | `group_attach_ack` / `group_detach_ack` | no |
+| `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
@@ -1134,13 +1154,30 @@ 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 `is_node_admin()` — the authenticated session's `user_id` equals the account the
-node records as its own operator (`node_user_id`), computed from the node's own state
-and never from a hub claim. 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 is deliberate but worth stating plainly: a signed op proves
-possession of an operator *key*, while these prove only that the session belongs to the
-operator's *account*, which the handshake already established.
+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 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 —
+so a value the executor uses and the subject omits is a value the operator never
+signed. Where an operation's effect is several values, the subject is canonical JSON
+of all of them (`adminop.structured_subject`, `adminSubject` in `crypto.js`: sorted
+keys, no whitespace, UTF-8), which keeps `null`, `""` and a value distinct and cannot
+be forged by a field that contains a separator. A secret is named by its SHA-256,
+because the subject is written to the audit log.
+
+**What waits for a signature is bounded.** Any authenticated member can ask for a
+challenge — the signature is checked later — so a connection holds at most 8 pending
+operations (`too_many_pending` beyond), each at most 64 KiB of subject and payload
+(`too_large`), and an expired one is dropped when the next is issued.
Rules that hold across the table:
@@ -1170,6 +1207,11 @@ one is the one that decides. A front door is allowed to differ in how it *authen
— a signature here, a run token on loopback, an operator's shell for the CLI — and never
in what it *does*.
+An operation has the doors something uses, and no more: a door nobody calls is an
+untested way in. The per-group settings the group's Settings tab changes —
+application folders, the chat folder, link previews, the Search listing, the scan
+settings — are signed MNP operations only.
+
---
## 11. Content plane
@@ -1199,7 +1241,9 @@ content-addressed: `id` is the BLAKE3 hash of the file.
| updates[], roots[]} |
|
|<- index_progress {v, group_id, scanning, | every ~2 s while scanning,
- | scanned_bytes, total_bytes} -----------| plus once on the return to idle
+ | scanned_bytes, total_bytes, | plus once on the return to idle
+ | files_done, files_total, kind, |
+ | root_pos, queued} ---------------------|
NOT sealed — see below
```
@@ -1237,7 +1281,11 @@ ejected or plugged would leave every connected client's directory table stale un
somebody reloaded the page, and the delta that tells them something changed would be the
one message unable to say what.
-`index_progress` carries counters only, never a path or filename.
+`index_progress` carries counters only, never a path, a filename or a root name:
+`kind` is what the indexer is doing (`scan`, `rescan`, `reconcile`, `watch`, or `""`
+when idle), `root_pos` is a position in the `roots` table the member already opened
+from the sealed index (`-1` when none), and `queued` is the number of roots waiting
+their turn, not their names.
### 11.1a The sealed envelope
@@ -1267,9 +1315,9 @@ ciphertext. That is the same statement the index makes, one step stronger.
`salt = <none>` is Python's `salt=None` and WebCrypto's `salt: new Uint8Array(0)`;
RFC 5869 extracts with a zero key either way. The subkeys are purpose-separated
-rather than borrowed from a file's key space — `GroupIndex.serialize()` reuses
-`chunk_key_aes` with a pseudo-file ("the index as chunk 0 of a virtual index file"),
-which is a hack this deliberately does not repeat.
+rather than borrowed from a file's key space — reusing `chunk_key_aes` with a
+pseudo-file ("the index as chunk 0 of a virtual index file") is a hack this
+deliberately does not repeat.
**What stays in clear, and why each one has to:**
@@ -1314,10 +1362,6 @@ purpose and a fresh 96-bit random nonce per message: at one message per 48 KiB c
reaches 2⁻³², and `gek_rotate` exists. Deriving the nonce from the payload instead
would be worse, not better — two chunks of identical bytes are ordinary in a file.
-`GroupIndex.serialize()` is not a candidate for reuse here: it compresses with zstd,
-which no browser can decompress (`DecompressionStream` offers gzip and deflate only),
-so reusing it would mean shipping a WASM decoder to every client for no gain.
-
**Failure is fatal, never degraded** (I8). A client that cannot open an index message
ends the session naming the message type; it never reports an empty index, because "the
group has no files" is a state a real group can be in.
@@ -1376,8 +1420,8 @@ an absent setting as no limit would leave the node-wide cap as the only control,
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
-daemon settings. A member's own caps ride on the handshake ack so the interface can say
-"2 of your 2 slots are busy" rather than draw a spinner that explains nothing.
+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.
**Queueing.** One FIFO per kind. `_pump` walks it in arrival order and **skips** a
member who is at their own cap rather than stopping at them — granting strictly in
@@ -1386,9 +1430,9 @@ told how many are `ahead` of it. Beyond 32 queued per member the answer is
`too_many_queued`, because an unbounded queue is how a node runs out of memory politely.
`used` and `cap` on `transfer_state` are this member's own count and this member's own
-limit **in this group** — the same value `_has_room` enforces and the same one the
-handshake ack announces. Three readings of one number, and an interface that draws a
-different one from the node's is an interface that offers a slot the node will queue.
+limit **in this group** — the same value `_has_room` enforces. The interface reads it
+from here and from nowhere else: one number with two sources is an interface that can
+offer a slot the node will queue.
**Reclaim.** The session teardown is the primary path and it is immediate. A sweeper
runs every 15 s for whatever the teardown cannot see, and tells two failures apart:
@@ -1419,6 +1463,19 @@ numbers), `bad_kind`, `too_many_queued`, and `not_your_transfer` — the last fo
or closing a `tr` another connection holds, which would otherwise be a denial of
service one random id away.
+**A lease is what the node granted, not what the client called it.** `tr` is drawn by
+the client, so on `file_req` and `file_upload` it is a claim, resolved against the
+node's own record for this connection:
+
+| `tr` names | `file_req` | `file_upload` |
+|---|---|---|
+| a lease of this connection, **granted** | served as a leased transfer; marks the lease alive | accepted; marks the lease alive |
+| a lease of this connection, still **queued** | refused, `lease_not_granted` — reading while queued is the cap not applying | refused, `lease_not_granted` |
+| nothing this connection holds, or no `tr` | a leaseless read, counted against the ceiling below | accepted, bounded by the upload protections (§11.4) — this is what a reconnect looks like, with the old connection's leases gone and the client re-opening them |
+
+Read as a bare presence check, the field would let any non-empty string skip the
+leaseless ceiling and every cap behind it.
+
#### Reads that carry no lease
Browsing a group is **never** subject to a transfer slot: not the poster grid, not the
@@ -1465,15 +1522,16 @@ as one.
```
C N
|-- file_req {v, file_id, chunk_index, [tr]} ------->|
- | | `tr` present: mark that lease
- | | alive (§11.2)
+ | | `tr` of a granted lease: mark
+ | | it alive; of a queued one:
+ | | `lease_not_granted` (§11.2)
| | index lookup; if the id is
| | not a file, try the media
| | cache (thumbnail/poster/
| | cover/transcode), sliced the
| | same way — never leased
- | | `tr` absent: admit against the
- | | leaseless ceiling, else
+ | | no granted lease: admit against
+ | | the leaseless ceiling, else
| | `transfer_required`
| | backpressure: wait while
| | bufferedAmount > 2 MiB
@@ -1494,8 +1552,9 @@ nonce = 12 random bytes
ct = AES-256-GCM(chunk_key).encrypt(nonce, plaintext) no AAD
```
-* The `:aes` suffix keeps AES keys distinct from the ChaCha20 variant
- (`chunk_key`/`encrypt_chunk`, `info` without the suffix) derived from the same GEK.
+* The `:aes` suffix is part of every chunk key: it once kept these distinct from a
+ ChaCha20 variant derived from the same GEK, which no longer exists, and it stays
+ because removing it would change every key.
* `nonce` and `ct` are msgpack **binary**, not base64. Every message that carries
content carries it this way, and none carries it outside an AEAD.
* The key is a pure function of (GEK, file hash, index), so chunks are cacheable,
@@ -1515,6 +1574,11 @@ ct = AES-256-GCM(chunk_key).encrypt(nonce, plaintext) no AAD
* **A chunk that is not this shape aborts the download**, with an error. There is no
fallback that decodes it some other way: a client that guesses at a chunk it does not
recognise writes its guess into the file the person is saving.
+* **In a public group, a file the hub's content blocklist names is not served.** It is
+ left out of `index_sync` and `index_delta`, and `file_req` for it or for its
+ thumbnail, `stream_req`, `subtitle_req` and `audio_transcode_req` are refused with
+ `content_blocked`. The node syncs the list from the hub and applies pushed changes
+ (`MESHBAY_DESIGN.md` §7.5); private groups are never affected.
### 11.4 Upload
@@ -1852,6 +1916,8 @@ duplicates is already stored and there is nothing for anyone to retry.
| the connection has identified its device (§9.4) | the `device` field is what receivers verify against |
| `device` == this connection's pinned device | **a device may only send as itself.** A member free to name another member's key could *be* that member to everyone, and the signature would verify |
| `sender_id` is taken from the authenticated session | never read from the message; it never was |
+| ciphertext at most 64 KiB (`chat_too_large`) | 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 |
+| at most 60 messages per 60 s per account per group (`chat_rate_limited`) | keyed by account, not connection, so a second tab does not buy a second budget. There is no node-wide chat ceiling: it would let a busy group silence a quiet one |
`format` is a *storage* state as well as a wire one, so the store can distinguish rows
the wire would not accept. Nothing changes what is accepted from a peer: the sealed
@@ -1994,7 +2060,6 @@ it back (§3.5).
| `stream_more` / `stream_stop` | C→N | auth | grant credit / abandon the stream |
| `chat_msg` | C→N, N⇒C | auth | send and fan out a message |
| `chat_hist` / `chat_hist_resp` | C→N / N→C | auth | paged history |
-| `chat_attach` | — | auth | attachment metadata (declared, unused on the wire) |
| `link_preview_req` / `_resp` | C→N / N→C | auth | OpenGraph unfurl |
| `media_meta_req` / `_resp` | C→N / N→C | auth | TMDB metadata for one file |
| `season_meta_req` / `_resp` | C→N / N→C | auth | per-season TMDB fields |
@@ -2003,6 +2068,9 @@ it back (§3.5).
| `audio_transcode_req` / `_resp` | C→N / N→C | auth | browser-playable copy of a WMA/MPC file |
| `subtitle_req` / `_resp` | C→N / N→C | auth | one embedded subtitle track as WebVTT, by cache hash |
| `ping` / `pong` | C→N / N→C | auth | liveness on an open channel |
+| `admin_challenge` | N→C | auth | the transcript fields of a signed op, to rebuild and sign (§10.2) |
+| `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 |
@@ -2029,7 +2097,6 @@ it back (§3.5).
| `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` |
-| `ephemeral_stream` | — | — | reserved, mobile live push |
| `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) |
@@ -2052,7 +2119,7 @@ speak, and every message above is available on it.
**QUIC is in development** (§5.2): a partial message set, no client, and not a shipped
feature. Nothing about it is a compatibility commitment yet.
-Two rules hold across transports, and both are about there being exactly one of each
+One rule holds across transports, and it is about there being exactly one of each
message:
* **One encoder per message type, shared by every transport.** `file_chunk` comes from
@@ -2061,21 +2128,25 @@ message:
its own. Two encoders for one type is a type free to drift, with a name that no longer
says which shape will arrive — and, when one of them is a sealed envelope, a second
construction site that goes on sending cleartext.
-* **`GroupIndex.serialize()` / `deserialize()` describes no MNP message.** It is a
- signed, compressed, encrypted at-rest and interchange format, and reading it as a wire
- contract is a mistake worth naming: the sealed envelope of §11.1a is what index
- messages travel under, and it is deliberately not this, because zstd decompresses in
- no browser.
---
## 13. Versioning and compatibility
-MNP versions independently of the package version. Current: **`3.4`**; oldest peer
-accepted: **`3.0`** — 3.1, 3.2, 3.3 and 3.4 are all additive, so the floor does not move
-with them. 3.4 adds the challenge signature (section 6.5) and invitation links
-(section 8.6): `invite_link_create`, `invite_link_result`, `invite_cancel`, which an older
-node answers as unknown messages.
+MNP versions independently of the package version. Current: **`5.0`**; oldest peer
+accepted: **`4.0`**.
+
+4.0 is the floor: a member presents a short-lived node-audience token bound to one node
+(§6.3) instead of its hub session token, a change to what a peer must *present*, so a
+pre-4.0 client is refused at the handshake. It carries everything 3.x added — the
+challenge signature (§6.5), invitation links (§8.6), audio-track and subtitle
+selection, per-account blobs.
+
+5.0 is a MAJOR confined to four signed operations — `root_add`, `group_attach`,
+`invite_create`, `tmdb_config` — whose subjects now name every value the node acts on
+(§10.4). A peer across the break refuses to sign the other side's subject, so those
+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.
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
@@ -2157,7 +2228,9 @@ walks through the gate meant to stop it.
| No MitM on the signaling path | both DTLS fingerprints (or the QUIC certificate hash) inside every transcript; empty binding is a refusal |
| The hub cannot read content | the group key never reaches it; chunk keys and chat epoch keys derive from or are wrapped under it |
| The hub cannot substitute a key at invite | the node wraps for a key its owner presented and signed, bound to an identity by a code the hub never sees |
-| The hub cannot administer a node | privileged ops need an Ed25519 signature from a roster-pinned operator key |
+| The hub cannot administer a node | privileged ops need an Ed25519 signature from a roster-pinned operator key; the unsigned operator controls need a device that proved such a key (§10.4) |
+| The token a member hands a node opens nothing at the hub | the handshake takes only `aud = MNP_AUD` tokens, and the hub API only its own audience (§6.3) |
+| A token captured by one node's operator is useless at another node | the MNP token names the node it was minted for, and a node refuses one naming a different key (`wrong_node`) — before the pre-proof window can serve anything |
| The hub cannot impersonate a device owner | device linking is countersigned by a key the node pinned; the hub stores no user keys |
| A revoked token cannot connect | denylist on user, `jti` and group, persisted across restarts |
| A signature cannot be repurposed | domain-separated, length-prefixed transcripts naming op, subject, node, group, nonce and time |
@@ -2183,9 +2256,10 @@ walks through the gate meant to stop it.
attacks apart: the pairing code defeats a hub that *lies in its directory* — silent,
undetectable, per-request — not one that *rewrites the client*, which is an artifact
that can be inspected and compared.
-* **An invitation link's code is a bearer code** (§8.6). The node admits whoever brings
- it first; what restricts who can bring it is the hub, which lets only the addressed
- account reach the node — a rule an active hub does not have to keep. The challenge
+* **An invitation link is a bearer secret on both halves** (§8.6). The hub admits the
+ first account that redeems its ticket, and the node admits whoever brings the code
+ first; the link is bound to no address, so whoever holds it first joins. What bounds
+ it is that it works once, for seven days, and can be cancelled. The challenge
signature (§6.5) keeps the code from reaching any node but the issuer; it does not
keep it from a hub the inviter asked to mail it, which then holds it.
* **The pre-proof window is a disclosure surface.** A hub that forges a JWT can fetch a
@@ -2281,8 +2355,10 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| Constant | Value | Source |
|---|---|---|
-| `MNP_VERSION` | `3.4` | `meshbay_common/__init__.py` |
-| `MNP_MIN_SUPPORTED` | `3.0` | `handshake.py` |
+| `MNP_VERSION` | `5.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`) |
| `NONCE_LEN` | 32 bytes (both handshake nonces) | `handshake.py` |
| `ADMIN_CHALLENGE_TTL` | 120 s | `adminop.py` |
| `JOIN_TTL`, `DEVICE_TTL` | 120 s | `join.py`, `device.py` |
@@ -2317,10 +2393,11 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| `MAX_CONCURRENT_TRANSCODES` | 8 | `webrtc/apps/streaming.py` |
| Link preview rate | 15/conn, 60/node per 60 s; cache 1 h × 256 | `webrtc/chat.py` |
| ICE gathering deadline | 4 s | `transport.js` |
-| Signaling: max SDP, pending per user, rate | 16 KiB, 3, 30/min, 15 s answer timeout | `api/signaling.py` |
+| Signaling: max SDP, pending per account, per-account-per-node budget, per-address rate | 16 KiB, 32, burst 120 refilled at 2/s, 600/min per node, 15 s answer timeout | `api/signaling.py` |
+| `MAX_CHAT_CIPHERTEXT` / chat rate | 64 KiB / 60 per 60 s per account per group | `webrtc/chat.py` |
| GEK | 256-bit, node CSPRNG | `crypto.py` |
| Chat epoch key | 256-bit, node CSPRNG, one per group per epoch | `chatbox.py` |
-| Chunk cipher | AES-256-GCM, 96-bit nonce (ChaCha20-Poly1305 variant for native) | `webcrypto.py`, `crypto.py` |
+| Chunk cipher | AES-256-GCM, 96-bit nonce | `webcrypto.py` |
| GEK wrap | X25519 + HKDF-SHA256 + AES-256-GCM, AAD = recipient public key | `crypto.py` |
| Sealed message envelope | HKDF-SHA256 subkey per purpose, AES-256-GCM, 96-bit random nonce | `groupbox.py`, `static/crypto.js` |
| Chat envelope | HKDF-SHA256 subkey per device per epoch, AES-256-GCM, 96-bit random nonce, Ed25519 over the ciphertext | `chatbox.py` |
@@ -2340,8 +2417,9 @@ meshbay-common/ protocol.py message types, chunk and upload codecs
adminop.py the admin transcript and the operation catalogue
join.py join transcript and pairing codes
device.py device request / add / hello transcripts
- crypto.py GEK, chunk keys, ECIES wrap, BLAKE3 ids
- webcrypto.py the AES variants the browser can also compute
+ crypto.py GEK, ECIES wrap, keystore, BLAKE3 ids
+ webcrypto.py the content cipher: per-chunk AES-GCM keys
+ tokens.py the two token audiences (node vs hub API)
meshbay-node/ transport/webrtc_server.py the reference implementation of MNP,
transport/webrtc/ assembled from these modules