feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 12:33:39 +01:00
parent f7a6559429
commit 898c061089
4 changed files with 276 additions and 7 deletions

View File

@@ -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);
}

View File

@@ -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 ---
{