aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-05-19 13:26:47 -0300
committerSebastian <sebasjm@gmail.com>2023-05-19 13:26:47 -0300
commit0544b8358af68df87dbc472221d8c0842c2b2db0 (patch)
tree14c7a931cf3bf79a1e9e39cc6b177dd0a4374ba2
parent35cc13e229ddc4953a1e68b6b7ea18c54eb9a70b (diff)
downloadwallet-core-0544b8358af68df87dbc472221d8c0842c2b2db0.tar.xz
accounts and notifications
-rw-r--r--packages/exchange-backoffice-ui/src/Dashboard.tsx217
-rw-r--r--packages/exchange-backoffice-ui/src/NiceForm.tsx1
-rw-r--r--packages/exchange-backoffice-ui/src/account.ts243
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx12
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputLine.tsx8
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputText.tsx2
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/forms.ts13
-rw-r--r--packages/exchange-backoffice-ui/src/pages/Officer.tsx313
8 files changed, 679 insertions, 130 deletions
diff --git a/packages/exchange-backoffice-ui/src/Dashboard.tsx b/packages/exchange-backoffice-ui/src/Dashboard.tsx
index 9a0ba41d5..9be86c533 100644
--- a/packages/exchange-backoffice-ui/src/Dashboard.tsx
+++ b/packages/exchange-backoffice-ui/src/Dashboard.tsx
@@ -3,19 +3,26 @@ import {
ChevronDownIcon,
MagnifyingGlassIcon,
UserIcon,
+ XCircleIcon,
} from "@heroicons/react/20/solid";
import {
Bars3Icon,
BellIcon,
+ CheckCircleIcon,
Cog6ToothIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { ComponentChildren, Fragment, VNode, h } from "preact";
import { ForwardedRef, forwardRef } from "preact/compat";
-import { useRef, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import { Pages } from "./pages.js";
import { Router, useCurrentLocation } from "./route.js";
import { InformationCircleIcon } from "@heroicons/react/24/solid";
+import {
+ useLocalStorage,
+ useMemoryStorage,
+ useNotifications,
+} from "@gnu-taler/web-util/browser";
/**
* references between forms
@@ -259,6 +266,7 @@ export function Dashboard({
setSidebarOpen(true);
}}
/>
+ <Notifications />
<main class="py-10 px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl">
<Router
@@ -355,6 +363,9 @@ function NavigationBar({
}
function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
+ const password = useMemoryStorage("password");
+ const officer = useLocalStorage("officer");
+
return (
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button
@@ -402,60 +413,66 @@ function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
aria-hidden="true"
/>
- {/* Profile dropdown */}
- <Menu
- as="div"
- /* @ts-ignore */
- class="relative"
- >
- <Menu.Button class="-m-1.5 flex items-center p-1.5">
- <span class="sr-only">Open user menu</span>
- <img
- class="h-8 w-8 rounded-full bg-gray-50"
- src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
- alt=""
- />
- <span class="hidden lg:flex lg:items-center">
- <span
- class="ml-4 text-sm font-semibold leading-6 text-gray-900"
- aria-hidden="true"
- >
- Tom Cook
- </span>
- <ChevronDownIcon
- class="ml-2 h-5 w-5 text-gray-400"
- aria-hidden="true"
- />
- </span>
- </Menu.Button>
- <Transition
- as={Fragment}
- enter="transition ease-out duration-100"
- enterFrom="transform opacity-0 scale-95"
- enterTo="transform opacity-100 scale-100"
- leave="transition ease-in duration-75"
- leaveFrom="transform opacity-100 scale-100"
- leaveTo="transform opacity-0 scale-95"
+ {officer.value === undefined ? (
+ <div />
+ ) : (
+ <Menu
+ as="div"
+ /* @ts-ignore */
+ class="relative"
>
- <Menu.Items class="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
- {userNavigation.map((item) => (
- <Menu.Item key={item.name}>
+ <Menu.Button class="-m-1.5 flex items-center p-1.5">
+ <span class="sr-only">Open user menu</span>
+ <img
+ class="h-8 w-8 rounded-full bg-gray-50"
+ src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
+ alt=""
+ />
+ <span class="hidden lg:flex lg:items-center">
+ <span
+ class="ml-4 text-sm font-semibold leading-6 text-gray-900"
+ aria-hidden="true"
+ >
+ {/* Tom Cook */}
+ {officer.value?.substring(0, 6)}
+ </span>
+ <ChevronDownIcon
+ class="ml-2 h-5 w-5 text-gray-400"
+ aria-hidden="true"
+ />
+ </span>
+ </Menu.Button>
+ <Transition
+ as={Fragment}
+ enter="transition ease-out duration-100"
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leave="transition ease-in duration-75"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ >
+ <Menu.Items class="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
+ <Menu.Item>
{({ active }: { active: boolean }) => (
<a
- href={item.href}
+ // href={item.href}
+ onClick={() => {
+ officer.reset();
+ password.reset();
+ }}
class={classNames(
active ? "bg-gray-50" : "",
"block px-3 py-1 text-sm leading-6 text-gray-900",
)}
>
- {item.name}
+ Forget account
</a>
)}
</Menu.Item>
- ))}
- </Menu.Items>
- </Transition>
- </Menu>
+ </Menu.Items>
+ </Transition>
+ </Menu>
+ )}
</div>
</div>
</div>
@@ -473,3 +490,115 @@ function Footer() {
</footer>
);
}
+
+function Notifications() {
+ const ns = useNotifications();
+
+ // useEffect(() => {
+ // if (ns.length) {
+ // // remove notifications after some timeout
+ // }
+ // }, []);
+ {
+ /* <!-- Global notification live region, render this permanently at the end of the document --> */
+ }
+ console.log("render", ns.length);
+ return (
+ <div
+ aria-live="assertive"
+ class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
+ >
+ <div class="flex w-full flex-col items-center space-y-4 sm:items-end ">
+ {/* <!--
+ Notification panel, dynamically insert this into the live region when it needs to be displayed
+
+ Entering: "transform ease-out duration-300 transition"
+ From: "translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
+ To: "translate-y-0 opacity-100 sm:translate-x-0"
+ Leaving: "transition ease-in duration-100"
+ From: "opacity-100"
+ To: "opacity-0"
+--> */}
+ {ns.map(({ message, remove }) => {
+ switch (message.type) {
+ case "error": {
+ return (
+ <div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 ">
+ <div class="p-4 ">
+ <div class="flex items-start ">
+ <div class="flex-shrink-0">
+ <XCircleIcon class="h-6 w-6 text-red-400" />
+ </div>
+ <div class="ml-3 w-0 flex-1 pt-0.5">
+ <p class="text-sm font-medium text-gray-900">
+ {message.title}
+ </p>
+ {message.description && (
+ <p class="mt-1 text-sm text-gray-500">
+ {message.description}
+ </p>
+ )}
+ </div>
+ <div class="ml-4 flex flex-shrink-0">
+ <button
+ type="button"
+ onClick={remove}
+ class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+ >
+ <span class="sr-only">Close</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+ case "info": {
+ return (
+ <div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 ">
+ <div class="p-4 ">
+ <div class="flex items-start ">
+ <div class="flex-shrink-0">
+ <CheckCircleIcon class="h-6 w-6 text-green-400" />
+ </div>
+ <div class="ml-3 w-0 flex-1 pt-0.5">
+ <p class="text-sm font-medium text-gray-900">
+ {message.title}
+ </p>
+ </div>
+ <div class="ml-4 flex flex-shrink-0">
+ <button
+ type="button"
+ onClick={remove}
+ class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+ >
+ <span class="sr-only">Close</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+ }
+ })}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/exchange-backoffice-ui/src/NiceForm.tsx b/packages/exchange-backoffice-ui/src/NiceForm.tsx
index b7790bbec..593a373c1 100644
--- a/packages/exchange-backoffice-ui/src/NiceForm.tsx
+++ b/packages/exchange-backoffice-ui/src/NiceForm.tsx
@@ -18,6 +18,7 @@ export function NiceForm<T extends object>({
<FormProvider
initialValue={initial}
onUpdate={onUpdate}
+ onSubmit={() => {}}
computeFormState={form.behavior}
>
<div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
diff --git a/packages/exchange-backoffice-ui/src/account.ts b/packages/exchange-backoffice-ui/src/account.ts
new file mode 100644
index 000000000..1e770794a
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/account.ts
@@ -0,0 +1,243 @@
+import { decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
+
+/**
+ * Create a new session id from which it will
+ * be derive the crypto parameters from
+ * securing the private key
+ *
+ * @returns session id as string
+ */
+export function createNewSessionId(): string {
+ const salt = crypto.getRandomValues(new Uint8Array(8));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer);
+}
+
+/**
+ * Restore previous session and unlock account
+ *
+ * @param sessionId string from which crypto params will be derived
+ * @param accountId secured private key
+ * @param password password for the private key
+ * @returns
+ */
+export async function unlockAccount(
+ sessionId: string,
+ accountId: string,
+ password: string,
+) {
+ const key = str2ab(window.atob(accountId));
+
+ const privateKey = await recoverWithPassword(key, sessionId, password);
+
+ const publicKey = await getPublicFromPrivate(privateKey);
+
+ const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
+ throw new Error(String(e));
+ });
+
+ const pub = btoa(ab2str(pubRaw));
+
+ return { accountId, pub };
+}
+
+/**
+ * Create new account (secured private key) under session
+ * secured with the given password
+ *
+ * @param sessionId
+ * @param password
+ * @returns
+ */
+export async function createNewAccount(sessionId: string, password: string) {
+ const { privateKey, publicKey } = await createPair();
+
+ const protectedPrivKey = await protectWithPassword(
+ privateKey,
+ sessionId,
+ password,
+ );
+
+ // const privRaw = await crypto.subtle
+ // .exportKey("pkcs8", privateKey)
+ // .catch((e) => {
+ // throw new Error(String(e));
+ // });
+
+ const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
+ throw new Error(String(e));
+ });
+
+ const pub = btoa(ab2str(pubRaw));
+ const protectedPriv = btoa(ab2str(protectedPrivKey));
+
+ return { accountId: protectedPriv, pub };
+}
+
+const rsaAlgorithm: RsaHashedKeyGenParams = {
+ name: "RSA-OAEP",
+ modulusLength: 2048,
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
+ hash: "SHA-256",
+};
+
+async function createPair(): Promise<CryptoKeyPair> {
+ const key = await crypto.subtle
+ .generateKey(rsaAlgorithm, true, ["encrypt", "decrypt"])
+ .catch((e) => {
+ throw new Error(String(e));
+ });
+ return key;
+}
+
+const textEncoder = new TextEncoder();
+
+async function protectWithPassword(
+ privateKey: CryptoKey,
+ sessionId: string,
+ password: string,
+): Promise<ArrayBuffer> {
+ const { salt, initVector: iv } = getCryptoPArameters(sessionId);
+ const passwordAsKey = await crypto.subtle
+ .importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
+ "deriveBits",
+ "deriveKey",
+ ])
+ .catch((e) => {
+ throw new Error(String(e));
+ });
+ const wrappingKey = await crypto.subtle
+ .deriveKey(
+ {
+ name: "PBKDF2",
+ salt,
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ passwordAsKey,
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["wrapKey", "unwrapKey"],
+ )
+ .catch((e) => {
+ throw new Error(String(e));
+ });
+
+ const protectedPrivKey = await crypto.subtle
+ .wrapKey("pkcs8", privateKey, wrappingKey, {
+ name: "AES-GCM",
+ iv,
+ })
+ .catch((e) => {
+ throw new Error(String(e));
+ });
+ return protectedPrivKey;
+}
+
+async function recoverWithPassword(
+ value: ArrayBuffer,
+ sessionId: string,
+ password: string,
+): Promise<CryptoKey> {
+ const { salt, initVector: iv } = getCryptoPArameters(sessionId);
+
+ const master = await crypto.subtle
+ .importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
+ "deriveBits",
+ "deriveKey",
+ ])
+ .catch((e) => {
+ throw new UnwrapKeyError("starting", String(e));
+ });
+
+ const unwrappingKey = await crypto.subtle
+ .deriveKey(
+ {
+ name: "PBKDF2",
+ salt,
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ master,
+ { name: "AES-GCM", length: 256 },
+ true,
+ ["wrapKey", "unwrapKey"],
+ )
+ .catch((e) => {
+ throw new UnwrapKeyError("deriving", String(e));
+ });
+
+ const privKey = await crypto.subtle
+ .unwrapKey(
+ "pkcs8",
+ value,
+ unwrappingKey,
+ {
+ name: "AES-GCM",
+ iv,
+ },
+ rsaAlgorithm,
+ true,
+ ["decrypt"],
+ )
+ .catch((e) => {
+ throw new UnwrapKeyError("unwrapping", String(e));
+ });
+ return privKey;
+}
+
+type Steps = "starting" | "deriving" | "unwrapping";
+export class UnwrapKeyError extends Error {
+ public step: Steps;
+ public cause: string;
+ constructor(step: Steps, cause: string) {
+ super(`Recovering private key failed on "${step}": ${cause}`);
+ this.step = step;
+ this.cause = cause;
+ }
+}
+
+/**
+ * Looks like there is no easy way to do it with the Web Crypto API
+ */
+async function getPublicFromPrivate(key: CryptoKey): Promise<CryptoKey> {
+ const jwk = await crypto.subtle.exportKey("jwk", key).catch((e) => {
+ throw new Error(String(e));
+ });
+
+ delete jwk.d;
+ delete jwk.dp;
+ delete jwk.dq;
+ delete jwk.q;
+ delete jwk.qi;
+ jwk.key_ops = ["encrypt"];
+
+ return crypto.subtle
+ .importKey("jwk", jwk, rsaAlgorithm, true, ["encrypt"])
+ .catch((e) => {
+ throw new Error(String(e));
+ });
+}
+
+function ab2str(buf: ArrayBuffer) {
+ return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)));
+}
+function str2ab(str: string) {
+ const buf = new ArrayBuffer(str.length);
+ const bufView = new Uint8Array(buf);
+ for (let i = 0, strLen = str.length; i < strLen; i++) {
+ bufView[i] = str.charCodeAt(i);
+ }
+ return buf;
+}
+
+function getCryptoPArameters(sessionId: string): {
+ salt: Uint8Array;
+ initVector: Uint8Array;
+} {
+ const [saltId, vectorId] = sessionId.split("-");
+ return {
+ salt: decodeCrock(saltId),
+ initVector: decodeCrock(vectorId),
+ };
+}
diff --git a/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx b/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
index d8877333c..87c4c43fb 100644
--- a/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
@@ -41,10 +41,12 @@ export function FormProvider<T>({
children,
initialValue,
onUpdate,
+ onSubmit,
computeFormState,
}: {
initialValue?: Partial<T>;
onUpdate?: (v: Partial<T>) => void;
+ onSubmit: (v: T) => void;
computeFormState?: (v: T) => FormState<T>;
children: ComponentChildren;
}): VNode {
@@ -58,7 +60,15 @@ export function FormProvider<T>({
<FormContext.Provider
value={{ initialValue, value, onUpdate, computeFormState }}
>
- <form>{children}</form>
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ //@ts-ignore
+ onSubmit(value.current);
+ }}
+ >
+ {children}
+ </form>
</FormContext.Provider>
);
}
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx b/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
index 255654949..32b16313d 100644
--- a/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
@@ -23,7 +23,7 @@ interface StringConverter<T> {
}
export interface UIFormProps<T> {
- name: string;
+ name: keyof T;
label: TranslatedString;
placeholder?: TranslatedString;
tooltip?: TranslatedString;
@@ -181,7 +181,11 @@ function defaultFromString(v: string) {
return v;
}
-export function InputLine<T>(props: { type: string } & UIFormProps<T>): VNode {
+type InputType = "text" | "text-area" | "password" | "email";
+
+export function InputLine<T>(
+ props: { type: InputType } & UIFormProps<T>,
+): VNode {
const { name, placeholder, before, after, converter, type } = props;
const { value, onChange, state, isDirty } = useField(name);
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputText.tsx b/packages/exchange-backoffice-ui/src/handlers/InputText.tsx
index 107d87860..014730d92 100644
--- a/packages/exchange-backoffice-ui/src/handlers/InputText.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/InputText.tsx
@@ -1,6 +1,6 @@
import { VNode, h } from "preact";
import { InputLine, UIFormProps } from "./InputLine.js";
-export function InputText(props: UIFormProps<string>): VNode {
+export function InputText<T>(props: UIFormProps<T>): VNode {
return <InputLine type="text" {...props} />;
}
diff --git a/packages/exchange-backoffice-ui/src/handlers/forms.ts b/packages/exchange-backoffice-ui/src/handlers/forms.ts
index 1d6a7daa4..a97b8561d 100644
--- a/packages/exchange-backoffice-ui/src/handlers/forms.ts
+++ b/packages/exchange-backoffice-ui/src/handlers/forms.ts
@@ -11,6 +11,8 @@ import { InputFile } from "./InputFile.js";
import { Caption } from "./Caption.js";
import { Group } from "./Group.js";
import { InputSelectOne } from "./InputSelectOne.js";
+import { FormProvider } from "./FormProvider.js";
+import { InputLine } from "./InputLine.js";
export type DoubleColumnForm = DoubleColumnFormSection[];
@@ -94,3 +96,14 @@ export function RenderAllFieldsByUiConfig({
}),
);
}
+
+type FormSet<T> = {
+ Provider: typeof FormProvider<T>;
+ InputLine: typeof InputLine<T>;
+};
+export function createNewForm<T>(): FormSet<T> {
+ return {
+ Provider: FormProvider,
+ InputLine: InputLine,
+ };
+}
diff --git a/packages/exchange-backoffice-ui/src/pages/Officer.tsx b/packages/exchange-backoffice-ui/src/pages/Officer.tsx
index c72ca0720..4d8b90228 100644
--- a/packages/exchange-backoffice-ui/src/pages/Officer.tsx
+++ b/packages/exchange-backoffice-ui/src/pages/Officer.tsx
@@ -1,116 +1,265 @@
-import { useLocalStorage } from "@gnu-taler/web-util/browser";
-import { h } from "preact";
+import { TranslatedString } from "@gnu-taler/taler-util";
+import {
+ notifyError,
+ notifyInfo,
+ useLocalStorage,
+ useMemoryStorage,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
+import {
+ UnwrapKeyError,
+ createNewAccount,
+ createNewSessionId,
+ unlockAccount,
+} from "../account.js";
+import { createNewForm } from "../handlers/forms.js";
-const oldKey =
- "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDPQVq8F0Ce6kTXKQ5Ea2fZRoap6poFYs0FOln8o8+ehGI8rDdMBzNU3pLIlOMKs/vKvhDNMG4m4xxb92wDbvefDxkxaEkbRSZnRiJd4MIbh8Lx8zvFbLp03rkXu9KPN8IprKOXxgN7xbxm0KKcu03rtqLiOvC1gMqja2LMIPCi32nyNneduszHZ57d+CqIKZdVnaqAcXOSMAQsVoEq2joBOeIaSAnIJHg+T8HQ+VcLV8Y722jhX/bH84IyEMup9e7mhgVFnHgINc77c6TONH8H+dHlXCQ+hMPGw9wM+wgpJgIDzrhIN+QSjn283EOXD6z6dpiWBdEYfJRLHwEWk8wNAgMBAAECggEAB/anZrMasQsoXP9qBG1Uvq+r4fXZODFtK5vBNGi+RAWAhCX2iU3SMPB3wbby0wj1DlESR91qBhrTjqG+/TzIzUxLuARyoVZysiTVkjeIzdJVcRgwU5bTbUUs5da6MaA/WNGWMZvoALFUMBEpMQ4uDCC8OSbG8/prDtoZSuWjHrxBhsqSyIoJ3Q0iPQxPT0ShC9d5T56QuhsRQeRIWhQVtFlytXl1lqEbqljhIEOzkvS5QOcXcS3OBo/Nvdit+vi9kkLuiP8z2p6WAiVZCgCXfffNH3EEbQG/BEpIOynkchiDy1L31mFRFk1oYJRs9xD8+oF/N75GhlmYO7IbxeHw0wKBgQDnYZWjGlRM2oHpeiPSII5m9rC7qohO0ImxqifYZPp47vdRMbBWrdbxX68SqdzGfSzXcDPLfBAObG4QR8Xol1LMNJUT9og9pERZHgob+yWkTd68lLSdxfCJEKRJaDmD8dHgSrBYe86ADUeAj+fC4dycYXH//fwed1gt/G8iXtdU9wKBgQDlTp9752+tEh9fMlUdINbZXmGbjHBrZMTnAYJI509iJLIvJvYroU5TvRMsp+rACDc2Zy2nbsaCM5Xzd5wUxRBvF+PiBCFoi7c/EBaLCtb9+vyXtHAIHtzHeYUP/1cq7MOdTwrWvZqzIoW6xm7L9HRX/5i+n+rVUSxnzYIxgTlaGwKBgQC0INgpXbn7CrDQXnG8h/PUXIBB2QS8tsQ7N8hFQndr5j1LTG+HS1ZmGqNk2DAzpgdewM7RvweQ8wDMU9PSutuOdfEI1YhC1LsQ1b3xApfPTX/1N59UpGAZlIcRTr5X5c4J2ptmhxu/vJbJkz5ODR997q6dJ9E6tpZDVp3+F+9zCQKBgQCrp+OzuVjcUoixltgoagDrz7951fQCMPlFhNenA6FlctsAeUYm+yXLgersrvcIsh3C2BJRGJf5t+w0ygFJewwGXff1pensfUq8Jqr5gy/WCSE135lOOuxDVzDI/Pif5YW6KQWQI3e/ScSaQRmIDINbrLcHXGdLMOzw9+LSdE4eqQKBgQDe86MfzwMLPoDH07WC09dCcoIUSYMThYrFwUK3qgEiYaJMZJvdAIwr12szVwVRYIX4wHBObFsQZLTaY5+O/REnze6Q1AQa2H6eH1TalC1r6jBS5/LhIrVWl/0VSdsUIe41tc8xPDWrm9hmLeJLZk+xb5/hAm3REsDM1Iif9O7zzg==";
export function Officer() {
- const storage = useLocalStorage("officer");
- const [keys, setKeys] = useState({ priv: "", pub: "" });
+ const password = useMemoryStorage("password");
+ const session = useLocalStorage("session");
+ const officer = useLocalStorage("officer");
+ const [keys, setKeys] = useState({ accountId: "", pub: "" });
+
useEffect(() => {
- loadPreviousSession(oldKey).then((keys) =>
- setKeys(keys ?? { priv: "", pub: "" }),
- );
- // generateNewId().then((keys) => setKeys(keys));
+ if (
+ officer.value === undefined ||
+ session.value === undefined ||
+ password.value === undefined
+ ) {
+ return;
+ }
+ unlockAccount(session.value, officer.value, password.value)
+ .then((keys) => setKeys(keys ?? { accountId: "", pub: "" }))
+ .catch((e) => {
+ if (e instanceof UnwrapKeyError) {
+ console.log(e);
+ }
+ });
+ }, [officer.value, session.value, password.value]);
+
+ useEffect(() => {
+ if (!session.value) {
+ session.update(createNewSessionId());
+ }
}, []);
- console.log(keys.pub);
- console.log(keys.priv);
+ const { value: sessionId } = session;
+ if (!sessionId) {
+ return <div>loading...</div>;
+ }
+
+ if (officer.value === undefined) {
+ return (
+ <CreateAccount
+ sessionId={sessionId}
+ onNewAccount={(id) => {
+ password.reset();
+ officer.update(id);
+ }}
+ />
+ );
+ }
+
+ console.log("pwd", password.value);
+ if (password.value === undefined) {
+ return (
+ <UnlockAccount
+ sessionId={sessionId}
+ accountId={officer.value}
+ onAccountUnlocked={(pwd) => {
+ password.update(pwd);
+ }}
+ />
+ );
+ }
+
return (
<div>
<div>Officer</div>
+ <h1>{sessionId}</h1>
<h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
Public key
</h1>
<div>
- -----BEGIN PUBLIC KEY-----
- <p class="mt-6 leading-8 text-gray-700 break-all">{keys.pub}</p>
- -----END PUBLIC KEY-----
+ <p class="mt-6 leading-8 text-gray-700 break-all">
+ -----BEGIN PUBLIC KEY-----
+ <div>{keys.pub}</div>
+ -----END PUBLIC KEY-----
+ </p>
</div>
<h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
Private key
</h1>
<div>
- -----BEGIN PRIVATE KEY-----
- <p class="mt-6 leading-8 text-gray-700 break-all">{keys.priv}</p>
- -----END PRIVATE KEY-----
+ <p class="mt-6 leading-8 text-gray-700 break-all">
+ -----BEGIN PRIVATE KEY-----
+ <div>{keys.accountId}</div>
+ -----END PRIVATE KEY-----
+ </p>
</div>
</div>
);
}
-const rsaAlgorithm: RsaHashedKeyGenParams = {
- name: "RSA-OAEP",
- modulusLength: 2048,
- publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
- hash: "SHA-256",
-};
-
-async function generateNewId() {
- const key = await crypto.subtle.generateKey(rsaAlgorithm, true, [
- "encrypt",
- "decrypt",
- ]);
-
- if (key instanceof CryptoKey) {
- throw Error("unexpected key without pair");
- }
- const { privateKey, publicKey } = key;
- const privRaw = await crypto.subtle.exportKey("pkcs8", privateKey);
-
- const pubRaw = await crypto.subtle.exportKey("spki", publicKey);
+function CreateAccount({
+ sessionId,
+ onNewAccount,
+}: {
+ sessionId: string;
+ onNewAccount: (accountId: string) => void;
+}): VNode {
+ const Form = createNewForm<{
+ email: string;
+ password: string;
+ }>();
- const priv = btoa(ab2str(privRaw));
-
- const pub = btoa(ab2str(pubRaw));
- return { priv, pub };
-}
+ return (
+ <div class="flex min-h-full flex-col ">
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ Create account
+ </h2>
+ </div>
-async function loadPreviousSession(priv: string) {
- const key = str2ab(window.atob(priv));
- const privateKey = await window.crypto.subtle
- .importKey("pkcs8", key, rsaAlgorithm, true, ["decrypt"])
- .catch(throwErrorWithStack);
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
+ <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
+ <Form.Provider
+ onSubmit={async (v) => {
+ const keys = await createNewAccount(sessionId, v.password);
+ onNewAccount(keys.accountId);
+ }}
+ >
+ <div class="mb-4">
+ <Form.InputLine
+ label={"Email" as TranslatedString}
+ name="email"
+ type="email"
+ required
+ />
+ </div>
- if (!privateKey) return undefined;
+ <div class="mb-4">
+ <Form.InputLine
+ label={"Password" as TranslatedString}
+ name="password"
+ type="password"
+ required
+ />
+ </div>
- // export private key to JWK
- const jwk = await crypto.subtle
- .exportKey("jwk", privateKey)
- .catch(throwErrorWithStack);
+ <div class="mt-8">
+ <button
+ type="submit"
+ class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ Create
+ </button>
+ </div>
+ </Form.Provider>
+ </div>
+ </div>
+ </div>
+ );
+}
- // remove private data from JWK
- delete jwk.d;
- delete jwk.dp;
- delete jwk.dq;
- delete jwk.q;
- delete jwk.qi;
- jwk.key_ops = ["encrypt"];
+function UnlockAccount({
+ sessionId,
+ accountId,
+ onAccountUnlocked,
+}: {
+ sessionId: string;
+ accountId: string;
+ onAccountUnlocked: (password: string) => void;
+}): VNode {
+ const Form = createNewForm<{
+ sessionId: string;
+ accountId: string;
+ password: string;
+ }>();
- const publicKey = await crypto.subtle
- .importKey("jwk", jwk, rsaAlgorithm, true, ["encrypt"])
- .catch(throwErrorWithStack);
+ return (
+ <div class="flex min-h-full flex-col ">
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ Unlock account
+ </h2>
+ </div>
- const pubRaw = await crypto.subtle
- .exportKey("spki", publicKey)
- .catch(throwErrorWithStack);
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
+ <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
+ <Form.Provider
+ initialValue={{
+ sessionId,
+ accountId:
+ accountId.substring(0, 6) +
+ "..." +
+ accountId.substring(accountId.length - 6),
+ }}
+ computeFormState={(v) => {
+ return {
+ accountId: {
+ disabled: true,
+ },
+ sessionId: {
+ disabled: true,
+ },
+ };
+ }}
+ onSubmit={async (v) => {
+ try {
+ // test login
+ await unlockAccount(sessionId, accountId, v.password);
- const pub = btoa(ab2str(pubRaw));
+ onAccountUnlocked(v.password ?? "");
+ notifyInfo("Account unlocked" as TranslatedString);
+ } catch (e) {
+ if (e instanceof UnwrapKeyError) {
+ notifyError(
+ "Could not unlock account" as any,
+ e.message as any,
+ );
+ } else {
+ throw e;
+ }
+ }
+ }}
+ >
+ <div class="mb-4">
+ <Form.InputLine
+ label={"Session" as TranslatedString}
+ name="sessionId"
+ type="text"
+ />
+ </div>
+ <div class="mb-4">
+ <Form.InputLine
+ label={"AccountId" as TranslatedString}
+ name="accountId"
+ type="text"
+ />
+ </div>
- return { priv, pub };
-}
+ <div class="mb-4">
+ <Form.InputLine
+ label={"Password" as TranslatedString}
+ name="password"
+ type="password"
+ required
+ />
+ </div>
-function ab2str(buf: ArrayBuffer) {
- return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)));
-}
-function str2ab(str: string) {
- const buf = new ArrayBuffer(str.length);
- const bufView = new Uint8Array(buf);
- for (let i = 0, strLen = str.length; i < strLen; i++) {
- bufView[i] = str.charCodeAt(i);
- }
- return buf;
-}
-function throwErrorWithStack(e: Error): never {
- throw new Error(e.message);
+ <div class="mt-8">
+ <button
+ type="submit"
+ class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ Unlock
+ </button>
+ </div>
+ </Form.Provider>
+ </div>
+ </div>
+ </div>
+ );
}