diff --git a/.env.example b/.env.example index 665a966..d0fe7e5 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,5 @@ JWT_SECRET= DATABASE_URL= JWT_ACCESS_TOKEN_EXPIRES= JWT_REFRESH_TOKEN_EXPIRES= -PUBKEY_CHALLENGE_EXPIRES= +USER_CLIENT_CHALLENGE_EXPIRES= +TOKEN_UPGRADE_CHALLENGE_EXPIRES= diff --git a/.prettierignore b/.prettierignore index ab78a95..0d5b39a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,6 @@ package-lock.json pnpm-lock.yaml yarn.lock + +# Output +/drizzle diff --git a/docker-compose.yaml b/docker-compose.yaml index ce7e8aa..5a15630 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,8 @@ services: - JWT_SECRET=${JWT_SECRET:?} # Required - JWT_ACCESS_TOKEN_EXPIRES - JWT_REFRESH_TOKEN_EXPIRES - - PUBKEY_CHALLENGE_EXPIRES + - USER_CLIENT_CHALLENGE_EXPIRES + - TOKEN_UPGRADE_CHALLENGE_EXPIRES # SvelteKit - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - XFF_DEPTH=${TRUST_PROXY:-} diff --git a/drizzle/0001_silly_vanisher.sql b/drizzle/0001_silly_vanisher.sql new file mode 100644 index 0000000..32f51e0 --- /dev/null +++ b/drizzle/0001_silly_vanisher.sql @@ -0,0 +1,20 @@ +CREATE TABLE `token_upgrade_challenge` ( + `id` integer PRIMARY KEY NOT NULL, + `refresh_token_id` text NOT NULL, + `client_id` integer NOT NULL, + `challenge` text NOT NULL, + `allowed_ip` text NOT NULL, + `expires_at` integer NOT NULL, + `is_used` integer DEFAULT false NOT NULL, + FOREIGN KEY (`refresh_token_id`) REFERENCES `refresh_token`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`client_id`) REFERENCES `client`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +ALTER TABLE `client` RENAME COLUMN `public_key` TO `encryption_public_key`;--> statement-breakpoint +DROP INDEX IF EXISTS `client_public_key_unique`;--> statement-breakpoint +ALTER TABLE `client` ADD `signature_public_key` text NOT NULL;--> statement-breakpoint +ALTER TABLE `user_client_challenge` ADD `is_used` integer DEFAULT false NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `token_upgrade_challenge_challenge_unique` ON `token_upgrade_challenge` (`challenge`);--> statement-breakpoint +CREATE UNIQUE INDEX `client_encryption_public_key_unique` ON `client` (`encryption_public_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `client_signature_public_key_unique` ON `client` (`signature_public_key`);--> statement-breakpoint +CREATE UNIQUE INDEX `client_encryption_public_key_signature_public_key_unique` ON `client` (`encryption_public_key`,`signature_public_key`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c33f453 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,611 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f5b74176-eb87-436d-8f32-6da01727b564", + "prevId": "64e2c1ed-92bf-44d1-9094-7e3610b3224f", + "tables": { + "client": { + "name": "client", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_public_key": { + "name": "signature_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "client_encryption_public_key_unique": { + "name": "client_encryption_public_key_unique", + "columns": [ + "encryption_public_key" + ], + "isUnique": true + }, + "client_signature_public_key_unique": { + "name": "client_signature_public_key_unique", + "columns": [ + "signature_public_key" + ], + "isUnique": true + }, + "client_encryption_public_key_signature_public_key_unique": { + "name": "client_encryption_public_key_signature_public_key_unique", + "columns": [ + "encryption_public_key", + "signature_public_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_client": { + "name": "user_client", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'challenging'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_client_user_id_user_id_fk": { + "name": "user_client_user_id_user_id_fk", + "tableFrom": "user_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_client_client_id_client_id_fk": { + "name": "user_client_client_id_client_id_fk", + "tableFrom": "user_client", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_client_user_id_client_id_pk": { + "columns": [ + "client_id", + "user_id" + ], + "name": "user_client_user_id_client_id_pk" + } + }, + "uniqueConstraints": {} + }, + "user_client_challenge": { + "name": "user_client_challenge", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_ip": { + "name": "allowed_ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_client_challenge_challenge_unique": { + "name": "user_client_challenge_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_client_challenge_user_id_user_id_fk": { + "name": "user_client_challenge_user_id_user_id_fk", + "tableFrom": "user_client_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_client_challenge_client_id_client_id_fk": { + "name": "user_client_challenge_client_id_client_id_fk", + "tableFrom": "user_client_challenge", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "client_master_encryption_key": { + "name": "client_master_encryption_key", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "master_encryption_key_version": { + "name": "master_encryption_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_master_encryption_key": { + "name": "encrypted_master_encryption_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "client_master_encryption_key_user_id_user_id_fk": { + "name": "client_master_encryption_key_user_id_user_id_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_master_encryption_key_client_id_client_id_fk": { + "name": "client_master_encryption_key_client_id_client_id_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_master_encryption_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk": { + "name": "client_master_encryption_key_user_id_master_encryption_key_version_master_encryption_key_user_id_version_fk", + "tableFrom": "client_master_encryption_key", + "tableTo": "master_encryption_key", + "columnsFrom": [ + "user_id", + "master_encryption_key_version" + ], + "columnsTo": [ + "user_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_master_encryption_key_user_id_client_id_master_encryption_key_version_pk": { + "columns": [ + "client_id", + "master_encryption_key_version", + "user_id" + ], + "name": "client_master_encryption_key_user_id_client_id_master_encryption_key_version_pk" + } + }, + "uniqueConstraints": {} + }, + "master_encryption_key": { + "name": "master_encryption_key", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retired_at": { + "name": "retired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "master_encryption_key_user_id_user_id_fk": { + "name": "master_encryption_key_user_id_user_id_fk", + "tableFrom": "master_encryption_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "master_encryption_key_created_by_client_id_fk": { + "name": "master_encryption_key_created_by_client_id_fk", + "tableFrom": "master_encryption_key", + "tableTo": "client", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "master_encryption_key_user_id_version_pk": { + "columns": [ + "user_id", + "version" + ], + "name": "master_encryption_key_user_id_version_pk" + } + }, + "uniqueConstraints": {} + }, + "refresh_token": { + "name": "refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "refresh_token_user_id_client_id_unique": { + "name": "refresh_token_user_id_client_id_unique", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_token_client_id_client_id_fk": { + "name": "refresh_token_client_id_client_id_fk", + "tableFrom": "refresh_token", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "token_upgrade_challenge": { + "name": "token_upgrade_challenge", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_id": { + "name": "refresh_token_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_ip": { + "name": "allowed_ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_used": { + "name": "is_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "token_upgrade_challenge_challenge_unique": { + "name": "token_upgrade_challenge_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": { + "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk": { + "name": "token_upgrade_challenge_refresh_token_id_refresh_token_id_fk", + "tableFrom": "token_upgrade_challenge", + "tableTo": "refresh_token", + "columnsFrom": [ + "refresh_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_upgrade_challenge_client_id_client_id_fk": { + "name": "token_upgrade_challenge_client_id_client_id_fk", + "tableFrom": "token_upgrade_challenge", + "tableTo": "client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"client\".\"public_key\"": "\"client\".\"encryption_public_key\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 70b290a..dc91af8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1735525637133, "tag": "0000_spicy_morgan_stark", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1735588850570, + "tag": "0001_silly_vanisher", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 777e492..e237845 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,7 +2,10 @@ import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; import schedule from "node-schedule"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { migrateDB } from "$lib/server/db/drizzle"; -import { cleanupExpiredRefreshTokens } from "$lib/server/db/token"; +import { + cleanupExpiredRefreshTokens, + cleanupExpiredTokenUpgradeChallenges, +} from "$lib/server/db/token"; export const init: ServerInit = () => { migrateDB(); @@ -10,6 +13,7 @@ export const init: ServerInit = () => { schedule.scheduleJob("0 * * * *", () => { cleanupExpiredUserClientChallenges(); cleanupExpiredRefreshTokens(); + cleanupExpiredTokenUpgradeChallenges(); }); }; diff --git a/src/lib/hooks/gotoStateful.ts b/src/lib/hooks/gotoStateful.ts index a6f7bd5..47f1190 100644 --- a/src/lib/hooks/gotoStateful.ts +++ b/src/lib/hooks/gotoStateful.ts @@ -4,8 +4,12 @@ type Path = "/key/export"; interface KeyExportState { redirectPath: string; - pubKeyBase64: string; - privKeyBase64: string; + + encryptKeyBase64: string; + decryptKeyBase64: string; + signKeyBase64: string; + verifyKeyBase64: string; + mekDraft: ArrayBuffer; } diff --git a/src/lib/indexedDB.ts b/src/lib/indexedDB.ts index ff0c29e..1282314 100644 --- a/src/lib/indexedDB.ts +++ b/src/lib/indexedDB.ts @@ -1,33 +1,43 @@ import { Dexie, type EntityTable } from "dexie"; -interface KeyPair { - type: "publicKey" | "privateKey"; +type RSAKeyUsage = "encrypt" | "decrypt" | "sign" | "verify"; + +interface RSAKey { + usage: RSAKeyUsage; key: CryptoKey; } const keyStore = new Dexie("keyStore") as Dexie & { - keyPair: EntityTable; + rsaKey: EntityTable; }; keyStore.version(1).stores({ - keyPair: "type", + rsaKey: "usage", }); -export const getKeyPairFromIndexedDB = async () => { - const pubKey = await keyStore.keyPair.get("publicKey"); - const privKey = await keyStore.keyPair.get("privateKey"); - return { - pubKey: pubKey?.key ?? null, - privKey: privKey?.key ?? null, - }; +export const getRSAKey = async (usage: RSAKeyUsage) => { + const key = await keyStore.rsaKey.get(usage); + return key?.key ?? null; }; -export const storeKeyPairIntoIndexedDB = async (pubKey: CryptoKey, privKey: CryptoKey) => { - if (!pubKey.extractable) throw new Error("Public key must be extractable"); - if (privKey.extractable) throw new Error("Private key must be non-extractable"); - - await keyStore.keyPair.bulkPut([ - { type: "publicKey", key: pubKey }, - { type: "privateKey", key: privKey }, - ]); +export const storeRSAKey = async (key: CryptoKey, usage: RSAKeyUsage) => { + switch (usage) { + case "encrypt": + case "verify": + if (key.type !== "public") { + throw new Error("Public key required"); + } else if (!key.extractable) { + throw new Error("Public key must be extractable"); + } + break; + case "decrypt": + case "sign": + if (key.type !== "private") { + throw new Error("Private key required"); + } else if (key.extractable) { + throw new Error("Private key must be non-extractable"); + } + break; + } + await keyStore.rsaKey.put({ usage, key }); }; diff --git a/src/lib/modules/crypto.ts b/src/lib/modules/crypto.ts index d3fe0d2..0d82085 100644 --- a/src/lib/modules/crypto.ts +++ b/src/lib/modules/crypto.ts @@ -1,3 +1,4 @@ +export type RSAKeyPurpose = "encryption" | "signature"; export type RSAKeyType = "public" | "private"; export const encodeToBase64 = (data: ArrayBuffer) => { @@ -8,42 +9,42 @@ export const decodeFromBase64 = (data: string) => { return Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer; }; -export const generateRSAKeyPair = async () => { - const keyPair = await window.crypto.subtle.generateKey( +export const generateRSAKeyPair = async (purpose: RSAKeyPurpose) => { + return await window.crypto.subtle.generateKey( { - name: "RSA-OAEP", + name: purpose === "encryption" ? "RSA-OAEP" : "RSA-PSS", modulusLength: 4096, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256", } satisfies RsaHashedKeyGenParams, true, - ["encrypt", "decrypt"], + purpose === "encryption" ? ["encrypt", "decrypt"] : ["sign", "verify"], ); - return keyPair; }; -export const makeRSAKeyNonextractable = async (key: CryptoKey, type: RSAKeyType) => { - const { format, key: exportedKey } = await exportRSAKey(key, type); +export const makeRSAKeyNonextractable = async (key: CryptoKey) => { + const { format, key: exportedKey } = await exportRSAKey(key); return await window.crypto.subtle.importKey( format, exportedKey, - { - name: "RSA-OAEP", - hash: "SHA-256", - } satisfies RsaHashedImportParams, + key.algorithm, false, - [type === "public" ? "encrypt" : "decrypt"], + key.usages, ); }; -export const exportRSAKey = async (key: CryptoKey, type: RSAKeyType) => { - const format = type === "public" ? ("spki" as const) : ("pkcs8" as const); +export const exportRSAKey = async (key: CryptoKey) => { + const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); return { format, key: await window.crypto.subtle.exportKey(format, key), }; }; +export const exportRSAKeyToBase64 = async (key: CryptoKey) => { + return encodeToBase64((await exportRSAKey(key)).key); +}; + export const encryptRSAPlaintext = async (plaintext: ArrayBuffer, publicKey: CryptoKey) => { return await window.crypto.subtle.encrypt( { @@ -64,6 +65,17 @@ export const decryptRSACiphertext = async (ciphertext: ArrayBuffer, privateKey: ); }; +export const signRSAMessage = async (message: ArrayBuffer, privateKey: CryptoKey) => { + return await window.crypto.subtle.sign( + { + name: "RSA-PSS", + saltLength: 32, + } satisfies RsaPssParams, + privateKey, + message, + ); +}; + export const generateAESKey = async () => { return await window.crypto.subtle.generateKey( { @@ -79,12 +91,9 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => { return await window.crypto.subtle.importKey( "raw", await exportAESKey(key), - { - name: "AES-GCM", - length: 256, - } satisfies AesKeyAlgorithm, + key.algorithm, false, - ["encrypt", "decrypt"], + key.usages, ); }; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index c31579b..29f4806 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -1,10 +1,21 @@ -import { and, eq, gt, lte } from "drizzle-orm"; +import { and, or, eq, gt, lte, count } from "drizzle-orm"; import db from "./drizzle"; import { client, userClient, userClientChallenge } from "./schema"; -export const createClient = async (pubKey: string, userId: number) => { +export const createClient = async (encPubKey: string, sigPubKey: string, userId: number) => { return await db.transaction(async (tx) => { - const insertRes = await tx.insert(client).values({ pubKey }).returning({ id: client.id }); + const clients = await tx + .select() + .from(client) + .where(or(eq(client.encPubKey, sigPubKey), eq(client.sigPubKey, encPubKey))); + if (clients.length > 0) { + throw new Error("Already used public key(s)"); + } + + const insertRes = await tx + .insert(client) + .values({ encPubKey, sigPubKey }) + .returning({ id: client.id }); const { id: clientId } = insertRes[0]!; await tx.insert(userClient).values({ userId, clientId }); @@ -12,11 +23,28 @@ export const createClient = async (pubKey: string, userId: number) => { }); }; -export const getClientByPubKey = async (pubKey: string) => { - const clients = await db.select().from(client).where(eq(client.pubKey, pubKey)).execute(); +export const getClient = async (clientId: number) => { + const clients = await db.select().from(client).where(eq(client.id, clientId)).execute(); return clients[0] ?? 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))) + .execute(); + return clients[0] ?? null; +}; + +export const countClientByPubKey = async (pubKey: string) => { + const clients = await db + .select({ count: count() }) + .from(client) + .where(or(eq(client.encPubKey, pubKey), eq(client.encPubKey, pubKey))); + return clients[0]?.count ?? 0; +}; + export const createUserClient = async (userId: number, clientId: number) => { await db.insert(userClient).values({ userId, clientId }).execute(); }; @@ -62,7 +90,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe .execute(); }; -export const createUserClientChallenge = async ( +export const registerUserClientChallenge = async ( userId: number, clientId: number, answer: string, @@ -90,12 +118,21 @@ export const getUserClientChallenge = async (answer: string, ip: string) => { eq(userClientChallenge.answer, answer), eq(userClientChallenge.allowedIp, ip), gt(userClientChallenge.expiresAt, new Date()), + eq(userClientChallenge.isUsed, false), ), ) .execute(); return challenges[0] ?? null; }; +export const markUserClientChallengeAsUsed = async (id: number) => { + await db + .update(userClientChallenge) + .set({ isUsed: true }) + .where(eq(userClientChallenge.id, id)) + .execute(); +}; + export const cleanupExpiredUserClientChallenges = async () => { await db .delete(userClientChallenge) diff --git a/src/lib/server/db/schema/client.ts b/src/lib/server/db/schema/client.ts index 75c9fc4..7d83435 100644 --- a/src/lib/server/db/schema/client.ts +++ b/src/lib/server/db/schema/client.ts @@ -1,10 +1,17 @@ -import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, primaryKey, unique } from "drizzle-orm/sqlite-core"; import { user } from "./user"; -export const client = sqliteTable("client", { - id: integer("id").primaryKey(), - pubKey: text("public_key").notNull().unique(), // Base64 -}); +export const client = sqliteTable( + "client", + { + id: integer("id").primaryKey(), + encPubKey: text("encryption_public_key").notNull().unique(), // Base64 + sigPubKey: text("signature_public_key").notNull().unique(), // Base64 + }, + (t) => ({ + unq: unique().on(t.encPubKey, t.sigPubKey), + }), +); export const userClient = sqliteTable( "user_client", @@ -35,4 +42,5 @@ export const userClientChallenge = sqliteTable("user_client_challenge", { answer: text("challenge").notNull().unique(), // Base64 allowedIp: text("allowed_ip").notNull(), expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false), }); diff --git a/src/lib/server/db/schema/token.ts b/src/lib/server/db/schema/token.ts index 7007f77..72106d7 100644 --- a/src/lib/server/db/schema/token.ts +++ b/src/lib/server/db/schema/token.ts @@ -16,3 +16,17 @@ export const refreshToken = sqliteTable( unq: unique().on(t.userId, t.clientId), }), ); + +export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", { + id: integer("id").primaryKey(), + refreshTokenId: text("refresh_token_id") + .notNull() + .references(() => refreshToken.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + answer: text("challenge").notNull().unique(), // Base64 + allowedIp: text("allowed_ip").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false), +}); diff --git a/src/lib/server/db/token.ts b/src/lib/server/db/token.ts index 133b3b4..61545e4 100644 --- a/src/lib/server/db/token.ts +++ b/src/lib/server/db/token.ts @@ -1,9 +1,9 @@ import { SqliteError } from "better-sqlite3"; -import { eq, lte } from "drizzle-orm"; +import { and, eq, gt, lte } from "drizzle-orm"; import ms from "ms"; import env from "$lib/server/loadenv"; import db from "./drizzle"; -import { refreshToken } from "./schema"; +import { refreshToken, tokenUpgradeChallenge } from "./schema"; const expiresIn = ms(env.jwt.refreshExp); const expiresAt = () => new Date(Date.now() + expiresIn); @@ -38,15 +38,20 @@ export const getRefreshToken = async (tokenId: string) => { }; export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => { - const res = await db - .update(refreshToken) - .set({ - id: newTokenId, - expiresAt: expiresAt(), - }) - .where(eq(refreshToken.id, oldTokenId)) - .execute(); - return res.changes > 0; + return await db.transaction(async (tx) => { + await tx + .delete(tokenUpgradeChallenge) + .where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId)); + const res = await db + .update(refreshToken) + .set({ + id: newTokenId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; + }); }; export const upgradeRefreshToken = async ( @@ -54,16 +59,21 @@ export const upgradeRefreshToken = async ( newTokenId: string, clientId: number, ) => { - const res = await db - .update(refreshToken) - .set({ - id: newTokenId, - clientId, - expiresAt: expiresAt(), - }) - .where(eq(refreshToken.id, oldTokenId)) - .execute(); - return res.changes > 0; + return await db.transaction(async (tx) => { + await tx + .delete(tokenUpgradeChallenge) + .where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId)); + const res = await tx + .update(refreshToken) + .set({ + id: newTokenId, + clientId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; + }); }; export const revokeRefreshToken = async (tokenId: string) => { @@ -73,3 +83,53 @@ export const revokeRefreshToken = async (tokenId: string) => { export const cleanupExpiredRefreshTokens = async () => { await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute(); }; + +export const registerTokenUpgradeChallenge = async ( + tokenId: string, + clientId: number, + answer: string, + allowedIp: string, + expiresAt: Date, +) => { + await db + .insert(tokenUpgradeChallenge) + .values({ + refreshTokenId: tokenId, + clientId, + answer, + allowedIp, + expiresAt, + }) + .execute(); +}; + +export const getTokenUpgradeChallenge = async (answer: string, ip: string) => { + const challenges = await db + .select() + .from(tokenUpgradeChallenge) + .where( + and( + eq(tokenUpgradeChallenge.answer, answer), + eq(tokenUpgradeChallenge.allowedIp, ip), + gt(tokenUpgradeChallenge.expiresAt, new Date()), + eq(tokenUpgradeChallenge.isUsed, false), + ), + ) + .execute(); + return challenges[0] ?? null; +}; + +export const markTokenUpgradeChallengeAsUsed = async (id: number) => { + await db + .update(tokenUpgradeChallenge) + .set({ isUsed: true }) + .where(eq(tokenUpgradeChallenge.id, id)) + .execute(); +}; + +export const cleanupExpiredTokenUpgradeChallenges = async () => { + await db + .delete(tokenUpgradeChallenge) + .where(lte(tokenUpgradeChallenge.expiresAt, new Date())) + .execute(); +}; diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index c31fb07..58f6e72 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -13,6 +13,7 @@ export default { refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d", }, challenge: { - pubKeyExp: env.PUBKEY_CHALLENGE_EXPIRES || "5m", + userClientExp: env.USER_CLIENT_CHALLENGE_EXPIRES || "5m", + tokenUpgradeExp: env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m", }, }; diff --git a/src/lib/server/modules/crypto.ts b/src/lib/server/modules/crypto.ts new file mode 100644 index 0000000..49201d9 --- /dev/null +++ b/src/lib/server/modules/crypto.ts @@ -0,0 +1,36 @@ +import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto"; +import { promisify } from "util"; + +const makePubKeyToPem = (pubKey: string) => + `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; + +export const verifyPubKey = (pubKey: string) => { + const pubKeyPem = makePubKeyToPem(pubKey); + const pubKeyObject = createPublicKey(pubKeyPem); + return ( + pubKeyObject.asymmetricKeyType === "rsa" && + pubKeyObject.asymmetricKeyDetails?.modulusLength === 4096 + ); +}; + +export const encryptAsymmetric = (data: Buffer, encPubKey: string) => { + return publicEncrypt({ key: makePubKeyToPem(encPubKey), oaepHash: "sha256" }, data); +}; + +export const verifySignature = (data: string, signature: string, sigPubKey: string) => { + return verify( + "rsa-sha256", + Buffer.from(data, "base64"), + { + key: makePubKeyToPem(sigPubKey), + padding: constants.RSA_PKCS1_PSS_PADDING, + }, + Buffer.from(signature, "base64"), + ); +}; + +export const generateChallenge = async (length: number, encPubKey: string) => { + const answer = await promisify(randomBytes)(length); + const challenge = encryptAsymmetric(answer, encPubKey); + return { answer, challenge }; +}; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index 8a9a2c4..aeaf858 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -1,16 +1,22 @@ import { error } from "@sveltejs/kit"; import argon2 from "argon2"; +import ms from "ms"; import { v4 as uuidv4 } from "uuid"; -import { getClientByPubKey, getUserClient } from "$lib/server/db/client"; +import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client"; import { getUserByEmail } from "$lib/server/db/user"; +import env from "$lib/server/loadenv"; import { getRefreshToken, registerRefreshToken, rotateRefreshToken, upgradeRefreshToken, revokeRefreshToken, + registerTokenUpgradeChallenge, + getTokenUpgradeChallenge, + markTokenUpgradeChallengeAsUsed, } from "$lib/server/db/token"; import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; +import { verifySignature, generateChallenge } from "$lib/server/modules/crypto"; const verifyPassword = async (hash: string, password: string) => { return await argon2.verify(hash, password); @@ -30,23 +36,15 @@ const issueRefreshToken = async (userId: number, clientId?: number) => { return token; }; -export const login = async (email: string, password: string, pubKey?: string) => { +export const login = async (email: string, password: string) => { const user = await getUserByEmail(email); if (!user || !(await verifyPassword(user.password, password))) { error(401, "Invalid email or password"); } - 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 === "challenging")) { - error(401, "Unregistered public key"); - } - return { - accessToken: issueAccessToken(user.id, client?.id), - refreshToken: await issueRefreshToken(user.id, client?.id), + accessToken: issueAccessToken(user.id), + refreshToken: await issueRefreshToken(user.id), }; }; @@ -75,7 +73,7 @@ export const logout = async (refreshToken: string) => { await revokeRefreshToken(jti); }; -export const refreshTokens = async (refreshToken: string) => { +export const refreshToken = async (refreshToken: string) => { const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); const newJti = uuidv4(); @@ -88,20 +86,75 @@ export const refreshTokens = async (refreshToken: string) => { }; }; -export const upgradeTokens = async (refreshToken: string, pubKey: string) => { +const expiresIn = ms(env.challenge.tokenUpgradeExp); +const expiresAt = () => new Date(Date.now() + expiresIn); + +const createChallenge = async ( + ip: string, + tokenId: string, + clientId: number, + encPubKey: string, +) => { + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerTokenUpgradeChallenge( + tokenId, + clientId, + answer.toString("base64"), + ip, + expiresAt(), + ); + return challenge.toString("base64"); +}; + +export const createTokenUpgradeChallenge = async ( + refreshToken: string, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + const { jti, userId, clientId } = await verifyRefreshToken(refreshToken); + if (clientId) { + error(403, "Forbidden"); + } + + const client = await getClientByPubKeys(encPubKey, sigPubKey); + const userClient = client ? await getUserClient(userId, client.id) : undefined; + if (!client) { + error(401, "Invalid public key(s)"); + } else if (!userClient || userClient.state === "challenging") { + error(401, "Unregistered client"); + } + + return { challenge: await createChallenge(ip, jti, client.id, encPubKey) }; +}; + +export const upgradeToken = async ( + refreshToken: string, + ip: string, + answer: string, + sigAnswer: string, +) => { const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); if (clientId) { error(403, "Forbidden"); } - const client = await getClientByPubKey(pubKey); - const userClient = client ? await getUserClient(userId, client.id) : undefined; - if (!client) { - error(401, "Invalid public key"); - } else if (client && (!userClient || userClient.state === "challenging")) { - error(401, "Unregistered public key"); + const challenge = await getTokenUpgradeChallenge(answer, ip); + if (!challenge) { + error(401, "Invalid challenge answer"); + } else if (challenge.refreshTokenId !== oldJti) { + error(403, "Forbidden"); } + const client = await getClient(challenge.clientId); + if (!client) { + error(500, "Invalid challenge answer"); + } else if (!verifySignature(answer, sigAnswer, client.sigPubKey)) { + error(401, "Invalid challenge answer signature"); + } + + await markTokenUpgradeChallengeAsUsed(challenge.id); + const newJti = uuidv4(); if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) { error(500, "Refresh token not found"); diff --git a/src/lib/server/services/client.ts b/src/lib/server/services/client.ts index a3b9605..07729c0 100644 --- a/src/lib/server/services/client.ts +++ b/src/lib/server/services/client.ts @@ -1,17 +1,19 @@ import { error } from "@sveltejs/kit"; -import { randomBytes, publicEncrypt, createPublicKey } from "crypto"; import ms from "ms"; -import { promisify } from "util"; import { createClient, - getClientByPubKey, + getClient, + getClientByPubKeys, + countClientByPubKey, createUserClient, getAllUserClients, getUserClient, - createUserClientChallenge, - getUserClientChallenge, setUserClientStateToPending, + registerUserClientChallenge, + getUserClientChallenge, + markUserClientChallengeAsUsed, } from "$lib/server/db/client"; +import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto"; import { isInitialMekNeeded } from "$lib/server/modules/mek"; import env from "$lib/server/loadenv"; @@ -25,45 +27,53 @@ export const getUserClientList = async (userId: number) => { }; }; -const expiresIn = ms(env.challenge.pubKeyExp); +const expiresIn = ms(env.challenge.userClientExp); const expiresAt = () => new Date(Date.now() + expiresIn); -const generateChallenge = async (userId: number, ip: string, clientId: number, pubKey: string) => { - const answer = await promisify(randomBytes)(32); - const answerBase64 = answer.toString("base64"); - await createUserClientChallenge(userId, clientId, answerBase64, ip, expiresAt()); - - const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; - const challenge = publicEncrypt({ key: pubKeyPem, oaepHash: "sha256" }, answer); +const createUserClientChallenge = async ( + userId: number, + ip: string, + clientId: number, + encPubKey: string, +) => { + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt()); return challenge.toString("base64"); }; -export const registerUserClient = async (userId: number, ip: string, pubKey: string) => { - const client = await getClientByPubKey(pubKey); +export const registerUserClient = async ( + userId: number, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { let clientId; + const client = await getClientByPubKeys(encPubKey, sigPubKey); if (client) { const userClient = await getUserClient(userId, client.id); if (userClient) { - error(409, "Public key already registered"); + error(409, "Client already registered"); } await createUserClient(userId, client.id); clientId = client.id; } else { - const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; - const pubKeyObject = createPublicKey(pubKeyPem); - if ( - pubKeyObject.asymmetricKeyType !== "rsa" || - pubKeyObject.asymmetricKeyDetails?.modulusLength !== 4096 + if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { + error(400, "Invalid public key(s)"); + } else if (encPubKey === sigPubKey) { + error(400, "Public keys must be different"); + } else if ( + (await countClientByPubKey(encPubKey)) > 0 || + (await countClientByPubKey(sigPubKey)) > 0 ) { - error(400, "Invalid public key"); + error(409, "Public key(s) already registered"); } - clientId = await createClient(pubKey, userId); + clientId = await createClient(encPubKey, sigPubKey, userId); } - return await generateChallenge(userId, ip, clientId, pubKey); + return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) }; }; export const getUserClientStatus = async (userId: number, clientId: number) => { @@ -78,7 +88,12 @@ export const getUserClientStatus = async (userId: number, clientId: number) => { }; }; -export const verifyUserClient = async (userId: number, ip: string, answer: string) => { +export const verifyUserClient = async ( + userId: number, + ip: string, + answer: string, + sigAnswer: string, +) => { const challenge = await getUserClientChallenge(answer, ip); if (!challenge) { error(401, "Invalid challenge answer"); @@ -86,5 +101,13 @@ export const verifyUserClient = async (userId: number, ip: string, answer: strin error(403, "Forbidden"); } + const client = await getClient(challenge.clientId); + if (!client) { + error(500, "Invalid challenge answer"); + } else if (!verifySignature(answer, sigAnswer, client.sigPubKey)) { + error(401, "Invalid challenge answer signature"); + } + + await markUserClientChallengeAsUsed(challenge.id); await setUserClientStateToPending(userId, challenge.clientId); }; diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts new file mode 100644 index 0000000..865b056 --- /dev/null +++ b/src/lib/services/auth.ts @@ -0,0 +1,41 @@ +import { + encodeToBase64, + decodeFromBase64, + decryptRSACiphertext, + signRSAMessage, +} from "$lib/modules/crypto"; + +export const requestTokenUpgrade = async ( + encryptKeyBase64: string, + decryptKey: CryptoKey, + verifyKeyBase64: string, + signKey: CryptoKey, +) => { + let res = await fetch("/api/auth/upgradeToken", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + encPubKey: encryptKeyBase64, + sigPubKey: verifyKeyBase64, + }), + }); + if (!res.ok) return false; + + const { challenge } = await res.json(); + const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey); + const sigAnswer = await signRSAMessage(answer, signKey); + + res = await fetch("/api/auth/upgradeToken/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + answer: encodeToBase64(answer), + sigAnswer: encodeToBase64(sigAnswer), + }), + }); + return res.ok; +}; diff --git a/src/lib/services/key.ts b/src/lib/services/key.ts new file mode 100644 index 0000000..0183982 --- /dev/null +++ b/src/lib/services/key.ts @@ -0,0 +1,42 @@ +import { callAPI } from "$lib/hooks"; +import { + encodeToBase64, + decodeFromBase64, + decryptRSACiphertext, + signRSAMessage, +} from "$lib/modules/crypto"; + +export const requestClientRegistration = async ( + encryptKeyBase64: string, + decryptKey: CryptoKey, + verifyKeyBase64: string, + signKey: CryptoKey, +) => { + let res = await callAPI("/api/client/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + encPubKey: encryptKeyBase64, + sigPubKey: verifyKeyBase64, + }), + }); + if (!res.ok) return false; + + const { challenge } = await res.json(); + const answer = await decryptRSACiphertext(decodeFromBase64(challenge), decryptKey); + const sigAnswer = await signRSAMessage(answer, signKey); + + res = await callAPI("/api/client/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + answer: encodeToBase64(answer), + sigAnswer: encodeToBase64(sigAnswer), + }), + }); + return res.ok; +}; diff --git a/src/lib/stores/key.ts b/src/lib/stores/key.ts index 4b806c7..19cff13 100644 --- a/src/lib/stores/key.ts +++ b/src/lib/stores/key.ts @@ -1,4 +1,11 @@ import { writable } from "svelte/store"; -export const keyPairStore = writable(null); +export interface ClientKeys { + encryptKey: CryptoKey; + decryptKey: CryptoKey; + signKey: CryptoKey; + verifyKey: CryptoKey; +} + +export const clientKeyStore = writable(null); export const mekStore = writable>(new Map()); diff --git a/src/routes/(fullscreen)/auth/login/+page.svelte b/src/routes/(fullscreen)/auth/login/+page.svelte index 015bcfc..556911f 100644 --- a/src/routes/(fullscreen)/auth/login/+page.svelte +++ b/src/routes/(fullscreen)/auth/login/+page.svelte @@ -5,8 +5,8 @@ import { TitleDiv, BottomDiv } from "$lib/components/divs"; import { TextInput } from "$lib/components/inputs"; import { refreshToken } from "$lib/hooks/callAPI"; - import { keyPairStore } from "$lib/stores"; - import { requestLogin } from "./service"; + import { clientKeyStore } from "$lib/stores"; + import { requestLogin, requestTokenUpgrade } from "./service"; let { data } = $props(); @@ -16,14 +16,20 @@ const login = async () => { // TODO: Validation - if (await requestLogin(email, password, $keyPairStore)) { + try { + if (!(await requestLogin(email, password))) throw new Error("Failed to login"); + + if ($clientKeyStore && !(await requestTokenUpgrade($clientKeyStore))) + throw new Error("Failed to upgrade token"); + await goto( - $keyPairStore + $clientKeyStore ? data.redirectPath : "/key/generate?redirect=" + encodeURIComponent(data.redirectPath), ); - } else { + } catch (e) { // TODO: Alert + throw e; } }; diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts index d8abe33..77c4620 100644 --- a/src/routes/(fullscreen)/auth/login/service.ts +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -1,48 +1,38 @@ -import { encodeToBase64, exportRSAKey } from "$lib/modules/crypto"; -import { requestPubKeyRegistration } from "../../key/export/service"; +import { exportRSAKeyToBase64 } from "$lib/modules/crypto"; +import { requestTokenUpgrade as requestTokenUpgradeInternal } from "$lib/services/auth"; +import { requestClientRegistration } from "$lib/services/key"; +import type { ClientKeys } from "$lib/stores"; -const callLoginAPI = async (email: string, password: string, pubKeyBase64?: string) => { - return await fetch("/api/auth/login", { +export const requestLogin = async (email: string, password: string) => { + const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - email, - password, - pubKey: pubKeyBase64, - }), + body: JSON.stringify({ email, password }), }); + return res.ok; }; -export const requestLogin = async ( - email: string, - password: string, - keyPair: CryptoKeyPair | null, - registerPubKey = true, -): Promise => { - const pubKeyBase64 = keyPair - ? encodeToBase64((await exportRSAKey(keyPair.publicKey, "public")).key) - : undefined; - let loginRes = await callLoginAPI(email, password, pubKeyBase64); - if (loginRes.ok) { +export const requestTokenUpgrade = async ({ + encryptKey, + decryptKey, + signKey, + verifyKey, +}: ClientKeys) => { + const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey); + const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey); + if (await requestTokenUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { return true; - } else if (loginRes.status !== 401 || !keyPair || !registerPubKey) { - return false; } - const { message } = await loginRes.json(); - if (message !== "Unregistered public key") { - return false; - } - - loginRes = await callLoginAPI(email, password); - if (!loginRes.ok) { - return false; - } - - if (await requestPubKeyRegistration(pubKeyBase64!, keyPair.privateKey)) { - return requestLogin(email, password, keyPair, false); + if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { + return await requestTokenUpgradeInternal( + encryptKeyBase64, + decryptKey, + verifyKeyBase64, + signKey, + ); } else { return false; } diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte index 267fd18..74ee5ea 100644 --- a/src/routes/(fullscreen)/key/export/+page.svelte +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -3,13 +3,13 @@ import { goto } from "$app/navigation"; import { Button, TextButton } from "$lib/components/buttons"; import { BottomDiv } from "$lib/components/divs"; - import { keyPairStore } from "$lib/stores"; + import { clientKeyStore } from "$lib/stores"; import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte"; import BeforeContinueModal from "./BeforeContinueModal.svelte"; import { - createBlobFromKeyPairBase64, - requestPubKeyRegistration, - storeKeyPairPersistently, + exportClientKeys, + requestClientRegistration, + storeClientKeys, requestTokenUpgrade, requestInitialMekRegistration, } from "./service"; @@ -22,8 +22,16 @@ let isBeforeContinueBottomSheetOpen = $state(false); const exportKeyPair = () => { - const keyPairBlob = createBlobFromKeyPairBase64(data.pubKeyBase64, data.privKeyBase64); - saveAs(keyPairBlob, "arkvalut-keypair.pem"); + const clientKeysExported = exportClientKeys( + data.encryptKeyBase64, + data.decryptKeyBase64, + data.signKeyBase64, + data.verifyKeyBase64, + ); + const clientKeysBlob = new Blob([JSON.stringify(clientKeysExported)], { + type: "application/json", + }); + saveAs(clientKeysBlob, "arkvalut-clientkey.json"); if (!isBeforeContinueBottomSheetOpen) { setTimeout(() => { @@ -33,7 +41,7 @@ }; const registerPubKey = async () => { - if (!$keyPairStore) { + if (!$clientKeyStore) { throw new Error("Failed to find key pair"); } @@ -41,15 +49,29 @@ isBeforeContinueBottomSheetOpen = false; try { - if (!(await requestPubKeyRegistration(data.pubKeyBase64, $keyPairStore.privateKey))) - throw new Error("Failed to register public key"); + if ( + !(await requestClientRegistration( + data.encryptKeyBase64, + $clientKeyStore.decryptKey, + data.verifyKeyBase64, + $clientKeyStore.signKey, + )) + ) + throw new Error("Failed to register client"); - await storeKeyPairPersistently($keyPairStore); + await storeClientKeys($clientKeyStore); - if (!(await requestTokenUpgrade(data.pubKeyBase64))) + if ( + !(await requestTokenUpgrade( + data.encryptKeyBase64, + $clientKeyStore.decryptKey, + data.verifyKeyBase64, + $clientKeyStore.signKey, + )) + ) throw new Error("Failed to upgrade token"); - if (!(await requestInitialMekRegistration(data.mekDraft, $keyPairStore.publicKey))) + if (!(await requestInitialMekRegistration(data.mekDraft, $clientKeyStore.encryptKey))) throw new Error("Failed to register initial MEK"); await goto(data.redirectPath); diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts index cd0dd89..bd9493c 100644 --- a/src/routes/(fullscreen)/key/export/service.ts +++ b/src/routes/(fullscreen)/key/export/service.ts @@ -1,68 +1,51 @@ import { callAPI } from "$lib/hooks"; -import { storeKeyPairIntoIndexedDB } from "$lib/indexedDB"; -import { - encodeToBase64, - decodeFromBase64, - encryptRSAPlaintext, - decryptRSACiphertext, -} from "$lib/modules/crypto"; +import { storeRSAKey } from "$lib/indexedDB"; +import { encodeToBase64, encryptRSAPlaintext } from "$lib/modules/crypto"; +import type { ClientKeys } from "$lib/stores"; -export const createBlobFromKeyPairBase64 = (pubKeyBase64: string, privKeyBase64: string) => { - const pubKeyFormatted = pubKeyBase64.match(/.{1,64}/g)?.join("\n"); - const privKeyFormatted = privKeyBase64.match(/.{1,64}/g)?.join("\n"); - if (!pubKeyFormatted || !privKeyFormatted) { - throw new Error("Failed to format key pair"); - } +export { requestTokenUpgrade } from "$lib/services/auth"; +export { requestClientRegistration } from "$lib/services/key"; - const pubKeyPem = `-----BEGIN RSA PUBLIC KEY-----\n${pubKeyFormatted}\n-----END RSA PUBLIC KEY-----`; - const privKeyPem = `-----BEGIN RSA PRIVATE KEY-----\n${privKeyFormatted}\n-----END RSA PRIVATE KEY-----`; - return new Blob([`${pubKeyPem}\n${privKeyPem}\n`], { type: "text/plain" }); +type ExportedKeyPairs = { + generator: "ArkVault"; + exportedAt: Date; +} & { + version: 1; + encryptKey: string; + decryptKey: string; + signKey: string; + verifyKey: string; }; -export const requestPubKeyRegistration = async (pubKeyBase64: string, privateKey: CryptoKey) => { - let res = await callAPI("/api/client/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 decryptRSACiphertext(decodeFromBase64(challenge), privateKey); - - res = await callAPI("/api/client/verify", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ answer: encodeToBase64(answer) }), - }); - return res.ok; +export const exportClientKeys = ( + encryptKeyBase64: string, + decryptKeyBase64: string, + signKeyBase64: string, + verifyKeyBase64: string, +) => { + return { + version: 1, + generator: "ArkVault", + exportedAt: new Date(), + encryptKey: encryptKeyBase64, + decryptKey: decryptKeyBase64, + signKey: signKeyBase64, + verifyKey: verifyKeyBase64, + } satisfies ExportedKeyPairs; }; -export const storeKeyPairPersistently = async (keyPair: CryptoKeyPair) => { - await storeKeyPairIntoIndexedDB(keyPair.publicKey, keyPair.privateKey); -}; - -export const requestTokenUpgrade = async (pubKeyBase64: string) => { - const res = await fetch("/api/auth/upgradeToken", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ pubKey: pubKeyBase64 }), - }); - return res.ok; +export const storeClientKeys = async (clientKeys: ClientKeys) => { + await storeRSAKey(clientKeys.encryptKey, "encrypt"); + await storeRSAKey(clientKeys.decryptKey, "decrypt"); + await storeRSAKey(clientKeys.signKey, "sign"); + await storeRSAKey(clientKeys.verifyKey, "verify"); }; export const requestInitialMekRegistration = async ( mekDraft: ArrayBuffer, - publicKey: CryptoKey, + encryptKey: CryptoKey, ) => { - const mekDraftEncrypted = await encryptRSAPlaintext(mekDraft, publicKey); + const mekDraftEncrypted = await encryptRSAPlaintext(mekDraft, encryptKey); const res = await callAPI("/api/mek/register/initial", { method: "POST", headers: { diff --git a/src/routes/(fullscreen)/key/generate/+page.svelte b/src/routes/(fullscreen)/key/generate/+page.svelte index 01bf7d3..4d48fa6 100644 --- a/src/routes/(fullscreen)/key/generate/+page.svelte +++ b/src/routes/(fullscreen)/key/generate/+page.svelte @@ -3,14 +3,15 @@ import { Button, TextButton } from "$lib/components/buttons"; import { TitleDiv, BottomDiv } from "$lib/components/divs"; import { gotoStateful } from "$lib/hooks"; - import { keyPairStore } from "$lib/stores"; + import { clientKeyStore } from "$lib/stores"; import Order from "./Order.svelte"; - import { generateKeyPair, generateMekDraft } from "./service"; + import { generateClientKeys, generateMekDraft } from "./service"; import IconKey from "~icons/material-symbols/key"; let { data } = $props(); + // TODO: Update const orders = [ { title: "암호 키는 공개 키와 개인 키로 구성돼요.", @@ -33,19 +34,18 @@ const generate = async () => { // TODO: Loading indicator - const { pubKeyBase64, privKeyBase64 } = await generateKeyPair(); + const clientKeys = await generateClientKeys(); const { mekDraft } = await generateMekDraft(); await gotoStateful("/key/export", { + ...clientKeys, redirectPath: data.redirectPath, - pubKeyBase64, - privKeyBase64, mekDraft, }); }; $effect(() => { - if ($keyPairStore) { + if ($clientKeyStore) { goto(data.redirectPath); } }); diff --git a/src/routes/(fullscreen)/key/generate/service.ts b/src/routes/(fullscreen)/key/generate/service.ts index 8900fd3..4e16e63 100644 --- a/src/routes/(fullscreen)/key/generate/service.ts +++ b/src/routes/(fullscreen)/key/generate/service.ts @@ -1,26 +1,29 @@ import { - encodeToBase64, generateRSAKeyPair, makeRSAKeyNonextractable, - exportRSAKey, + exportRSAKeyToBase64, generateAESKey, makeAESKeyNonextractable, exportAESKey, } from "$lib/modules/crypto"; -import { keyPairStore, mekStore } from "$lib/stores"; +import { clientKeyStore, mekStore } from "$lib/stores"; -export const generateKeyPair = async () => { - const keyPair = await generateRSAKeyPair(); - const privKeySecured = await makeRSAKeyNonextractable(keyPair.privateKey, "private"); +export const generateClientKeys = async () => { + const encKeyPair = await generateRSAKeyPair("encryption"); + const sigKeyPair = await generateRSAKeyPair("signature"); - keyPairStore.set({ - publicKey: keyPair.publicKey, - privateKey: privKeySecured, + clientKeyStore.set({ + encryptKey: encKeyPair.publicKey, + decryptKey: await makeRSAKeyNonextractable(encKeyPair.privateKey), + signKey: await makeRSAKeyNonextractable(sigKeyPair.privateKey), + verifyKey: sigKeyPair.publicKey, }); return { - pubKeyBase64: encodeToBase64((await exportRSAKey(keyPair.publicKey, "public")).key), - privKeyBase64: encodeToBase64((await exportRSAKey(keyPair.privateKey, "private")).key), + encryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.publicKey), + decryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.privateKey), + signKeyBase64: await exportRSAKeyToBase64(sigKeyPair.privateKey), + verifyKeyBase64: await exportRSAKeyToBase64(sigKeyPair.publicKey), }; }; @@ -29,7 +32,7 @@ export const generateMekDraft = async () => { const mekSecured = await makeAESKeyNonextractable(mek); mekStore.update((meks) => { - meks.set(meks.size, mekSecured); + meks.set(0, mekSecured); return meks; }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 44112c1..34d3688 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,21 +1,19 @@ diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index ccd86f5..1e2281f 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -10,14 +10,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { .object({ email: z.string().email().nonempty(), password: z.string().nonempty(), - pubKey: z.string().base64().nonempty().optional(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); + const { email, password } = zodRes.data; - const { email, password, pubKey } = zodRes.data; - const { accessToken, refreshToken } = await login(email.trim(), password.trim(), pubKey?.trim()); - + const { accessToken, refreshToken } = await login(email.trim(), password.trim()); cookies.set("accessToken", accessToken, { path: "/", maxAge: Math.floor(ms(env.jwt.accessExp) / 1000), @@ -28,5 +26,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => { maxAge: Math.floor(ms(env.jwt.refreshExp) / 1000), sameSite: "strict", }); + return text("Logged in", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index a2750c9..ae5dcaf 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -7,8 +7,8 @@ export const POST: RequestHandler = async ({ cookies }) => { if (!token) error(401, "Refresh token not found"); await logout(token.trim()); - cookies.delete("accessToken", { path: "/" }); cookies.delete("refreshToken", { path: "/api/auth" }); + return text("Logged out", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/refreshToken/+server.ts b/src/routes/api/auth/refreshToken/+server.ts index d05fc52..54fcd03 100644 --- a/src/routes/api/auth/refreshToken/+server.ts +++ b/src/routes/api/auth/refreshToken/+server.ts @@ -1,13 +1,12 @@ import { error, text } from "@sveltejs/kit"; -import { refreshTokens } from "$lib/server/services/auth"; +import { refreshToken as doRefreshToken } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ cookies }) => { const token = cookies.get("refreshToken"); if (!token) error(401, "Refresh token not found"); - const { accessToken, refreshToken } = await refreshTokens(token.trim()); - + const { accessToken, refreshToken } = await doRefreshToken(token.trim()); cookies.set("accessToken", accessToken, { path: "/", sameSite: "strict", @@ -16,5 +15,6 @@ export const POST: RequestHandler = async ({ cookies }) => { path: "/api/auth", sameSite: "strict", }); + return text("Token refreshed", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/upgradeToken/+server.ts b/src/routes/api/auth/upgradeToken/+server.ts index 20237e4..46fc5ca 100644 --- a/src/routes/api/auth/upgradeToken/+server.ts +++ b/src/routes/api/auth/upgradeToken/+server.ts @@ -1,29 +1,26 @@ -import { error, text } from "@sveltejs/kit"; +import { error, json } from "@sveltejs/kit"; import { z } from "zod"; -import { upgradeTokens } from "$lib/server/services/auth"; +import { createTokenUpgradeChallenge } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const token = cookies.get("refreshToken"); if (!token) error(401, "Refresh token not found"); const zodRes = z .object({ - pubKey: z.string().base64().nonempty(), + encPubKey: z.string().base64().nonempty(), + sigPubKey: z.string().base64().nonempty(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); + const { encPubKey, sigPubKey } = zodRes.data; - const { pubKey } = zodRes.data; - const { accessToken, refreshToken } = await upgradeTokens(token.trim(), pubKey.trim()); - - cookies.set("accessToken", accessToken, { - path: "/", - sameSite: "strict", - }); - cookies.set("refreshToken", refreshToken, { - path: "/api/auth", - sameSite: "strict", - }); - return text("Token upgraded", { headers: { "Content-Type": "text/plain" } }); + const { challenge } = await createTokenUpgradeChallenge( + token.trim(), + getClientAddress(), + encPubKey.trim(), + sigPubKey.trim(), + ); + return json({ challenge }); }; diff --git a/src/routes/api/auth/upgradeToken/verify/+server.ts b/src/routes/api/auth/upgradeToken/verify/+server.ts new file mode 100644 index 0000000..f4e291f --- /dev/null +++ b/src/routes/api/auth/upgradeToken/verify/+server.ts @@ -0,0 +1,35 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { upgradeToken } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + const zodRes = z + .object({ + answer: z.string().base64().nonempty(), + sigAnswer: z.string().base64().nonempty(), + }) + .safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { answer, sigAnswer } = zodRes.data; + + const { accessToken, refreshToken } = await upgradeToken( + token.trim(), + getClientAddress(), + answer.trim(), + sigAnswer.trim(), + ); + cookies.set("accessToken", accessToken, { + path: "/", + sameSite: "strict", + }); + cookies.set("refreshToken", refreshToken, { + path: "/api/auth", + sameSite: "strict", + }); + + return text("Token upgraded", { headers: { "Content-Type": "text/plain" } }); +}; diff --git a/src/routes/api/client/register/+server.ts b/src/routes/api/client/register/+server.ts index 72f34ce..d6c81b0 100644 --- a/src/routes/api/client/register/+server.ts +++ b/src/routes/api/client/register/+server.ts @@ -12,12 +12,18 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const zodRes = z .object({ - pubKey: z.string().base64().nonempty(), + encPubKey: z.string().base64().nonempty(), + sigPubKey: z.string().base64().nonempty(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); - const { pubKey } = zodRes.data; + const { encPubKey, sigPubKey } = zodRes.data; - const challenge = await registerUserClient(userId, getClientAddress(), pubKey.trim()); + const { challenge } = await registerUserClient( + userId, + getClientAddress(), + encPubKey.trim(), + sigPubKey.trim(), + ); return json({ challenge }); }; diff --git a/src/routes/api/client/verify/+server.ts b/src/routes/api/client/verify/+server.ts index 65b99b4..2573cb7 100644 --- a/src/routes/api/client/verify/+server.ts +++ b/src/routes/api/client/verify/+server.ts @@ -13,11 +13,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const zodRes = z .object({ answer: z.string().base64().nonempty(), + sigAnswer: z.string().base64().nonempty(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); - const { answer } = zodRes.data; + const { answer, sigAnswer } = zodRes.data; - await verifyUserClient(userId, getClientAddress(), answer.trim()); + await verifyUserClient(userId, getClientAddress(), answer.trim(), sigAnswer.trim()); return text("Client verified", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/services.ts b/src/routes/services.ts new file mode 100644 index 0000000..bb07c6a --- /dev/null +++ b/src/routes/services.ts @@ -0,0 +1,15 @@ +import { getRSAKey } from "$lib/indexedDB"; +import { clientKeyStore } from "$lib/stores"; + +export const prepareClientKeyStore = async () => { + const encryptKey = await getRSAKey("encrypt"); + const decryptKey = await getRSAKey("decrypt"); + const signKey = await getRSAKey("sign"); + const verifyKey = await getRSAKey("verify"); + if (encryptKey && decryptKey && signKey && verifyKey) { + clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey }); + return true; + } else { + return false; + } +};