diff options
Diffstat (limited to 'docs/MESHBAY_DESIGN.md')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 336 |
1 files changed, 241 insertions, 95 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 2419916..525c5c3 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -16,8 +16,8 @@ > 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), -> **MHP 0.1**, packages **0.17.0**. The normative source for the wire format is +> Wire versions at the time of writing: **MNP 6.0** (oldest peer accepted 4.0), +> **MHP 0.1**, packages **0.18.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 @@ -3345,6 +3343,45 @@ A client, not a host. The platform is hostile to *hosting* a node — background execution, storage, battery — and fine as a *client*, which is one of the reasons enrichment is node-side (§6.5). +**The application is the desktop client's design in a system WebView** +(`packages/meshbay-android/`). What §8.2 depends on is not Electron but an +engine with `RTCPeerConnection`, WebCrypto X25519/Ed25519 and MSE, the +interface loaded from the package, and a privileged side reached through a +narrow bridge — and Android has all three: + +- **The interface is the hub's `static/`, copied at build time** (§8.3) and + served from the APK by an asset loader under + `https://appassets.androidplatform.net` — a secure context, so `crypto.subtle` + exists. Nothing the hub serves is ever loaded into the WebView; the policy is + the desktop's, sent as a header. +- **The bridge offers the desktop preload's `window.meshbay`** wherever it + offers anything. What a phone does not have — the node, shared folders, the + tray — is **absent, not a function that refuses**: `platform.js` decides what + to show from whether an object exists. The bridge answers the packaged + origin's top-level document only (`addWebMessageListener`, `isMainFrame`); a + same-origin child frame does get the port, and is refused there. +- **Keys are held natively** (§3.7, §14.1 #20): device key, bundle key and every + node identity, under an Android Keystore key, never handed to the page. The + Kotlin keyring is a third implementation of the bundle format and the + transcripts, so **`tests/vectors/keyring.json` — generated from the desktop + keyring and checked against the specification — is what every implementation + must reproduce**; a list the vectors cannot hold (the admin operations that + may be signed) is compared by source. +- **Hub calls leave from native code**, to the signed-in hub only, as on the + desktop. **Downloads go to disk** through the Storage Access Framework or the + Downloads collection, as pending files until complete; uploads come through + the system picker. +- **A hidden page is frozen by Chromium sixty seconds after it is hidden** — + measured, and whatever the process's importance: a foreground service, wake + locks and the renderer's priority policy do not prevent it. A cast's pipeline + lives in the page (WebRTC → decrypt → relay), so while one runs the shell + keeps the WebView reported visible and holds a media-playback foreground + service; nowhere else, because a page never hidden is never throttled. + +Release builds are signed with the development key until the release key exists +(Stage D12); §2.3's sentence about who holds a signing key applies to whichever +store distributes them. + ### 11.4 Casting An HTTP relay in the desktop client serves a standard fragmented-MP4 stream that @@ -3352,6 +3389,39 @@ any LAN renderer can play; the relay is device-agnostic. Chromecast discovery an control ship. DLNA/UPnP is designed and not built: it is a second device backend beside the first, not a second relay. +**Discovery lists receivers as they answer.** The mDNS scan runs six seconds, +because a receiver coming back from a reset can take that long, but most answer +within two. The main process holds what the scan has found and the page polls it +(`cast:scan`, then `cast:devices`) for as long as the picker is open, rather than +waiting on one call for the whole scan; a poll uses the same checked `handle()` +door as every other call, where a pushed event would be a second one. + +**The Android relay is the desktop's, with three things the phone found.** +(1) **The header is everything before the first moof**, however many chunks it +arrives in: the node's first chunk can be the 28-byte ftyp alone, and served as +the header it left the receiver without a moov. (2) **What a receiver has not +read waits in a spool file, not in memory.** A fragment is a segment — 5 to +10 MB at a film's bitrate — and the page runs ahead of the television by its +whole read-ahead; dropped past the desktop's 8 MB bound, each lost fragment +froze the picture for its length. (3) **A seek's first segments are held while +it lands** (`video-player.js`): they are the new stream, its header first, and +they arrived while `reinitAt` waited on the SourceBuffer and were dropped as the +old film's — the local player never noticed, a relay restarted there did. The +local element keeps playing while a cast runs, because its playhead paces the +stream, and is muted. + +**While a receiver plays the film, the player is its remote.** Phone and +television start apart and drift, so a scrubber on the local playhead lied about +where the film was and a seek from it landed off by the gap. The page polls +`cast:status`, whose `chromecast` carries the receiver's `playerState` and +`position` (stream seconds, zero at the relay's start; the page adds the +stream's start), and shows that over the local picture with play/pause +(`cast:chromecast:pause`/`play`), ±30 s and a scrubber. A seek is the ordinary +seek: it restarts the relay and reloads the receiver there, and until that +lands the target is shown, not the old stream's position. Pausing the receiver +pauses the local element too, which otherwise goes on fetching for nobody. The +copy-URL cast has no receiver to read and keeps the ordinary player. + **Subtitles are rebased onto the relay's clock before they are sent.** The node extracts a track whole, so its cues carry the film's timeline, and the player can use them unchanged because its SourceBuffer is given `timestampOffset = @@ -3539,7 +3609,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`) @@ -3605,7 +3675,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 | @@ -3672,6 +3742,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 @@ -3743,7 +3879,9 @@ pause and resume, the group-application framework with Chat, Files, Videos, Music and Photos, cross-group search with source merging, per-account playlists, casting to a Chromecast with subtitles rebased onto the relay's clock, the operator CLI and loopback control API, the desktop client through its identity -and download stages, account recovery, and the Windows port through packaging. +and download stages, account recovery, the Windows port through packaging, and +the Android client — the shell, native keys, downloads and uploads, and casting +(§11.3). The packages install: a machine has been taken from the built artefacts to a running hub and node on **Ubuntu 26.04 (`.deb`), Fedora 44 (`.rpm`) and @@ -3771,13 +3909,19 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens | | — | Delegation (§3.4) | | — | Tier 3 roster attestation (§3.3) | -| — | Android client | +| — | **Android: phone behaviour and release** — the back button driving the page, recovery from a network handover, keeping a download alive with the screen off, lock-screen media controls, and a release key (§11.3) | | — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines | ### 15.3 Open, and why each is where it is | Item | Status | |---|---| +| **The desktop cast relay drops whole segments** | `cast-relay.js` drops a fragment once 8 MB wait for a receiver, and a fragment is a segment, 5 to 10 MB at a film's bitrate: the picture freezes for its length. Its backlog is bounded in fragments only. The Android relay spools a receiver's lead to disk and bounds its backlog in bytes (§11.4); the desktop has neither | +| **The desktop cast relay takes the first chunk for the whole header** | The node's first chunk can be the ftyp alone, the moov in the next; the receiver then gives up. Fixed in the Android relay (§11.4), not in `cast-relay.js` | +| **A cast restart may pull far ahead** | Seen once on an emulator with a synthetic film: after the restart's reinit the node reported `duration=None`, and the page pulled most of the film at network speed. Not reproduced on a real film; the suspicion is a read-ahead budget computed without a duration | +| **"Copy stream URL" after picking a receiver casts to that receiver** | The player keeps the last device chosen, so the copy-only path reconnects it instead of only starting the relay | +| **The home page says "No groups yet" when the hub cannot be reached** | An unreachable hub reads as an account with no groups, rather than as an error | +| **`meshbay-node init` says "Settings → Link Node"** | The control is on the Profile page, as QUICKSTART says | | **C4** for accounts with browser access | Closed against operators by the pepper; **open against an active hub**, which holds the pepper and can fetch a bundle with a token it mints — the adversary T3 already concedes for browsers (§3.7) | | **A desktop that never held an identity cannot open its recovery copy** | The application opens a node's passphrase copy, not the recovery copy: after a reset, an identity it never held is recovered from a browser (§3.6). And an identity the application mints gets a recovery copy only when the recovery key is entered on its Profile page with browser access on | | **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds | @@ -3791,10 +3935,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 | --- @@ -3814,7 +3960,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 | |