diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index ba339a7..96c3259 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,4 +1,4 @@ -import { ClientRepo, SessionRepo } from "#lib/server/db/index.ts"; +import { ClientRepo, SessionRepo, db } from "#lib/server/db/index.ts"; import env from "#lib/server/config.ts"; import { verifySessionId } from "#lib/server/crypto.ts"; import { AppError, invalidSessionError } from "#lib/server/errors.ts"; @@ -39,7 +39,7 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen throw new AuthenticationError(400, "Invalid session id"); } - const session = await SessionRepo.refreshSession(sessionId, ip, userAgent); + const session = await SessionRepo.refreshSession(db, sessionId, ip, userAgent); if (!session) { throw new AuthenticationError(401, "Invalid session id"); } @@ -59,7 +59,7 @@ const ensureUserClientState = async ( throw new AppError("FORBIDDEN", "Forbidden"); } - const userClient = await ClientRepo.getUserClient(userId, clientId); + const userClient = await ClientRepo.getUserClient(db, userId, clientId); if (!userClient) { throw invalidSessionError(); } else if (userClient.state !== state) { diff --git a/src/lib/server/db/executor.ts b/src/lib/server/db/executor.ts new file mode 100644 index 0000000..79a57a5 --- /dev/null +++ b/src/lib/server/db/executor.ts @@ -0,0 +1,39 @@ +import type { IsolationLevel, Kysely, Transaction } from "kysely"; +import pg from "pg"; +import db from "./kysely.ts"; +import type { Database } from "./schema/index.ts"; + +// Runs queries either directly or in a transaction +export type Executor = Kysely; +// Required by queries that only make sense in a transaction (e.g. locking rows) +export type Tx = Transaction; + +const isDatabaseError = (e: unknown, code: string): e is pg.DatabaseError => { + return e instanceof pg.DatabaseError && e.code === code; +}; + +// `constraint` narrows down the violated constraint if a query may violate several of them +export const isUniqueViolation = (e: unknown, constraint?: string) => { + return isDatabaseError(e, "23505") && (!constraint || e.constraint === constraint); +}; + +export const isForeignKeyViolation = (e: unknown) => isDatabaseError(e, "23503"); + +// Serialization failures and deadlocks succeed if the transaction is retried +const isRetryable = (e: unknown) => isDatabaseError(e, "40001") || isDatabaseError(e, "40P01"); + +export const transaction = async ( + callback: (trx: Tx) => Promise, + { isolationLevel, maxRetries = 3 }: { isolationLevel?: IsolationLevel; maxRetries?: number } = {}, +) => { + for (let attempt = 0; ; attempt++) { + try { + const builder = db.transaction(); + return await (isolationLevel ? builder.setIsolationLevel(isolationLevel) : builder).execute( + callback, + ); + } catch (e) { + if (!isRetryable(e) || attempt >= maxRetries) throw e; + } + } +}; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 09f63a5..74e37a0 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -4,6 +4,7 @@ export * as DirectoryRepo from "./repositories/directory.ts"; export * as FileRepo from "./repositories/file.ts"; export * as FileDeletionRepo from "./repositories/file-deletion.ts"; export * as HskRepo from "./repositories/hsk.ts"; +export * as LogRepo from "./repositories/log.ts"; export * as MediaRepo from "./repositories/media.ts"; export * as MekRepo from "./repositories/mek.ts"; export * as SessionRepo from "./repositories/session.ts"; @@ -11,4 +12,5 @@ export * as UploadRepo from "./repositories/upload.ts"; export * as UserRepo from "./repositories/user.ts"; export * from "./error.ts"; +export * from "./executor.ts"; export { default as db } from "./kysely.ts"; diff --git a/src/lib/server/db/queries.ts b/src/lib/server/db/queries.ts new file mode 100644 index 0000000..d41a534 --- /dev/null +++ b/src/lib/server/db/queries.ts @@ -0,0 +1,50 @@ +import type { Executor } from "./executor.ts"; + +// Entries in the root have NULL as their parent, so use `is` instead of `=` to compare it +export const encodeParentId = (parentId: DirectoryId | CategoryId) => { + return parentId === "root" ? null : parentId; +}; + +export const decodeParentId = (parentId: number | null) => parentId ?? ("root" as const); + +export const parentIdOperator = (parentId: DirectoryId | CategoryId) => { + return parentId === "root" ? ("is" as const) : ("=" as const); +}; + +// Adds recursive CTEs of the directories in the subtree of `rootId` including itself (empty if the +// root), and of the directories in the subtrees of favorite directories (empty unless needed) +export const withDirectoryTrees = ( + executor: Executor, + userId: number, + { rootId, inFavorites }: { rootId: DirectoryId; inFavorites: boolean }, +) => { + return executor + .withRecursive("directory_tree", (qc) => + qc + .selectFrom("directory") + .select("id") + .where("user_id", "=", userId) + .$if(rootId === "root", (qb) => qb.where((eb) => eb.lit(false))) + .$if(rootId !== "root", (qb) => qb.where("id", "=", rootId as number)) + .unionAll((qc) => + qc + .selectFrom("directory as d") + .innerJoin("directory_tree as dt", "d.parent_id", "dt.id") + .select("d.id"), + ), + ) + .withRecursive("favorite_directory_tree", (qc) => + qc + .selectFrom("directory") + .select("id") + .where("user_id", "=", userId) + .$if(!inFavorites, (qb) => qb.where((eb) => eb.lit(false))) + .$if(inFavorites, (qb) => qb.where("is_favorite", "=", true)) + .unionAll((qc) => + qc + .selectFrom("directory as d") + .innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id") + .select("d.id"), + ), + ); +}; diff --git a/src/lib/server/db/repositories/category.ts b/src/lib/server/db/repositories/category.ts index a659852..215b9df 100644 --- a/src/lib/server/db/repositories/category.ts +++ b/src/lib/server/db/repositories/category.ts @@ -53,7 +53,9 @@ export const ensureCategoryExists = async ( export const registerCategory = async (params: NewCategory) => { await db.transaction().execute(async (trx) => { - await lockActiveMekVersion(trx, params.userId, params.mekVersion); + if ((await lockActiveMekVersion(trx, params.userId)) !== params.mekVersion) { + throw new IntegrityError("Inactive MEK version"); + } await ensureCategoryExists(trx, params.userId, params.parentId); diff --git a/src/lib/server/db/repositories/client.ts b/src/lib/server/db/repositories/client.ts index cc2bb15..a51558e 100644 --- a/src/lib/server/db/repositories/client.ts +++ b/src/lib/server/db/repositories/client.ts @@ -1,7 +1,6 @@ -import pg from "pg"; -import { IntegrityError } from "../error.ts"; -import db from "../kysely.ts"; -import type { UserClientState } from "../schema/index.ts"; +import type { Selectable } from "kysely"; +import type { Executor } from "../executor.ts"; +import type { ClientTable, UserClientState, UserClientTable } from "../schema/index.ts"; interface Client { id: number; @@ -15,154 +14,117 @@ interface UserClient { state: UserClientState; } -interface UserClientWithDetails extends UserClient { - encPubKey: string; - sigPubKey: string; -} +const toClient = (row: Selectable): Client => ({ + id: row.id, + encPubKey: row.encryption_public_key, + sigPubKey: row.signature_public_key, +}); -export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { - 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 toUserClient = (row: Selectable): UserClient => ({ + userId: row.user_id, + clientId: row.client_id, + state: row.state, +}); - 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 { id: clientId }; - }); +// Whether any of the keys is used by a client as either of its keys +export const isAnyPublicKeyRegistered = async ( + executor: Executor, + encPubKey: string, + sigPubKey: string, +) => { + const client = await executor + .selectFrom("client") + .select("id") + .where((eb) => + eb.or([ + eb("encryption_public_key", "in", [encPubKey, sigPubKey]), + eb("signature_public_key", "in", [encPubKey, sigPubKey]), + ]), + ) + .limit(1) + .executeTakeFirst(); + return !!client; }; -export const getClient = async (clientId: number) => { - const client = await db +export const createClient = async (executor: Executor, encPubKey: string, sigPubKey: string) => { + const { id } = await executor + .insertInto("client") + .values({ encryption_public_key: encPubKey, signature_public_key: sigPubKey }) + .returning("id") + .executeTakeFirstOrThrow(); + return { id }; +}; + +export const getClient = async (executor: Executor, clientId: number) => { + const client = await executor .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; + return client ? toClient(client) : null; }; -export const getClientByPubKeys = async (encPubKey: string, sigPubKey: string) => { - const client = await db +export const getClientByPubKeys = async ( + executor: Executor, + encPubKey: string, + sigPubKey: string, +) => { + const client = await executor .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; + return client ? toClient(client) : null; }; -export const createUserClient = async (userId: number, clientId: number) => { - try { - await db.insertInto("user_client").values({ user_id: userId, client_id: clientId }).execute(); - } catch (e) { - if (e instanceof pg.DatabaseError && e.code === "23505") { - throw new IntegrityError("User client already exists"); - } - throw e; - } +// Violates a unique constraint if the user already has the client +export const createUserClient = async (executor: Executor, userId: number, clientId: number) => { + await executor + .insertInto("user_client") + .values({ user_id: userId, client_id: clientId }) + .execute(); }; -export const getUserClient = async (userId: number, clientId: number) => { - const userClient = await db +export const getUserClient = async (executor: Executor, userId: number, clientId: number) => { + const userClient = await executor .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; + return userClient ? toUserClient(userClient) : null; }; -export const getUserClientWithDetails = async (userId: number, clientId: number) => { - const userClient = await db - .selectFrom("user_client") - .innerJoin("client", "user_client.client_id", "client.id") - .selectAll() +// Returns whether the state has changed, which fails if the client isn't in the `from` state +export const transitionUserClientState = async ( + executor: Executor, + userId: number, + clientId: number, + { from, to }: { from: UserClientState; to: UserClientState }, +) => { + const { numUpdatedRows } = await executor + .updateTable("user_client") + .set({ state: to }) .where("user_id", "=", userId) .where("client_id", "=", clientId) - .limit(1) + .where("state", "=", from) .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; + return numUpdatedRows > 0n; }; -export const setUserClientStateToPending = async (userId: number, clientId: number) => { - await db - .updateTable("user_client") - .set({ state: "pending" }) - .where("user_id", "=", userId) - .where("client_id", "=", clientId) - .where("state", "=", "challenging") - .execute(); -}; - -export const setUserClientStateToActive = async (userId: number, clientId: number) => { - await db - .updateTable("user_client") - .set({ state: "active" }) - .where("user_id", "=", userId) - .where("client_id", "=", clientId) - .where("state", "=", "pending") - .execute(); -}; - -export const registerUserClientChallenge = async ( +export const createUserClientChallenge = async ( + executor: Executor, userId: number, clientId: number, answer: string, allowedIp: string, expiresAt: Date, ) => { - const { id } = await db + const { id } = await executor .insertInto("user_client_challenge") .values({ user_id: userId, @@ -177,11 +139,12 @@ export const registerUserClientChallenge = async ( }; export const consumeUserClientChallenge = async ( + executor: Executor, challengeId: number, userId: number, ip: string, ) => { - const challenge = await db + const challenge = await executor .deleteFrom("user_client_challenge") .where("id", "=", challengeId) .where("user_id", "=", userId) @@ -192,6 +155,9 @@ export const consumeUserClientChallenge = async ( return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null; }; -export const cleanupExpiredUserClientChallenges = async () => { - await db.deleteFrom("user_client_challenge").where("expires_at", "<=", new Date()).execute(); +export const deleteExpiredUserClientChallenges = async (executor: Executor) => { + await executor + .deleteFrom("user_client_challenge") + .where("expires_at", "<=", new Date()) + .execute(); }; diff --git a/src/lib/server/db/repositories/directory.ts b/src/lib/server/db/repositories/directory.ts index 3267db5..3b51635 100644 --- a/src/lib/server/db/repositories/directory.ts +++ b/src/lib/server/db/repositories/directory.ts @@ -49,7 +49,9 @@ export const ensureDirectoryExists = async ( export const registerDirectory = async (params: Omit) => { await db.transaction().execute(async (trx) => { - await lockActiveMekVersion(trx, params.userId, params.mekVersion); + if ((await lockActiveMekVersion(trx, params.userId)) !== params.mekVersion) { + throw new IntegrityError("Inactive MEK version"); + } await ensureDirectoryExists(trx, params.userId, params.parentId); diff --git a/src/lib/server/db/repositories/hsk.ts b/src/lib/server/db/repositories/hsk.ts index 1e35a7d..dd183aa 100644 --- a/src/lib/server/db/repositories/hsk.ts +++ b/src/lib/server/db/repositories/hsk.ts @@ -1,17 +1,7 @@ -import pg from "pg"; -import { IntegrityError } from "../error.ts"; -import db from "../kysely.ts"; -import type { HskState } from "../schema/index.ts"; +import type { Executor, Tx } from "../executor.ts"; -interface Hsk { - userId: number; - version: number; - state: HskState; - mekVersion: number; - encHsk: string; -} - -export const lockActiveHskVersion = async (trx: typeof db, userId: number, version: number) => { +// Locks the active version until the transaction ends, so that it isn't retired in the meantime +export const lockActiveHskVersion = async (trx: Tx, userId: number) => { const hsk = await trx .selectFrom("hmac_secret_key") .select("version") @@ -20,63 +10,40 @@ export const lockActiveHskVersion = async (trx: typeof db, userId: number, versi .limit(1) .forUpdate() .executeTakeFirst(); - if (hsk?.version !== version) { - throw new IntegrityError("Inactive HSK version"); - } + return hsk?.version ?? null; }; -export const registerInitialHsk = async ( +// Violates a unique constraint if the user already has an HMAC secret key +export const createInitialHsk = async ( + executor: Executor, userId: number, - createdBy: number, mekVersion: number, encHsk: string, ) => { - await db.transaction().execute(async (trx) => { - try { - await trx - .insertInto("hmac_secret_key") - .values({ - user_id: userId, - version: 1, - state: "active", - 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", - action_by: createdBy, - }) - .execute(); - } catch (e) { - if (e instanceof pg.DatabaseError && e.code === "23505") { - throw new IntegrityError("HSK already registered"); - } - throw e; - } - }); + await executor + .insertInto("hmac_secret_key") + .values({ + user_id: userId, + version: 1, + state: "active", + master_encryption_key_version: mekVersion, + encrypted_key: encHsk, + }) + .execute(); + return { version: 1 }; }; -export const getAllValidHsks = async (userId: number) => { - const hsks = await db +export const getAllValidHsks = async (executor: Executor, userId: number) => { + const hsks = await executor .selectFrom("hmac_secret_key") - .selectAll() + .select(["version", "state", "master_encryption_key_version", "encrypted_key"]) .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, - ); + return hsks.map(({ version, state, master_encryption_key_version, encrypted_key }) => ({ + version, + state, + mekVersion: master_encryption_key_version, + encHsk: encrypted_key, + })); }; diff --git a/src/lib/server/db/repositories/log.ts b/src/lib/server/db/repositories/log.ts new file mode 100644 index 0000000..508469d --- /dev/null +++ b/src/lib/server/db/repositories/log.ts @@ -0,0 +1,48 @@ +import type { Insertable } from "kysely"; +import type { Executor } from "../executor.ts"; +import type { + CategoryLogTable, + DirectoryLogTable, + FileLogTable, + HskLogTable, + MekLogTable, +} from "../schema/index.ts"; + +// Audit logs of changes, timestamped when appended + +type NewLog = Omit, "id" | "timestamp">; + +export const appendDirectoryLog = async (executor: Executor, log: NewLog) => { + await executor + .insertInto("directory_log") + .values({ ...log, timestamp: new Date() }) + .execute(); +}; + +export const appendFileLog = async (executor: Executor, log: NewLog) => { + await executor + .insertInto("file_log") + .values({ ...log, timestamp: new Date() }) + .execute(); +}; + +export const appendCategoryLog = async (executor: Executor, log: NewLog) => { + await executor + .insertInto("category_log") + .values({ ...log, timestamp: new Date() }) + .execute(); +}; + +export const appendMekLog = async (executor: Executor, log: NewLog) => { + await executor + .insertInto("master_encryption_key_log") + .values({ ...log, timestamp: new Date() }) + .execute(); +}; + +export const appendHskLog = async (executor: Executor, log: NewLog) => { + await executor + .insertInto("hmac_secret_key_log") + .values({ ...log, timestamp: new Date() }) + .execute(); +}; diff --git a/src/lib/server/db/repositories/mek.ts b/src/lib/server/db/repositories/mek.ts index 271fc49..c0127d0 100644 --- a/src/lib/server/db/repositories/mek.ts +++ b/src/lib/server/db/repositories/mek.ts @@ -1,21 +1,7 @@ -import pg from "pg"; -import { IntegrityError } from "../error.ts"; -import db from "../kysely.ts"; -import type { MekState } from "../schema/index.ts"; +import type { Executor, Tx } from "../executor.ts"; -interface Mek { - userId: number; - version: number; - state: MekState; -} - -interface ClientMekWithDetails extends Mek { - clientId: number; - encMek: string; - encMekSig: string; -} - -export const lockActiveMekVersion = async (trx: typeof db, userId: number, version: number) => { +// Locks the active version until the transaction ends, so that it isn't retired in the meantime +export const lockActiveMekVersion = async (trx: Tx, userId: number) => { const mek = await trx .selectFrom("master_encryption_key") .select("version") @@ -24,78 +10,60 @@ export const lockActiveMekVersion = async (trx: typeof db, userId: number, versi .limit(1) .forUpdate() .executeTakeFirst(); - if (mek?.version !== version) { - throw new IntegrityError("Inactive MEK version"); - } + return mek?.version ?? null; }; -export const registerInitialMek = async ( +// Violates a unique constraint if the user already has a master key +export const createInitialMek = async ( + trx: Tx, userId: number, - createdBy: number, + clientId: number, encMek: string, encMekSig: string, ) => { - await db.transaction().execute(async (trx) => { - try { - await trx - .insertInto("master_encryption_key") - .values({ - user_id: userId, - version: 1, - state: "active", - }) - .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", - action_by: createdBy, - }) - .execute(); - } catch (e) { - if (e instanceof pg.DatabaseError && e.code === "23505") { - throw new IntegrityError("MEK already registered"); - } - throw e; - } - }); + await trx + .insertInto("master_encryption_key") + .values({ user_id: userId, version: 1, state: "active" }) + .execute(); + await trx + .insertInto("client_master_encryption_key") + .values({ + user_id: userId, + client_id: clientId, + version: 1, + encrypted_key: encMek, + encrypted_key_signature: encMekSig, + }) + .execute(); + return { version: 1 }; }; -export const getAllValidClientMeks = async (userId: number, clientId: number) => { - const clientMeks = await db +export const getAllValidClientMeks = async ( + executor: Executor, + userId: number, + clientId: number, +) => { + const clientMeks = await executor .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() + .select([ + "client_master_encryption_key.version", + "state", + "encrypted_key", + "encrypted_key_signature", + ]) .where("client_master_encryption_key.user_id", "=", userId) .where("client_master_encryption_key.client_id", "=", clientId) .where("state", "in", ["active", "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, - ); + return clientMeks.map(({ version, state, encrypted_key, encrypted_key_signature }) => ({ + version, + state: state as "active" | "retired", + encMek: encrypted_key, + encMekSig: encrypted_key_signature, + })); }; diff --git a/src/lib/server/db/repositories/session.ts b/src/lib/server/db/repositories/session.ts index 33abce8..bec99ca 100644 --- a/src/lib/server/db/repositories/session.ts +++ b/src/lib/server/db/repositories/session.ts @@ -1,16 +1,15 @@ -import pg from "pg"; import env from "#lib/server/config.ts"; -import { IntegrityError } from "../error.ts"; -import db from "../kysely.ts"; +import type { Executor } from "../executor.ts"; export const createSession = async ( + executor: Executor, userId: number, sessionId: string, ip: string | null, agent: string | null, ) => { const now = new Date(); - await db + await executor .insertInto("session") .values({ id: sessionId, @@ -23,13 +22,15 @@ export const createSession = async ( .execute(); }; +// Returns `null` if the session doesn't exist or has expired export const refreshSession = async ( + executor: Executor, sessionId: string, ip: string | null, agent: string | null, ) => { const now = new Date(); - const session = await db + const session = await executor .updateTable("session") .set({ last_used_at: now, @@ -43,93 +44,74 @@ export const refreshSession = async ( return session ? { userId: session.user_id, clientId: session.client_id } : null; }; -export const upgradeSession = async ( +// Returns whether the session has been upgraded, which fails if it was already upgraded. Violates +// `session_ak01` if the client has another session. +export const setSessionClient = async (executor: Executor, sessionId: string, clientId: number) => { + const { numUpdatedRows } = await executor + .updateTable("session") + .set({ client_id: clientId }) + .where("id", "=", sessionId) + .where("client_id", "is", null) + .executeTakeFirst(); + return numUpdatedRows > 0n; +}; + +export const deleteSession = async (executor: Executor, sessionId: string) => { + await executor.deleteFrom("session").where("id", "=", sessionId).execute(); +}; + +// Deletes the other sessions of the user, or only those of the client if given +export const deleteOtherSessions = async ( + executor: Executor, userId: number, sessionId: string, - clientId: number, - force: boolean, + clientId?: number, ) => { - try { - await db.transaction().execute(async (trx) => { - if (force) { - await trx - .deleteFrom("session") - .where("id", "!=", sessionId) - .where("user_id", "=", userId) - .where("client_id", "=", clientId) - .execute(); - } - const res = await trx - .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"); - } - }); - } catch (e) { - if (e instanceof pg.DatabaseError && e.code === "23505") { - throw new IntegrityError("Already logged in"); - } - throw e; - } -}; - -export const deleteSession = async (sessionId: string) => { - await db.deleteFrom("session").where("id", "=", sessionId).execute(); -}; - -export const deleteAllOtherSessions = async (userId: number, sessionId: string) => { - await db + await executor .deleteFrom("session") .where("id", "!=", sessionId) .where("user_id", "=", userId) + .$if(clientId !== undefined, (qb) => qb.where("client_id", "=", clientId!)) .execute(); }; -export const cleanupExpiredSessions = async () => { - await db +export const deleteExpiredSessions = async (executor: Executor) => { + await executor .deleteFrom("session") .where("last_used_at", "<=", new Date(Date.now() - env.session.exp)) .execute(); }; -export const registerSessionUpgradeChallenge = async ( +// Violates a unique constraint if the session already has a challenge +export const createSessionUpgradeChallenge = async ( + executor: Executor, sessionId: string, clientId: number, answer: string, allowedIp: string, expiresAt: Date, ) => { - try { - const { id } = await db - .insertInto("session_upgrade_challenge") - .values({ - session_id: sessionId, - client_id: clientId, - answer, - allowed_ip: allowedIp, - expires_at: expiresAt, - }) - .returning("id") - .executeTakeFirstOrThrow(); - return { id }; - } catch (e) { - if (e instanceof pg.DatabaseError && e.code === "23505") { - throw new IntegrityError("Challenge already registered"); - } - throw e; - } + const { id } = await executor + .insertInto("session_upgrade_challenge") + .values({ + session_id: sessionId, + client_id: clientId, + answer, + allowed_ip: allowedIp, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + return { id }; }; export const consumeSessionUpgradeChallenge = async ( + executor: Executor, challengeId: number, sessionId: string, ip: string, ) => { - const challenge = await db + const challenge = await executor .deleteFrom("session_upgrade_challenge") .where("id", "=", challengeId) .where("session_id", "=", sessionId) @@ -140,6 +122,9 @@ export const consumeSessionUpgradeChallenge = async ( return challenge ? { clientId: challenge.client_id, answer: challenge.answer } : null; }; -export const cleanupExpiredSessionUpgradeChallenges = async () => { - await db.deleteFrom("session_upgrade_challenge").where("expires_at", "<=", new Date()).execute(); +export const deleteExpiredSessionUpgradeChallenges = async (executor: Executor) => { + await executor + .deleteFrom("session_upgrade_challenge") + .where("expires_at", "<=", new Date()) + .execute(); }; diff --git a/src/lib/server/db/repositories/upload.ts b/src/lib/server/db/repositories/upload.ts index 9dd1106..002bb28 100644 --- a/src/lib/server/db/repositories/upload.ts +++ b/src/lib/server/db/repositories/upload.ts @@ -40,10 +40,14 @@ export const createFileUploadSession = async ( params: Omit, ) => { await db.transaction().execute(async (trx) => { - await lockActiveMekVersion(trx, params.userId, params.mekVersion); + if ((await lockActiveMekVersion(trx, params.userId)) !== params.mekVersion) { + throw new IntegrityError("Inactive MEK version"); + } if (params.hskVersion) { - await lockActiveHskVersion(trx, params.userId, params.hskVersion); + if ((await lockActiveHskVersion(trx, params.userId)) !== params.hskVersion) { + throw new IntegrityError("Inactive HSK version"); + } } await ensureDirectoryExists(trx, params.userId, params.parentId); diff --git a/src/lib/server/db/repositories/user.ts b/src/lib/server/db/repositories/user.ts index 4407f60..ef01eb4 100644 --- a/src/lib/server/db/repositories/user.ts +++ b/src/lib/server/db/repositories/user.ts @@ -1,4 +1,6 @@ -import db from "../kysely.ts"; +import type { Selectable } from "kysely"; +import type { Executor } from "../executor.ts"; +import type { UserTable } from "../schema/index.ts"; interface User { id: number; @@ -7,26 +9,33 @@ interface User { password: string; } -export const getUser = async (userId: number) => { - const user = await db +const toUser = (row: Selectable): User => ({ + id: row.id, + email: row.email, + nickname: row.nickname, + password: row.password, +}); + +export const getUser = async (executor: Executor, userId: number) => { + const user = await executor .selectFrom("user") .selectAll() .where("id", "=", userId) .limit(1) .executeTakeFirst(); - return user ? (user satisfies User) : null; + return user ? toUser(user) : null; }; -export const getUserByEmail = async (email: string) => { - const user = await db +export const getUserByEmail = async (executor: Executor, email: string) => { + const user = await executor .selectFrom("user") .selectAll() .where("email", "=", email) .limit(1) .executeTakeFirst(); - return user ? (user satisfies User) : null; + return user ? toUser(user) : null; }; -export const setUserPassword = async (userId: number, password: string) => { - await db.updateTable("user").set({ password }).where("id", "=", userId).execute(); +export const setUserPassword = async (executor: Executor, userId: number, password: string) => { + await executor.updateTable("user").set({ password }).where("id", "=", userId).execute(); }; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index 4b8d638..d227730 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -1,5 +1,12 @@ import argon2 from "argon2"; -import { ClientRepo, SessionRepo, UserRepo } from "#lib/server/db/index.ts"; +import { + ClientRepo, + SessionRepo, + UserRepo, + db, + isUniqueViolation, + transaction, +} from "#lib/server/db/index.ts"; import env from "#lib/server/config.ts"; import { issueSessionId } from "#lib/server/crypto.ts"; import { AppError, invalidSessionError } from "#lib/server/errors.ts"; @@ -8,18 +15,18 @@ import { createChallenge, verifyChallengeAnswer } from "./client.ts"; const MIN_PASSWORD_LENGTH = 8; export const login = async (email: string, password: string, ip: string, userAgent: string) => { - const user = await UserRepo.getUserByEmail(email); + const user = await UserRepo.getUserByEmail(db, email); if (!user || !(await argon2.verify(user.password, password))) { throw new AppError("UNAUTHORIZED", "Invalid email or password"); } const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); - await SessionRepo.createSession(user.id, sessionId, ip, userAgent); + await SessionRepo.createSession(db, user.id, sessionId, ip, userAgent); return { sessionIdSigned }; }; export const logout = async (sessionId: string) => { - await SessionRepo.deleteSession(sessionId); + await SessionRepo.deleteSession(db, sessionId); }; export const changePassword = async ( @@ -34,15 +41,18 @@ export const changePassword = async ( throw new AppError("BAD_REQUEST", "Too short password"); } - const user = await UserRepo.getUser(userId); + const user = await UserRepo.getUser(db, userId); if (!user) { throw invalidSessionError(); } else if (!(await argon2.verify(user.password, oldPassword))) { throw new AppError("FORBIDDEN", "Invalid password"); } - await UserRepo.setUserPassword(userId, await argon2.hash(newPassword)); - await SessionRepo.deleteAllOtherSessions(userId, sessionId); + const passwordHash = await argon2.hash(newPassword); + await transaction(async (trx) => { + await UserRepo.setUserPassword(trx, userId, passwordHash); + await SessionRepo.deleteOtherSessions(trx, userId, sessionId); + }); }; export const requestSessionUpgrade = async ( @@ -52,25 +62,33 @@ export const requestSessionUpgrade = async ( encPubKey: string, sigPubKey: string, ) => { - const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); + const client = await ClientRepo.getClientByPubKeys(db, encPubKey, sigPubKey); if (!client) { throw new AppError("UNAUTHORIZED", "Invalid public key(s)"); } - const userClient = await ClientRepo.getUserClient(userId, client.id); + const userClient = await ClientRepo.getUserClient(db, userId, client.id); if (!userClient || userClient.state === "challenging") { throw new AppError("FORBIDDEN", "Unregistered client"); } - return await createChallenge(encPubKey, (answer) => - SessionRepo.registerSessionUpgradeChallenge( - sessionId, - client.id, - answer, - ip, - new Date(Date.now() + env.challenge.sessionUpgradeExp), - ), - ); + try { + return await createChallenge(encPubKey, (answer) => + SessionRepo.createSessionUpgradeChallenge( + db, + sessionId, + client.id, + answer, + ip, + new Date(Date.now() + env.challenge.sessionUpgradeExp), + ), + ); + } catch (e) { + if (isUniqueViolation(e)) { + throw new AppError("CONFLICT", "Challenge already registered"); + } + throw e; + } }; export const verifySessionUpgrade = async ( @@ -81,14 +99,34 @@ export const verifySessionUpgrade = async ( answerSig: string, force: boolean, ) => { - const challenge = await SessionRepo.consumeSessionUpgradeChallenge(challengeId, sessionId, ip); + const challenge = await SessionRepo.consumeSessionUpgradeChallenge( + db, + challengeId, + sessionId, + ip, + ); const client = await verifyChallengeAnswer(challenge, answerSig); - await SessionRepo.upgradeSession(userId, sessionId, client.id, force); + + try { + await transaction(async (trx) => { + if (force) { + await SessionRepo.deleteOtherSessions(trx, userId, sessionId, client.id); + } + if (!(await SessionRepo.setSessionClient(trx, sessionId, client.id))) { + throw new AppError("UNAUTHORIZED", "Session not found"); + } + }); + } catch (e) { + if (isUniqueViolation(e, "session_ak01")) { + throw new AppError("CONFLICT", "Already logged in"); + } + throw e; + } }; export const cleanupExpiredSessions = async () => { await Promise.all([ - SessionRepo.cleanupExpiredSessions(), - SessionRepo.cleanupExpiredSessionUpgradeChallenges(), + SessionRepo.deleteExpiredSessions(db), + SessionRepo.deleteExpiredSessionUpgradeChallenges(db), ]); }; diff --git a/src/lib/server/services/client.ts b/src/lib/server/services/client.ts index 95af66f..be3977e 100644 --- a/src/lib/server/services/client.ts +++ b/src/lib/server/services/client.ts @@ -1,4 +1,4 @@ -import { ClientRepo } from "#lib/server/db/index.ts"; +import { ClientRepo, db, isUniqueViolation, transaction } from "#lib/server/db/index.ts"; import env from "#lib/server/config.ts"; import { generateChallenge, verifyPubKey, verifySignature } from "#lib/server/crypto.ts"; import { AppError } from "#lib/server/errors.ts"; @@ -22,7 +22,7 @@ export const verifyChallengeAnswer = async ( throw new AppError("FORBIDDEN", "Invalid challenge answer"); } - const client = await ClientRepo.getClient(challenge.clientId); + const client = await ClientRepo.getClient(db, challenge.clientId); if (!client) { throw new AppError("INTERNAL_SERVER_ERROR", "Invalid challenge answer"); } else if ( @@ -33,28 +33,66 @@ export const verifyChallengeAnswer = async ( return client; }; +const addExistingClient = async (userId: number, clientId: number) => { + try { + await ClientRepo.createUserClient(db, userId, clientId); + } catch (e) { + if (isUniqueViolation(e)) { + throw new AppError("CONFLICT", "User client already exists"); + } + throw e; + } +}; + +const addNewClient = async (userId: number, encPubKey: string, sigPubKey: string) => { + if (encPubKey === sigPubKey) { + throw new AppError("BAD_REQUEST", "Same public keys"); + } else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { + throw new AppError("BAD_REQUEST", "Invalid public key(s)"); + } + + const publicKeysRegistered = () => new AppError("CONFLICT", "Public key(s) already registered"); + try { + // Serializable, as a key must not be used as either key of another client, which can't be + // ensured by unique constraints + return await transaction( + async (trx) => { + if (await ClientRepo.isAnyPublicKeyRegistered(trx, encPubKey, sigPubKey)) { + throw publicKeysRegistered(); + } + + const { id } = await ClientRepo.createClient(trx, encPubKey, sigPubKey); + await ClientRepo.createUserClient(trx, userId, id); + return id; + }, + { isolationLevel: "serializable" }, + ); + } catch (e) { + if (isUniqueViolation(e)) { + throw publicKeysRegistered(); + } + throw e; + } +}; + export const registerClient = async ( userId: number, ip: string, encPubKey: string, sigPubKey: string, ) => { - const getClientId = async () => { - const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); - if (client) { - await ClientRepo.createUserClient(userId, client.id); - return client.id; - } else if (encPubKey === sigPubKey) { - throw new AppError("BAD_REQUEST", "Same public keys"); - } else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { - throw new AppError("BAD_REQUEST", "Invalid public key(s)"); - } - return (await ClientRepo.createClient(encPubKey, sigPubKey, userId)).id; - }; + const client = await ClientRepo.getClientByPubKeys(db, encPubKey, sigPubKey); + let clientId; + if (client) { + await addExistingClient(userId, client.id); + clientId = client.id; + } else { + clientId = await addNewClient(userId, encPubKey, sigPubKey); + } - const clientId = await getClientId(); return await createChallenge(encPubKey, (answer) => - ClientRepo.registerUserClientChallenge( + ClientRepo.createUserClientChallenge( + db, userId, clientId, answer, @@ -70,11 +108,14 @@ export const verifyClient = async ( challengeId: number, answerSig: string, ) => { - const challenge = await ClientRepo.consumeUserClientChallenge(challengeId, userId, ip); + const challenge = await ClientRepo.consumeUserClientChallenge(db, challengeId, userId, ip); const client = await verifyChallengeAnswer(challenge, answerSig); - await ClientRepo.setUserClientStateToPending(userId, client.id); + await ClientRepo.transitionUserClientState(db, userId, client.id, { + from: "challenging", + to: "pending", + }); }; export const cleanupExpiredChallenges = async () => { - await ClientRepo.cleanupExpiredUserClientChallenges(); + await ClientRepo.deleteExpiredUserClientChallenges(db); }; diff --git a/src/lib/server/services/common.ts b/src/lib/server/services/common.ts index c527ae7..a9985dc 100644 --- a/src/lib/server/services/common.ts +++ b/src/lib/server/services/common.ts @@ -1,3 +1,4 @@ +import { HskRepo, MekRepo, type Tx } from "#lib/server/db/index.ts"; import { AppError } from "#lib/server/errors.ts"; const DEK_VERSION_TOLERANCE = 60 * 1000; // 1 minute @@ -8,3 +9,17 @@ export const ensureFreshDekVersion = (dekVersion: Date) => { throw new AppError("BAD_REQUEST", "Invalid DEK version"); } }; + +// New data must be encrypted with the active keys, which are locked until the transaction ends + +export const ensureActiveMekVersion = async (trx: Tx, userId: number, mekVersion: number) => { + if ((await MekRepo.lockActiveMekVersion(trx, userId)) !== mekVersion) { + throw new AppError("BAD_REQUEST", "Inactive MEK version"); + } +}; + +export const ensureActiveHskVersion = async (trx: Tx, userId: number, hskVersion: number) => { + if ((await HskRepo.lockActiveHskVersion(trx, userId)) !== hskVersion) { + throw new AppError("BAD_REQUEST", "Inactive HSK version"); + } +}; diff --git a/src/lib/server/services/key.ts b/src/lib/server/services/key.ts index e5cedd9..b9c15c3 100644 --- a/src/lib/server/services/key.ts +++ b/src/lib/server/services/key.ts @@ -1,26 +1,34 @@ -import { ClientRepo, HskRepo, MekRepo } from "#lib/server/db/index.ts"; +import { + ClientRepo, + HskRepo, + LogRepo, + MekRepo, + db, + isUniqueViolation, + transaction, +} from "#lib/server/db/index.ts"; import { verifySignature } from "#lib/server/crypto.ts"; import { AppError, invalidSessionError } from "#lib/server/errors.ts"; import { serializeMasterKeyWrapped } from "#lib/shared/crypto/index.ts"; +import { ensureActiveMekVersion } from "./common.ts"; export const getClientMeks = async (userId: number, clientId: number) => { - return await MekRepo.getAllValidClientMeks(userId, clientId); + return await MekRepo.getAllValidClientMeks(db, userId, clientId); }; const verifyClientEncMekSig = async ( - userId: number, clientId: number, version: number, encMek: string, encMekSig: string, ) => { - const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId); - if (!userClient) { + const client = await ClientRepo.getClient(db, clientId); + if (!client) { throw invalidSessionError(); } const data = serializeMasterKeyWrapped(encMek, version); - return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey); + return verifySignature(Buffer.from(data), encMekSig, client.sigPubKey); }; export const registerInitialMek = async ( @@ -29,16 +37,35 @@ export const registerInitialMek = async ( encMek: string, encMekSig: string, ) => { - if (!(await verifyClientEncMekSig(userId, clientId, 1, encMek, encMekSig))) { + if (!(await verifyClientEncMekSig(clientId, 1, encMek, encMekSig))) { throw new AppError("BAD_REQUEST", "Invalid signature"); } - await MekRepo.registerInitialMek(userId, clientId, encMek, encMekSig); - await ClientRepo.setUserClientStateToActive(userId, clientId); + try { + // The client that registered the master key is approved to access it at the same time + await transaction(async (trx) => { + const { version } = await MekRepo.createInitialMek(trx, userId, clientId, encMek, encMekSig); + await LogRepo.appendMekLog(trx, { + user_id: userId, + master_encryption_key_version: version, + action: "create", + action_by: clientId, + }); + await ClientRepo.transitionUserClientState(trx, userId, clientId, { + from: "pending", + to: "active", + }); + }); + } catch (e) { + if (isUniqueViolation(e)) { + throw new AppError("CONFLICT", "MEK already registered"); + } + throw e; + } }; export const getHsks = async (userId: number) => { - return await HskRepo.getAllValidHsks(userId); + return await HskRepo.getAllValidHsks(db, userId); }; export const registerInitialHsk = async ( @@ -47,5 +74,21 @@ export const registerInitialHsk = async ( mekVersion: number, encHsk: string, ) => { - await HskRepo.registerInitialHsk(userId, clientId, mekVersion, encHsk); + try { + await transaction(async (trx) => { + await ensureActiveMekVersion(trx, userId, mekVersion); + const { version } = await HskRepo.createInitialHsk(trx, userId, mekVersion, encHsk); + await LogRepo.appendHskLog(trx, { + user_id: userId, + hmac_secret_key_version: version, + action: "create", + action_by: clientId, + }); + }); + } catch (e) { + if (isUniqueViolation(e)) { + throw new AppError("CONFLICT", "HSK already registered"); + } + throw e; + } }; diff --git a/src/lib/server/services/user.ts b/src/lib/server/services/user.ts index 8abe62c..11e6014 100644 --- a/src/lib/server/services/user.ts +++ b/src/lib/server/services/user.ts @@ -1,8 +1,8 @@ -import { UserRepo } from "#lib/server/db/index.ts"; +import { UserRepo, db } from "#lib/server/db/index.ts"; import { invalidSessionError } from "#lib/server/errors.ts"; export const getUser = async (userId: number) => { - const user = await UserRepo.getUser(userId); + const user = await UserRepo.getUser(db, userId); if (!user) { throw invalidSessionError(); }