키 가져오기 기능 추가

This commit is contained in:
static
2025-07-12 01:28:44 +09:00
parent c47885d571
commit eac81abe5a
10 changed files with 304 additions and 118 deletions

View File

@@ -46,6 +46,56 @@ export const exportRSAKeyToBase64 = async (key: CryptoKey) => {
return encodeToBase64((await exportRSAKey(key)).key); return encodeToBase64((await exportRSAKey(key)).key);
}; };
export const importEncryptionKeyPairFromBase64 = async (
encryptKeyBase64: string,
decryptKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-OAEP",
hash: "SHA-256",
};
const encryptKey = await window.crypto.subtle.importKey(
"spki",
decodeFromBase64(encryptKeyBase64),
algorithm,
true,
["encrypt", "wrapKey"],
);
const decryptKey = await window.crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(decryptKeyBase64),
algorithm,
true,
["decrypt", "unwrapKey"],
);
return { encryptKey, decryptKey };
};
export const importSigningKeyPairFromBase64 = async (
signKeyBase64: string,
verifyKeyBase64: string,
) => {
const algorithm: RsaHashedImportParams = {
name: "RSA-PSS",
hash: "SHA-256",
};
const signKey = await window.crypto.subtle.importKey(
"pkcs8",
decodeFromBase64(signKeyBase64),
algorithm,
true,
["sign"],
);
const verifyKey = await window.crypto.subtle.importKey(
"spki",
decodeFromBase64(verifyKeyBase64),
algorithm,
true,
["verify"],
);
return { signKey, verifyKey };
};
export const makeRSAKeyNonextractable = async (key: CryptoKey) => { export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key); const { key: exportedKey, format } = await exportRSAKey(key);
return await window.crypto.subtle.importKey( return await window.crypto.subtle.importKey(

65
src/lib/modules/key.ts Normal file
View File

@@ -0,0 +1,65 @@
import { z } from "zod";
import { storeClientKey } from "$lib/indexedDB";
import type { ClientKeys } from "$lib/stores";
const serializedClientKeysSchema = z.intersection(
z.object({
generator: z.literal("ArkVault"),
exportedAt: z.string().datetime(),
}),
z.object({
version: z.literal(1),
encryptKey: z.string().base64().nonempty(),
decryptKey: z.string().base64().nonempty(),
signKey: z.string().base64().nonempty(),
verifyKey: z.string().base64().nonempty(),
}),
);
type SerializedClientKeys = z.infer<typeof serializedClientKeysSchema>;
type DeserializedClientKeys = {
encryptKeyBase64: string;
decryptKeyBase64: string;
signKeyBase64: string;
verifyKeyBase64: string;
};
export const serializeClientKeys = ({
encryptKeyBase64,
decryptKeyBase64,
signKeyBase64,
verifyKeyBase64,
}: DeserializedClientKeys) => {
return JSON.stringify({
version: 1,
generator: "ArkVault",
exportedAt: new Date().toISOString(),
encryptKey: encryptKeyBase64,
decryptKey: decryptKeyBase64,
signKey: signKeyBase64,
verifyKey: verifyKeyBase64,
} satisfies SerializedClientKeys);
};
export const deserializeClientKeys = (serialized: string) => {
const zodRes = serializedClientKeysSchema.safeParse(JSON.parse(serialized));
if (zodRes.success) {
return {
encryptKeyBase64: zodRes.data.encryptKey,
decryptKeyBase64: zodRes.data.decryptKey,
signKeyBase64: zodRes.data.signKey,
verifyKeyBase64: zodRes.data.verifyKey,
} satisfies DeserializedClientKeys;
}
return undefined;
};
export const storeClientKeys = async (clientKeys: ClientKeys) => {
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
};

View File

@@ -2,18 +2,23 @@ import { callGetApi, callPostApi } from "$lib/hooks";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
exportRSAKeyToBase64,
decryptChallenge, decryptChallenge,
signMessageRSA, signMessageRSA,
unwrapMasterKey, unwrapMasterKey,
signMasterKeyWrapped,
verifyMasterKeyWrapped, verifyMasterKeyWrapped,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import type { import type {
ClientRegisterRequest, ClientRegisterRequest,
ClientRegisterResponse, ClientRegisterResponse,
ClientRegisterVerifyRequest, ClientRegisterVerifyRequest,
InitialHmacSecretRegisterRequest,
MasterKeyListResponse, MasterKeyListResponse,
InitialMasterKeyRegisterRequest,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { masterKeyStore } from "$lib/stores"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -38,6 +43,35 @@ export const requestClientRegistration = async (
return res.ok; return res.ok;
}; };
export const requestClientRegistrationAndSessionUpgrade = async (
{ encryptKey, decryptKey, signKey, verifyKey }: ClientKeys,
force: boolean,
) => {
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
const [res, error] = await requestSessionUpgrade(
encryptKeyBase64,
decryptKey,
verifyKeyBase64,
signKey,
force,
);
if (error === undefined) return [res] as const;
if (
error === "Unregistered client" &&
!(await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey))
) {
return [false] as const;
} else if (error === "Already logged in") {
return [false, force ? undefined : error] as const;
}
return [
(await requestSessionUpgrade(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey))[0],
] as const;
};
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const res = await callGetApi("/api/mek/list"); const res = await callGetApi("/api/mek/list");
if (!res.ok) return false; if (!res.ok) return false;
@@ -68,3 +102,23 @@ export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey:
return true; return true;
}; };
export const requestInitialMasterKeyAndHmacSecretRegistration = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
if (!res.ok) {
return res.status === 403 || res.status === 409;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
};

View File

@@ -7,7 +7,7 @@
import { import {
requestLogout, requestLogout,
requestLogin, requestLogin,
requestSessionUpgrade, requestClientRegistrationAndSessionUpgrade,
requestMasterKeyDownload, requestMasterKeyDownload,
} from "./service"; } from "./service";
@@ -24,7 +24,10 @@
const upgradeSession = async (force: boolean) => { const upgradeSession = async (force: boolean) => {
try { try {
const [upgradeRes, upgradeError] = await requestSessionUpgrade($clientKeyStore!, force); const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(
$clientKeyStore!,
force,
);
if (!force && upgradeError === "Already logged in") { if (!force && upgradeError === "Already logged in") {
isForceLoginModalOpen = true; isForceLoginModalOpen = true;
return; return;

View File

@@ -1,45 +1,13 @@
import { callPostApi } from "$lib/hooks"; import { callPostApi } from "$lib/hooks";
import { exportRSAKeyToBase64 } from "$lib/modules/crypto";
import type { LoginRequest } from "$lib/server/schemas"; import type { LoginRequest } from "$lib/server/schemas";
import { requestSessionUpgrade as requestSessionUpgradeInternal } from "$lib/services/auth";
import { requestClientRegistration } from "$lib/services/key";
import type { ClientKeys } from "$lib/stores";
export { requestLogout } from "$lib/services/auth"; export { requestLogout } from "$lib/services/auth";
export { requestMasterKeyDownload } from "$lib/services/key"; export {
requestClientRegistrationAndSessionUpgrade,
requestMasterKeyDownload,
} from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => { export const requestLogin = async (email: string, password: string) => {
const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password }); const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
return res.ok; return res.ok;
}; };
export const requestSessionUpgrade = async (
{ encryptKey, decryptKey, signKey, verifyKey }: ClientKeys,
force: boolean,
) => {
const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey);
const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey);
const [res, error] = await requestSessionUpgradeInternal(
encryptKeyBase64,
decryptKey,
verifyKeyBase64,
signKey,
force,
);
if (error === undefined) return [res] as const;
if (
error === "Unregistered client" &&
!(await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey))
) {
return [false] as const;
} else if (error === "Already logged in") {
return [false, force ? undefined : error] as const;
}
return [
(
await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)
)[0],
] as const;
};

View File

@@ -3,13 +3,12 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
import { TitledDiv } from "$lib/components/molecules"; import { TitledDiv } from "$lib/components/molecules";
import { serializeClientKeys, storeClientKeys } from "$lib/modules/key";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte"; import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
import BeforeContinueModal from "./BeforeContinueModal.svelte"; import BeforeContinueModal from "./BeforeContinueModal.svelte";
import { import {
serializeClientKeys,
requestClientRegistration, requestClientRegistration,
storeClientKeys,
requestSessionUpgrade, requestSessionUpgrade,
requestInitialMasterKeyAndHmacSecretRegistration, requestInitialMasterKeyAndHmacSecretRegistration,
} from "./service"; } from "./service";
@@ -22,15 +21,8 @@
let isBeforeContinueBottomSheetOpen = $state(false); let isBeforeContinueBottomSheetOpen = $state(false);
const exportClientKeys = () => { const exportClientKeys = () => {
const clientKeysSerialized = serializeClientKeys( const clientKeysSerialized = serializeClientKeys(data);
data.encryptKeyBase64, const clientKeysBlob = new Blob([clientKeysSerialized], { type: "application/json" });
data.decryptKeyBase64,
data.signKeyBase64,
data.verifyKeyBase64,
);
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
type: "application/json",
});
FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json"); FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json");
if (!isBeforeContinueBottomSheetOpen) { if (!isBeforeContinueBottomSheetOpen) {

View File

@@ -1,68 +1,5 @@
import { callPostApi } from "$lib/hooks";
import { storeClientKey } from "$lib/indexedDB";
import { signMasterKeyWrapped } from "$lib/modules/crypto";
import type {
InitialMasterKeyRegisterRequest,
InitialHmacSecretRegisterRequest,
} from "$lib/server/schemas";
import type { ClientKeys } from "$lib/stores";
export { requestSessionUpgrade } from "$lib/services/auth"; export { requestSessionUpgrade } from "$lib/services/auth";
export { requestClientRegistration } from "$lib/services/key"; export {
requestClientRegistration,
type SerializedKeyPairs = { requestInitialMasterKeyAndHmacSecretRegistration,
generator: "ArkVault"; } from "$lib/services/key";
exportedAt: Date;
} & {
version: 1;
encryptKey: string;
decryptKey: string;
signKey: string;
verifyKey: string;
};
export const serializeClientKeys = (
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 SerializedKeyPairs;
};
export const storeClientKeys = async (clientKeys: ClientKeys) => {
await Promise.all([
storeClientKey(clientKeys.encryptKey, "encrypt"),
storeClientKey(clientKeys.decryptKey, "decrypt"),
storeClientKey(clientKeys.signKey, "sign"),
storeClientKey(clientKeys.verifyKey, "verify"),
]);
};
export const requestInitialMasterKeyAndHmacSecretRegistration = async (
masterKeyWrapped: string,
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
if (!res.ok) {
return res.status === 409;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
};

View File

@@ -4,18 +4,27 @@
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
import { TitledDiv } from "$lib/components/molecules"; import { TitledDiv } from "$lib/components/molecules";
import { gotoStateful } from "$lib/hooks"; import { gotoStateful } from "$lib/hooks";
import { storeClientKeys } from "$lib/modules/key";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
import ForceLoginModal from "./ForceLoginModal.svelte";
import Order from "./Order.svelte"; import Order from "./Order.svelte";
import { import {
generateClientKeys, generateClientKeys,
generateInitialMasterKey, generateInitialMasterKey,
generateInitialHmacSecret, generateInitialHmacSecret,
importClientKeys,
requestClientRegistrationAndSessionUpgrade,
requestInitialMasterKeyAndHmacSecretRegistration,
} from "./service"; } from "./service";
import IconKey from "~icons/material-symbols/key"; import IconKey from "~icons/material-symbols/key";
let { data } = $props(); let { data } = $props();
let fileInput: HTMLInputElement | undefined = $state();
let isForceLoginModalOpen = $state(false);
// TODO: Update // TODO: Update
const orders = [ const orders = [
{ {
@@ -51,6 +60,53 @@
}); });
}; };
const importKeys = async () => {
const file = fileInput?.files?.[0];
if (!file) return;
if (await importClientKeys(await file.text())) {
await upgradeSession(false);
} else {
// TODO: Error Handling
}
fileInput!.value = "";
};
const upgradeSession = async (force: boolean) => {
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(
$clientKeyStore!,
force,
);
if (!force && upgradeError === "Already logged in") {
isForceLoginModalOpen = true;
return;
} else if (!upgradeRes) {
// TODO: Error Handling
return;
}
const { masterKey, masterKeyWrapped } = await generateInitialMasterKey(
$clientKeyStore!.encryptKey,
);
const { hmacSecretWrapped } = await generateInitialHmacSecret(masterKey);
await storeClientKeys($clientKeyStore!);
if (
!(await requestInitialMasterKeyAndHmacSecretRegistration(
masterKeyWrapped,
hmacSecretWrapped,
$clientKeyStore!.signKey,
))
) {
// TODO: Error Handling
return;
}
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
};
onMount(async () => { onMount(async () => {
if ($clientKeyStore) { if ($clientKeyStore) {
await goto(data.redirectPath, { replaceState: true }); await goto(data.redirectPath, { replaceState: true });
@@ -62,6 +118,14 @@
<title>암호 키 생성하기</title> <title>암호 키 생성하기</title>
</svelte:head> </svelte:head>
<input
bind:this={fileInput}
onchange={importKeys}
type="file"
accept="application/json"
class="hidden"
/>
<FullscreenDiv> <FullscreenDiv>
<TitledDiv childrenClass="space-y-4"> <TitledDiv childrenClass="space-y-4">
{#snippet title()} {#snippet title()}
@@ -83,6 +147,8 @@
</TitledDiv> </TitledDiv>
<BottomDiv class="flex flex-col items-center gap-y-2"> <BottomDiv class="flex flex-col items-center gap-y-2">
<Button onclick={generateKeys} class="w-full">새 암호 키 생성하기</Button> <Button onclick={generateKeys} class="w-full">새 암호 키 생성하기</Button>
<TextButton>키를 갖고 있어요</TextButton> <TextButton onclick={() => fileInput?.click()}>키를 갖고 있어요</TextButton>
</BottomDiv> </BottomDiv>
</FullscreenDiv> </FullscreenDiv>
<ForceLoginModal bind:isOpen={isForceLoginModalOpen} onLoginClick={() => upgradeSession(true)} />

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
interface Props {
isOpen: boolean;
onLoginClick: () => void;
}
let { isOpen = $bindable(), onLoginClick }: Props = $props();
</script>
<ActionModal
bind:isOpen
title="다른 디바이스에 이미 로그인되어 있어요."
cancelText="아니요"
confirmText="네"
onConfirmClick={onLoginClick}
>
<p>다른 디바이스에서는 로그아웃하고, 이 디바이스에서 로그인할까요?</p>
</ActionModal>

View File

@@ -2,6 +2,8 @@ import {
generateEncryptionKeyPair, generateEncryptionKeyPair,
generateSigningKeyPair, generateSigningKeyPair,
exportRSAKeyToBase64, exportRSAKeyToBase64,
importEncryptionKeyPairFromBase64,
importSigningKeyPairFromBase64,
makeRSAKeyNonextractable, makeRSAKeyNonextractable,
wrapMasterKey, wrapMasterKey,
generateMasterKey, generateMasterKey,
@@ -9,8 +11,15 @@ import {
wrapHmacSecret, wrapHmacSecret,
generateHmacSecret, generateHmacSecret,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { deserializeClientKeys } from "$lib/modules/key";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
export { requestLogout } from "$lib/services/auth";
export {
requestClientRegistrationAndSessionUpgrade,
requestInitialMasterKeyAndHmacSecretRegistration,
} from "$lib/services/key";
export const generateClientKeys = async () => { export const generateClientKeys = async () => {
const { encryptKey, decryptKey } = await generateEncryptionKeyPair(); const { encryptKey, decryptKey } = await generateEncryptionKeyPair();
const { signKey, verifyKey } = await generateSigningKeyPair(); const { signKey, verifyKey } = await generateSigningKeyPair();
@@ -45,3 +54,25 @@ export const generateInitialHmacSecret = async (masterKey: CryptoKey) => {
hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey), hmacSecretWrapped: await wrapHmacSecret(hmacSecret, masterKey),
}; };
}; };
export const importClientKeys = async (clientKeysSerialized: string) => {
const clientKeys = deserializeClientKeys(clientKeysSerialized);
if (!clientKeys) return false;
const { encryptKey, decryptKey } = await importEncryptionKeyPairFromBase64(
clientKeys.encryptKeyBase64,
clientKeys.decryptKeyBase64,
);
const { signKey, verifyKey } = await importSigningKeyPairFromBase64(
clientKeys.signKeyBase64,
clientKeys.verifyKeyBase64,
);
clientKeyStore.set({
encryptKey,
decryptKey: await makeRSAKeyNonextractable(decryptKey),
signKey: await makeRSAKeyNonextractable(signKey),
verifyKey,
});
return true;
};