aboutsummaryrefslogtreecommitdiffstats
path: root/devel-phases-next.md
diff options
context:
space:
mode:
Diffstat (limited to 'devel-phases-next.md')
-rw-r--r--devel-phases-next.md167
1 files changed, 119 insertions, 48 deletions
diff --git a/devel-phases-next.md b/devel-phases-next.md
index 04d7b9b..db9267d 100644
--- a/devel-phases-next.md
+++ b/devel-phases-next.md
@@ -1,7 +1,7 @@
# 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
+> Architecture reference: **docs/meshbay-draft-v5.md** (v4 superseded 2026-08-13)
> First security review: first-review.md (2026-08-10)
> **Second security review: second-review.md (2026-08-13) — 6 critical, 7 high findings.**
>
@@ -11,7 +11,8 @@
> 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 (hub minimization), 13 (native desktop client).
+> New: 11.5 (security remediation), 12 (client key verification — reworked 2026-08-13,
+> hub minimization deferred by operator decision), 13 (native desktop client).
---
@@ -613,7 +614,45 @@ phase reduces the node to two paths and brings both to the same standard.
|---|---|---|---|
| 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 |
+| 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
@@ -693,44 +732,56 @@ cannot delete or overwrite another member's file, and cannot change the group ke
---
-## Phase 12 — Hub minimization: registrar and nothing more
+## Phase 12 — Client key verification + served-SPA integrity
-**Objective:** reduce the hub to its legitimate role and make that reduction *structural*
-rather than a matter of good behaviour. The hub must not be able to see private keys,
-unencrypted content, or file listings — not "does not currently", but "cannot".
+> **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.
-### What the hub is allowed to know
+**Objective:** make the hub's remaining power over confidentiality *detectable*, given
+that it stays in the trusted path by choice.
-| Category | Allowed | Notes |
-|---|---|---|
-| Account: username, encrypted email, public keys, status, role | ✅ | Required to be a registrar |
-| Group registry: id, admin, visibility, join policy, membership | ✅ | Required to issue the `groups` claim |
-| Public group name + description | ✅ | Required for discovery |
-| IP logs | ✅ | Legal retention, 1 year |
-| Signaling relay (SDP/ICE, in-memory, seconds) | ✅ | Never persisted |
-| **Private keys, keypair bundles, GEK bundles** | ❌ | Removed in Phase 12 (old); 13.3 removes the last copies |
-| **File content, file names, file hashes, index** | ❌ | H7 was leaking hashes; 11.5.18 closes it |
-| **Message content or per-message metadata** | ❌ | `chat_notify` currently leaks it — 12.3 |
-| **Private group name / description** | ❌ (target) | 12.5 |
+### 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 `docs/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 | Route inventory + blindness test | Enumerate every hub route; assert no response body can contain key material, content, a file name or a content hash. Runs in CI, fails the build on regression |
-| 12.2 | **Key transparency + safety numbers** [H3] | Append-only, hub-signed key log; clients pin the key they first saw and audit the log; key change raises a blocking warning; safety-number comparison UI between two members. This is the fix for the last structural way a hub can read content |
-| 12.3 | Chat metadata minimization | `chat_notify` (`webrtc_server.py:634-644` → `revocation.py:101-128`) currently tells the hub *who* posted in *which* group and *when*. Drop `sender_name`, make notification opt-in per group, coalesce and delay to blunt timing correlation |
-| 12.4 | Swarm hardening | Enforce 11.5.18 at the API layer too: reject registration for a group the hub knows is private; authenticate `GET /v1/swarm/{hash}` |
-| 12.5 | Opaque private-group metadata | For `visibility == "private"`, store name/description as a member-encrypted blob; the hub holds an opaque value and an id. Public groups unchanged (discovery needs plaintext) |
-| 12.6 | SPA integrity + honest labelling | Strict CSP, SRI on the bundle, hub publishes a signed digest of the served bundle that native clients and extensions can verify; `/app/` carries an explicit "reduced trust — this hub serves this code" notice |
-| 12.7 | Remove dead crypto plumbing | Drop residual columns/migrations/constants from the pre-Phase-12 GEK era so the schema cannot be quietly repopulated |
-| 12.8 | 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. Referenced from draft-v5 |
+| 12.1 | ~~Key transparency + safety numbers~~ [H3] | ✅ **DONE 2026-08-14**, by a different design — see above and `docs/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 |
-**Acceptance criteria:** a hub operator holding root on the server, the full PostgreSQL
-database, the Ed25519 signing key, and the ability to forge any JWT can obtain: no private
-key, no GEK, no file content, no file name, no content hash, no message content, and no
-private group name. Every remaining capability is on the list above and is documented in
-12.8. Any attempt to substitute a public key is detectable by clients via 12.2.
+**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.
---
@@ -790,7 +841,7 @@ used for the API only, and the bundle is covered by 13.9 signing.
|---|---|---|
| 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.3 | Local keystore + Ed25519 client auth — **the real close for C4**, now partially mitigated by Argon2id (draft-v5 §7.1) | 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`** |
@@ -817,31 +868,52 @@ These carry NAT traversal and are not browser workarounds.
> 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.
+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` | Show daemon state: groups, peers, connected members, uptime |
+| 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` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser |
+| 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` | 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.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) | 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.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` | Inspect and clear the persisted revocation denylist (11.5.17) |
+| 14.11 | `meshbay-node file rm` | The one operator action still requiring a browser |
### 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.
+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.
---
@@ -1002,11 +1074,10 @@ community developers. Core functionality must be complete and stable first.
```
Phase 11.5 (Security remediation) ⛔ BLOCKING — nothing else starts
Phase 13.1 (Platform adapter split) ← free refactor, unblocks every D2 option
-Phase 12 (Hub minimization) ← makes "the hub cannot read" structural
+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
-── decision point D2: extension / native / both ──
-Phase 13.2–13.11 (Desktop client) ← product-driven; needs 18.7 for the security claim
+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