diff --git a/packages/apps/dev-wallet/index.html b/packages/apps/dev-wallet/index.html index a7627aee8b..801d6683ea 100644 --- a/packages/apps/dev-wallet/index.html +++ b/packages/apps/dev-wallet/index.html @@ -8,7 +8,6 @@ -
@@ -19,9 +18,10 @@ style="margin: 0" />

Chainweaver v3

-

Loading

+
Loading...
+
diff --git a/packages/apps/dev-wallet/public/boot.css b/packages/apps/dev-wallet/public/boot.css index 4e47e052fc..6b4df4c9a0 100644 --- a/packages/apps/dev-wallet/public/boot.css +++ b/packages/apps/dev-wallet/public/boot.css @@ -1,20 +1,26 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + .welcome-wrapper { position: fixed; top: 0; left: 0; width: 100%; - height: 100%; display: flex; justify-content: center; align-items: center; flex-direction: column; height: 100vh; - background: #050e1b; opacity: 0; } .welcome-message-content { display: flex; - padding: 40px 100px; + padding: 40px; + max-width: 455px; + width: 455px; justify-content: center; align-items: center; flex-direction: column; @@ -27,8 +33,30 @@ text-align: center; -webkit-font-smoothing: antialiased; } -body.boot.boot-theme-light .welcome-wrapper { - background: #f5f5f5; + +#loading-content { + display: flex; + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + font-family: 'Roboto', sans-serif; + font-smooth: antialiased; + line-height: 1rem; + text-align: center; + -webkit-font-smoothing: antialiased; + width: 300px; + background: black; + height: 80px; + padding: 10px; + box-sizing: border-box; + text-align: left; + font-size: 1rem; + gap: 4px; + border-radius: 4px; +} + +body.boot.boot-theme-light #loading-content { + background: #f0f0f0; } body.boot.boot-theme-light .welcome-wrapper .welcome-message-content { diff --git a/packages/apps/dev-wallet/src/App/Layout/SideBar.tsx b/packages/apps/dev-wallet/src/App/Layout/SideBar.tsx index 52870cc253..18fb26ab01 100644 --- a/packages/apps/dev-wallet/src/App/Layout/SideBar.tsx +++ b/packages/apps/dev-wallet/src/App/Layout/SideBar.tsx @@ -19,8 +19,8 @@ import { import { NetworkSelector } from '@/Components/NetworkSelector/NetworkSelector'; import { useWallet } from '@/modules/wallet/wallet.hook'; +import { getWebAuthnPass } from '@/modules/wallet/wallet.service'; import { getInitials } from '@/utils/get-initials'; -import { unlockWithWebAuthn } from '@/utils/unlockWithWebAuthn'; import { Avatar, Button, @@ -193,7 +193,11 @@ export const SideBar: FC = () => { onClick={async () => { if (prf.uuid === profile?.uuid) return; if (prf.options.authMode === 'WEB_AUTHN') { - await unlockWithWebAuthn(prf, unlockProfile); + const pass = await getWebAuthnPass(prf); + if (pass) { + lockProfile(); + await unlockProfile(prf.uuid, pass); + } } else { navigate(`/unlock-profile/${prf.uuid}`); } diff --git a/packages/apps/dev-wallet/src/App/Layout/style.css.ts b/packages/apps/dev-wallet/src/App/Layout/style.css.ts index 348b68d30a..a88587d4e9 100644 --- a/packages/apps/dev-wallet/src/App/Layout/style.css.ts +++ b/packages/apps/dev-wallet/src/App/Layout/style.css.ts @@ -16,6 +16,7 @@ export const isExpandedMainClass = style({ export const mainContainerClass = style({ display: 'flex', + padding: '0 10px', flexDirection: 'column', maxWidth: '100vw', width: '100%', diff --git a/packages/apps/dev-wallet/src/App/app.tsx b/packages/apps/dev-wallet/src/App/app.tsx index eecf705a0a..ca492eda69 100644 --- a/packages/apps/dev-wallet/src/App/app.tsx +++ b/packages/apps/dev-wallet/src/App/app.tsx @@ -1,6 +1,6 @@ import { DatabaseProvider } from '@/modules/db/db.provider'; import { WalletProvider } from '@/modules/wallet/wallet.provider'; -import { MediaContextProvider } from '@kadena/kode-ui'; +import { MediaContextProvider, useTheme } from '@kadena/kode-ui'; import { LayoutProvider } from '@kadena/kode-ui/patterns'; import { useEffect } from 'react'; import { PromptProvider } from '../Components/PromptProvider/Prompt'; @@ -8,6 +8,7 @@ import { Routes } from './routes'; import { SessionProvider } from './session'; function Providers({ children }: { children: React.ReactNode }) { + useTheme(); useEffect(() => { if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'dark'); @@ -15,18 +16,18 @@ function Providers({ children }: { children: React.ReactNode }) { }, []); return ( - - - - - + + + + + {/* TODO: fixed the issue with prompt and remove this one in favor of the one above */} {children} - - - - - + + + + + ); } diff --git a/packages/apps/dev-wallet/src/App/session.tsx b/packages/apps/dev-wallet/src/App/session.tsx index 0488186e6b..e707ec3cf1 100644 --- a/packages/apps/dev-wallet/src/App/session.tsx +++ b/packages/apps/dev-wallet/src/App/session.tsx @@ -1,4 +1,6 @@ +import { BootContent } from '@/Components/BootContent/BootContent'; import { Session } from '@/utils/session'; +import { Text } from '@kadena/kode-ui'; import { FC, PropsWithChildren, @@ -42,7 +44,13 @@ export const SessionProvider: FC = ({ children }) => { return ( - {loaded ? children : 'Loading session...'} + {loaded ? ( + children + ) : ( + + Loading session... + + )} ); }; diff --git a/packages/apps/dev-wallet/src/Components/BootContent/BootContent.tsx b/packages/apps/dev-wallet/src/Components/BootContent/BootContent.tsx new file mode 100644 index 0000000000..d5e529d18b --- /dev/null +++ b/packages/apps/dev-wallet/src/Components/BootContent/BootContent.tsx @@ -0,0 +1,16 @@ +import { Stack } from '@kadena/kode-ui'; +import { createPortal } from 'react-dom'; + +const loadingContent = document.getElementById('loading-content'); + +export function BootContent({ children }: { children: React.ReactNode }) { + if (!loadingContent) { + return children; + } + return createPortal( + + {children} + , + loadingContent, + ); +} diff --git a/packages/apps/dev-wallet/src/Components/ProfileChanger/ProfileChanger.tsx b/packages/apps/dev-wallet/src/Components/ProfileChanger/ProfileChanger.tsx index e2e745b1b8..80679aafb3 100644 --- a/packages/apps/dev-wallet/src/Components/ProfileChanger/ProfileChanger.tsx +++ b/packages/apps/dev-wallet/src/Components/ProfileChanger/ProfileChanger.tsx @@ -1,5 +1,6 @@ import { useWallet } from '@/modules/wallet/wallet.hook'; -import { IProfilePicked, unlockWithWebAuthn } from '@/utils/unlockWithWebAuthn'; +import { IProfile } from '@/modules/wallet/wallet.repository'; +import { getWebAuthnPass } from '@/modules/wallet/wallet.service'; import { MonoMoreHoriz } from '@kadena/kode-icons/system'; import { Button, @@ -26,9 +27,12 @@ export const ProfileChanger: FC = () => { } = useWallet(); const navigate = useNavigate(); - const handleSelect = async (profile: IProfilePicked) => { + const handleSelect = async (profile: Pick) => { if (profile.options.authMode === 'WEB_AUTHN') { - await unlockWithWebAuthn(profile, unlockProfile); + const pass = await getWebAuthnPass(profile); + if (pass) { + await unlockProfile(profile.uuid, pass); + } } else { navigate(`/unlock-profile/${profile.uuid}`); } diff --git a/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx b/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx index c5ebe01408..0c30dd2c9f 100644 --- a/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx +++ b/packages/apps/dev-wallet/src/Components/UnlockPrompt/UnlockPrompt.tsx @@ -16,6 +16,7 @@ import { unlockPrompt } from './style.css.ts'; export const UnlockPrompt: React.FC<{ showPassword?: boolean; rememberPassword?: IProfile['options']['rememberPassword']; + profile: IProfile; resolve: ({ password, keepOpen, @@ -24,7 +25,7 @@ export const UnlockPrompt: React.FC<{ keepOpen: 'session' | 'short-time' | 'never'; }) => void; reject: (reason: any) => void; -}> = ({ resolve, reject, showPassword, rememberPassword }) => { +}> = ({ resolve, reject, showPassword, rememberPassword, profile }) => { const { control, register, handleSubmit } = useForm({ defaultValues: { keepOpen: rememberPassword || 'session', @@ -49,6 +50,12 @@ export const UnlockPrompt: React.FC<{ You need to unlock the security module in order to use it for sensitive actions (e.g. sign or account creation) + + Profile:{' '} + + {profile.name} + + {showPassword && ( { document.body.classList.remove(`boot-theme-${getTheme()}`); }; +const loadingContent = document.getElementById('loading-content'); + // the entry file for the dev wallet app // TODO: we need to do setup app here like service worker, etc async function bootstrap() { + await registerServiceWorker(); addBootTheme(); import('./App/main').then(async ({ renderApp }) => { + if (loadingContent) { + loadingContent.innerHTML = ''; + } renderApp(); globalThis.addEventListener('wallet-loaded', function () { - document.getElementById('welcome-message')?.remove(); + const welcomeMessage = document.getElementById('welcome-message'); + if (welcomeMessage) { + welcomeMessage.style.display = 'none'; + } removeBootTheme(); }); }); @@ -27,4 +36,41 @@ async function bootstrap() { }, 200); } +async function registerServiceWorker() { + if (loadingContent) { + loadingContent.innerHTML = 'Loading Service Worker...'; + } + if ('serviceWorker' in navigator) { + await navigator.serviceWorker + .register('/sw.js') + .then((registration) => { + console.log( + 'Service Worker registered with scope:', + registration.scope, + ); + if (loadingContent) { + loadingContent.innerHTML = + '
Service Worker registered!
Loading components...
'; + } + registration.onupdatefound = () => { + const newWorker = registration.installing; + if (!newWorker) return; + newWorker.onstatechange = () => { + if (newWorker.state === 'activated') { + // If the service worker is activated, reload the page + window.location.reload(); + } + }; + }; + }) + .catch((error) => { + if (loadingContent) { + loadingContent.innerHTML = + '
Service Worker registration failed!
using fallback mode
Loading components...
'; + } + console.error('Service Worker registration failed:', error); + }); + } +} + bootstrap(); diff --git a/packages/apps/dev-wallet/src/modules/account/account.repository.ts b/packages/apps/dev-wallet/src/modules/account/account.repository.ts index 777e61d19d..9865f12cbc 100644 --- a/packages/apps/dev-wallet/src/modules/account/account.repository.ts +++ b/packages/apps/dev-wallet/src/modules/account/account.repository.ts @@ -36,6 +36,7 @@ export interface IAccount { }>; keyset?: IKeySet; alias?: string; + syncTime?: number; } export type IWatchedAccount = Omit & { @@ -72,7 +73,7 @@ const createAccountRepository = ({ ...account, keyset: await getKeyset(account.keysetId), }); - return { + const actions = { async getKeysetByPrincipal( principal: string, profileId: string, @@ -148,6 +149,28 @@ const createAccountRepository = ({ updateWatchedAccount: async (account: IWatchedAccount): Promise => { return update('watched-account', account); }, + + patchAccount: async ( + uuid: string, + patch: Partial, + ): Promise => { + const account = await actions.getAccount(uuid); + const updatedAccount = { ...account, ...patch }; + await actions.updateAccount(updatedAccount); + return updatedAccount; + }, + patchWatchedAccount: async ( + uuid: string, + patch: Partial, + ): Promise => { + const account = (await getOne( + 'watched-account', + uuid, + )) as IWatchedAccount; + const updatedAccount = { ...account, ...patch }; + await actions.updateWatchedAccount(updatedAccount); + return updatedAccount; + }, async getWatchedAccountsByProfileId(profileId: string, networkUUID: UUID) { const accounts: Array = await getAll( 'watched-account', @@ -157,6 +180,7 @@ const createAccountRepository = ({ return accounts; }, }; + return actions; }; export const chainIds = [...Array(20).keys()].map((key) => `${key}` as ChainId); diff --git a/packages/apps/dev-wallet/src/modules/account/account.service.ts b/packages/apps/dev-wallet/src/modules/account/account.service.ts index 168ecc0753..597ac667d7 100644 --- a/packages/apps/dev-wallet/src/modules/account/account.service.ts +++ b/packages/apps/dev-wallet/src/modules/account/account.service.ts @@ -29,6 +29,7 @@ import { genKeyPair } from '@kadena/cryptography-utils'; import { transactionRepository } from '../transaction/transaction.repository'; import type { IKeyItem, IKeySource } from '../wallet/wallet.repository'; +import { config } from '@/config'; import * as transactionService from '@/modules/transaction/transaction.service'; import { execution } from '@kadena/client/fp'; import { INetwork, networkRepository } from '../network/network.repository'; @@ -221,49 +222,45 @@ const hasSameGuard = (a?: IKeySet['guard'], b?: IKeySet['guard']) => { }; export const syncAccount = async (account: IAccount | IWatchedAccount) => { - console.log('syncing account', account.address); const network = await networkRepository.getNetwork(account.networkUUID); - const updatedAccount = { ...account }; + const patch: Partial = {}; const chainResult = (await discoverAccount( - updatedAccount.address, + account.address, network.networkId, undefined, - updatedAccount.contract, + account.contract, ) .execute() .catch((error) => console.error('DISCOVERY ERROR', error), )) as IDiscoveredAccount[]; - console.log('chainResult', account.address, chainResult); - const filteredResult = chainResult.filter( ({ result }) => Boolean(result) && hasSameGuard(result?.guard, account.keyset?.guard), ); - updatedAccount.chains = filteredResult.map(({ chainId, result }) => ({ + patch.chains = filteredResult.map(({ chainId, result }) => ({ chainId, balance: result!.balance ? new PactNumber(result!.balance).toString() : '0', })); - updatedAccount.overallBalance = filteredResult.reduce( + patch.overallBalance = filteredResult.reduce( (acc, { result }) => result?.balance ? new PactNumber(result.balance).plus(acc).toDecimal() : acc, '0', ); - if ('watched' in updatedAccount) { - if (updatedAccount.watched) { - await accountRepository.updateWatchedAccount(updatedAccount); - } - } else { - await accountRepository.updateAccount(updatedAccount as IAccount); + patch.syncTime = Date.now(); + if ('watched' in account && account.watched) { + return accountRepository.patchWatchedAccount(account.uuid, patch); } - console.log('updated account', updatedAccount); - return updatedAccount; + return accountRepository.patchAccount( + account.uuid, + patch as Partial, + ); }; export const syncAllAccounts = async (profileId: string, networkUUID: UUID) => { @@ -275,15 +272,27 @@ export const syncAllAccounts = async (profileId: string, networkUUID: UUID) => { profileId, networkUUID, ); - console.log('syncing accounts', accounts); // sync all accounts sequentially to avoid rate limiting const result = []; - for (const account of accounts) { - result.push(await syncAccount(account)); - } - for (const account of watchedAccounts) { + const now = Date.now(); + const accountsToSync = [...accounts, ...watchedAccounts].filter( + (account) => + !account.syncTime || + account.syncTime < now - config.ACCOUNTS.SYNC_INTERVAL, + ); + + await Promise.all( + accountsToSync.map((account) => + 'watched' in account + ? accountRepository.patchWatchedAccount(account.uuid, { syncTime: now }) + : accountRepository.patchAccount(account.uuid, { syncTime: now }), + ), + ); + + for (const account of accountsToSync) { result.push(await syncAccount(account)); } + return result; }; diff --git a/packages/apps/dev-wallet/src/modules/db/db.provider.tsx b/packages/apps/dev-wallet/src/modules/db/db.provider.tsx index 8398f1ad99..e2f36fe26f 100644 --- a/packages/apps/dev-wallet/src/modules/db/db.provider.tsx +++ b/packages/apps/dev-wallet/src/modules/db/db.provider.tsx @@ -1,13 +1,22 @@ +import { BootContent } from '@/Components/BootContent/BootContent'; import { sleep } from '@/utils/helpers'; +import { Stack, Text } from '@kadena/kode-ui'; import { FC, ReactNode, useEffect, useState } from 'react'; import { addDefaultFungibles } from '../account/account.repository'; import { addDefaultNetworks } from '../network/network.repository'; import { closeDatabaseConnections, setupDatabase } from './db.service'; const renderDefaultError = ({ message }: Error) => ( -
- DataBase Error happens;
{message} -
+ + + DataBase Error! + {message} + + ); export const DatabaseProvider: FC<{ @@ -16,7 +25,7 @@ export const DatabaseProvider: FC<{ renderError?: (error: Error) => ReactNode; }> = ({ children, - fallback = 'initializing database', + fallback = initializing database ..., renderError = renderDefaultError, }) => { const [initialized, setInitialized] = useState(false); @@ -61,8 +70,8 @@ export const DatabaseProvider: FC<{ }, []); if (errorObject) { - return renderError(errorObject); + return {renderError(errorObject)}; } - return initialized ? children : fallback; + return initialized ? children : {fallback}; }; diff --git a/packages/apps/dev-wallet/src/modules/db/db.service.tsx b/packages/apps/dev-wallet/src/modules/db/db.service.tsx index b3d45ed409..4e365db0e5 100644 --- a/packages/apps/dev-wallet/src/modules/db/db.service.tsx +++ b/packages/apps/dev-wallet/src/modules/db/db.service.tsx @@ -191,6 +191,11 @@ export interface IDBService { subscribe: ISubscribe; } +const dbChannel = new BroadcastChannel('db-channel'); +const broadcast = (event: EventTypes, storeName: string, data: any[]) => { + dbChannel.postMessage({ type: event, storeName, data }); +}; + export const createDbService = () => { const listeners: Listener[] = []; const subscribe: ISubscribe = ( @@ -222,11 +227,22 @@ export const createDbService = () => { } }; }; + + dbChannel.onmessage = (event) => { + const { + type, + storeName, + data, + }: { type: EventTypes; storeName: string; data: any[] } = event.data; + listeners.forEach((cb) => cb(type, storeName, ...data)); + }; + const notify = (event: EventTypes) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (storeName: string, ...rest: any[]) => { listeners.forEach((cb) => cb(event, storeName, ...rest)); + broadcast(event, storeName, rest); }; return { getAll: injectDb(getAllItems), diff --git a/packages/apps/dev-wallet/src/modules/security/fallback.service.ts b/packages/apps/dev-wallet/src/modules/security/fallback.service.ts new file mode 100644 index 0000000000..73cafe093a --- /dev/null +++ b/packages/apps/dev-wallet/src/modules/security/fallback.service.ts @@ -0,0 +1,64 @@ +import { ISetSecurityPhrase } from '@/service-worker/types'; +import { kadenaDecrypt, kadenaEncrypt, randomBytes } from '@kadena/hd-wallet'; + +export interface SecureContext { + encryptionKey: Uint8Array; + encryptionPhrase: Uint8Array; + keepPolicy: 'session' | 'short-time' | 'never'; + ttl?: number; +} + +export function fallbackSecurityService() { + let context: SecureContext | null = null; + let clearTimer: NodeJS.Timeout | null = null; + console.log('Service Worker is not available, using fallback service'); + + async function setSecurityPhrase({ + phrase, + keepPolicy, + ttl, + }: { + phrase: string; + keepPolicy: ISetSecurityPhrase['payload']['keepPolicy']; + ttl?: number; + }) { + if (clearTimer) { + clearTimeout(clearTimer); + clearTimer = null; + } + if (keepPolicy === 'never') { + return { result: 'success' }; + } + const encryptionKey = randomBytes(32); + context = { + encryptionKey, + encryptionPhrase: await kadenaEncrypt(encryptionKey, phrase, 'buffer'), + keepPolicy: keepPolicy, + }; + if (context.keepPolicy === 'short-time') { + clearTimer = setTimeout( + () => { + context = null; + }, + ttl || 5 * 60 * 1000, + ); + } + return { result: 'success' }; + } + + async function getSecurityPhrase() { + if (!context) { + return null; + } + return new TextDecoder().decode( + await kadenaDecrypt(context.encryptionKey, context.encryptionPhrase), + ); + } + + async function clearSecurityPhrase() { + context = null; + return { result: 'success' }; + } + + return { setSecurityPhrase, getSecurityPhrase, clearSecurityPhrase }; +} diff --git a/packages/apps/dev-wallet/src/modules/security/security.service.ts b/packages/apps/dev-wallet/src/modules/security/security.service.ts new file mode 100644 index 0000000000..3e2a9283ee --- /dev/null +++ b/packages/apps/dev-wallet/src/modules/security/security.service.ts @@ -0,0 +1,43 @@ +import { + IGetPhraseResponse, + ISetPhraseResponse, + ISetSecurityPhrase, +} from '@/service-worker/types'; +import { sendMessageToServiceWorker } from '@/utils/service-worker-com'; +import { fallbackSecurityService } from './fallback.service'; + +async function setSecurityPhrase({ + phrase, + keepPolicy, + ttl, +}: { + phrase: string; + keepPolicy: ISetSecurityPhrase['payload']['keepPolicy']; + ttl?: number; +}) { + const { result } = (await sendMessageToServiceWorker({ + action: 'setSecurityPhrase', + payload: { phrase, keepPolicy, ttl }, + })) as ISetPhraseResponse; + + return { result }; +} + +async function getSecurityPhrase() { + const { phrase } = (await sendMessageToServiceWorker({ + action: 'getSecurityPhrase', + })) as IGetPhraseResponse; + return phrase; +} + +async function clearSecurityPhrase() { + const { result } = (await sendMessageToServiceWorker({ + action: 'clearSecurityPhrase', + })) as ISetPhraseResponse; + + return { result }; +} + +export const securityService = !navigator.serviceWorker.controller + ? fallbackSecurityService() + : { setSecurityPhrase, getSecurityPhrase, clearSecurityPhrase }; diff --git a/packages/apps/dev-wallet/src/modules/wallet/wallet.hook.tsx b/packages/apps/dev-wallet/src/modules/wallet/wallet.hook.tsx index 207c68e64c..203f156e32 100644 --- a/packages/apps/dev-wallet/src/modules/wallet/wallet.hook.tsx +++ b/packages/apps/dev-wallet/src/modules/wallet/wallet.hook.tsx @@ -11,7 +11,12 @@ import { BIP44Service } from '../key-source/hd-wallet/BIP44'; import { ChainweaverService } from '../key-source/hd-wallet/chainweaver'; import { keySourceManager } from '../key-source/key-source-manager'; import { INetwork } from '../network/network.repository'; -import { ExtWalletContextType, WalletContext } from './wallet.provider'; +import { securityService } from '../security/security.service'; +import { + channel, + ExtWalletContextType, + WalletContext, +} from './wallet.provider'; import { IKeyItem, IKeySource, IProfile } from './wallet.repository'; import * as WalletService from './wallet.service'; @@ -61,8 +66,11 @@ export const useWallet = () => { async (profileId: string, password: string) => { console.log('unlockProfile', profileId, password); const profile = await WalletService.unlockProfile(profileId, password); + await securityService.clearSecurityPhrase(); if (profile) { - return setProfile(profile); + const res = await setProfile(profile); + channel.postMessage({ action: 'switch-profile', payload: profile }); + return res; } return null; }, @@ -70,12 +78,18 @@ export const useWallet = () => { ); const lockProfile = useCallback(() => { - setProfile(undefined); + const run = async () => { + await securityService.clearSecurityPhrase(); + await setProfile(undefined); + channel.postMessage({ action: 'switch-profile', payload: undefined }); + }; + run(); }, [setProfile]); const unlockKeySource = useCallback( async (keySource: IKeySource) => { const password = await askForPassword(); + console.log('unlockKeySource', keySource, password); if (!password) { throw new Error('Password is required'); } diff --git a/packages/apps/dev-wallet/src/modules/wallet/wallet.provider.tsx b/packages/apps/dev-wallet/src/modules/wallet/wallet.provider.tsx index cb3e1e575d..3896208ae9 100644 --- a/packages/apps/dev-wallet/src/modules/wallet/wallet.provider.tsx +++ b/packages/apps/dev-wallet/src/modules/wallet/wallet.provider.tsx @@ -11,11 +11,10 @@ import { import { useSession } from '@/App/session'; import { usePrompt } from '@/Components/PromptProvider/Prompt'; import { UnlockPrompt } from '@/Components/UnlockPrompt/UnlockPrompt'; -import { throttle } from '@/utils/session'; -import { recoverPublicKey, retrieveCredential } from '@/utils/webAuthn'; +import { ISetPhraseResponse, ISetSecurityPhrase } from '@/service-worker/types'; +import { Session, throttle } from '@/utils/session'; import { IClient, createClient } from '@kadena/client'; import { setGlobalConfig } from '@kadena/client-utils/core'; -import { kadenaDecrypt, kadenaEncrypt, randomBytes } from '@kadena/hd-wallet'; import { Fungible, IAccount, @@ -29,6 +28,7 @@ import { dbService } from '../db/db.service'; import { keySourceManager } from '../key-source/key-source-manager'; import { INetwork, networkRepository } from '../network/network.repository'; import { hostUrlGenerator } from '../network/network.service'; +import { securityService } from '../security/security.service'; import { IKeySource, IProfile, walletRepository } from './wallet.repository'; import * as WalletService from './wallet.service'; @@ -66,37 +66,34 @@ export const WalletContext = createContext< export const syncAllAccounts = throttle(AccountService.syncAllAccounts, 10000); function usePassword(profile: IProfile | undefined) { - const ref = useRef({ - encryptionKey: null as Uint8Array | null, - encryptedPassword: null as Uint8Array | null, - }); + const profileRef = useRef(profile); + profileRef.current = profile; const prompt = usePrompt(); const getPassword = useCallback(async () => { - const { encryptionKey, encryptedPassword } = ref.current; - if (!encryptionKey || !encryptedPassword) { - return null; - } - return new TextDecoder().decode( - await kadenaDecrypt(encryptionKey, encryptedPassword), - ); - }, []); - - const setPassword = useCallback(async (password: string) => { - const encryptionKey = randomBytes(32); - const encryptedPassword = await kadenaEncrypt( - encryptionKey, - password, - 'buffer', - ); - ref.current = { encryptionKey, encryptedPassword }; + const phrase = await securityService.getSecurityPhrase(); + return phrase; }, []); - const clearContext = useCallback(() => { - ref.current = { encryptionKey: null, encryptedPassword: null }; - }, []); + const setPassword = useCallback( + async ( + password: string, + keepPolicy: ISetSecurityPhrase['payload']['keepPolicy'], + ) => { + const { result } = (await securityService.setSecurityPhrase({ + phrase: password, + keepPolicy, + })) as ISetPhraseResponse; + if (result !== 'success') { + throw new Error('Failed to set password'); + } + }, + [], + ); const askForPassword = useCallback( async (force = false): Promise => { + const profile = profileRef.current; + console.log('asking for password', profile); if (!force) { const password = await getPassword(); if (password) { @@ -106,117 +103,81 @@ function usePassword(profile: IProfile | undefined) { if (!profile) { return null; } - let unlockOptions: { + const storeData = async (unlockOptions: { password: string; keepOpen: 'session' | 'short-time' | 'never'; + }) => { + if (!unlockOptions.password) { + return null; + } + const result = await WalletService.unlockProfile( + profile.uuid, + unlockOptions.password, + ); + if (!result) { + throw new Error('Failed to unlock profile'); + } + if (profile.options.rememberPassword !== unlockOptions.keepOpen) { + walletRepository.updateProfile({ + ...profile, + options: { + ...profile.options, + rememberPassword: unlockOptions.keepOpen, + }, + }); + } + if (unlockOptions.keepOpen === 'never') { + return unlockOptions.password; + } + await setPassword(unlockOptions.password, unlockOptions.keepOpen); }; switch (profile.options.authMode) { case 'PASSWORD': { - unlockOptions = (await prompt((resolve, reject) => ( + return (await prompt((resolve, reject) => ( { + try { + await storeData(unlockOptions); + resolve(unlockOptions.password); + } catch (e) { + reject(e); + } + }} reject={reject} showPassword rememberPassword={profile.options.rememberPassword} + profile={profile} /> - ))) as { - password: string; - keepOpen: 'session' | 'short-time' | 'never'; - }; - if (!unlockOptions.password) { - return null; - } - const result = await WalletService.unlockProfile( - profile.uuid, - unlockOptions.password, - ); - if (!result) { - throw new Error('Failed to unlock profile'); - } - break; + ))) as string; } case 'WEB_AUTHN': { - const webAuthnUnlock = async ({ - keepOpen, - }: { - keepOpen: 'session' | 'short-time' | 'never'; - }) => { - const credentialId = - profile.options.authMode === 'WEB_AUTHN' - ? profile.options.webAuthnCredential - : null; - if (!credentialId) { - return null; - } - const credential = await retrieveCredential(credentialId); - if (!credential) { - return null; - } - const keys = await recoverPublicKey(credential); - for (const key of keys) { - const result = await WalletService.unlockProfile( - profile.uuid, - key, - ); - if (result) { - return { password: key, keepOpen }; - } - } - throw new Error('Failed to unlock profile'); - }; - unlockOptions = (await prompt((resolve, reject) => ( + return (await prompt((resolve, reject) => ( - webAuthnUnlock(data).then(resolve).catch(reject) - } + resolve={async ({ keepOpen }) => { + try { + const pass = await WalletService.getWebAuthnPass(profile); + if (!pass) reject('Failed to unlock profile'); + const unlockOptions = { password: pass, keepOpen }; + await storeData(unlockOptions); + resolve(unlockOptions.password); + } catch (e) { + reject(e); + } + }} reject={reject} rememberPassword={profile.options.rememberPassword} + profile={profile} /> - ))) as { - password: string; - keepOpen: 'session' | 'short-time' | 'never'; - }; - if (!unlockOptions.password) { - return null; - } - break; + ))) as string; } default: { throw new Error('Unsupported auth mode'); } } - if (profile.options.rememberPassword !== unlockOptions.keepOpen) { - walletRepository.updateProfile({ - ...profile, - options: { - ...profile.options, - rememberPassword: unlockOptions.keepOpen, - }, - }); - } - if (unlockOptions.keepOpen === 'never') { - return unlockOptions.password; - } - await setPassword(unlockOptions.password); - if (unlockOptions.keepOpen === 'short-time') { - setTimeout( - () => { - console.log('clearing password', unlockOptions.keepOpen); - clearContext(); - }, - 1000 * 60 * 5, - ); - } - console.log('unlockOptions', unlockOptions); - return unlockOptions.password; }, - [profile, getPassword, prompt, setPassword, clearContext], + [getPassword, prompt, setPassword], ); - useEffect(() => { - clearContext(); - }, [profile?.securityPhraseId, clearContext]); - return askForPassword; } @@ -250,10 +211,37 @@ const resetProfileRelatedData = ( keysets: [], }); +export const channel = new BroadcastChannel('profile-activity'); + export const WalletProvider: FC = ({ children }) => { const [contextValue, setContextValue] = useState(() => getDefaultContext(), ); + + useEffect(() => { + channel.onmessage = (event) => { + const { action, payload } = event.data; + if (action === 'switch-profile') { + setProfile(payload); + } + }; + return () => { + // channel.close(); + }; + }, []); + + useEffect(() => { + const unsubscribe = Session.subscribe((event) => { + if (event === 'expired' && contextValue.profile) { + setProfile(undefined); + channel.postMessage({ action: 'switch-profile', payload: undefined }); + } + }); + return () => { + unsubscribe(); + }; + }, [contextValue.profile?.uuid]); + const session = useSession(); const askForPassword = usePassword(contextValue.profile); @@ -356,11 +344,11 @@ export const WalletProvider: FC = ({ children }) => { // subscribe to db changes and update the context useEffect(() => { const unsubscribe = dbService.subscribe((event, storeName, data) => { - if (!['add', 'update', 'delete'].includes(event)) return; const profileId = data && typeof data === 'object' && 'profileId' in data ? data.profileId : contextValue.profile?.uuid; + if (profileId && profileId !== contextValue.profile?.uuid) return; // update the context when the db changes switch (storeName) { case 'profile': { @@ -426,7 +414,9 @@ export const WalletProvider: FC = ({ children }) => { const setProfile = useCallback( async (profile: IProfile | undefined, noSession = false) => { + console.log('setting profile', profile); if (!profile) { + console.log('resetting profile'); keySourceManager.reset(); session.clear(); setContextValue(resetProfileRelatedData); diff --git a/packages/apps/dev-wallet/src/modules/wallet/wallet.service.ts b/packages/apps/dev-wallet/src/modules/wallet/wallet.service.ts index bc8d0fe65a..a328a02db2 100644 --- a/packages/apps/dev-wallet/src/modules/wallet/wallet.service.ts +++ b/packages/apps/dev-wallet/src/modules/wallet/wallet.service.ts @@ -1,3 +1,4 @@ +import { recoverPublicKey, retrieveCredential } from '@/utils/webAuthn'; import { addSignatures, ICommand, @@ -162,3 +163,24 @@ export async function decryptSecret(password: string, secretId: string) { const mnemonic = new TextDecoder().decode(decryptedBuffer); return mnemonic; } + +export const getWebAuthnPass = async ( + profile: Pick, +) => { + if (profile.options.authMode !== 'WEB_AUTHN') { + throw new Error('Profile does not support WebAuthn'); + } + const credentialId = profile.options.webAuthnCredential; + const credential = await retrieveCredential(credentialId); + if (!credential) { + throw new Error('Failed to retrieve credential'); + } + const keys = await recoverPublicKey(credential); + for (const key of keys) { + const result = await unlockProfile(profile.uuid, key); + if (result) { + return key; + } + } + console.error('Failed to unlock profile'); +}; diff --git a/packages/apps/dev-wallet/src/pages/account/account.tsx b/packages/apps/dev-wallet/src/pages/account/account.tsx index 02400acdaa..6e9194c6b5 100644 --- a/packages/apps/dev-wallet/src/pages/account/account.tsx +++ b/packages/apps/dev-wallet/src/pages/account/account.tsx @@ -24,7 +24,7 @@ import { } from '@kadena/kode-icons/system'; import { Button, Heading, Stack, TabItem, Tabs, Text } from '@kadena/kode-ui'; import { SideBarBreadcrumbsItem, useLayout } from '@kadena/kode-ui/patterns'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { noStyleLinkClass, panelClass } from '../home/style.css'; import { linkClass } from '../transfer/style.css'; @@ -46,6 +46,13 @@ export function AccountPage() { const navigate = useNavigate(); + useEffect(() => { + if (account) { + console.log('syncing account', account?.uuid); + syncAccount(account); + } + }, [account?.uuid]); + const keyset = account?.keyset; const asset = fungibles.find((f) => f.contract === account?.contract); const [activities = []] = useAsync(getTransferActivities, [ diff --git a/packages/apps/dev-wallet/src/pages/select-profile/select-profile.tsx b/packages/apps/dev-wallet/src/pages/select-profile/select-profile.tsx index 8f123f7980..dcab4280fe 100644 --- a/packages/apps/dev-wallet/src/pages/select-profile/select-profile.tsx +++ b/packages/apps/dev-wallet/src/pages/select-profile/select-profile.tsx @@ -1,5 +1,5 @@ import { useWallet } from '@/modules/wallet/wallet.hook'; -import { unlockWithWebAuthn } from '@/utils/unlockWithWebAuthn'; +import { getWebAuthnPass } from '@/modules/wallet/wallet.service'; import { MonoAdd } from '@kadena/kode-icons'; import { Box, Heading, Stack } from '@kadena/kode-ui'; import { tokens } from '@kadena/kode-ui/styles'; @@ -43,8 +43,11 @@ export function SelectProfile() {