get vault working

This commit is contained in:
k00b 2024-10-27 02:43:45 -05:00
parent eae4c2b882
commit dce5762f63
19 changed files with 414 additions and 134 deletions

View File

@ -8,10 +8,8 @@ export default {
const k = await models.vault.findUnique({
where: {
userId_key_ownerId_ownerType: {
key,
userId: me.id
}
key,
userId: me.id
}
})
return k
@ -19,7 +17,7 @@ export default {
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
if (!me) throw new GqlAuthenticationError()
const entries = await models.vault.findMany({
const entries = await models.vaultEntry.findMany({
where: {
userId: me.id,
key: keysFilter?.length
@ -54,12 +52,13 @@ export default {
}
for (const entry of entries) {
console.log(entry)
txs.push(models.vaultEntry.update({
where: { userId_key: { userId: me.id, key: entry.key } },
data: { value: entry.value }
data: { value: entry.value, iv: entry.iv }
}))
}
await models.prisma.$transaction(txs)
await models.$transaction(txs)
return true
},
clearVault: async (parent, args, { me, models }) => {
@ -70,7 +69,7 @@ export default {
data: { vaultKeyHash: '' }
}))
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
await models.prisma.$transaction(txs)
await models.$transaction(txs)
return true
}
}

View File

@ -31,9 +31,7 @@ function injectResolvers (resolvers) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
// TODO: our validation should be improved
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries })
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }, { serverSide: true })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
@ -700,22 +698,27 @@ async function upsertWallet (
data: {
enabled,
priority,
[wallet.field]: {
update: {
where: { walletId: Number(id) },
data: walletData
}
},
// client only wallets has no walletData
...(Object.keys(walletData).length > 0
? {
[wallet.field]: {
update: {
where: { walletId: Number(id) },
data: walletData
}
}
}
: {}),
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, value }) => ({
key, value, userId: me.id
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
key, iv, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, value }) => ({
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value }
data: { value, iv }
}))
}
},
@ -735,9 +738,8 @@ async function upsertWallet (
priority,
userId: me.id,
type: wallet.type,
[wallet.field]: {
create: walletData
},
// client only wallets has no walletData
...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}),
vaultEntries: {
createMany: {
data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id }))

View File

@ -4,6 +4,7 @@ export default gql`
type VaultEntry {
id: ID!
key: String!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
@ -11,6 +12,7 @@ export default gql`
input VaultEntryInput {
key: String!
iv: String!
value: String!
walletId: ID
}

View File

@ -15,11 +15,11 @@ import { useApolloClient } from '@apollo/client'
export default function DeviceSync () {
const { me } = useMe()
const apollo = useApolloClient()
const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator()
const { key, setVaultKey, clearVault } = useVaultConfigurator()
const showModal = useShowModal()
const enabled = !!me?.privates?.vaultKeyHash
const connected = !!value?.key
const connected = !!key
const manage = useCallback(async () => {
if (enabled && connected) {
@ -27,7 +27,7 @@ export default function DeviceSync () {
<div>
<h2>Device sync is enabled!</h2>
<p>
Sensitive data (like wallet credentials) is now securely synced between all connected devices.
Sensitive data (like wallet credentials) are now securely synced between all connected devices.
</p>
<p className='text-muted text-sm'>
Disconnect to prevent this device from syncing data or to reset your passphrase.
@ -38,7 +38,7 @@ export default function DeviceSync () {
<Button
variant='primary'
onClick={() => {
disconnectVault()
clearVault()
onClose()
}}
>disconnect
@ -52,7 +52,7 @@ export default function DeviceSync () {
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
))
}
}, [enabled, connected, value])
}, [enabled, connected, key])
const reset = useCallback(async () => {
const schema = yup.object().shape({

View File

@ -37,8 +37,9 @@ import Clipboard from '@/svgs/clipboard-line.svg'
import QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal'
import QRCode from 'qrcode.react'
import { QrScanner } from '@yudiel/react-qr-scanner'
import { QRCodeSVG } from 'qrcode.react'
import { Scanner } from '@yudiel/react-qr-scanner'
import { qrImageSettings } from './qr'
export class SessionRequiredError extends Error {
constructor () {
@ -1069,7 +1070,7 @@ function Client (Component) {
// where the initial value is not available on first render.
// Example: value is stored in localStorage which is fetched
// after first render using an useEffect hook.
const [,, helpers] = useField(props)
const [,, helpers] = props.noForm ? [{}, {}, {}] : useField(props)
useEffect(() => {
initialValue && helpers.setValue(initialValue)
@ -1102,9 +1103,11 @@ function QrPassword ({ value }) {
const showQr = useCallback(() => {
showModal(close => (
<div className={styles.qr}>
<p>You can import this passphrase into another device by scanning this QR code</p>
<QRCode value={value} renderAs='svg' />
<div>
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
</div>
</div>
))
}, [toaster, value, showModal])
@ -1121,10 +1124,9 @@ function QrPassword ({ value }) {
)
}
function PasswordScanner ({ onDecode }) {
function PasswordScanner ({ onScan }) {
const showModal = useShowModal()
const toaster = useToast()
const ref = useRef(false)
return (
<InputGroup.Text
@ -1132,20 +1134,24 @@ function PasswordScanner ({ onDecode }) {
onClick={() => {
showModal(onClose => {
return (
<QrScanner
onDecode={(decoded) => {
onDecode(decoded)
// avoid accidentally calling onClose multiple times
if (ref?.current) return
ref.current = true
onClose({ back: 1 })
<Scanner
formats={['qr_code']}
onScan={([{ rawValue: result }]) => {
onScan(result)
onClose()
}}
styles={{
video: {
aspectRatio: '1 / 1'
}
}}
onError={(error) => {
if (error instanceof DOMException) return
toaster.danger('qr scan error:', error.message || error.toString?.())
onClose({ back: 1 })
if (error instanceof DOMException) {
console.log(error)
} else {
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
}
onClose()
}}
/>
)
@ -1159,9 +1165,9 @@ function PasswordScanner ({ onDecode }) {
)
}
export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) {
export function PasswordInput ({ newPass, qr, copy, readOnly, append, value, ...props }) {
const [showPass, setShowPass] = useState(false)
const [field] = useField(props)
const [field, helpers] = props.noForm ? [{ value }, {}, {}] : useField(props)
const Append = useMemo(() => {
return (
@ -1173,12 +1179,7 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }
{qr && (readOnly
? <QrPassword value={field?.value} />
: <PasswordScanner
onDecode={decoded => {
// Formik helpers don't seem to work in another modal.
// I assume it's because we unmount the Formik component
// when replace it with another modal.
window.localStorage.setItem('qr:passphrase', decoded)
}}
onScan={v => helpers.setValue(v)}
/>)}
{append}
</>

View File

@ -5,6 +5,15 @@ import { useEffect } from 'react'
import { useWallet } from '@/wallets/index'
import Bolt11Info from './bolt11-info'
export const qrImageSettings = {
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
x: undefined,
y: undefined,
height: 60,
width: 60,
excavate: true
}
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
const wallet = useWallet()
@ -26,14 +35,7 @@ export default function Qr ({ asIs, value, useWallet: automated, statusVariant,
<>
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
<QRCodeSVG
className='h-auto mw-100' value={qrValue} size={300} imageSettings={{
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
x: undefined,
y: undefined,
height: 60,
width: 60,
excavate: true
}}
className='h-auto mw-100' value={qrValue} size={300} imageSettings={qrImageSettings}
/>
</a>
{description && <div className='mt-1 text-center text-muted'>{description}</div>}

View File

@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
import { toHex } from '@/lib/hex'
import { decryptData, encryptData } from './use-vault'
import { decryptValue, encryptValue } from './use-vault'
const useImperativeQuery = (query) => {
const { refetch } = useQuery(query, { skip: true })
@ -21,7 +21,7 @@ const useImperativeQuery = (query) => {
export function useVaultConfigurator () {
const { me } = useMe()
const toaster = useToast()
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault' }), [me?.id])
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id])
const { set, get, remove } = useIndexedDB(idbConfig)
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
@ -47,7 +47,7 @@ export function useVaultConfigurator () {
// toaster?.danger('error loading vault configuration ' + e.message)
}
})()
}, [me?.privates?.vaultKeyHash, keyHash, get, remove])
}, [me?.privates?.vaultKeyHash, keyHash, get, remove, setKey])
// clear vault: remove everything and reset the key
const [clearVault] = useMutation(CLEAR_VAULT, {
@ -62,6 +62,12 @@ export function useVaultConfigurator () {
}
})
const disconnectVault = useCallback(async () => {
await remove('key')
setKey(null)
setKeyHash(null)
}, [remove, setKey, setKeyHash])
// initialize the vault and set a vault key
const setVaultKey = useCallback(async (passphrase) => {
try {
@ -69,10 +75,12 @@ export function useVaultConfigurator () {
const vaultKey = await deriveKey(me.id, passphrase)
const { data } = await getVaultEntries()
// TODO: push any local configurations to the server so long as they don't conflict
// delete any locally stored configurations after vault key is set
const entries = []
for (const entry of data.getVaultEntries) {
entry.value = await decryptData(oldKeyValue.key, entry.value)
entries.push({ key: entry.key, value: await encryptData(vaultKey.key, entry.value) })
for (const { key, iv, value } of data.getVaultEntries) {
const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
entries.push({ key, ...await encryptValue(vaultKey.key, plainValue) })
}
await updateVaultKey({
@ -89,11 +97,11 @@ export function useVaultConfigurator () {
setKeyHash(vaultKey.hash)
await set('key', vaultKey)
} catch (e) {
toaster.danger('error setting vault key ' + e.message)
toaster.danger(e.message)
}
}, [getVaultEntries, updateVaultKey, set, get, remove])
return [key, setVaultKey, clearVault]
return { key, setVaultKey, clearVault, disconnectVault }
}
/**

View File

@ -7,12 +7,12 @@ export default function useVault () {
const encrypt = useCallback(async (value) => {
if (!key) throw new Error('no vault key set')
return await encryptData(key.key, value)
return await encryptValue(key.key, value)
}, [key])
const decrypt = useCallback(async (value) => {
const decrypt = useCallback(async ({ iv, value }) => {
if (!key) throw new Error('no vault key set')
return await decryptData(key.key, value)
return await decryptValue(key.key, { iv, value })
}, [key])
return { encrypt, decrypt, isActive: !!key }
@ -21,14 +21,15 @@ export default function useVault () {
/**
* Encrypt data using AES-GCM
* @param {CryptoKey} sharedKey - the key to use for encryption
* @param {Object} data - the data to encrypt
* @returns {Promise<string>} a string representing the encrypted data, can be passed to decryptData to get the original data back
* @param {Object} value - the value to encrypt
* @returns {Promise<Object>} an object with iv and value properties, can be passed to decryptValue to get the original data back
*/
export async function encryptData (sharedKey, data) {
export async function encryptValue (sharedKey, 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(data))
const encoded = new TextEncoder().encode(JSON.stringify(value))
const encrypted = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
@ -37,27 +38,26 @@ export async function encryptData (sharedKey, data) {
sharedKey,
encoded
)
return JSON.stringify({
return {
iv: toHex(iv.buffer),
data: toHex(encrypted)
})
value: toHex(encrypted)
}
}
/**
* Decrypt data using AES-GCM
* @param {CryptoKey} sharedKey - the key to use for decryption
* @param {string} encryptedData - the encrypted data as returned by encryptData
* @param {Object} encryptedValue - the encrypted value as returned by encryptValue
* @returns {Promise<Object>} the original unencrypted data
*/
export async function decryptData (sharedKey, encryptedData) {
const { iv, data } = JSON.parse(encryptedData)
export async function decryptValue (sharedKey, { iv, value }) {
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: fromHex(iv)
},
sharedKey,
fromHex(data)
fromHex(value)
)
const decoded = new TextDecoder().decode(decrypted)
return JSON.parse(decoded)

View File

@ -1,9 +1,10 @@
import { gql } from '@apollo/client'
export const VAULT_FIELDS = gql`
fragment VaultFields on Vault {
export const VAULT_ENTRY_FIELDS = gql`
fragment VaultEntryFields on VaultEntry {
id
key
iv
value
createdAt
updatedAt
@ -11,21 +12,21 @@ export const VAULT_FIELDS = gql`
`
export const GET_VAULT_ENTRY = gql`
${VAULT_FIELDS}
${VAULT_ENTRY_FIELDS}
query GetVaultEntry(
$key: String!
) {
getVaultEntry(key: $key) {
...VaultFields
...VaultEntryFields
}
}
`
export const GET_VAULT_ENTRIES = gql`
${VAULT_FIELDS}
${VAULT_ENTRY_FIELDS}
query GetVaultEntries {
getVaultEntries {
...VaultFields
...VaultEntryFields
}
}
`

View File

@ -1,5 +1,6 @@
import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items'
import { VAULT_ENTRY_FIELDS } from './vault'
export const INVOICE_FIELDS = gql`
fragment InvoiceFields on Invoice {
@ -108,6 +109,7 @@ mutation removeWallet($id: ID!) {
`
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET_FIELDS = gql`
${VAULT_ENTRY_FIELDS}
fragment WalletFields on Wallet {
id
priority
@ -115,8 +117,7 @@ export const WALLET_FIELDS = gql`
updatedAt
enabled
vaultEntries {
key
value
...VaultEntryFields
}
wallet {
__typename

View File

@ -193,6 +193,12 @@ export const autowithdrawSchemaMembers = object({
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
})
export const vaultEntrySchema = key => object({
key: string().required('required').matches(key, `expected ${key}`),
iv: string().required('required').hex().length(24, 'must be 24 characters long'),
value: string().required('required').hex().min(2).max(1024 * 10)
})
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: lightningAddressValidator.required('required'),

View File

@ -1,7 +1,8 @@
import { addMethod, string, mixed } from 'yup'
import { addMethod, string, mixed, array } from 'yup'
import { parseNwcUrl } from './url'
import { NOSTR_PUBKEY_HEX } from './nostr'
import { ensureB64, HEX_REGEX } from './format'
export * from 'yup'
function orFunc (schemas, msg) {
return this.test({
@ -164,4 +165,26 @@ addMethod(string, 'nwcUrl', function () {
})
})
export * from 'yup'
addMethod(array, 'equalto', function equals (
schemas,
message
) {
return this.test({
name: 'equalto',
message: message || `${this.path} has invalid values`,
test: function (items) {
if (items.length !== schemas.length) {
return this.createError({ message: `Expected ${this.path} to be ${schemas.length} items, but got ${items.length}` })
}
const remainingSchemas = [...schemas]
for (let i = 0; i < items.length; i++) {
const index = remainingSchemas.findIndex(schema => schema.isValidSync(items[i], { strict: true }))
if (index === -1) {
return this.createError({ message: `${this.path}[${i}] has invalid value` })
}
remainingSchemas.splice(index, 1)
}
return true
}
})
})

View File

@ -78,6 +78,11 @@ export function SettingsHeader () {
<Nav.Link eventKey='mutes'>muted stackers</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href='/settings/passphrase' passHref legacyBehavior>
<Nav.Link eventKey='passphrase'>device sync</Nav.Link>
</Link>
</Nav.Item>
</Nav>
</>
)

View File

@ -0,0 +1,203 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import Layout from '@/components/layout'
import { SettingsHeader } from '../index'
import { useVaultConfigurator } from '@/components/vault/use-vault-configurator'
import { useMe } from '@/components/me'
import { Button, InputGroup } from 'react-bootstrap'
import bip39Words from '@/lib/bip39-words'
import { Form, PasswordInput, SubmitButton } from '@/components/form'
import { deviceSyncSchema } from '@/lib/validate'
import RefreshIcon from '@/svgs/refresh-line.svg'
import { useCallback, useEffect, useState } from 'react'
import { useToast } from '@/components/toast'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function DeviceSync ({ ssrData }) {
const { me } = useMe()
const { key, setVaultKey, clearVault, disconnectVault } = useVaultConfigurator()
const [passphrase, setPassphrase] = useState()
const setSeedPassphrase = useCallback(async (passphrase) => {
await setVaultKey(passphrase)
setPassphrase(passphrase)
}, [setVaultKey])
const enabled = !!me?.privates?.vaultKeyHash
const connected = !!key
return (
<Layout>
<div className='pb-3 w-100 mt-2'>
<SettingsHeader />
<div className='mt-3' style={{ maxWidth: '600px' }}>
{
(connected && passphrase && <Connect passphrase={passphrase} />) ||
(connected && <Connected disconnectVault={disconnectVault} />) ||
(enabled && <Enabled setVaultKey={setVaultKey} clearVault={clearVault} />) ||
<Setup setSeedPassphrase={setSeedPassphrase} />
}
</div>
</div>
</Layout>
)
}
function Connect ({ passphrase }) {
return (
<div>
<h2>Connect other devices</h2>
<p className='line-height-md'>
On your other devices, navigate to device sync settings and enter this exact passphrase.
</p>
<p className='line-height-md'>
Once you leave this page, this passphrase cannot be shown again. Connect all the devices you plan to use or write this passphrase down somewhere safe.
</p>
<PasswordInput
label='passphrase'
name='passphrase'
placeholder=''
required
autoFocus
as='textarea'
value={passphrase}
noForm
rows={3}
readOnly
copy
qr
/>
</div>
)
}
function Connected ({ disconnectVault }) {
return (
<div>
<h2>Device sync is enabled!</h2>
<p>
Sensitive data on this device is now securely synced between all connected devices.
</p>
<p className='text-muted text-sm'>
Disconnect to prevent this device from syncing data or to reset your passphrase.
</p>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto gap-2'>
<Button
variant='primary'
onClick={disconnectVault}
>disconnect
</Button>
</div>
</div>
</div>
)
}
function Enabled ({ setVaultKey, clearVault }) {
const toaster = useToast()
return (
<div>
<h2>Device sync is enabled</h2>
<p className='line-height-md'>
This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it.
</p>
<Form
schema={deviceSyncSchema}
initial={{ passphrase: '' }}
enableReinitialize
onSubmit={async ({ passphrase }) => {
try {
await setVaultKey(passphrase)
} catch (e) {
console.error(e)
toaster.danger('error setting vault key')
}
}}
>
<PasswordInput
label='passphrase'
name='passphrase'
placeholder=''
required
autoFocus
as='textarea'
rows={3}
qr
/>
<div className='mt-3'>
<div className='d-flex justify-content-between align-items-center'>
<Button variant='danger' onClick={clearVault}>reset</Button>
<SubmitButton variant='primary'>enable</SubmitButton>
</div>
</div>
</Form>
</div>
)
}
const generatePassphrase = (n = 12) => {
const rand = new Uint32Array(n)
window.crypto.getRandomValues(rand)
return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ')
}
function Setup ({ setSeedPassphrase }) {
const [passphrase, setPassphrase] = useState()
const toaster = useToast()
const newPassphrase = useCallback(() => {
setPassphrase(() => generatePassphrase(12))
}, [])
useEffect(() => {
setPassphrase(() => generatePassphrase(12))
}, [])
return (
<div>
<h2>Enable device sync</h2>
<p>
Enable secure sync of sensitive data (like wallet credentials) between your devices.
</p>
<p className='text-muted text-sm line-height-md'>
After enabled, your passphrase can be used to connect other devices.
</p>
<Form
schema={deviceSyncSchema}
initial={{ passphrase }}
enableReinitialize
onSubmit={async ({ passphrase }) => {
try {
await setSeedPassphrase(passphrase)
} catch (e) {
console.error(e)
toaster.danger('error setting passphrase')
}
}}
>
<PasswordInput
label='passphrase'
name='passphrase'
placeholder=''
required
autoFocus
as='textarea'
rows={3}
readOnly
append={
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
<RefreshIcon width={16} height={16} />
</InputGroup.Text>
}
/>
<div className='mt-3'>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto gap-2'>
<SubmitButton variant='primary'>enable</SubmitButton>
</div>
</div>
</div>
</Form>
</div>
)
}

View File

@ -52,7 +52,7 @@ export default function WalletSettings () {
const validate = useCallback(async (data) => {
try {
await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false })
await validateWallet(wallet.def, data, { yupOptions: { abortEarly: false }, topLevel: false })
} catch (error) {
if (error instanceof ValidationError) {
return error.inner.reduce((acc, error) => {

View File

@ -23,7 +23,7 @@ export function useWalletConfigurator (wallet) {
if (clientOnly && isActive) {
for (const [key, value] of Object.entries(clientOnly)) {
if (value) {
vaultEntries.push({ key, value: encrypt(value) })
vaultEntries.push({ key, ...await encrypt(value) })
}
}
}

View File

@ -41,10 +41,11 @@ function useLocalWallets () {
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
export function WalletsProvider ({ children }) {
const { decrypt } = useVault()
const { isActive, decrypt } = useVault()
const { me } = useMe()
const { wallets: localWallets, reloadLocalWallets } = useLocalWallets()
const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY)
const [serverWallets, setServerWallets] = useState([])
const { data, refetch } = useQuery(WALLETS,
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
@ -56,23 +57,37 @@ export function WalletsProvider ({ children }) {
}
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
const wallets = useMemo(() => {
// form wallets into a list of { config, def }
const wallets = data?.wallets?.map(w => {
const def = getWalletByType(w.type)
const { vaultEntries, ...config } = w
for (const { key, value } of vaultEntries) {
config[key] = decrypt(value)
useEffect(() => {
async function loadWallets () {
if (!data?.wallets) return
// form wallets into a list of { config, def }
const wallets = []
for (const w of data.wallets) {
const def = getWalletByType(w.type)
const { vaultEntries, ...config } = w
if (isActive) {
for (const { key, iv, value } of vaultEntries) {
try {
config[key] = await decrypt({ iv, value })
} catch (e) {
console.error('error decrypting vault entry', e)
}
}
}
// the specific wallet config on the server is stored in wallet.wallet
// on the client, it's stored unnested
wallets.push({ config: { ...config, ...w.wallet }, def })
}
setServerWallets(wallets)
}
loadWallets()
}, [data?.wallets, decrypt, isActive])
// the specific wallet config on the server is stored in wallet.wallet
// on the client, it's stored unnested
return { config: { ...config, ...w.wallet }, def }
}) ?? []
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
const wallets = useMemo(() => {
const merged = {}
for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) {
for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) {
merged[wallet.def.name] = {
def: wallet.def,
config: {
@ -91,7 +106,7 @@ export function WalletsProvider ({ children }) {
return Object.values(merged)
.sort(walletPrioritySort)
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
}, [data?.wallets, localWallets])
}, [serverWallets, localWallets])
const setPriorities = useCallback(async (priorities) => {
for (const { wallet, priority } of priorities) {

View File

@ -7,21 +7,22 @@
- a regular expression that must match
*/
import { autowithdrawSchemaMembers } from '@/lib/validate'
import { autowithdrawSchemaMembers, vaultEntrySchema } from '@/lib/validate'
import * as Yup from '@/lib/yup'
import { canReceive } from './common'
export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) {
let schema = composeWalletSchema(walletDef)
export default async function validateWallet (walletDef, data,
{ yupOptions = { abortEarly: true }, topLevel = true, serverSide = false } = {}) {
let schema = composeWalletSchema(walletDef, serverSide)
if (canReceive({ def: walletDef, config: data })) {
schema = schema.concat(autowithdrawSchemaMembers)
}
await schema.validate(data, options)
await schema.validate(data, yupOptions)
const casted = schema.cast(data, { assert: false })
if (options.topLevel && walletDef.validate) {
if (topLevel && walletDef.validate) {
await walletDef.validate(casted)
}
@ -57,29 +58,40 @@ function createFieldSchema (name, validate) {
}
}
function composeWalletSchema (walletDef) {
function composeWalletSchema (walletDef, serverSide) {
const { fields } = walletDef
const vaultEntrySchemas = []
const schemaShape = fields.reduce((acc, field) => {
const { name, validate, optional, requiredWithout } = field
const { name, validate, optional, clientOnly, requiredWithout } = field
acc[name] = createFieldSchema(name, validate)
if (clientOnly && serverSide) {
// For server-side validation, accumulate clientOnly fields as vaultEntries
vaultEntrySchemas.push(vaultEntrySchema(name))
} else {
acc[name] = createFieldSchema(name, validate)
if (!optional) {
acc[name] = acc[name].required('Required')
} else if (requiredWithout) {
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
return Yup.mixed().or([schema.test({
test: value => value !== pairSetting,
message: `${name} cannot be the same as ${requiredWithout}`
}), Yup.mixed().notRequired()])
})
if (!optional) {
acc[name] = acc[name].required('Required')
} else if (requiredWithout) {
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
return Yup.mixed().or([schema.test({
test: value => value !== pairSetting,
message: `${name} cannot be the same as ${requiredWithout}`
}), Yup.mixed().notRequired()])
})
}
}
return acc
}, {})
// Finalize the vaultEntries schema if it exists
if (vaultEntrySchemas.length > 0) {
schemaShape.vaultEntries = Yup.array().equalto(vaultEntrySchemas)
}
// we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({

View File

@ -3,7 +3,7 @@ export const walletType = 'WEBLN'
export const walletField = 'walletWebLN'
export const validate = ({ enabled }) => {
if (enabled && typeof window?.webln === 'undefined') {
if (enabled && typeof window !== 'undefined' && !window?.webln) {
throw new Error('no WebLN provider found')
}
}