diff --git a/.env.example b/.env.example index e76f3cd..665a966 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ JWT_SECRET= DATABASE_URL= JWT_ACCESS_TOKEN_EXPIRES= JWT_REFRESH_TOKEN_EXPIRES= +PUBKEY_CHALLENGE_EXPIRES= diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c915d9b..1419eeb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,11 @@ import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; import schedule from "node-schedule"; +import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { cleanupExpiredRefreshTokens } from "$lib/server/db/token"; export const init: ServerInit = () => { schedule.scheduleJob("0 * * * *", () => { + cleanupExpiredUserClientChallenges(); cleanupExpiredRefreshTokens(); }); }; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index 006aa32..c6b4dfd 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -1,14 +1,14 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, gt, lte } from "drizzle-orm"; import db from "./drizzle"; -import { client, userClient } from "./schema"; +import { client, userClient, userClientChallenge, UserClientState } from "./schema"; export const createClient = async (pubKey: string, userId: number) => { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const insertRes = await tx.insert(client).values({ pubKey }).returning({ id: client.id }); - await tx.insert(userClient).values({ - userId, - clientId: insertRes[0]!.id, - }); + const { id: clientId } = insertRes[0]!; + await tx.insert(userClient).values({ userId, clientId }); + + return clientId; }); }; @@ -25,3 +25,58 @@ export const getUserClient = async (userId: number, clientId: number) => { .execute(); return userClients[0] ?? null; }; + +export const setUserClientStateToPending = async (userId: number, clientId: number) => { + await db + .update(userClient) + .set({ state: UserClientState.Pending }) + .where( + and( + eq(userClient.userId, userId), + eq(userClient.clientId, clientId), + eq(userClient.state, UserClientState.Challenging), + ), + ) + .execute(); +}; + +export const createUserClientChallenge = async ( + userId: number, + clientId: number, + challenge: string, + allowedIp: string, + expiresAt: number, +) => { + await db + .insert(userClientChallenge) + .values({ + userId, + clientId, + challenge, + allowedIp, + expiresAt, + }) + .execute(); +}; + +export const getUserClientChallenge = async (challenge: string, ip: string) => { + const challenges = await db + .select() + .from(userClientChallenge) + .where( + and( + eq(userClientChallenge.challenge, challenge), + eq(userClientChallenge.allowedIp, ip), + gt(userClientChallenge.expiresAt, Date.now()), + ), + ) + .execute(); + return challenges[0] ?? null; +}; + +export const cleanupExpiredUserClientChallenges = async () => { + await db + .delete(userClientChallenge) + .where(lte(userClientChallenge.expiresAt, Date.now())) + .execute(); +}; diff --git a/src/lib/server/db/schema/client.ts b/src/lib/server/db/schema/client.ts index ad5d678..efacf31 100644 --- a/src/lib/server/db/schema/client.ts +++ b/src/lib/server/db/schema/client.ts @@ -2,8 +2,9 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" import { user } from "./user"; export enum UserClientState { - PENDING = 0, - ACTIVE = 1, + Challenging = 0, + Pending = 1, + Active = 2, } export const client = sqliteTable("client", { @@ -20,10 +21,23 @@ export const userClient = sqliteTable( clientId: integer("client_id") .notNull() .references(() => client.id), - state: integer("state").notNull().default(0), + state: integer("state").notNull().default(UserClientState.Challenging), encKey: text("encrypted_key"), }, (t) => ({ pk: primaryKey({ columns: [t.userId, t.clientId] }), }), ); + +export const userClientChallenge = sqliteTable("user_client_challenge", { + id: integer("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + challenge: text("challenge").notNull().unique(), + allowedIp: text("allowed_ip").notNull(), + expiresAt: integer("expires_at").notNull(), +}); diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index 8df1e19..c31fb07 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -12,4 +12,7 @@ export default { accessExp: env.JWT_ACCESS_TOKEN_EXPIRES || "5m", refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d", }, + challenge: { + pubKeyExp: env.PUBKEY_CHALLENGE_EXPIRES || "5m", + }, }; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index 0811802..e60c4be 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -1,7 +1,7 @@ import { error } from "@sveltejs/kit"; import argon2 from "argon2"; import { v4 as uuidv4 } from "uuid"; -import { getClientByPubKey } from "$lib/server/db/client"; +import { getClientByPubKey, getUserClient } from "$lib/server/db/client"; import { getUserByEmail } from "$lib/server/db/user"; import { getRefreshToken, @@ -9,6 +9,7 @@ import { rotateRefreshToken, revokeRefreshToken, } from "$lib/server/db/token"; +import { UserClientState } from "$lib/server/db/schema"; import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; const verifyPassword = async (hash: string, password: string) => { @@ -36,8 +37,11 @@ export const login = async (email: string, password: string, pubKey?: string) => } const client = pubKey ? await getClientByPubKey(pubKey) : undefined; + const userClient = client ? await getUserClient(user.id, client.id) : undefined; if (client === null) { error(401, "Invalid public key"); + } else if (client && (!userClient || userClient.state === UserClientState.Challenging)) { + error(401, "Unregistered public key"); } return { diff --git a/src/lib/server/services/key.ts b/src/lib/server/services/key.ts index 4d57e08..f5dfa44 100644 --- a/src/lib/server/services/key.ts +++ b/src/lib/server/services/key.ts @@ -1,10 +1,45 @@ import { error } from "@sveltejs/kit"; -import { createClient, getClientByPubKey } from "$lib/server/db/client"; +import { randomBytes, publicEncrypt } from "crypto"; +import ms from "ms"; +import { promisify } from "util"; +import { + createClient, + getClientByPubKey, + createUserClientChallenge, + getUserClientChallenge, + setUserClientStateToPending, +} from "$lib/server/db/client"; +import env from "$lib/server/loadenv"; -export const registerPubKey = async (userId: number, pubKey: string) => { +const expiresIn = ms(env.challenge.pubKeyExp); +const expiresAt = () => Date.now() + expiresIn; + +const generateChallenge = async (userId: number, ip: string, clientId: number, pubKey: string) => { + const challenge = await promisify(randomBytes)(32); + const challengeBase64 = challenge.toString("base64"); + await createUserClientChallenge(userId, clientId, challengeBase64, ip, expiresAt()); + + const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; + const challengeEncrypted = publicEncrypt({ key: pubKeyPem, oaepHash: "sha256" }, challenge); + return challengeEncrypted.toString("base64"); +}; + +export const registerPubKey = async (userId: number, ip: string, pubKey: string) => { if (await getClientByPubKey(pubKey)) { error(409, "Public key already registered"); } - await createClient(pubKey, userId); + const clientId = await createClient(pubKey, userId); + return await generateChallenge(userId, ip, clientId, pubKey); +}; + +export const verifyPubKey = async (userId: number, ip: string, answer: string) => { + const challenge = await getUserClientChallenge(answer, ip); + if (!challenge) { + error(401, "Invalid challenge answer"); + } else if (challenge.userId !== userId) { + error(403, "Forbidden"); + } + + await setUserClientStateToPending(userId, challenge.clientId); }; diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte index ba0283d..74bc801 100644 --- a/src/routes/(fullscreen)/key/export/+page.svelte +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -38,11 +38,11 @@ isBeforeContinueModalOpen = false; isBeforeContinueBottomSheetOpen = false; - if (await requestPubKeyRegistration(data.pubKeyBase64)) { + if (await requestPubKeyRegistration(data.pubKeyBase64, $keyPairStore.privateKey)) { await storeKeyPairPersistently($keyPairStore); await goto(data.redirectPath); } else { - // TODO + // TODO: Error handling } }; diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts index ac0319e..301c825 100644 --- a/src/routes/(fullscreen)/key/export/service.ts +++ b/src/routes/(fullscreen)/key/export/service.ts @@ -13,14 +13,39 @@ export const createBlobFromKeyPairBase64 = (pubKeyBase64: string, privKeyBase64: return new Blob([`${pubKeyPem}\n${privKeyPem}\n`], { type: "text/plain" }); }; -export const requestPubKeyRegistration = async (pubKeyBase64: string) => { - const res = await callAPI("/api/key/register", { +const decryptChallenge = async (challenge: string, privateKey: CryptoKey) => { + const challengeBuffer = Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)); + const answer = await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + } satisfies RsaOaepParams, + privateKey, + challengeBuffer, + ); + return btoa(String.fromCharCode(...new Uint8Array(answer))); +}; + +export const requestPubKeyRegistration = async (pubKeyBase64: string, privateKey: CryptoKey) => { + let res = await callAPI("/api/key/register", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ pubKey: pubKeyBase64 }), }); + if (!res.ok) return false; + + const data = await res.json(); + const challenge = data.challenge as string; + const answer = await decryptChallenge(challenge, privateKey); + + res = await callAPI("/api/key/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ answer }), + }); return res.ok; }; diff --git a/src/routes/api/key/register/+server.ts b/src/routes/api/key/register/+server.ts index 41888a8..766b13f 100644 --- a/src/routes/api/key/register/+server.ts +++ b/src/routes/api/key/register/+server.ts @@ -1,10 +1,10 @@ -import { error, text } from "@sveltejs/kit"; +import { error, json } from "@sveltejs/kit"; import { z } from "zod"; import { authenticate } from "$lib/server/modules/auth"; import { registerPubKey } from "$lib/server/services/key"; import type { RequestHandler } from "./$types"; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const zodRes = z .object({ pubKey: z.string().base64().nonempty(), @@ -17,6 +17,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => { error(403, "Forbidden"); } - await registerPubKey(userId, zodRes.data.pubKey); - return text("Public key registered", { headers: { "Content-Type": "text/plain" } }); + const challenge = await registerPubKey(userId, getClientAddress(), zodRes.data.pubKey); + return json({ challenge }); }; diff --git a/src/routes/api/key/verify/+server.ts b/src/routes/api/key/verify/+server.ts new file mode 100644 index 0000000..bc59816 --- /dev/null +++ b/src/routes/api/key/verify/+server.ts @@ -0,0 +1,22 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { authenticate } from "$lib/server/modules/auth"; +import { verifyPubKey } from "$lib/server/services/key"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const zodRes = z + .object({ + answer: z.string().base64().nonempty(), + }) + .safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + + const { userId, clientId } = authenticate(cookies); + if (clientId) { + error(403, "Forbidden"); + } + + await verifyPubKey(userId, getClientAddress(), zodRes.data.answer); + return text("Key verified", { headers: { "Content-Type": "text/plain" } }); +};