diff options
-rw-r--r-- | packages/exchange-backoffice-ui/src/Dashboard.tsx | 55 | ||||
-rw-r--r-- | packages/exchange-backoffice-ui/src/account.ts | 227 | ||||
-rw-r--r-- | packages/exchange-backoffice-ui/src/pages/Officer.tsx | 53 | ||||
-rw-r--r-- | packages/taler-util/src/taler-crypto.ts | 2 |
4 files changed, 75 insertions, 262 deletions
diff --git a/packages/exchange-backoffice-ui/src/Dashboard.tsx b/packages/exchange-backoffice-ui/src/Dashboard.tsx index 9f4a43513..6794ca1f8 100644 --- a/packages/exchange-backoffice-ui/src/Dashboard.tsx +++ b/packages/exchange-backoffice-ui/src/Dashboard.tsx @@ -1,36 +1,13 @@ -import { Dialog, Menu, Transition } from "@headlessui/react"; -import { - ChevronDownIcon, - MagnifyingGlassIcon, - UserIcon, - XCircleIcon, -} from "@heroicons/react/20/solid"; -import { - Bars3Icon, - BellIcon, - CheckCircleIcon, - Cog6ToothIcon, - XMarkIcon, -} from "@heroicons/react/24/outline"; +import { useNotifications } from "@gnu-taler/web-util/browser"; +import { Dialog, Transition } from "@headlessui/react"; +import { UserIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { InformationCircleIcon } from "@heroicons/react/24/solid"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { ForwardedRef, forwardRef } from "preact/compat"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; +import logo from "./assets/logo-2021.svg"; 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"; -import { - AbsoluteTime, - Codec, - buildCodecForObject, - codecForAbsoluteTime, - codecForString, -} from "@gnu-taler/taler-util"; -import logo from "./assets/logo-2021.svg"; function classNames(...classes: string[]) { return classes.filter(Boolean).join(" "); @@ -329,25 +306,7 @@ function NavigationBar({ ); } -export interface Officer { - salt: string; - when: AbsoluteTime; - key: string; -} - -export const codecForOfficer = (): Codec<Officer> => - buildCodecForObject<Officer>() - .property("salt", codecForString()) // FIXME - .property("when", codecForAbsoluteTime) // FIXME - .property("key", codecForString()) - .build("Officer"); - function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) { - const password = useMemoryStorage("password"); - const officer = useLocalStorage("officer", { - codec: codecForOfficer(), - }); - return ( <div class="relative flex h-16 justify-between"> <div class="relative z-10 flex p-2 lg:hidden"> diff --git a/packages/exchange-backoffice-ui/src/account.ts b/packages/exchange-backoffice-ui/src/account.ts index 6c3766940..05f0f8984 100644 --- a/packages/exchange-backoffice-ui/src/account.ts +++ b/packages/exchange-backoffice-ui/src/account.ts @@ -2,28 +2,17 @@ import { bytesToString, createEddsaKeyPair, decodeCrock, + decryptWithDerivedKey, + eddsaGetPublic, encodeCrock, encryptWithDerivedKey, getRandomBytesF, stringToBytes, } 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 createSalt(): string { - const salt = crypto.getRandomValues(new Uint8Array(8)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer); -} - export interface Account { - accountId: string; - secret: CryptoKey; + accountId: AccountId; + signingKey: SigningKey; } /** @@ -35,25 +24,36 @@ export interface Account { * @returns */ export async function unlockAccount( - salt: string, - key: string, + account: LockedAccount, password: string, ): Promise<Account> { - const rawKey = str2ab(window.atob(key)); + const rawKey = decodeCrock(account); + const rawPassword = stringToBytes(password); - const privateKey = await recoverWithPassword(rawKey, salt, password); + const signingKey = (await decryptWithDerivedKey( + rawKey, + rawPassword, + password, + ).catch((e: Error) => { + throw new UnwrapKeyError(e.message); + })) as SigningKey; - const publicKey = await getPublicFromPrivate(privateKey); + const publicKey = eddsaGetPublic(signingKey); - const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => { - throw new Error(String(e)); - }); + const accountId = encodeCrock(publicKey) as AccountId; - const accountId = btoa(ab2str(pubRaw)); - - return { accountId, secret: privateKey }; + return { accountId, signingKey }; } +declare const opaque_Account: unique symbol; +export type LockedAccount = string & { [opaque_Account]: true }; + +declare const opaque_AccountId: unique symbol; +export type AccountId = string & { [opaque_AccountId]: true }; + +declare const opaque_SigningKey: unique symbol; +export type SigningKey = Uint8Array & { [opaque_SigningKey]: true }; + /** * Create new account (secured private key) under session * secured with the given password @@ -62,9 +62,10 @@ export async function unlockAccount( * @param password * @returns */ -export async function createNewAccount(password: string) { +export async function createNewAccount( + password: string, +): Promise<LockedAccount> { const { eddsaPriv } = createEddsaKeyPair(); - const salt = createSalt(); const key = stringToBytes(password); @@ -72,178 +73,18 @@ export async function createNewAccount(password: string) { getRandomBytesF(24), key, eddsaPriv, - salt, + password, ); - const protectedPriv = bytesToString(protectedPrivKey); - - return { accountId: protectedPriv, salt }; -} - -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 protectedPriv = encodeCrock(protectedPrivKey); - 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; + return protectedPriv as LockedAccount; } -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; + constructor(cause: string) { + super(`Recovering private key failed on: ${cause}`); 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/pages/Officer.tsx b/packages/exchange-backoffice-ui/src/pages/Officer.tsx index 39e368b37..40ec33018 100644 --- a/packages/exchange-backoffice-ui/src/pages/Officer.tsx +++ b/packages/exchange-backoffice-ui/src/pages/Officer.tsx @@ -1,4 +1,11 @@ -import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + Codec, + TranslatedString, + buildCodecForObject, + codecForAbsoluteTime, + codecForString, +} from "@gnu-taler/taler-util"; import { notifyError, notifyInfo, @@ -10,12 +17,25 @@ import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { Account, + LockedAccount, UnwrapKeyError, createNewAccount, unlockAccount, } from "../account.js"; import { createNewForm } from "../handlers/forms.js"; -import { Officer, codecForOfficer } from "../Dashboard.js"; + +export interface Officer { + account: LockedAccount; + when: AbsoluteTime; +} + +const codecForLockedAccount = codecForString() as Codec<LockedAccount>; + +export const codecForOfficer = (): Codec<Officer> => + buildCodecForObject<Officer>() + .property("account", codecForLockedAccount) // FIXME + .property("when", codecForAbsoluteTime) // FIXME + .build("Officer"); export function Officer() { const password = useMemoryStorage("password"); @@ -29,7 +49,7 @@ export function Officer() { return; } - unlockAccount(officer.value.salt, officer.value.key, password.value) + unlockAccount(officer.value.account, password.value) .then((keys) => setKeys(keys ?? { accountId: "", pub: "" })) .catch((e) => { if (e instanceof UnwrapKeyError) { @@ -38,16 +58,12 @@ export function Officer() { }); }, [officer.value, password.value]); - if ( - officer.value === undefined || - !officer.value.key || - !officer.value.salt - ) { + if (officer.value === undefined || !officer.value.account) { return ( <CreateAccount - onNewAccount={(salt, key, pwd) => { + onNewAccount={(account, pwd) => { password.update(pwd); - officer.update({ salt, when: AbsoluteTime.now(), key }); + officer.update({ account, when: AbsoluteTime.now() }); }} /> ); @@ -56,8 +72,7 @@ export function Officer() { if (password.value === undefined) { return ( <UnlockAccount - salt={officer.value.salt} - sealedKey={officer.value.key} + lockedAccount={officer.value.account} onAccountUnlocked={(pwd) => { password.update(pwd); }} @@ -114,7 +129,7 @@ export function Officer() { function CreateAccount({ onNewAccount, }: { - onNewAccount: (salt: string, accountId: string, password: string) => void; + onNewAccount: (account: LockedAccount, password: string) => void; }): VNode { const { i18n } = useTranslationContext(); const Form = createNewForm<{ @@ -158,8 +173,8 @@ function CreateAccount({ }; }} onSubmit={async (v) => { - const keys = await createNewAccount(v.password); - onNewAccount(keys.salt, keys.accountId, v.password); + const account = await createNewAccount(v.password); + onNewAccount(account, v.password); }} > <div class="mb-4"> @@ -198,12 +213,10 @@ function CreateAccount({ } function UnlockAccount({ - salt, - sealedKey, + lockedAccount, onAccountUnlocked, }: { - salt: string; - sealedKey: string; + lockedAccount: LockedAccount; onAccountUnlocked: (password: string) => void; }): VNode { const Form = createNewForm<{ @@ -228,7 +241,7 @@ function UnlockAccount({ onSubmit={async (v) => { try { // test login - await unlockAccount(salt, sealedKey, v.password); + await unlockAccount(lockedAccount, v.password); onAccountUnlocked(v.password ?? ""); notifyInfo("Account unlocked" as TranslatedString); diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts index 6fc6d14f6..4a657b621 100644 --- a/packages/taler-util/src/taler-crypto.ts +++ b/packages/taler-util/src/taler-crypto.ts @@ -1406,7 +1406,7 @@ export async function encryptWithDerivedKey( const nonceSize = 24; -async function decryptWithDerivedKey( +export async function decryptWithDerivedKey( ciphertext: OpaqueData, keySeed: OpaqueData, salt: string, |