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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user