ekzyis 3f74279f29
Fix useMemo returns new component on change (#2315)
This fixes setting the input value on scan if the component remounts while the scanner is open.

No need to use useRef to close the scanner on remount.
2025-07-23 11:50:39 -05:00

374 lines
11 KiB
JavaScript

import { useCallback, useMemo, useState } from 'react'
import { fromHex, toHex } from '@/lib/hex'
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'
export class CryptoKeyRequiredError extends Error {
constructor () {
super('CryptoKey required')
this.name = 'CryptoKeyRequiredError'
}
}
export function useLoadKey () {
const { get } = useIndexedDB()
return useCallback(async () => {
return await get('vault', 'key')
}, [get])
}
export function useLoadOldKey () {
const { me } = useMe()
const oldDbName = me?.id ? `app:storage:${me?.id}:vault` : undefined
const { get } = useIndexedDB(oldDbName)
return useCallback(async () => {
return await get('vault', 'key')
}, [get])
}
export function useSetKey () {
const { set } = useIndexedDB()
const dispatch = useWalletsDispatch()
const updateKeyHash = useUpdateKeyHash()
const logger = useWalletLogger()
return useCallback(async ({ key, hash, updatedAt }, { updateDb = true } = {}) => {
if (updateDb) {
updatedAt = updatedAt ?? Date.now()
await set('vault', 'key', { key, hash, updatedAt })
}
await updateKeyHash(hash)
dispatch({ type: SET_KEY, key, hash, updatedAt })
logger.debug(`using key ${hash}`)
}, [set, dispatch, updateKeyHash, logger])
}
export function useEncryption () {
const defaultKey = useKey()
const defaultKeyHash = useKeyHash()
const encrypt = useCallback(
(value, { key, hash } = {}) => {
const k = key ?? defaultKey
const h = hash ?? defaultKeyHash
if (!k || !h) throw new CryptoKeyRequiredError()
return _encrypt({ key: k, hash: h }, value)
}, [defaultKey, defaultKeyHash])
return useMemo(() => ({
encrypt,
ready: !!defaultKey
}), [encrypt, defaultKey])
}
export function useDecryption () {
const key = useKey()
const decrypt = useCallback(value => {
if (!key) throw new CryptoKeyRequiredError()
return _decrypt(key, value)
}, [key])
return useMemo(() => ({
decrypt,
ready: !!key
}), [decrypt, key])
}
export function useRemoteKeyHash () {
const { me } = useMe()
return me?.privates?.vaultKeyHash
}
export function useRemoteKeyHashUpdatedAt () {
const { me } = useMe()
return me?.privates?.vaultKeyHashUpdatedAt
}
export function useIsWrongKey () {
const localHash = useKeyHash()
const remoteHash = useRemoteKeyHash()
return localHash && remoteHash && localHash !== remoteHash
}
export function useKeySalt () {
// TODO(wallet-v2): random salt
const { me } = useMe()
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 => <Passphrase passphrase={passphrase} />,
{ replaceModal: true, keepOpen: true }
)
}, [showModal, generateRandomKey, updateWalletEncryption, toaster])
const cb = useCallback(() => {
showModal(close => (
<div>
<p className='line-height-md'>
The next screen will show the passphrase that was used to encrypt your wallets.
</p>
<p className='line-height-md fw-bold'>
You will not be able to see the passphrase again.
</p>
<p className='line-height-md'>
Do you want to see it now?
</p>
<div className='mt-3 d-flex justify-content-between align-items-center'>
<Button variant='grey-medium' onClick={close}>cancel</Button>
<Button variant='danger' onClick={onShow}>yes, show me</Button>
</div>
</div>
))
}, [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 => (
<div>
<h4>Reset passphrase</h4>
<p className='line-height-md fw-bold mt-3'>
This will delete all your sending credentials. Your credentials for receiving will not be affected.
</p>
<p className='line-height-md'>
After the reset, you will be issued a new passphrase.
</p>
<div className='mt-3 d-flex justify-content-end align-items-center'>
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={close}>cancel</Button>
<Button variant='danger' onClick={resetPassphrase(close)}>reset</Button>
</div>
</div>
))
}, [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(() => (
<div>
<h4>Wallet decryption</h4>
<p className='line-height-md mt-3'>
Your wallets have been encrypted on another device. Enter your passphrase to use your wallets on this device.
</p>
<p className='line-height-md'>
{showPassphrase && 'You can find the button to reveal your passphrase above your wallets on the other device.'}
</p>
<p className='line-height-md'>
Press reset if you lost your passphrase.
</p>
<Form
schema={passphraseSchema({ hash, salt })}
initial={{ passphrase: '' }}
onSubmit={onSubmit}
>
<PasswordInput
label='passphrase'
name='passphrase'
placeholder=''
required
autoFocus
qr
/>
<div className='mt-3'>
<div className='d-flex justify-content-between align-items-center'>
<Button className='me-auto' variant='danger' onClick={resetPassphrase}>reset</Button>
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={togglePassphrasePrompt}>cancel</Button>
<SubmitButton variant='primary'>save</SubmitButton>
</div>
</div>
</Form>
</div>
), [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 = toHex(await window.crypto.subtle.digest('SHA-256', rawKey))
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: toHex(iv.buffer),
value: toHex(encrypted)
}
}
async function _decrypt (key, { iv, value }) {
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: fromHex(iv)
},
key,
fromHex(value)
)
const decoded = new TextDecoder().decode(decrypted)
return JSON.parse(decoded)
}
export function useGenerateRandomKey () {
const salt = useKeySalt()
return useCallback(async () => {
const passphrase = generateRandomPassphrase()
const { key, hash } = await deriveKey(passphrase, salt)
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(' ')
}