암호 키 내보내기 페이지 구현

This commit is contained in:
static
2024-12-28 22:15:46 +09:00
parent dfb56b62b1
commit 52a61297c5
13 changed files with 169 additions and 30 deletions

View File

@@ -23,6 +23,7 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/file-saver": "^2.0.7",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.7",
@@ -33,6 +34,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"eslint-plugin-tailwindcss": "^3.17.5",
"file-saver": "^2.0.5",
"globals": "^15.0.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",

14
pnpm-lock.yaml generated
View File

@@ -49,6 +49,9 @@ devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.11
version: 7.6.12
'@types/file-saver':
specifier: ^2.0.7
version: 2.0.7
'@types/jsonwebtoken':
specifier: ^9.0.7
version: 9.0.7
@@ -79,6 +82,9 @@ devDependencies:
eslint-plugin-tailwindcss:
specifier: ^3.17.5
version: 3.17.5(tailwindcss@3.4.17)
file-saver:
specifier: ^2.0.5
version: 2.0.5
globals:
specifier: ^15.0.0
version: 15.14.0
@@ -1288,6 +1294,10 @@ packages:
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
dev: true
/@types/file-saver@2.0.7:
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
dev: true
/@types/json-schema@7.0.15:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
@@ -2269,6 +2279,10 @@ packages:
flat-cache: 4.0.1
dev: true
/file-saver@2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
dev: true
/file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
dev: false

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
interface Props {
children: Snippet;
isOpen: boolean;
}
let { children, isOpen = $bindable() }: Props = $props();
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => {
isOpen = false;
}}
class="fixed inset-0 flex items-end justify-center"
>
<div class="absolute inset-0 bg-black bg-opacity-50" transition:fade={{ duration: 100 }}></div>
<div
onclick={(e) => e.stopPropagation()}
class="z-10 flex max-h-[70vh] min-h-[30vh] w-full items-stretch rounded-t-2xl bg-white p-4"
transition:fly={{ y: 100, duration: 200 }}
>
{@render children?.()}
</div>
</div>
{/if}

View File

@@ -8,24 +8,19 @@
}
let { children, isOpen = $bindable() }: Props = $props();
const closeModal = () => {
isOpen = false;
};
</script>
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={closeModal}
onclick={() => {
isOpen = false;
}}
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 px-2"
transition:fade={{ duration: 100 }}
>
<div
onclick={(e) => e.stopPropagation()}
class="max-w-full rounded-2xl bg-white p-4"
transition:fade={{ duration: 100 }}
>
<div onclick={(e) => e.stopPropagation()} class="max-w-full rounded-2xl bg-white p-4">
{@render children?.()}
</div>
</div>

View File

@@ -1 +1,2 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export { default as Modal } from "./Modal.svelte";

View File

@@ -3,6 +3,7 @@ import { goto } from "$app/navigation";
type Path = "/key/export";
interface KeyExportState {
redirectPath: string;
pubKeyBase64: string;
privKeyBase64: string;
}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons";
import { TitleDiv, BottomDiv } from "$lib/components/divs";
@@ -17,7 +16,7 @@
if (await requestLogin(email, password)) {
await goto(
get(keyPairStore)
$keyPairStore
? data.redirectPath
: "/key/generate?redirect=" + encodeURIComponent(data.redirectPath),
);

View File

@@ -1,31 +1,49 @@
<script lang="ts">
import { saveAs } from "file-saver";
import { goto } from "$app/navigation";
import { Button, TextButton } from "$lib/components/buttons";
import { BottomDiv } from "$lib/components/divs";
import { keyPairStore } from "$lib/stores";
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
import BeforeContinueModal from "./BeforeContinueModal.svelte";
import { requestPubKeyRegistration } from "./service";
import {
createBlobFromKeyPairBase64,
requestPubKeyRegistration,
storeKeyPairPersistently,
} from "./service";
import IconKey from "~icons/material-symbols/key";
let { data } = $props();
let isBeforeContinueModalOpen = $state(false);
let isBeforeContinueBottomSheetOpen = $state(false);
const exportKeyPair = () => {
// TODO
console.log(data.pubKeyBase64);
console.log(data.privKeyBase64);
const keyPairBlob = createBlobFromKeyPairBase64(data.pubKeyBase64, data.privKeyBase64);
saveAs(keyPairBlob, "arkvalut-keypair.pem");
if (!isBeforeContinueBottomSheetOpen) {
setTimeout(() => {
isBeforeContinueBottomSheetOpen = true;
}, 1000);
}
};
const continueWithoutExport = async () => {
isBeforeContinueModalOpen = false;
const ok = await requestPubKeyRegistration(data.pubKeyBase64);
if (!ok) {
// TODO
return;
const registerPubKey = async () => {
if (!$keyPairStore) {
throw new Error("Failed to find key pair");
}
// TODO
isBeforeContinueModalOpen = false;
isBeforeContinueBottomSheetOpen = false;
if (await requestPubKeyRegistration(data.pubKeyBase64)) {
await storeKeyPairPersistently($keyPairStore);
await goto(data.redirectPath);
} else {
// TODO
}
};
</script>
@@ -64,7 +82,9 @@
</div>
</div>
<BeforeContinueModal
bind:isOpen={isBeforeContinueModalOpen}
onContinueClick={continueWithoutExport}
<BeforeContinueModal bind:isOpen={isBeforeContinueModalOpen} onContinueClick={registerPubKey} />
<BeforeContinueBottomSheet
bind:isOpen={isBeforeContinueBottomSheetOpen}
onRetryClick={exportKeyPair}
onContinueClick={registerPubKey}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { BottomSheet } from "$lib/components";
import { Button } from "$lib/components/buttons";
import { BottomDiv } from "$lib/components/divs";
interface Props {
onRetryClick: () => void;
onContinueClick: () => void;
isOpen: boolean;
}
let { onRetryClick, onContinueClick, isOpen = $bindable() }: Props = $props();
</script>
<BottomSheet bind:isOpen>
<div class="flex flex-col justify-between space-y-4">
<div class="space-y-2">
<p class="break-keep text-xl font-bold">암호 키 파일을 저장하셨나요?</p>
<p class="break-keep">
보안상의 이유로 지금 시점 이후로는 암호 키를 파일로 내보낼 수 없어요. 파일이 저장되었는지
다시 확인해 주세요.
</p>
</div>
<BottomDiv>
<div class="flex w-full gap-2">
<div class="w-full">
<Button color="gray" onclick={onRetryClick}>다시 저장할래요</Button>
</div>
<div class="w-full">
<Button onclick={onContinueClick}> 저장되었어요</Button>
</div>
</div>
</BottomDiv>
</div>
</BottomSheet>

View File

@@ -23,8 +23,10 @@
color="gray"
onclick={() => {
isOpen = false;
}}>아니요</Button
}}
>
아니요
</Button>
<Button onclick={onContinueClick}>계속합니다</Button>
</div>
</div>

View File

@@ -1,4 +1,17 @@
import { callAPI } from "$lib/hooks";
import { storeKeyPairIntoIndexedDB } from "$lib/indexedDB";
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");
}
const pubKeyPem = `-----BEGIN PUBLIC KEY-----\n${pubKeyFormatted}\n-----END PUBLIC KEY-----`;
const privKeyPem = `-----BEGIN PRIVATE KEY-----\n${privKeyFormatted}\n-----END PRIVATE KEY-----`;
return new Blob([`${pubKeyPem}\n${privKeyPem}\n`], { type: "text/plain" });
};
export const requestPubKeyRegistration = async (pubKeyBase64: string) => {
const res = await callAPI("/api/key/register", {
@@ -10,3 +23,7 @@ export const requestPubKeyRegistration = async (pubKeyBase64: string) => {
});
return res.ok;
};
export const storeKeyPairPersistently = async (keyPair: CryptoKeyPair) => {
await storeKeyPairIntoIndexedDB(keyPair.publicKey, keyPair.privateKey);
};

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import { goto } from "$app/navigation";
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 Order from "./Order.svelte";
import { generateKeyPair } from "./service";
import IconKey from "~icons/material-symbols/key";
let { data } = $props();
const orders = [
{
title: "암호 키는 공개 키와 개인 키로 구성돼요.",
@@ -27,9 +31,21 @@
];
const generate = async () => {
// TODO
await gotoStateful("/key/export", await generateKeyPair());
// TODO: Loading indicator
const keyPair = await generateKeyPair();
await gotoStateful("/key/export", {
redirectPath: data.redirectPath,
pubKeyBase64: keyPair.pubKeyBase64,
privKeyBase64: keyPair.privKeyBase64,
});
};
$effect(() => {
if ($keyPairStore) {
goto(data.redirectPath);
}
});
</script>
<svetle:head>

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