feat(cli): e2e file encryption — file-crypto.ts + client + MCP tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
90
apps/cli/src/crypto/file-crypto.ts
Normal file
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -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<EncryptedFile> {
|
||||||
|
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<Uint8Array | null> {
|
||||||
|
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<string> {
|
||||||
|
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<Uint8Array | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -440,12 +440,75 @@ Your message mode is "${messageMode}".
|
|||||||
|
|
||||||
// --- Files ---
|
// --- Files ---
|
||||||
case "share_file": {
|
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);
|
if (!filePath) return text("share_file: `path` required", true);
|
||||||
const { existsSync } = await import("node:fs");
|
const { existsSync } = await import("node:fs");
|
||||||
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
|
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
|
||||||
const client = allClients()[0];
|
const client = allClients()[0];
|
||||||
if (!client) return text("share_file: not connected", true);
|
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 {
|
try {
|
||||||
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
|
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
|
||||||
name: fileName, tags, persistent: true,
|
name: fileName, tags, persistent: true,
|
||||||
@@ -463,6 +526,42 @@ Your message mode is "${messageMode}".
|
|||||||
if (!client) return text("get_file: not connected", true);
|
if (!client) return text("get_file: not connected", true);
|
||||||
const result = await client.getFile(id);
|
const result = await client.getFile(id);
|
||||||
if (!result) return text(`get_file: file ${id} not found`, true);
|
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) });
|
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
|
||||||
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
|
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
|
||||||
const { writeFileSync, mkdirSync } = await import("node:fs");
|
const { writeFileSync, mkdirSync } = await import("node:fs");
|
||||||
@@ -761,6 +860,36 @@ Your message mode is "${messageMode}".
|
|||||||
return text(results.join("\n"));
|
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:
|
default:
|
||||||
return text(`Unknown tool: ${name}`, true);
|
return text(`Unknown tool: ${name}`, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "share_file",
|
name: "share_file",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -217,6 +217,10 @@ export const TOOLS: Tool[] = [
|
|||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
description: "Tags for categorization",
|
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"],
|
required: ["path"],
|
||||||
},
|
},
|
||||||
@@ -269,6 +273,18 @@ export const TOOLS: Tool[] = [
|
|||||||
required: ["id"],
|
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 ---
|
// --- Vector tools ---
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class BrokerClient {
|
|||||||
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
|
private stateChangeHandlers = new Set<(change: { key: string; value: unknown; updatedBy: string }) => void>();
|
||||||
private sessionPubkey: string | null = null;
|
private sessionPubkey: string | null = null;
|
||||||
private sessionSecretKey: string | null = null;
|
private sessionSecretKey: string | null = null;
|
||||||
|
private grantFileAccessResolvers: Array<(ok: boolean) => void> = [];
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private reconnectAttempt = 0;
|
private reconnectAttempt = 0;
|
||||||
private helloTimer: NodeJS.Timeout | null = null;
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -110,6 +111,11 @@ export class BrokerClient {
|
|||||||
return this.pushBuffer;
|
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. */
|
/** Open WS, send hello, resolve when hello_ack received. */
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.closed) throw new Error("client is closed");
|
if (this.closed) throw new Error("client is closed");
|
||||||
@@ -412,7 +418,7 @@ export class BrokerClient {
|
|||||||
|
|
||||||
/** Check delivery status of a sent message. */
|
/** 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 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 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 fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
|
||||||
private vectorStoredResolvers: Array<(id: string | null) => void> = [];
|
private vectorStoredResolvers: Array<(id: string | null) => void> = [];
|
||||||
@@ -444,7 +450,7 @@ export class BrokerClient {
|
|||||||
// --- Files ---
|
// --- Files ---
|
||||||
|
|
||||||
/** Get a download URL for a shared file. */
|
/** 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;
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.fileUrlResolvers.push(resolve);
|
this.fileUrlResolvers.push(resolve);
|
||||||
@@ -497,10 +503,11 @@ export class BrokerClient {
|
|||||||
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
|
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: {
|
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
|
||||||
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
|
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
|
||||||
}): Promise<string | null> {
|
encrypted?: boolean; ownerPubkey?: string; fileKeys?: Array<{ peerPubkey: string; sealedKey: string }>;
|
||||||
|
}): Promise<string> {
|
||||||
const { readFileSync } = await import("node:fs");
|
const { readFileSync } = await import("node:fs");
|
||||||
const { basename } = await import("node:path");
|
const { basename } = await import("node:path");
|
||||||
const data = readFileSync(filePath);
|
const data = readFileSync(filePath);
|
||||||
@@ -522,6 +529,9 @@ export class BrokerClient {
|
|||||||
"X-Tags": JSON.stringify(opts.tags ?? []),
|
"X-Tags": JSON.stringify(opts.tags ?? []),
|
||||||
"X-Persistent": String(opts.persistent ?? true),
|
"X-Persistent": String(opts.persistent ?? true),
|
||||||
"X-Target-Spec": opts.targetSpec ?? "",
|
"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,
|
body: data,
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
@@ -533,6 +543,20 @@ export class BrokerClient {
|
|||||||
return body.fileId;
|
return body.fileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grant a peer access to an encrypted file (owner only). */
|
||||||
|
async grantFileAccess(fileId: string, peerPubkey: string, sealedKey: string): Promise<boolean> {
|
||||||
|
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 ---
|
// --- Vectors ---
|
||||||
|
|
||||||
/** Store an embedding in a per-mesh Qdrant collection. */
|
/** Store an embedding in a per-mesh Qdrant collection. */
|
||||||
@@ -945,7 +969,12 @@ export class BrokerClient {
|
|||||||
const resolver = this.fileUrlResolvers.shift();
|
const resolver = this.fileUrlResolvers.shift();
|
||||||
if (resolver) {
|
if (resolver) {
|
||||||
if (msg.url) {
|
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 {
|
} else {
|
||||||
resolver(null);
|
resolver(null);
|
||||||
}
|
}
|
||||||
@@ -964,6 +993,11 @@ export class BrokerClient {
|
|||||||
if (resolver) resolver(accesses);
|
if (resolver) resolver(accesses);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === "grant_file_access_ok") {
|
||||||
|
const resolver = this.grantFileAccessResolvers.shift();
|
||||||
|
if (resolver) resolver(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === "vector_stored") {
|
if (msg.type === "vector_stored") {
|
||||||
const resolver = this.vectorStoredResolvers.shift();
|
const resolver = this.vectorStoredResolvers.shift();
|
||||||
if (resolver) resolver(msg.id ? String(msg.id) : null);
|
if (resolver) resolver(msg.id ? String(msg.id) : null);
|
||||||
|
|||||||
Reference in New Issue
Block a user