diff --git a/src/lib/hooks/gotoStateful.ts b/src/lib/hooks/gotoStateful.ts index 47f1190..2d168a7 100644 --- a/src/lib/hooks/gotoStateful.ts +++ b/src/lib/hooks/gotoStateful.ts @@ -10,7 +10,7 @@ interface KeyExportState { signKeyBase64: string; verifyKeyBase64: string; - mekDraft: ArrayBuffer; + masterKeyWrapped: ArrayBuffer; } const useAutoNull = (value: T | null) => { diff --git a/src/lib/indexedDB.ts b/src/lib/indexedDB.ts index 1282314..ff4660b 100644 --- a/src/lib/indexedDB.ts +++ b/src/lib/indexedDB.ts @@ -1,26 +1,36 @@ import { Dexie, type EntityTable } from "dexie"; -type RSAKeyUsage = "encrypt" | "decrypt" | "sign" | "verify"; +type ClientKeyUsage = "encrypt" | "decrypt" | "sign" | "verify"; -interface RSAKey { - usage: RSAKeyUsage; +interface ClientKey { + usage: ClientKeyUsage; + key: CryptoKey; +} + +type MasterKeyState = "active" | "retired"; + +interface MasterKey { + version: number; + state: MasterKeyState; key: CryptoKey; } const keyStore = new Dexie("keyStore") as Dexie & { - rsaKey: EntityTable; + clientKey: EntityTable; + masterKey: EntityTable; }; keyStore.version(1).stores({ - rsaKey: "usage", + clientKey: "usage", + masterKey: "version", }); -export const getRSAKey = async (usage: RSAKeyUsage) => { - const key = await keyStore.rsaKey.get(usage); +export const getClientKey = async (usage: ClientKeyUsage) => { + const key = await keyStore.clientKey.get(usage); return key?.key ?? null; }; -export const storeRSAKey = async (key: CryptoKey, usage: RSAKeyUsage) => { +export const storeClientKey = async (key: CryptoKey, usage: ClientKeyUsage) => { switch (usage) { case "encrypt": case "verify": @@ -39,5 +49,16 @@ export const storeRSAKey = async (key: CryptoKey, usage: RSAKeyUsage) => { } break; } - await keyStore.rsaKey.put({ usage, key }); + await keyStore.clientKey.put({ usage, key }); +}; + +export const getMasterKeys = async () => { + return await keyStore.masterKey.toArray(); +}; + +export const storeMasterKeys = async (keys: MasterKey[]) => { + if (keys.some(({ key }) => key.extractable)) { + throw new Error("Master keys must be non-extractable"); + } + await keyStore.masterKey.bulkPut(keys); }; diff --git a/src/lib/modules/crypto.ts b/src/lib/modules/crypto.ts index 872265a..8a476d1 100644 --- a/src/lib/modules/crypto.ts +++ b/src/lib/modules/crypto.ts @@ -18,7 +18,7 @@ export const generateRSAKeyPair = async (purpose: RSAKeyPurpose) => { hash: "SHA-256", } satisfies RsaHashedKeyGenParams, true, - purpose === "encryption" ? ["encrypt", "decrypt"] : ["sign", "verify"], + purpose === "encryption" ? ["encrypt", "decrypt", "wrapKey", "unwrapKey"] : ["sign", "verify"], ); }; @@ -101,6 +101,33 @@ export const exportAESKey = async (key: CryptoKey) => { return await window.crypto.subtle.exportKey("raw", key); }; +export const wrapAESKeyUsingRSA = async (aesKey: CryptoKey, rsaPublicKey: CryptoKey) => { + return await window.crypto.subtle.wrapKey("raw", aesKey, rsaPublicKey, { + name: "RSA-OAEP", + } satisfies RsaOaepParams); +}; + +export const unwrapAESKeyUsingRSA = async (wrappedKey: BufferSource, rsaPrivateKey: CryptoKey) => { + return await window.crypto.subtle.unwrapKey( + "raw", + wrappedKey, + rsaPrivateKey, + { + name: "RSA-OAEP", + } satisfies RsaOaepParams, + { + name: "AES-GCM", + length: 256, + } satisfies AesKeyGenParams, + true, + ["encrypt", "decrypt"], + ); +}; + +export const digestSHA256 = async (data: BufferSource) => { + return await window.crypto.subtle.digest("SHA-256", data); +}; + export const signRequest = async (data: T, privateKey: CryptoKey) => { const dataBuffer = new TextEncoder().encode(JSON.stringify(data)); const signature = await signRSAMessage(dataBuffer, privateKey); diff --git a/src/lib/stores/key.ts b/src/lib/stores/key.ts index 19cff13..7690bbf 100644 --- a/src/lib/stores/key.ts +++ b/src/lib/stores/key.ts @@ -7,5 +7,11 @@ export interface ClientKeys { verifyKey: CryptoKey; } +export interface MasterKey { + state: "active" | "retired" | "dead"; + masterKey: CryptoKey; +} + export const clientKeyStore = writable(null); -export const mekStore = writable>(new Map()); + +export const masterKeyStore = writable | null>(null); diff --git a/src/routes/(fullscreen)/client/pending/+page.svelte b/src/routes/(fullscreen)/client/pending/+page.svelte new file mode 100644 index 0000000..f362bd5 --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/+page.svelte @@ -0,0 +1,64 @@ + + + + 승인을 기다리고 있어요. + + +
+ +
+

승인을 기다리고 있어요.

+

+ 회원님의 다른 디바이스에서 이 디바이스의 데이터 접근을 승인해야 서비스를 이용할 수 있어요. +

+
+
+
+ +

암호 키 지문

+
+
+

+ {#if !fingerprint} + 지문 생성하는 중... + {:else} + {#await fingerprint} + 지문 생성하는 중... + {:then fingerprint} + {fingerprint} + {/await} + {/if} +

+
+

+ 암호 키 지문은 디바이스마다 다르게 생성돼요.
+ 지문이 일치하는지 확인 후 승인해 주세요. +

+
+
+
diff --git a/src/routes/(fullscreen)/client/pending/+page.ts b/src/routes/(fullscreen)/client/pending/+page.ts new file mode 100644 index 0000000..626d2e0 --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ url }) => { + const redirectPath = url.searchParams.get("redirect") || "/"; + return { redirectPath }; +}; diff --git a/src/routes/(fullscreen)/client/pending/service.ts b/src/routes/(fullscreen)/client/pending/service.ts new file mode 100644 index 0000000..51037b5 --- /dev/null +++ b/src/routes/(fullscreen)/client/pending/service.ts @@ -0,0 +1,53 @@ +import { callAPI } from "$lib/hooks"; +import { storeMasterKeys } from "$lib/indexedDB"; +import { + decodeFromBase64, + exportRSAKey, + makeAESKeyNonextractable, + unwrapAESKeyUsingRSA, + digestSHA256, +} from "$lib/modules/crypto"; +import { masterKeyStore } from "$lib/stores"; + +export const generateEncryptKeyFingerprint = async (encryptKey: CryptoKey) => { + const { key } = await exportRSAKey(encryptKey); + const digest = await digestSHA256(key); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toUpperCase() + .match(/.{1,4}/g)! + .join(" "); +}; + +export const requestMasterKeyDownload = async (decryptKey: CryptoKey) => { + const res = await callAPI("/api/mek/list", { method: "GET" }); + if (!res.ok) return false; + + const data = await res.json(); + const { meks: masterKeysWrapped } = data as { + meks: { + version: number; + state: "active" | "retired"; + mek: string; + }[]; + }; + const masterKeys = await Promise.all( + masterKeysWrapped.map(async ({ version, state, mek: masterKeyWrapped }) => ({ + version, + state, + masterKey: await makeAESKeyNonextractable( + await unwrapAESKeyUsingRSA(decodeFromBase64(masterKeyWrapped), decryptKey), + ), + })), + ); + + await storeMasterKeys( + masterKeys.map(({ version, state, masterKey }) => ({ version, state, key: masterKey })), + ); + masterKeyStore.set( + new Map(masterKeys.map(({ version, state, masterKey }) => [version, { state, masterKey }])), + ); + + return true; +}; diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte index 553e81c..31d7384 100644 --- a/src/routes/(fullscreen)/key/export/+page.svelte +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -11,7 +11,7 @@ requestClientRegistration, storeClientKeys, requestTokenUpgrade, - requestInitialMekRegistration, + requestInitialMasterKeyRegistration, } from "./service"; import IconKey from "~icons/material-symbols/key"; @@ -72,11 +72,7 @@ throw new Error("Failed to upgrade token"); if ( - !(await requestInitialMekRegistration( - data.mekDraft, - $clientKeyStore.encryptKey, - $clientKeyStore.signKey, - )) + !(await requestInitialMasterKeyRegistration(data.masterKeyWrapped, $clientKeyStore.signKey)) ) throw new Error("Failed to register initial MEK"); diff --git a/src/routes/(fullscreen)/key/export/service.ts b/src/routes/(fullscreen)/key/export/service.ts index b26ff61..68c4f60 100644 --- a/src/routes/(fullscreen)/key/export/service.ts +++ b/src/routes/(fullscreen)/key/export/service.ts @@ -1,6 +1,6 @@ import { callAPI } from "$lib/hooks"; -import { storeRSAKey } from "$lib/indexedDB"; -import { encodeToBase64, encryptRSAPlaintext, signRequest } from "$lib/modules/crypto"; +import { storeClientKey } from "$lib/indexedDB"; +import { encodeToBase64, signRequest } from "$lib/modules/crypto"; import type { ClientKeys } from "$lib/stores"; export { requestTokenUpgrade } from "$lib/services/auth"; @@ -35,18 +35,16 @@ export const exportClientKeys = ( }; 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"); + await storeClientKey(clientKeys.encryptKey, "encrypt"); + await storeClientKey(clientKeys.decryptKey, "decrypt"); + await storeClientKey(clientKeys.signKey, "sign"); + await storeClientKey(clientKeys.verifyKey, "verify"); }; -export const requestInitialMekRegistration = async ( - mekDraft: ArrayBuffer, - encryptKey: CryptoKey, +export const requestInitialMasterKeyRegistration = async ( + masterKeyWrapped: ArrayBuffer, signKey: CryptoKey, ) => { - const mekDraftEncrypted = await encryptRSAPlaintext(mekDraft, encryptKey); const res = await callAPI("/api/mek/register/initial", { method: "POST", headers: { @@ -54,7 +52,7 @@ export const requestInitialMekRegistration = async ( }, body: await signRequest( { - mek: encodeToBase64(mekDraftEncrypted), + mek: encodeToBase64(masterKeyWrapped), }, signKey, ), diff --git a/src/routes/(fullscreen)/key/generate/+page.svelte b/src/routes/(fullscreen)/key/generate/+page.svelte index 4d48fa6..8af3b29 100644 --- a/src/routes/(fullscreen)/key/generate/+page.svelte +++ b/src/routes/(fullscreen)/key/generate/+page.svelte @@ -5,7 +5,7 @@ import { gotoStateful } from "$lib/hooks"; import { clientKeyStore } from "$lib/stores"; import Order from "./Order.svelte"; - import { generateClientKeys, generateMekDraft } from "./service"; + import { generateClientKeys, generateInitialMasterKey } from "./service"; import IconKey from "~icons/material-symbols/key"; @@ -34,13 +34,13 @@ const generate = async () => { // TODO: Loading indicator - const clientKeys = await generateClientKeys(); - const { mekDraft } = await generateMekDraft(); + const { encryptKey, ...clientKeys } = await generateClientKeys(); + const { masterKeyWrapped } = await generateInitialMasterKey(encryptKey); await gotoStateful("/key/export", { ...clientKeys, redirectPath: data.redirectPath, - mekDraft, + masterKeyWrapped, }); }; diff --git a/src/routes/(fullscreen)/key/generate/service.ts b/src/routes/(fullscreen)/key/generate/service.ts index 4e16e63..77c3f6f 100644 --- a/src/routes/(fullscreen)/key/generate/service.ts +++ b/src/routes/(fullscreen)/key/generate/service.ts @@ -4,9 +4,9 @@ import { exportRSAKeyToBase64, generateAESKey, makeAESKeyNonextractable, - exportAESKey, + wrapAESKeyUsingRSA, } from "$lib/modules/crypto"; -import { clientKeyStore, mekStore } from "$lib/stores"; +import { clientKeyStore } from "$lib/stores"; export const generateClientKeys = async () => { const encKeyPair = await generateRSAKeyPair("encryption"); @@ -20,6 +20,7 @@ export const generateClientKeys = async () => { }); return { + encryptKey: encKeyPair.publicKey, encryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.publicKey), decryptKeyBase64: await exportRSAKeyToBase64(encKeyPair.privateKey), signKeyBase64: await exportRSAKeyToBase64(sigKeyPair.privateKey), @@ -27,16 +28,10 @@ export const generateClientKeys = async () => { }; }; -export const generateMekDraft = async () => { - const mek = await generateAESKey(); - const mekSecured = await makeAESKeyNonextractable(mek); - - mekStore.update((meks) => { - meks.set(0, mekSecured); - return meks; - }); - +export const generateInitialMasterKey = async (encryptKey: CryptoKey) => { + const masterKey = await generateAESKey(); return { - mekDraft: await exportAESKey(mek), + masterKey: await makeAESKeyNonextractable(masterKey), + masterKeyWrapped: await wrapAESKeyUsingRSA(masterKey, encryptKey), }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 34d3688..066a4e0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,18 +2,24 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import "../app.css"; - import { prepareClientKeyStore } from "./services"; + import { prepareClientKeyStore, prepareMasterKeyStore } from "./services"; let { children } = $props(); - onMount(() => { - prepareClientKeyStore().then(async (ok) => { - if (!ok && !["/auth", "/key"].some((path) => location.pathname.startsWith(path))) { - await goto( - "/key/generate?redirect=" + encodeURIComponent(location.pathname + location.search), - ); + onMount(async () => { + const redirect = async (url: string) => { + const whitelist = ["/auth", "/key", "/client/pending"]; + if (!whitelist.some((path) => location.pathname.startsWith(path))) { + await goto(`${url}?redirect=${encodeURIComponent(location.pathname + location.search)}`); } - }); + }; + + if (!(await prepareClientKeyStore())) { + return await redirect("/key/generate"); + } + if (!(await prepareMasterKeyStore())) { + return await redirect("/client/pending"); + } }); diff --git a/src/routes/services.ts b/src/routes/services.ts index bb07c6a..1b5c854 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1,11 +1,11 @@ -import { getRSAKey } from "$lib/indexedDB"; -import { clientKeyStore } from "$lib/stores"; +import { getClientKey, getMasterKeys } from "$lib/indexedDB"; +import { clientKeyStore, masterKeyStore } 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"); + const encryptKey = await getClientKey("encrypt"); + const decryptKey = await getClientKey("decrypt"); + const signKey = await getClientKey("sign"); + const verifyKey = await getClientKey("verify"); if (encryptKey && decryptKey && signKey && verifyKey) { clientKeyStore.set({ encryptKey, decryptKey, signKey, verifyKey }); return true; @@ -13,3 +13,15 @@ export const prepareClientKeyStore = async () => { return false; } }; + +export const prepareMasterKeyStore = async () => { + const masterKeys = await getMasterKeys(); + if (masterKeys.length > 0) { + masterKeyStore.set( + new Map(masterKeys.map(({ version, state, key }) => [version, { state, masterKey: key }])), + ); + return true; + } else { + return false; + } +};