diff --git a/src/lib/modules/crypto.ts b/src/lib/modules/crypto.ts new file mode 100644 index 0000000..93a7e22 --- /dev/null +++ b/src/lib/modules/crypto.ts @@ -0,0 +1,50 @@ +export type RSAKeyType = "public" | "private"; + +export const generateRSAKeyPair = async () => { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + } satisfies RsaHashedKeyGenParams, + true, + ["encrypt", "decrypt"], + ); + return keyPair; +}; + +export const makeRSAKeyNonextractable = async (key: CryptoKey, type: RSAKeyType) => { + const format = type === "public" ? "spki" : "pkcs8"; + const keyUsage = type === "public" ? "encrypt" : "decrypt"; + return await window.crypto.subtle.importKey( + format, + await window.crypto.subtle.exportKey(format, key), + { + name: "RSA-OAEP", + hash: "SHA-256", + } satisfies RsaHashedImportParams, + false, + [keyUsage], + ); +}; + +export const exportRSAKeyToBase64 = async (key: CryptoKey, type: RSAKeyType) => { + const exportedKey = await window.crypto.subtle.exportKey( + type === "public" ? "spki" : "pkcs8", + key, + ); + return btoa(String.fromCharCode(...new Uint8Array(exportedKey))); +}; + +export const decryptRSACiphertext = async (ciphertext: string, privateKey: CryptoKey) => { + const ciphertextBuffer = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)); + const plaintext = await window.crypto.subtle.decrypt( + { + name: "RSA-OAEP", + } satisfies RsaOaepParams, + privateKey, + ciphertextBuffer, + ); + return btoa(String.fromCharCode(...new Uint8Array(plaintext))); +}; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index c6b4dfd..d992773 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -17,6 +17,10 @@ export const getClientByPubKey = async (pubKey: string) => { return clients[0] ?? null; }; +export const createUserClient = async (userId: number, clientId: number) => { + await db.insert(userClient).values({ userId, clientId }).execute(); +}; + export const getUserClient = async (userId: number, clientId: number) => { const userClients = await db .select() diff --git a/src/lib/server/services/key.ts b/src/lib/server/services/key.ts index 7222036..325d927 100644 --- a/src/lib/server/services/key.ts +++ b/src/lib/server/services/key.ts @@ -5,6 +5,8 @@ import { promisify } from "util"; import { createClient, getClientByPubKey, + createUserClient, + getUserClient, createUserClientChallenge, getUserClientChallenge, setUserClientStateToPending, @@ -25,20 +27,30 @@ const generateChallenge = async (userId: number, ip: string, clientId: number, p }; export const registerPubKey = async (userId: number, ip: string, pubKey: string) => { - if (await getClientByPubKey(pubKey)) { - error(409, "Public key already registered"); + const client = await getClientByPubKey(pubKey); + let clientId; + + if (client) { + const userClient = await getUserClient(userId, client.id); + if (userClient) { + error(409, "Public key 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 + ) { + error(400, "Invalid public key"); + } + + clientId = await createClient(pubKey, userId); } - const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; - const pubKeyObject = createPublicKey(pubKeyPem); - if ( - pubKeyObject.asymmetricKeyType !== "rsa" || - pubKeyObject.asymmetricKeyDetails?.modulusLength !== 4096 - ) { - error(400, "Invalid public key"); - } - - const clientId = await createClient(pubKey, userId); return await generateChallenge(userId, ip, clientId, pubKey); }; diff --git a/src/routes/(fullscreen)/auth/login/+page.svelte b/src/routes/(fullscreen)/auth/login/+page.svelte index 46c03fc..c6c4073 100644 --- a/src/routes/(fullscreen)/auth/login/+page.svelte +++ b/src/routes/(fullscreen)/auth/login/+page.svelte @@ -14,7 +14,7 @@ const login = async () => { // TODO: Validation - if (await requestLogin(email, password)) { + if (await requestLogin(email, password, $keyPairStore)) { await goto( $keyPairStore ? data.redirectPath diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts index dea5a25..7b5863c 100644 --- a/src/routes/(fullscreen)/auth/login/service.ts +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -1,10 +1,49 @@ -export const requestLogin = async (email: string, password: string) => { - const res = await fetch("/api/auth/login", { +import { exportRSAKeyToBase64 } from "$lib/modules/crypto"; +import { requestPubKeyRegistration } from "../../key/export/service"; + +const callLoginAPI = async (email: string, password: string, pubKeyBase64?: string) => { + return await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ + email, + password, + pubKey: pubKeyBase64, + }), }); - return res.ok; +}; + +export const requestLogin = async ( + email: string, + password: string, + keyPair: CryptoKeyPair | null, + registerPubKey = true, +): Promise => { + const pubKeyBase64 = keyPair + ? await exportRSAKeyToBase64(keyPair.publicKey, "public") + : undefined; + let loginRes = await callLoginAPI(email, password, pubKeyBase64); + if (loginRes.ok) { + 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); + } else { + return false; + } }; diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts index 301c825..5ca135e 100644 --- a/src/routes/(fullscreen)/key/export/service.ts +++ b/src/routes/(fullscreen)/key/export/service.ts @@ -1,5 +1,6 @@ import { callAPI } from "$lib/hooks"; import { storeKeyPairIntoIndexedDB } from "$lib/indexedDB"; +import { decryptRSACiphertext } from "$lib/modules/crypto"; export const createBlobFromKeyPairBase64 = (pubKeyBase64: string, privKeyBase64: string) => { const pubKeyFormatted = pubKeyBase64.match(/.{1,64}/g)?.join("\n"); @@ -13,18 +14,6 @@ export const createBlobFromKeyPairBase64 = (pubKeyBase64: string, privKeyBase64: return new Blob([`${pubKeyPem}\n${privKeyPem}\n`], { type: "text/plain" }); }; -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", @@ -37,7 +26,7 @@ export const requestPubKeyRegistration = async (pubKeyBase64: string, privateKey const data = await res.json(); const challenge = data.challenge as string; - const answer = await decryptChallenge(challenge, privateKey); + const answer = await decryptRSACiphertext(challenge, privateKey); res = await callAPI("/api/key/verify", { method: "POST", diff --git a/src/routes/(fullscreen)/key/generate/service.ts b/src/routes/(fullscreen)/key/generate/service.ts index f43b66d..360263e 100644 --- a/src/routes/(fullscreen)/key/generate/service.ts +++ b/src/routes/(fullscreen)/key/generate/service.ts @@ -1,44 +1,10 @@ +import { + generateRSAKeyPair, + makeRSAKeyNonextractable, + exportRSAKeyToBase64, +} from "$lib/modules/crypto"; import { keyPairStore } from "$lib/stores"; -type KeyType = "public" | "private"; - -const generateRSAKeyPair = async () => { - const keyPair = await window.crypto.subtle.generateKey( - { - name: "RSA-OAEP", - modulusLength: 4096, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - } satisfies RsaHashedKeyGenParams, - true, - ["encrypt", "decrypt"], - ); - return keyPair; -}; - -const makeRSAKeyNonextractable = async (key: CryptoKey, type: KeyType) => { - const format = type === "public" ? "spki" : "pkcs8"; - const keyUsage = type === "public" ? "encrypt" : "decrypt"; - return await window.crypto.subtle.importKey( - format, - await window.crypto.subtle.exportKey(format, key), - { - name: "RSA-OAEP", - hash: "SHA-256", - } satisfies RsaHashedImportParams, - false, - [keyUsage], - ); -}; - -const exportKeyToBase64 = async (key: CryptoKey, type: KeyType) => { - const exportedKey = await window.crypto.subtle.exportKey( - type === "public" ? "spki" : "pkcs8", - key, - ); - return btoa(String.fromCharCode(...new Uint8Array(exportedKey))); -}; - export const generateKeyPair = async () => { const keyPair = await generateRSAKeyPair(); const privKeySecured = await makeRSAKeyNonextractable(keyPair.privateKey, "private"); @@ -49,7 +15,7 @@ export const generateKeyPair = async () => { }); return { - pubKeyBase64: await exportKeyToBase64(keyPair.publicKey, "public"), - privKeyBase64: await exportKeyToBase64(keyPair.privateKey, "private"), + pubKeyBase64: await exportRSAKeyToBase64(keyPair.publicKey, "public"), + privKeyBase64: await exportRSAKeyToBase64(keyPair.privateKey, "private"), }; };