diff options
| -rw-r--r-- | CLAUDE.md | 7 | ||||
| -rw-r--r-- | docs/QUICKSTART.md | 18 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 291 | ||||
| -rw-r--r-- | docs/meshbay-draft-v5.md | 40 |
4 files changed, 232 insertions, 124 deletions
@@ -245,6 +245,13 @@ keypair bundle, or anything that looks like a user's public key. the task for good. Symptom is always the same — daemon running, logging nothing, `connected_nodes: 0`, socket in CLOSE-WAIT. Look there first +- **`create_all()` is not a migration.** It creates missing *tables* and never a + missing *column*, so a new column reaches the tests (fresh DB every run) and never + reaches the deployed hub. Symptom: one endpoint answering 500 with an HTML body + while everything else works, and a `psycopg` `UndefinedColumn` in the journal. + `deploy-hub.sh` runs `alembic upgrade head` before restarting the service; a schema + change that skips a migration file will still pass every test you have + **Corrections to remember:** - `punch_nat()` is **not** a NAT traversal stack — one UDP probe, no STUN, no candidate gathering, one ISP validated. **ICE/STUN (WebRTC) is the traversal path**, for native diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index ca3beee..2d59f1f 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -106,22 +106,24 @@ Fichiers disponibles : 1 fichier(s) indexé(s) -✓ Node actif sur http://127.0.0.1:19001 - Info: http://localhost:19001/ - Index: http://localhost:19001/index +✓ Node actif — MNP sur le port 19001 + Admin UI : meshbay-node ui (boucle locale, jeton requis) CTRL+C pour arrêter. ``` Vérification rapide dans un autre terminal : ```bash -curl http://localhost:19001/ -# {"node_version":"0.1.0","file_count":1,"group_name":"demo-group",...} - -curl http://localhost:19001/index -# {"entries":[{"name":"README.txt","size":93,...}],...} +meshbay-node status +# état du node, clés, groupes configurés, fichiers indexés ``` +> **Le node n'expose aucune API HTTP publique.** Les endpoints `/`, `/index` et +> `/file/{id}` ont été supprimés en 0.2.0 (findings C1 et C6) : ils servaient l'index +> et les fichiers en dehors du handshake qui décide de ce qu'un pair a le droit de +> voir. Le port 19001 est le listener MNP, pas un serveur web. La seule surface HTTP +> est l'UI d'administration, sur la boucle locale et protégée par un jeton. + **Ajouter vos propres fichiers :** ```bash cp ~/Videos/ma_video.mp4 QE/demo-v1/shared/ diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 71b0060..a8991b1 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -141,14 +141,59 @@ The JWT payload contains: |---|---| | `iss` | Hub ID (`meshbay.org`) | | `sub` | Your `user_id` (UUID4) | -| `pk_user` | Your Ed25519 public key (base64) | | `hub_id` | `meshbay.org` | | `jti` | UUID4 — unique per token, enables revocation, prevents replay | +| `groups` | The `group_id`s you are a member of, for node-side authorization | +| `scope` | `user` for a browser, `node` for a daemon | | `iat` | Issued at (Unix timestamp) | | `exp` | Expires at (Unix timestamp, 1 hour from issue) | +The token carries **no public key of yours**. It used to carry `pk_user`, and a node +recorded that key as the uploader of a file — which meant the party issuing tokens +decided who was allowed to delete it. The hub certifies *accounts*; keys are generated +on each node and pinned there (§4). + Nodes verify this JWT locally using the hub's cached Ed25519 public key. No hub roundtrip is needed — verified at 884µs in testing. This means your files remain accessible even if the hub is temporarily unreachable. +### Deleting your account + +**Settings → Delete account.** You re-enter your passphrase: a live session may be a +borrowed laptop or a tab left open, and this cannot be undone. A hub administrator can +also delete an account, from Administration → Users. + +What deletion does: + +- Releases the username — someone else may register it afterwards +- Clears the email and password hash, and drops the node linking key +- Removes group memberships, notifications and refresh tokens +- Refuses any access token still in its hour of validity, immediately + +What deletion does **not** do: + +- **It does not touch anything on a node.** Your files stay where you uploaded them, and + so do the identity pinned in the node's roster and the keypair bundle it holds for you. + Nodes are other people's machines; the hub cannot command them. To be removed there, + ask the operator — `meshbay-node member unpin <user>` and deleting your files are + their commands to run (§4). +- **It does not erase the connection log.** IP records are kept for their legal retention + period and stay attributable; that is what they exist for. + +Deletion is refused while you still own a group. Hand the group over or delete it first — +otherwise its members would be stranded. The error names the groups blocking you. + +``` +DELETE /v1/users/me +Authorization: Bearer <access_token> +{"auth_key": "<derived from your passphrase, as at login>"} +→ 200 {"status": "deleted", "username": "alice_test"} +→ 403 {"detail": "Passphrase does not match"} +→ 409 {"detail": "This account still owns groups: ..."} +``` + +Node registrations are removed as well, so a deleted operator's nodes stop being +announced. The daemons keep running and keep their data — again, the hub does not +command them. + --- ## 3. Groups @@ -226,6 +271,35 @@ Rotating the GEK (`meshbay-node gek-init`) makes the node encrypt new content wi 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. +### Notifications + +The bell in the top bar counts what you have not read. Clicking an entry takes you to +what it is about and dismisses it. + +- **Chat is one entry per group, not one per message.** A conversation that has been busy + all afternoon is a single line whose date moves to the last thing said and which turns + unread again each time. Opening the group clears it. +- **You are never notified of your own messages.** The node names the author when it + tells the hub a message was posted, and the hub skips them. +- **An invitation disappears once you have joined**, i.e. after you enter the pairing + code — not when you first look at it. +- **Muting a group works from anywhere.** The setting lives on the hub with your + membership, so a muted group creates no notification at all rather than hiding one + after the fact. It follows you to another browser. (It used to be a checkbox in the + browser's local storage that nothing read, so it did nothing.) +- **Clear all** empties the list in one action. + +``` +GET /v1/notifications → {"notifications": [{id, kind, group_id, title, link, read, created_at}], "unread": 3} +POST /v1/notifications/{id}/read → mark one read +POST /v1/notifications/read-all → mark every one read +DELETE /v1/notifications → delete them all +POST /v1/groups/{group_id}/mute {"muted": true} +``` + +`GET /v1/groups/mine` reports `muted` for each group, so the browser shows the checkbox +in the state the hub actually holds. + --- ## 4. Setting up a Node @@ -386,6 +460,25 @@ The node detects type by file extension and MIME sniffing: | `archive` | zip, tar, gz, bz2, xz, 7z | | `other` | everything else | +### Where uploaded files land + +Everything a member sends arrives in **`shared_dir/uploads/`** — both files uploaded from +the Files panel and attachments sent in the chat. One visible directory, so an operator can +look at what was sent, move it, or empty it without hunting through the tree. + +- Filenames are checked against a conservative allowlist and nothing is ever overwritten: + a colliding name gets a suffix, and the sender is told the name it was stored under. +- Chat thumbnails are scaled by the browser from the file itself. The node writes no + derived images, so nothing accumulates beside your files. +- Uploads are attributed to the identity the node pinned for that member, and that is what + decides who may delete the file later — not anything the hub says. + +### Creating a directory + +Any active member can create a directory from the Files panel (**New folder**). It is +created relative to the folder you are looking at, under `shared_dir`, and the same name +rules apply. Paths that try to leave the shared root are refused. + ### Files are stored in plaintext on disk The node holds your files in plaintext. Encryption happens at read time — the node encrypts each 1 MB chunk using a per-chunk key derived from the GEK before sending it over the wire. This means: @@ -398,134 +491,84 @@ The node holds your files in plaintext. Encryption happens at read time — the ## 6. Accessing Files -### Browse the index +**There is no HTTP file API.** Files are requested over MNP — the node's authenticated +message channel, carried by WebRTC DataChannel or QUIC — and nothing on the node answers +an unauthenticated request. The `GET /index`, `GET /file/{id}` and `GET /stream/...` +endpoints documented before 0.2.0 were removed (findings C1 and C6): they served the index +and file bytes to anyone holding a token, outside the handshake that decides what a peer +is allowed to see. Port 19001 is the MNP listener, not a web server. -``` -GET /index -Authorization: Bearer <access_token> - -→ [ - {"id": "blake3hash", "name": "filename", "size": 1234, "type": "video"}, - ... - ] -``` +The node's only HTTP surface is its admin UI, bound to loopback and requiring a token +(§4). It is for the operator, on the machine, over SSH. -```bash -curl -s -H "Authorization: Bearer $TOKEN" http://meshbay.org:19001/index | python3 -m json.tool -``` +### Browse the index -### Download a full file +After the handshake, ask for the index: ``` -GET /file/{file_id} -Authorization: Bearer <access_token> - -→ raw file bytes (Content-Type set by file type) +→ {"type": "index_sync", "v": "0.1"} +← {"type": "index_sync", "entries": [{"id": "<blake3 hex>", "name": "...", "size": 1234, + "type": "video", "path": "uploads/"}, ...]} ``` -```bash -curl -s -H "Authorization: Bearer $TOKEN" \ - http://meshbay.org:19001/file/<file_id> \ - -o output.txt -``` +For a private group the index itself is encrypted with the GEK, so a peer that never +proved possession of the key is served nothing to read. -For private groups, the returned bytes are the concatenated encrypted chunks. You must decrypt them client-side using the GEK (see the Quickstart download script for the full decryption flow). - -### Download an encrypted chunk +### Download a file, chunk by chunk ``` -GET /file/{file_id}/{chunk_index} -Authorization: Bearer <access_token> - -→ { - "ct_b64": "base64 ChaCha20-Poly1305 ciphertext", - "nonce_b64": "base64 12-byte nonce", - "file_hash_b64": "base64 blake3 of full file (HKDF salt input)", - "chunk_index": 0, - "plaintext_size": 1048576 - } +→ {"type": "file_req", "v": "0.1", "file_id": "<blake3 hex>", "chunk_index": 0} +← {"type": "file_chunk", "file_id": ..., "chunk_index": 0, + "nonce": <bytes>, "ct": <bytes>, "plaintext_size": 1048576} ``` -Chunks are 1 MB. Chunk 0 is the first megabyte. For a 5 MB file, request chunks 0–4. +Chunks are 1 MB. Chunk 0 is the first megabyte; for a 5 MB file, request 0–4. -**Per-chunk key derivation:** +**Per-chunk key derivation** — each chunk has an independent key derived from the GEK and +the chunk's position, so a leaked chunk key opens exactly one chunk of one file, and a +player can seek without decrypting from the start: ```python chunk_key = HKDF( algorithm=hashes.SHA256(), length=32, salt=None, - info=b"file:" + file_hash_bytes + b":chunk:" + chunk_index.to_bytes(4, "big") + info=b"file:" + file_hash_bytes + b":chunk:" + chunk_index.to_bytes(4, "big"), ).derive(gek) plaintext = ChaCha20Poly1305(chunk_key).decrypt(nonce, ciphertext, None) ``` -Each chunk has an independent key derived from the GEK and the chunk's position. This enables seeking in media files without decrypting from the start. +The browser client derives the same key the same way but uses **AES-GCM**: WebCrypto has +no ChaCha20-Poly1305. The node picks the cipher from what the peer negotiated at +handshake; the key schedule above is identical in both. -### Node info endpoint +### Identifying the node -``` -GET / -→ { - "file_count": 2, - "pk_node": "base64 Ed25519 public key", - "group_id": "uuid" - } -``` - -The node's Ed25519 public key (`pk_node`) is also what nodes use for TLS certificate pinning. Clients retrieve it from the hub via `GET /v1/nodes/{node_id}` and validate the node's self-signed TLS cert against it. +The node's `pk_node` (Ed25519) comes from the hub — `GET /v1/nodes/{node_id}` — and the +client checks the handshake signature against it. A node that cannot sign the transcript +with the key the hub published for it is refused, so hub signaling can introduce you to a +node but cannot substitute one. --- ## 7. Video Streaming -The node generates HLS (HTTP Live Streaming) segments on the fly for video files. - -### HLS endpoint +Video is streamed over the same MNP channel and played through Media Source Extensions. +The node transcodes to fragmented MP4 on the fly and encrypts each segment exactly like a +file chunk, so a standard `<video src=...>` cannot play it — the segments are ciphertext +until the client decrypts them. ``` -GET /stream/{file_id}/index.m3u8 -Authorization: Bearer <access_token> - -→ M3U8 playlist with segment URLs +→ {"type": "stream_req", "v": "0.1", "file_id": "<blake3 hex>"} +← {"type": "stream_init", "codec": "video/mp4; codecs=\"avc1.640028,mp4a.40.2\"", + "duration": 5124.3} +← {"type": "stream_data", "segment_index": 0, "nonce": <bytes>, "ct": <bytes>} +← ... one message per segment ... +← {"type": "stream_end"} ``` -Each segment is a GEK-encrypted 1 MB chunk served as `application/octet-stream`. The browser-side player must decrypt segments before handing them to the media element. - -### Play in a browser - -Because segments are encrypted, a standard `<video>` tag cannot play them directly. Use the Media Source Extensions (MSE) API with a JavaScript decryption shim: - -```html -<video id="player" controls></video> - -<script type="module"> -import { MeshBayPlayer } from "https://meshbay.org/static/player.js"; - -const player = new MeshBayPlayer({ - element: document.getElementById("player"), - nodeUrl: "http://meshbay.org:19001", - fileId: "YOUR_FILE_ID", - gek: gek_bytes, // Uint8Array(32), obtained from GEK unwrap - token: access_token, -}); -player.load(); -</script> -``` - -`player.js` handles: -- Fetching the M3U8 playlist -- Requesting encrypted chunks on demand -- Deriving per-chunk keys from the GEK -- Decrypting with WebCrypto (AES-256-GCM — see §8 for cipher choice) -- Feeding plaintext segments to MSE - -### Seeking - -Because each chunk uses an independently derived key, seeking jumps directly to the target chunk without decrypting preceding chunks. The HLS playlist embeds the `chunk_index` for each segment so the player computes the correct key immediately. - -### Supported formats for streaming - -The node can serve HLS for any container it can segment at 1 MB boundaries: MP4, MKV, WebM. The browser must support the codec in the file. For broad compatibility, H.264 + AAC in MP4 is recommended. +Each `stream_data` segment is decrypted with the chunk key for its `segment_index` and +appended to a `SourceBuffer`. The web client does this in `static/app.js` (`VideoPlayer`); +ffmpeg must be installed on the node for transcoding. --- @@ -732,7 +775,8 @@ Usually a malformed request body. Check that: ## 11. API Reference -All hub endpoints are under `https://meshbay.org`. Node endpoints are under `http://<node-ip>:<port>`. +All hub endpoints are under `https://meshbay.org`. **Nodes have no public HTTP API** — +they speak MNP (§6), and their only HTTP surface is the operator's admin UI on loopback. ### Hub API @@ -750,7 +794,9 @@ All hub endpoints are under `https://meshbay.org`. Node endpoints are under `htt | POST | `/v1/users/register` | None | Register account. Body: `username, email, auth_key`. No keys — identity keypairs are per node. Returns `user_id`. | | POST | `/v1/users/login` | None | Authenticate. Body: `username, password`. Returns `access_token, refresh_token`. | | POST | `/v1/users/token/refresh` | Refresh token | Issue new access token. Body: `refresh_token`. | -| GET | `/v1/users/{username}/pubkeys` | Access token | Fetch `pk_ed25519` and `pk_x25519` for any user (used by group admins for GEK wrapping). | +| GET | `/v1/users/{username}/pubkeys` | Access token | Returns `user_id`, `username` and `pk_node_ed25519` only. It no longer returns identity keys: wrapping the group key for whatever this endpoint answered was finding H3, and the node wraps it now (§3). | +| DELETE | `/v1/users/me` | Access token | Delete your own account. Body: `auth_key` — the passphrase is re-checked. `409` if you still own groups. | +| DELETE | `/v1/admin/users/{user_id}` | Access token (hub admin) | Delete someone else's account. Same tombstone, same refusal if they own groups. | **Node management** @@ -768,12 +814,20 @@ All hub endpoints are under `https://meshbay.org`. Node endpoints are under `htt | GET | `/v1/groups/{group_id}` | None / Access token | Group metadata. | | DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke and delete group. | -**GEK distribution (private groups)** +**GEK distribution — removed.** The hub used to carry wrapped group keys between members. +It does not any more: the node holds the GEK and wraps it itself, for a key the recipient +proved possession of over an authenticated channel (§3, and `docs/invite-pairing-v1.md`). +There is no hub endpoint that touches group key material. + +**Notifications** | Method | Path | Auth | Description | |---|---|---|---| -| POST | `/v1/groups/{group_id}/members/{username}/gek` | Access token (admin) | Upload GEK bundle for a member. Body: `pk_eph_b64, nonce_b64, wrapped_b64`. | -| GET | `/v1/groups/{group_id}/gek` | Access token (member) | Retrieve your own GEK bundle. Returns `pk_eph_b64, nonce_b64, wrapped_b64`. | +| GET | `/v1/notifications` | Access token | Your notifications, newest first, plus an `unread` count. Chat is one entry per group. | +| POST | `/v1/notifications/{id}/read` | Access token | Mark one read. | +| POST | `/v1/notifications/read-all` | Access token | Mark all read. | +| DELETE | `/v1/notifications` | Access token | Delete all of yours. | +| POST | `/v1/groups/{group_id}/mute` | Access token (member) | Body: `muted`. A muted group creates no notifications at all. | **Revocation** @@ -785,18 +839,35 @@ All hub endpoints are under `https://meshbay.org`. Node endpoints are under `htt ### Node API -| Method | Path | Auth | Description | -|---|---|---|---| -| GET | `/` | None | Node info: `file_count, pk_node, group_id`. | -| GET | `/index` | Bearer token | Group file index. Returns array of `{id, name, size, type}`. | -| GET | `/file/{file_id}` | Bearer token | Download full file (encrypted bytes for private groups). | -| GET | `/file/{file_id}/{chunk_index}` | Bearer token | Download single 1 MB encrypted chunk as JSON: `ct_b64, nonce_b64, file_hash_b64, chunk_index, plaintext_size`. | -| GET | `/stream/{file_id}/index.m3u8` | Bearer token | HLS playlist for video streaming. | +The unauthenticated HTTP API (`/`, `/index`, `/file/{id}`, `/stream/...`) was **removed in +0.2.0**, findings C1 and C6. Everything a member does now goes through MNP after a +handshake that establishes what they are allowed to see: + +| Client → node | Node → client | Purpose | +|---|---|---| +| `handshake` | `handshake_ack` | Token, group, and the node's signature over the transcript | +| `handshake_challenge` (node first) | `handshake_response` | Proof the peer holds the GEK | +| `join_request` | `join_result` | Pairing code, or recognition of a pinned identity; carries the wrapped GEK on success | +| `index_sync` | `index_sync` / `index_delta` | The group index | +| `file_req` | `file_chunk` | One encrypted 1 MB chunk | +| `file_upload` | `file_upload_ack` | Upload into `uploads/`; the ack names the file as stored | +| `dir_create` | `dir_create_ack` | Create a directory | +| `file_delete` | `file_delete_ack` | Delete a file you uploaded | +| `stream_req` | `stream_init`, `stream_data`, `stream_end` | MSE video | +| `chat_msg`, `chat_hist` | `chat_hist_resp` | Chat and its history | +| `gek_bundle_fetch` | `gek_bundle_resp` | Your own wrapped group key | +| `keypair_bundle_fetch` / `_store` / `_delete` | `keypair_bundle_resp` | Your encrypted keypair backup on that node | + +**Auth:** the JWT is verified offline against the hub's Ed25519 public key — the hub is not +contacted (~884µs). The token proves which account you are; it does not decide what the +node serves you. That is the node's roster and the GEK proof. -**Auth:** all node endpoints that require a Bearer token verify the JWT offline using the hub's Ed25519 public key. The hub is not contacted during verification (~884µs). +**The node's admin UI** (`/api/*`, loopback, token required) is the operator's, not a +member API. --- -*MeshBay protocol: MNP v0.1 over TCP+TLS 1.3. QUIC transport planned for v2.* +*MeshBay protocol: MNP v0.1 over WebRTC DataChannel (browsers) and QUIC (native). The +TCP+TLS transport was removed in 0.2.0 — finding C6.* *Hub: https://meshbay.org — FastAPI + PostgreSQL + Caddy.* *Packages: python3-meshbay-common, meshbay-hub, meshbay-node.* diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 2a0f3b1..68f6843 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -65,6 +65,7 @@ This replaces the informal assurances scattered through v4 §4.2.x and §13. | 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 | — | — | ✅ | +| Deleting your account erases you | ✅ hub-side | ✅ hub-side | ❌ **files, pinned identity and bundle stay on the node** — §6.1 | — | — | | Your identity keys stay yours | ✅ | ✅ | ⚠️ **offline attack on the bundle they hold** — see §7.1. Succeeds against a weak passphrase, and yields the identity used **on that node only**: nothing on anyone else's | ✅ | ✅ | **The claim this project can make:** *the hub cannot read your content unless it ships you @@ -237,10 +238,17 @@ of the loop, so nobody has to be online when the invitee actually joins. ### 5.2 Uploads -Confined to `shared_root/.uploads/{user_id}/`, filename allowlist, no overwrite, chunk -ordering enforced, size capped. Previously uploads landed in the shared root under a -client-chosen name and overwrote anything there — which also defeated deletion -authorization, since overwriting a file made the attacker its recorded uploader. +Confined to `shared_root/uploads/`, filename allowlist, no overwrite (a colliding name +gets a suffix), chunk ordering enforced, size capped. Chat attachments land there too, so +there is one directory an operator can look at, empty, or back up. Nothing derived is +written beside them: a chat thumbnail is the decrypted file scaled by the browser, so the +node stores no second copy of an image it already holds. + +The destination has moved twice — `shared_root/` → `.uploads/{user_id}/` → `uploads/` — +and the four protections above were carried across each move deliberately. The original +version accepted a client-chosen path and overwrote whatever was there, which defeated +deletion authorization as well: overwriting a file made the attacker its recorded +uploader, and therefore the party allowed to delete it (C5a). ### 5.3 Operator interface @@ -333,8 +341,28 @@ who could delete a file. private keys, GEK bundles, keypair bundles, node IPs beyond ephemeral signaling. **Knows, unavoidably:** who is a member of what, when nodes connect, when a chat message -was posted and by whom (`chat_notify` — minimization was in the dropped phase), and -content hashes for **public** groups only. +was posted and by whom, and content hashes for **public** groups only. + +The "by whom" is a `user_id`, not a display name, since 2026-08-15. The node has to name +the author for the hub to skip them when creating notifications — it used to name nobody, +so the hub substituted the node's own account and told every author about their own +message. The exchange is `chat_notify` over the node's hub socket and it carries no +content, but it is a stable identifier: the hub can build who-talks-in-which-group-when +without reading a word. Minimizing it was in the dropped phase, and it stays a known +metadata leak rather than a solved problem. + +**Erases on request, and what survives.** A user can delete their own account from +Settings (passphrase re-entered — a live token may be a borrowed laptop), and a hub admin +can delete one. The row is tombstoned rather than dropped: username released, email and +password hash cleared, node linking key dropped, memberships, notifications and refresh +tokens removed, active access tokens refused at once by status check rather than left to +expire. Two things survive on purpose. The IP log is kept for its legal retention period +and stays attributable, since detaching it would keep the data and lose the only thing it +is for. And **nothing on a node is touched**: files, the pinned identity and the keypair +bundle live on machines the hub does not command, which is the same sovereignty that makes +§5.5 work. Deleting the hub account is not an erasure request to the operators who host +you — the operator interface (§5.3) is where that happens. Deletion is refused outright +while the account still owns groups, rather than cascading into other people's data. **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 |