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

@@ -29,4 +29,5 @@ export { runSeedTestMesh } from "./seed-test-mesh.js";
export { runNotificationList } from "./notification.js";
export { runMemberList } from "./member.js";
export { runTopicTail } from "./topic-tail.js";
export { runTopicPost } from "./topic-post.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 { withRestKey } from "~/services/api/with-rest-key.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 { bold, clay, dim } from "~/ui/styles.js";
import { bold, clay, dim, yellow } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export interface TopicTailFlags {
@@ -26,6 +31,7 @@ interface TopicMessage {
senderName: string;
nonce: string;
ciphertext: string;
bodyVersion?: number;
createdAt: string;
}
@@ -35,7 +41,11 @@ interface HistoryResponse {
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 {
return Buffer.from(b64, "base64").toString("utf-8");
} 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 {
try {
return new Date(iso).toLocaleTimeString([], {
@@ -55,14 +75,19 @@ function fmtTime(iso: string): string {
}
}
function printMessage(m: TopicMessage, json: boolean): void {
const text = decodeCiphertext(m.ciphertext);
async function printMessage(
m: TopicMessage,
topicKey: Uint8Array | null,
json: boolean,
): Promise<void> {
const text = await decryptForRender(m, topicKey);
if (json) {
console.log(JSON.stringify({ ...m, message: text }));
return;
}
const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : "";
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"],
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
// when they tail an active topic.
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.
for (const m of history.messages.slice().reverse()) {
printMessage(m, flags.json ?? false);
await printMessage(m, topicKey, flags.json ?? false);
}
} catch (err) {
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") {
try {
const m = JSON.parse(ev.data) as TopicMessage;
printMessage(m, flags.json ?? false);
await printMessage(m, topicKey, flags.json ?? false);
} catch {
// skip malformed
}
@@ -190,6 +297,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
} finally {
process.removeListener("SIGINT", onSig);
process.removeListener("SIGTERM", onSig);
if (resealTimer) clearInterval(resealTimer);
}
},
);