diff --git a/pages/_app.js b/pages/_app.js index 6c99fc1fcc..4745e8fe47 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -19,7 +19,7 @@ import 'nprogress/nprogress.css' import { ChainFeeProvider } from '@/components/chain-fee.js' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' -import WalletsProvider from '@/wallets/client/context' +import { WalletsProvider } from '@/wallets/client/hooks' import FaviconProvider from '@/components/favicon' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) diff --git a/pages/wallets/index.js b/pages/wallets/index.js index 98bf14e1f5..57c9098273 100644 --- a/pages/wallets/index.js +++ b/pages/wallets/index.js @@ -1,9 +1,11 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import { Button } from 'react-bootstrap' -import { useWallets, useTemplates, DndProvider, KeyStatus, useWalletsLoading, useKeyError, useWalletsError } from '@/wallets/client/context' +import { + DndProvider, KeyStatus, useWallets, useTemplates, useWalletsLoading, useKeyError, useWalletsError, + usePassphrasePrompt, useShowPassphrase, useSetWalletPriorities +} from '@/wallets/client/hooks' import { WalletCard, WalletLayout, WalletLayoutHeader, WalletLayoutLink, WalletLayoutSubHeader } from '@/wallets/client/components' import styles from '@/styles/wallet.module.css' -import { usePassphrasePrompt, useShowPassphrase, useSetWalletPriorities } from '@/wallets/client/hooks' import { WalletSearch } from '@/wallets/client/components/search' import { useMemo, useState } from 'react' import { walletDisplayName } from '@/wallets/lib/util' diff --git a/wallets/client/components/debug.js b/wallets/client/components/debug.js index 8bc29459f8..4809b289c4 100644 --- a/wallets/client/components/debug.js +++ b/wallets/client/components/debug.js @@ -1,7 +1,8 @@ import { formatBytes } from '@/lib/format' import { useEffect, useState } from 'react' -import { useKeyHash, useKeyUpdatedAt } from '@/wallets/client/context' -import { useDiagnostics, useRemoteKeyHash, useRemoteKeyHashUpdatedAt, useWalletsUpdatedAt } from '@/wallets/client/hooks' +import { + useKeyHash, useKeyUpdatedAt, useDiagnostics, useRemoteKeyHash, useRemoteKeyHashUpdatedAt, useWalletsUpdatedAt +} from '@/wallets/client/hooks' import { timeSince } from '@/lib/time' export function WalletDebugSettings () { diff --git a/wallets/client/components/draggable.js b/wallets/client/components/dnd.js similarity index 94% rename from wallets/client/components/draggable.js rename to wallets/client/components/dnd.js index c53677e8ab..d3c2effc9d 100644 --- a/wallets/client/components/draggable.js +++ b/wallets/client/components/dnd.js @@ -1,4 +1,4 @@ -import { useDndHandlers } from '@/wallets/client/context' +import { useDndHandlers } from '@/wallets/client/hooks' import classNames from 'classnames' import styles from '@/styles/dnd.module.css' diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js index 95e1abd54d..a434f2e854 100644 --- a/wallets/client/components/index.js +++ b/wallets/client/components/index.js @@ -1,5 +1,5 @@ export * from './card' -export * from './draggable' +export * from './dnd' export * from './form/index' export * from './layout' export * from './passphrase' diff --git a/wallets/client/context/index.js b/wallets/client/context/index.js deleted file mode 100644 index c2d2ca8aeb..0000000000 --- a/wallets/client/context/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import WalletsProvider from './provider' - -export * from './provider' -export * from './dnd' -export * from './reducer' - -export default WalletsProvider diff --git a/wallets/client/context/provider.js b/wallets/client/context/provider.js deleted file mode 100644 index d04a0d5194..0000000000 --- a/wallets/client/context/provider.js +++ /dev/null @@ -1,93 +0,0 @@ -import { createContext, useContext, useReducer } from 'react' -import walletsReducer from './reducer' -import { useServerWallets, useAutomatedRetries, useKeyInit, useWalletMigration } from './hooks' -import { WebLnProvider } from '@/wallets/lib/protocols/webln' - -// https://react.dev/learn/scaling-up-with-reducer-and-context -const WalletsContext = createContext(null) -const WalletsDispatchContext = createContext(null) - -export function useWallets () { - const { wallets } = useContext(WalletsContext) - return wallets -} - -export function useTemplates () { - const { templates } = useContext(WalletsContext) - return templates -} - -export function useWalletsLoading () { - const { walletsLoading } = useContext(WalletsContext) - return walletsLoading -} - -export function useWalletsError () { - const { walletsError } = useContext(WalletsContext) - return walletsError -} - -export function useWalletsDispatch () { - return useContext(WalletsDispatchContext) -} - -export function useKey () { - const { key } = useContext(WalletsContext) - return key -} - -export function useKeyHash () { - const { keyHash } = useContext(WalletsContext) - return keyHash -} - -export function useKeyUpdatedAt () { - const { keyUpdatedAt } = useContext(WalletsContext) - return keyUpdatedAt -} - -export function useKeyError () { - const { keyError } = useContext(WalletsContext) - return keyError -} - -export default function WalletsProvider ({ children }) { - const [state, dispatch] = useReducer(walletsReducer, { - wallets: [], - walletsLoading: true, - walletsError: null, - templates: [], - key: null, - keyHash: null, - keyUpdatedAt: null, - keyError: null - }) - - return ( - - - - - {children} - - - - - ) -} - -function WalletHooks ({ children }) { - useServerWallets() - useAutomatedRetries() - useKeyInit() - - // TODO(wallet-v2): remove migration code - // ============================================================= - // ****** Below is the migration code for WALLET v1 -> v2 ****** - // remove when we can assume migration is complete (if ever) - // ============================================================= - - useWalletMigration() - - return children -} diff --git a/wallets/client/context/reducer.js b/wallets/client/context/reducer.js deleted file mode 100644 index 688f82d6ef..0000000000 --- a/wallets/client/context/reducer.js +++ /dev/null @@ -1,64 +0,0 @@ -import { isTemplate, isWallet } from '@/wallets/lib/util' - -export const KeyStatus = { - KEY_STORAGE_UNAVAILABLE: 'KEY_STORAGE_UNAVAILABLE', - WRONG_KEY: 'WRONG_KEY' -} - -// wallet actions -export const SET_WALLETS = 'SET_WALLETS' -export const SET_KEY = 'SET_KEY' -export const WRONG_KEY = 'WRONG_KEY' -export const KEY_MATCH = 'KEY_MATCH' -export const KEY_STORAGE_UNAVAILABLE = 'KEY_STORAGE_UNAVAILABLE' -export const WALLETS_QUERY_ERROR = 'WALLETS_QUERY_ERROR' - -export default function reducer (state, action) { - switch (action.type) { - case SET_WALLETS: { - const wallets = action.wallets - .filter(isWallet) - .sort((a, b) => a.priority === b.priority ? a.id - b.id : a.priority - b.priority) - const templates = action.wallets - .filter(isTemplate) - .sort((a, b) => a.name.localeCompare(b.name)) - return { - ...state, - walletsLoading: false, - walletsError: null, - wallets, - templates - } - } - case WALLETS_QUERY_ERROR: - return { - ...state, - walletsLoading: false, - walletsError: action.error - } - case SET_KEY: - return { - ...state, - key: action.key, - keyHash: action.hash, - keyUpdatedAt: action.updatedAt - } - case WRONG_KEY: - return { - ...state, - keyError: KeyStatus.WRONG_KEY - } - case KEY_MATCH: - return { - ...state, - keyError: null - } - case KEY_STORAGE_UNAVAILABLE: - return { - ...state, - keyError: KeyStatus.KEY_STORAGE_UNAVAILABLE - } - default: - return state - } -} diff --git a/wallets/client/hooks/crypto.js b/wallets/client/hooks/crypto.js index 0b9993407b..4f7b0355ca 100644 --- a/wallets/client/hooks/crypto.js +++ b/wallets/client/hooks/crypto.js @@ -1,15 +1,10 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useMe } from '@/components/me' import { useIndexedDB } from '@/components/use-indexeddb' -import { useShowModal } from '@/components/modal' -import { Button } from 'react-bootstrap' -import { Passphrase } from '@/wallets/client/components' -import bip39Words from '@/lib/bip39-words' -import { Form, PasswordInput, SubmitButton } from '@/components/form' -import { object, string } from 'yup' -import { SET_KEY, useKey, useKeyHash, useWalletsDispatch } from '@/wallets/client/context' -import { useDisablePassphraseExport, useUpdateKeyHash, useWalletEncryptionUpdate, useWalletLogger, useWalletReset } from '@/wallets/client/hooks' -import { useToast } from '@/components/toast' +import { SET_KEY, useKey, useKeyHash, useWalletsDispatch } from '@/wallets/client/hooks/global' +import { useUpdateKeyHash } from '@/wallets/client/hooks/query' +import { useWalletLogger } from '@/wallets/client/hooks/logger' +import { generateRandomPassphrase, deriveKey, decrypt as _decrypt, encrypt as _encrypt } from '@/wallets/lib/crypto' export class CryptoKeyRequiredError extends Error { constructor () { @@ -18,22 +13,14 @@ export class CryptoKeyRequiredError extends Error { } } -export function useLoadKey () { - const { get } = useIndexedDB() - - return useCallback(async () => { - return await get('vault', 'key') - }, [get]) -} - -export function useLoadOldKey () { +export function useDeleteOldDb () { const { me } = useMe() const oldDbName = me?.id ? `app:storage:${me?.id}:vault` : undefined - const { get } = useIndexedDB(oldDbName) + const { deleteDb } = useIndexedDB(oldDbName) return useCallback(async () => { - return await get('vault', 'key') - }, [get]) + return await deleteDb() + }, [deleteDb]) } export function useSetKey () { @@ -107,253 +94,6 @@ export function useKeySalt () { return `stacker${me?.id}` } -export function useShowPassphrase () { - const { me } = useMe() - const showModal = useShowModal() - const generateRandomKey = useGenerateRandomKey() - const updateWalletEncryption = useWalletEncryptionUpdate() - const toaster = useToast() - - const onShow = useCallback(async () => { - let passphrase, key, hash - try { - ({ passphrase, key, hash } = await generateRandomKey()) - await updateWalletEncryption({ key, hash }) - } catch (err) { - toaster.danger('failed to update wallet encryption: ' + err.message) - return - } - showModal( - close => , - { replaceModal: true, keepOpen: true } - ) - }, [showModal, generateRandomKey, updateWalletEncryption, toaster]) - - const cb = useCallback(() => { - showModal(close => ( -
-

- The next screen will show the passphrase that was used to encrypt your wallets. -

-

- You will not be able to see the passphrase again. -

-

- Do you want to see it now? -

-
- - -
-
- )) - }, [showModal, onShow]) - - if (!me || !me.privates?.showPassphrase) { - return null - } - - return cb -} - -export function useSavePassphrase () { - const setKey = useSetKey() - const salt = useKeySalt() - const disablePassphraseExport = useDisablePassphraseExport() - const logger = useWalletLogger() - - return useCallback(async ({ passphrase }) => { - logger.debug('passphrase entered') - const { key, hash } = await deriveKey(passphrase, salt) - await setKey({ key, hash }) - await disablePassphraseExport() - }, [setKey, disablePassphraseExport, logger]) -} - -export function useResetPassphrase () { - const showModal = useShowModal() - const walletReset = useWalletReset() - const generateRandomKey = useGenerateRandomKey() - const setKey = useSetKey() - const toaster = useToast() - const logger = useWalletLogger() - - const resetPassphrase = useCallback((close) => - async () => { - try { - logger.debug('passphrase reset') - const { key: randomKey, hash } = await generateRandomKey() - await setKey({ key: randomKey, hash }) - await walletReset({ newKeyHash: hash }) - close() - } catch (err) { - logger.debug('failed to reset passphrase: ' + err) - console.error('failed to reset passphrase:', err) - toaster.error('failed to reset passphrase') - } - }, [walletReset, generateRandomKey, setKey, toaster, logger]) - - return useCallback(async () => { - showModal(close => ( -
-

Reset passphrase

-

- This will delete all your sending credentials. Your credentials for receiving will not be affected. -

-

- After the reset, you will be issued a new passphrase. -

-
- - -
-
- )) - }, [showModal, resetPassphrase]) -} - -const passphraseSchema = ({ hash, salt }) => object().shape({ - passphrase: string().required('required') - .test(async (value, context) => { - const { hash: expectedHash } = await deriveKey(value, salt) - if (hash !== expectedHash) { - return context.createError({ message: 'wrong passphrase' }) - } - return true - }) -}) - -export function usePassphrasePrompt () { - const savePassphrase = useSavePassphrase() - const hash = useRemoteKeyHash() - const salt = useKeySalt() - const showPassphrase = useShowPassphrase() - const resetPassphrase = useResetPassphrase() - - const onSubmit = useCallback(async ({ passphrase }) => { - await savePassphrase({ passphrase }) - }, [savePassphrase]) - - const [showPassphrasePrompt, setShowPassphrasePrompt] = useState(false) - const togglePassphrasePrompt = useCallback(() => setShowPassphrasePrompt(v => !v), []) - - const Prompt = useMemo(() => ( -
-

Wallet decryption

-

- Enter your passphrase to decrypt your wallets on this device. -

-

- {showPassphrase && 'The passphrase reveal button is above your wallets on the original device.'} -

-

- Press reset if you lost your passphrase. -

-
- -
-
- - - save -
-
- -
- ), [showPassphrase, resetPassphrase, togglePassphrasePrompt, onSubmit, hash, salt]) - - return useMemo( - () => [showPassphrasePrompt, togglePassphrasePrompt, Prompt], - [showPassphrasePrompt, togglePassphrasePrompt, Prompt] - ) -} - -export async function deriveKey (passphrase, salt) { - const enc = new TextEncoder() - - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - enc.encode(passphrase), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ) - - const key = await window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: enc.encode(salt), - // 600,000 iterations is recommended by OWASP - // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - iterations: 600_000, - hash: 'SHA-256' - }, - keyMaterial, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ) - - const rawKey = await window.crypto.subtle.exportKey('raw', key) - const hash = Buffer.from(await window.crypto.subtle.digest('SHA-256', rawKey)).toString('hex') - const unextractableKey = await window.crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ) - - return { - key: unextractableKey, - hash - } -} - -async function _encrypt ({ key, hash }, value) { - // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure - // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm - // 12 bytes (96 bits) is the recommended IV size for AES-GCM - const iv = window.crypto.getRandomValues(new Uint8Array(12)) - const encoded = new TextEncoder().encode(JSON.stringify(value)) - const encrypted = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv - }, - key, - encoded - ) - return { - keyHash: hash, - iv: Buffer.from(iv).toString('hex'), - value: Buffer.from(encrypted).toString('hex') - } -} - -async function _decrypt (key, { iv, value }) { - const decrypted = await window.crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: Buffer.from(iv, 'hex') - }, - key, - Buffer.from(value, 'hex') - ) - const decoded = new TextDecoder().decode(decrypted) - return JSON.parse(decoded) -} - export function useGenerateRandomKey () { const salt = useKeySalt() @@ -363,9 +103,3 @@ export function useGenerateRandomKey () { return { passphrase, key, hash } }, [salt]) } - -function generateRandomPassphrase () { - const rand = new Uint32Array(12) - window.crypto.getRandomValues(rand) - return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') -} diff --git a/wallets/client/hooks/diagnostics.js b/wallets/client/hooks/diagnostics.js index afeec74fc1..572e79c37f 100644 --- a/wallets/client/hooks/diagnostics.js +++ b/wallets/client/hooks/diagnostics.js @@ -1,8 +1,8 @@ +import { useCallback } from 'react' +import { useMutation } from '@apollo/client' import { useMe } from '@/components/me' import { useToast } from '@/components/toast' import { SET_DIAGNOSTICS } from '@/fragments/users' -import { useMutation } from '@apollo/client' -import { useCallback } from 'react' export function useDiagnostics () { const { me, refreshMe } = useMe() diff --git a/wallets/client/context/dnd.js b/wallets/client/hooks/dnd.js similarity index 100% rename from wallets/client/context/dnd.js rename to wallets/client/hooks/dnd.js diff --git a/wallets/client/context/hooks.js b/wallets/client/hooks/global.js similarity index 54% rename from wallets/client/context/hooks.js rename to wallets/client/hooks/global.js index 505bb50c29..3684b568e8 100644 --- a/wallets/client/context/hooks.js +++ b/wallets/client/hooks/global.js @@ -1,17 +1,109 @@ -import { useCallback, useEffect, useState } from 'react' +/** + * This file provides: + * - the global context for the wallets + * - the global hooks that are always mounted like: + * - fetching wallets + * - checking for invoices to retry + * - generating or reading the CryptoKey from IndexedDB if it exists + * - hooks to access the global context + */ +import { createContext, useCallback, useContext, useEffect, useReducer, useState } from 'react' import { useLazyQuery } from '@apollo/client' -import { FAILED_INVOICES } from '@/fragments/invoice' + import { NORMAL_POLL_INTERVAL_MS } from '@/lib/constants' import useInvoice from '@/components/use-invoice' import { useMe } from '@/components/me' -import { - useWalletsQuery, useWalletPayment, useGenerateRandomKey, useSetKey, useLoadKey, useLoadOldKey, - useWalletMigrationMutation, CryptoKeyRequiredError, useIsWrongKey, - useWalletLogger -} from '@/wallets/client/hooks' -import { WalletConfigurationError } from '@/wallets/client/errors' -import { SET_WALLETS, WRONG_KEY, KEY_MATCH, useWalletsDispatch, WALLETS_QUERY_ERROR, KEY_STORAGE_UNAVAILABLE } from '@/wallets/client/context' import { useIndexedDB } from '@/components/use-indexeddb' +import { FAILED_INVOICES } from '@/fragments/invoice' +import { isTemplate, isWallet } from '@/wallets/lib/util' +import { WebLnProvider } from '@/wallets/lib/protocols/webln' +import { useWalletsQuery } from '@/wallets/client/hooks/query' +import { useWalletPayment } from '@/wallets/client/hooks/payment' +import { useGenerateRandomKey, useSetKey, useIsWrongKey, useDeleteOldDb } from '@/wallets/client/hooks/crypto' +import { useWalletLogger } from '@/wallets/client/hooks/logger' +import { WalletConfigurationError } from '@/wallets/client/errors' + +const WalletsContext = createContext(null) +const WalletsDispatchContext = createContext(null) + +export function useWallets () { + const { wallets } = useContext(WalletsContext) + return wallets +} + +export function useWalletsLoading () { + const { walletsLoading } = useContext(WalletsContext) + return walletsLoading +} + +export function useTemplates () { + const { templates } = useContext(WalletsContext) + return templates +} + +export function useWalletsError () { + const { walletsError } = useContext(WalletsContext) + return walletsError +} + +export function useWalletsDispatch () { + return useContext(WalletsDispatchContext) +} + +export function useKey () { + const { key } = useContext(WalletsContext) + return key +} + +export function useKeyHash () { + const { keyHash } = useContext(WalletsContext) + return keyHash +} + +export function useKeyUpdatedAt () { + const { keyUpdatedAt } = useContext(WalletsContext) + return keyUpdatedAt +} + +export function useKeyError () { + const { keyError } = useContext(WalletsContext) + return keyError +} + +export function WalletsProvider ({ children }) { + // https://react.dev/learn/scaling-up-with-reducer-and-context + const [state, dispatch] = useReducer(walletsReducer, { + wallets: [], + walletsLoading: true, + walletsError: null, + templates: [], + key: null, + keyHash: null, + keyUpdatedAt: null, + keyError: null + }) + + return ( + + + + + {children} + + + + + ) +} + +function WalletHooks ({ children }) { + useServerWallets() + useAutomatedRetries() + useKeyInit() + useDeleteLocalWallets() + + return children +} export function useServerWallets () { const dispatch = useWalletsDispatch() @@ -120,8 +212,7 @@ export function useKeyInit () { const generateRandomKey = useGenerateRandomKey() const setKey = useSetKey() - const loadKey = useLoadKey() - const loadOldKey = useLoadOldKey() + const deleteOldDb = useDeleteOldDb() const [db, setDb] = useState(null) const { open } = useIndexedDB() @@ -150,13 +241,12 @@ export function useKeyInit () { async function keyInit () { try { - // TODO(wallet-v2): remove migration code - // and delete the old IndexedDB after wallet v2 has been released for some time + // delete the old IndexedDB since wallet v2 has been released 2 months ago + await deleteOldDb() - // load old key and create random key before opening transaction in case we need them + // create random key before opening transaction in case we need it // because we can't run async code in a transaction because it will close the transaction // see https://javascript.info/indexeddb#transactions-autocommit - const oldKeyAndHash = await loadOldKey() const { key: randomKey, hash: randomHash } = await generateRandomKey() // run read and write in one transaction to avoid race conditions @@ -176,12 +266,6 @@ export function useKeyInit () { return resolve(read.result) } - if (oldKeyAndHash) { - // return key+hash found in old db - logger.debug('key init: key found in old IndexedDB') - return resolve(oldKeyAndHash) - } - // no key found, write and return generated random key const updatedAt = Date.now() const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash, updatedAt }, 'key') @@ -206,52 +290,82 @@ export function useKeyInit () { } } keyInit() - }, [me?.id, db, generateRandomKey, loadOldKey, setKey, loadKey, logger]) + }, [me?.id, db, deleteOldDb, generateRandomKey, setKey, logger]) } -// TODO(wallet-v2): remove migration code -// ============================================================= -// ****** Below is the migration code for WALLET v1 -> v2 ****** -// remove when we can assume migration is complete (if ever) -// ============================================================= - -export function useWalletMigration () { +export function useDeleteLocalWallets () { const { me } = useMe() - const { migrate: walletMigration, ready } = useWalletMigrationMutation() useEffect(() => { - if (!me?.id || !ready) return - - async function migrate () { - const localWallets = Object.entries(window.localStorage) - .filter(([key]) => key.startsWith('wallet:')) - .filter(([key]) => key.split(':').length < 3 || key.endsWith(me.id)) - .reduce((acc, [key, value]) => { - try { - const config = JSON.parse(value) - acc.push({ key, ...config }) - } catch (err) { - console.error(`useLocalWallets: ${key}: invalid JSON:`, err) - } - return acc - }, []) - - await Promise.allSettled( - localWallets.map(async ({ key, ...localWallet }) => { - const name = key.split(':')[1].toUpperCase() - try { - await walletMigration({ ...localWallet, name }) - window.localStorage.removeItem(key) - } catch (err) { - if (err instanceof CryptoKeyRequiredError) { - // key not set yet, skip this wallet - return - } - console.error(`${name}: wallet migration failed:`, err) - } - }) - ) + if (!me?.id) return + + // we used to store wallets locally so this makes sure we delete them if there are any left over + Object.keys(window.localStorage) + .filter((key) => key.startsWith('wallet:')) + .filter((key) => key.split(':').length < 3 || key.endsWith(me.id)) + .forEach((key) => window.localStorage.removeItem(key)) + }, [me?.id]) +} + +export const KeyStatus = { + KEY_STORAGE_UNAVAILABLE: 'KEY_STORAGE_UNAVAILABLE', + WRONG_KEY: 'WRONG_KEY' +} + +// wallet actions +export const SET_WALLETS = 'SET_WALLETS' +export const SET_KEY = 'SET_KEY' +export const WRONG_KEY = 'WRONG_KEY' +export const KEY_MATCH = 'KEY_MATCH' +export const KEY_STORAGE_UNAVAILABLE = 'KEY_STORAGE_UNAVAILABLE' +export const WALLETS_QUERY_ERROR = 'WALLETS_QUERY_ERROR' + +function walletsReducer (state, action) { + switch (action.type) { + case SET_WALLETS: { + const wallets = action.wallets + .filter(isWallet) + .sort((a, b) => a.priority === b.priority ? a.id - b.id : a.priority - b.priority) + const templates = action.wallets + .filter(isTemplate) + .sort((a, b) => a.name.localeCompare(b.name)) + return { + ...state, + walletsLoading: false, + walletsError: null, + wallets, + templates + } } - migrate() - }, [ready, me?.id, walletMigration]) + case WALLETS_QUERY_ERROR: + return { + ...state, + walletsLoading: false, + walletsError: action.error + } + case SET_KEY: + return { + ...state, + key: action.key, + keyHash: action.hash, + keyUpdatedAt: action.updatedAt + } + case WRONG_KEY: + return { + ...state, + keyError: KeyStatus.WRONG_KEY + } + case KEY_MATCH: + return { + ...state, + keyError: null + } + case KEY_STORAGE_UNAVAILABLE: + return { + ...state, + keyError: KeyStatus.KEY_STORAGE_UNAVAILABLE + } + default: + return state + } } diff --git a/wallets/client/hooks/index.js b/wallets/client/hooks/index.js index 36555adea3..d78fc7bffd 100644 --- a/wallets/client/hooks/index.js +++ b/wallets/client/hooks/index.js @@ -1,3 +1,4 @@ +export * from './global' export * from './payment' export * from './image' export * from './indicator' @@ -7,3 +8,5 @@ export * from './crypto' export * from './query' export * from './logger' export * from './diagnostics' +export * from './passphrase' +export * from './dnd' diff --git a/wallets/client/hooks/indicator.js b/wallets/client/hooks/indicator.js index 53f4a832e7..16e0253ad6 100644 --- a/wallets/client/hooks/indicator.js +++ b/wallets/client/hooks/indicator.js @@ -1,4 +1,4 @@ -import { useWallets, useWalletsLoading } from '@/wallets/client/context' +import { useWallets, useWalletsLoading } from '@/wallets/client/hooks/global' export function useWalletIndicator () { const wallets = useWallets() diff --git a/wallets/client/hooks/passphrase.js b/wallets/client/hooks/passphrase.js new file mode 100644 index 0000000000..f4e465575b --- /dev/null +++ b/wallets/client/hooks/passphrase.js @@ -0,0 +1,183 @@ +import { Form, PasswordInput, SubmitButton } from '@/components/form' +import { useCallback, useMemo, useState } from 'react' +import { Button } from 'react-bootstrap' +import { object, string } from 'yup' +import { Passphrase } from '@/wallets/client/components' +import { useMe } from '@/components/me' +import { useShowModal } from '@/components/modal' +import { useToast } from '@/components/toast' +import { useDisablePassphraseExport, useWalletEncryptionUpdate, useWalletReset } from '@/wallets/client/hooks/query' +import { useWalletLogger } from '@/wallets/client/hooks/logger' +import { useGenerateRandomKey, useKeySalt, useRemoteKeyHash, useSetKey } from '@/wallets/client/hooks/crypto' +import { deriveKey } from '@/wallets/lib/crypto' + +export function useShowPassphrase () { + const { me } = useMe() + const showModal = useShowModal() + const generateRandomKey = useGenerateRandomKey() + const updateWalletEncryption = useWalletEncryptionUpdate() + const toaster = useToast() + + const onShow = useCallback(async () => { + let passphrase, key, hash + try { + ({ passphrase, key, hash } = await generateRandomKey()) + await updateWalletEncryption({ key, hash }) + } catch (err) { + toaster.danger('failed to update wallet encryption: ' + err.message) + return + } + showModal( + close => , + { replaceModal: true, keepOpen: true } + ) + }, [showModal, generateRandomKey, updateWalletEncryption, toaster]) + + const cb = useCallback(() => { + showModal(close => ( +
+

+ The next screen will show the passphrase that was used to encrypt your wallets. +

+

+ You will not be able to see the passphrase again. +

+

+ Do you want to see it now? +

+
+ + +
+
+ )) + }, [showModal, onShow]) + + if (!me || !me.privates?.showPassphrase) { + return null + } + + return cb +} + +function useSavePassphrase () { + const setKey = useSetKey() + const salt = useKeySalt() + const disablePassphraseExport = useDisablePassphraseExport() + const logger = useWalletLogger() + + return useCallback(async ({ passphrase }) => { + logger.debug('passphrase entered') + const { key, hash } = await deriveKey(passphrase, salt) + await setKey({ key, hash }) + await disablePassphraseExport() + }, [setKey, disablePassphraseExport, logger]) +} + +export function useResetPassphrase () { + const showModal = useShowModal() + const walletReset = useWalletReset() + const generateRandomKey = useGenerateRandomKey() + const setKey = useSetKey() + const toaster = useToast() + const logger = useWalletLogger() + + const resetPassphrase = useCallback((close) => + async () => { + try { + logger.debug('passphrase reset') + const { key: randomKey, hash } = await generateRandomKey() + await setKey({ key: randomKey, hash }) + await walletReset({ newKeyHash: hash }) + close() + } catch (err) { + logger.debug('failed to reset passphrase: ' + err) + console.error('failed to reset passphrase:', err) + toaster.error('failed to reset passphrase') + } + }, [walletReset, generateRandomKey, setKey, toaster, logger]) + + return useCallback(async () => { + showModal(close => ( +
+

Reset passphrase

+

+ This will delete all your sending credentials. Your credentials for receiving will not be affected. +

+

+ After the reset, you will be issued a new passphrase. +

+
+ + +
+
+ )) + }, [showModal, resetPassphrase]) +} + +const passphraseSchema = ({ hash, salt }) => object().shape({ + passphrase: string().required('required') + .test(async (value, context) => { + const { hash: expectedHash } = await deriveKey(value, salt) + if (hash !== expectedHash) { + return context.createError({ message: 'wrong passphrase' }) + } + return true + }) +}) + +export function usePassphrasePrompt () { + const savePassphrase = useSavePassphrase() + const hash = useRemoteKeyHash() + const salt = useKeySalt() + const showPassphrase = useShowPassphrase() + const resetPassphrase = useResetPassphrase() + + const onSubmit = useCallback(async ({ passphrase }) => { + await savePassphrase({ passphrase }) + }, [savePassphrase]) + + const [showPassphrasePrompt, setShowPassphrasePrompt] = useState(false) + const togglePassphrasePrompt = useCallback(() => setShowPassphrasePrompt(v => !v), []) + + const Prompt = useMemo(() => ( +
+

Wallet decryption

+

+ Enter your passphrase to decrypt your wallets on this device. +

+

+ {showPassphrase && 'The passphrase reveal button is above your wallets on the original device.'} +

+

+ Press reset if you lost your passphrase. +

+
+ +
+
+ + + save +
+
+ +
+ ), [showPassphrase, resetPassphrase, togglePassphrasePrompt, onSubmit, hash, salt]) + + return useMemo( + () => [showPassphrasePrompt, togglePassphrasePrompt, Prompt], + [showPassphrasePrompt, togglePassphrasePrompt, Prompt] + ) +} diff --git a/wallets/client/hooks/payment.js b/wallets/client/hooks/payment.js index 7c1c5ac0f2..0068211145 100644 --- a/wallets/client/hooks/payment.js +++ b/wallets/client/hooks/payment.js @@ -3,13 +3,13 @@ import { sha256 } from '@noble/hashes/sha2.js' import { useSendProtocols, useWalletLoggerFactory } from '@/wallets/client/hooks' import useInvoice from '@/components/use-invoice' import { FAST_POLL_INTERVAL_MS, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' -import { - AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, - WalletPaymentError, WalletError, WalletReceiverError -} from '@/wallets/client/errors' import { timeoutSignal, withTimeout } from '@/lib/time' -import { useMe } from '@/components/me' import { formatSats } from '@/lib/format' +import { useMe } from '@/components/me' +import { + AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, + WalletPaymentAggregateError, WalletPaymentError, WalletError, WalletReceiverError +} from '@/wallets/client/errors' export function useWalletPayment () { const protocols = useSendProtocols() diff --git a/wallets/client/hooks/prompt.js b/wallets/client/hooks/prompt.js index 51f10b2678..3b70b3d570 100644 --- a/wallets/client/hooks/prompt.js +++ b/wallets/client/hooks/prompt.js @@ -1,13 +1,13 @@ import { useCallback } from 'react' import { boolean, object } from 'yup' import { Button } from 'react-bootstrap' -import { Form, ClientInput, SubmitButton, Checkbox } from '@/components/form' -import { useMe } from '@/components/me' -import { useShowModal } from '@/components/modal' import Link from 'next/link' -import styles from '@/styles/wallet.module.css' import { useMutation } from '@apollo/client' +import styles from '@/styles/wallet.module.css' import { HIDE_WALLET_RECV_PROMPT_MUTATION } from '@/fragments/users' +import { Form, ClientInput, SubmitButton, Checkbox } from '@/components/form' +import { useMe } from '@/components/me' +import { useShowModal } from '@/components/modal' import { useToast } from '@/components/toast' import { useLightningAddressUpsert } from '@/wallets/client/hooks/query' import { protocolClientSchema } from '@/wallets/lib/util' diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js index 13ac561de6..83ece5eac1 100644 --- a/wallets/client/hooks/query.js +++ b/wallets/client/hooks/query.js @@ -1,3 +1,9 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { timeoutSignal } from '@/lib/time' +import { FAST_POLL_INTERVAL_MS, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' +import { useToast } from '@/components/toast' +import { useMe } from '@/components/me' +import { requestPersistentStorage } from '@/components/use-indexeddb' import { UPSERT_WALLET_RECEIVE_BLINK, UPSERT_WALLET_RECEIVE_CLN_REST, @@ -32,19 +38,14 @@ import { DELETE_WALLET } from '@/wallets/client/fragments' import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' -import { useDecryption, useEncryption, useSetKey, useWalletLoggerFactory, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTemplates, useWallets } from '@/wallets/client/hooks/global' +import { useDecryption, useEncryption, useSetKey } from '@/wallets/client/hooks/crypto' +import { useWalletLoggerFactory } from '@/wallets/client/hooks/logger' +import { useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks/wallet' import { - isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, protocolLogName, reverseProtocolRelationName, - walletLud16Domain + isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolLogName, reverseProtocolRelationName, walletLud16Domain } from '@/wallets/lib/util' import { protocolTestSendPayment } from '@/wallets/client/protocols' -import { timeoutSignal } from '@/lib/time' -import { FAST_POLL_INTERVAL_MS, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' -import { useToast } from '@/components/toast' -import { useMe } from '@/components/me' -import { useTemplates, useWallets, useWalletsLoading } from '@/wallets/client/context' -import { requestPersistentStorage } from '@/components/use-indexeddb' export function useWalletsQuery () { const { me } = useMe() @@ -453,71 +454,6 @@ function useEncryptConfig (defaultProtocol, options = {}) { return useMemo(() => ({ encryptConfig, ready }), [encryptConfig, ready]) } -// TODO(wallet-v2): remove migration code -// ============================================================= -// ****** Below is the migration code for WALLET v1 -> v2 ****** -// remove when we can assume migration is complete (if ever) -// ============================================================= - -export function useWalletMigrationMutation () { - const wallets = useWallets() - const loading = useWalletsLoading() - const client = useApolloClient() - const { encryptConfig, ready } = useEncryptConfig() - - // XXX We use a ref for the wallets to avoid duplicate wallets - // Without a ref, the migrate callback would depend on the wallets and thus update every time the migration creates a wallet. - // This update would then cause the useEffect in wallets/client/context/hooks that triggers the migration to run again before the first migration is complete. - const walletsRef = useRef(wallets) - useEffect(() => { - if (!loading) walletsRef.current = wallets - }, [loading]) - - const migrate = useCallback(async ({ name, enabled, ...configV1 }) => { - const protocol = { name, send: true } - - const configV2 = migrateConfig(protocol, configV1) - - const isSameProtocol = (p) => { - const sameName = p.name === protocol.name - const sameSend = p.send === protocol.send - const sameConfig = Object.keys(p.config) - .filter(k => !['__typename', 'id'].includes(k)) - .every(k => p.config[k] === configV2[k]) - return sameName && sameSend && sameConfig - } - - const exists = walletsRef.current.some(w => w.name === name && w.protocols.some(isSameProtocol)) - if (exists) return - - const schema = protocolClientSchema(protocol) - await schema.validate(configV2) - - const encrypted = await encryptConfig(configV2, { protocol }) - - // decide if we create a new wallet (templateName) or use an existing one (walletId) - const templateName = getWalletTemplateName(protocol) - let walletId - const wallet = walletsRef.current.find(w => - w.name === name && !w.protocols.some(p => p.name === protocol.name && p.send) - ) - if (wallet) { - walletId = Number(wallet.id) - } - - await client.mutate({ - mutation: protocolUpsertMutation(protocol), - variables: { - ...(walletId ? { walletId } : { templateName }), - enabled, - ...encrypted - } - }) - }, [client, encryptConfig]) - - return useMemo(() => ({ migrate, ready: ready && !loading }), [migrate, ready, loading]) -} - export function useUpdateKeyHash () { const [mutate] = useMutation(UPDATE_KEY_HASH) @@ -525,55 +461,3 @@ export function useUpdateKeyHash () { await mutate({ variables: { keyHash } }) }, [mutate]) } - -function migrateConfig (protocol, config) { - switch (protocol.name) { - case 'LNBITS': - return { - url: config.url, - apiKey: config.adminKey - } - case 'PHOENIXD': - return { - url: config.url, - apiKey: config.primaryPassword - } - case 'BLINK': - return { - url: config.url, - apiKey: config.apiKey, - currency: config.currency - } - case 'LNC': - return { - pairingPhrase: config.pairingPhrase, - localKey: config.localKey, - remoteKey: config.remoteKey, - serverHost: config.serverHost - } - case 'WEBLN': - return {} - case 'NWC': - return { - url: config.nwcUrl - } - default: - return config - } -} - -function getWalletTemplateName (protocol) { - switch (protocol.name) { - case 'LNBITS': - case 'PHOENIXD': - case 'BLINK': - case 'NWC': - return protocol.name - case 'LNC': - return 'LND' - case 'WEBLN': - return 'ALBY' - default: - return null - } -} diff --git a/wallets/client/hooks/wallet.js b/wallets/client/hooks/wallet.js index 1844745413..86bbab73e4 100644 --- a/wallets/client/hooks/wallet.js +++ b/wallets/client/hooks/wallet.js @@ -1,5 +1,5 @@ import { useMe } from '@/components/me' -import { useWallets } from '@/wallets/client/context' +import { useWallets } from '@/wallets/client/hooks/global' import protocols from '@/wallets/client/protocols' import { isWallet } from '@/wallets/lib/util' import { useMemo } from 'react' diff --git a/wallets/lib/crypto.js b/wallets/lib/crypto.js new file mode 100644 index 0000000000..e59b7fc99b --- /dev/null +++ b/wallets/lib/crypto.js @@ -0,0 +1,83 @@ +import bip39Words from '@/lib/bip39-words' + +export async function deriveKey (passphrase, salt) { + const enc = new TextEncoder() + + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: enc.encode(salt), + // 600,000 iterations is recommended by OWASP + // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + iterations: 600_000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const rawKey = await window.crypto.subtle.exportKey('raw', key) + const hash = Buffer.from(await window.crypto.subtle.digest('SHA-256', rawKey)).toString('hex') + const unextractableKey = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + + return { + key: unextractableKey, + hash + } +} + +export async function encrypt ({ key, hash }, value) { + // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure + // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm + // 12 bytes (96 bits) is the recommended IV size for AES-GCM + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(JSON.stringify(value)) + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encoded + ) + return { + keyHash: hash, + iv: Buffer.from(iv).toString('hex'), + value: Buffer.from(encrypted).toString('hex') + } +} + +export async function decrypt (key, { iv, value }) { + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: Buffer.from(iv, 'hex') + }, + key, + Buffer.from(value, 'hex') + ) + const decoded = new TextDecoder().decode(decrypted) + return JSON.parse(decoded) +} + +export function generateRandomPassphrase () { + const rand = new Uint32Array(12) + window.crypto.getRandomValues(rand) + return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') +}