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.
-
-
-
- ), [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.
+
+
+
+ ), [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(' ')
+}