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 => ,
{ 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
Your wallets have been encrypted on another device. Enter your passphrase to use your wallets on this device.
{showPassphrase && 'You can find the button to reveal your passphrase above your wallets on the other 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 = 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(' ')
}