diff options
| -rw-r--r-- | CLAUDE.md | 1 | ||||
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 74 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 143 |
3 files changed, 146 insertions, 72 deletions
@@ -1005,7 +1005,6 @@ here are kept only where they are a rule about *editing* the code. ## meshbay.org server (target state) - OS: Ubuntu 26.04 LTS, Python 3.14.4 -- SSH: `ssh cbesson@meshbay.org` - Caddy: HTTPS reverse proxy on 80/443 - UFW rules: **22/tcp, 80/tcp, 443/tcp only** - Legitimate services: `meshbay-hub.service`, Caddy, PostgreSQL (local), `fcgiwrap` (cgit on git.meshbay.org) diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 78376a3..170081f 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -99,7 +99,7 @@ opens them. │ └─────────┘ MHP 0.1 │ signalling (SDP/ICE, <1 KB), presence, revocation push MHP │ - ┌────┴────┐ MNP 3.0 ┌──────────┐ + ┌────┴────┐ MNP 4.0 ┌──────────┐ │ node │◄──────── WebRTC DataChannel / QUIC ──────────►│ client │ └─────────┘ index, file chunks, streams, chat, admin └──────────┘ holds the files browser SPA or desktop @@ -293,7 +293,7 @@ comes from the hash binding: |---|---| | Request TTL | 1 h, `[node] device_request_ttl_minutes` | | Devices per account per node | 5 | -| Attempts per connection | 5, then a node-wide lockout | +| Attempts per connection | 5, audited on exhaustion | | Filing a request | requires the account to have at least one pinned identity already | `member unpin <user>` removes **every** device of an account, and is the only @@ -489,9 +489,10 @@ The Members tab can ask the hub to mail the code to the invitee's address on fil can join in the invitee's place. It is offered because a code that arrives on its own is worth more to most groups than the property, and it is stated rather than hidden: the box reads *"Send the invitation by e-mail (may land in spam)"*, is -checked by default, and is **remembered per account** (the `invite_email` -preference), so an operator who unticks it once is not asked to again. Unticked, -the hub is never called and the table above holds exactly. The CLI mails nothing. +**unticked by default** — giving the hub the code is something the inviter opts into, +never something done unasked — and is **remembered per account** (the `invite_email` +preference), so an operator who ticks it once is not asked to again. Unticked, the +hub is never called and the table above holds exactly. The CLI mails nothing. The same box sits under the link form, sharing the same preference. Ticked, and with an address typed, the hub mails the link to that address — and @@ -549,8 +550,10 @@ rendered as a grouped mnemonic. `recovery_key = HKDF-SHA256(R, info = "meshbay:recovery:v1:" + username)` — HKDF and not Argon2, because `R` has 256 bits and there is nothing to brute-force. Every time an identity bundle is written to a node, a **second copy** is written beside it wrapped under `recovery_key` -(`bundle_enc_recovery`, additive on the wire). `R` is a pass-through: offered in -the registration email by default, never written to any database, never logged. +(`bundle_enc_recovery`, additive on the wire). `R` is a pass-through: shown once +on screen at registration and mailed with the verification code only if the person +ticks the box for it (unticked by default), never written to any database, never +logged. The reset endpoints are built to leak nothing. `POST /v1/users/password/reset-request` requires **the username and the email on file as a pair**, checked against a blind @@ -929,7 +932,7 @@ implementations of one security check is **C6** waiting to happen. ``` client → node handshake {token, group_id, nonce_c, v, v_min} -node authorize_token() JWT · scope · denylist · group_id · membership · hosting +node authorize_token() JWT · aud · scope · node · denylist · group_id · membership · hosting node → client handshake_challenge {nonce_s, node_pk, sig} sig: Ed25519 over the challenge (3.4) ── pre-proof window: bundle fetch, join ── client → node handshake_response {proof} @@ -956,7 +959,9 @@ buys, per the convention at the top: a client that knows which node it means to reach can refuse to send a code anywhere else — against a hijacked signaling path and against a second host of the same group. It proves *a* key, not the *right* one: it helps only a client that already knows which key to expect. A wrong -signature is refused; an absent one is an older node, discovered from its answer. +signature is refused; an absent one leaves the key unproved until the ack, and a +client holding a link code then does not send it. With the floor at 4.0 every +reachable node signs, so that branch is one only a lowered floor could reach again. **Channel binding is mandatory and an absent one is refused** — never degraded to nonce-only, which would silently drop MitM detection: @@ -1000,7 +1005,9 @@ for `hubFetch` and signaling, and a short-lived **MNP token** (`aud = MNP_AUD`, from `POST /v1/nodes/mnp-token`) that carries the member's `sub`, `groups` and `jti` and is the only thing presented in the handshake. The node binds `MNP_AUD` when it decodes, so a session token is refused here; the hub API binds its own -audience, so an MNP token captured by an operator is refused there. It is +audience and requires `exp`, `sub` and a known `scope`, so an MNP token captured +by an operator is refused there, and so is any other hub-signed token that is not a +session (a revocation broadcast, an MHP token). It is checked once, before the proof, so its short life never interrupts a transfer or a film already playing — a reconnect fetches a fresh one. `meshbay_common/tokens.py` holds the two audience strings, shared by the hub that issues and the node that @@ -1031,10 +1038,11 @@ requester and it therefore grants nothing across accounts. `gek_required: false` bypass (**NS8**). **Refusals carry a code**, not only a sentence, because a client can act on a code. -`not_a_member` in particular is usually a token issued before the person was added -to the group — `groups` is baked in at sign-in and the hub pushes no updates — so -the client refreshes once and retries rather than telling someone who was invited a -minute ago that they are not a member. +`not_a_member` means the hub did not count the account a member when it minted the +token; since the MNP token is minted per connection from the membership the hub holds +then, a stale `groups` claim is no longer the usual cause. The client still refreshes +once and retries on that code before telling someone who was invited a minute ago +that they are not a member. ### 5.3 Correlation and liveness @@ -1279,11 +1287,13 @@ it**. > which point it silently takes the other. **A field kept "just in case" is how > the branches come back.** -**The floor is not the current version, and MINOR additions are why.** It is -`MNP_MIN_SUPPORTED` in `handshake.py`, it equals the last MAJOR, and 3.1, 3.2, -3.3 and 3.4 have all been added above it without moving it. 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**. +**The floor is not necessarily the current version, and MINOR additions are why.** +It is `MNP_MIN_SUPPORTED` in `handshake.py` and it equals the last MAJOR. Today the +two coincide at 4.0, but 3.1, 3.2, 3.3 and 3.4 were each added above the 3.0 floor +without moving it, and the next MINOR will be added above 4.0 the same way. 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**. 3.2's audio tracks are the worked example: the node lists them in `stream_init`, the client draws its selector from that list, and a node that sends no list gets no selector. 3.3's subtitles repeat it exactly, and add the case where the list is @@ -1813,8 +1823,10 @@ the next node on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3). The node authenticates to the hub with an Ed25519 signature over a domain-separated timestamped message — **no password and no auth key on a node** — and receives a -`scope: "node"` token that is refused for group management. The operator manages -groups from a client (**NS7**). +`scope: "node"` token that is refused for group management and on every admin and +moderator route, even when the account behind it holds a hub role: what a node may do +is its operator's roster pin, and a hub role is a person's, exercised from a client. +The operator manages groups from a client (**NS7**). Signaling is rate-limited, SDP-size bounded, capped per user, and **the caller must share an active group with the target node**. Otherwise any authenticated user @@ -1950,6 +1962,16 @@ The client shows the real state, not a blanket one. **Revocation is honoured by nodes** and the denylist survives a restart (**H4**); signaling refuses a group that is not active. +**Revocation has one door, and it broadcasts.** Only an administrator revokes, and +only through `POST /v1/admin/revoke`, which signs the revocation and pushes it to every +connected node — the same signed broadcast an administrator's account deletion sends +(§7.7). The user and group PATCH handlers refuse `revoked` outright, because a +status written there reached no node and behaved as a suspension while claiming to be +a revocation. Moving a group or an account *out* of `revoked` is an administrator's +call too, and it changes the hub row only: the nodes keep enforcing the revocation +they received, so the hub and the nodes then disagree until each operator clears it +(`denylist clear`). That is why the table says "no". + **Moderator is not administrator.** The user-patch handler is split by field: a moderator may act on the fields moderation needs and may not write `role`. @@ -2067,8 +2089,8 @@ The rules that make this safe: attempts than the limit. A request that checked no passphrase gives its attempt back. - **Every path that checks the passphrase counts on the same row**: sign-in, - passphrase change and account deletion. A right passphrase clears it; failures - older than the window age out. + passphrase change, changing the e-mail address on file and account deletion. A + right passphrase clears it; failures older than the window age out. - **A lockout refuses passphrase sign-in and nothing else.** Open sessions, token renewal and device sign-in continue, and a reset code sent to the address on file clears it — so a stranger who locks a public username costs its owner at @@ -2952,7 +2974,7 @@ The bulk of the codebase is portable because the portability rules in §10 were treated as correctness from the start. What the port needed is registered as **W1–W9** (§13.7) and is done; packaging is built and awaits a clean-machine run. -Two Windows-specific design points worth stating here: +Three Windows-specific design points worth stating here: - **The node runs in one of three modes, chosen at install and switchable afterwards** from the Node page: only while the application is open (it starts @@ -3090,7 +3112,7 @@ be understood, not so the incident can be retold. | **NS4** | **Operator authority comes from the node's roster and from nowhere else.** No auto-pin from the keystore, no resolution through the hub, no config key — a config naming one is warned about and never obeyed (§3.4, §6.1) | | **NS5** | The proof is **bound to the transport channel** (DTLS fingerprints / certificate hash), so a signaling relay that substitutes its own cannot produce it (§5.2) | | **NS6** | **`sender_id` is enforced from the authenticated session, never the wire.** It is what the store keys on; it is not what authenticates a message — the device signature is (§4.5) | -| **NS7** | The node authenticates to the hub with **Ed25519 and no password**, and its token's scope is refused for group management (§7.2) | +| **NS7** | The node authenticates to the hub with **Ed25519 and no password**, and its token's scope is refused for group management and on the admin and moderator API (§7.2) | | **NS8** | **The node refuses connections when it holds no group key.** There is no bypass switch | ### 13.3 Second review (code review) — the default numbering @@ -3274,7 +3296,7 @@ had already been asked. |---|---| | **W1** | Platform directories: no hardcoded XDG paths | | **W2** | Signal handling is platform-guarded | -| **W3** | Daemon lifecycle: a per-user startup launcher by default, a scheduled-task service mode offered, switchable after install (§11.2) | +| **W3** | Daemon lifecycle: three modes — only while the application is open, at sign-in, or as a boot-time scheduled task — chosen at install and switchable after; one start/stop implementation, the CLI's (§11.2) | | **W4** | Packaging: one per-user installer carrying client and node, with media tools bundled | | **W5** | File permission calls are skipped where they have no meaning | | **W6** | Media-tool discovery fails at startup with a stated reason rather than at first use | diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 8de731e..34e2464 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`) +**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,13 @@ 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` (§10.4) | +| Metadata | `transcode_not_applicable`, `tmdb_search_rate_limited` (§11.9) | 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 +308,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 +380,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 +472,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 +481,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 +549,13 @@ 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`); no `sig` leaves the +key unproved until the ack, and a client holding an invitation-link code then refuses +to send it. 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. With the floor at 4.0 every peer a client can reach signs, so an absent `sig` +no longer means an older node: the client's handling of it is a branch only a lowered +floor could make reachable again. ### 6.6 `handshake_ack` fields @@ -567,7 +582,7 @@ at all — including a decryption. | `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 @@ -1045,8 +1060,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,7 +1098,7 @@ 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_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 | @@ -1134,13 +1149,16 @@ 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. Rules that hold across the table: @@ -1199,7 +1217,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 +1257,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 @@ -1419,6 +1443,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 +1502,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 @@ -1852,6 +1890,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 @@ -2003,6 +2043,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 | @@ -2071,11 +2114,14 @@ message: ## 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: **`4.0`**; oldest peer +accepted: **`4.0`**. 4.0 is a MAJOR: a member presents a short-lived node-audience token +bound to one node (§6.3) instead of its hub session token, which is a change to what a +peer must *present*, so a pre-4.0 client is refused at the handshake and the floor moved +with the version. It carries everything 3.x added — the challenge signature (§6.5), +invitation links (§8.6), audio-track and subtitle selection, per-account blobs — so +nothing is currently above the floor, and every capability this document describes is +one every reachable peer has. 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 +2203,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 +2231,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 +2330,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` | `4.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,7 +2368,8 @@ 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` | @@ -2342,6 +2394,7 @@ meshbay-common/ protocol.py message types, chunk and upload codecs 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 + 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 |