From a3c169f70624869251889ef172c3a13474100052 Mon Sep 17 00:00:00 2001 From: static Date: Mon, 20 Jan 2025 16:05:35 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=EC=9D=98=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20Kysely=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20(WiP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- docker-compose.dev.yaml | 15 + docker-compose.yaml | 19 +- package.json | 1 + src/lib/server/db/client.ts | 234 +++++++++----- src/lib/server/db/file.ts | 499 +++++++++++++++++------------ src/lib/server/db/hsk.ts | 80 +++-- src/lib/server/db/kysely.ts | 7 +- src/lib/server/db/mek.ts | 127 +++++--- src/lib/server/db/schema/client.ts | 4 +- src/lib/server/db/schema/file.ts | 6 +- src/lib/server/db/schema/hsk.ts | 4 +- src/lib/server/db/schema/index.ts | 1 + src/lib/server/db/schema/mek.ts | 4 +- src/lib/server/db/session.ts | 125 ++++---- src/lib/server/db/user.ts | 39 ++- src/lib/server/loadenv.ts | 9 +- 17 files changed, 724 insertions(+), 456 deletions(-) create mode 100644 docker-compose.dev.yaml diff --git a/.env.example b/.env.example index 128bd9f..f492443 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,12 @@ # Required environment variables +DATABASE_PASSWORD= SESSION_SECRET= # Optional environment variables -DATABASE_URL= +DATABASE_HOST= +DATABASE_PORT= +DATABASE_USER= +DATABASE_NAME= SESSION_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES= diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..f6570a5 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,15 @@ +services: + database: + image: postgres:17.2 + restart: on-failure + volumes: + - database:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${DATABASE_USER:-} + - POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} # Required + - POSTGRES_DB=${DATABASE_NAME:-} + ports: + - ${DATABASE_PORT:-5432}:5432 + +volumes: + database: diff --git a/docker-compose.yaml b/docker-compose.yaml index aecd8c8..b14f0df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,13 +1,17 @@ services: server: build: . - restart: unless-stopped + restart: on-failure + depends_on: + - database user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} volumes: - - ./data:/app/data + - ./data/library:/app/data/library environment: # ArkVault - - DATABASE_URL=/app/data/database.sqlite + - DATABASE_HOST=database + - DATABASE_USER=arkvault + - DATABASE_PASSWORD=${DATABASE_PASSWORD:?} # Required - SESSION_SECRET=${SESSION_SECRET:?} # Required - SESSION_EXPIRES - USER_CLIENT_CHALLENGE_EXPIRES @@ -19,3 +23,12 @@ services: - NODE_ENV=${NODE_ENV:-production} ports: - ${PORT:-80}:3000 + + database: + image: postgres:17.2-alpine + restart: on-failure + volumes: + - ./data/database:/var/lib/postgresql/data + environment: + - POSTGRES_USER=arkvault + - POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} diff --git a/package.json b/package.json index 02c4be9..b71f912 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", + "dev:db": "docker compose -f docker-compose.dev.yaml up -d", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index 37a1054..4fd23f3 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -1,53 +1,97 @@ -import { SqliteError } from "better-sqlite3"; -import { and, or, eq, gt, lte } from "drizzle-orm"; -import db from "./drizzle"; +import { DatabaseError } from "pg"; import { IntegrityError } from "./error"; -import { client, userClient, userClientChallenge } from "./schema"; +import db from "./kysely"; +import type { UserClientState } from "./schema"; + +interface Client { + id: number; + encPubKey: string; + sigPubKey: string; +} + +interface UserClient { + userId: number; + clientId: number; + state: UserClientState; +} + +interface UserClientWithDetails extends UserClient { + encPubKey: string; + sigPubKey: string; +} export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { - return await db.transaction( - async (tx) => { - const clients = await tx - .select({ id: client.id }) - .from(client) - .where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey))) - .limit(1); - if (clients.length !== 0) { + return await db + .transaction() + .setIsolationLevel("serializable") + .execute(async (trx) => { + const client = await trx + .selectFrom("client") + .where((eb) => + eb.or([ + eb("encryption_public_key", "=", encPubKey), + eb("encryption_public_key", "=", sigPubKey), + eb("signature_public_key", "=", encPubKey), + eb("signature_public_key", "=", sigPubKey), + ]), + ) + .limit(1) + .executeTakeFirst(); + if (client) { throw new IntegrityError("Public key(s) already registered"); } - const newClients = await tx - .insert(client) - .values({ encPubKey, sigPubKey }) - .returning({ id: client.id }); - const { id: clientId } = newClients[0]!; - await tx.insert(userClient).values({ userId, clientId }); - - return clientId; - }, - { behavior: "exclusive" }, - ); + const { clientId } = await trx + .insertInto("client") + .values({ encryption_public_key: encPubKey, signature_public_key: sigPubKey }) + .returning("id as clientId") + .executeTakeFirstOrThrow(); + await trx + .insertInto("user_client") + .values({ user_id: userId, client_id: clientId }) + .execute(); + return { clientId }; + }); }; export const getClient = async (clientId: number) => { - const clients = await db.select().from(client).where(eq(client.id, clientId)).limit(1); - return clients[0] ?? null; + const client = await db + .selectFrom("client") + .selectAll() + .where("id", "=", clientId) + .limit(1) + .executeTakeFirst(); + return client + ? ({ + id: client.id, + encPubKey: client.encryption_public_key, + sigPubKey: client.signature_public_key, + } satisfies Client) + : null; }; export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => { - const clients = await db - .select() - .from(client) - .where(and(eq(client.encPubKey, encPubKey), eq(client.sigPubKey, sigPubKey))) - .limit(1); - return clients[0] ?? null; + const client = await db + .selectFrom("client") + .selectAll() + .where("encryption_public_key", "=", encPubKey) + .where("signature_public_key", "=", sigPubKey) + .limit(1) + .executeTakeFirst(); + return client + ? ({ + id: client.id, + encPubKey: client.encryption_public_key, + sigPubKey: client.signature_public_key, + } satisfies Client) + : null; }; export const createUserClient = async (userId: number, clientId: number) => { try { - await db.insert(userClient).values({ userId, clientId }); + await db.insertInto("user_client").values({ user_id: userId, client_id: clientId }).execute(); } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { + if (e instanceof DatabaseError && e.code === "23505") { throw new IntegrityError("User client already exists"); } throw e; @@ -55,52 +99,76 @@ export const createUserClient = async (userId: number, clientId: number) => { }; export const getAllUserClients = async (userId: number) => { - return await db.select().from(userClient).where(eq(userClient.userId, userId)); + const userClients = await db + .selectFrom("user_client") + .selectAll() + .where("user_id", "=", userId) + .execute(); + return userClients.map( + ({ user_id, client_id, state }) => + ({ + userId: user_id, + clientId: client_id, + state, + }) satisfies UserClient, + ); }; export const getUserClient = async (userId: number, clientId: number) => { - const userClients = await db - .select() - .from(userClient) - .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) - .limit(1); - return userClients[0] ?? null; + const userClient = await db + .selectFrom("user_client") + .selectAll() + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .limit(1) + .executeTakeFirst(); + return userClient + ? ({ + userId: userClient.user_id, + clientId: userClient.client_id, + state: userClient.state, + } satisfies UserClient) + : null; }; export const getUserClientWithDetails = async (userId: number, clientId: number) => { - const userClients = await db - .select() - .from(userClient) - .innerJoin(client, eq(userClient.clientId, client.id)) - .where(and(eq(userClient.userId, userId), eq(userClient.clientId, clientId))) - .limit(1); - return userClients[0] ?? null; + const userClient = await db + .selectFrom("user_client") + .innerJoin("client", "user_client.client_id", "client.id") + .selectAll() + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .limit(1) + .executeTakeFirst(); + return userClient + ? ({ + userId: userClient.user_id, + clientId: userClient.client_id, + state: userClient.state, + encPubKey: userClient.encryption_public_key, + sigPubKey: userClient.signature_public_key, + } satisfies UserClientWithDetails) + : null; }; export const setUserClientStateToPending = async (userId: number, clientId: number) => { await db - .update(userClient) + .updateTable("user_client") .set({ state: "pending" }) - .where( - and( - eq(userClient.userId, userId), - eq(userClient.clientId, clientId), - eq(userClient.state, "challenging"), - ), - ); + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .where("state", "=", "challenging") + .execute(); }; export const setUserClientStateToActive = async (userId: number, clientId: number) => { await db - .update(userClient) + .updateTable("user_client") .set({ state: "active" }) - .where( - and( - eq(userClient.userId, userId), - eq(userClient.clientId, clientId), - eq(userClient.state, "pending"), - ), - ); + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .where("state", "=", "pending") + .execute(); }; export const registerUserClientChallenge = async ( @@ -110,30 +178,30 @@ export const registerUserClientChallenge = async ( allowedIp: string, expiresAt: Date, ) => { - await db.insert(userClientChallenge).values({ - userId, - clientId, - answer, - allowedIp, - expiresAt, - }); + await db + .insertInto("user_client_challenge") + .values({ + user_id: userId, + client_id: clientId, + answer, + allowed_ip: allowedIp, + expires_at: expiresAt, + }) + .execute(); }; export const consumeUserClientChallenge = async (userId: number, answer: string, ip: string) => { - const challenges = await db - .delete(userClientChallenge) - .where( - and( - eq(userClientChallenge.userId, userId), - eq(userClientChallenge.answer, answer), - eq(userClientChallenge.allowedIp, ip), - gt(userClientChallenge.expiresAt, new Date()), - ), - ) - .returning({ clientId: userClientChallenge.clientId }); - return challenges[0] ?? null; + const challenge = await db + .deleteFrom("user_client_challenge") + .where("user_id", "=", userId) + .where("answer", "=", answer) + .where("allowed_ip", "=", ip) + .where("expires_at", ">", new Date()) + .returning("client_id") + .executeTakeFirst(); + return challenge ? { clientId: challenge.client_id } : null; }; export const cleanupExpiredUserClientChallenges = async () => { - await db.delete(userClientChallenge).where(lte(userClientChallenge.expiresAt, new Date())); + await db.deleteFrom("user_client_challenge").where("expires_at", "<=", new Date()).execute(); }; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 6bd0452..a893b7d 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,21 +1,23 @@ -import { and, eq, isNull } from "drizzle-orm"; -import db from "./drizzle"; import { IntegrityError } from "./error"; -import { directory, directoryLog, file, fileLog, hsk, mek } from "./schema"; +import db from "./kysely"; +import type { Ciphertext } from "./schema"; type DirectoryId = "root" | number; -export interface NewDirectoryParams { +interface Directory { + id: number; parentId: DirectoryId; userId: number; mekVersion: number; encDek: string; dekVersion: Date; - encName: string; - encNameIv: string; + encName: Ciphertext; } -export interface NewFileParams { +export type NewDirectory = Omit; + +interface File { + id: number; parentId: DirectoryId; userId: number; path: string; @@ -27,217 +29,264 @@ export interface NewFileParams { contentType: string; encContentIv: string; encContentHash: string; - encName: string; - encNameIv: string; - encCreatedAt: string | null; - encCreatedAtIv: string | null; - encLastModifiedAt: string; - encLastModifiedAtIv: string; + encName: Ciphertext; + encCreatedAt: Ciphertext | null; + encLastModifiedAt: Ciphertext; } -export const registerDirectory = async (params: NewDirectoryParams) => { - await db.transaction( - async (tx) => { - const meks = await tx - .select({ version: mek.version }) - .from(mek) - .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))) - .limit(1); - if (meks[0]?.version !== params.mekVersion) { - throw new IntegrityError("Inactive MEK version"); - } +export type NewFile = Omit; - const newDirectories = await tx - .insert(directory) - .values({ - parentId: params.parentId === "root" ? null : params.parentId, - userId: params.userId, - mekVersion: params.mekVersion, - encDek: params.encDek, - dekVersion: params.dekVersion, - encName: { ciphertext: params.encName, iv: params.encNameIv }, - }) - .returning({ id: directory.id }); - const { id: directoryId } = newDirectories[0]!; - await tx.insert(directoryLog).values({ - directoryId, +export const registerDirectory = async (params: NewDirectory) => { + await db.transaction().execute(async (trx) => { + const mek = await trx + .selectFrom("master_encryption_key") + .select("version") + .where("user_id", "=", params.userId) + .where("state", "=", "active") + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (mek?.version !== params.mekVersion) { + throw new IntegrityError("Inactive MEK version"); + } + + const { directoryId } = await trx + .insertInto("directory") + .values({ + parent_id: params.parentId !== "root" ? params.parentId : null, + user_id: params.userId, + master_encryption_key_version: params.mekVersion, + encrypted_data_encryption_key: params.encDek, + data_encryption_key_version: params.dekVersion, + encrypted_name: params.encName, + }) + .returning("id as directoryId") + .executeTakeFirstOrThrow(); + await trx + .insertInto("directory_log") + .values({ + directory_id: directoryId, timestamp: new Date(), action: "create", - newName: { ciphertext: params.encName, iv: params.encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: params.encName, + }) + .execute(); + }); }; export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => { - return await db - .select() - .from(directory) - .where( - and( - eq(directory.userId, userId), - parentId === "root" ? isNull(directory.parentId) : eq(directory.parentId, parentId), - ), - ); + let query = db.selectFrom("directory").selectAll().where("user_id", "=", userId); + query = + parentId === "root" + ? query.where("parent_id", "is", null) + : query.where("parent_id", "=", parentId); + const directories = await query.execute(); + return directories.map( + (directory) => + ({ + id: directory.id, + parentId: directory.parent_id ?? "root", + userId: directory.user_id, + mekVersion: directory.master_encryption_key_version, + encDek: directory.encrypted_data_encryption_key, + dekVersion: directory.data_encryption_key_version, + encName: directory.encrypted_name, + }) satisfies Directory, + ); }; export const getDirectory = async (userId: number, directoryId: number) => { - const res = await db - .select() - .from(directory) - .where(and(eq(directory.userId, userId), eq(directory.id, directoryId))) - .limit(1); - return res[0] ?? null; + const directory = await db + .selectFrom("directory") + .selectAll() + .where("id", "=", directoryId) + .where("user_id", "=", userId) + .limit(1) + .executeTakeFirst(); + return directory + ? ({ + id: directory.id, + parentId: directory.parent_id ?? "root", + userId: directory.user_id, + mekVersion: directory.master_encryption_key_version, + encDek: directory.encrypted_data_encryption_key, + dekVersion: directory.data_encryption_key_version, + encName: directory.encrypted_name, + } satisfies Directory) + : null; }; export const setDirectoryEncName = async ( userId: number, directoryId: number, dekVersion: Date, - encName: string, - encNameIv: string, + encName: Ciphertext, ) => { - await db.transaction( - async (tx) => { - const directories = await tx - .select({ version: directory.dekVersion }) - .from(directory) - .where(and(eq(directory.userId, userId), eq(directory.id, directoryId))) - .limit(1); - if (!directories[0]) { - throw new IntegrityError("Directory not found"); - } else if (directories[0].version.getTime() !== dekVersion.getTime()) { - throw new IntegrityError("Invalid DEK version"); - } + await db.transaction().execute(async (trx) => { + const directory = await trx + .selectFrom("directory") + .select("data_encryption_key_version") + .where("id", "=", directoryId) + .where("user_id", "=", userId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!directory) { + throw new IntegrityError("Directory not found"); + } else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } - await tx - .update(directory) - .set({ encName: { ciphertext: encName, iv: encNameIv } }) - .where(and(eq(directory.userId, userId), eq(directory.id, directoryId))); - await tx.insert(directoryLog).values({ - directoryId, + await trx + .updateTable("directory") + .set({ encrypted_name: encName }) + .where("id", "=", directoryId) + .where("user_id", "=", userId) + .execute(); + await trx + .insertInto("directory_log") + .values({ + directory_id: directoryId, timestamp: new Date(), action: "rename", - newName: { ciphertext: encName, iv: encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: encName, + }) + .execute(); + }); }; export const unregisterDirectory = async (userId: number, directoryId: number) => { - return await db.transaction( - async (tx) => { + return await db + .transaction() + .setIsolationLevel("repeatable read") // TODO: Sufficient? + .execute(async (trx) => { const unregisterFiles = async (parentId: number) => { - return await tx - .delete(file) - .where(and(eq(file.userId, userId), eq(file.parentId, parentId))) - .returning({ id: file.id, path: file.path }); + return await trx + .deleteFrom("file") + .where("parent_id", "=", parentId) + .where("user_id", "=", userId) + .returning(["id", "path"]) + .execute(); }; const unregisterDirectoryRecursively = async ( directoryId: number, ): Promise<{ id: number; path: string }[]> => { const files = await unregisterFiles(directoryId); - const subDirectories = await tx - .select({ id: directory.id }) - .from(directory) - .where(and(eq(directory.userId, userId), eq(directory.parentId, directoryId))); + const subDirectories = await trx + .selectFrom("directory") + .select("id") + .where("parent_id", "=", directoryId) + .where("user_id", "=", userId) + .execute(); const subDirectoryFilePaths = await Promise.all( subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)), ); - const deleteRes = await tx.delete(directory).where(eq(directory.id, directoryId)); - if (deleteRes.changes === 0) { + const deleteRes = await trx + .deleteFrom("directory") + .where("id", "=", directoryId) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (deleteRes.numDeletedRows === 0n) { throw new IntegrityError("Directory not found"); } return files.concat(...subDirectoryFilePaths); }; return await unregisterDirectoryRecursively(directoryId); - }, - { behavior: "exclusive" }, - ); + }); }; -export const registerFile = async (params: NewFileParams) => { - if ( - (params.hskVersion && !params.contentHmac) || - (!params.hskVersion && params.contentHmac) || - (params.encCreatedAt && !params.encCreatedAtIv) || - (!params.encCreatedAt && params.encCreatedAtIv) - ) { +export const registerFile = async (params: NewFile) => { + if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { throw new Error("Invalid arguments"); } - await db.transaction( - async (tx) => { - const meks = await tx - .select({ version: mek.version }) - .from(mek) - .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))) - .limit(1); - if (meks[0]?.version !== params.mekVersion) { - throw new IntegrityError("Inactive MEK version"); - } + await db.transaction().execute(async (trx) => { + const mek = await trx + .selectFrom("master_encryption_key") + .select("version") + .where("user_id", "=", params.userId) + .where("state", "=", "active") + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (mek?.version !== params.mekVersion) { + throw new IntegrityError("Inactive MEK version"); + } - if (params.hskVersion) { - const hsks = await tx - .select({ version: hsk.version }) - .from(hsk) - .where(and(eq(hsk.userId, params.userId), eq(hsk.state, "active"))) - .limit(1); - if (hsks[0]?.version !== params.hskVersion) { - throw new IntegrityError("Inactive HSK version"); - } + if (params.hskVersion) { + const hsk = await trx + .selectFrom("hmac_secret_key") + .select("version") + .where("user_id", "=", params.userId) + .where("state", "=", "active") + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (hsk?.version !== params.hskVersion) { + throw new IntegrityError("Inactive HSK version"); } + } - const newFiles = await tx - .insert(file) - .values({ - path: params.path, - parentId: params.parentId === "root" ? null : params.parentId, - userId: params.userId, - mekVersion: params.mekVersion, - hskVersion: params.hskVersion, - encDek: params.encDek, - dekVersion: params.dekVersion, - contentHmac: params.contentHmac, - contentType: params.contentType, - encContentIv: params.encContentIv, - encContentHash: params.encContentHash, - encName: { ciphertext: params.encName, iv: params.encNameIv }, - encCreatedAt: - params.encCreatedAt && params.encCreatedAtIv - ? { ciphertext: params.encCreatedAt, iv: params.encCreatedAtIv } - : null, - encLastModifiedAt: { - ciphertext: params.encLastModifiedAt, - iv: params.encLastModifiedAtIv, - }, - }) - .returning({ id: file.id }); - const { id: fileId } = newFiles[0]!; - await tx.insert(fileLog).values({ - fileId, + const { fileId } = await trx + .insertInto("file") + .values({ + parent_id: params.parentId !== "root" ? params.parentId : null, + user_id: params.userId, + path: params.path, + master_encryption_key_version: params.mekVersion, + encrypted_data_encryption_key: params.encDek, + data_encryption_key_version: params.dekVersion, + hmac_secret_key_version: params.hskVersion, + content_hmac: params.contentHmac, + content_type: params.contentType, + encrypted_content_iv: params.encContentIv, + encrypted_content_hash: params.encContentHash, + encrypted_name: params.encName, + encrypted_created_at: params.encCreatedAt, + encrypted_last_modified_at: params.encLastModifiedAt, + }) + .returning("id as fileId") + .executeTakeFirstOrThrow(); + await trx + .insertInto("file_log") + .values({ + file_id: fileId, timestamp: new Date(), action: "create", - newName: { ciphertext: params.encName, iv: params.encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: params.encName, + }) + .execute(); + }); }; export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => { - return await db - .select() - .from(file) - .where( - and( - eq(file.userId, userId), - parentId === "root" ? isNull(file.parentId) : eq(file.parentId, parentId), - ), - ); + let query = db.selectFrom("file").selectAll().where("user_id", "=", userId); + query = + parentId === "root" + ? query.where("parent_id", "is", null) + : query.where("parent_id", "=", parentId); + const files = await query.execute(); + return files.map( + (file) => + ({ + id: file.id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + }) satisfies File, + ); }; export const getAllFileIdsByContentHmac = async ( @@ -245,69 +294,93 @@ export const getAllFileIdsByContentHmac = async ( hskVersion: number, contentHmac: string, ) => { - return await db - .select({ id: file.id }) - .from(file) - .where( - and( - eq(file.userId, userId), - eq(file.hskVersion, hskVersion), - eq(file.contentHmac, contentHmac), - ), - ); + const files = await db + .selectFrom("file") + .select("id") + .where("user_id", "=", userId) + .where("hmac_secret_key_version", "=", hskVersion) + .where("content_hmac", "=", contentHmac) + .execute(); + return files.map(({ id }) => ({ id })); }; 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))) - .limit(1); - return res[0] ?? null; + const file = await db + .selectFrom("file") + .selectAll() + .where("id", "=", fileId) + .where("user_id", "=", userId) + .limit(1) + .executeTakeFirst(); + return file + ? ({ + id: file.id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + } satisfies File) + : null; }; export const setFileEncName = async ( userId: number, fileId: number, dekVersion: Date, - encName: string, - encNameIv: string, + encName: Ciphertext, ) => { - await db.transaction( - async (tx) => { - const files = await tx - .select({ version: file.dekVersion }) - .from(file) - .where(and(eq(file.userId, userId), eq(file.id, fileId))) - .limit(1); - if (!files[0]) { - throw new IntegrityError("File not found"); - } else if (files[0].version.getTime() !== dekVersion.getTime()) { - throw new IntegrityError("Invalid DEK version"); - } + await db.transaction().execute(async (trx) => { + const file = await trx + .selectFrom("file") + .select("data_encryption_key_version") + .where("id", "=", fileId) + .where("user_id", "=", userId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!file) { + throw new IntegrityError("File not found"); + } else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } - await tx - .update(file) - .set({ encName: { ciphertext: encName, iv: encNameIv } }) - .where(and(eq(file.userId, userId), eq(file.id, fileId))); - await tx.insert(fileLog).values({ - fileId, + await trx + .updateTable("file") + .set({ encrypted_name: encName }) + .where("id", "=", fileId) + .where("user_id", "=", userId) + .execute(); + await trx + .insertInto("file_log") + .values({ + file_id: fileId, timestamp: new Date(), action: "rename", - newName: { ciphertext: encName, iv: encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: encName, + }) + .execute(); + }); }; export const unregisterFile = async (userId: number, fileId: number) => { - const files = await db - .delete(file) - .where(and(eq(file.userId, userId), eq(file.id, fileId))) - .returning({ path: file.path }); - if (!files[0]) { + const file = await db + .deleteFrom("file") + .where("id", "=", fileId) + .where("user_id", "=", userId) + .returning("path") + .executeTakeFirst(); + if (!file) { throw new IntegrityError("File not found"); } - return files[0].path; + return { path: file.path }; }; diff --git a/src/lib/server/db/hsk.ts b/src/lib/server/db/hsk.ts index faf7dc6..a1bf66b 100644 --- a/src/lib/server/db/hsk.ts +++ b/src/lib/server/db/hsk.ts @@ -1,8 +1,15 @@ -import { SqliteError } from "better-sqlite3"; -import { and, eq } from "drizzle-orm"; -import db from "./drizzle"; +import { DatabaseError } from "pg"; import { IntegrityError } from "./error"; -import { hsk, hskLog } from "./schema"; +import db from "./kysely"; +import type { HskState } from "./schema"; + +interface Hsk { + userId: number; + version: number; + state: HskState; + mekVersion: number; + encHsk: string; +} export const registerInitialHsk = async ( userId: number, @@ -10,37 +17,52 @@ export const registerInitialHsk = async ( mekVersion: number, encHsk: string, ) => { - await db.transaction( - async (tx) => { - try { - await tx.insert(hsk).values({ - userId, + await db.transaction().execute(async (trx) => { + try { + await trx + .insertInto("hmac_secret_key") + .values({ + user_id: userId, version: 1, state: "active", - mekVersion, - encHsk, - }); - await tx.insert(hskLog).values({ - userId, - hskVersion: 1, + master_encryption_key_version: mekVersion, + encrypted_key: encHsk, + }) + .execute(); + await trx + .insertInto("hmac_secret_key_log") + .values({ + user_id: userId, + hmac_secret_key_version: 1, timestamp: new Date(), action: "create", - actionBy: createdBy, - }); - } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { - throw new IntegrityError("HSK already registered"); - } - throw e; + action_by: createdBy, + }) + .execute(); + } catch (e) { + if (e instanceof DatabaseError && e.code === "23505") { + throw new IntegrityError("HSK already registered"); } - }, - { behavior: "exclusive" }, - ); + throw e; + } + }); }; export const getAllValidHsks = async (userId: number) => { - return await db - .select() - .from(hsk) - .where(and(eq(hsk.userId, userId), eq(hsk.state, "active"))); + const hsks = await db + .selectFrom("hmac_secret_key") + .selectAll() + .where("user_id", "=", userId) + .where("state", "=", "active") + .execute(); + return hsks.map( + ({ user_id, version, state, master_encryption_key_version, encrypted_key }) => + ({ + userId: user_id, + version, + state: state as "active", + mekVersion: master_encryption_key_version, + encHsk: encrypted_key, + }) satisfies Hsk, + ); }; diff --git a/src/lib/server/db/kysely.ts b/src/lib/server/db/kysely.ts index 908e090..9665bb3 100644 --- a/src/lib/server/db/kysely.ts +++ b/src/lib/server/db/kysely.ts @@ -1,10 +1,15 @@ import { Kysely, PostgresDialect } from "kysely"; import { Pool } from "pg"; +import env from "$lib/server/loadenv"; import type { Database } from "./schema"; const dialect = new PostgresDialect({ pool: new Pool({ - // TODO + host: env.database.host, + port: env.database.port, + user: env.database.user, + password: env.database.password, + database: env.database.name, }), }); diff --git a/src/lib/server/db/mek.ts b/src/lib/server/db/mek.ts index 944636e..4d57013 100644 --- a/src/lib/server/db/mek.ts +++ b/src/lib/server/db/mek.ts @@ -1,8 +1,19 @@ -import { SqliteError } from "better-sqlite3"; -import { and, or, eq } from "drizzle-orm"; -import db from "./drizzle"; +import { DatabaseError } from "pg"; import { IntegrityError } from "./error"; -import { mek, mekLog, clientMek } from "./schema"; +import db from "./kysely"; +import type { MekState } from "./schema"; + +interface Mek { + userId: number; + version: number; + state: MekState; +} + +interface ClientMekWithDetails extends Mek { + clientId: number; + encMek: string; + encMekSig: string; +} export const registerInitialMek = async ( userId: number, @@ -10,58 +21,80 @@ export const registerInitialMek = async ( encMek: string, encMekSig: string, ) => { - await db.transaction( - async (tx) => { - try { - await tx.insert(mek).values({ - userId, + await db.transaction().execute(async (trx) => { + try { + await trx + .insertInto("master_encryption_key") + .values({ + user_id: userId, version: 1, state: "active", - }); - await tx.insert(clientMek).values({ - userId, - clientId: createdBy, - mekVersion: 1, - encMek, - encMekSig, - }); - await tx.insert(mekLog).values({ - userId, - mekVersion: 1, + }) + .execute(); + await trx + .insertInto("client_master_encryption_key") + .values({ + user_id: userId, + client_id: createdBy, + version: 1, + encrypted_key: encMek, + encrypted_key_signature: encMekSig, + }) + .execute(); + await trx + .insertInto("master_encryption_key_log") + .values({ + user_id: userId, + master_encryption_key_version: 1, timestamp: new Date(), action: "create", - actionBy: createdBy, - }); - } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { - throw new IntegrityError("MEK already registered"); - } - throw e; + action_by: createdBy, + }) + .execute(); + } catch (e) { + if (e instanceof DatabaseError && e.code === "23505") { + throw new IntegrityError("MEK already registered"); } - }, - { behavior: "exclusive" }, - ); + throw e; + } + }); }; export const getInitialMek = async (userId: number) => { - const meks = await db - .select() - .from(mek) - .where(and(eq(mek.userId, userId), eq(mek.version, 1))) - .limit(1); - return meks[0] ?? null; + const mek = await db + .selectFrom("master_encryption_key") + .selectAll() + .where("user_id", "=", userId) + .where("version", "=", 1) + .limit(1) + .executeTakeFirst(); + return mek + ? ({ userId: mek.user_id, version: mek.version, state: mek.state } satisfies Mek) + : null; }; export const getAllValidClientMeks = async (userId: number, clientId: number) => { - return await db - .select() - .from(clientMek) - .innerJoin(mek, and(eq(clientMek.userId, mek.userId), eq(clientMek.mekVersion, mek.version))) - .where( - and( - eq(clientMek.userId, userId), - eq(clientMek.clientId, clientId), - or(eq(mek.state, "active"), eq(mek.state, "retired")), - ), - ); + const clientMeks = await db + .selectFrom("client_master_encryption_key") + .innerJoin("master_encryption_key", (join) => + join + .onRef("client_master_encryption_key.user_id", "=", "master_encryption_key.user_id") + .onRef("client_master_encryption_key.version", "=", "master_encryption_key.version"), + ) + .selectAll() + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .where((eb) => eb.or([eb("state", "=", "active"), eb("state", "=", "retired")])) + .execute(); + return clientMeks.map( + ({ user_id, client_id, version, state, encrypted_key, encrypted_key_signature }) => + ({ + userId: user_id, + version, + state: state as "active" | "retired", + clientId: client_id, + encMek: encrypted_key, + encMekSig: encrypted_key_signature, + }) satisfies ClientMekWithDetails, + ); }; diff --git a/src/lib/server/db/schema/client.ts b/src/lib/server/db/schema/client.ts index 08d16ed..e5642a6 100644 --- a/src/lib/server/db/schema/client.ts +++ b/src/lib/server/db/schema/client.ts @@ -67,10 +67,12 @@ interface ClientTable { signature_public_key: string; // Base64 } +export type UserClientState = "challenging" | "pending" | "active"; + interface UserClientTable { user_id: number; client_id: number; - state: "challenging" | "pending" | "active"; + state: ColumnType; } interface UserClientChallengeTable { diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index feda927..60e3f22 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -1,5 +1,5 @@ import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core"; -import type { ColumnType, Generated, JSONColumnType } from "kysely"; +import type { ColumnType, Generated } from "kysely"; import { hsk } from "./hsk"; import { mek } from "./mek"; import { user } from "./user"; @@ -88,10 +88,10 @@ export const fileLog = sqliteTable("file_log", { newName: ciphertext("new_name"), }); -type Ciphertext = JSONColumnType<{ +export type Ciphertext = { ciphertext: string; // Base64 iv: string; // Base64 -}>; +}; interface DirectoryTable { id: Generated; diff --git a/src/lib/server/db/schema/hsk.ts b/src/lib/server/db/schema/hsk.ts index 28b7a89..aca5193 100644 --- a/src/lib/server/db/schema/hsk.ts +++ b/src/lib/server/db/schema/hsk.ts @@ -44,10 +44,12 @@ export const hskLog = sqliteTable( }), ); +export type HskState = "active"; + interface HskTable { user_id: number; version: number; - state: "active"; + state: HskState; master_encryption_key_version: number; encrypted_key: string; // Base64 } diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 4292231..64aa270 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -5,4 +5,5 @@ export * from "./mek"; export * from "./session"; export * from "./user"; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Database {} diff --git a/src/lib/server/db/schema/mek.ts b/src/lib/server/db/schema/mek.ts index e0ac10d..ae454c1 100644 --- a/src/lib/server/db/schema/mek.ts +++ b/src/lib/server/db/schema/mek.ts @@ -60,10 +60,12 @@ export const clientMek = sqliteTable( }), ); +export type MekState = "active" | "retired" | "dead"; + interface MekTable { user_id: number; version: number; - state: "active" | "retired" | "dead"; + state: MekState; } interface MekLogTable { diff --git a/src/lib/server/db/session.ts b/src/lib/server/db/session.ts index 819dd86..cd4d558 100644 --- a/src/lib/server/db/session.ts +++ b/src/lib/server/db/session.ts @@ -1,30 +1,31 @@ -import { SqliteError } from "better-sqlite3"; -import { and, eq, ne, gt, lte, isNull } from "drizzle-orm"; +import { DatabaseError } from "pg"; import env from "$lib/server/loadenv"; -import db from "./drizzle"; import { IntegrityError } from "./error"; -import { session, sessionUpgradeChallenge } from "./schema"; +import db from "./kysely"; export const createSession = async ( userId: number, clientId: number | null, sessionId: string, ip: string | null, - userAgent: string | null, + agent: string | null, ) => { try { const now = new Date(); - await db.insert(session).values({ - id: sessionId, - userId, - clientId, - createdAt: now, - lastUsedAt: now, - lastUsedByIp: ip || null, - lastUsedByUserAgent: userAgent || null, - }); + await db + .insertInto("session") + .values({ + id: sessionId, + user_id: userId, + client_id: clientId, + created_at: now, + last_used_at: now, + last_used_by_ip: ip || null, + last_used_by_agent: agent || null, + }) + .execute(); } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if (e instanceof DatabaseError && e.code === "23505") { throw new IntegrityError("Session already exists"); } throw e; @@ -34,49 +35,55 @@ export const createSession = async ( export const refreshSession = async ( sessionId: string, ip: string | null, - userAgent: string | null, + agent: string | null, ) => { const now = new Date(); - const sessions = await db - .update(session) + const session = await db + .updateTable("session") .set({ - lastUsedAt: now, - lastUsedByIp: ip || undefined, - lastUsedByUserAgent: userAgent || undefined, + last_used_at: now, + last_used_by_ip: ip !== "" ? ip : undefined, // Don't update if empty + last_used_by_agent: agent !== "" ? agent : undefined, // Don't update if empty }) - .where( - and( - eq(session.id, sessionId), - gt(session.lastUsedAt, new Date(now.getTime() - env.session.exp)), - ), - ) - .returning({ userId: session.userId, clientId: session.clientId }); - if (!sessions[0]) { + .where("id", "=", sessionId) + .where("last_used_at", ">", new Date(now.getTime() - env.session.exp)) + .returning(["user_id", "client_id"]) + .executeTakeFirst(); + if (!session) { throw new IntegrityError("Session not found"); } - return sessions[0]; + return { userId: session.user_id, clientId: session.client_id }; }; export const upgradeSession = async (sessionId: string, clientId: number) => { const res = await db - .update(session) - .set({ clientId }) - .where(and(eq(session.id, sessionId), isNull(session.clientId))); - if (res.changes === 0) { + .updateTable("session") + .set({ client_id: clientId }) + .where("id", "=", sessionId) + .where("client_id", "is", null) + .executeTakeFirst(); + if (res.numUpdatedRows === 0n) { throw new IntegrityError("Session not found"); } }; export const deleteSession = async (sessionId: string) => { - await db.delete(session).where(eq(session.id, sessionId)); + await db.deleteFrom("session").where("id", "=", sessionId).execute(); }; export const deleteAllOtherSessions = async (userId: number, sessionId: string) => { - await db.delete(session).where(and(eq(session.userId, userId), ne(session.id, sessionId))); + await db + .deleteFrom("session") + .where("id", "!=", sessionId) + .where("user_id", "=", userId) + .execute(); }; export const cleanupExpiredSessions = async () => { - await db.delete(session).where(lte(session.lastUsedAt, new Date(Date.now() - env.session.exp))); + await db + .deleteFrom("session") + .where("last_used_at", "<=", new Date(Date.now() - env.session.exp)) + .execute(); }; export const registerSessionUpgradeChallenge = async ( @@ -87,15 +94,18 @@ export const registerSessionUpgradeChallenge = async ( expiresAt: Date, ) => { try { - await db.insert(sessionUpgradeChallenge).values({ - sessionId, - clientId, - answer, - allowedIp, - expiresAt, - }); + await db + .insertInto("session_upgrade_challenge") + .values({ + session_id: sessionId, + client_id: clientId, + answer, + allowed_ip: allowedIp, + expires_at: expiresAt, + }) + .execute(); } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if (e instanceof DatabaseError && e.code === "23505") { throw new IntegrityError("Challenge already registered"); } throw e; @@ -107,22 +117,17 @@ export const consumeSessionUpgradeChallenge = async ( answer: string, ip: string, ) => { - const challenges = await db - .delete(sessionUpgradeChallenge) - .where( - and( - eq(sessionUpgradeChallenge.sessionId, sessionId), - eq(sessionUpgradeChallenge.answer, answer), - eq(sessionUpgradeChallenge.allowedIp, ip), - gt(sessionUpgradeChallenge.expiresAt, new Date()), - ), - ) - .returning({ clientId: sessionUpgradeChallenge.clientId }); - return challenges[0] ?? null; + const challenge = await db + .deleteFrom("session_upgrade_challenge") + .where("session_id", "=", sessionId) + .where("answer", "=", answer) + .where("allowed_ip", "=", ip) + .where("expires_at", ">", new Date()) + .returning("client_id") + .executeTakeFirst(); + return challenge ? { clientId: challenge.client_id } : null; }; export const cleanupExpiredSessionUpgradeChallenges = async () => { - await db - .delete(sessionUpgradeChallenge) - .where(lte(sessionUpgradeChallenge.expiresAt, new Date())); + await db.deleteFrom("session_upgrade_challenge").where("expires_at", "<=", new Date()).execute(); }; diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/user.ts index d970438..3964a94 100644 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/user.ts @@ -1,21 +1,36 @@ -import { eq } from "drizzle-orm"; -import db from "./drizzle"; -import { user } from "./schema"; +import db from "./kysely"; + +interface User { + id: number; + email: string; + nickname: string; + password: string; +} export const getUser = async (userId: number) => { - const users = await db.select().from(user).where(eq(user.id, userId)).limit(1); - return users[0] ?? null; + const user = await db + .selectFrom("user") + .selectAll() + .where("id", "=", userId) + .limit(1) + .executeTakeFirst(); + return user ? (user satisfies User) : null; }; export const getUserByEmail = async (email: string) => { - const users = await db.select().from(user).where(eq(user.email, email)).limit(1); - return users[0] ?? null; -}; - -export const setUserPassword = async (userId: number, password: string) => { - await db.update(user).set({ password }).where(eq(user.id, userId)); + const user = await db + .selectFrom("user") + .selectAll() + .where("email", "=", email) + .limit(1) + .executeTakeFirst(); + return user ? (user satisfies User) : null; }; export const setUserNickname = async (userId: number, nickname: string) => { - await db.update(user).set({ nickname }).where(eq(user.id, userId)); + await db.updateTable("user").set({ nickname }).where("id", "=", userId).execute(); +}; + +export const setUserPassword = async (userId: number, password: string) => { + await db.updateTable("user").set({ password }).where("id", "=", userId).execute(); }; diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index 01e442a..0ebb110 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -3,11 +3,18 @@ import { building } from "$app/environment"; import { env } from "$env/dynamic/private"; if (!building) { + if (!env.DATABASE_PASSWORD) throw new Error("DATABASE_PASSWORD not set"); if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET not set"); } export default { - databaseUrl: env.DATABASE_URL || "local.db", + database: { + host: env.DATABASE_HOST || "localhost", + port: parseInt(env.DATABASE_PORT || "5432", 10), + user: env.DATABASE_USER, + password: env.DATABASE_PASSWORD!, + name: env.DATABASE_NAME, + }, session: { secret: env.SESSION_SECRET!, exp: ms(env.SESSION_EXPIRES || "14d"),