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

View File

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

View File

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

View File

@ -15,11 +15,11 @@ import { useApolloClient } from '@apollo/client'
export default function DeviceSync () { export default function DeviceSync () {
const { me } = useMe() const { me } = useMe()
const apollo = useApolloClient() const apollo = useApolloClient()
const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator() const { key, setVaultKey, clearVault } = useVaultConfigurator()
const showModal = useShowModal() const showModal = useShowModal()
const enabled = !!me?.privates?.vaultKeyHash const enabled = !!me?.privates?.vaultKeyHash
const connected = !!value?.key const connected = !!key
const manage = useCallback(async () => { const manage = useCallback(async () => {
if (enabled && connected) { if (enabled && connected) {
@ -27,7 +27,7 @@ export default function DeviceSync () {
<div> <div>
<h2>Device sync is enabled!</h2> <h2>Device sync is enabled!</h2>
<p> <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>
<p className='text-muted text-sm'> <p className='text-muted text-sm'>
Disconnect to prevent this device from syncing data or to reset your passphrase. Disconnect to prevent this device from syncing data or to reset your passphrase.
@ -38,7 +38,7 @@ export default function DeviceSync () {
<Button <Button
variant='primary' variant='primary'
onClick={() => { onClick={() => {
disconnectVault() clearVault()
onClose() onClose()
}} }}
>disconnect >disconnect
@ -52,7 +52,7 @@ export default function DeviceSync () {
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} /> <ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
)) ))
} }
}, [enabled, connected, value]) }, [enabled, connected, key])
const reset = useCallback(async () => { const reset = useCallback(async () => {
const schema = yup.object().shape({ 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 QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg' import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import QRCode from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { QrScanner } from '@yudiel/react-qr-scanner' import { Scanner } from '@yudiel/react-qr-scanner'
import { qrImageSettings } from './qr'
export class SessionRequiredError extends Error { export class SessionRequiredError extends Error {
constructor () { constructor () {
@ -1069,7 +1070,7 @@ function Client (Component) {
// where the initial value is not available on first render. // where the initial value is not available on first render.
// Example: value is stored in localStorage which is fetched // Example: value is stored in localStorage which is fetched
// after first render using an useEffect hook. // after first render using an useEffect hook.
const [,, helpers] = useField(props) const [,, helpers] = props.noForm ? [{}, {}, {}] : useField(props)
useEffect(() => { useEffect(() => {
initialValue && helpers.setValue(initialValue) initialValue && helpers.setValue(initialValue)
@ -1102,9 +1103,11 @@ function QrPassword ({ value }) {
const showQr = useCallback(() => { const showQr = useCallback(() => {
showModal(close => ( showModal(close => (
<div className={styles.qr}> <div>
<p>You can import this passphrase into another device by scanning this QR code</p> <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>
<QRCode value={value} renderAs='svg' /> <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> </div>
)) ))
}, [toaster, value, showModal]) }, [toaster, value, showModal])
@ -1121,10 +1124,9 @@ function QrPassword ({ value }) {
) )
} }
function PasswordScanner ({ onDecode }) { function PasswordScanner ({ onScan }) {
const showModal = useShowModal() const showModal = useShowModal()
const toaster = useToast() const toaster = useToast()
const ref = useRef(false)
return ( return (
<InputGroup.Text <InputGroup.Text
@ -1132,20 +1134,24 @@ function PasswordScanner ({ onDecode }) {
onClick={() => { onClick={() => {
showModal(onClose => { showModal(onClose => {
return ( return (
<QrScanner <Scanner
onDecode={(decoded) => { formats={['qr_code']}
onDecode(decoded) onScan={([{ rawValue: result }]) => {
onScan(result)
// avoid accidentally calling onClose multiple times onClose()
if (ref?.current) return }}
ref.current = true styles={{
video: {
onClose({ back: 1 }) aspectRatio: '1 / 1'
}
}} }}
onError={(error) => { onError={(error) => {
if (error instanceof DOMException) return if (error instanceof DOMException) {
toaster.danger('qr scan error:', error.message || error.toString?.()) console.log(error)
onClose({ back: 1 }) } 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 [showPass, setShowPass] = useState(false)
const [field] = useField(props) const [field, helpers] = props.noForm ? [{ value }, {}, {}] : useField(props)
const Append = useMemo(() => { const Append = useMemo(() => {
return ( return (
@ -1173,12 +1179,7 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }
{qr && (readOnly {qr && (readOnly
? <QrPassword value={field?.value} /> ? <QrPassword value={field?.value} />
: <PasswordScanner : <PasswordScanner
onDecode={decoded => { onScan={v => helpers.setValue(v)}
// 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)
}}
/>)} />)}
{append} {append}
</> </>

View File

@ -5,6 +5,15 @@ import { useEffect } from 'react'
import { useWallet } from '@/wallets/index' import { useWallet } from '@/wallets/index'
import Bolt11Info from './bolt11-info' 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 }) { export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
const wallet = useWallet() 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}> <a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
<QRCodeSVG <QRCodeSVG
className='h-auto mw-100' value={qrValue} size={300} imageSettings={{ className='h-auto mw-100' value={qrValue} size={300} imageSettings={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
}}
/> />
</a> </a>
{description && <div className='mt-1 text-center text-muted'>{description}</div>} {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 { E_VAULT_KEY_EXISTS } from '@/lib/error'
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault' import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
import { toHex } from '@/lib/hex' import { toHex } from '@/lib/hex'
import { decryptData, encryptData } from './use-vault' import { decryptValue, encryptValue } from './use-vault'
const useImperativeQuery = (query) => { const useImperativeQuery = (query) => {
const { refetch } = useQuery(query, { skip: true }) const { refetch } = useQuery(query, { skip: true })
@ -21,7 +21,7 @@ const useImperativeQuery = (query) => {
export function useVaultConfigurator () { export function useVaultConfigurator () {
const { me } = useMe() const { me } = useMe()
const toaster = useToast() 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 { set, get, remove } = useIndexedDB(idbConfig)
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY) const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES) const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
@ -47,7 +47,7 @@ export function useVaultConfigurator () {
// toaster?.danger('error loading vault configuration ' + e.message) // 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 // clear vault: remove everything and reset the key
const [clearVault] = useMutation(CLEAR_VAULT, { 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 // initialize the vault and set a vault key
const setVaultKey = useCallback(async (passphrase) => { const setVaultKey = useCallback(async (passphrase) => {
try { try {
@ -69,10 +75,12 @@ export function useVaultConfigurator () {
const vaultKey = await deriveKey(me.id, passphrase) const vaultKey = await deriveKey(me.id, passphrase)
const { data } = await getVaultEntries() 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 = [] const entries = []
for (const entry of data.getVaultEntries) { for (const { key, iv, value } of data.getVaultEntries) {
entry.value = await decryptData(oldKeyValue.key, entry.value) const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
entries.push({ key: entry.key, value: await encryptData(vaultKey.key, entry.value) }) entries.push({ key, ...await encryptValue(vaultKey.key, plainValue) })
} }
await updateVaultKey({ await updateVaultKey({
@ -89,11 +97,11 @@ export function useVaultConfigurator () {
setKeyHash(vaultKey.hash) setKeyHash(vaultKey.hash)
await set('key', vaultKey) await set('key', vaultKey)
} catch (e) { } catch (e) {
toaster.danger('error setting vault key ' + e.message) toaster.danger(e.message)
} }
}, [getVaultEntries, updateVaultKey, set, get, remove]) }, [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) => { const encrypt = useCallback(async (value) => {
if (!key) throw new Error('no vault key set') if (!key) throw new Error('no vault key set')
return await encryptData(key.key, value) return await encryptValue(key.key, value)
}, [key]) }, [key])
const decrypt = useCallback(async (value) => { const decrypt = useCallback(async ({ iv, value }) => {
if (!key) throw new Error('no vault key set') if (!key) throw new Error('no vault key set')
return await decryptData(key.key, value) return await decryptValue(key.key, { iv, value })
}, [key]) }, [key])
return { encrypt, decrypt, isActive: !!key } return { encrypt, decrypt, isActive: !!key }
@ -21,14 +21,15 @@ export default function useVault () {
/** /**
* Encrypt data using AES-GCM * Encrypt data using AES-GCM
* @param {CryptoKey} sharedKey - the key to use for encryption * @param {CryptoKey} sharedKey - the key to use for encryption
* @param {Object} data - the data to encrypt * @param {Object} value - the value to encrypt
* @returns {Promise<string>} a string representing the encrypted data, can be passed to decryptData to get the original data back * @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 // 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 // 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 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( const encrypted = await window.crypto.subtle.encrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
@ -37,27 +38,26 @@ export async function encryptData (sharedKey, data) {
sharedKey, sharedKey,
encoded encoded
) )
return JSON.stringify({ return {
iv: toHex(iv.buffer), iv: toHex(iv.buffer),
data: toHex(encrypted) value: toHex(encrypted)
}) }
} }
/** /**
* Decrypt data using AES-GCM * Decrypt data using AES-GCM
* @param {CryptoKey} sharedKey - the key to use for decryption * @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 * @returns {Promise<Object>} the original unencrypted data
*/ */
export async function decryptData (sharedKey, encryptedData) { export async function decryptValue (sharedKey, { iv, value }) {
const { iv, data } = JSON.parse(encryptedData)
const decrypted = await window.crypto.subtle.decrypt( const decrypted = await window.crypto.subtle.decrypt(
{ {
name: 'AES-GCM', name: 'AES-GCM',
iv: fromHex(iv) iv: fromHex(iv)
}, },
sharedKey, sharedKey,
fromHex(data) fromHex(value)
) )
const decoded = new TextDecoder().decode(decrypted) const decoded = new TextDecoder().decode(decrypted)
return JSON.parse(decoded) return JSON.parse(decoded)

View File

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

View File

@ -1,5 +1,6 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items' import { ITEM_FULL_FIELDS } from './items'
import { VAULT_ENTRY_FIELDS } from './vault'
export const INVOICE_FIELDS = gql` export const INVOICE_FIELDS = gql`
fragment InvoiceFields on Invoice { 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 // XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET_FIELDS = gql` export const WALLET_FIELDS = gql`
${VAULT_ENTRY_FIELDS}
fragment WalletFields on Wallet { fragment WalletFields on Wallet {
id id
priority priority
@ -115,8 +117,7 @@ export const WALLET_FIELDS = gql`
updatedAt updatedAt
enabled enabled
vaultEntries { vaultEntries {
key ...VaultEntryFields
value
} }
wallet { wallet {
__typename __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) 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 } = {}) => export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({ object({
addr: lightningAddressValidator.required('required'), 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 { parseNwcUrl } from './url'
import { NOSTR_PUBKEY_HEX } from './nostr' import { NOSTR_PUBKEY_HEX } from './nostr'
import { ensureB64, HEX_REGEX } from './format' import { ensureB64, HEX_REGEX } from './format'
export * from 'yup'
function orFunc (schemas, msg) { function orFunc (schemas, msg) {
return this.test({ 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> <Nav.Link eventKey='mutes'>muted stackers</Nav.Link>
</Link> </Link>
</Nav.Item> </Nav.Item>
<Nav.Item>
<Link href='/settings/passphrase' passHref legacyBehavior>
<Nav.Link eventKey='passphrase'>device sync</Nav.Link>
</Link>
</Nav.Item>
</Nav> </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) => { const validate = useCallback(async (data) => {
try { try {
await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false }) await validateWallet(wallet.def, data, { yupOptions: { abortEarly: false }, topLevel: false })
} catch (error) { } catch (error) {
if (error instanceof ValidationError) { if (error instanceof ValidationError) {
return error.inner.reduce((acc, error) => { return error.inner.reduce((acc, error) => {

View File

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

View File

@ -7,21 +7,22 @@
- a regular expression that must match - a regular expression that must match
*/ */
import { autowithdrawSchemaMembers } from '@/lib/validate' import { autowithdrawSchemaMembers, vaultEntrySchema } from '@/lib/validate'
import * as Yup from '@/lib/yup' import * as Yup from '@/lib/yup'
import { canReceive } from './common' import { canReceive } from './common'
export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) { export default async function validateWallet (walletDef, data,
let schema = composeWalletSchema(walletDef) { yupOptions = { abortEarly: true }, topLevel = true, serverSide = false } = {}) {
let schema = composeWalletSchema(walletDef, serverSide)
if (canReceive({ def: walletDef, config: data })) { if (canReceive({ def: walletDef, config: data })) {
schema = schema.concat(autowithdrawSchemaMembers) schema = schema.concat(autowithdrawSchemaMembers)
} }
await schema.validate(data, options) await schema.validate(data, yupOptions)
const casted = schema.cast(data, { assert: false }) const casted = schema.cast(data, { assert: false })
if (options.topLevel && walletDef.validate) { if (topLevel && walletDef.validate) {
await walletDef.validate(casted) await walletDef.validate(casted)
} }
@ -57,29 +58,40 @@ function createFieldSchema (name, validate) {
} }
} }
function composeWalletSchema (walletDef) { function composeWalletSchema (walletDef, serverSide) {
const { fields } = walletDef const { fields } = walletDef
const vaultEntrySchemas = []
const schemaShape = fields.reduce((acc, field) => { 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) { if (!optional) {
acc[name] = acc[name].required('Required') acc[name] = acc[name].required('Required')
} else if (requiredWithout) { } else if (requiredWithout) {
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => { acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`) if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
return Yup.mixed().or([schema.test({ return Yup.mixed().or([schema.test({
test: value => value !== pairSetting, test: value => value !== pairSetting,
message: `${name} cannot be the same as ${requiredWithout}` message: `${name} cannot be the same as ${requiredWithout}`
}), Yup.mixed().notRequired()]) }), Yup.mixed().notRequired()])
}) })
}
} }
return acc 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 // we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042 // see https://github.com/jquense/yup/issues/176#issuecomment-367352042
const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({ 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 walletField = 'walletWebLN'
export const validate = ({ enabled }) => { export const validate = ({ enabled }) => {
if (enabled && typeof window?.webln === 'undefined') { if (enabled && typeof window !== 'undefined' && !window?.webln) {
throw new Error('no WebLN provider found') throw new Error('no WebLN provider found')
} }
} }