diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js
index 5915899f..8ee4237e 100644
--- a/api/resolvers/vault.js
+++ b/api/resolvers/vault.js
@@ -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
}
}
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index 6711b20b..ecf5aacf 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -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 }))
diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js
index a1600ea9..76be6a4e 100644
--- a/api/typeDefs/vault.js
+++ b/api/typeDefs/vault.js
@@ -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
}
diff --git a/components/device-sync.js b/components/device-sync.js
index 2a3084d5..99b590d8 100644
--- a/components/device-sync.js
+++ b/components/device-sync.js
@@ -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 () {
Device sync is enabled!
- 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.
Disconnect to prevent this device from syncing data or to reset your passphrase.
@@ -38,7 +38,7 @@ export default function DeviceSync () {
{
- disconnectVault()
+ clearVault()
onClose()
}}
>disconnect
@@ -52,7 +52,7 @@ export default function DeviceSync () {
))
}
- }, [enabled, connected, value])
+ }, [enabled, connected, key])
const reset = useCallback(async () => {
const schema = yup.object().shape({
diff --git a/components/form.js b/components/form.js
index 9a26091e..d52fee36 100644
--- a/components/form.js
+++ b/components/form.js
@@ -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 => (
-
-
You can import this passphrase into another device by scanning this QR code
-
+
+
Import this passphrase into another device by navigating to device sync settings and scanning this QR code
+
+
+
))
}, [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 (
{
showModal(onClose => {
return (
- {
- onDecode(decoded)
-
- // avoid accidentally calling onClose multiple times
- if (ref?.current) return
- ref.current = true
-
- onClose({ back: 1 })
+ {
+ 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
?
: {
- // 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}
>
diff --git a/components/qr.js b/components/qr.js
index 8b3f4563..23757ba3 100644
--- a/components/qr.js
+++ b/components/qr.js
@@ -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,
<>
{description && {description}
}
diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js
index f55e7cf1..b62b4cee 100644
--- a/components/vault/use-vault-configurator.js
+++ b/components/vault/use-vault-configurator.js
@@ -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 }
}
/**
diff --git a/components/vault/use-vault.js b/components/vault/use-vault.js
index 4ae7e86a..b8333559 100644
--- a/components/vault/use-vault.js
+++ b/components/vault/use-vault.js
@@ -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} 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} 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} 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)
diff --git a/fragments/vault.js b/fragments/vault.js
index 20f524dc..5608ae61 100644
--- a/fragments/vault.js
+++ b/fragments/vault.js
@@ -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
}
}
`
diff --git a/fragments/wallet.js b/fragments/wallet.js
index 10e71e3d..59249a08 100644
--- a/fragments/wallet.js
+++ b/fragments/wallet.js
@@ -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
diff --git a/lib/validate.js b/lib/validate.js
index 9c3efeb1..49fee995 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -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'),
diff --git a/lib/yup.js b/lib/yup.js
index 6102eae4..9984ca03 100644
--- a/lib/yup.js
+++ b/lib/yup.js
@@ -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
+ }
+ })
+})
diff --git a/pages/settings/index.js b/pages/settings/index.js
index feb3fadd..b1c9f5c1 100644
--- a/pages/settings/index.js
+++ b/pages/settings/index.js
@@ -78,6 +78,11 @@ export function SettingsHeader () {
muted stackers
+
+
+ device sync
+
+
>
)
diff --git a/pages/settings/passphrase/index.js b/pages/settings/passphrase/index.js
new file mode 100644
index 00000000..39461c25
--- /dev/null
+++ b/pages/settings/passphrase/index.js
@@ -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 (
+
+
+
+
+ {
+ (connected && passphrase && ) ||
+ (connected && ) ||
+ (enabled && ) ||
+
+ }
+
+
+
+ )
+}
+
+function Connect ({ passphrase }) {
+ return (
+
+
Connect other devices
+
+ On your other devices, navigate to device sync settings and enter this exact passphrase.
+
+
+ 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.
+
+
+
+ )
+}
+
+function Connected ({ disconnectVault }) {
+ return (
+
+
Device sync is enabled!
+
+ Sensitive data on this device is now securely synced between all connected devices.
+
+
+ Disconnect to prevent this device from syncing data or to reset your passphrase.
+
+
+
+ )
+}
+
+function Enabled ({ setVaultKey, clearVault }) {
+ const toaster = useToast()
+ return (
+
+
Device sync is enabled
+
+ This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it.
+
+
+
+ )
+}
+
+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 (
+
+
Enable device sync
+
+ Enable secure sync of sensitive data (like wallet credentials) between your devices.
+
+
+ After enabled, your passphrase can be used to connect other devices.
+
+
+
+ )
+}
diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js
index fbe624e9..3b934c7f 100644
--- a/pages/settings/wallets/[wallet].js
+++ b/pages/settings/wallets/[wallet].js
@@ -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) => {
diff --git a/wallets/config.js b/wallets/config.js
index 2c827ff5..3a7e4a67 100644
--- a/wallets/config.js
+++ b/wallets/config.js
@@ -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) })
}
}
}
diff --git a/wallets/index.js b/wallets/index.js
index 99eea902..ba8502ec 100644
--- a/wallets/index.js
+++ b/wallets/index.js
@@ -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) {
diff --git a/wallets/validate.js b/wallets/validate.js
index 4040c826..b3b3a4da 100644
--- a/wallets/validate.js
+++ b/wallets/validate.js
@@ -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({
diff --git a/wallets/webln/index.js b/wallets/webln/index.js
index 4fe2efba..37cf9f1e 100644
--- a/wallets/webln/index.js
+++ b/wallets/webln/index.js
@@ -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')
}
}