diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 250 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 97 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 5 | ||||
| -rw-r--r-- | docs/transfers-v1.md | 5 |
4 files changed, 215 insertions, 142 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index eec354a..3ce0a67 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 @@ -134,76 +134,67 @@ paths, no filenames**. --- -## 2. Trust model +## 2. Who you trust -### 2.1 Adversaries +MeshBay lets you share things with the people you choose, and keeps everyone else +out. In plain terms, here is who can see your group — and the one caveat worth +knowing. -Every claim below is written against one of these, and they are the only ones the -document uses: +### 2.1 Inside your group -| Adversary | What they can do | -|---|---| -| **Passive hub** | Read everything the hub legitimately stores and relays | -| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser | -| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host | -| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse | -| **Network attacker** | Observe and tamper with traffic between any two parties | -| **Local attacker** | Reach loopback services and files on a client or node machine | -| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership | -| **Federated peer hub** | Push directory rows and revocations over MHP | +A group lives on a node, run by its operator. Everyone in the group — the operator +and the members — shares its files, its index and its chat. That is what a group +*is*: the people you decided to let in, the same as a shared folder or a team +workspace. They are the people you trust, and they are the only ones who can read +what you put there. -### 2.2 Security claims +Two things follow from that by design — they are the shape of a shared space, not +gaps in it: -| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker | -|---|---|---|---|---|---| -| Data never transits the hub | ✅ | ✅ | — | — | ✅ | -| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ | -| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | -| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ | -| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ | -| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | -| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | -| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **detectable, not prevented** | ✅ | ✅ | ✅ | -| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ | -| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | -| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ | -| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ | -| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — | -| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — | -| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ | +- Your operator hosts your files, so they can change what they host. A node's + content is the operator's to keep and to serve (§7). +- Leaving a group removes you on the hub, but copies already on a node stay with + its operator (§7.7) — as with anything you have handed to someone in person. -### 2.3 What the project must not claim +### 2.2 Everyone else sees nothing -Three sentences are forbidden, each for a deliberate reason: +Outside your group nobody can read what you share — not the hub that connects you, +not anyone watching the network, not the operator of any *other* group: -- **"Everything is encrypted and unreadable by other parties, even the hub."** - A hub that ships the code can lift keys from the page regardless of protocol - design (**T3**). That is an artifact-level attack, not a silent directory lie, - and it is removed for native clients — not for browsers. -- **"A native client makes the hub untrusted."** It converts an undetectable, - per-request, per-user attack into a persistent artifact that can be hashed and - compared. That value is realised by reproducible builds and published hashes, - not by the packaging format. A build signed with a key the hub operator holds - *relocates* trust; it does not remove it. -- **"C4 is closed."** It is closed for an account whose identities the desktop - application keeps (browser access off, §3.7): nothing of them is on any node. - For an account with browser access the bundle is still on each node, sealed - under the passphrase **and** a pepper the hub holds — no operator can search it - offline, but an active hub can, and that is T3's adversary already. **An account - is only as strong as its weakest client.** +- Your content never passes through the hub in the clear. The hub introduces nodes + to one another and relays sealed traffic; it does not hold what you share (§5). +- The hub holds no key of yours. It cannot read your group, add a device to your + account, or act as you — and it cannot hand your group's key to anyone, save + where you ask it to (an open-join group, or an invitation you asked it to mail; + §7.3, §3.4). +- Each node carries its own identity keys, so a key that somehow leaked would open + that one node and no other (§3), and a copy of a node's storage without its + unlock key reveals none of its chat (§4.5). + +### 2.3 Your operator hosts you, but cannot become you -"End-to-end" here describes **client ↔ node**, never client ↔ client. Members and -the operator read everything in their group; that is what a group is. +Keeping your content is the deal you made with your operator. Turning that into +*being* you is the line they cannot cross. The identity bundle stored on their +node opens only with your passphrase and a secret the hub releases to no one but a +session that has already proved the passphrase or a device key; the only attempts +left to an operator are ordinary sign-ins, which the hub counts and locks out. And +reading what *they* host never reaches what *other* operators host (§3.2). -### 2.4 One boundary worth naming +### 2.4 The one honest caveat: the browser -An operator hosts your content by design. They should not be able to become -*you*. The bundle on their disk does not let them try: it opens only with the -passphrase and a pepper the hub hands to nobody but a session that proved the -passphrase or a device key, so the only guesses left to them are sign-ins, which -the hub counts and locks out. And were a bundle key to leak all the same, it -opens **the bundle on that node** and no other. Reading what they host is by -design; reading what *other* operators host is not, and does not follow (§3.2). +Used in a web browser, MeshBay is a page the hub sends you on each visit — so a hub +that had been taken over could send an altered page and lift your keys from it. +This is true of **every** web application; most simply never say so, and no +protocol can prevent it, because the attack is in the code itself rather than in +the messages. So we never claim your content is unreadable *even by the hub* when +you use a browser. + +The desktop and mobile apps close this. Their code is installed once, from a +package anyone can rebuild from source and check against a published hash, so a +tampered build is caught instead of silently trusted. For the strongest assurance, +use the app. This is the project's single standing limit for browser use — tracked +as **T3** — and the full, adversary-by-adversary analysis behind every statement +in this section is in §13.9 for readers who want it. --- @@ -786,10 +777,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 +889,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 +1224,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 +1442,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 +1532,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 +1890,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 @@ -1957,7 +1955,7 @@ an operator-signed op. Hub minimisation was considered and **deferred, and may be dropped** (decision D4). The hub keeps serving the web UI and remains in the trusted path by choice. That is a legitimate product call; what follows from it is carried deliberately rather than -by accident (§2.3). +by accident (§2.4). **Stores:** accounts (username, encrypted email, status, role), the group registry and membership, IP logs (one year, legal retention), node registrations, refresh @@ -3546,7 +3544,7 @@ Two structural recommendations from that review stand as rules: |---|---| | **T1** | **The password split.** The hub never sees a passphrase; it holds a verifier for a client-derived `auth_key`. The passphrase floor can therefore only be enforced client-side (§3.1) | | **T2** | The hub was the key directory. **Closed** by admission redesign, not by safety numbers: the invite path reads no directory at all (§3.4). Reclassified as **H3** | -| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§2.3, §8.2) | +| **T3** | **The hub serves the SPA. Accepted permanently for browser users.** It is the only remaining way an active hub reads content, it is an artifact-level attack rather than a silent lie, and it does not exist for a native client — whose value is realised by reproducible builds, not by packaging (§13.9, §8.2) | ### 13.5b Availability between members (`AV`) @@ -3612,7 +3610,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 | @@ -3679,6 +3677,72 @@ had already been asked. | **O13** | **Hub identity pinning.** The client points at a hub by URL and nothing pins that hub's identity. Bounded, because a substituted hub can neither read content nor ship the code to a native client — worth doing all the same | | **V1–V13**, **P1–P5** | Per-application open items: wording of a disabled-service state, whether artwork reuses the chunk path, cache TTL, multi-track surfacing, HEIC/RAW support, a fuller EXIF panel, lightbox preloading, album-boundary behaviour, cover selection | +### 13.9 Formal adversary model + +§2 states the trust model for a human reader. This restates the same ground +formally, for auditors and implementers: the adversaries every claim is written +against, the claim-by-adversary matrix, and the over-claims the project refuses to +make. Nothing here is new — it is §2 with the proofs shown. + +"End-to-end" throughout describes **client ↔ node**, never client ↔ client. The +operator and the members read everything in their group; that is what a group is. + +#### Adversaries + +Every claim is written against one of these, and they are the only ones the +document uses: + +| Adversary | What they can do | +|---|---| +| **Passive hub** | Read everything the hub legitimately stores and relays | +| **Active hub** | Also lie: forge tokens, invent accounts, substitute values it publishes, ship modified client code to a browser | +| **Malicious node operator** | Read and alter everything on their own machine, including the plaintext files they host | +| **Malicious group member** | Everything a member may do, plus anything the protocol fails to refuse | +| **Network attacker** | Observe and tamper with traffic between any two parties | +| **Local attacker** | Reach loopback services and files on a client or node machine | +| **Registered hub user with no membership** | Reach every hub endpoint that does not check membership | +| **Federated peer hub** | Push directory rows and revocations over MHP | + +#### Claim matrix + +| Claim | Passive hub | Active hub | Malicious node operator | Malicious member | Network attacker | +|---|---|---|---|---|---| +| Data never transits the hub | ✅ | ✅ | — | — | ✅ | +| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the group key | ✅ | +| The file index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | +| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ — the operator is a member | ❌ | ✅ | +| Chat is unreadable **from a copy of the node's storage that lacks its unlock key** — not from a whole disk by default (§4.5) | ✅ | ✅ | — the operator holds the unlock key | ✅ | ✅ | +| Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | +| The node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | +| Client code integrity | ❌ **T3, accepted** (browser) · ✅ ships in the package (native) | ❌ T3 · ⚠️ native: **publicly verifiable, not prevented** | ✅ | ✅ | ✅ | +| The hub cannot obtain the group key | ✅ | ✅ **except** in an open-join group, where it can join legitimately (§7.3), and for an invitation the inviter asked the hub to mail, whose code it then holds (§3.4) | — | — | ✅ | +| Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | +| Devices cannot be added by the hub | ✅ | ✅ — the hub holds no user key and cannot countersign | ⚠️ a node adds a device only to itself, where it already reads everything | ✅ | ✅ | +| Chat senders are authenticated to each other | ✅ | ✅ | ⚠️ only for accounts the reader has already seen (§3.3) | ✅ | ✅ | +| Keypair bundles (**C4**) | ✅ it holds the pepper and no bundle | ⚠️ it can fetch a bundle with a token it mints and holds the pepper: an offline passphrase search, as T3 already concedes for browsers · none to fetch for an account without browser access | ✅ no offline search: the bundle does not open without the hub's pepper — only sign-in attempts, bounded and audited · none on disk for an account without browser access | — | — | +| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ files, pinned identity and bundle stay on the node (§7.7) | — | — | +| Your identity keys stay yours | ✅ | ⚠️ as the row above | ✅ the bundle they hold does not open without the pepper, and a leaked bundle key opens **that node's** bundle only | ✅ | ✅ | + +#### Over-claims the project refuses to make + +Three sentences are forbidden, each for a deliberate reason: + +- **"Everything is encrypted and unreadable by other parties, even the hub."** + A hub that ships the code can lift keys from the page regardless of protocol + design (**T3**). That is an artifact-level attack, not a silent directory lie, + and it is removed for native clients — not for browsers. +- **"A native client makes the hub untrusted."** It converts an undetectable, + per-request, per-user attack into a persistent artifact that can be hashed and + compared. That value is realised by reproducible builds and published hashes, + not by the packaging format. A build signed with a key the hub operator holds + *relocates* trust; it does not remove it. +- **"C4 is closed."** It is closed for an account whose identities the desktop + application keeps (browser access off, §3.7): nothing of them is on any node. + For an account with browser access the bundle is still on each node, sealed + under the passphrase **and** a pepper the hub holds — no operator can search it + offline, but an active hub can, and that is T3's adversary already. **An account + is only as strong as its weakest client.** + --- ## 14. Decisions that are not revisited @@ -3798,10 +3862,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 | --- @@ -3821,7 +3887,7 @@ superseded document kept under `docs/`, a note somebody holds elsewhere. | Cited as | Read | |---|---| -| `draft-v5 §2`, `draft-v6 §4` — security claims | §2.2 | +| `draft-v5 §2`, `draft-v6 §4` — security claims | §13.9 | | `draft-v5 §3` — transport, NAT traversal | §5.1 | | `draft-v5 §4`, §4.1–4.4 — handshake, transcript, channel binding, mutual auth | §5.2 | | `draft-v5 §5.1`, `draft-v6 §2.3`, `§2.4b` — privileged operations, key activation, authorship | §5.4 | 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 e85b260..d631804 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -437,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 |