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
|
# 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: docs/meshbay-draft-v4.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
Recommended Caddyfile snippet for meshbay.org:
```
meshbay.org {
root * /path/to/meshbay/site
try_files {path} {path}.html
file_server
handle /v1/* {
reverse_proxy localhost:8000
}
handle /app* {
reverse_proxy localhost:8000
}
handle /style.css {
reverse_proxy localhost:8000
}
handle /*.js {
reverse_proxy localhost:8000
}
}
```
### 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 old STREAM_SEGMENT handler)
---
## 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:** channel binding for QUIC. No DTLS fingerprint exists — bind to the QUIC server certificate hash as the analogue (`sha256(server_cert) ‖ sha256(client_cert)`); prefer an RFC 5705 TLS exporter if `aioquic` can expose one | C6/NS5 | QUIC handshake proof is bound to the connection, not replayable across connections |
### 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 two remaining powers over confidentiality *detectable*,
given that it stays in the trusted path by choice.
### Why these two survive
**H3 is the last open High finding, and nothing else fixes it.** The hub is the public
key directory: when a member invites someone, the inviter fetches the invitee's
`pk_x25519` from the hub and wraps the GEK for it. A hub that returns its own key gets
the group key, decrypts everything, and nothing in the protocol notices. This needs no
JWT forgery and no code injection. Deferring Phase 12 wholesale would leave it open
indefinitely, so it moves here rather than disappearing.
**Serving the SPA is now a deliberate choice, not a residual risk.** A hub that ships
the code can exfiltrate keys from the page whatever the protocol does (T3). That is
accepted — but it should be labelled honestly and made verifiable where possible.
### Milestones
| # | Component | Description |
|---|---|---|
| 12.1 | **Key transparency + safety numbers** [H3] | Hub-signed append-only key log; clients pin the key they first saw for a contact and audit the log; a key change raises a blocking warning before any GEK is wrapped for it; safety-number comparison UI between two members. Applies to the SPA and the native client alike |
| 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 |
| 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 actively attacks you" |
| 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 (pywebview + aiortc)
> **Status (2026-08-13): 13.1 active, 13.2–13.11 DEFERRED to after Phase 15**, pending
> decision D2 in `tmp-decisions.md` (browser extension vs native client vs both).
>
> **13.1 (platform adapter split) proceeds regardless** — it is pure refactoring whose
> acceptance criterion is "the browser SPA behaves identically", and it is the prerequisite
> for every option under D2.
**Objective:** ship a desktop application with durable key storage, hub-independent
`group://` access, and a better media path than the browser allows.
> ⚠️ **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. Native also *costs* the browser sandbox, hands you patch
> velocity for WebKitGTK and every bundled dependency, and adds the loopback media server,
> the IPC bridge and the updater as new attack surface.
>
> The security-per-effort ranking is: **11.5 ≫ 12 ≫ 14 (CLI) ≫ 13.** This phase is justified
> on product grounds. It permanently closes **C4** as a side effect, but C4 can also be closed
> in a browser by not storing keypair bundles remotely at all.
### Why this is cheap
The SPA never touches a browser crypto or network primitive directly: `app.js` contains
**0** occurrences of `crypto.subtle` and **0** of `RTCPeerConnection`. All crypto and
transport go through three injected globals (`window.MeshBayCrypto`, `MeshBayKeys`,
`MeshBayTransport` — 16 call sites) and all hub I/O through one function (`hubFetch`, 30
call sites). That is the seam.
| Asset | Lines | Native |
|---|---|---|
| `style.css`, `i18n.js`, `vendor/htm-preact.js` | 1708 | **reuse as-is** |
| `app.js` — components, routing, theme, admin | ~2050 | **reuse as-is** |
| `app.js` — storage glue, `hubFetch`, download/upload callbacks, MSE `VideoPlayer` | ~550 | rewrite |
| `transport.js`, `crypto.js`, `keyderive.js` | 1145 | **delete** |
≈ **69 % reused unchanged**, and the 31 % that is not is largely code `second-review.md`
says to delete anyway (WebCrypto AES variant, PBKDF2 password split, keypair bundles).
### 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.
### Milestones
| # | Component | Description |
|---|---|---|
| 13.1 | Platform adapter split | Extract `platform-web.js` (WebRTC/WebCrypto/fetch — today's behaviour) and `platform-native.js` (pywebview bridge). `app.js` imports neither directly. **Acceptance: the browser SPA is byte-for-byte functional after the split** — this lands first, on its own, with no native code |
| 13.2 | pywebview shell + Python bridge | `meshbay-client` package; `window.pywebview.api.*` implements the same surface as the three globals; single-instance, tray, window state |
| 13.3 | Local keystore + Ed25519 client auth | Reuse `keystore.py` (Argon2id 256 MB, OS keychain later). Client authenticates like the daemon does: signed timestamp, `POST /v1/users/auth`. **No password on the wire, no `auth_key`/`bundle_key`, no keypair bundle anywhere** → closes **C4** permanently |
| 13.4 | aiortc client transport | `RTCPeerConnection` + `createDataChannel` + `createOffer` in Python; ICE/STUN via `aioice` — the traversal path validated on 2 ISPs. Calls the unified handshake from 11.5.4. QUIC (`quic_client.py`) retained as opt-in for LAN / port-forwarded / hub-less `group://` |
| 13.5 | Local index cache | SQLite in the client profile dir, replacing IndexedDB (also restricted under `file://` in some WebViews) |
| 13.6 | Loopback media server | Python decrypts and serves with HTTP Range; `<video src="http://127.0.0.1:…">`. Drops MSE + the fMP4 remux for native (WebKitGTK MSE is unreliable). **Hardening is mandatory and mirrors C1: bind `127.0.0.1` only, random port, per-file capability token scoped to the session, no CORS, reject non-local `Origin`** |
| 13.7 | Native file dialogs | Replace `showSaveFilePicker`; stream decrypted chunks to disk with constant memory |
| 13.8 | Safety-number UI | Consumes 12.2: display and compare fingerprints, warn on key change |
| 13.9 | Signed releases + verified updates | **Gate for GA.** GPG/minisign release key, client verifies before applying, documented key + revocation procedure. Without this the update channel becomes the new T3 |
| 13.10 | Packaging | AppImage + Flatpak (Linux, primary), MSI (Windows), dmg (macOS) |
| 13.11 | Decision point | Retire the browser SPA, or keep it explicitly labelled reduced-trust (12.6). Deferring is fine; deciding by accident is not |
### 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 ·
MSE path (`stream_init/data/end`, `_probe_video` remux) · `_bundleKey` in IndexedDB +
`_sessionKeys` in sessionStorage + `_pkFromSk`.
**Kept regardless:** WebRTC/aiortc 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. Users can manage groups, members, and node state from the
command line. More important once native clients exist, since group and GEK
management moves out of the browser.
### Milestones
| # | Component | Description |
|---|---|---|
| 14.1 | `meshbay-node status` | Show daemon state: groups, peers, connected members, uptime |
| 14.2 | `meshbay-node group list` | List configured groups with online status |
| 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` | Wrap GEK for new member, store bundle in the local BundleStore (**not** the hub — bundles have been P2P since Phase 12) |
| 14.6 | `meshbay-node member remove` | Rotate GEK, re-wrap for remaining members, store locally |
| 14.7 | `meshbay-node member list` | List group members with online status |
| 14.8 | Config reload (SIGHUP) | Daemon reloads config and adds/removes groups without restart |
| 14.9 | `meshbay-node admin-key` | Pin the operator's **client** Ed25519 key as `admin_pk_ed25519` — fixes M3, where auto-pinning the node keystore key makes operator deletion impossible |
| 14.10 | `meshbay-node denylist` | Inspect and clear the persisted revocation denylist (11.5.17) |
### Architecture
CLI commands communicate with the running daemon via a local Unix socket
(`/run/meshbay-node.sock`, mode 0600, owner-only). The daemon exposes a small internal API
for status queries and management operations. If the daemon is not running,
commands that require it fail with a clear error.
---
## Phase 15 — Chat encryption (Sender Keys) + retention
> 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 — Decide the distribution channel FIRST (blocking sub-milestone)
`draft-v4` §6.6 says sender keys are distributed "via pairwise channels (GEK-wrapped or
direct)". **GEK-wrapped is the wrong choice** and must not be implemented: it makes every
sender key a function of the GEK, so anyone who holds the GEK — including an attacker who
obtained it via H3 key substitution, or a former member who kept it — recovers every sender
key. The encryption would then be decorative.
Distribution must be **pairwise to identity keys**: wrap each sender key with ECIES to the
recipient's `pk_x25519` (the existing `wrap_gek_aes` primitive), or run the existing
`ratchet.py` Double Ratchet per member pair. Decide and record before writing 15.1.
### Honest threat delta (state this in the docs, not just here)
Sender Keys protects chat against **someone who holds the node's disk but is not a group
member** — a seized machine, a hosting provider, a compromised node. It does **not** protect
chat from the node operator, because on this platform the operator is a group member and
therefore a legitimate sender-key recipient. Claiming more than that would repeat the
overstatement pattern `second-review.md` §7 flags.
### Milestones
| # | Component | Description |
|---|---|---|
| 15.0 | **Distribution decision** | Pairwise-to-identity-key, never GEK-derived. Blocking |
| 15.1 | Node: sender key init | Generate sender key on group join, distribute to members |
| 15.2 | Node: encrypt chat on send | Encrypt payload with sender's chain key before broadcast |
| 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order |
| 15.4 | Key rotation on member removal | Admin removes member → all remaining members rotate keys |
| 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore |
| 15.6 | MNP version negotiation | Handshake declares supported version range, not just a single `v` field (L2 — today `v` is sent by everyone and checked by no one) |
| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly |
---
## Phase 16 — Android client MVP
> Was Phase 14 before the 2026-08-13 renumbering.
> **Shares the Phase 13 design:** local keystore, Ed25519 client auth, no keypair bundles,
> aiortc-equivalent WebRTC for traversal (Android has a native WebRTC stack — prefer it over
> `punch_nat`, for the same reason the desktop client does). Do not re-derive a second
> crypto or auth model here.
**Objective:** Android app for account creation, group browsing, file download,
chat. No node functionality on mobile (client-only).
**Stack:** Kotlin native + Jetpack Compose. QUIC via `quiche` (Cloudflare, Rust
JNI binding). Crypto via Bouncy Castle JVM. Same NAT traversal as desktop native
clients (`punch_nat` + QUIC).
| # | Component | Tech | Priority |
|---|---|---|---|
| 14.1 | Hub client (auth, groups, GEK) | Kotlin + Retrofit | High |
| 14.2 | Crypto (Ed25519, X25519, ChaCha20) | Bouncy Castle JVM | High |
| 14.3 | QUIC client | quiche (Rust JNI) | High |
| 14.4 | NAT traversal (STUN + punch) | Kotlin native UDP | High |
| 14.5 | File browser + download | Kotlin + streaming IO | High |
| 14.6 | Chat UI | Jetpack Compose | Medium |
| 14.7 | Contact list integration | Android Contacts API (permission-gated) | Medium |
| 14.8 | Account creation from app | Registration flow + keypair bundle | High |
**Cross-device compatibility:** the user may switch between web and Android.
The `keypair_bundle` (encrypted, stored on hub) enables this — same credentials,
same keys on both platforms. Notification state and read markers should sync
via hub (small encrypted blob per user, minimal storage).
**Upload from mobile:** posting photos/videos to a group. The mobile uploads to
the group's node(s), not to the hub. The node stores it. MNP protocol extended
with an `upload` message type for client→node push.
**Out of scope:** node functionality on mobile, Mac/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 (Platform adapter split) ← free refactor, unblocks every D2 option
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–13.11 (Desktop client) ← DECIDED: 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 (platform adapter split) 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.
**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**, UI assets shipped inside the package and loaded from
disk — never fetched from the hub, or T3 is not fixed. ✅ (2026-08-13)
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)
20. **Sender keys are distributed pairwise to identity keys, never derived from or wrapped
under the GEK.** ✅ (2026-08-13)
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)
|