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:
245
apps/cli/src/services/crypto/topic-key.ts
Normal file
245
apps/cli/src/services/crypto/topic-key.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user