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:
@@ -262,10 +262,16 @@ export const ensureGeneralTopic = async (
|
||||
recipientX25519,
|
||||
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({
|
||||
topicId: row.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),
|
||||
}).onConflictDoNothing();
|
||||
} catch {
|
||||
|
||||
@@ -58,6 +58,13 @@ const sendMessageSchema = z.object({
|
||||
/** base64 nonce. */
|
||||
nonce: z.string().min(1),
|
||||
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
|
||||
* from the plaintext. Capped at 16 to bound notification fan-out
|
||||
@@ -160,6 +167,7 @@ export const v1Router = new Hono<Env>()
|
||||
senderMemberId,
|
||||
nonce: body.nonce,
|
||||
ciphertext: body.ciphertext,
|
||||
bodyVersion: body.bodyVersion,
|
||||
})
|
||||
.returning({ id: meshTopicMessage.id });
|
||||
|
||||
@@ -176,12 +184,17 @@ export const v1Router = new Hono<Env>()
|
||||
.returning({ id: messageQueue.id });
|
||||
|
||||
// Mention fan-out → notification rows. Client-extracted mentions
|
||||
// win when present (post-encryption clients MUST extract and send);
|
||||
// otherwise we regex the base64 plaintext as a transitional fallback.
|
||||
// win when present (v2 ciphertext clients MUST extract and send;
|
||||
// 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(/^@/, ""));
|
||||
if (!mentionTokens || mentionTokens.length === 0) {
|
||||
if (
|
||||
(!mentionTokens || mentionTokens.length === 0) &&
|
||||
body.bodyVersion === 1
|
||||
) {
|
||||
mentionTokens = extractMentionsFromBase64(body.ciphertext);
|
||||
}
|
||||
if (!mentionTokens) mentionTokens = [];
|
||||
let notifications = 0;
|
||||
if (historyRow && mentionTokens.length > 0) {
|
||||
const recipients = await db
|
||||
@@ -386,6 +399,7 @@ export const v1Router = new Hono<Env>()
|
||||
senderName: meshMember.displayName,
|
||||
nonce: meshTopicMessage.nonce,
|
||||
ciphertext: meshTopicMessage.ciphertext,
|
||||
bodyVersion: meshTopicMessage.bodyVersion,
|
||||
createdAt: meshTopicMessage.createdAt,
|
||||
})
|
||||
.from(meshTopicMessage)
|
||||
@@ -413,6 +427,7 @@ export const v1Router = new Hono<Env>()
|
||||
senderName: r.senderName,
|
||||
nonce: r.nonce,
|
||||
ciphertext: r.ciphertext,
|
||||
bodyVersion: r.bodyVersion,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
@@ -484,6 +499,7 @@ export const v1Router = new Hono<Env>()
|
||||
senderName: meshMember.displayName,
|
||||
nonce: meshTopicMessage.nonce,
|
||||
ciphertext: meshTopicMessage.ciphertext,
|
||||
bodyVersion: meshTopicMessage.bodyVersion,
|
||||
createdAt: meshTopicMessage.createdAt,
|
||||
})
|
||||
.from(meshTopicMessage)
|
||||
@@ -510,6 +526,7 @@ export const v1Router = new Hono<Env>()
|
||||
senderName: r.senderName,
|
||||
nonce: r.nonce,
|
||||
ciphertext: r.ciphertext,
|
||||
bodyVersion: r.bodyVersion,
|
||||
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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user