diff --git a/.env.example b/.env.example index d0fe7e5..c0eef8e 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,4 @@ JWT_ACCESS_TOKEN_EXPIRES= JWT_REFRESH_TOKEN_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES= TOKEN_UPGRADE_CHALLENGE_EXPIRES= +LIBRARY_PATH= diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index e5285dc..a85dcab 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -13,6 +13,17 @@ export interface NewDirectroyParams { encNameIv: string; } +export interface NewFileParams { + path: string; + parentId: DirectroyId; + userId: number; + mekVersion: number; + encDek: string; + encContentIv: string; + encName: string; + encNameIv: string; +} + export const registerNewDirectory = async (params: NewDirectroyParams) => { return await db.transaction(async (tx) => { const meks = await tx @@ -58,6 +69,31 @@ export const getDirectory = async (userId: number, directoryId: number) => { return res[0] ?? null; }; +export const registerNewFile = async (params: NewFileParams) => { + await db.transaction(async (tx) => { + const meks = await tx + .select() + .from(mek) + .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))); + if (meks[0]?.version !== params.mekVersion) { + throw new Error("Invalid MEK version"); + } + + const now = new Date(); + await tx.insert(file).values({ + path: params.path, + parentId: params.parentId === "root" ? null : params.parentId, + createdAt: now, + userId: params.userId, + mekVersion: params.mekVersion, + encDek: params.encDek, + encryptedAt: now, + encContentIv: params.encContentIv, + encName: { ciphertext: params.encName, iv: params.encNameIv }, + }); + }); +}; + export const getAllFilesByParent = async (userId: number, parentId: DirectroyId) => { return await db .select() @@ -70,3 +106,12 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectroyId) ) .execute(); }; + +export const getFile = async (userId: number, fileId: number) => { + const res = await db + .select() + .from(file) + .where(and(eq(file.userId, userId), eq(file.id, fileId))) + .execute(); + return res[0] ?? null; +}; diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index b5c41fd..b0bf7f8 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -47,6 +47,7 @@ export const file = sqliteTable( mekVersion: integer("master_encryption_key_version").notNull(), encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64 encryptedAt: integer("encrypted_at", { mode: "timestamp_ms" }).notNull(), + encContentIv: text("encrypted_content_iv").notNull(), // Base64 encName: ciphertext("encrypted_name").notNull(), }, (t) => ({ diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index 58f6e72..40aa88e 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -16,4 +16,5 @@ export default { userClientExp: env.USER_CLIENT_CHALLENGE_EXPIRES || "5m", tokenUpgradeExp: env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m", }, + libraryPath: env.LIBRARY_PATH || "library", }; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts new file mode 100644 index 0000000..a9ba7e4 --- /dev/null +++ b/src/lib/server/schemas/file.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const fileInfoResponse = z.object({ + createdAt: z.date(), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + contentIv: z.string().base64().nonempty(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type FileInfoResponse = z.infer; + +export const fileUploadRequest = z.object({ + parentId: z.union([z.enum(["root"]), z.number().int().positive()]), + mekVersion: z.number().int().positive(), + dek: z.string().base64().nonempty(), + contentHash: z.string().base64().nonempty(), + contentIv: z.string().base64().nonempty(), + name: z.string().base64().nonempty(), + nameIv: z.string().base64().nonempty(), +}); +export type FileUploadRequest = z.infer; diff --git a/src/lib/server/schemas/index.ts b/src/lib/server/schemas/index.ts index 0328467..cd2a366 100644 --- a/src/lib/server/schemas/index.ts +++ b/src/lib/server/schemas/index.ts @@ -1,4 +1,5 @@ export * from "./auth"; export * from "./client"; export * from "./directory"; +export * from "./file"; export * from "./mek"; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index 8cabf7b..fda36a6 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -1,12 +1,21 @@ import { error } from "@sveltejs/kit"; +import { createHash } from "crypto"; +import { createReadStream, createWriteStream, ReadStream, WriteStream } from "fs"; +import { mkdir, stat, unlink } from "fs/promises"; +import { dirname } from "path"; +import { v4 as uuidv4 } from "uuid"; import { getAllDirectoriesByParent, registerNewDirectory, getDirectory, + registerNewFile, getAllFilesByParent, + getFile, type NewDirectroyParams, + type NewFileParams, } from "$lib/server/db/file"; import { getActiveMekVersion } from "$lib/server/db/mek"; +import env from "$lib/server/loadenv"; export const getDirectroyInformation = async (userId: number, directroyId: "root" | number) => { const directory = directroyId !== "root" ? await getDirectory(userId, directroyId) : undefined; @@ -39,3 +48,113 @@ export const createDirectory = async (params: NewDirectroyParams) => { await registerNewDirectory(params); }; + +const convertToReadableStream = (readStream: ReadStream) => { + return new ReadableStream({ + start: (controller) => { + readStream.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer))); + readStream.on("end", () => controller.close()); + readStream.on("error", (e) => controller.error(e)); + }, + cancel: () => { + readStream.destroy(); + }, + }); +}; + +export const getFileStream = async (userId: number, fileId: number) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } + + const { size } = await stat(file.path); + return { + encContentStream: convertToReadableStream(createReadStream(file.path)), + encContentSize: size, + }; +}; + +export const getFileInformation = async (userId: number, fileId: number) => { + const file = await getFile(userId, fileId); + if (!file) { + error(404, "Invalid file id"); + } + + return { + createdAt: file.createdAt, + mekVersion: file.mekVersion, + encDek: file.encDek, + encContentIv: file.encContentIv, + encName: file.encName, + }; +}; + +const convertToWritableStream = (writeStream: WriteStream) => { + return new WritableStream({ + write: (chunk) => + new Promise((resolve, reject) => { + writeStream.write(chunk, (e) => { + if (e) { + reject(e); + } else { + resolve(); + } + }); + }), + close: () => new Promise((resolve) => writeStream.end(resolve)), + }); +}; + +const safeUnlink = async (path: string) => { + await unlink(path).catch(console.error); +}; + +export const uploadFile = async ( + params: Omit, + encContentStream: ReadableStream, + encContentHash: string, +) => { + const activeMekVersion = await getActiveMekVersion(params.userId); + if (activeMekVersion === null) { + error(500, "Invalid MEK version"); + } else if (activeMekVersion !== params.mekVersion) { + error(400, "Invalid MEK version"); + } + + const path = `${env.libraryPath}/${params.userId}/${uuidv4()}`; + const hash = createHash("sha256"); + + await mkdir(dirname(path), { recursive: true }); + + try { + const hashStream = new TransformStream({ + transform: (chunk, controller) => { + hash.update(chunk); + controller.enqueue(chunk); + }, + }); + const fileStream = convertToWritableStream( + createWriteStream(path, { flags: "wx", mode: 0o600 }), + ); + await encContentStream.pipeThrough(hashStream).pipeTo(fileStream); + } catch (e) { + await safeUnlink(path); + throw e; + } + + if (hash.digest("base64") !== encContentHash) { + await safeUnlink(path); + error(400, "Invalid content hash"); + } + + try { + await registerNewFile({ + ...params, + path, + }); + } catch (e) { + await safeUnlink(path); + throw e; + } +}; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 9f652df..fbe42c0 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,7 +1,7 @@ import { error, text } from "@sveltejs/kit"; import ms from "ms"; import env from "$lib/server/loadenv"; -import { loginRequest } from "$lib/server/schemas/auth"; +import { loginRequest } from "$lib/server/schemas"; import { login } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/auth/upgradeToken/+server.ts b/src/routes/api/auth/upgradeToken/+server.ts index 0436f22..cb09582 100644 --- a/src/routes/api/auth/upgradeToken/+server.ts +++ b/src/routes/api/auth/upgradeToken/+server.ts @@ -3,7 +3,7 @@ import { tokenUpgradeRequest, tokenUpgradeResponse, type TokenUpgradeResponse, -} from "$lib/server/schemas/auth"; +} from "$lib/server/schemas"; import { createTokenUpgradeChallenge } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/auth/upgradeToken/verify/+server.ts b/src/routes/api/auth/upgradeToken/verify/+server.ts index 8abc130..df001ed 100644 --- a/src/routes/api/auth/upgradeToken/verify/+server.ts +++ b/src/routes/api/auth/upgradeToken/verify/+server.ts @@ -1,7 +1,7 @@ import { error, text } from "@sveltejs/kit"; import ms from "ms"; import env from "$lib/server/loadenv"; -import { tokenUpgradeVerifyRequest } from "$lib/server/schemas/auth"; +import { tokenUpgradeVerifyRequest } from "$lib/server/schemas"; import { upgradeToken } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/client/list/+server.ts b/src/routes/api/client/list/+server.ts index 72f09d8..47fa5ab 100644 --- a/src/routes/api/client/list/+server.ts +++ b/src/routes/api/client/list/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { authenticate } from "$lib/server/modules/auth"; -import { clientListResponse, type ClientListResponse } from "$lib/server/schemas/client"; +import { clientListResponse, type ClientListResponse } from "$lib/server/schemas"; import { getUserClientList } from "$lib/server/services/client"; import type { RequestHandler } from "@sveltejs/kit"; diff --git a/src/routes/api/client/register/+server.ts b/src/routes/api/client/register/+server.ts index 3a9f884..0a6e5a0 100644 --- a/src/routes/api/client/register/+server.ts +++ b/src/routes/api/client/register/+server.ts @@ -4,7 +4,7 @@ import { clientRegisterRequest, clientRegisterResponse, type ClientRegisterResponse, -} from "$lib/server/schemas/client"; +} from "$lib/server/schemas"; import { registerUserClient } from "$lib/server/services/client"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/client/register/verify/+server.ts b/src/routes/api/client/register/verify/+server.ts index a2f2f9c..f261497 100644 --- a/src/routes/api/client/register/verify/+server.ts +++ b/src/routes/api/client/register/verify/+server.ts @@ -1,6 +1,6 @@ import { error, text } from "@sveltejs/kit"; import { authenticate } from "$lib/server/modules/auth"; -import { clientRegisterVerifyRequest } from "$lib/server/schemas/client"; +import { clientRegisterVerifyRequest } from "$lib/server/schemas"; import { verifyUserClient } from "$lib/server/services/client"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/client/status/+server.ts b/src/routes/api/client/status/+server.ts index 1eed893..3b413b7 100644 --- a/src/routes/api/client/status/+server.ts +++ b/src/routes/api/client/status/+server.ts @@ -1,6 +1,6 @@ import { error, json } from "@sveltejs/kit"; import { authenticate } from "$lib/server/modules/auth"; -import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas/client"; +import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas"; import { getUserClientStatus } from "$lib/server/services/client"; import type { RequestHandler } from "@sveltejs/kit"; diff --git a/src/routes/api/directory/[id]/+server.ts b/src/routes/api/directory/[id]/+server.ts index 361d4dd..108a3b1 100644 --- a/src/routes/api/directory/[id]/+server.ts +++ b/src/routes/api/directory/[id]/+server.ts @@ -1,7 +1,7 @@ import { error, json } from "@sveltejs/kit"; import { z } from "zod"; import { authorize } from "$lib/server/modules/auth"; -import { directroyInfoResponse, type DirectroyInfoResponse } from "$lib/server/schemas/directory"; +import { directroyInfoResponse, type DirectroyInfoResponse } from "$lib/server/schemas"; import { getDirectroyInformation } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/directory/create/+server.ts b/src/routes/api/directory/create/+server.ts index fd7ae7d..ca13705 100644 --- a/src/routes/api/directory/create/+server.ts +++ b/src/routes/api/directory/create/+server.ts @@ -1,7 +1,7 @@ import { text } from "@sveltejs/kit"; import { authorize } from "$lib/server/modules/auth"; import { parseSignedRequest } from "$lib/server/modules/crypto"; -import { directoryCreateRequest } from "$lib/server/schemas/directory"; +import { directoryCreateRequest } from "$lib/server/schemas"; import { createDirectory } from "$lib/server/services/file"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/file/[id]/+server.ts b/src/routes/api/file/[id]/+server.ts new file mode 100644 index 0000000..7c83075 --- /dev/null +++ b/src/routes/api/file/[id]/+server.ts @@ -0,0 +1,33 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileInfoResponse, type FileInfoResponse } from "$lib/server/schemas"; +import { getFileInformation } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { createdAt, mekVersion, encDek, encContentIv, encName } = await getFileInformation( + userId, + id, + ); + return json( + fileInfoResponse.parse({ + createdAt, + mekVersion, + dek: encDek, + contentIv: encContentIv, + name: encName.ciphertext, + nameIv: encName.iv, + } satisfies FileInfoResponse), + ); +}; diff --git a/src/routes/api/file/[id]/download/+server.ts b/src/routes/api/file/[id]/download/+server.ts new file mode 100644 index 0000000..42b832f --- /dev/null +++ b/src/routes/api/file/[id]/download/+server.ts @@ -0,0 +1,25 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { getFileStream } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ cookies, params }) => { + const { userId } = await authorize(cookies, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { encContentStream, encContentSize } = await getFileStream(userId, id); + return new Response(encContentStream, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": encContentSize.toString(), + }, + }); +}; diff --git a/src/routes/api/file/upload/+server.ts b/src/routes/api/file/upload/+server.ts new file mode 100644 index 0000000..1cf9e87 --- /dev/null +++ b/src/routes/api/file/upload/+server.ts @@ -0,0 +1,39 @@ +import { error, text } from "@sveltejs/kit"; +import { authorize } from "$lib/server/modules/auth"; +import { parseSignedRequest } from "$lib/server/modules/crypto"; +import { fileUploadRequest } from "$lib/server/schemas"; +import { uploadFile } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const { userId, clientId } = await authorize(cookies, "activeClient"); + + const form = await request.formData(); + + const metadata = form.get("metadata"); + if (!metadata || typeof metadata !== "string") { + error(400, "Invalid request body"); + } + const { parentId, mekVersion, dek, contentHash, contentIv, name, nameIv } = + await parseSignedRequest(clientId, JSON.parse(metadata), fileUploadRequest); + + const content = form.get("content"); + if (!content || !(content instanceof File)) { + error(400, "Invalid request body"); + } + + await uploadFile( + { + userId, + parentId, + mekVersion, + encDek: dek, + encContentIv: contentIv, + encName: name, + encNameIv: nameIv, + }, + content.stream(), + contentHash, + ); + return text("File uploaded", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/mek/list/+server.ts b/src/routes/api/mek/list/+server.ts index 3effea3..ccb9fa3 100644 --- a/src/routes/api/mek/list/+server.ts +++ b/src/routes/api/mek/list/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { authorize } from "$lib/server/modules/auth"; -import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/schemas/mek"; +import { masterKeyListResponse, type MasterKeyListResponse } from "$lib/server/schemas"; import { getClientMekList } from "$lib/server/services/mek"; import type { RequestHandler } from "./$types"; diff --git a/src/routes/api/mek/register/initial/+server.ts b/src/routes/api/mek/register/initial/+server.ts index be1c8ee..c39ef37 100644 --- a/src/routes/api/mek/register/initial/+server.ts +++ b/src/routes/api/mek/register/initial/+server.ts @@ -1,7 +1,7 @@ import { error, text } from "@sveltejs/kit"; import { authenticate } from "$lib/server/modules/auth"; import { parseSignedRequest } from "$lib/server/modules/crypto"; -import { initialMasterKeyRegisterRequest } from "$lib/server/schemas/mek"; +import { initialMasterKeyRegisterRequest } from "$lib/server/schemas"; import { registerInitialActiveMek } from "$lib/server/services/mek"; import type { RequestHandler } from "./$types";