get vault working
This commit is contained in:
parent
eae4c2b882
commit
dce5762f63
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
|
27
lib/yup.js
27
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue