summaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v6.md
blob: 2ea73c4ab1085c7ff7d121afbe65f6c4d450d7f9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# MeshBay — Architecture Draft v6

> Status: **current specification.** Supersedes `meshbay-draft-v5.md`.
> **Sections not restated here are unchanged from v5**, which remains the reference for
> everything v6 does not touch — the handshake (§4), node authority (§5), the hub's role
> (§6), cryptography (§7) and the testing posture (§10) are all still v5's.
>
> v6 exists because a design discussion on 2026-08-17 settled the desktop client and, in
> doing so, changed four things v5 states: what a group's content *is*, how a person's
> devices are admitted, how authorship is established, and which shell the native client
> uses. It also records one rule v5 assumed without writing down.
>
> The v5 convention is carried forward and is not negotiable: **a claim in this document
> must name the adversary it holds against.** A property that holds against a passive hub
> and not an active one is written that way.

---

## 0. Reading order

| Read | For |
|---|---|
| **this document** | what is true now, and what changed from v5 |
| `meshbay-draft-v5.md` | everything v6 does not restate — still authoritative there |
| `second-review.md` | the findings (C1–C6, H1–H7, M*, L*) referenced throughout the code |
| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** |
| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none |
| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order |
| `devel-phases-next.md` | the roadmap |

---

## 1. Changes from v5

| # | Category | Change | Source |
|---|---|---|---|
| 1 | Content model | A group's content is **several named roots**, not one directory. Names are unique, derived from the directory's basename, and form a union virtual root | E7 / decision 11, 13 |
| 2 | Identity | **Device linking**: one person may hold several devices on a node, admitted by a key the node already pinned, bound by a one-time code the new device generates | E2 / decision 2 |
| 3 | Client | The native client is **Electron**, not pywebview. Structural decision 18 reversed | E1 / decision 1 |
| 4 | Node authority | `gek_rotate` may be a signed MNP op — the C5b rule forbids *key material arriving from outside*, not an operator-signed instruction where the node generates the key | §2.3 below |
| 5 | Server state | **Group-related server state lives on the node, always.** Written down because v5 assumed it | E9 / decision 17 |
| 6 | Portability | exFAT/NTFS and Windows are the **common** case. Case folding and Unicode normalization become correctness requirements, not compatibility notes | E8 / decision 12 |
| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b |

---

## 2. What v6 changes in v5's text

### 2.1 §5.2 Uploads — now per root

v5 confines uploads to `shared_root/uploads/` with a filename allowlist, no overwrite,
chunk ordering and a size cap. All four protections stand. Two amendments:

- There is no single `shared_root`. **The operator designates one root as the upload
  destination**; the quarantine lives inside it. If that root is unavailable the upload
  fails with a stated reason and never falls back to another; if none is designated,
  uploads are refused rather than guessed.
- **The no-overwrite rule is unchanged and still holds on exFAT/NTFS.** An earlier
  draft claimed a string comparison let `README.TXT` land on `readme.txt` there. It does
  not: the check is `Path.exists()`, and `stat()` is itself case-insensitive on those
  filesystems, so the upload already gets a free name. C5a is not reachable through the
  filesystem. Case folding is needed for index identity, collision reporting and root
  names — comparisons the code makes itself.

v5's rule that nothing derived is written beside the originals is **unchanged**, and it
decides the video-thumbnail question: a frame grab is produced on demand and cached on the
device that asked, so the node keeps no thumbnail store.

### 2.1b §5.2 Uploads — the operator may close them

New. A group where every member may add files is the default and stays the default;
some groups want a library the operator curates, and until now the only way to get one
was to designate no upload root at all, which refuses the operator too.

`member_upload` is a per-group setting, and three things about it are load-bearing:

- **It lives on the node**, in `roster.db`, not in `node.toml` and not on the hub. Not
  the hub because a hub that decides who may write to someone else's disk has authority
  over that node, which is the arrangement this design exists to avoid (change 5). Not
  `node.toml` because that file is hand-written, full of comments recording decisions,
  and `ops.py` deliberately appends to it rather than round-tripping it through a
  writer — a setting changed from a panel must not rewrite the operator's file, and must
  not need a restart.
- **Changing it is a signed operator instruction** (`OP_MEMBER_UPLOAD`, MNP
  `member_upload`), on the same path as removing a member. An unsigned one would let any
  member turn it back on, which makes the control a suggestion. The transcript's subject
  is `on` or `off` — what the operator is shown before signing has to name the outcome,
  not the operation.
- **The node enforces it**; the interface merely stops offering it. `handshake_ack`
  carries `member_upload` so a client knows whether to draw the Upload button and the
  chat paperclip, and the node broadcasts `member_upload_ack` to everyone connected when
  it changes. None of that is the control: a member on an old tab, or one speaking MNP
  directly, is refused by the node with `member_upload_off`.

**Absent means allowed**, at every layer — no row in `group_settings`, no key in the
group context, no field in the ack. A node or client that predates the setting behaves
exactly as it did, and an upgrade never silently closes a group.

The operator is always exempt. Turning it off otherwise locks them out of their own
node, with a config file and a restart as the only way back.

### 2.2 §5.5 Admission — devices, not one key per person

v5 and `invite-pairing-v1.md` bind **one** key pair to an account per node: `identities`
has `user_id` as its primary key and `pin_identity` does `INSERT OR REPLACE`. A person
with a browser and a native client needs two keys on the same node, so:

- `identities` becomes keyed by `(user_id, pk_ed25519)`, with `label`, `added_at`,
  `added_by_pk` and `revoked_at`. **`INSERT OR REPLACE` must go** — today it silently
  overwrites, which becomes a hole the moment a second key is legitimate.
- A new device is admitted when **a key the node already pinned countersigns it**, bound
  by a one-time code the new device generates and displays, hashed together with the new
  keys so the node cannot substitute them.
- The operator's one-time code remains available and is unchanged. Device linking is an
  addition to admission, not a replacement.

**Against an active hub this holds**, and for the same reason §5.5 holds: the hub has
stored no user keys since 2026-08-14, so it cannot produce the countersignature. Against a
malicious node operator it is not a new exposure — a node can only add a device to itself,
where it already reads everything it serves.

**Where it does not hold:** approval performed *in a browser* inherits T3, because the hub
serves that browser its code and can read the typed code. The first browser-to-native link
is therefore the moment of highest exposure for an account, and it happens once.

Full design: `docs/desktop-client-v1.md` §4.

### 2.3 §5.1 GEK activation — the rule, stated precisely

v5 says *"nothing arriving over MNP can activate a GEK"*. Read precisely: the rule targets
**key material arriving from outside** (C5b), not the instruction. An operator-signed
`gek_rotate` where **the node generates the key with its own CSPRNG** satisfies the
property v5 §5.5 actually establishes — the node produces every copy of the key — and is
allowed.

**The initial `gek-init` stays local.** With no GEK, `join_result` answers `no_gek` and no
MNP session completes, so there is no authenticated session to carry a signed op. Placing
it in the pre-proof window is possible and is deliberately deferred; that window is where
C4 and C5b were born.

### 2.4 §8.2 Native client — Electron

pywebview is replaced by Electron plus an optional Python sidecar for hub-less `group://`
over QUIC. The non-negotiable is unchanged and is the entire point: **UI assets ship
inside the package and load from disk.** A shell pointing at the hub's `/app/` is a browser
with a different icon.

What changes is the engine, not the claim. Native still does **not** remove trust in the
hub operator; it converts an undetectable, per-request attack into an artifact that can be
hashed and compared, and that value is realised by reproducible builds (18.7), not by the
packaging format.

Two corrections to v5's client table:

- **Key storage.** Identity keys are generated and kept locally, never bundled. C4 closes
  for a native device unconditionally — and **stays open for any account that also uses a
  browser**, which has no durable storage of its own and still needs a bundle on each
  node. An account is only as strong as its weakest client.
- **Crypto.** The client keeps WebCrypto *and* gains local Argon2id and ChaCha20 in the
  main process. v5 implied WebCrypto is lost with the browser engine; under Electron it is
  not.

### 2.4b §5.1 Authorship — authenticated, not asserted

v5 §5.1 authorizes `file_delete` by "the node operator, or the user who uploaded the file
(verified by the key recorded at upload)". Two changes:

**Authorization moves from the key to the account.** With several devices per person,
`_admin_exec_file_delete` — which verifies against `entry.uploader_pk`, the exact uploading
key — would refuse Alice's desktop the right to delete what her phone uploaded. It becomes
**any non-revoked device of `uploader_id` in the roster**, with `uploader_pk` kept as the
audit record of which device acted. This remains **roster-rooted, not token-rooted**: a hub
minting a token that claims to be Alice holds no key the node pinned for Alice, so the
signature fails — the property `per-node-identity-v1.md` established is preserved.

**Ownership becomes provable.** The uploader signs `meshbay:upload:v1` over node, group,
root, path, content hash, account and timestamp; the node stores it with the index entry.
Ownership is then verifiable by any member rather than asserted by the node, and the C5a
path — overwriting a file to become its recorded uploader — is closed a second time.

**Chat senders must be cryptographically authenticated to each other.** v5 relied on NS6,
where the node enforces `sender_id` from the authenticated session; that is the node's
word. Messages are signed with the sender's **device** key, clients pin `account → device
keys` on first sight using the device-add countersignatures as evidence, and the operator
may sign a roster attestation to close first contact.

**Against whom this holds.** Against another member: fully — no member can forge another's
signature. Against someone holding the node's disk: fully — a stolen chat store cannot be
*extended* with messages that verify. Against the node operator: **partially, and the
partial part is worth having** — once a member's client has pinned Alice's device key, an
operator who turns malicious later cannot forge Alice to that member; forgery is limited to
accounts the victim has never seen. Full protection at first contact requires an
attestation rooted outside the node, which is what the operator-signed roster and safety
numbers provide.

Design: `docs/desktop-client-v1.md` §4.8.

### 2.5 §6.1 Hub role — one addition, one rule

The hub gains exactly one endpoint from all of this: **`POST /v1/users/auth`**, device
Ed25519 authentication on the pattern of `POST /v1/nodes/auth`. Nothing else in the
desktop-client design adds a row or a column to the hub.

And the rule v5 assumed without writing:

> **Group-related server state lives on the node.** Files, indexes, members' devices,
> pending device requests, invitations, chat, per-root availability, and anything a future
> feature wants to keep about a group — all on the node. The hub holds accounts, the group
> registry and membership, signaling, notifications and the moderation surface, and
> nothing else about content.

Verified for the multi-root change: `SwarmSource` carries `content_hash`, `node_id` and
`endpoint` — **no paths, no filenames** — and private groups register nothing (H7). The
content model changes end to end without the hub moving.

### 2.6 §7 Cryptography — unchanged, one consumer added

No parameter changes. `keyderive.py` now has a third consumer: the desktop client derives
`auth_key` exactly as the browser does at registration. `test_bundle_kdf_parity.py` covers
it, and the standing warning is unchanged — **never change those parameters in one
place**; a mismatch does not look like an error, it looks like an account nobody can open.

---

## 3. Filesystem portability as a security property

New in v6, because it was treated as an edge case and is not one. Most users are expected
to share from an external exFAT or NTFS volume, on Windows.

| Property | Consequence |
|---|---|
| Case-insensitive, case-preserving | `Film.mkv` and `film.mkv` cannot coexist. The index needs a canonical identity and a **case-folding collision check** at scan time. The no-overwrite rule must be case-folded — **this one is a security fix** (§2.1) |
| Unicode normalization | `Café.mkv` written on macOS (NFD) and Windows (NFC) are different byte strings. Normalize to **NFC for identity**, preserve the original bytes for display and opening |
| Windows reserved names, `MAX_PATH` | A group indexed on Linux can hold names Windows cannot create. The client sanitizes on save **and says so**; use `\\?\` paths |
| FAT/exFAT timestamps (2 s, local time) | mtime alone is not a change detector. Size + mtime with tolerance, rehash when in doubt |
| Watcher reliability | `ReadDirectoryChangesW` drops events under load; inotify on a FUSE mount is unreliable. **Periodic reconciliation is mandatory on both platforms** |
| No symlinks, no POSIX permissions | Simplifications: nothing to defend against, and the node runs as the user anyway |

A volume that disappears must **freeze** the affected root's subtree, never empty it.
Emptying propagates deletions for a whole library as though the owner had erased it.

---

## 4. Security claims — deltas only

v5 §2's table stands. Three rows change, and they are the honest version:

| Claim | Passive hub | Active hub | Malicious node operator |
|---|---|---|---|
| Client code integrity | ✅ ships in the package (native) | ⚠️ **detectable, not prevented** — realised by 18.7, not by packaging | ✅ |
| Keypair bundles (**C4**) | closed for native devices | closed for native devices | ⚠️ **open for any account that also uses a browser** |
| Devices | ✅ | ✅ the hub cannot countersign a device — it holds no user keys | ⚠️ a node adds devices only to itself, where it already reads everything |

**The claim v6 supports:** *the hub cannot read your content, and against a native client
its only remaining lever is the artifact it ships — which can be hashed and compared.*

**The claim it must not make:** that a native client makes the hub untrusted, or that C4
is closed for an account that still signs in from a browser.

---

## 5. Still open

v5 §9's list stands, with these movements:

| # | Item | Status |
|---|---|---|
| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships |
| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 |
| — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person |
| — | Delegation | Designed, deferred, unchanged |
| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client |

**Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.**
The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part:

- **A sender key is per device, never per person.** A shared per-person chain advanced by
  two devices produces key and nonce reuse — which is exactly why `first-review.md` C1
  rejected a shared Double Ratchet for groups. The same mistake, one level down.
- `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does
  `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id`
  **overwrites the first and drops its chain**. `sender_id` must become a device
  identifier.
- **Revoking a device must rotate**, like revoking a member.
- **A newly linked device cannot read history** until every sender redistributes, unless
  the linking device hands over its own state sealed to the new device's key.
- **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping
  from device to account comes from the node's roster. Encrypted chat does not make
  senders cryptographically authenticated to each other, and the docs must not imply it.

Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is
built against an identity model that is about to change underneath it.

---

## 6. Where the detail lives

This document states what changed and what holds. It does not restate the desktop client's
design, which is long and belongs in one place:
**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema,
account creation, node management over signed MNP ops, several roots per group, filesystem
portability, packaging and first run, the web tier, and the execution order for all of it.