From f7a65594297857cb9d8edde9105dc103c7f25b35 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:32:46 +0100 Subject: [PATCH] feat(broker): add E2E file encryption to HTTP upload and WS handlers - parse x-encrypted/x-owner-pubkey/x-file-keys headers in handleUploadPost - pass encrypted and ownerPubkey to uploadFile, call insertFileKeys after - get_file: fetch sealedKey for non-owners, block if missing, include in response - list_files: include encrypted field per file - add grant_file_access WS handler so owners can seal keys for peers - update types.ts with new message interfaces and union members Co-Authored-By: Claude Sonnet 4.6 --- apps/broker/src/index.ts | 61 ++++++++++++++++++++++++++++++++++++++++ apps/broker/src/types.ts | 20 +++++++++++++ 2 files changed, 81 insertions(+) diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 360fe7e..67dcd1f 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -31,10 +31,13 @@ import { forgetMemory, getContext, getFile, + getFileKey, getFileStatus, getState, + grantFileKey, handleHookSetStatus, heartbeat, + insertFileKeys, joinGroup, joinMesh, leaveGroup, @@ -378,6 +381,9 @@ function handleUploadPost( const tagsRaw = req.headers["x-tags"] as string | undefined; const persistentRaw = req.headers["x-persistent"] as string | undefined; const targetSpec = req.headers["x-target-spec"] as string | undefined; + const encryptedRaw = req.headers["x-encrypted"] as string | undefined; + const ownerPubkey = req.headers["x-owner-pubkey"] as string | undefined; + const fileKeysRaw = req.headers["x-file-keys"] as string | undefined; if (!meshId || !memberId || !fileName) { writeJson(res, 400, { @@ -449,6 +455,14 @@ function handleUploadPost( // mapper calls .map() on the value; non-Array iterables break it). // Skip uploadedByMember FK — memberId from the client header is the // mesh slug, not a mesh.member primary key. + const encrypted = encryptedRaw === "true"; + let fileKeys: Array<{ peerPubkey: string; sealedKey: string }> = []; + if (encrypted && fileKeysRaw) { + try { + fileKeys = JSON.parse(fileKeysRaw); + } catch { /* ignore */ } + } + const dbFileId = await uploadFile({ meshId, name: fileName, @@ -460,8 +474,21 @@ function handleUploadPost( uploadedByName: memberId || undefined, uploadedByMember: undefined, targetSpec: targetSpec || undefined, + encrypted: encrypted || false, + ownerPubkey: ownerPubkey || undefined, }); + if (encrypted && fileKeys.length > 0) { + await insertFileKeys( + dbFileId, + fileKeys.map((k) => ({ + peerPubkey: k.peerPubkey, + sealedKey: k.sealedKey, + grantedByPubkey: ownerPubkey, + })), + ); + } + writeJson(res, 200, { ok: true, fileId: dbFileId }); log.info("upload", { route: "POST /upload", @@ -950,6 +977,20 @@ function handleConnection(ws: WebSocket): void { break; } } + // E2E: for encrypted files, fetch the sealed key for this peer + let sealedKey: string | null = null; + if (file.encrypted) { + const peerPubkey = conn.sessionPubkey ?? conn.memberPubkey; + const isOwner = file.ownerPubkey && peerPubkey === file.ownerPubkey; + if (!isOwner) { + sealedKey = peerPubkey ? await getFileKey(gf.fileId, peerPubkey) : null; + if (!sealedKey) { + sendError(conn.ws, "forbidden", "no decryption key for this file"); + break; + } + } + // Owner gets sealedKey = null (they already have Kf from upload) + } // Generate presigned URL (60s expiry) const bucket = meshBucketName(conn.meshId); const presignedUrl = await minioClient.presignedGetObject( @@ -971,6 +1012,8 @@ function handleConnection(ws: WebSocket): void { fileId: gf.fileId, url: presignedUrl, name: file.name, + encrypted: file.encrypted, + sealedKey: sealedKey ?? undefined, }); log.info("ws get_file", { presence_id: presenceId, @@ -991,6 +1034,7 @@ function handleConnection(ws: WebSocket): void { uploadedBy: f.uploadedBy, uploadedAt: f.uploadedAt.toISOString(), persistent: f.persistent, + encrypted: f.encrypted, })), }); log.info("ws list_files", { @@ -1017,6 +1061,23 @@ function handleConnection(ws: WebSocket): void { }); break; } + case "grant_file_access": { + const gfa = msg as { type: "grant_file_access"; fileId: string; peerPubkey: string; sealedKey: string }; + const file = await getFile(conn.meshId, gfa.fileId); + if (!file) { + sendError(conn.ws, "not_found", "file not found"); + break; + } + const requestorPubkey = conn.sessionPubkey ?? conn.memberPubkey; + if (file.ownerPubkey && file.ownerPubkey !== requestorPubkey) { + sendError(conn.ws, "forbidden", "only the file owner can grant access"); + break; + } + await grantFileKey(gfa.fileId, gfa.peerPubkey, gfa.sealedKey, requestorPubkey ?? undefined); + sendToPeer(presenceId, { type: "grant_file_access_ok", fileId: gfa.fileId, peerPubkey: gfa.peerPubkey }); + log.info("ws grant_file_access", { presence_id: presenceId, file_id: gfa.fileId, peer: gfa.peerPubkey }); + break; + } case "delete_file": { const df = msg as Extract; await deleteFile(conn.meshId, df.fileId); diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index 502a858..561424e 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -404,12 +404,22 @@ export interface WSDeleteFileMessage { fileId: string; } +/** Client → broker: grant a peer access to an encrypted file. */ +export interface WSGrantFileAccessMessage { + type: "grant_file_access"; + fileId: string; + peerPubkey: string; + sealedKey: string; +} + /** Broker → client: presigned URL for downloading a file. */ export interface WSFileUrlMessage { type: "file_url"; fileId: string; url: string; name: string; + encrypted?: boolean; + sealedKey?: string; } /** Broker → client: list of files in the mesh. */ @@ -423,9 +433,17 @@ export interface WSFileListMessage { uploadedBy: string; uploadedAt: string; persistent: boolean; + encrypted: boolean; }>; } +/** Broker → client: acknowledgement for grant_file_access. */ +export interface WSGrantFileAccessOkMessage { + type: "grant_file_access_ok"; + fileId: string; + peerPubkey: string; +} + /** Broker → client: access log for a file. */ export interface WSFileStatusResultMessage { type: "file_status_result"; @@ -627,6 +645,7 @@ export type WSClientMessage = | WSListFilesMessage | WSFileStatusMessage | WSDeleteFileMessage + | WSGrantFileAccessMessage | WSShareContextMessage | WSGetContextMessage | WSListContextsMessage @@ -664,6 +683,7 @@ export type WSServerMessage = | WSFileUrlMessage | WSFileListMessage | WSFileStatusResultMessage + | WSGrantFileAccessOkMessage | WSContextSharedMessage | WSContextResultsMessage | WSContextListMessage