From 898c061089b776b450f9bc3cb768dc24291c8f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:33:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli):=20e2e=20file=20encryption=20?= =?UTF-8?q?=E2=80=94=20file-crypto.ts=20+=20client=20+=20MCP=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/cli/src/crypto/file-crypto.ts | 90 ++++++++++++++++++++ apps/cli/src/mcp/server.ts | 131 ++++++++++++++++++++++++++++- apps/cli/src/mcp/tools.ts | 18 +++- apps/cli/src/ws/client.ts | 44 ++++++++-- 4 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 apps/cli/src/crypto/file-crypto.ts diff --git a/apps/cli/src/crypto/file-crypto.ts b/apps/cli/src/crypto/file-crypto.ts new file mode 100644 index 0000000..6a11fae --- /dev/null +++ b/apps/cli/src/crypto/file-crypto.ts @@ -0,0 +1,90 @@ +/** + * File encryption for claudemesh E2E file sharing. + * + * Symmetric: crypto_secretbox_easy with random Kf (32-byte key). + * Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519). + * Key opening: crypto_box_seal_open with own X25519 keypair. + */ + +import { ensureSodium } from "./keypair"; + +export interface EncryptedFile { + ciphertext: Uint8Array; // secretbox ciphertext (includes MAC) + nonce: string; // base64 24-byte nonce + key: Uint8Array; // 32-byte symmetric Kf (keep in memory only) +} + +/** + * Encrypt file bytes with a fresh random symmetric key. + * Returns ciphertext, nonce (base64), and the plaintext Kf. + */ +export async function encryptFile(plaintext: Uint8Array): Promise { + const sodium = await ensureSodium(); + const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES); + const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key); + return { + ciphertext, + nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), + key, + }; +} + +/** + * Decrypt file bytes with the symmetric key Kf. + * Returns null if decryption fails. + */ +export async function decryptFile( + ciphertext: Uint8Array, + nonceB64: string, + key: Uint8Array, +): Promise { + const sodium = await ensureSodium(); + try { + const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL); + return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key); + } catch { + return null; + } +} + +/** + * Seal Kf for a recipient using crypto_box_seal (ephemeral sender key). + * recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars). + * Returns base64 sealed box. + */ +export async function sealKeyForPeer( + kf: Uint8Array, + recipientPubkeyHex: string, +): Promise { + const sodium = await ensureSodium(); + const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519( + sodium.from_hex(recipientPubkeyHex), + ); + const sealed = sodium.crypto_box_seal(kf, recipientCurve); + return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL); +} + +/** + * Open a sealed key blob using own ed25519 keypair (converted to X25519). + * Returns the 32-byte Kf or null if decryption fails. + */ +export async function openSealedKey( + sealedB64: string, + myPubkeyHex: string, + mySecretKeyHex: string, +): Promise { + const sodium = await ensureSodium(); + try { + const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519( + sodium.from_hex(myPubkeyHex), + ); + const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519( + sodium.from_hex(mySecretKeyHex), + ); + const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL); + return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec); + } catch { + return null; + } +} diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 6b3c4da..a9d2140 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -440,12 +440,75 @@ Your message mode is "${messageMode}". // --- Files --- case "share_file": { - const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] }; + const { path: filePath, name: fileName, tags, to: fileTo } = (args ?? {}) as { path?: string; name?: string; tags?: string[]; to?: string }; if (!filePath) return text("share_file: `path` required", true); const { existsSync } = await import("node:fs"); if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); const client = allClients()[0]; if (!client) return text("share_file: not connected", true); + + // If 'to' specified, do E2E encryption + if (fileTo) { + const { encryptFile, sealKeyForPeer } = await import("../crypto/file-crypto"); + const { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join, basename } = await import("node:path"); + + // Resolve target peer pubkey + const peers = await client.listPeers(); + const targetPeer = peers.find(p => p.pubkey === fileTo || p.displayName === fileTo); + if (!targetPeer) { + return text(`share_file: peer not found: ${fileTo}`, true); + } + + // Read and encrypt file + const plaintext = readFileSync(filePath); + const { ciphertext, nonce, key } = await encryptFile(new Uint8Array(plaintext)); + + // Seal Kf for target peer + const sealedForTarget = await sealKeyForPeer(key, targetPeer.pubkey); + + // Seal Kf for ourselves (owner) + const myPubkey = client.getSessionPubkey(); + const sealedForSelf = myPubkey ? await sealKeyForPeer(key, myPubkey) : null; + + const fileKeys = [ + { peerPubkey: targetPeer.pubkey, sealedKey: sealedForTarget }, + ...(sealedForSelf && myPubkey ? [{ peerPubkey: myPubkey, sealedKey: sealedForSelf }] : []), + ]; + + // Build combined buffer: nonce (24 bytes) + ciphertext + const { ensureSodium } = await import("../crypto/keypair"); + const sodium = await ensureSodium(); + const nonceBytes = sodium.from_base64(nonce, sodium.base64_variants.ORIGINAL); + const combined = new Uint8Array(nonceBytes.length + ciphertext.length); + combined.set(nonceBytes, 0); + combined.set(ciphertext, nonceBytes.length); + + const baseName = fileName ?? basename(filePath); + const tmpDir = mkdtempSync(join(tmpdir(), "cm-")); + const tmpPath = join(tmpDir, baseName); + writeFileSync(tmpPath, combined); + + try { + const fileId = await client.uploadFile(tmpPath, client.meshId, client.meshSlug, { + name: baseName, + tags, + persistent: true, + encrypted: true, + ownerPubkey: myPubkey ?? undefined, + fileKeys, + }); + return text(`Shared (E2E encrypted): ${baseName} → ${targetPeer.displayName} (${fileId})`); + } catch (e) { + return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true); + } finally { + try { unlinkSync(tmpPath); } catch { /* ignore */ } + try { rmdirSync(tmpDir); } catch { /* ignore */ } + } + } + + // Plain (unencrypted) upload — existing code try { const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { name: fileName, tags, persistent: true, @@ -463,6 +526,42 @@ Your message mode is "${messageMode}". if (!client) return text("get_file: not connected", true); const result = await client.getFile(id); if (!result) return text(`get_file: file ${id} not found`, true); + + if (result.encrypted && result.sealedKey) { + const { openSealedKey, decryptFile } = await import("../crypto/file-crypto"); + const { ensureSodium } = await import("../crypto/keypair"); + const myPubkey = client.getSessionPubkey(); + const mySecret = client.getSessionSecretKey(); + + if (!myPubkey || !mySecret) { + return text("get_file: no session keypair — cannot decrypt", true); + } + + const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret); + if (!kf) return text("get_file: failed to open sealed key", true); + + // Download file bytes from presigned URL + const resp = await fetch(result.url, { signal: AbortSignal.timeout(30_000) }); + if (!resp.ok) return text(`get_file: download failed (${resp.status})`, true); + const buf = new Uint8Array(await resp.arrayBuffer()); + + // Wire format: first 24 bytes = nonce, rest = ciphertext + const sodium = await ensureSodium(); + const NONCE_BYTES = sodium.crypto_secretbox_NONCEBYTES; // 24 + const nonce = sodium.to_base64(buf.slice(0, NONCE_BYTES), sodium.base64_variants.ORIGINAL); + const ciphertext = buf.slice(NONCE_BYTES); + + const plaintext = await decryptFile(ciphertext, nonce, kf); + if (!plaintext) return text("get_file: decryption failed", true); + + const { writeFileSync, mkdirSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(save_to), { recursive: true }); + writeFileSync(save_to, plaintext); + return text(`Downloaded and decrypted: ${result.name} → ${save_to}`); + } + + // Unencrypted — existing download logic const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) }); if (!res.ok) return text(`get_file: download failed (${res.status})`, true); const { writeFileSync, mkdirSync } = await import("node:fs"); @@ -761,6 +860,36 @@ Your message mode is "${messageMode}". return text(results.join("\n")); } + case "grant_file_access": { + const { fileId, to: grantTo } = (args ?? {}) as { fileId?: string; to?: string }; + if (!fileId || !grantTo) return text("grant_file_access: `fileId` and `to` required", true); + const client = allClients()[0]; + if (!client) return text("grant_file_access: not connected", true); + + const peers = await client.listPeers(); + const targetPeer = peers.find(p => p.pubkey === grantTo || p.displayName === grantTo); + if (!targetPeer) return text(`grant_file_access: peer not found: ${grantTo}`, true); + + const result = await client.getFile(fileId); + if (!result) return text("grant_file_access: file not found", true); + if (!result.encrypted) return text("grant_file_access: file is not encrypted", true); + if (!result.sealedKey) return text("grant_file_access: no key available (are you the owner?)", true); + + const { openSealedKey, sealKeyForPeer } = await import("../crypto/file-crypto"); + const myPubkey = client.getSessionPubkey(); + const mySecret = client.getSessionSecretKey(); + if (!myPubkey || !mySecret) return text("grant_file_access: no session keypair", true); + + const kf = await openSealedKey(result.sealedKey, myPubkey, mySecret); + if (!kf) return text("grant_file_access: cannot decrypt your own key", true); + + const sealedForPeer = await sealKeyForPeer(kf, targetPeer.pubkey); + const ok = await client.grantFileAccess(fileId, targetPeer.pubkey, sealedForPeer); + + if (!ok) return text("grant_file_access: broker did not confirm", true); + return text(`Access granted: ${targetPeer.displayName} can now download file ${fileId}`); + } + default: return text(`Unknown tool: ${name}`, true); } diff --git a/apps/cli/src/mcp/tools.ts b/apps/cli/src/mcp/tools.ts index e78e8ed..12ba47e 100644 --- a/apps/cli/src/mcp/tools.ts +++ b/apps/cli/src/mcp/tools.ts @@ -203,7 +203,7 @@ export const TOOLS: Tool[] = [ { name: "share_file", description: - "Share a persistent file with the mesh. All current and future peers can access it.", + "Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).", inputSchema: { type: "object", properties: { @@ -217,6 +217,10 @@ export const TOOLS: Tool[] = [ items: { type: "string" }, description: "Tags for categorization", }, + to: { + type: "string", + description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only", + }, }, required: ["path"], }, @@ -269,6 +273,18 @@ export const TOOLS: Tool[] = [ required: ["id"], }, }, + { + name: "grant_file_access", + description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.", + inputSchema: { + type: "object", + properties: { + fileId: { type: "string", description: "File ID" }, + to: { type: "string", description: "Peer display name or pubkey hex to grant access to" }, + }, + required: ["fileId", "to"], + }, + }, // --- Vector tools --- { diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index bd60d3e..5bc36da 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -83,6 +83,7 @@ export class BrokerClient { private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>(); private sessionPubkey: string | null = null; private sessionSecretKey: string | null = null; + private grantFileAccessResolvers: Array<(ok: boolean) => void> = []; private closed = false; private reconnectAttempt = 0; private helloTimer: NodeJS.Timeout | null = null; @@ -110,6 +111,11 @@ export class BrokerClient { return this.pushBuffer; } + /** Session public key hex (null before first connection). */ + getSessionPubkey(): string | null { return this.sessionPubkey; } + /** Session secret key hex (null before first connection). */ + getSessionSecretKey(): string | null { return this.sessionSecretKey; } + /** Open WS, send hello, resolve when hello_ack received. */ async connect(): Promise { if (this.closed) throw new Error("client is closed"); @@ -412,7 +418,7 @@ export class BrokerClient { /** Check delivery status of a sent message. */ private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = []; - private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = []; + private fileUrlResolvers: Array<(result: { url: string; name: string; encrypted?: boolean; sealedKey?: string } | null) => void> = []; private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = []; private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = []; private vectorStoredResolvers: Array<(id: string | null) => void> = []; @@ -444,7 +450,7 @@ export class BrokerClient { // --- Files --- /** Get a download URL for a shared file. */ - async getFile(fileId: string): Promise<{ url: string; name: string } | null> { + async getFile(fileId: string): Promise<{ url: string; name: string; encrypted?: boolean; sealedKey?: string } | null> { if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; return new Promise((resolve) => { this.fileUrlResolvers.push(resolve); @@ -497,10 +503,11 @@ export class BrokerClient { this.ws.send(JSON.stringify({ type: "delete_file", fileId })); } - /** Upload a file to the broker via HTTP POST. Returns file ID or null. */ + /** Upload a file to the broker via HTTP POST. Returns file ID. */ async uploadFile(filePath: string, meshId: string, memberId: string, opts: { name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string; - }): Promise { + encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>; + }): Promise { const { readFileSync } = await import("node:fs"); const { basename } = await import("node:path"); const data = readFileSync(filePath); @@ -522,6 +529,9 @@ export class BrokerClient { "X-Tags": JSON.stringify(opts.tags ?? []), "X-Persistent": String(opts.persistent ?? true), "X-Target-Spec": opts.targetSpec ?? "", + ...(opts.encrypted ? { "X-Encrypted": "true" } : {}), + ...(opts.ownerPubkey ? { "X-Owner-Pubkey": opts.ownerPubkey } : {}), + ...(opts.fileKeys?.length ? { "X-File-Keys": JSON.stringify(opts.fileKeys) } : {}), }, body: data, signal: AbortSignal.timeout(30_000), @@ -533,6 +543,20 @@ export class BrokerClient { return body.fileId; } + /** Grant a peer access to an encrypted file (owner only). */ + async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise { + if (!this.ws || this.ws.readyState !== this.ws.OPEN) return false; + return new Promise((resolve) => { + const resolvers = this.grantFileAccessResolvers; + resolvers.push(resolve); + this.ws!.send(JSON.stringify({ type: "grant_file_access", fileId, peerPubkey, sealedKey })); + setTimeout(() => { + const idx = resolvers.indexOf(resolve); + if (idx !== -1) { resolvers.splice(idx, 1); resolve(false); } + }, 5_000); + }); + } + // --- Vectors --- /** Store an embedding in a per-mesh Qdrant collection. */ @@ -945,7 +969,12 @@ export class BrokerClient { const resolver = this.fileUrlResolvers.shift(); if (resolver) { if (msg.url) { - resolver({ url: String(msg.url), name: String(msg.name ?? "") }); + resolver({ + url: String(msg.url), + name: String(msg.name ?? ""), + encrypted: msg.encrypted ? true : undefined, + sealedKey: msg.sealedKey ? String(msg.sealedKey) : undefined, + }); } else { resolver(null); } @@ -964,6 +993,11 @@ export class BrokerClient { if (resolver) resolver(accesses); return; } + if (msg.type === "grant_file_access_ok") { + const resolver = this.grantFileAccessResolvers.shift(); + if (resolver) resolver(true); + return; + } if (msg.type === "vector_stored") { const resolver = this.vectorStoredResolvers.shift(); if (resolver) resolver(msg.id ? String(msg.id) : null);