클라이언트 승인 대기 페이지 구현

This commit is contained in:
static
2024-12-31 21:58:13 +09:00
parent ccad4fbd8b
commit e5cbd46b35
13 changed files with 243 additions and 59 deletions

View File

@@ -10,7 +10,7 @@ interface KeyExportState {
signKeyBase64: string;
verifyKeyBase64: string;
mekDraft: ArrayBuffer;
masterKeyWrapped: ArrayBuffer;
}
const useAutoNull = <T>(value: T | null) => {

View File

@@ -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<RSAKey, "usage">;
clientKey: EntityTable<ClientKey, "usage">;
masterKey: EntityTable<MasterKey, "version">;
};
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);
};

View File

@@ -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 <T>(data: T, privateKey: CryptoKey) => {
const dataBuffer = new TextEncoder().encode(JSON.stringify(data));
const signature = await signRSAMessage(dataBuffer, privateKey);

View File

@@ -7,5 +7,11 @@ export interface ClientKeys {
verifyKey: CryptoKey;
}
export interface MasterKey {
state: "active" | "retired" | "dead";
masterKey: CryptoKey;
}
export const clientKeyStore = writable<ClientKeys | null>(null);
export const mekStore = writable<Map<number, CryptoKey>>(new Map());
export const masterKeyStore = writable<Map<number, MasterKey> | null>(null);

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { TitleDiv } from "$lib/components/divs";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import { generateEncryptKeyFingerprint, requestMasterKeyDownload } from "./service";
import IconFingerprint from "~icons/material-symbols/fingerprint";
let { data } = $props();
let fingerprint = $derived(
$clientKeyStore ? generateEncryptKeyFingerprint($clientKeyStore.encryptKey) : undefined,
);
$effect(() => {
if ($masterKeyStore) {
goto(data.redirectPath);
} else if ($clientKeyStore) {
requestMasterKeyDownload($clientKeyStore.decryptKey).then(async (ok) => {
if (ok) {
return await goto(data.redirectPath);
}
});
}
});
</script>
<svetle:head>
<title>승인을 기다리고 있어요.</title>
</svetle:head>
<div class="flex h-full flex-col">
<TitleDiv>
<div class="flex flex-col gap-y-2">
<h1 class="text-3xl font-bold">승인을 기다리고 있어요.</h1>
<p>
회원님의 다른 디바이스에서 이 디바이스의 데이터 접근을 승인해야 서비스를 이용할 수 있어요.
</p>
</div>
<div class="my-4 flex flex-col gap-y-2">
<div>
<IconFingerprint class="mx-auto text-7xl" />
<p class="text-center text-xl font-bold text-primary-500">암호 키 지문</p>
</div>
<div class="rounded-2xl bg-gray-100 p-4">
<p class="text-center text-2xl font-medium text-gray-800">
{#if !fingerprint}
지문 생성하는 중...
{:else}
{#await fingerprint}
지문 생성하는 중...
{:then fingerprint}
{fingerprint}
{/await}
{/if}
</p>
</div>
<p class="text-center">
암호 키 지문은 디바이스마다 다르게 생성돼요. <br />
지문이 일치하는지 확인 후 승인해 주세요.
</p>
</div>
</TitleDiv>
</div>

View File

@@ -0,0 +1,6 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ url }) => {
const redirectPath = url.searchParams.get("redirect") || "/";
return { redirectPath };
};

View File

@@ -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;
};

View File

@@ -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");

View File

@@ -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,
),

View File

@@ -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,
});
};

View File

@@ -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),
};
};

View File

@@ -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");
}
});
</script>

View File

@@ -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;
}
};