diff options
author | Sebastian <sebasjm@gmail.com> | 2023-05-19 13:26:47 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-05-19 13:26:47 -0300 |
commit | 0544b8358af68df87dbc472221d8c0842c2b2db0 (patch) | |
tree | 14c7a931cf3bf79a1e9e39cc6b177dd0a4374ba2 /packages | |
parent | 35cc13e229ddc4953a1e68b6b7ea18c54eb9a70b (diff) | |
download | wallet-core-0544b8358af68df87dbc472221d8c0842c2b2db0.tar.xz |
accounts and notifications
Diffstat (limited to 'packages')
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> + ); } |