From a90046a8e3e2c6bbbaa9c25456c884a03eaa107b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:10:23 +0100 Subject: [PATCH] fix(cli): e2e encrypt vault entries with libsodium Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/package.json | 2 +- apps/cli/src/mcp/server.ts | 24 +++++++++++++++++++----- apps/cli/src/ws/client.ts | 4 ++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 8d7fa87..fd4b905 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.8.3", + "version": "0.8.4", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 9fbda77..016326b 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -1354,16 +1354,30 @@ Your message mode is "${messageMode}". const client = allClients()[0]; if (!client) return text("vault_set: not connected", true); const entryType = vType ?? "env"; - let plaintext = value; + + // Read plaintext + let plaintextBytes: Uint8Array; if (entryType === "file") { const { existsSync, readFileSync } = await import("node:fs"); if (!existsSync(value)) return text(`vault_set: file not found: ${value}`, true); - plaintext = readFileSync(value, "base64"); + plaintextBytes = new Uint8Array(readFileSync(value)); + } else { + plaintextBytes = new TextEncoder().encode(value); } - const encoded = Buffer.from(plaintext).toString("base64"); - const ok = await client.vaultSet(key, encoded, "placeholder-nonce", "placeholder-sealed", entryType, mount_path, description); + + // E2E encrypt: crypto_secretbox with random Kf, then seal Kf with mesh pubkey + const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto"); + const { ciphertext, nonce, key: kf } = await encryptFile(plaintextBytes); + const sealedKey = await sealKeyForPeer(kf, client.getMeshPubkey()); + + // Convert ciphertext to base64 for storage + const { ensureSodium } = await import("../crypto/keypair"); + const sodium = await ensureSodium(); + const ciphertextB64 = sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL); + + const ok = await client.vaultSet(key, ciphertextB64, nonce, sealedKey, entryType, mount_path, description); if (!ok) return text("vault_set: broker did not acknowledge", true); - return text(`Vault entry "${key}" stored (${entryType}).`); + return text(`Vault entry "${key}" stored (${entryType}, E2E encrypted).`); } case "vault_list": { const client = allClients()[0]; diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index f34d588..e95254d 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -194,6 +194,10 @@ export class BrokerClient { getSessionPubkey(): string | null { return this.sessionPubkey; } /** Session secret key hex (null before first connection). */ getSessionSecretKey(): string | null { return this.sessionSecretKey; } + /** Mesh member public key hex (stable across sessions). */ + getMeshPubkey(): string { return this.mesh.pubkey; } + /** Mesh member secret key hex (stable across sessions). */ + getMeshSecretKey(): string { return this.mesh.secretKey; } private makeReqId(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36);