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:
@@ -683,20 +683,28 @@ async function sealTopicKeyForMember(args: {
|
||||
recipientX25519,
|
||||
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
|
||||
.insert(meshTopicMemberKey)
|
||||
.values({
|
||||
topicId: args.topicId,
|
||||
memberId: args.memberId,
|
||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
encryptedKey,
|
||||
nonce: nonceB64,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
|
||||
set: {
|
||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
encryptedKey,
|
||||
nonce: nonceB64,
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user