feat(broker+api+cli): per-topic E2E encryption — v0.3.0 phase 3 (CLI)
Wire format:
topic_member_key.encrypted_key = base64(
<32-byte sender x25519 pubkey> || crypto_box(topic_key)
)
Embedding sender pubkey inline lets re-sealed copies (carrying a
different sender than the original creator-seal) decode the same
way as creator copies, without an extra schema column or join.
topic.encrypted_key_pubkey stays for backwards-compat metadata
but the wire truth is the inline prefix.
API (phase 3):
GET /v1/topics/:name/pending-seals list members without keys
POST /v1/topics/:name/seal submit a re-sealed copy
POST /v1/messages now accepts bodyVersion (1|2); v2 skips the
regex mention extraction (server can't read v2 ciphertext).
GET /messages + /stream now return bodyVersion per row.
Broker + web mutations updated to use the inline-sender format
when sealing. ensureGeneralTopic (web) also generates topic keys
per the bugfix that landed earlier today; both producers now
share one wire format.
CLI (claudemesh-cli@1.8.0):
+ apps/cli/src/services/crypto/topic-key.ts — fetch/decrypt/encrypt/seal
+ claudemesh topic post <name> <msg> — encrypted REST send (v2)
* claudemesh topic tail <name> — decrypts v2 on render, runs a
30s background re-seal loop for pending joiners
Web client stays on v1 plaintext until phase 3.5 (browser-side
persistent identity in IndexedDB). Mention fan-out from phase 1
already works for both versions, so /v1/notifications keeps
working through the cutover.
Spec at .artifacts/specs/2026-05-02-topic-key-onboarding.md
updated with the implemented inline-sender format and the
phase 3.5 web plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,43 @@ ephemeral pubkey. Either:
|
||||
Decoder pulls the prefix, uses it as the sender pubkey. No schema
|
||||
change beyond what 0026 already ships.
|
||||
|
||||
(b) wins on simplicity. Phase 2 implementation uses it.
|
||||
**(b) wins on simplicity. Phase 3 implementation ships it. Both the
|
||||
broker creator-seal and the CLI re-seal write the
|
||||
`<32-byte sender pubkey><cipher>` blob.** `topic.encrypted_key_pubkey`
|
||||
becomes informational only — the wire-format truth is the inline prefix.
|
||||
|
||||
## Web client gap (phase 3.5)
|
||||
|
||||
The CLI side of phase 3 ships in this cut. The web side does NOT —
|
||||
because web member rows have `peerPubkey` registered server-side but
|
||||
the corresponding ed25519 SECRET is discarded immediately after
|
||||
generation (see `mutations.ts:createMyMesh`). Without the secret the
|
||||
browser can't `crypto_box_open` its sealed topic key.
|
||||
|
||||
Three fixes, in increasing order of effort:
|
||||
|
||||
1. **Browser-side persistent identity (recommended)** — generate an
|
||||
ed25519 keypair in the browser on first dashboard visit, store the
|
||||
secret in IndexedDB, sync the public half to `mesh.member.peerPubkey`
|
||||
via a new `POST /v1/me/peer-pubkey` endpoint. Topic keys then seal
|
||||
to the new pubkey; web user decrypts locally. Existing #general
|
||||
topics need a re-seal cycle (the v0.3.0 phase-3 re-seal loop in
|
||||
the CLI already does this for any pending member, including web
|
||||
ones). Spec lift: ~3 hours, mostly browser code + a sync endpoint.
|
||||
|
||||
2. **Server-held secret** — keep the member's ed25519 secret server-
|
||||
side. Trivial to implement, but the broker can read everything,
|
||||
defeating the security claim. **Rejected.**
|
||||
|
||||
3. **JWT-derived keys** — derive the member's keypair from a stable
|
||||
user-secret (e.g. PBKDF2 over their session JWT). Means cross-
|
||||
device same key, but needs the JWT to include ~32 bytes of stable
|
||||
key material. Tied to v2.0.0 daemon redesign. **Deferred.**
|
||||
|
||||
Phase 3 ships option 1 deferred; web stays on v1 plaintext until 3.5.
|
||||
The CLI re-seal loop in `topic tail` already handles re-sealing for
|
||||
web members ONCE they have a real pubkey — no broker work needed
|
||||
when 3.5 lands.
|
||||
|
||||
## Option C — leaderless protocol (DEFERRED)
|
||||
|
||||
@@ -95,44 +131,48 @@ asks for FS (forward secrecy) on group chat.
|
||||
|
||||
---
|
||||
|
||||
## Phase-2 implementation checklist
|
||||
## Implementation checklist
|
||||
|
||||
Schema (0026 — done):
|
||||
- [x] `topic.encrypted_key_pubkey` (legacy field, will be unused in
|
||||
Option B's "embed in payload" mode, but keeping it for
|
||||
forward-compat if we ever switch to Option C)
|
||||
- [x] `topic.encrypted_key_pubkey` (informational; wire truth is the
|
||||
inline 32-byte prefix on each `topic_member_key.encryptedKey`)
|
||||
- [x] `topic_member_key.(encrypted_key, nonce)`
|
||||
- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext)
|
||||
- [x] `topic_message.body_version` (1 = plaintext, 2 = v2 ciphertext)
|
||||
|
||||
API (some done — see annotations):
|
||||
API (phase 3 — done):
|
||||
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
|
||||
- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys
|
||||
- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
||||
- [x] `GET /v1/topics/:name/pending-seals` — list members without keys
|
||||
- [x] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
||||
- [x] `GET /v1/topics/:name/messages` returns `bodyVersion`
|
||||
- [x] `GET /v1/topics/:name/stream` emits `bodyVersion`
|
||||
- [x] `POST /v1/messages` accepts `bodyVersion` (1|2) + skips regex
|
||||
mention extraction on v2
|
||||
|
||||
Broker:
|
||||
- [x] `createTopic` generates topic key + seals for creator
|
||||
- [ ] `joinTopic` becomes a "pending" insert — no key seal
|
||||
- [ ] (optional) WS notification to online topic members when a new
|
||||
joiner arrives, so re-seal latency is sub-second instead of
|
||||
polling-bound
|
||||
Broker / web mutation (phase 3 — done):
|
||||
- [x] `createTopic` generates topic key + seals for creator with
|
||||
inline-sender-pubkey blob format
|
||||
- [x] `ensureGeneralTopic` (web) mirrors the same flow
|
||||
|
||||
Client (CLI + web):
|
||||
- [ ] On topic open, fetch sealed key, decrypt + cache in memory
|
||||
- [ ] On send, encrypt body with topic key, set `body_version: 2`
|
||||
- [ ] On render, decrypt v2 messages with cached key; v1 stays
|
||||
base64 plaintext (legacy)
|
||||
- [ ] Background re-seal loop — poll for pending joiners, seal,
|
||||
POST
|
||||
Client — CLI (phase 3 — done):
|
||||
- [x] `services/crypto/topic-key.ts` — fetch + decrypt + encrypt + reseal helpers
|
||||
- [x] `topic tail` decrypts v2 messages on render
|
||||
- [x] `topic post` encrypts v2 on send via REST POST /v1/messages
|
||||
- [x] Background re-seal loop in `topic tail` (30s cadence)
|
||||
|
||||
UX:
|
||||
- [ ] "waiting for a peer to share the topic key" state when GET key
|
||||
returns 404
|
||||
- [ ] "you are the only online member — joiners can't read messages
|
||||
until someone else logs in" warning when sole online holder
|
||||
goes offline
|
||||
Client — web (phase 3.5 — DEFERRED):
|
||||
- [ ] Browser-side persistent identity (IndexedDB)
|
||||
- [ ] `POST /v1/me/peer-pubkey` sync endpoint
|
||||
- [ ] Web chat panel encrypt-on-send + decrypt-on-render (currently v1)
|
||||
|
||||
The phase-2 commit ships only the schema + creator-seal + GET /key.
|
||||
The pending-seals endpoint, seal POST, and client encryption land in
|
||||
phase 3 once this spec gets a code review. Mention fan-out from
|
||||
phase 1 already works for both v1 and v2 messages, so /v1/notifications
|
||||
keeps working through the cutover.
|
||||
UX surfaces (phase 3 — done in CLI):
|
||||
- [x] "waiting for a peer to share the topic key" warning on tail
|
||||
- [ ] (web) "your encryption keys are pending — pair this browser"
|
||||
banner once 3.5 lands
|
||||
|
||||
Mention fan-out from phase 1 already works for both v1 and v2
|
||||
messages, so `/v1/notifications` keeps working through the cutover.
|
||||
|
||||
The phase-3 cut ships full CLI encryption + re-seal flow. Web remains
|
||||
on v1 plaintext until 3.5 lands the browser identity layer. Mixed
|
||||
CLI+web meshes in the meantime should keep using v1 sends OR accept
|
||||
that web members can't read v2 messages.
|
||||
|
||||
Reference in New Issue
Block a user