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