diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/USERGUIDE.md | 70 | ||||
| -rw-r--r-- | docs/meshbay-draft-v5.md | 129 |
2 files changed, 129 insertions, 70 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index ccbf112..f5c10b8 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -189,59 +189,49 @@ Save `group_id` — you will need it in your `node.toml` and when adding members | Join | Open / approval-gated | By invitation only | | GEK | Not applicable | Required | -For private groups, the group admin generates a Group Encryption Key (GEK) — a random 32-byte ChaCha20-Poly1305 key. The GEK is never sent over the wire in cleartext. Instead, the admin wraps a copy of it for each member using that member's X25519 public key and stores the opaque bundle on the hub. +For private groups the node holds a Group Encryption Key (GEK) — a random 32-byte key that is never sent over the wire in cleartext. Each member receives a copy wrapped for their own X25519 public key (ECIES: X25519 + HKDF + AEAD). + +**The node does the wrapping, and it never asks the hub for anybody's key.** That matters: the hub is the account directory, so a hub that answered a key lookup with its own key would be handed the group key by an honest member following the protocol exactly (finding H3). Instead the recipient presents their own public keys over the authenticated P2P channel, signed by their identity key, and the node wraps for what it just verified. ### Add a member to a private group -The group admin fetches the new member's X25519 public key from the hub, wraps the GEK for them, and uploads the bundle: +The node operator issues a one-time code, from the server or from their browser: -```python -import base64, httpx -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes, serialization -import os +```bash +# On the node, over SSH — no browser needed +meshbay-node member invite bob + +INVITATION CODE R3H8-TB6V +valid until 2026-08-21T12:00:00+00:00 +``` -HUB = "https://meshbay.org" -GROUP_ID = "10484cb7-e45a-4cdc-8b06-f8ccdacc04d2" -TOKEN = "alice_test-access-token" -GEK_RAW = bytes.fromhex("your-32-byte-gek-in-hex") # load from keystore +Send the code to Bob however you already talk to him — it never passes through the hub, which is what stops the hub from claiming to be Bob. He enters it the first time he opens the group, and the node then wraps the group key for the key he proved he holds. -headers = {"Authorization": f"Bearer {TOKEN}"} +After that first time the pin is his credential: he is recognised on every later connection, and asked for nothing. You do not need to be online when he joins. -# 1. Fetch new member's X25519 public key -r = httpx.get(f"{HUB}/v1/users/bob_test/pubkeys", headers=headers) -r.raise_for_status() -pk_member_raw = base64.b64decode(r.json()["pk_x25519"]) +| | | +|---|---| +| Code lifetime | 7 days (`[node] invite_ttl_hours`) | +| Reuse | Single use; re-inviting supersedes the previous code | +| If it expires | Issue another one — nothing else is affected | +| Wrong code, repeatedly | Bounded per connection and node-wide, and logged in the node's audit log | -# 2. Wrap GEK for them (ECIES-like) -sk_eph = X25519PrivateKey.generate() -pk_eph_raw = sk_eph.public_key().public_bytes( - serialization.Encoding.Raw, serialization.PublicFormat.Raw) -shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_member_raw)) -wrap_key = HKDF(algorithm=hashes.SHA256(), length=32, - salt=pk_eph_raw, info=b"meshbay:gek_wrap:v1").derive(shared) -nonce = os.urandom(12) -wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, GEK_RAW, pk_member_raw) +The same operation is available in the web app: the group's **Members** tab, if your browser is paired with the node (`meshbay-node operator pair`). -# 3. Upload the opaque bundle to the hub -bundle = { - "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(), - "nonce_b64": base64.b64encode(nonce).decode(), - "wrapped_b64": base64.b64encode(wrapped).decode(), -} -r = httpx.post(f"{HUB}/v1/groups/{GROUP_ID}/members/bob_test/gek", - json=bundle, headers=headers) -r.raise_for_status() -print("Bundle stored. bob_test can now access the group.") +### Removing a member + +```bash +meshbay-node member revoke bob +meshbay-node gek-init # rotate: Bob still holds the old key ``` -The hub stores the bundle as an opaque blob. It cannot decrypt it — it stores `pk_eph`, `nonce`, and `wrapped` as separate columns but has no key to derive `wrap_key`. +Revoking stops the node serving Bob the key from his next connection onward — there is no stored bundle left behind that could outlive the decision. It does **not** take back the key he already has, which is why the second command exists. + +### What revocation does and does not do -### Member revocation +Rotating the GEK (`meshbay-node gek-init`) makes the node encrypt new content with a new key, which every remaining member picks up automatically on their next connection — nothing has to be re-uploaded or re-wrapped by hand. -To revoke a member: generate a new GEK, re-encrypt it for all remaining members, and upload the new bundles. The node begins encrypting new content with the new GEK from that point. Former members can still decrypt previously received content (no retroactive re-encryption). +A former member can still decrypt content they already received: there is no retroactive re-encryption, and there is no way to reach into someone's disk. Revocation controls what happens next, not what already happened. --- diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 8035824..b4a9e45 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -1,6 +1,8 @@ # MeshBay — Architecture Draft v5 -> Status: **Phase 11.5 (security remediation) complete.** See `devel-phases-next.md`. +> Status: **Phase 11.5 (security remediation) complete. Invite redesign complete +> (2026-08-14) — H3 and M3 closed.** See `devel-phases-next.md` and, for the invite +> and pairing design, `docs/invite-pairing-v1.md`. > Supersedes `meshbay-draft-v4.md`. Sections not restated here are unchanged from v4. > > v5 exists because the second security review (`second-review.md`, 2026-08-13) found @@ -19,7 +21,7 @@ v4 described intent. Several of its statements were contradicted by the implemen | "ALL operations require passing the GEK proof first" (§4.2.x) | True on WebRTC only. QUIC, TCP and the node HTTP API each had their own weaker handshake | | "The node NEVER serves the GEK in plaintext" | True, but the node's HTTP API served the *decrypted files themselves* with no authentication at all | | "Argon2id parameters updated: memory_cost 262144" | Applied to the hub's password verifier only; the node keystore was still at 64 MB | -| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub is the key directory, so it can substitute a key at invite time and be handed the GEK legitimately | +| "GEK bundles: opaque blobs; hub cannot decrypt them" | True, and irrelevant — the hub was the key directory, so it could substitute a key at invite time and be handed the GEK legitimately *(fixed 2026-08-14; the invite path no longer reads the directory — §5.5)* | | "hub stores no content metadata" | The node registered blake3 hashes of **private** files with the hub | So v5 carries an explicit rule: **a claim in this document must name the adversary it @@ -38,7 +40,8 @@ written that way. | 4 | Handshake | **Mutual authentication**: the node proves GEK possession and signs the transcript. Authentication used to run one way only | 11.5.7, finding C3 | | 5 | Handshake | Transcripts are length-prefixed and domain-separated; `group_id` is mandatory; missing channel binding is refused | L4, M1 | | 6 | Admin ops | Destructive operations sign a structured transcript naming operation, node, group, subject, nonce and time — not 32 opaque random bytes | Finding H5 | -| 7 | Node authority | GEK bundle storage requires the node operator's signature; **nothing arriving over MNP can activate a GEK** | Finding C5b | +| 7 | Node authority | **Nothing arriving over MNP can activate a GEK.** Member-supplied bundles are not gated but *gone*: `gek_bundle_store` was deleted when the node took over wrapping | Finding C5b | +| 7b | Admission | **The node wraps the group key itself**, for a key the recipient proved possession of, and admits people from its own roster using one-time codes the hub never sees (§5.5) | Findings **H3**, **M3** | | 8 | Uploads | Confined to a per-user quarantine directory; no overwrite | Finding C5a | | 9 | Node identity | Hub verifies node ownership on WebSocket registration; group claims are derived from the database | Finding C2 | | 10 | Revocation | Group revocations are honoured by nodes; denylist persists across restarts | Finding H4 | @@ -54,31 +57,39 @@ This replaces the informal assurances scattered through v4 §4.2.x and §13. | Claim | Passive hub | **Active hub** | Malicious node operator | Malicious group member | Network attacker | |---|---|---|---|---|---| -| File content is unreadable | ✅ | ❌ **H3** | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ | -| Chat content is unreadable | ✅ | ❌ H3 | ❌ plaintext at rest until Phase 15 | ❌ | ✅ | -| File index is unreadable | ✅ | ❌ H3 | ❌ | ❌ | ✅ | +| File content is unreadable | ✅ | ❌ **T3** (browser) · ✅ native | ❌ by design — the operator hosts the files | ❌ members share the GEK | ✅ | +| Chat content is unreadable | ✅ | ❌ T3 · ✅ native | ❌ plaintext at rest until Phase 15 | ❌ | ✅ | +| File index is unreadable | ✅ | ❌ T3 · ✅ native | ❌ | ❌ | ✅ | | Content cannot be modified | ✅ | ✅ | ❌ by design | ✅ | ✅ | | Node cannot be impersonated | ✅ | ✅ | — | ✅ | ✅ | | Client code integrity | ❌ **T3, accepted** | ❌ T3 | ✅ | ✅ | ✅ | | Node content authority | ✅ | ✅ | ✅ sovereign | ✅ | ✅ | +| Hub cannot obtain the group key | ✅ | ✅ **since H3 closed** — except in `join_policy = "open"` groups, where it can join legitimately | — | — | ✅ | -**The claim this project can make:** *the hub cannot read your content unless it actively -attacks you.* That is true, defensible, and stronger than most platforms offer. +**The claim this project can make:** *the hub cannot read your content unless it ships you +malicious client code.* Since H3 closed (2026-08-14) that is the **only** remaining path, +it is not silent, and it does not exist for a native client. -**The claim it must not make:** *"everything is encrypted and unreadable by other parties, -even the hub."* Two reasons, both deliberate: +**What changed.** The hub used to be the key directory on the invite path: the inviter +fetched the invitee's `pk_x25519` from the hub and wrapped the GEK for whatever came back, +so a hub answering with its own key was handed the group key by an honest member following +the protocol exactly. That path is gone. The node holds the GEK and wraps it itself, for a +key the recipient proved possession of over an authenticated channel, bound to an identity +the operator admitted with a one-time code the hub never sees (§5.5). -- **H3 — key substitution.** 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 returning its own key is handed the group key. No forgery, no code - injection, undetectable by the client today. Fixed only by key transparency and safety - numbers (Phase 12.1); open until then. -- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). - A hub that ships the code can lift keys from the page regardless of protocol design. +**The claim it must not make:** *"everything is encrypted and unreadable by other parties, +even the hub."* Three reasons, all deliberate: -Content is also readable by **every group member** and by **the node operator**, who -stores it in plaintext on disk. That is inherent to the model, not a defect — but it -means "end-to-end" here describes *client ↔ node*, never *client ↔ client*. +- **T3 — the hub serves the SPA.** Accepted permanently for browser users (decision D1). A + hub that ships the code can lift keys from the page regardless of protocol design — and + can read a pairing code out of it. This is an artifact-level attack, not a silent + directory lie: the native client (Phase 13) plus reproducible builds (18.7) remove it. +- **Open-join groups.** Where `join_policy = "open"`, anyone the hub calls a member is + served the key, so the hub can join through the front door. That is a property of open + joining, not a defect, and the setting is read from `node.toml` — the hub cannot declare + a group open. +- **Members and the operator read everything.** Inherent: "end-to-end" here describes + *client ↔ node*, never *client ↔ client*. --- @@ -187,14 +198,22 @@ was 32 opaque random bytes signed blind — an unbound signing oracle. | Operation | Authority | |---|---| | `file_delete` | Node operator, or the user who uploaded the file (verified by the key recorded at upload) | -| `gek_bundle_store` | **Node operator only** | +| `invite_create` | **Node operator only** (delegation designed, deferred) | | GEK activation | **Local admin UI or CLI only** — no MNP message can activate a GEK | +| ~~`gek_bundle_store`~~ | **Message deleted.** No member ever hands the node key material | `gek_bundle_store` previously accepted any member's bundle and auto-activated one addressed to the operator. Since the operator's X25519 public key is published in `handshake_ack`, any member could wrap a key of their choosing for it and take over the -group. **Consequence of the fix:** a group admin who does not run the node can no longer -invite members — the operator decides what is stored on their machine. +group. It was first gated behind an operator signature, then removed outright when the +node took over wrapping (§5.5) — the message has no remaining purpose, and a path that +does not exist cannot be mis-authorized. + +**Who can invite.** Still the node operator only: `invite_create` is verified against the +operator keys in the node's roster. A group admin who does not run the node cannot admit +anyone until delegation ships (§6.2 of `invite-pairing-v1.md`, deferred by decision +2026-08-13). What *did* change is the timing: the operator issues a code and is then out +of the loop, so nobody has to be online when the invitee actually joins. ### 5.2 Uploads @@ -210,22 +229,65 @@ Two personas, and they need different tools: | Operator | Reaches the node via | |---|---| | Desktop | the local admin UI in their own browser | -| **Headless / SSH** | the CLI — `meshbay-node status`, `ui`, `gek-init` | +| **Headless / SSH** | the CLI — `status`, `ui`, `gek-init`, `operator pair`, `member list\|invite\|revoke\|unpin` | The CLI is the primary interface for servers, which is the normal deployment. `status` deliberately reads the keystore and config directly so it works while the daemon is stopped — the state an operator is most often in, since the daemon will not stay up before its key is linked or before a group exists. Anything the -UI can do should eventually have a CLI equivalent (Phase 14); until then a -headless operator still needs a browser to invite members or delete files. +UI can do should eventually have a CLI equivalent (Phase 14). Inviting, revoking and +pairing now have one; deleting a file is the remaining browser-only operation. ### 5.4 Local admin UI Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at startup. "Localhost only" is not authentication: any local process can reach it, as can a page in the operator's browser via DNS rebinding — and this API re-initialises group -keys and reads the audit log. All interpolated values are HTML-escaped; CSP contains -exfiltration but cannot prevent injected inline script, so escaping is the actual fix. +keys, issues invitations and reads the audit log. All interpolated values are +HTML-escaped — filenames chosen by members, and usernames that originate at the hub; +CSP contains exfiltration but cannot prevent injected inline script, so escaping is the +actual fix. + +### 5.5 Admission and key delivery (closes H3) + +The node decides who receives the group key, and produces every copy of it itself. + +``` +grenet (SSH) meshbay-node member invite bob → CODE R3H8-TB6V + (or the same from the SPA's Members tab, signed by the paired browser) +grenet sends the code to bob out of band +bob opens the group; the client holds no GEK +bob → node join_request {pk_ed25519, pk_x25519, code, sig} ← pre-proof window +node code valid for this account → pin the identity, admit to the group +node → bob the GEK, wrapped for the X25519 key bob just proved he holds +``` + +Four properties, each load-bearing: + +1. **No public key is ever fetched from the directory.** The invitee's key arrives from + the invitee, signed by their identity key in the same transcript (`meshbay:join:v1`), + so the identity key vouches for the encryption key. This is the H3 fix. +2. **The code binds the key to the account**, and the hub never sees it. 40 bits, + single-use, one account, node-wide lockout on repeated failures. Codes live 7 days + for invitations and 24 h for operator pairing, both configurable. +3. **The node's roster is the authority**, not hub membership. A hub that invents an + account, adds it to a group and mints it a token gets `not_authorized_for_group`. + Exception by configuration: `join_policy = "open"` pins the first comer (TOFU) and + serves them — read from `node.toml`, never from the hub. +4. **Wrapping happens on every connection**, so key rotation propagates by itself and + revocation actually takes effect. No member bundle is stored; nothing outlives the + decision to stop serving someone. (Rotating the GEK after a revocation is still + required — the ex-member holds the old one.) + +`gek-init` no longer pre-wraps the key for anyone but the node's own keystore. It used to +fetch every member's public key from the hub and wrap for what came back, which was H3 +with the node as the victim rather than the inviter. + +Node authority is established the same way, once per node: `meshbay-node operator pair` +prints a code, the operator types it into their browser, and the node pins that identity +key. It is never learned from the hub — a hub able to name the operator's key could +install itself as node administrator (finding M3, which the old keystore auto-pin left +inert and failing closed). --- @@ -248,6 +310,11 @@ private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling. was posted and by whom (`chat_notify` — minimization was in the dropped phase), and content hashes for **public** groups only. +**No longer decides:** who holds the group key. The hub still publishes public keys, and +they are still used to look up an account by name — but nothing wraps a key for a key +that came from there. Hub membership lets someone *reach* a node; the node's roster +decides whether it wraps anything for them (§5.5). A pairing code never touches the hub. + ### 6.2 Node registration Registration on `/v1/nodes/ws` requires a node-scoped token, verifies `Node.user_id` @@ -335,16 +402,18 @@ format. | 11.5.6 | QUIC channel-binding anchor | Certificate hash. Server reads its own certificate from disk; the client's access to the peer certificate is guarded and fails loudly if aioquic moves it. **A resumed TLS session carries no certificate**, so the anchor travels with the session ticket — sound, because the ticket is derived from the handshake where the certificate was presented | | 11.5.8 | `pk_node` TOFU pinning | Pinned per `node_id` on first handshake; a change is **refused**, with a deliberate reset in Settings | | **M8** | Node announce proof-of-possession | Announcer signs `meshbay:node_announce:{user_id}:{pk_node}:{timestamp}`; re-announcing updates in place | +| **H3** | Hub key substitution | **Closed 2026-08-14** by the invite redesign (§5.5), not by safety numbers: no public key is fetched from the directory at all, so there is nothing to substitute. See `docs/invite-pairing-v1.md` | +| **M3** | Node authority inert as shipped | Closed with it — authority comes from the roster, established locally by pairing | ### Still open | # | Item | Status | |---|---|---| -| **H3** | Hub key substitution | **Open** — the last unfixed finding. The hub is the public-key directory, so it can be handed the GEK at invite time. Fixed by Phase 12.1 safety numbers | | C4 | Remote keypair bundles | Bounded (4 pre-proof fetches, audited); closes in Phase 13.3 when the native client stops storing them remotely | | — | Chat encryption (Sender Keys) | Phase 15. Distribution must be **pairwise to identity keys**, never GEK-derived | | — | Chat attachments | Plaintext on the node; decide in Phase 15.7 | -| **T3** | Hub serves the SPA | **Accepted permanently** (D1) | +| — | Delegation | Designed, deferred: a group admin who does not run the node still cannot invite | +| **T3** | Hub serves the SPA | **Accepted permanently** (D1). Now the *only* way an active hub reads content — and it can also read a pairing code out of the page it served. Removed for native clients by Phase 13 + reproducible builds (18.7) | ### Scope note on pinning |