diff options
Diffstat (limited to 'packages/exchange-backoffice-ui/src')
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> +  );  } | 
