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,
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<WSClientMessage, { type: "delete_file" }>;
await deleteFile(conn.meshId, df.fileId);

View File

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