summaryrefslogtreecommitdiffstats
path: root/docs/devel-phases-next.md
blob: a155652657d8c2391ae705d1df3746682884f5dd (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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
# MeshBay — Next Implementation Phases

> Base: Phases 1–12 complete (except 10.9 → Phase 18). Web SPA + admin panel + self-service UI + MSE video streaming live on meshbay.org. Node daemon is production-ready (WebRTC, WS, chat, HTTP, index push, swarm all wired).
> Architecture reference: **meshbay-draft-v6.md** (2026-08-17; v5 remains
> authoritative for everything v6 does not restate, v4 superseded 2026-08-13).
> Desktop client, roots, device linking: **desktop-client-v1.md**
> First security review: first-review.md (2026-08-10)
> **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.**
>
> ⛔ **Phase 11.5 is BLOCKING.** No feature phase starts until C1–C6 and H1–H7 are closed.
> The current build must not host real private data: the node's HTTP API serves private
> group content unauthenticated (C1), any user can hijack a node's signaling identity (C2),
> and an active hub can obtain any group key through the key directory it controls (H3).
>
> **Phases renumbered 2026-08-13** (old → new): 12→14, 13→15, 14→16, 15→17, 16→18, 17→19.
> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13,
> hub minimization deferred by operator decision), 13 (native desktop client).

---

## Phase 7 — Node v2 : production, streaming, chat ✅ DONE

Commit: fc56585 — 26 files, +2155/−159 lines, 109 tests.

| # | Component | Status |
|---|---|---|
| 7.0 | JWT group claims + node authz check | ✅ |
| 7.1 | QUIC 0-RTT session resumption | ✅ |
| 7.2 | Signaling `client_incoming`/`punch_ready` + jti denylist push | ✅ |
| 7.3 | Multi-group daemon (1-port multiplexing) | ✅ |
| 7.4 | HLS streaming via QUIC | ✅ |
| 7.5 | Chat: Sender Keys protocol + storage + MNP wire | ✅ |
| 7.6 | Chat: local web UI + WS push to members | ✅ |

---

## Phase 8 — Hub v2: admin, federation, security ✅ DONE

Commit: 46918ec — 20 files, +508/−90 lines, 117 tests.
Deployed to meshbay.org. Existing emails encrypted. DB schema migrated.

| # | Component | Status |
|---|---|---|
| 8.1 | Admin roles — config-based `require_admin` | ✅ S1 resolved |
| 8.2 | Email encrypted at rest — AES-256-GCM, HKDF | ✅ S2 resolved |
| 8.3 | Refresh token rotation — family-based reuse detection | ✅ S5 resolved |
| 8.4 | Federation DB persistence (HubPeer model) | ✅ |
| 8.5 | Federation token verification async (DB-backed) | ✅ |
| 8.6 | CSAM hash check in swarm registration | ✅ |
| 8.7 | Rate limiting on auth endpoints (5/10/20 per min) | ✅ |
| 8.8 | Healthcheck endpoint (GET /v1/health) | ✅ |
| 8.9 | IP log cleanup background task (365-day retention) | ✅ |
| 8.10 | Argon2id bumped to 256 MB (pw_version=2, rehash on login) | ✅ |

---

## Phase 9 — Web client: WebRTC transport + core SPA ✅ DONE

Commit: ab4d389 — 27 files, +3053/−330 lines, 132 tests.
Deployed to meshbay.org + Orange node. Tested browser → node P2P through two ISP NATs.

**Objective:** a web browser can connect P2P to a node behind residential NAT,
browse files, download, stream video, and chat — with zero data through the hub.

**Architecture decisions (settled 2026-08-10):**

### Transport: WebRTC DataChannel for browsers

Native clients (desktop, Android) use QUIC with `punch_nat()` — already validated
in demo-v2 on SFR residential (Port-Restricted Cone NAT).

Browsers cannot use QUIC for NAT traversal because WebTransport does not allow
the browser to choose its UDP source port. Port-Restricted Cone NAT requires the
client to connect from the exact port the node probed — impossible for browsers.

**Solution:** WebRTC DataChannel with ICE/STUN. The browser's built-in WebRTC
stack handles NAT traversal automatically. The node uses `aiortc` (same author as
`aioquic`, already referenced in draft-v3 as [future]).

ICE is strictly superior to our custom `punch_nat()` for this use case:
- Both sides send STUN binding requests simultaneously → mutual hole-punching
- No need for the client to pre-announce its port
- Handles both sides behind NAT
- Battle-tested by billions of users (Google Meet, Discord, etc.)

The MNP protocol (handshake, file_request, file_chunk, chat_message, etc.) runs
identically over WebRTC DataChannel as over QUIC streams. Same E2E encryption.

**Node dual transport:**
- QUIC (port 19000) — native clients, already in place
- WebRTC DataChannel — browsers, using `aiortc`

### Signaling: hub WebSocket relay

The hub relays WebRTC signaling (SDP offer/answer, ICE candidates) between
browser and node. This is the same role described in draft-v3 section 4.1.3:
"NAT traversal coordination [...] stateless [...] <1 KB per message."

```
Browser → Hub (HTTPS) : POST /v1/nodes/{id}/webrtc/offer  {sdp, ice_candidates}
Hub → Node  (WS)      : {type: "webrtc_offer", sdp, ice_candidates, peer_id}
Node → Hub  (WS)      : {type: "webrtc_answer", sdp, ice_candidates, peer_id}
Hub → Browser (SSE)   : {sdp, ice_candidates}
```

After signaling, the DataChannel is P2P. Hub is no longer involved.

### UI: Preact SPA

- **Framework:** Preact (~3 KB gzipped) + preact-router
- **Build:** esbuild (single binary, no node_modules bloat) for minification
- **Theming:** CSS `prefers-color-scheme` + localStorage toggle (dark/light)
- **i18n:** JSON translation files loaded client-side, English default
- **Responsive:** sidebar collapses to hamburger on mobile viewports
- **Crypto:** existing `crypto.js` (SubtleCrypto AES-GCM) for E2E decryption

### Hub role (reminder — fundamental constraint)

The hub is a registrar and signaling facilitator. It stores ONLY:
- User accounts (login, encrypted email, public keys, keypair bundle)
- Group metadata (name, admin, members, GEK bundles — no file indexes)
- Node registrations (endpoint hints, public keys)

All data (files, streams, chat messages, directory indexes) lives on mesh nodes.
Clients (web or native) transfer data E2E with nodes. The hub never touches
content. This is non-negotiable.

### Chat/forum storage

Chat messages are stored on the node(s) hosting the group, not on the hub.
The browser retrieves chat history from the node via DataChannel, same as files.
If no node in the group is online, the group (including chat) is unavailable.
This is inherent to the P2P model and acceptable.

### File search

Content is not indexed on the hub. Search works client-side:
- Node provides a Mesh Group Index (file metadata: names, paths, sizes, hashes)
- For private groups, the index is GEK-encrypted — hub stores it opaque, client decrypts
- Browser caches decrypted indexes in IndexedDB (~50–100 MB quota, extensible)
- Search runs locally on cached indexes — instant, no network call, no hub involvement

### Milestones

| # | Component | Files | Priority |
|---|---|---|---|
| 9.1 | **Spike: WebRTC DataChannel on node** | `aiortc` integration, 4 tests (handshake, file transfer, auth, guard) | ✅ |
| 9.2 | WebRTC signaling endpoints on hub | `hub/api/signaling.py` — relay SDP/ICE, 2 tests | ✅ |
| 9.3 | WebRTC→MNP transport adapter on node | `node/transport/webrtc_server.py` + hub_client WebRTC handler | ✅ |
| 9.4 | `transport.js` — browser WebRTC client | `static/transport.js` — connect, handshake, fetch, msgpack | ✅ |
| 9.5 | **Spike: E2E browser→NAT→node file transfer** | Mobile 4G → SFR NAT → node, IPv4 STUN + IPv6 validated | ✅ |
| 9.6 | Preact SPA shell (login, routing, theme) | `static/app.js`, `static/style.css`, `static/vendor/htm-preact.js` | ✅ |
| 9.7 | Group list + file explorer UI | `app.js` GroupPage, `groups.py` nodes endpoint, `revocation.py` group tracking | ✅ |
| 9.8 | File download via DataChannel | AES-GCM chunks, GEK delivery, progress bar, browser download | ✅ |
| 9.9 | Video streaming via DataChannel | Chunk download → Blob URL, video overlay with native controls | ✅ |
| 9.10 | Chat/forum UI via DataChannel | ChatPanel component, chat history MNP, peer broadcast, tabs UI | ✅ |
| 9.11 | i18n framework + English strings | `static/i18n.js` — t() lookup, ESM, localStorage lang, all strings extracted | ✅ |
| 9.12 | Settings UI (profile, theme, language) | SettingsPage component, system theme support, sidebar link | ✅ |
| 9.13 | Tests: unit + integration | WebRTC transport, MNP over DataChannel | ✅ |
| 9.14 | Performance: pipelined download | sliding window (8 concurrent chunks) | ✅ |
| 9.15 | Performance: binary wire format | raw bytes via msgpack, no base64 (+33%) | ✅ |
| 9.16 | Performance: avoid redundant I/O | file_hash from index, not re-read per chunk | ✅ |
| 9.17 | Large file download to disk | File System Access API (`showSaveFilePicker`) | ✅ |

**Critical path validated (2026-08-10):** 9.1 → 9.5 all pass. WebRTC DataChannel
works browser → node through two different ISP residential NATs:

**SFR residential NAT** (mobile 4G → node behind SFR Port-Restricted Cone + CGNAT):

| Test | ICE path | Result |
|---|---|---|
| WiFi LAN (same network) | IPv6 direct | OK, ~100ms |
| Mobile 4G SFR + IPv6 | IPv6 inter-network | OK, ~600ms |
| Mobile 4G SFR + IPv4 only | STUN hole-punch IPv4 | OK, ~650ms |

**Orange Livebox NAT** (laptop browser → node behind Orange residential NAT, cross-site):

| Test | ICE path | Result |
|---|---|---|
| Chrome laptop → Orange node | IPv6 inter-network | OK, ~7000ms |
| Firefox laptop → Orange node | IPv6 inter-network | OK, ~6700ms |
| Firefox laptop → Orange node (IPv6 disabled) | STUN hole-punch IPv4 | OK, ~6900ms |

Two ISPs validated, both Chrome and Firefox. No TURN relay needed.
ICE/STUN handles all tested NAT types automatically.

**Performance optimizations (2026-08-11):**
- Initial transfer speed: ~2 MB/s (sequential, base64, redundant I/O)
- After file_hash fix (9.16): ~3 MB/s (eliminated 78 GB redundant reads on 279 MB file)
- After pipelining (9.14): ~5 MB/s (8-chunk sliding window, concurrent requests)
- After binary wire format (9.15): eliminated 33% base64 inflation + removed
  redundant per-chunk fields (sig, hashes, pk_node) — AES-GCM tag already
  authenticates ciphertext, DTLS authenticates transport
- Large file support (9.17): `showSaveFilePicker` (Chrome/Edge) streams decrypted
  chunks directly to disk — flat ~8 MB RAM regardless of file size. Firefox/Safari
  fall back to Blob-in-RAM approach.

**Indexer debounce (2026-08-11):**
- File copy triggers multiple watchdog events at different file sizes → duplicate
  index entries with different blake3 hashes. Fixed with 2-second debounce +
  path-based dedup (remove old entry before adding new).

**Known remaining items for future phases:**
- ~~True video streaming (MSE or Service Worker)~~ → Phase 10c (2026-08-11)
- Multiple shared directories per node (UI + config)
- Multi-node per user support

**Dependencies added:**
- `aiortc>=1.9` in `meshbay-node/pyproject.toml` ✅
- `esbuild` as a dev tool (single binary, not npm) — needed for 9.6+
- `preact` + `preact-router` (ESM imports, no npm needed — CDN or vendored)

---

## Phase 10 — meshbay.org site + admin/moderation UI

Commit: 8fa298e (10.1–10.4), 022da76 (10.5–10.10) — 155 tests.

**Objective:** meshbay.org becomes both a production hub and the project's public
website, with admin/moderation interfaces and user-facing features.

### Site architecture

Two layers, cleanly separated:
- **Generic hub** (API + web app) — reusable by any hub operator
- **Site overlay** — meshbay.org-specific pages (landing, /downloads, /about)

The site overlay is served by Caddy (static files) with priority over the hub.
The hub serves the SPA for authenticated users at `/app/`.

```
site/                       # meshbay.org-specific (not in generic hub package)
├── index.html              # Landing page — project promotion
├── downloads.html          # Package repos (placeholder, Phase 13)
├── about.html              # Project info, GitHub link, contact
└── assets/
    └── site.css            # Landing page styles (dark/light aware)
```

### User roles

| Role | Capabilities |
|---|---|
| `user` | Standard user — browse, download, chat, manage own profile |
| `moderator` | Review reports, suspend content/groups/users |
| `admin` | All moderator rights + hub management (same as moderator for now, distinction reserved for future federation/mirror) |

Role stored as `role` column on User model (`user` | `moderator` | `admin`).
`require_moderator` dependency (checks role ≥ moderator OR config allowlist).
`require_admin` checks role = admin OR config allowlist (backward compat).
Config-listed admin usernames are synced to `role = "admin"` in DB at startup.

### Milestones

| # | Component | Status |
|---|---|---|
| 10.1 | Landing page + /downloads + /about | ✅ |
| 10.2 | Moderator role + `require_moderator` dependency + admin API | ✅ |
| 10.3 | Moderation UI (user/group suspend, blocklist management) | ✅ |
| 10.4 | Admin UI (stats, user list, group list, audit logs viewer, blocklist) | ✅ |
| 10.5 | Notification system (invitations, role changes, account status) | ✅ |
| 10.6 | User settings (profile, role display, per-group notification mute) | ✅ |
| 10.7 | Public group search (name keyword filtering) | ✅ |
| 10.8 | Front page (notification feed with unread badge) | ✅ |
| 10.9 | Package repositories (APT/DNF) | Deferred to Phase 13 |
| 10.10 | Auto-update check endpoint (`GET /v1/hub/version`) | ✅ |

### API endpoints (10.2, 10.5, 10.7, 10.10)

| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/v1/users/me` | Access token | Current user info (id, username, role, status) |
| GET | `/v1/admin/stats` | Moderator+ | Hub stats (user/group/node counts, online nodes) |
| GET | `/v1/admin/users` | Moderator+ | List users (paginated, searchable) |
| GET | `/v1/admin/users/{id}` | Moderator+ | User detail (email decrypted, group count) |
| PATCH | `/v1/admin/users/{id}` | Moderator+ | Update role or status (triggers notification) |
| GET | `/v1/admin/groups` | Moderator+ | List groups (with member count) |
| PATCH | `/v1/admin/groups/{id}` | Moderator+ | Update group status |
| GET | `/v1/admin/logs` | Moderator+ | IP audit logs (filterable by event, user) |
| GET | `/v1/notifications` | Access token | List notifications (unread_only, paginated) |
| POST | `/v1/notifications/{id}/read` | Access token | Mark single notification read |
| POST | `/v1/notifications/read-all` | Access token | Mark all notifications read |
| GET | `/v1/groups?q=` | None | Search public groups by name |
| GET | `/v1/hub/version` | None | Version check (hub, MNP, MHP versions) |

### Admin UI (10.3–10.4)

Admin page at `#/admin` in SPA, accessible to moderators and admins.
Five tabs: Stats, Users, Groups, Logs, Blocklist.

- **Stats:** card grid (users, groups, nodes, online nodes)
- **Users:** searchable table, inline role dropdown, suspend/unsuspend buttons, detail overlay
- **Groups:** table with member count, suspend/unsuspend
- **Logs:** filterable IP audit log table, paginated (50/page, load more)
- **Blocklist:** existing `/v1/admin/blocklist` endpoints, add/remove hashes

### SPA route change

SPA now also served at `/app/` and `/app/{path}` (in addition to `/`).
With Caddy site overlay, Caddy serves `site/index.html` at `/`,
and requests to `/app/` fall through to the hub.

### Caddy integration

**The real configuration lives at `packaging/caddy/meshbay.org.Caddyfile`** (added
2026-08-17). Use it, not the snippet this section used to carry.

The snippet that was here served `site/` from the root with `try_files` and proxied
`/v1/*`, `/app*`, `/style.css` and `/*.js` to the hub. It predates asset versioning and
**would have broken the SPA**: the module graph is served under `/a/<hash>/`, which
`handle /*.js` does not match, and neither does `/locales/*.js`. Worse, `/sw.js` would
have 404ed — the service worker has to stay at the root or its scope stops covering the
pages it intercepts downloads for, which breaks streamed downloads on Firefox and Safari
without any visible error.

The rule is inverted: an **allowlist** of site paths served statically, everything else
proxied to the hub. The hub mounts its whole static directory at `/` (`app.py`), so it
owns the root namespace by default and the site takes only what it names.

**Consequence to be aware of:** with the site overlay in front, `/` is the landing page,
so `webapp.py`'s `GET /` (which returns the SPA shell) is unreachable on meshbay.org. That
route stays — a **generic** hub with no site overlay should serve the application at its
root. The overlay is meshbay.org-specific by design.

`site/` is **not** pushed by the hub deploy procedure; it syncs separately to
`/srv/meshbay/site`.

### Hub mirror (design only — implementation deferred)

A mirror hub is a complete replica of the primary hub (same user DB, same groups,
same GEK bundles, same storage). Purpose: load distribution via DNS round-robin.

**Design constraints:**
- Shared Ed25519 private key (transferred once at setup, securely)
- PostgreSQL logical replication for active-active read/write on both mirrors
- Both mirrors can issue JWTs (same signing key)
- DNS round-robin (2+ A records on meshbay.org)
- If one mirror goes down, the other continues serving

**Not implemented now.** The design must not prevent future implementation:
- Hub config and private key paths must be externalizable
- No hub-specific state that can't be replicated
- JWT verification must not depend on hub-local state

---

## Phase 10b — Self-service UI + client-side features

Pending commit — 166 tests.

**Objective:** make the web SPA fully self-service — users can create groups,
manage members, join open groups, upload files, and search across all cached
group file indexes. No admin intervention needed for basic operations.

### Self-service features

| # | Component | Status |
|---|---|---|
| 10b.1 | Group creation UI (CreateGroupPage) | ✅ |
| 10b.2 | Member management + invite (MembersPanel) | ✅ |
| 10b.3 | Group join flow (open groups self-join) | ✅ |
| 10b.4 | File upload (client → node via MNP FILE_UPLOAD) | ✅ |
| 10b.5 | IndexedDB caching (group file indexes cached locally) | ✅ |
| 10b.6 | Cross-group file search (SearchPage — client-side, no hub) | ✅ |

### New API endpoints (10b.1–10b.3)

| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/v1/groups` | Access token | Create a new group (name, visibility, join_policy) |
| GET | `/v1/groups/{id}/members` | Access token | List group members (requires membership) |
| POST | `/v1/groups/{id}/join` | Access token | Self-join open group (checks join_policy) |
| POST | `/v1/groups/{id}/members/{username}/gek` | Access token | Store GEK bundle for invitee |
| GET | `/v1/groups/{id}/gek` | Access token | Get own GEK bundle (for wrapping) |

### New MNP message types (10b.4)

| Type | Direction | Description |
|---|---|---|
| `file_upload` | client → node | Push encrypted file chunk (filename, chunk_index, total_chunks, data) |
| `file_upload_ack` | node → client | Acknowledge chunk receipt |

Node stores uploads in `shared_root/.uploads/` as `.part` files during transfer,
renames to final location on last chunk. Filename sanitized (no path traversal).

### Browser crypto additions (10b.2)

AES-256-GCM ECIES variant for GEK wrapping in browsers. WebCrypto does not
support ChaCha20-Poly1305, so a parallel ECIES scheme uses AES-256-GCM with
a distinct HKDF info string (`meshbay:gek_wrap:v1:aes` vs `meshbay:gek_wrap:v1`).
Both Python and browser implement the AES variant for interop.

Functions added to `crypto.js`: `generateGEK()`, `wrapGEK()`, `unwrapGEK()`,
`encryptChunk()`, `b64encode()`.

Functions added to `crypto.py`: `wrap_gek_aes()`, `unwrap_gek_aes()`.

### IndexedDB caching (10b.5)

When a group's file index is fetched from a node, it is cached in IndexedDB
(`meshbay` database, `group_indexes` store). On subsequent visits, cached
entries are shown immediately while the live connection is established. This
gives instant file list display even before WebRTC connects.

Cache key: `groupId`. Stored: `{ groupId, groupName, entries[], cachedAt }`.
Best-effort — failures are silently ignored.

### Cross-group file search (10b.6)

SearchPage component at `#/search`. Searches file names and paths across ALL
cached group indexes in IndexedDB. Pure client-side — no hub involvement.
Results link back to the group page. Accessible from sidebar.

### Tests added

- 8 tests: group self-service (create, join open, join invite rejected, join already member, members list, non-member denied, search, join triggers notification)
- 3 tests: AES GEK wrap/unwrap (round-trip, wrong key rejected, differs from ChaCha20 wrap)

---

## Phase 10c — MSE video streaming (real-time playback)

Pending commit — 167 tests.

**Objective:** replace the download-then-play video player with real-time
MSE (MediaSource Extensions) streaming. Playback starts within seconds
instead of waiting for the full file download.

### Architecture

```
Browser                          Node
  │                                │
  ├── stream_req {file_id} ──────►│
  │                                ├── ffprobe → codec info
  │◄──── stream_init {codec,dur} ──┤
  │                                ├── ffmpeg -c copy → fMP4 pipe
  │◄──── stream_data {seg 0, ct} ──┤  (256 KB encrypted segments)
  │◄──── stream_data {seg 1, ct} ──┤
  │  ...                           │
  │◄──── stream_end ───────────────┤
  │                                │
  MediaSource → SourceBuffer       │
  ├── appendBuffer(decrypted)      │
  ├── video.play() after ~2-3s     │
```

**Key design decisions:**

1. **Node-side remux via ffmpeg** — `ffmpeg -c copy -movflags frag_keyframe+empty_moov+default_base_moof -f mp4 pipe:1` remuxes any video format (MP4, MKV, AVI, WebM, MOV) into fragmented MP4 (fMP4) that MSE can consume. No transcoding — just remuxing. Near-zero CPU overhead.

2. **Codec detection via ffprobe** — the node probes the video to determine the exact codec string for MSE SourceBuffer creation (e.g., `avc1.640028,mp4a.40.2` for H.264 High@4.0 + AAC-LC). This ensures the browser creates the correct decoder.

3. **Same encryption model** — each 256 KB fMP4 segment is encrypted with AES-256-GCM using the same key derivation as file downloads (GEK + file_hash + segment_index → HKDF → chunk_key). E2E encryption is maintained.

4. **Progressive SourceBuffer append** — the browser creates a MediaSource, opens a SourceBuffer with the probed codec, and appends decrypted segments as they arrive. SourceBuffer handles partial MP4 boxes internally. Playback starts after ~2-3 segments (~512 KB buffered).

### Supported codecs

| Codec | MSE string | Browser support |
|---|---|---|
| H.264 (AVC) | `avc1.PPCCLL` | Chrome, Firefox, Safari, Edge |
| H.265 (HEVC) | `hev1.1.6.L93.B0` | Safari, Chrome (partial) |
| VP9 | `vp09.00.10.08` | Chrome, Firefox |
| AV1 | `av01.0.01M.08` | Chrome, Firefox |
| AAC | `mp4a.40.2` | All |
| MP3 | `mp4a.6b` | All |
| Opus | `opus` | Chrome, Firefox |
| AC-3 | `ac-3` | Safari, Chrome |

### New MNP message types

| Type | Direction | Description |
|---|---|---|
| `stream_req` | client → node | Request MSE video stream for file_id |
| `stream_init` | node → client | Codec string + duration (probed via ffprobe) |
| `stream_data` | node → client | Encrypted fMP4 segment (256 KB, AES-GCM) |
| `stream_end` | node → client | End of stream signal |

### Milestones

| # | Component | Status |
|---|---|---|
| 10c.1 | MNP protocol: STREAM_REQUEST/INIT/DATA/END message types | ✅ |
| 10c.2 | Node: ffprobe codec detection + MSE codec string derivation | ✅ |
| 10c.3 | Node: ffmpeg fMP4 remux + encrypted segment streaming | ✅ |
| 10c.4 | Transport: event-based stream message dispatch | ✅ |
| 10c.5 | Browser: MSE VideoPlayer (MediaSource + SourceBuffer) | ✅ |
| 10c.6 | Tests: stream_request error handling | ✅ |

### File changes

**Modified:**
- `packages/meshbay-common/src/meshbay_common/protocol.py` — STREAM_REQUEST/INIT/DATA/END
- `packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py` — `_probe_video()`, `_stream_video()` handler
- `packages/meshbay-hub/src/meshbay_hub/static/transport.js` — `requestStream()`, stream event handlers
- `packages/meshbay-hub/src/meshbay_hub/static/app.js` — MSE-based VideoPlayer component
- `packages/meshbay-hub/src/meshbay_hub/static/style.css` — streaming progress bar
- `packages/meshbay-hub/src/meshbay_hub/static/i18n.js` — buffering/MSE error strings
- `packages/meshbay-node/tests/test_webrtc_transport.py` — stream_request error test

### Known limitations (future work)

- No seeking beyond buffered range (user must wait for data to arrive)
- No adaptive bitrate (single quality stream)
- Requires ffmpeg/ffprobe on the node (already a dependency for the live streaming path)

---

## Phase 11 — Node daemon: production-ready ✅ DONE

Pending commit — 171 tests.

**Objective:** the node daemon (`meshbay-node`) runs as a complete, self-contained
service. Previously the daemon only started QUIC/TCP servers and the local web UI;
everything browser-facing (WebRTC, hub WS, chat store, HTTP API) was only wired
in QE demo scripts. This phase moved all that logic into the daemon.

### What changed

**`daemon.py` — complete rewrite.** The daemon now starts all transports and
services in a single process:

1. Keystore + hub login (unchanged)
2. Per-group directory indexers (unchanged)
3. **ChatStore** per group (new) — SQLite DB in `~/.local/share/meshbay/{group_id}/chat.db`
4. **WebRTC transport** (new) — browser clients via DataChannel, wired as
   `on_webrtc_offer` callback on the hub WS
5. QUIC + TCP servers (unchanged)
6. **Hub WebSocket** (new) — `maintain_ws()` as asyncio task, receives signaling,
   revocation tokens, WebRTC offers. Auto-reconnect on disconnect.
7. **HTTP file API** (new) — one `create_http_app()` per group on configured port
8. Local web UI (unchanged)
9. **Graceful shutdown** (enhanced) — cancels WS task, closes WebRTC peers, closes
   chat stores, stops HTTP/QUIC/TCP servers, stops indexers

**`hub_client.py`** — added `_ws` tracking, `send_ws()` for chat notifications,
and `register_swarm()` for file hash registration with the hub.

**`config.py`** — added `data_dir` field (default `~/.local/share/meshbay/`)
for chat DBs and other persistent state.

**`meshbay-node.service`** — updated systemd unit with `StateDirectory=meshbay`,
`ProtectSystem=strict`, `ReadWritePaths` for config and data directories.

**Index push (11.5):** when watchdog detects file changes, the debounced
`on_change` callback fires `_on_index_change` on the daemon, which pushes a
full `INDEX_SYNC` to all WebRTC peers in that group. Only peers whose
`_group_id` matches receive the push.

**Swarm registration (11.9):** on startup and on each index change, the daemon
registers all file hashes with the hub's `/v1/swarm/register` endpoint. This
allows other nodes/clients to discover which nodes host which content.

### Milestones

| # | Component | Status |
|---|---|---|
| 11.1 | Daemon: hub WS integration | ✅ |
| 11.2 | Daemon: WebRTC transport | ✅ |
| 11.3 | Daemon: chat store | ✅ |
| 11.4 | Daemon: HTTP file API | ✅ |
| 11.5 | Daemon: index push on change | ✅ |
| 11.6 | Daemon: node_user_id + hub_ws context | ✅ |
| 11.7 | Daemon: graceful shutdown | ✅ |
| 11.8 | Systemd unit file | ✅ |
| 11.9 | Swarm registration | ✅ |
| 11.10 | Integration test | ✅ (4 tests: lifecycle, no-groups, index push, group filtering) |

### File changes

**Modified:**
- `packages/meshbay-node/src/meshbay_node/daemon.py` — complete rewrite
- `packages/meshbay-node/src/meshbay_node/hub_client.py` — `_ws` tracking, `send_ws()`
- `packages/meshbay-node/src/meshbay_node/config.py` — `data_dir` field
- `packaging/systemd/meshbay-node.service` — hardening, StateDirectory

**Added:**
- `packages/meshbay-node/tests/test_daemon.py` — 2 integration tests

---

## Phase 11.5 — Security remediation ⛔ BLOCKING

> Source: `second-review.md` (2026-08-13). Finding IDs in brackets.
> **No other phase starts until section J acceptance criteria pass.**

**Objective:** close the gap between what the documents describe and what the code
enforces. The Phase 12 sovereignty work (GEK-HMAC proof, DTLS channel binding, Ed25519
admin challenge) is sound but was implemented on one of four paths into the node. This
phase reduces the node to two paths and brings both to the same standard.

### Transport decision (settled 2026-08-13)

| Listener | Fate | Reason |
|---|---|---|
| WebRTC DataChannel (aiortc) | **Primary** — browser + native | ICE/STUN is the only NAT traversal validated here (2 ISPs, 2 browsers, IPv4 STUN + IPv6, 4G CGNAT) |
| QUIC 19000 | **Kept, brought to parity** | LAN, port-forwarded, and hub-less `group://` direct access |
| TCP+TLS 18001 | **Removed** | Superseded; no GEK proof; nothing uses it |
| HTTP 19001 | **Removed** | Source of C1; duplicates MNP without any of its controls |

> `punch_nat()` is a single UDP probe (`quic_server.py:446`) with no STUN client, no
> candidate gathering and no dual-stack fallback — `aioice` is pulled in by `aiortc` only.
> It is a direct-connection helper, **not** a traversal stack. ICE remains the primary path.

### A — Reduce the node's exposed surface

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.1 | Delete `transport/http_server.py` + daemon wiring (`daemon.py:341-366`) | **C1** | No listener on `0.0.0.0` other than QUIC; no endpoint serves file bytes or an index without a completed handshake |
| 11.5.2 | Delete `transport/server.py` + `transport/client.py` (TCP+TLS) | C6 scope | `ChunkServer` gone from `daemon.py`; port 18001 unbound |
| 11.5.3 | Node admin UI stays loopback + gains a session token in the URL | H2 | UI unreachable without the token printed at daemon startup |

### B — One handshake, two transports

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.4 | Extract `meshbay_common/handshake.py`: JWT verify → `scope == "user"` → denylist → **mandatory** `group_id` in claims → group hosted → GEK challenge → proof verify → ack | **C6**, M1, M9 | Single implementation; `webrtc_server.py` and `quic_server.py` contain no JWT logic of their own |
| 11.5.5 | Both transports call it; test parametrized over `[webrtc, quic]` | C6 | A test that adds a step to the handshake fails for any transport that skips it |
| 11.5.6 | **Spike DONE 2026-08-13 — see findings below.** Channel binding for QUIC | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections |

#### 11.5.6 spike results (aioquic 1.3.0)

**No RFC 5705 exporter.** `aioquic.tls.Context` has no `export_keying_material`, so the
preferred anchor is unavailable.

**Certificate access is asymmetric and partly private:**

| Side | Path to the server certificate | API status |
|---|---|---|
| Server | `tls.certificate` | public attribute |
| Client | `tls._peer_certificate` | **private** — set by `_set_peer_certificate()` |

`QuicConnection` exposes no `tls`/`cert` attribute either, so the client's route is
`protocol._quic.tls._peer_certificate` — two levels of private API.

**The risk this creates.** Binding a security check to a private attribute means an
aioquic upgrade can remove it silently. A channel binding that silently becomes
unavailable is the worst failure mode: 11.5.21 already established that the handshake
must *refuse* rather than degrade, so a rename would turn every QUIC connection into a
hard failure — noisy, but only if the refusal path is right. If it were ever made
tolerant, it would turn into a silent loss of MitM detection.

**Options for the implementer, in order of preference:**

1. **Certificate hash via the private attribute, guarded.** Pin `aioquic` in
   `pyproject.toml`, and add a test that asserts `_peer_certificate` is reachable and
   non-None on a live connection — so an upgrade fails CI rather than production. Keep
   `make_proof()` refusing an empty binding.
2. **Bind to `pk_node` instead of the channel.** For QUIC the MitM story differs from
   WebRTC: signaling is not hub-relayed, and the client already learns `pk_node` from the
   hub. The C3 mutual proof (node signs the transcript with `sk_node`) may be sufficient
   connection authentication on its own — but note the QUIC client currently does **not**
   verify the TLS certificate (`verify_mode` disabled, identity checked at the MNP layer),
   so this option must be paired with pinning, or the TLS layer authenticates nobody.
3. **Upstream an exporter.** Correct long-term answer, wrong timescale for 11.5.

Recommendation: option 1 with the guard test, and open option 3 upstream.

### C — Mutual authentication

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.7 | Node proves GEK possession over a client nonce **and** signs the transcript with `sk_node`: `Ed25519(sk_node, "meshbay:node_proof:v1" ‖ nonce_c ‖ binding)` | **C3** | Client rejects a peer that cannot produce both |
| 11.5.8 | Client pins `pk_node` (TOFU on first connect, persisted); key change raises a blocking warning | C3 | Swapping the node's key surfaces to the user instead of silently succeeding |
| 11.5.9 | Node WS registration: require `scope == "node"`, verify `Node.user_id == payload["sub"]`, derive `group_ids` **from the DB**, refuse to overwrite a live registration | **C2** | A user-scoped token, or a mismatched `node_id`, is rejected at `/v1/nodes/ws` |
| 11.5.10 | `POST /v1/nodes/announce` requires proof of possession of `sk_node`; one active record per user | M8 | Announcing someone else's `pk_node` fails |

### D — MNP authorization

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.11 | `gek_bundle_store` requires an Ed25519 admin challenge; **delete `_try_activate_gek`** — GEK activation is local-UI/CLI only | **C5b** | A member cannot change the group's active GEK |
| 11.5.12 | Upload: per-user quarantine `.uploads/{user_id}/`, refuse to overwrite an existing index entry, size cap + per-user quota, filename allowlist (`[A-Za-z0-9._-]`) | **C5a**, H2 | A member cannot replace another member's file, and cannot inject markup via a filename |
| 11.5.13 | Admin challenge becomes a structured transcript: `"meshbay:file_delete:v1" ‖ node_pk ‖ group_id ‖ file_id ‖ nonce ‖ ts`; client displays what it signs | **H5** | No path exists where a peer obtains a signature over bytes it fully chose |
| 11.5.14 | `gek_bundle_fetch` / `keypair_bundle_fetch` move **after** proof verification; interim rate-limit + audit on the pre-proof window | C4 (partial) | Pre-proof window serves nothing; full fix lands in 13.3 |

### E — Isolation

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.15 | `chat_store` and `_peers` resolve from `_group_ctx()`, one peer registry per group (`daemon.py:249`, `webrtc_server.py:602,617,650`) | **H1** | Two-group / two-user test proves neither history nor broadcast crosses groups |

### F — Node admin UI

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.16 | `html.escape()` on every interpolated value (`ui/app.py:363`), `textContent` in the audit page (`:632`), CSP header | **H2** | A file named `<img src=x onerror=...>` renders as text |

### G — Revocation

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.17 | Handle `target == "group"` on the node; persist the denylist to `data_dir`; check group status in `webrtc_offer` | **H4** | Revoking a group drops live sessions and blocks new signaling |

### H — Privacy

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.18 | Swarm registers hashes for `visibility == "public"` groups only; fix the mis-mounted route (`/v1/groups/v1/swarm/...`); authenticate the lookup | **H7** | No private-group content hash ever reaches the hub |

### I — Resource limits

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.19 | Pre-handshake buffer cap (a few KB, not 64 MB); `asyncio.Semaphore` around ffmpeg; delete the synchronous `subprocess.run` in `_do_stream_segment`; per-user signaling rate limit + membership check before relaying an offer; validate `peer_ip` against the request source | **H6** | One client cannot stall the daemon's event loop or exhaust its memory/CPU |

### J — Crypto hygiene, hub fixes, acceptance

| # | Component | Finding | Done when |
|---|---|---|---|
| 11.5.20 | Keystore Argon2id → 256 MB, parameters stored per-node in `node.toml` (not a `meshbay_common` constant); raise the password minimum | M2 | `calibrate-argon2` writes usable config; `crypto.py:173` no longer hardcodes 64 MB |
| 11.5.21 | Length-prefix every field in the HMAC transcript; **reject** empty DTLS fingerprints instead of proceeding | L4 | A missing fingerprint fails the handshake rather than degrading it to nonce-only |
| 11.5.22 | Hub: fix IPLog backfill (`users.py:118-122`), trusted-proxy XFF, scrub `str(e)` from peer-visible errors, drop `GEK_REQUEST`/`GEK_RESPONSE` constants, validate email | M6, M7, L3, L1, L6 | Compliance log attributes each row to the right account |
| 11.5.23 | Regression suite | all | See below |

**Required regression tests (all must exist and fail on reintroduction):**

```
test_no_unauthenticated_content     — every node listener refuses index/chunks pre-handshake
test_handshake_parity[webrtc,quic]  — identical checks on both transports
test_group_isolation                — 2 groups × 2 users: chat + peers never cross
test_upload_cannot_overwrite        — member B cannot replace member A's file
test_gek_store_requires_admin       — member cannot store/activate a GEK
test_ws_node_identity               — user token / foreign node_id rejected
test_node_proof_required            — client aborts when the node cannot prove GEK + sk_node
test_ui_escapes_filenames           — markup in a filename renders inert
test_swarm_public_only              — private hashes never registered
```

**Acceptance criteria for the phase:** with a hub whose signing key is in the attacker's
hands, an attacker who is not a group member obtains **no** index entry, **no** file byte,
**no** chat message, and cannot write to any node. A member who is not the node operator
cannot delete or overwrite another member's file, and cannot change the group key.

---

## Phase 12 — Client key verification + served-SPA integrity

> **Reworked 2026-08-13 by operator decision.** This phase was "Hub minimization:
> registrar and nothing more". That work is **deferred and may be dropped** — see
> decisions D1/D2 in `tmp-decisions.md`. The hub will keep serving the web UI, and a
> native client will be offered *in addition to* it, not as a replacement.
>
> Two items are kept here because the decision makes them *more* relevant, not less:
> the hub stays in the trusted path, so what it can substitute and what code it serves
> both still matter. Everything else from the old Phase 12 (route blindness test,
> opaque private-group metadata, chat_notify minimization, schema cleanup) is dropped
> from the plan; the swarm item already shipped in 11.5.18.

**Objective:** make the hub's remaining power over confidentiality *detectable*, given
that it stays in the trusted path by choice.

### 12.1 is DONE — and not as it was written

**H3 is closed (2026-08-14), by removing the lookup rather than by verifying it.** The
plan here was key transparency and safety numbers: keep fetching the invitee's key from
the hub, and give humans a way to notice a substitution. What shipped instead is the
invite redesign in `invite-pairing-v1.md` — the node holds the GEK and wraps it
itself, for a key the recipient proves possession of, and identities are bound to
accounts by one-time codes the hub never sees.

Why that is better than what was planned: safety numbers make a substitution *detectable
by a human who bothers to check*, at the single worst moment (first contact, when there
is no previous key to compare against). Removing the directory read from the invite path
makes the substitution impossible instead, and costs the user one code to pass along
rather than a fingerprint comparison ritual.

It also closed **M3** as a side effect, and absorbed **14.3/14.4** (CLI invite, member
management), which had to exist for a headless operator to admit anyone.

Safety numbers may still return later as defence in depth for *identity* verification —
"is this really Bob's account" — which is a different question from "which key gets the
group key". They are no longer load-bearing.

### Milestones

| # | Component | Description |
|---|---|---|
| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `invite-pairing-v1.md` |
| 12.2 | Served-SPA integrity | Strict CSP, Subresource Integrity on the bundle, and a signed digest of the served bundle published by the hub so a native client or extension can verify what the browser was given. **Now the highest-value item here**: T3 is the only remaining way an active hub reads content, and it can also lift a pairing code out of the page it served. **The CSP must keep `wasm-unsafe-eval` in `script-src`** — the bundle KDF is Argon2id in WebAssembly since 2026-08-14, and a policy that forbids it locks every user out of their keys |
| 12.3 | Honest labelling | `/app/` states plainly that the hub serves this code and what that implies. Docs stop claiming end-to-end integrity for the hub-served path — the claim that holds is "the hub cannot read your content unless it ships you malicious client code" |
| 12.4 | Written threat model | One page: passive hub, active hub, malicious node operator, malicious member, network attacker, local attacker — and for each claim, which adversary it holds against. This is what stops the overclaiming pattern the second review kept finding |

**Dropped from the old Phase 12** (recorded so the intent is not lost if it returns):
route-inventory blindness test, opaque private-group name/description, chat_notify
metadata minimization, residual schema cleanup.

---

## Phase 13 — Native desktop client (Electron + optional Python sidecar)

> **Reworked 2026-08-17 by operator decision. `desktop-client-v1.md` is
> authoritative for this phase** — shell, device linking, account creation, node
> management, packaging and the open items. The milestone table below is the summary;
> the design and its rationale are there.
>
> **The shell changed: pywebview → Electron.** Structural decision 18 is reversed. The
> reason is measured, not aesthetic: the SPA depends on Chromium-class APIs (WebRTC,
> WebCrypto X25519/Ed25519, MSE, Service Workers, File System Access), so keeping
> Chromium keeps `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js` and `sw.js`
> unchanged, while a system webview meant reimplementing ~2500–3000 lines plus a loopback
> media server and native dialogs. The old "69 % reused" figure was measured against an
> `app.js` of ~2600 lines; it is **4586** as of 2026-08-17, and `app.js` now holds 2 direct
> `crypto.subtle` calls — the three-globals seam had already leaked.
>
> **One blocking addition: device linking (13.3b).** Identity keys are per node, so a
> native client holding its own keys is refused by `webrtc_server.py:886-894` where a
> browser is not. Without linking, an account created natively could never be opened in a
> browser without an operator code per node — the "native must not prevent web use"
> objective fails outright. See `desktop-client-v1.md` §4.

**Objective:** ship a desktop application whose UI is not served by the hub, with durable
local key storage, at feature parity with the web for a standard user, that does not
prevent web use, and that can manage locally installed nodes.

> ⚠️ **Do not justify this phase as "the fix for T3".** An earlier draft of
> `second-review.md` claimed a native client makes code integrity independent of the hub.
> That was wrong: a binary downloaded from `meshbay.org` and signed with a key the hub
> operator holds relocates the trust rather than removing it. What native actually changes is
> **detectability** — an attack must ship as an artifact that can be hashed and compared
> instead of a one-off HTTP response — and that value is realised only by **18.7 reproducible
> builds** plus published hashes.
>
> **Cost line revised 2026-08-17.** Two of the three costs recorded here were pywebview's,
> not native's. Electron with `sandbox` and `contextIsolation` **keeps** the Chromium
> renderer sandbox — the strongest available — so "native costs the browser sandbox" is
> false for this shell, and the D2 table in `tmp-decisions.md` is wrong on that row. The
> loopback media server is deleted (13.6). What remains, and is real: **we own Chromium's
> patch cadence**, the renderer parses attacker-controlled content from nodes, npm enters
> the supply chain, and the updater is new surface.
>
> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified
> on product grounds. It closes **C4** for native devices, but **not for accounts that also
> use a browser** — see `desktop-client-v1.md` §5.1. Reproducible builds are unusually
> tractable here: the UI has no bundler or minifier, and Electron's own binaries have
> published upstream hashes.

### Why this is cheap (re-measured 2026-08-17)

| Asset | Lines | Under Electron | Under a system webview |
|---|---|---|---|
| `style.css`, `i18n.js`, `vendor/`, `zipstream.js`, `transfers.js` | ~4200 | reuse | reuse |
| `app.js` | 4586 | reuse, minus the hub base URL | reuse, minus storage glue + MSE player |
| `transport.js`, `crypto.js`, `keyderive.js` | 1907 | **reuse** | delete and reimplement |
| `downloads.js`, `sw.js` | 363 | **reuse** | delete |

`HUB = ''` (`app.js:12`) becoming a configurable absolute base URL is the one structural
change. That is what 13.1 exists for, and under Electron it is nearly all of it.

The old plan also budgeted a loopback media server (WebKitGTK MSE is unreliable), a SQLite
index cache (IndexedDB is restricted under `file://`) and native file dialogs. Chromium
provides all three, so those milestones are deleted rather than rewritten.

### Non-negotiable

**UI assets ship inside the package and load from disk.** A shell that points its WebView at
`https://meshbay.org/app/` is a browser with a different icon and fixes nothing. The hub is
used for the API only, and the bundle is covered by 13.9 signing.

Shell hardening is part of that and is not optional: `contextIsolation` and `sandbox` on,
`nodeIntegration` off, a custom `app://` protocol rather than `file://`, navigation to
non-local targets refused, and a strict in-package CSP that **keeps `wasm-unsafe-eval`** —
the bundle KDF is Argon2id in WebAssembly, and a policy forbidding it locks every user out
of their keys.

### Milestones

| # | Component | Prio | Description |
|---|---|---|---|
| 13.1 | Hub base URL + storage/save-file adapter | 1 | `HUB = ''` becomes configurable; a thin adapter for storage and saving. **Acceptance: the browser SPA behaves identically.** Much smaller than the old platform split — Chromium stays Chromium |
| 13.2 | Electron shell | 1 | `app://` via `protocol.handle`, CSP, preload with an enumerated API, sandbox, single instance, tray, window state |
| 13.3 | Local key storage + device auth | 1 | `safeStorage` (OS keychain) with an explicit fallback where no keyring exists; device Ed25519 auth on the `POST /v1/nodes/auth` pattern. **`POST /v1/users/auth` does not exist yet and must be written** |
| **13.3b** | **Device linking** | 1 | **Blocking.** One-time code generated by the new device, countersigned by an already-pinned key. `identities` gains a device dimension; `pin_identity`'s `INSERT OR REPLACE` must go. `desktop-client-v1.md` §4 |
| 13.3c | Hybrid registration | 1 | Register with a passphrase-derived `auth_key` (existing endpoint, browser-compatible at once, and the only account recovery path), device key thereafter |
| ~~13.4~~ | aiortc client transport | — | **Deleted** — Chromium provides WebRTC. `transport.js` is kept as the client |
| ~~13.5~~ | SQLite index cache | — | **Deleted** — IndexedDB works under `app://` |
| ~~13.6~~ | Loopback media server | — | **Deleted** — Chromium MSE works. Removes the C1-shaped surface this milestone would have added |
| 13.7 | Native save dialog | 2 | `dialog.showSaveDialog` + streamed write from the main process; the service-worker path already works, so this is an improvement, not a prerequisite |
| 13.8 | Safety-number UI | 3 | Consumes 12.2. **No longer load-bearing for device linking** — the code binding replaced digit comparison |
| 13.9 | Release key + verified updates | 3 | Prefer the signed apt/dnf repo (18.5) over a bespoke updater. **The key must exist before the first public package**; without it the update channel is the new T3 |
| 13.10 | Packaging | 1 / 3 | `.deb` (Ubuntu 24+) and `.rpm` (Fedora 44+) first; MSI per-user (Windows) later; AppImage/Flatpak optional |
| ~~13.11~~ | "Retire the SPA?" | — | **Settled**: the web stays. A native client must not prevent web use |
| 13.11b | Per-root `unavailable` index state | 2 | Freeze the subtree instead of emptying it when a volume goes away. **Prerequisite for root selection** — otherwise unplugging a USB drive propagates deletions for a whole library |
| 13.11c | Named roots per group | 2 | `shared_dir` (one string) → a list of `{name, path, kind}`; the name is the directory's basename, derived once and stored. Unique names (case-insensitive), no nesting, per-root availability, operator-designated upload target |
| 13.11d | Filesystem portability | 1 | Case folding, NFC normalization, Windows reserved names, `\\?\` paths, reconciliation scans. Case folding is for index identity, collision reporting and root names. **Not** the no-overwrite check: `Path.exists()` is already case-insensitive on NTFS/exFAT, so C5a is not reachable that way (verified 2026-08-18) |
| 13.12 | Node management over signed MNP ops | 2 | Invitations, revoke, unpin, devices, file/dir delete, roster, audit, peers, root selection, **`gek_rotate`**. The loopback admin API is never exposed to the network |
| 13.13 | First-run wizard | 2 | Detect a local node, `systemctl --user enable --now` (no privilege), link to the hub account, create/attach a group, `gek-init`, `operator pair` — all on loopback, no terminal |
| 13.14 | Python sidecar — `group://` over QUIC | 2 | Reuses `quic_client.py`. Also the only local management path on a LAN with no internet, since MNP setup needs the hub's signaling |

### Deletions enabled once native is the recommended client

`webcrypto.py` + the `:aes` HKDF variant · `deriveAuthKey`/`deriveEncryptionKey` +
`pw_version` 3 + legacy migration · keypair bundle MNP messages + `keypair_bundles` table ·
`_bundleKey` in IndexedDB + `_sessionKeys` in sessionStorage + `_pkFromSk`.

**Revised 2026-08-17:** `transport.js`, `crypto.js`, `keyderive.js`, `downloads.js`, `sw.js`
and the MSE path are **no longer on this list** — under Electron they are the client, not
browser workarounds. The keypair-bundle deletions still apply, but only for accounts that
opt out of browser use (`desktop-client-v1.md` §5.1); the browser path needs them.

**Kept regardless:** WebRTC transport, hub signaling relay, DTLS channel binding.
These carry NAT traversal and are not browser workarounds.

---

## Phase 14 — Node CLI + management

> Was Phase 12 before the 2026-08-13 renumbering.

**Objective:** `meshbay-node` CLI becomes a full management tool, not just a
daemon launcher.

**Partially delivered early (2026-08-13), forced by the first real deployment.**
Every operator action lived behind a web UI on the node's own loopback interface,
so a node on a server reached over SSH could not be operated at all without
port-forwarding a browser session — and 11.5.3 added a token that had to be
copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock
that.

**Member management landed 2026-08-14** with the invite redesign, for the same
reason: a node admits people from its own roster, and a headless operator had no
way to put anyone on it. `operator pair`, `member list|invite|revoke|unpin` all
work over SSH. **Deleting a file is now the only operator action that still needs
a browser.**

### Milestones

| # | Component | Description |
|---|---|---|
| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped |
| 14.1b | ~~`meshbay-node ui`~~ | **Removed 2026-09-01** (`refactor-node-ui.md` phase 5). The server-rendered admin page it opened is gone; the CLI and the desktop client's Node page use the loopback control API directly |
| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine |
| 14.2 | `meshbay-node group list` | ✅ **DONE 2026-08-18** — groups with roots, key state, file and peer counts |
| 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK |
| 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config |
| 14.5 | `meshbay-node member invite` | ✅ **DONE 2026-08-14** — issues a one-time code; the node wraps the GEK itself when the invitee connects. The original description ("wrap GEK for new member, store bundle") describes the design the invite redesign replaced |
| 14.6 | `meshbay-node member revoke` | ✅ **DONE** — stops the node serving the key, and tells the operator to rotate it, since the ex-member still holds the current one |
| 14.6b | `meshbay-node member unpin` | ✅ **DONE** — forget a pinned identity so someone can pair again after a key reset |
| 14.7 | `meshbay-node member list` | ✅ **DONE** — roster: who is admitted, with what role, pinned when and how. Online status still to add |
| 14.8 | Config reload (SIGHUP) | ✅ **DONE 2026-08-18** — `meshbay-node reload`. Deliberately narrow: it re-roots **groups already hosted**, which is what an operator adjusts day to day, and reports a changed group *set* as needing a restart. Adding a group live means new indexers, chat stores, GEK loads and transport contexts, and that is how a half-built group ends up serving content. No connection is dropped |
| 14.9 | ~~`meshbay-node admin-key`~~ | ✅ **Superseded by `operator pair`** — pairing binds the operator's browser key with a one-time code instead of pasting a base64 key, and the auto-pin that made M3 possible is deleted |
| 14.10 | `meshbay-node denylist` | ✅ **DONE 2026-08-18** — `denylist show|clear [identifier]`. Clearing asks for confirmation and reports the count, because it re-admits whoever it was keeping out |
| 14.11 | `meshbay-node file rm` | ✅ **DONE 2026-08-18** — `file list|rm <id>`. **No operator action now requires a browser.** Refuses a file whose root is unavailable: it is frozen, not gone |

### Sequencing and factoring (added 2026-08-17)

**Phase 14 finishes before 13.12** (node management from the desktop client), and the
remaining commands are written against a single internal module rather than beside one.

- The CLI is the only interface that works with the daemon stopped, with no GEK, or with
  no operator paired — exactly the states the desktop client cannot reach, and the ones
  decision E5 sends back to the local machine. Holes here have no fallback.
- 13.12 would add MNP handlers for operations the CLI already performs through the
  loopback API. **Two paths to one operation with different authorization is the shape of
  C1 and C6.** Factor each operation into `meshbay_node/ops.py`, with the CLI, the
  loopback API and the MNP handler as three thin adapters. Parity becomes structural,
  authorization lives in one place, and 13.12 is adapter code.

The refactor is cheaper now, with six commands left, than after 13.12 exists.
See `desktop-client-v1.md` §6.6.

**Done 2026-08-18.** `meshbay_node/ops.py` holds each operation once; the loopback API is
a one-line adapter per endpoint (`_op()` translates `OpError` into a JSON response) and the
MNP handlers call the same functions through `_run_op`. `test_ops.py` asserts the shape
rather than trusting it: every operation takes `state` first, `ops` imports nothing
web-shaped, and no loopback handler performs an operation itself.

**Signed MNP ops shipped with it:** `gek_rotate` and `member_unpin`, both operator-signed
over a structured transcript like every other destructive operation. Rotation is the half
of revocation that revocation cannot do — the ex-member holds the current key — and the
node generates the replacement with its own CSPRNG, so no key material crosses the wire.

### Architecture

CLI commands talk to the running daemon over its **loopback admin API**, authenticated
with the per-run session token (11.5.3) — `_daemon_api()` in `daemon.py`. The Unix-socket
design below was the original plan; the loopback API already existed for the admin UI,
carries the same authorization, and avoided a second control plane. A socket would still
be an improvement (no port, file permissions instead of a token file) if the admin UI
ever goes away.

`status` deliberately does *not* use it: it reads the keystore, the config and the roster
directly, so it works while the daemon is stopped — which is when an operator most needs
to know why.

---

## Phase 15 — Chat encryption + retention

> **Superseded 2026-09-07 by `docs/chat-sender-keys.md`, which is the
> specification and the decision record. Built.** Read that document before this
> section: the milestone table below is kept for the history of the decision and
> is wrong in three places, each marked. In particular 15.1–15.3 put encryption
> *in the node* — the node is a relay and an archive, and messages are composed
> and read in the client, so built as written the feature would have protected
> nothing it claimed.
>
> The protocol is no longer Sender Keys. `senderkeys.py` is unused and kept for a
> possible future 1:1 DM, alongside `ratchet.py`.

> Was Phase 13 before the 2026-08-13 renumbering.

**Objective:** implement spec section 6.6 — group chat messages are encrypted
with the Sender Keys protocol. Currently, chat messages are stored and
transmitted as plaintext payloads (relying on transport encryption only).

### Background

`meshbay_common.senderkeys` (Phase 7.5) implements the Sender Keys protocol, but nothing
in production imports it — `grep` finds it only in its own tests. The node chat flow
(`_do_chat_message`) stores raw payloads. The module provides per-sender chain key
derivation, symmetric message encryption, and a distribution format.

### 15.0 — Distribution channel ✅ DECIDED 2026-09-03

`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
direct)". An earlier version of this document rejected GEK-wrapped distribution on the
grounds that anyone holding the GEK recovers every sender key.

**Revised 2026-09-03 by operator decision: GEK-wrapped distribution is the right choice
for this platform.** The reasoning that led to pairwise was sound in isolation but wrong
for the actual threat model:

- The GEK already gives access to **all files** in the group. Wrapping sender keys
  under it means "anyone who can read the files can read the chat" — which is exactly
  the semantics of a group chat. There is no scenario where a member should read files
  but not chat, or vice versa.
- The node operator is always a group member and therefore a legitimate sender-key
  recipient. Sender Keys does not protect chat from the operator regardless of the
  distribution channel (see threat delta below).
- H3 (key substitution at invite) is **closed** (2026-08-14, `invite-pairing-v1.md`).
  The GEK is no longer obtainable through the hub. A former member who kept the old
  GEK is handled by GEK rotation on removal, which is already implemented.
- Pairwise distribution would add O(devices × members) ECIES wraps per sender key
  change, for a marginal security gain: separating "file access" from "chat access"
  on a platform where both are gated by the same group membership.

**Distribution is GEK-wrapped:** each sender key distribution message is encrypted
with `wrap_gek_aes` under the group's current GEK. Every member who has the GEK can
unwrap it. Simple, no fan-out, no new crypto primitive.

### 15.0b — A sender key is per DEVICE, never per person (added 2026-08-17)

**This phase predates device linking (`desktop-client-v1.md` §4) and is wrong as
written.** One person now holds several identity keys on one node — a browser and a
desktop client, up to the device cap. Two consequences, and the first is the whole
decision:

**A shared per-person chain reintroduces C1, one level down.** If Alice's two devices share
one sending chain, both advance it, and concurrent sends produce **key and nonce reuse** —
which is precisely why `first-review.md` C1 rejected a shared Double Ratchet for groups.
Per-device chains have no shared mutable state and no reuse. There is no third option worth
weighing.

**The code already fails this, silently.** `senderkeys.py` keys everything by
`sender_id: str`, and `GroupSenderKeyStore.add_sender` does
`self._states[dist.sender_id] = ...` — so a second device registering under the same
`sender_id` **overwrites the first, dropping its chain**. Same shape as `pin_identity`'s
`INSERT OR REPLACE`, same fix: `sender_id` becomes a **device** identifier (account plus
device key fingerprint), not a `user_id`. The module needs its identifier redefined, not
restructuring — and the class docstring, which says "one chain per member", needs to say
per device.

**What follows from per-device chains:**

- **A new device receives all current sender keys via the GEK it already holds** (revised
  2026-09-03). Since distribution is GEK-wrapped, a device that has completed the
  handshake and received the GEK can unwrap every sender key distribution message. No
  redistribution by every sender is needed; the node replays the latest distribution
  message for each active chain. History encrypted under older chain keys remains
  unreadable only if the chain has ratcheted forward since — which is the expected
  forward-secrecy property, not a gap.
- **Revoking a device must rotate**, exactly like revoking a member: a lost laptop holds
  every sender key it ever received. 15.4 only knows about members today and must cover
  `device revoke` and `member unpin`.

### Honest threat delta (state this in the docs, not just here)

Sender Keys protects chat against **someone who holds the node's disk but not the GEK** —
a hosting provider imaging the machine, a backup that leaks, a law-enforcement seizure
where the keystore password is not surrendered. It does **not** protect chat from anyone
who holds the GEK, which includes every current group member and the node operator.
This is the same boundary as file access, by design (operator decision 2026-09-03):
the GEK is the group secret, and both files and chat are gated by it.

Claiming more than that would repeat the overstatement pattern `second-review.md` §7
flags. Additions that belong in the user-facing docs:

- **It does not protect against anyone holding any one device of any member.** With
  several devices per person, that surface is larger than it was.
- **It does not protect against a former member who kept the GEK before rotation.**
  GEK rotation on member removal is implemented, but messages encrypted under the
  old GEK remain readable to anyone who held it. This is the same property as files.
- **C4's blast radius reaches chat history.** A browser recovers its identity key from the
  keypair bundle on the node; cracking that bundle yields the GEK, and therefore every
  sender key distributed under it. Not a regression — chat is plaintext at rest today —
  but it means Sender Keys is worth measurably less to a browser-using account than to a
  native one, which is the same asymmetry as everywhere else in `desktop-client-v1.md`
  §5.1.
- **Sender authentication is now a requirement, not an accepted limitation**
  (operator decision, 2026-08-17). A sender key proves *a device*; it does not prove which
  account that device belongs to, and NS6's enforcement of `sender_id` from the session is
  the node's word. The design is in `desktop-client-v1.md` §4.8: **sign every message
  with the sender's device key** (independent of encryption, so it can land before this
  phase), **pin `account → device keys` client-side** using the device-add
  countersignatures as evidence, and optionally have the **operator sign a roster
  attestation** to close first contact. What survives: an operator who turns malicious
  *later* cannot forge an account a member has already seen — forgery is limited to
  accounts the victim has never encountered.

### Milestones

| # | Component | Description |
|---|---|---|
| 15.0 | **Distribution decision** | ✅ DECIDED 2026-09-03 (GEK-wrapped), then **overtaken 2026-09-07**: the epoch key is delivered wrapped under the group key, but nothing is *stored* under it — which is what makes a group-key rotation a re-wrap instead of the destruction of the archive |
| 15.0b | **Per-device chains** | ✅ **Obtained without chains.** One key per device, derived by name from the epoch key, so there is no shared mutable sending state to reuse a nonce and nothing to persist per device |
| 15.1 | ~~Node: sender key init~~ | ❌ **Wrong as written** — the node is a relay and an archive. It generates and delivers the epoch key (`ops.open_chat_epoch`, `chat_keys_req`); the client seals |
| 15.2 | ~~Node: encrypt chat on send~~ | ❌ **Wrong as written.** Encryption is in `static/crypto.js`; the node stores what it cannot read |
| 15.3 | ~~Node: decrypt chat on receive~~ | ❌ **Wrong as written.** Only clients decrypt. Out-of-order does not arise: there is no chain to advance |
| 15.4 | Key rotation on removal | ✅ Member revoked, unpinned, device revoked, or `gek_rotate` → a new epoch, pushed to everyone connected. Old epochs kept, or the removal would take the history with it |
| 15.5 | Chat retention config | ✅ `meshbay-node chat prune <days>` / `ops.prune_chat`. Deletes messages, never epoch keys |
| 15.6 | MNP version negotiation | ✅ **DONE 2026-09-03**, and not here: it shipped with **MNP 1.0** (the sealed index and ack), which forced a coordinated deployment anyway. `handshake` and `handshake_challenge` each carry `v` and `v_min`; `check_version` refuses with `version_too_old` / `version_too_new` / `version_unreadable`, shaped like `not_a_member`. The flag day was already being paid for, so the next breaking change costs a refusal message instead of a second one. See `MESHBAY_NODE_PROTOCOL.md` §13.1 |
| 15.7 | Chat attachments | **Documented, not encrypted.** Attachments are ordinary files on a shared root and stay plaintext on disk; the *reference* to one is inside the sealed payload, but the file and its name are in the index. Encrypting them is a different feature with a different blast radius — `docs/chat-sender-keys.md` §5.8 states the asymmetry rather than hiding it |
| 15.8 | **The switch** | ✅ Per group, operator-signed (`OP_CHAT_ENCRYPTED`), reported inside the sealed handshake ack. Off by default — a node upgraded into a running group must refuse nobody. On, the node refuses plaintext outright |
| 15.9 | **`chat encrypt-history`** | ✅ Explicit CLI command, backs `chat.db` up first, one transaction. Deliberately not done by the switch: it rewrites the only copy of a conversation, and a toggle that does that is one somebody flips twice |

---

## Phase 16 — Android client MVP

> Was Phase 14 before the 2026-08-13 renumbering.
>
> **Rewritten 2026-08-17.** The previous text described an architecture that no longer
> exists and, in two places, one that was deliberately dismantled. Corrections are listed
> below rather than silently applied, because the same mistakes are easy to make twice.
>
> **Shares the desktop design** (`desktop-client-v1.md`): keys generated and kept
> locally, device Ed25519 authentication, no keypair bundles, and **an Android client is
> simply another device** under device linking. Do not re-derive a second crypto, auth or
> admission model here.

**Objective:** Android app for account creation, group browsing, file download, streaming
and chat. **Client only — no node functionality on mobile**, and that is structural: an
app cannot freely read the phone's folders (the user grants access to one tree at a time,
revocably), background processes are killed, and a long-lived listening socket is not
guaranteed. All three are things a node must have.

### What the previous text got wrong

| It said | Reality |
|---|---|
| "The `keypair_bundle` (encrypted, stored on hub) enables cross-device" | **The hub has stored no keypair bundle since 2026-08-12**, and since 2026-08-14 identity keys are **per node** — there is no single identity to carry between platforms. Cross-device is **device linking**, not a shared bundle |
| "Notification state and read markers sync via hub (small encrypted blob per user)" | Violates the rule that **group-related server state lives on the node** (draft-v6 §2.5). Even encrypted, a per-user blob the hub stores gives it update timing and frequency — who reads which group, when. Node-side or not synced |
| "Hub client (auth, groups, **GEK**)" | The hub does not serve GEKs. `GET /gek` and the `gek_bundles` table were removed in the T3 work; the node wraps the key on every connection |
| "NAT traversal (`punch_nat` + QUIC)" | `punch_nat()` is **not** a traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated (structural decision 17). **ICE/STUN is the traversal path**, and Android has a native WebRTC stack |
| "MNP extended with an `upload` message type" | Already shipped — `FILE_UPLOAD`, Phase 10b.4 |
| "Account creation … + keypair bundle" | Hybrid registration (draft-v6 §1 item 7): passphrase-derived `auth_key`, then a device Ed25519 key. No bundle anywhere |
| Milestones numbered 14.x inside Phase 16 | Leftover from the renumbering; they are 16.x below |

**Stack:** Kotlin + Jetpack Compose. **WebRTC via Android's native stack** — the traversal
path, same as every other client. Crypto via Bouncy Castle JVM.

**QUIC is deferred.** It exists for LAN and hub-less `group://`, which is marginal on a
phone, and it would drag a Rust JNI dependency (`quiche`) into an MVP. Add it if a real
use case appears.

### Milestones

| # | Component | Priority |
|---|---|---|
| 16.1 | Hub client — auth, groups, notifications (Retrofit) | High |
| 16.2 | Crypto — Ed25519, X25519, ChaCha20, and **`auth_key` derivation byte-identical to `keyderive.js`/`keyderive.py`** | High |
| 16.3 | WebRTC DataChannel transport + the unified handshake (11.5.4) | High |
| 16.4 | **Device linking** — the app generates its own keys and is approved by an already-paired device (§4 of the desktop-client doc) | High |
| 16.5 | Hybrid registration from the app | High |
| 16.6 | File browser + download, **root-aware paths**, per-root "unavailable" state | High |
| 16.7 | Upload from mobile via the existing `FILE_UPLOAD` handler; photo picker, no broad storage permission | Medium |
| 16.8 | Chat UI | Medium |
| 16.9 | Video streaming (native player, MSE not required) | Medium |
| 16.10 | Contact list integration (permission-gated) | Low |

### Consequences carried from the other phases

- **Device linking is a prerequisite** (Stage C), exactly as for the desktop client. Without
  it, installing the app on a phone would need an operator code per node.
- **A fourth consumer of the KDF parity test.** `auth_key` is PBKDF2-SHA512 600 000 in
  `keyderive.js`, `keyderive.py`, the QE harness and now Kotlin. The standing warning
  applies and matters more each time: **never change those parameters in one place** — a
  mismatch does not look like an error, it looks like an account nobody can open.
- **Sender Keys**: a phone is a device, so it gets its own chain (§15.0b). The "no history
  until every sender redistributes" property is **most visible here** — people install an
  app and expect their backlog — which argues for the sealed state handover rather than
  the accept-and-explain option.
- **Version skew is worse than on desktop.** An installed client meets a newer hub
  (`desktop-client-v1.md` §2.6), and store review latency means a fix cannot be
  pushed quickly. The minimum-client-version check is not optional here.
- **Multi-root** falls out for free if the app is built after Stage A; it must not assume a
  group is one directory.

### Open, and worth deciding before 16.8

**Chat delivery on a phone has no answer today.** Android will not let an app hold a
WebRTC DataChannel open in the background, so a message arriving while the app is closed
reaches nobody. The obvious mechanism is a push service, and the obvious push service is
FCM — which would mean **Google learning the timing of your group activity, and the hub
sending it**, against the whole metadata posture (H7, draft-v6 §2.5). Alternatives
(a self-hosted UnifiedPush distributor, a foreground service the user opts into, polling
on open) each cost something different. **Decide it explicitly; do not let FCM arrive as
an implementation detail.**

**Out of scope:** node functionality on mobile, Mac and iPhone support.

---

## Phase 17 — Network resilience (optional, low priority)

> Was Phase 15 before the 2026-08-13 renumbering.

**Objective:** handle edge cases — symmetric NAT (CGNAT mobile), TURN relay,
0-RTT reconnection. Not needed for typical residential users.

| # | Component | Priority |
|---|---|---|
| 15.1 | Mesh Relay TURN server | Low |
| 15.2 | Relay registration via MHP | Low |
| 15.3 | Node fallback to relay after ICE failure | Low |
| 15.4 | QUIC 0-RTT (session tickets) | Medium |
| 15.5 | Connection pool (1 QUIC conn = N requests) | Medium |
| 15.6 | Test CGNAT mobile 4G | Low |

**Note:** enterprise users behind restrictive firewalls can configure port
forwarding themselves. This phase targets the ~15% of residential connections
where even ICE/STUN fails (symmetric NAT behind CGNAT). Not a priority —
the user explicitly deprioritized this.

---

## Phase 18 — Packaging, repositories, CI, supply chain

> Was Phase 16 before the 2026-08-13 renumbering.
> Release **signing** is not here — it moved into 13.9, because a desktop application
> cannot ship without a verified update channel. This phase covers distro packaging and CI.

| # | Component |
|---|---|
| 18.1 | RPM build pipeline (Fedora, RHEL) |
| 18.2 | DEB build pipeline (Ubuntu, Debian) |
| 18.3 | GitHub Actions CI (pytest + ruff on PR) |
| 18.4 | **Security CI**: the 11.5.23 regression suite + the 12.1 hub-blindness test run on every PR; dependency audit (`pip-audit`); static analysis (`bandit`/`semgrep`) |
| 18.5 | Repo apt/dnf on meshbay.org/packages/, signed with the 13.9 key |
| 18.6 | Android APK distribution on meshbay.org/downloads/ |
| 18.7 | Reproducible builds for the desktop client (stretch) — lets third parties verify the shipped bundle matches the source, the last piece of the T3 answer |

---

## Phase 19 — Extension module sandbox (future)

> Was Phase 17 before the 2026-08-13 renumbering.
> Adds a large new attack surface (arbitrary code near group data). Requires its own
> security review before any code is written. Must stay last.

**Objective:** implement spec section 12 — Python extension modules that can
react to group events, access the file index, and send messages, running in
a sandboxed subprocess with limited permissions.

| # | Component | Description |
|---|---|---|
| 17.1 | Module manifest loader | Parse `module.toml`, validate permissions |
| 17.2 | Sandboxed subprocess | `read_index()`, `send_message()`, `receive_events()` API |
| 17.3 | Permission enforcement | No filesystem/network beyond group context |
| 17.4 | Module marketplace on hub | List/install/rate extension modules |

**Low priority.** This is an extensibility feature for power users and
community developers. Core functionality must be complete and stable first.

---

## Recommended order

```
Phase 11.5 (Security remediation)  ⛔ BLOCKING — nothing else starts
Phase 13.1 (Hub base URL + adapters)← free refactor, prerequisite for the desktop client
Phase 12   (Key verification)      ← H3 safety numbers + served-SPA integrity
Phase 14   (Node CLI)              ← best security-per-effort answer to T3
Phase 15   (Sender Keys)           ← chat encryption; 15.0 decision first
Phase 13.2+ (Desktop client)       ← Electron; offered alongside the browser SPA
Phase 16   (Android)               ← reuses the Phase 13 design
Phase 17   (Resilience)            ← optional, edge cases only
Phase 18   (Packaging + CI)        ← distro repos; 18.7 gates 13's security argument
Phase 19   (Extensions)            ← last, needs its own security review
```

**Reordered 2026-08-13.** The desktop client was originally placed third on the strength of
"it removes T3". That claim was corrected (see the Phase 13 banner), so the client is now
sequenced after the work that closes actual findings, and behind decision D2 in
`tmp-decisions.md`. Security-per-effort: **11.5 ≫ 12 ≫ 14 ≫ 13**.

Phase 14 (CLI) moved ahead of the client work for a specific reason: the node operator holds
the GEK and is the content authority, yet today must use hub-served JS to initialize GEKs and
invite members. The CLI removes that dependency for the highest-value target at a fraction of
any client's cost.

**Phase 11.5 is blocking and not negotiable.** The current build serves private group
content over an unauthenticated HTTP port (C1), lets any user hijack a node's signaling
identity (C2), and lets any member seize the group key (C5b). No feature work lands on top
of that.

**One task can run in parallel:** 13.1 (hub base URL + storage/save-file adapter) is pure
refactoring with the acceptance criterion "the browser SPA is unchanged in behaviour". It
de-risks Phase 13 and touches none of the security surface.

**One task must not be deferred inside Phase 13:** 13.3b (device linking). It is a protocol
and schema change, it gates the "native must not prevent web use" objective, and the
roster's `pin_identity` currently does `INSERT OR REPLACE` on a `user_id` primary key — a
silent overwrite that becomes a hole the moment more than one key per person is legitimate.

**Renumbering map (2026-08-13):**

| Old | New | Phase |
|---|---|---|
| — | 11.5 | Security remediation (new) |
| — | 12 | Hub minimization (new) |
| — | 13 | Native desktop client (new) |
| 12 | 14 | Node CLI + management |
| 13 | 15 | Chat encryption (Sender Keys) |
| 14 | 16 | Android client |
| 15 | 17 | Network resilience |
| 16 | 18 | Packaging, repos, CI |
| 17 | 19 | Extension module sandbox |

---

## Structural decisions (all resolved)

1. Multi-group on a single QUIC port ✅ (Phase 7)
2. Signaling punch/connect via hub WS ✅ (Phase 7)
3. Chat is a core feature, not a module ✅ (draft v3)
4. Chat encryption: Sender Keys ✅ (security review)
5. JWT group claims required ✅ (security review)
6. Admin model: config-based ✅ (Phase 8)
7. Refresh token rotation: family-based ✅ (Phase 8)
8. Email encrypted at rest: AES-256-GCM ✅ (Phase 8)
9. Argon2id params: 256 MB, pw_version for migration ✅ (Phase 8)
10. **Browser transport: WebRTC DataChannel + ICE/STUN** ✅ (decided 2026-08-10)
11. **Hub role: registrar + signaling ONLY, never in data path** ✅ (reinforced 2026-08-10)
12. **Chat stored on nodes, not hub** ✅ (decided 2026-08-10)
13. **Web UI: Preact SPA, dark/light, responsive, i18n** ✅ (decided 2026-08-10)
14. **Site overlay: meshbay.org-specific pages separate from generic hub** ✅ (decided 2026-08-10)
15. **MSE streaming: ffmpeg fMP4 remux on node, SourceBuffer on browser** ✅ (Phase 10c)
16. **Transport: aiortc/ICE is primary for browser AND native. QUIC kept at parity for LAN,
    port-forwarded and hub-less `group://` access. TCP+TLS and the node HTTP API are
    removed.** ✅ (decided 2026-08-13, second review)
17. **`punch_nat()` is a direct-connection helper, not a NAT traversal stack** — no STUN, no
    candidate gathering, no dual-stack fallback, validated on one ISP. ICE/STUN (validated on
    two ISPs, two browsers, IPv4 + IPv6 + 4G CGNAT) is the traversal path. ✅ (2026-08-13)
18. ~~**Native desktop shell: pywebview**~~ → **Electron**, with an optional Python sidecar
    for hub-less `group://` over QUIC. **Reversed 2026-08-17** — the SPA depends on
    Chromium-class APIs, so a system webview meant reimplementing ~2500–3000 lines and
    losing the renderer sandbox. What is unchanged and non-negotiable: **UI assets ship
    inside the package and load from disk**, never fetched from the hub, or T3 is not
    fixed. See `desktop-client-v1.md` §2.
18b. **A second device is admitted by device linking, not by an operator code.** The
    already-pinned key countersigns; the binding is a one-time code the new device
    generates and displays, never a human comparing digits. The hub cannot produce that
    countersignature. ✅ (2026-08-17)
19. **Private keys never leave the device on native clients.** Keypair bundles are retired
    rather than relocated; Phase 12's move of bundles from hub to node was the wrong
    destination (C4). ✅ (2026-08-13). **Qualified 2026-08-17:** this holds for native
    devices. A browser has no durable storage of its own and still needs a bundle on each
    node, so C4 closes for an account only when it opts out of browser use.
20. ~~Sender keys are distributed pairwise to identity keys, never derived from or wrapped
    under the GEK.~~ **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**.
    The GEK is the group secret; both files and chat are gated by it. Pairwise distribution
    would add complexity for a separation (files vs chat) that has no meaning in this
    platform's group model. Per-device chains (15.0b) remain required for correctness
21. **Hub minimization is enforced by an acceptance test (12.1), not by policy.** The hub
    must be *unable* to see keys, content, or file listings. ✅ (2026-08-13)