feat(broker+api+cli): per-topic E2E encryption — v0.3.0 phase 3 (CLI)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-05-02 21:03:11 +01:00
parent 82ebd2b6be
commit 77f4316f2d
11 changed files with 795 additions and 54 deletions

View File

@@ -79,7 +79,43 @@ ephemeral pubkey. Either:
Decoder pulls the prefix, uses it as the sender pubkey. No schema Decoder pulls the prefix, uses it as the sender pubkey. No schema
change beyond what 0026 already ships. 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) ## 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): Schema (0026 — done):
- [x] `topic.encrypted_key_pubkey` (legacy field, will be unused in - [x] `topic.encrypted_key_pubkey` (informational; wire truth is the
Option B's "embed in payload" mode, but keeping it for inline 32-byte prefix on each `topic_member_key.encryptedKey`)
forward-compat if we ever switch to Option C)
- [x] `topic_member_key.(encrypted_key, nonce)` - [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 - [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys - [x] `GET /v1/topics/:name/pending-seals` — list members without keys
- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy - [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: Broker / web mutation (phase 3 — done):
- [x] `createTopic` generates topic key + seals for creator - [x] `createTopic` generates topic key + seals for creator with
- [ ] `joinTopic` becomes a "pending" insert — no key seal inline-sender-pubkey blob format
- [ ] (optional) WS notification to online topic members when a new - [x] `ensureGeneralTopic` (web) mirrors the same flow
joiner arrives, so re-seal latency is sub-second instead of
polling-bound
Client (CLI + web): Client CLI (phase 3 — done):
- [ ] On topic open, fetch sealed key, decrypt + cache in memory - [x] `services/crypto/topic-key.ts` — fetch + decrypt + encrypt + reseal helpers
- [ ] On send, encrypt body with topic key, set `body_version: 2` - [x] `topic tail` decrypts v2 messages on render
- [ ] On render, decrypt v2 messages with cached key; v1 stays - [x] `topic post` encrypts v2 on send via REST POST /v1/messages
base64 plaintext (legacy) - [x] Background re-seal loop in `topic tail` (30s cadence)
- [ ] Background re-seal loop — poll for pending joiners, seal,
POST
UX: Client — web (phase 3.5 — DEFERRED):
- [ ] "waiting for a peer to share the topic key" state when GET key - [ ] Browser-side persistent identity (IndexedDB)
returns 404 - [ ] `POST /v1/me/peer-pubkey` sync endpoint
- [ ] "you are the only online member — joiners can't read messages - [ ] Web chat panel encrypt-on-send + decrypt-on-render (currently v1)
until someone else logs in" warning when sole online holder
goes offline
The phase-2 commit ships only the schema + creator-seal + GET /key. UX surfaces (phase 3 — done in CLI):
The pending-seals endpoint, seal POST, and client encryption land in - [x] "waiting for a peer to share the topic key" warning on tail
phase 3 once this spec gets a code review. Mention fan-out from - [ ] (web) "your encryption keys are pending — pair this browser"
phase 1 already works for both v1 and v2 messages, so /v1/notifications banner once 3.5 lands
keeps working through the cutover.
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.

View File

@@ -683,20 +683,28 @@ async function sealTopicKeyForMember(args: {
recipientX25519, recipientX25519,
args.bundle.senderSecret, args.bundle.senderSecret,
); );
// Embed sender x25519 pubkey as the first 32 bytes so re-sealed
// copies (which carry their own sender pubkey from a different
// member) decode the same way as creator-sealed copies.
const blob = new Uint8Array(32 + sealed.length);
blob.set(args.bundle.senderPubkey, 0);
blob.set(sealed, 32);
const encryptedKey = sodium.to_base64(blob, sodium.base64_variants.ORIGINAL);
const nonceB64 = sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL);
await db await db
.insert(meshTopicMemberKey) .insert(meshTopicMemberKey)
.values({ .values({
topicId: args.topicId, topicId: args.topicId,
memberId: args.memberId, memberId: args.memberId,
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), encryptedKey,
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), nonce: nonceB64,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId], target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
set: { set: {
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), encryptedKey,
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), nonce: nonceB64,
rotatedAt: new Date(), rotatedAt: new Date(),
}, },
}); });

View File

@@ -2,7 +2,9 @@
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `<channel>` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill. Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `<channel>` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill.
> **What's new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed. > **What's new in 1.8.0:** per-topic end-to-end encryption (v0.3.0 phase 3, CLI side). `claudemesh topic post <topic> <msg>` encrypts the body with `crypto_secretbox` under the topic's symmetric key — broker stores ciphertext only. `claudemesh topic tail` now decrypts v2 messages on render and runs a background re-seal loop every 30s, so new topic joiners get their sealed keys without manual action. `topic-key` cache is process-only — kill the CLI, the key forgets. Web dashboard reads v1 plaintext for now (phase 3.5 brings browser-side identity).
>
> **What was new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed.
> >
> **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket. > **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket.
> >
@@ -45,7 +47,8 @@ USAGE
claudemesh profile view or edit your profile claudemesh profile view or edit your profile
claudemesh topic ... create, list, join, send to topics claudemesh topic ... create, list, join, send to topics
claudemesh topic tail <t> live SSE tail of a topic claudemesh topic tail <t> live SSE tail of a topic (decrypts v2)
claudemesh topic post <t> encrypted REST post (v2 ciphertext)
claudemesh member list mesh roster with online state claudemesh member list mesh roster with online state
claudemesh notification list recent @-mentions of you claudemesh notification list recent @-mentions of you
claudemesh apikey ... issue, list, revoke API keys (REST clients) claudemesh apikey ... issue, list, revoke API keys (REST clients)

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "1.7.0", "version": "1.8.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -29,4 +29,5 @@ export { runSeedTestMesh } from "./seed-test-mesh.js";
export { runNotificationList } from "./notification.js"; export { runNotificationList } from "./notification.js";
export { runMemberList } from "./member.js"; export { runMemberList } from "./member.js";
export { runTopicTail } from "./topic-tail.js"; export { runTopicTail } from "./topic-tail.js";
export { runTopicPost } from "./topic-post.js";
export { withMesh } from "./connect.js"; export { withMesh } from "./connect.js";

View File

@@ -0,0 +1,130 @@
/**
* `claudemesh topic post <name> <message>` — REST-encrypted send.
*
* Distinct from `claudemesh topic send` (WS-based, currently v1
* plaintext). This verb:
* 1. Mints an ephemeral REST apikey scoped to the topic.
* 2. Fetches + decrypts the topic key (crypto_box).
* 3. Encrypts the body with crypto_secretbox under the topic key.
* 4. POSTs body_version: 2 ciphertext to /api/v1/messages.
* 5. Revokes the apikey.
*
* If the topic doesn't yet have a sealed key for this member (404
* not_sealed) we surface a clear error and skip — the user must wait
* for a holder to re-seal.
*/
import { withRestKey } from "~/services/api/with-rest-key.js";
import { request } from "~/services/api/client.js";
import {
getTopicKey,
encryptMessage,
} from "~/services/crypto/topic-key.js";
import { render } from "~/ui/render.js";
import { clay, dim, green } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export interface TopicPostFlags {
mesh?: string;
json?: boolean;
/** Force v1 plaintext send even if the topic is encrypted. */
plaintext?: boolean;
}
interface PostResponse {
messageId: string | null;
historyId: string | null;
topic: string;
topicId: string;
notifications: number;
}
export async function runTopicPost(
topicName: string,
message: string,
flags: TopicPostFlags,
): Promise<number> {
if (!topicName || !message) {
render.err("Usage: claudemesh topic post <topic> <message>");
return EXIT.INVALID_ARGS;
}
const cleanName = topicName.replace(/^#/, "");
// Extract @-mention tokens for write-time fan-out so the server can
// populate notifications without reading ciphertext.
const mentions: string[] = [];
const mentionRe = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
let m: RegExpExecArray | null;
while ((m = mentionRe.exec(message)) !== null) {
mentions.push(m[2]!.toLowerCase());
if (mentions.length >= 16) break;
}
return withRestKey(
{
meshSlug: flags.mesh ?? null,
purpose: `post-${cleanName}`,
capabilities: ["read", "send"],
topicScopes: [cleanName],
},
async ({ secret, mesh }) => {
let bodyVersion: 1 | 2 = 1;
let ciphertext: string;
let nonce: string;
if (flags.plaintext) {
// Explicit v1: caller wants plaintext. Encode UTF-8 → base64.
ciphertext = Buffer.from(message, "utf-8").toString("base64");
nonce = Buffer.from(new Uint8Array(24)).toString("base64");
} else {
const keyResult = await getTopicKey({
apiKeySecret: secret,
memberSecretKeyHex: mesh.secretKey,
topicName: cleanName,
});
if (keyResult.ok && keyResult.topicKey) {
const enc = await encryptMessage(keyResult.topicKey, message);
ciphertext = enc.ciphertext;
nonce = enc.nonce;
bodyVersion = 2;
} else if (keyResult.error === "topic_unencrypted") {
// Legacy v0.2.0 topic — fall back to v1 plaintext.
ciphertext = Buffer.from(message, "utf-8").toString("base64");
nonce = Buffer.from(new Uint8Array(24)).toString("base64");
} else {
render.err(
`cannot encrypt for #${cleanName}: ${keyResult.error ?? "unknown"}${
keyResult.message ? " — " + keyResult.message : ""
}`,
);
return EXIT.INTERNAL_ERROR;
}
}
const result = await request<PostResponse>({
path: "/api/v1/messages",
method: "POST",
token: secret,
body: {
topic: cleanName,
ciphertext,
nonce,
bodyVersion,
...(mentions.length > 0 ? { mentions } : {}),
},
});
if (flags.json) {
console.log(JSON.stringify({ ...result, bodyVersion, mentions }));
return EXIT.SUCCESS;
}
const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1");
render.ok(
"posted",
`${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`,
);
return EXIT.SUCCESS;
},
);
}

View File

@@ -8,8 +8,13 @@
import { URLS } from "~/constants/urls.js"; import { URLS } from "~/constants/urls.js";
import { withRestKey } from "~/services/api/with-rest-key.js"; import { withRestKey } from "~/services/api/with-rest-key.js";
import { request } from "~/services/api/client.js"; import { request } from "~/services/api/client.js";
import {
getTopicKey,
decryptMessage,
sealTopicKeyFor,
} from "~/services/crypto/topic-key.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js"; import { bold, clay, dim, yellow } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js"; import { EXIT } from "~/constants/exit-codes.js";
export interface TopicTailFlags { export interface TopicTailFlags {
@@ -26,6 +31,7 @@ interface TopicMessage {
senderName: string; senderName: string;
nonce: string; nonce: string;
ciphertext: string; ciphertext: string;
bodyVersion?: number;
createdAt: string; createdAt: string;
} }
@@ -35,7 +41,11 @@ interface HistoryResponse {
messages: TopicMessage[]; messages: TopicMessage[];
} }
function decodeCiphertext(b64: string): string { /**
* v1 (legacy plaintext-base64) decode. v2 messages are decrypted via
* the topic key separately — see decryptForRender below.
*/
function decodeV1(b64: string): string {
try { try {
return Buffer.from(b64, "base64").toString("utf-8"); return Buffer.from(b64, "base64").toString("utf-8");
} catch { } catch {
@@ -43,6 +53,16 @@ function decodeCiphertext(b64: string): string {
} }
} }
async function decryptForRender(
m: TopicMessage,
topicKey: Uint8Array | null,
): Promise<string> {
if ((m.bodyVersion ?? 1) === 1) return decodeV1(m.ciphertext);
if (!topicKey) return "[encrypted — no topic key]";
const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce);
return plain ?? "[decrypt failed]";
}
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
try { try {
return new Date(iso).toLocaleTimeString([], { return new Date(iso).toLocaleTimeString([], {
@@ -55,14 +75,19 @@ function fmtTime(iso: string): string {
} }
} }
function printMessage(m: TopicMessage, json: boolean): void { async function printMessage(
const text = decodeCiphertext(m.ciphertext); m: TopicMessage,
topicKey: Uint8Array | null,
json: boolean,
): Promise<void> {
const text = await decryptForRender(m, topicKey);
if (json) { if (json) {
console.log(JSON.stringify({ ...m, message: text })); console.log(JSON.stringify({ ...m, message: text }));
return; return;
} }
const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : "";
process.stdout.write( process.stdout.write(
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${text}\n`, ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`,
); );
} }
@@ -118,7 +143,89 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
capabilities: ["read"], capabilities: ["read"],
topicScopes: [cleanName], topicScopes: [cleanName],
}, },
async ({ secret, meshSlug }) => { async ({ secret, meshSlug, mesh }) => {
// Fetch + decrypt the topic key once. Stays in memory for this
// invocation; tail dies → key forgotten. v1 topics return
// not_sealed/topic_unencrypted and we just don't decrypt.
const keyResult = await getTopicKey({
apiKeySecret: secret,
memberSecretKeyHex: mesh.secretKey,
topicName: cleanName,
});
const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null;
// Re-seal background loop. While we hold the topic key, every
// 30s we look for newly-joined members who don't have a sealed
// copy yet, seal the key for each, and POST. Soft-failures stay
// silent so a flaky network doesn't spam the tail output.
let resealTimer: ReturnType<typeof setInterval> | null = null;
if (topicKey) {
const reseal = async () => {
try {
const pending = await request<{
pending: Array<{
memberId: string;
pubkey: string;
displayName: string;
}>;
}>({
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/pending-seals`,
token: secret,
});
for (const target of pending.pending) {
const sealed = await sealTopicKeyFor(
topicKey,
target.pubkey,
mesh.secretKey,
);
if (!sealed) continue;
try {
await request({
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/seal`,
method: "POST",
token: secret,
body: {
memberId: target.memberId,
encryptedKey: sealed.encryptedKey,
nonce: sealed.nonce,
},
});
if (!flags.json) {
render.info(
dim(`re-sealed topic key for ${target.displayName}`),
);
}
} catch {
// Another holder likely sealed first — ignore.
}
}
} catch {
// Soft-fail; next tick retries.
}
};
void reseal();
resealTimer = setInterval(reseal, 30_000);
}
if (!flags.json && !keyResult.ok) {
if (keyResult.error === "topic_unencrypted") {
render.info(
dim("topic is on v1 (plaintext) — encryption will activate after creator-seal"),
);
} else if (keyResult.error === "not_sealed") {
render.warn(
yellow(
"no topic key sealed for you yet — wait for a holder to re-seal",
),
);
} else if (keyResult.error === "decrypt_failed") {
render.warn(
yellow(
`topic key fetched but decrypt failed: ${keyResult.message ?? ""}`,
),
);
}
}
// 1. Backfill the most recent N messages so the user sees context // 1. Backfill the most recent N messages so the user sees context
// when they tail an active topic. // when they tail an active topic.
if (!flags.forwardOnly && limit > 0) { if (!flags.forwardOnly && limit > 0) {
@@ -134,7 +241,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
} }
// History is newest-first; reverse for chronological display. // History is newest-first; reverse for chronological display.
for (const m of history.messages.slice().reverse()) { for (const m of history.messages.slice().reverse()) {
printMessage(m, flags.json ?? false); await printMessage(m, topicKey, flags.json ?? false);
} }
} catch (err) { } catch (err) {
render.warn(`backfill failed: ${(err as Error).message}`); render.warn(`backfill failed: ${(err as Error).message}`);
@@ -176,7 +283,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
if (ev.event === "message") { if (ev.event === "message") {
try { try {
const m = JSON.parse(ev.data) as TopicMessage; const m = JSON.parse(ev.data) as TopicMessage;
printMessage(m, flags.json ?? false); await printMessage(m, topicKey, flags.json ?? false);
} catch { } catch {
// skip malformed // skip malformed
} }
@@ -190,6 +297,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
} finally { } finally {
process.removeListener("SIGINT", onSig); process.removeListener("SIGINT", onSig);
process.removeListener("SIGTERM", onSig); process.removeListener("SIGTERM", onSig);
if (resealTimer) clearInterval(resealTimer);
} }
}, },
); );

View File

@@ -120,7 +120,8 @@ Topic (conversation scope, v0.2.0)
claudemesh topic history <t> fetch message history [--limit --before] claudemesh topic history <t> fetch message history [--limit --before]
claudemesh topic read <topic> mark all as read claudemesh topic read <topic> mark all as read
claudemesh topic tail <topic> live SSE tail [--limit --forward-only] claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
claudemesh send "#topic" "msg" send to a topic claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2)
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
claudemesh member list mesh roster with online state [--online] claudemesh member list mesh roster with online state [--online]
claudemesh notification list recent @-mentions of you [--since <ISO>] claudemesh notification list recent @-mentions of you [--since <ISO>]
@@ -586,7 +587,17 @@ async function main(): Promise<void> {
const { runTopicTail } = await import("~/commands/topic-tail.js"); const { runTopicTail } = await import("~/commands/topic-tail.js");
process.exit(await runTopicTail(arg, tailFlags)); process.exit(await runTopicTail(arg, tailFlags));
} }
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read|tail>"); process.exit(EXIT.INVALID_ARGS); } else if (sub === "post") {
const postFlags = {
mesh: flags.mesh as string,
json: !!flags.json,
plaintext: !!flags.plaintext,
};
const message = positionals.slice(2).join(" ");
const { runTopicPost } = await import("~/commands/topic-post.js");
process.exit(await runTopicPost(arg, message, postFlags));
}
else { console.error("Usage: claudemesh topic <create|list|join|leave|members|history|read|tail|post>"); process.exit(EXIT.INVALID_ARGS); }
break; break;
} }

View File

@@ -0,0 +1,245 @@
/**
* Per-topic symmetric-key cache + crypto_box plumbing.
*
* Lifecycle:
* 1. CLI command minted a REST apikey via withRestKey().
* 2. Caller asks for a topic key by (mesh_secret_key, topic_name).
* 3. We fetch GET /v1/topics/:name/key for the sealed copy + sender pubkey.
* 4. We convert the mesh's ed25519 secret to x25519, then crypto_box_open
* the sealed key. Plaintext key is cached in-process and used to
* encrypt + decrypt v2 message bodies.
*
* Failures:
* - 404 key_not_sealed_for_member: caller is in the topic but no peer
* has re-sealed the key for them yet. Caller surfaces a "waiting for
* a peer to share the topic key" message and falls back to v1 path.
* - 409 topic_unencrypted: legacy v0.2.0 topic. Caller stays on v1.
* - decrypt failure: server fed us a junk seal. Caller re-fetches
* once; if still bad, surface error and fall back.
*
* The cache is keyed on (apiKeyHash, topicName) so it never crosses
* sessions. Process-only — no disk persistence.
*/
import { request } from "~/services/api/client.js";
import { ApiError } from "~/services/api/errors.js";
interface CacheEntry {
topicKey: Uint8Array;
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
interface SealedKeyResponse {
topic: string;
topicId: string;
encryptedKey: string;
nonce: string;
senderPubkey: string;
createdAt: string;
}
export type TopicKeyError =
| "not_sealed"
| "topic_unencrypted"
| "decrypt_failed"
| "bad_member_secret"
| "network";
export interface TopicKeyResult {
ok: boolean;
topicKey?: Uint8Array;
error?: TopicKeyError;
message?: string;
}
function cacheKey(apiKeySecret: string, topicName: string): string {
// First 12 chars of the apikey is plenty to dedupe within a session
// and short enough to avoid keeping the full secret in a Map key.
return `${apiKeySecret.slice(0, 12)}:${topicName}`;
}
export async function getTopicKey(args: {
apiKeySecret: string;
memberSecretKeyHex: string;
topicName: string;
/** Bypass cache — useful after a re-seal. */
fresh?: boolean;
}): Promise<TopicKeyResult> {
const cacheId = cacheKey(args.apiKeySecret, args.topicName);
if (!args.fresh) {
const cached = cache.get(cacheId);
if (cached) return { ok: true, topicKey: cached.topicKey };
}
let sealed: SealedKeyResponse;
try {
sealed = await request<SealedKeyResponse>({
path: `/api/v1/topics/${encodeURIComponent(args.topicName)}/key`,
token: args.apiKeySecret,
});
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 404) return { ok: false, error: "not_sealed" };
if (e.status === 409) return { ok: false, error: "topic_unencrypted" };
}
return {
ok: false,
error: "network",
message: e instanceof Error ? e.message : String(e),
};
}
const sodium = (await import("libsodium-wrappers")).default;
await sodium.ready;
let recipientX25519Secret: Uint8Array;
try {
const ed = sodium.from_hex(args.memberSecretKeyHex);
recipientX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519(ed);
} catch {
return { ok: false, error: "bad_member_secret" };
}
let topicKey: Uint8Array;
try {
const blob = sodium.from_base64(
sealed.encryptedKey,
sodium.base64_variants.ORIGINAL,
);
const nonce = sodium.from_base64(
sealed.nonce,
sodium.base64_variants.ORIGINAL,
);
// Wire format: first 32 bytes = sender x25519 pubkey, rest =
// crypto_box ciphertext. The topic.encryptedKeyPubkey on the topic
// record is the original creator's sender; subsequent re-seals
// each carry their own sender pubkey, so the joiner can decrypt
// regardless of who sealed for them.
if (blob.length < 32 + sodium.crypto_box_MACBYTES) {
return {
ok: false,
error: "decrypt_failed",
message: "sealed key blob too short to contain sender pubkey + cipher",
};
}
const senderX25519 = blob.slice(0, 32);
const cipher = blob.slice(32);
topicKey = sodium.crypto_box_open_easy(
cipher,
nonce,
senderX25519,
recipientX25519Secret,
);
} catch (e) {
return {
ok: false,
error: "decrypt_failed",
message: e instanceof Error ? e.message : String(e),
};
}
cache.set(cacheId, { topicKey, fetchedAt: Date.now() });
return { ok: true, topicKey };
}
/**
* Encrypt a UTF-8 plaintext message body with the topic's symmetric
* key via crypto_secretbox. Returns base64 ciphertext + base64 nonce
* suitable for POST /v1/messages with bodyVersion: 2.
*/
export async function encryptMessage(
topicKey: Uint8Array,
plaintext: string,
): Promise<{ ciphertext: string; nonce: string }> {
const sodium = (await import("libsodium-wrappers")).default;
await sodium.ready;
const nonceBytes = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const cipher = sodium.crypto_secretbox_easy(
sodium.from_string(plaintext),
nonceBytes,
topicKey,
);
return {
ciphertext: sodium.to_base64(cipher, sodium.base64_variants.ORIGINAL),
nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL),
};
}
/**
* Decrypt a v2 message body. Returns null on auth failure (bad key
* or tampering) — caller should fall back to a placeholder string,
* not crash the renderer.
*/
export async function decryptMessage(
topicKey: Uint8Array,
ciphertextB64: string,
nonceB64: string,
): Promise<string | null> {
try {
const sodium = (await import("libsodium-wrappers")).default;
await sodium.ready;
const cipher = sodium.from_base64(
ciphertextB64,
sodium.base64_variants.ORIGINAL,
);
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
const plain = sodium.crypto_secretbox_open_easy(cipher, nonce, topicKey);
return sodium.to_string(plain);
} catch {
return null;
}
}
/**
* Seal a topic key for another member — used by the re-seal flow when
* a holder helps onboard a new joiner. Returns the bundle ready to
* POST to /v1/topics/:name/seal.
*/
export async function sealTopicKeyFor(
topicKey: Uint8Array,
recipientPubkeyHex: string,
ourMemberSecretKeyHex: string,
): Promise<{
/** base64( our_x25519_pubkey || crypto_box(topicKey) ). */
encryptedKey: string;
nonce: string;
} | null> {
try {
const sodium = (await import("libsodium-wrappers")).default;
await sodium.ready;
const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const ourEdSecret = sodium.from_hex(ourMemberSecretKeyHex);
const ourX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519(
ourEdSecret,
);
// Derive our x25519 public from our ed25519 public half (back half
// of the secret key contains the ed25519 pubkey per libsodium spec).
const ourEdPublic = ourEdSecret.slice(32, 64);
const ourX25519Public = sodium.crypto_sign_ed25519_pk_to_curve25519(
ourEdPublic,
);
const nonceBytes = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const cipher = sodium.crypto_box_easy(
topicKey,
nonceBytes,
recipientX25519,
ourX25519Secret,
);
// Embed sender pubkey as the first 32 bytes so the recipient can
// decrypt without a separate lookup. Matches the format the broker's
// creator-seal writes (see broker.ts sealTopicKeyForMember).
const blob = new Uint8Array(32 + cipher.length);
blob.set(ourX25519Public, 0);
blob.set(cipher, 32);
return {
encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL),
nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL),
};
} catch {
return null;
}
}

View File

@@ -262,10 +262,16 @@ export const ensureGeneralTopic = async (
recipientX25519, recipientX25519,
senderKp.privateKey, senderKp.privateKey,
); );
// Embed sender x25519 pubkey as the first 32 bytes so future
// re-sealed copies (carrying a different sender) decode the same
// way as creator-sealed copies.
const blob = new Uint8Array(32 + sealed.length);
blob.set(senderKp.publicKey, 0);
blob.set(sealed, 32);
await db.insert(meshTopicMemberKey).values({ await db.insert(meshTopicMemberKey).values({
topicId: row.id, topicId: row.id,
memberId: owner.id, memberId: owner.id,
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL),
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
}).onConflictDoNothing(); }).onConflictDoNothing();
} catch { } catch {

View File

@@ -58,6 +58,13 @@ const sendMessageSchema = z.object({
/** base64 nonce. */ /** base64 nonce. */
nonce: z.string().min(1), nonce: z.string().min(1),
priority: z.enum(["now", "next", "low"]).optional().default("next"), priority: z.enum(["now", "next", "low"]).optional().default("next"),
/**
* Body format version. 1 = base64-of-plaintext (v0.2.0 placeholder),
* 2 = crypto_secretbox under the topic's symmetric key (v0.3.0). The
* server does not look inside ciphertext either way; this field
* tells readers how to interpret it.
*/
bodyVersion: z.literal(1).or(z.literal(2)).optional().default(1),
/** /**
* Optional list of `@<displayName>` mentions extracted client-side * Optional list of `@<displayName>` mentions extracted client-side
* from the plaintext. Capped at 16 to bound notification fan-out * from the plaintext. Capped at 16 to bound notification fan-out
@@ -160,6 +167,7 @@ export const v1Router = new Hono<Env>()
senderMemberId, senderMemberId,
nonce: body.nonce, nonce: body.nonce,
ciphertext: body.ciphertext, ciphertext: body.ciphertext,
bodyVersion: body.bodyVersion,
}) })
.returning({ id: meshTopicMessage.id }); .returning({ id: meshTopicMessage.id });
@@ -176,12 +184,17 @@ export const v1Router = new Hono<Env>()
.returning({ id: messageQueue.id }); .returning({ id: messageQueue.id });
// Mention fan-out → notification rows. Client-extracted mentions // Mention fan-out → notification rows. Client-extracted mentions
// win when present (post-encryption clients MUST extract and send); // win when present (v2 ciphertext clients MUST extract and send;
// otherwise we regex the base64 plaintext as a transitional fallback. // server can't read v2 bodies). v1 plaintext falls back to a regex
// on the body so legacy senders don't lose mention notifications.
let mentionTokens = body.mentions?.map((s) => s.toLowerCase().replace(/^@/, "")); let mentionTokens = body.mentions?.map((s) => s.toLowerCase().replace(/^@/, ""));
if (!mentionTokens || mentionTokens.length === 0) { if (
(!mentionTokens || mentionTokens.length === 0) &&
body.bodyVersion === 1
) {
mentionTokens = extractMentionsFromBase64(body.ciphertext); mentionTokens = extractMentionsFromBase64(body.ciphertext);
} }
if (!mentionTokens) mentionTokens = [];
let notifications = 0; let notifications = 0;
if (historyRow && mentionTokens.length > 0) { if (historyRow && mentionTokens.length > 0) {
const recipients = await db const recipients = await db
@@ -386,6 +399,7 @@ export const v1Router = new Hono<Env>()
senderName: meshMember.displayName, senderName: meshMember.displayName,
nonce: meshTopicMessage.nonce, nonce: meshTopicMessage.nonce,
ciphertext: meshTopicMessage.ciphertext, ciphertext: meshTopicMessage.ciphertext,
bodyVersion: meshTopicMessage.bodyVersion,
createdAt: meshTopicMessage.createdAt, createdAt: meshTopicMessage.createdAt,
}) })
.from(meshTopicMessage) .from(meshTopicMessage)
@@ -413,6 +427,7 @@ export const v1Router = new Hono<Env>()
senderName: r.senderName, senderName: r.senderName,
nonce: r.nonce, nonce: r.nonce,
ciphertext: r.ciphertext, ciphertext: r.ciphertext,
bodyVersion: r.bodyVersion,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
})), })),
}); });
@@ -484,6 +499,7 @@ export const v1Router = new Hono<Env>()
senderName: meshMember.displayName, senderName: meshMember.displayName,
nonce: meshTopicMessage.nonce, nonce: meshTopicMessage.nonce,
ciphertext: meshTopicMessage.ciphertext, ciphertext: meshTopicMessage.ciphertext,
bodyVersion: meshTopicMessage.bodyVersion,
createdAt: meshTopicMessage.createdAt, createdAt: meshTopicMessage.createdAt,
}) })
.from(meshTopicMessage) .from(meshTopicMessage)
@@ -510,6 +526,7 @@ export const v1Router = new Hono<Env>()
senderName: r.senderName, senderName: r.senderName,
nonce: r.nonce, nonce: r.nonce,
ciphertext: r.ciphertext, ciphertext: r.ciphertext,
bodyVersion: r.bodyVersion,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
}), }),
}); });
@@ -681,6 +698,178 @@ export const v1Router = new Hono<Env>()
}); });
}) })
// GET /v1/topics/:name/pending-seals — list topic members that don't
// yet have a sealed copy of the topic key. Members who hold the key
// poll this and re-seal for any pending recipient via POST /seal.
//
// Returns roster format so the caller can do the crypto:
// { pending: [{ memberId, pubkey, displayName }] }
//
// Caps at 50 — if more are pending the next poll picks up the rest.
// Anyone with read capability + topic scope can list (any holder can
// re-seal; the trust model accepts that).
.get("/topics/:name/pending-seals", async (c) => {
const key = c.var.apiKey;
requireCapability(key, "read");
const name = c.req.param("name");
requireTopicScope(key, name);
const [topic] = await db
.select({
id: meshTopic.id,
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
})
.from(meshTopic)
.where(
and(
eq(meshTopic.meshId, key.meshId),
eq(meshTopic.name, name),
isNull(meshTopic.archivedAt),
),
);
if (!topic) {
return c.json({ error: "topic_not_found", topic: name }, 404);
}
if (!topic.encryptedKeyPubkey) {
return c.json({ pending: [], senderPubkey: null });
}
// Member is "pending" iff joined the topic but has no key row yet.
// LEFT JOIN topic_member_key on the same (topic, member) pair —
// NULL = pending.
const rows = await db
.select({
memberId: meshTopicMember.memberId,
pubkey: meshMember.peerPubkey,
displayName: meshMember.displayName,
})
.from(meshTopicMember)
.innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId))
.leftJoin(
meshTopicMemberKey,
and(
eq(meshTopicMemberKey.topicId, meshTopicMember.topicId),
eq(meshTopicMemberKey.memberId, meshTopicMember.memberId),
),
)
.where(
and(
eq(meshTopicMember.topicId, topic.id),
isNull(meshMember.revokedAt),
isNull(meshTopicMemberKey.id),
),
)
.limit(50);
return c.json({
topic: name,
topicId: topic.id,
senderPubkey: topic.encryptedKeyPubkey,
pending: rows,
});
})
// POST /v1/topics/:name/seal — submit a re-sealed copy of the topic
// key for a specific member. Body: {memberId, encryptedKey, nonce}.
// Idempotent on (topicId, memberId) — re-submitting overwrites.
//
// The CALLER must already hold the topic key (otherwise their seal
// would be garbage). Server can't verify that at submission time —
// the joiner verifies on first decrypt by attempting crypto_box_open
// and discarding the row if it fails. Bad seals waste a round-trip
// but can't break the security model.
.post(
"/topics/:name/seal",
validate(
"json",
z.object({
memberId: z.string().min(1),
encryptedKey: z.string().min(1),
nonce: z.string().min(1),
}),
),
async (c) => {
const key = c.var.apiKey;
requireCapability(key, "send");
const name = c.req.param("name");
requireTopicScope(key, name);
const body = c.req.valid("json");
const [topic] = await db
.select({
id: meshTopic.id,
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
})
.from(meshTopic)
.where(
and(
eq(meshTopic.meshId, key.meshId),
eq(meshTopic.name, name),
isNull(meshTopic.archivedAt),
),
);
if (!topic) {
return c.json({ error: "topic_not_found", topic: name }, 404);
}
if (!topic.encryptedKeyPubkey) {
return c.json(
{
error: "topic_unencrypted",
topic: name,
hint: "legacy v0.2.0 topic — no key to seal",
},
409,
);
}
// Recipient must be a non-revoked member of the same mesh AND
// already a topic_member (joined the topic). Otherwise we'd let
// anyone seal for any member, which the joiner would then accept
// on first GET /key — that's a denial-of-content vector.
const [recipient] = await db
.select({ id: meshMember.id })
.from(meshTopicMember)
.innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId))
.where(
and(
eq(meshTopicMember.topicId, topic.id),
eq(meshMember.id, body.memberId),
eq(meshMember.meshId, key.meshId),
isNull(meshMember.revokedAt),
),
);
if (!recipient) {
return c.json({ error: "recipient_not_in_topic" }, 404);
}
const now = new Date();
await db
.insert(meshTopicMemberKey)
.values({
topicId: topic.id,
memberId: body.memberId,
encryptedKey: body.encryptedKey,
nonce: body.nonce,
})
.onConflictDoUpdate({
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
set: {
encryptedKey: body.encryptedKey,
nonce: body.nonce,
rotatedAt: now,
},
});
return c.json({
topic: name,
topicId: topic.id,
memberId: body.memberId,
sealedAt: now.toISOString(),
});
},
)
// GET /v1/notifications — recent @-mentions of the viewer across // GET /v1/notifications — recent @-mentions of the viewer across
// all topics in the key's mesh. Reads from mesh.notification, which // all topics in the key's mesh. Reads from mesh.notification, which
// is populated at write time by POST /v1/messages and the broker's // is populated at write time by POST /v1/messages and the broker's