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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 12:32:46 +01:00
parent 579d0c3d3e
commit f7a6559429
2 changed files with 81 additions and 0 deletions

View File

@@ -31,10 +31,13 @@ import {
forgetMemory, forgetMemory,
getContext, getContext,
getFile, getFile,
getFileKey,
getFileStatus, getFileStatus,
getState, getState,
grantFileKey,
handleHookSetStatus, handleHookSetStatus,
heartbeat, heartbeat,
insertFileKeys,
joinGroup, joinGroup,
joinMesh, joinMesh,
leaveGroup, leaveGroup,
@@ -378,6 +381,9 @@ function handleUploadPost(
const tagsRaw = req.headers["x-tags"] as string | undefined; const tagsRaw = req.headers["x-tags"] as string | undefined;
const persistentRaw = req.headers["x-persistent"] as string | undefined; const persistentRaw = req.headers["x-persistent"] as string | undefined;
const targetSpec = req.headers["x-target-spec"] 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) { if (!meshId || !memberId || !fileName) {
writeJson(res, 400, { writeJson(res, 400, {
@@ -449,6 +455,14 @@ function handleUploadPost(
// mapper calls .map() on the value; non-Array iterables break it). // mapper calls .map() on the value; non-Array iterables break it).
// Skip uploadedByMember FK — memberId from the client header is the // Skip uploadedByMember FK — memberId from the client header is the
// mesh slug, not a mesh.member primary key. // 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({ const dbFileId = await uploadFile({
meshId, meshId,
name: fileName, name: fileName,
@@ -460,8 +474,21 @@ function handleUploadPost(
uploadedByName: memberId || undefined, uploadedByName: memberId || undefined,
uploadedByMember: undefined, uploadedByMember: undefined,
targetSpec: targetSpec || 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 }); writeJson(res, 200, { ok: true, fileId: dbFileId });
log.info("upload", { log.info("upload", {
route: "POST /upload", route: "POST /upload",
@@ -950,6 +977,20 @@ function handleConnection(ws: WebSocket): void {
break; 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) // Generate presigned URL (60s expiry)
const bucket = meshBucketName(conn.meshId); const bucket = meshBucketName(conn.meshId);
const presignedUrl = await minioClient.presignedGetObject( const presignedUrl = await minioClient.presignedGetObject(
@@ -971,6 +1012,8 @@ function handleConnection(ws: WebSocket): void {
fileId: gf.fileId, fileId: gf.fileId,
url: presignedUrl, url: presignedUrl,
name: file.name, name: file.name,
encrypted: file.encrypted,
sealedKey: sealedKey ?? undefined,
}); });
log.info("ws get_file", { log.info("ws get_file", {
presence_id: presenceId, presence_id: presenceId,
@@ -991,6 +1034,7 @@ function handleConnection(ws: WebSocket): void {
uploadedBy: f.uploadedBy, uploadedBy: f.uploadedBy,
uploadedAt: f.uploadedAt.toISOString(), uploadedAt: f.uploadedAt.toISOString(),
persistent: f.persistent, persistent: f.persistent,
encrypted: f.encrypted,
})), })),
}); });
log.info("ws list_files", { log.info("ws list_files", {
@@ -1017,6 +1061,23 @@ function handleConnection(ws: WebSocket): void {
}); });
break; 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": { case "delete_file": {
const df = msg as Extract<WSClientMessage, { type: "delete_file" }>; const df = msg as Extract<WSClientMessage, { type: "delete_file" }>;
await deleteFile(conn.meshId, df.fileId); await deleteFile(conn.meshId, df.fileId);

View File

@@ -404,12 +404,22 @@ export interface WSDeleteFileMessage {
fileId: string; 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. */ /** Broker → client: presigned URL for downloading a file. */
export interface WSFileUrlMessage { export interface WSFileUrlMessage {
type: "file_url"; type: "file_url";
fileId: string; fileId: string;
url: string; url: string;
name: string; name: string;
encrypted?: boolean;
sealedKey?: string;
} }
/** Broker → client: list of files in the mesh. */ /** Broker → client: list of files in the mesh. */
@@ -423,9 +433,17 @@ export interface WSFileListMessage {
uploadedBy: string; uploadedBy: string;
uploadedAt: string; uploadedAt: string;
persistent: boolean; 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. */ /** Broker → client: access log for a file. */
export interface WSFileStatusResultMessage { export interface WSFileStatusResultMessage {
type: "file_status_result"; type: "file_status_result";
@@ -627,6 +645,7 @@ export type WSClientMessage =
| WSListFilesMessage | WSListFilesMessage
| WSFileStatusMessage | WSFileStatusMessage
| WSDeleteFileMessage | WSDeleteFileMessage
| WSGrantFileAccessMessage
| WSShareContextMessage | WSShareContextMessage
| WSGetContextMessage | WSGetContextMessage
| WSListContextsMessage | WSListContextsMessage
@@ -664,6 +683,7 @@ export type WSServerMessage =
| WSFileUrlMessage | WSFileUrlMessage
| WSFileListMessage | WSFileListMessage
| WSFileStatusResultMessage | WSFileStatusResultMessage
| WSGrantFileAccessOkMessage
| WSContextSharedMessage | WSContextSharedMessage
| WSContextResultsMessage | WSContextResultsMessage
| WSContextListMessage | WSContextListMessage