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 {
|
||||
|
||||
Reference in New Issue
Block a user