{!enabled ? 'Enable device sync' : 'Input your passphrase'}
+
+ {!enabled
+ ? 'Enable secure sync of sensitive data (like wallet credentials) between your devices. You’ll need to enter this passphrase on each device you want to connect.'
+ : 'Enter the passphrase from device sync to access your encrypted sensitive data (like wallet credentials) on the server.'}
+
)
+ : undefined}
/>
)
}
diff --git a/components/form.module.css b/components/form.module.css
index 6fa0c699..5d3f8f53 100644
--- a/components/form.module.css
+++ b/components/form.module.css
@@ -2,6 +2,10 @@
border-top-left-radius: 0;
}
+textarea.passwordInput {
+ resize: none;
+}
+
.markdownInput textarea {
margin-top: -1px;
font-size: 94%;
@@ -69,4 +73,16 @@
0% {
opacity: 42%;
}
-}
\ No newline at end of file
+}
+
+div.qr {
+ display: grid;
+}
+
+div.qr>svg {
+ justify-self: center;
+ width: 100%;
+ height: auto;
+ padding: 1rem;
+ background-color: white;
+}
diff --git a/components/modal.js b/components/modal.js
index 96aff77a..f01ca8a1 100644
--- a/components/modal.js
+++ b/components/modal.js
@@ -45,13 +45,20 @@ export default function useModal () {
}, [getCurrentContent, forceUpdate])
// this is called on every navigation due to below useEffect
- const onClose = useCallback(() => {
+ const onClose = useCallback((options) => {
+ if (options?.back) {
+ for (let i = 0; i < options.back; i++) {
+ onBack()
+ }
+ return
+ }
+
while (modalStack.current.length) {
getCurrentContent()?.options?.onClose?.()
modalStack.current.pop()
}
forceUpdate()
- }, [])
+ }, [onBack])
const router = useRouter()
useEffect(() => {
@@ -90,7 +97,7 @@ export default function useModal () {
{overflow}
}
- {modalStack.current.length > 1 ?
: null}
+ {modalStack.current.length > 1 ?
: null}
X
diff --git a/components/nav/common.js b/components/nav/common.js
index fa2a5617..d42bca06 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from 'wallets'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
+import { unsetLocalKey as resetVaultKey } from '@/components/use-vault'
export function Brand ({ className }) {
return (
@@ -265,6 +266,7 @@ function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const wallets = useWallets()
const { multiAuthSignout } = useAccounts()
+ const { me } = useMe()
return (
@@ -293,6 +295,7 @@ function LogoutObstacle ({ onClose }) {
}
await wallets.resetClient().catch(console.error)
+ await resetVaultKey(me?.id)
await signOut({ callbackUrl: '/' })
}}
diff --git a/components/use-local-storage.js b/components/use-local-storage.js
new file mode 100644
index 00000000..fcf09aa2
--- /dev/null
+++ b/components/use-local-storage.js
@@ -0,0 +1,291 @@
+import { SSR } from '@/lib/constants'
+import { useMe } from './me'
+import { useEffect, useState } from 'react'
+import createTaskQueue from '@/lib/task-queue'
+
+const VERSION = 1
+
+/**
+ * A react hook to use the local storage
+ * It handles the lifecycle of the storage, opening and closing it as needed.
+ *
+ * @param {*} options
+ * @param {string} options.database - the database name
+ * @param {[string]} options.namespace - the namespace of the storage
+ * @returns {[object]} - the local storage
+ */
+export default function useLocalStorage ({ database = 'default', namespace = ['default'] }) {
+ const { me } = useMe()
+ if (!Array.isArray(namespace)) namespace = [namespace]
+ const joinedNamespace = namespace.join(':')
+ const [storage, setStorage] = useState(openLocalStorage({ database, userId: me?.id, namespace }))
+
+ useEffect(() => {
+ const currentStorage = storage
+ const newStorage = openLocalStorage({ database, userId: me?.id, namespace })
+ setStorage(newStorage)
+ if (currentStorage) currentStorage.close()
+ return () => {
+ newStorage.close()
+ }
+ }, [me, database, joinedNamespace])
+
+ return [storage]
+}
+
+/**
+ * Open a local storage.
+ * This is an abstraction on top of IndexedDB or, when not available, an in-memory storage.
+ * A combination of userId, database and namespace is used to efficiently separate different storage units.
+ * Namespaces can be an array of strings, that will be internally joined to form a single namespace.
+ *
+ * @param {*} options
+ * @param {string} options.userId - the user that owns the storage (anon if not provided)
+ * @param {string} options.database - the database name (default if not provided)
+ * @param {[string]} options.namespace - the namespace of the storage (default if not provided)
+ * @returns {object} - the local storage
+ * @throws Error if the namespace is invalid
+ */
+export function openLocalStorage ({ userId, database = 'default', namespace = ['default'] }) {
+ if (!userId) userId = 'anon'
+ if (!Array.isArray(namespace)) namespace = [namespace]
+ if (SSR) return createMemBackend(userId, namespace)
+
+ let backend = newIdxDBBackend(userId, database, namespace)
+
+ if (!backend) {
+ console.warn('no local storage backend available, fallback to in memory storage')
+ backend = createMemBackend(userId, namespace)
+ }
+ return backend
+}
+
+export async function listLocalStorages ({ userId, database }) {
+ if (SSR) return []
+ return await listIdxDBBackendNamespaces(userId, database)
+}
+
+/**
+ * In memory storage backend (volatile/dummy storage)
+ */
+function createMemBackend (userId, namespace) {
+ const joinedNamespace = userId + ':' + namespace.join(':')
+ let memory = window?.snMemStorage?.[joinedNamespace]
+ if (!memory) {
+ memory = {}
+ if (window) {
+ if (!window.snMemStorage) window.snMemStorage = {}
+ window.snMemStorage[joinedNamespace] = memory
+ }
+ }
+ return {
+ set: (key, value) => { memory[key] = value },
+ get: (key) => memory[key],
+ unset: (key) => { delete memory[key] },
+ clear: () => { Object.keys(memory).forEach(key => delete memory[key]) },
+ list: () => Object.keys(memory),
+ close: () => { }
+ }
+}
+
+/**
+ * Open an IndexedDB connection
+ * @param {*} userId
+ * @param {*} database
+ * @param {*} onupgradeneeded
+ * @param {*} queue
+ * @returns {object} - an open connection
+ * @throws Error if the connection cannot be opened
+ */
+async function openIdxDB (userId, database, onupgradeneeded, queue) {
+ const fullDbName = `${database}:${userId}`
+ // we keep a reference to every open indexed db connection
+ // to reuse them whenever possible
+ if (window && !window.snIdxDB) window.snIdxDB = {}
+ let openConnection = window?.snIdxDB?.[fullDbName]
+
+ const close = () => {
+ const conn = openConnection
+ conn.ref--
+ if (conn.ref === 0) { // close the connection for real if nothing is using it
+ if (window?.snIdxDB) delete window.snIdxDB[fullDbName]
+ queue.enqueue(() => {
+ conn.db.close()
+ })
+ }
+ }
+
+ // if for any reason the connection is outdated, we close it
+ if (openConnection && openConnection.version !== VERSION) {
+ close()
+ openConnection = undefined
+ }
+ // an open connections is not available, so we create a new one
+ if (!openConnection) {
+ openConnection = {
+ version: VERSION,
+ ref: 1, // we need a ref count to know when to close the connection for real
+ db: null,
+ close
+ }
+ openConnection.db = await new Promise((resolve, reject) => {
+ const request = window.indexedDB.open(fullDbName, VERSION)
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result
+ if (onupgradeneeded) onupgradeneeded(db)
+ }
+ request.onsuccess = (event) => {
+ const db = event.target.result
+ if (!db?.transaction) reject(new Error('unsupported implementation'))
+ else resolve(db)
+ }
+ request.onerror = reject
+ })
+ window.snIdxDB[fullDbName] = openConnection
+ } else {
+ // increase the reference count
+ openConnection.ref++
+ }
+ return openConnection
+}
+
+/**
+ * An IndexedDB based persistent storage
+ * @param {string} userId - the user that owns the storage
+ * @param {string} database - the database name
+ * @returns {object} - an indexedDB persistent storage
+ * @throws Error if the namespace is invalid
+ */
+function newIdxDBBackend (userId, database, namespace) {
+ if (!window.indexedDB) return undefined
+ if (!namespace) throw new Error('missing namespace')
+ if (!Array.isArray(namespace) || !namespace.length || namespace.find(n => !n || typeof n !== 'string')) throw new Error('invalid namespace. must be a non-empty array of strings')
+ if (namespace.find(n => n.includes(':'))) throw new Error('invalid namespace. must not contain ":"')
+
+ namespace = namespace.join(':')
+
+ const queue = createTaskQueue()
+
+ let openConnection = null
+
+ const initialize = async () => {
+ if (!openConnection) {
+ openConnection = await openIdxDB(userId, database, (db) => {
+ db.createObjectStore(database, { keyPath: ['namespace', 'key'] })
+ }, queue)
+ }
+ }
+
+ return {
+ set: async (key, value) => {
+ await queue.enqueue(async () => {
+ await initialize()
+ const tx = openConnection.db.transaction([database], 'readwrite')
+ const objectStore = tx.objectStore(database)
+ objectStore.put({ namespace, key, value })
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = resolve
+ tx.onerror = reject
+ })
+ })
+ },
+ get: async (key) => {
+ return await queue.enqueue(async () => {
+ await initialize()
+ const tx = openConnection.db.transaction([database], 'readonly')
+ const objectStore = tx.objectStore(database)
+ const request = objectStore.get([namespace, key])
+ return await new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result?.value)
+ request.onerror = reject
+ })
+ })
+ },
+ unset: async (key) => {
+ await queue.enqueue(async () => {
+ await initialize()
+ const tx = openConnection.db.transaction([database], 'readwrite')
+ const objectStore = tx.objectStore(database)
+ objectStore.delete([namespace, key])
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = resolve
+ tx.onerror = reject
+ })
+ })
+ },
+ clear: async () => {
+ await queue.enqueue(async () => {
+ await initialize()
+ const tx = openConnection.db.transaction([database], 'readwrite')
+ const objectStore = tx.objectStore(database)
+ objectStore.clear()
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = resolve
+ tx.onerror = reject
+ })
+ })
+ },
+ list: async () => {
+ return await queue.enqueue(async () => {
+ await initialize()
+ const tx = openConnection.db.transaction([database], 'readonly')
+ const objectStore = tx.objectStore(database)
+ const keys = []
+ return await new Promise((resolve, reject) => {
+ const request = objectStore.openCursor()
+ request.onsuccess = (event) => {
+ const cursor = event.target.result
+ if (cursor) {
+ if (cursor.key[0] === namespace) {
+ keys.push(cursor.key[1]) // Push only the 'key' part of the composite key
+ }
+ cursor.continue()
+ } else {
+ resolve(keys)
+ }
+ }
+ request.onerror = reject
+ })
+ })
+ },
+ close: async () => {
+ queue.enqueue(async () => {
+ if (openConnection) await openConnection.close()
+ })
+ }
+ }
+}
+
+/**
+ * List all the namespaces used in an IndexedDB database
+ * @param {*} userId - the user that owns the storage
+ * @param {*} database - the database name
+ * @returns {array} - an array of namespace names
+ */
+async function listIdxDBBackendNamespaces (userId, database) {
+ if (!window?.indexedDB) return []
+ const queue = createTaskQueue()
+ const openConnection = await openIdxDB(userId, database, null, queue)
+ try {
+ const list = await queue.enqueue(async () => {
+ const objectStore = openConnection.db.transaction([database], 'readonly').objectStore(database)
+ const namespaces = new Set()
+ return await new Promise((resolve, reject) => {
+ const request = objectStore.openCursor()
+ request.onsuccess = (event) => {
+ const cursor = event.target.result
+ if (cursor) {
+ namespaces.add(cursor.key[0])
+ cursor.continue()
+ } else {
+ resolve(Array.from(namespaces).map(n => n.split(':')))
+ }
+ }
+ request.onerror = reject
+ })
+ })
+ return list
+ } finally {
+ openConnection.close()
+ }
+}
diff --git a/components/use-vault.js b/components/use-vault.js
new file mode 100644
index 00000000..3383c786
--- /dev/null
+++ b/components/use-vault.js
@@ -0,0 +1,426 @@
+import { useCallback, useState, useEffect, useRef } from 'react'
+import { useMe } from '@/components/me'
+import { useMutation, useApolloClient } from '@apollo/client'
+import { SET_ENTRY, UNSET_ENTRY, GET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault'
+import { E_VAULT_KEY_EXISTS } from '@/lib/error'
+import { useToast } from '@/components/toast'
+import useLocalStorage, { openLocalStorage, listLocalStorages } from '@/components/use-local-storage'
+import { toHex, fromHex } from '@/lib/hex'
+import createTaskQueue from '@/lib/task-queue'
+
+/**
+ * A react hook to configure the vault for the current user
+ */
+export function useVaultConfigurator () {
+ const { me } = useMe()
+ const toaster = useToast()
+ const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH)
+
+ const [vaultKey, innerSetVaultKey] = useState(null)
+ const [config, configError] = useConfig()
+
+ useEffect(() => {
+ if (!me) return
+ if (configError) {
+ toaster.danger('error loading vault configuration ' + configError.message)
+ return
+ }
+ (async () => {
+ let localVaultKey = await config.get('key')
+ if (localVaultKey && (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash)) {
+ // If the hash stored in the server does not match the hash of the local key,
+ // we can tell that the key is outdated (reset by another device or other reasons)
+ // in this case we clear the local key and let the user re-enter the passphrase
+ console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash)
+ localVaultKey = null
+ await config.unset('key')
+ }
+ innerSetVaultKey(localVaultKey)
+ })()
+ }, [me?.privates?.vaultKeyHash, config, configError])
+
+ // clear vault: remove everything and reset the key
+ const [clearVault] = useMutation(CLEAR_VAULT, {
+ onCompleted: async () => {
+ await config.unset('key')
+ innerSetVaultKey(null)
+ }
+ })
+
+ // initialize the vault and set a vault key
+ const setVaultKey = useCallback(async (passphrase) => {
+ const vaultKey = await deriveKey(me.id, passphrase)
+ await setVaultKeyHash({
+ variables: { hash: vaultKey.hash },
+ onError: (error) => {
+ const errorCode = error.graphQLErrors[0]?.extensions?.code
+ if (errorCode === E_VAULT_KEY_EXISTS) {
+ throw new Error('wrong passphrase')
+ }
+ toaster.danger(error.graphQLErrors[0].message)
+ }
+ })
+ innerSetVaultKey(vaultKey)
+ await config.set('key', vaultKey)
+ }, [setVaultKeyHash])
+
+ // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that)
+ const disconnectVault = useCallback(async () => {
+ await config.unset('key')
+ innerSetVaultKey(null)
+ }, [innerSetVaultKey, config])
+
+ return [vaultKey, setVaultKey, clearVault, disconnectVault]
+}
+
+/**
+ * A react hook to migrate local vault storage to the synched vault
+ */
+export function useVaultMigration () {
+ const { me } = useMe()
+ const apollo = useApolloClient()
+ // migrate local storage to vault
+ const migrate = useCallback(async () => {
+ let migratedCount = 0
+ const config = await openConfig(me?.id)
+ const vaultKey = await config.get('key')
+ if (!vaultKey) throw new Error('vault key not found')
+ // we collect all the storages used by the vault
+ const namespaces = await listLocalStorages({ userId: me?.id, database: 'vault', supportLegacy: true })
+ for (const namespace of namespaces) {
+ // we open every one of them and copy the entries to the vault
+ const storage = await openLocalStorage({ userId: me?.id, database: 'vault', namespace, supportLegacy: true })
+ const entryNames = await storage.list()
+ for (const entryName of entryNames) {
+ try {
+ const value = await storage.get(entryName)
+ if (!value) throw new Error('no value found in local storage')
+ // (we know the layout we use for vault entries)
+ const type = namespace[0]
+ const id = namespace[1]
+ if (!type || !id || isNaN(id)) throw new Error('unknown vault namespace layout')
+ // encrypt and store on the server
+ const encrypted = await encryptData(vaultKey.key, value)
+ const { data } = await apollo.mutate({
+ mutation: SET_ENTRY,
+ variables: {
+ key: entryName,
+ value: encrypted,
+ skipIfSet: true,
+ ownerType: type,
+ ownerId: Number(id)
+ }
+ })
+ if (data?.setVaultEntry) {
+ // clear local storage
+ await storage.unset(entryName)
+ migratedCount++
+ console.log('migrated to vault:', entryName)
+ } else {
+ throw new Error('could not set vault entry')
+ }
+ } catch (e) {
+ console.error('failed migrate to vault:', entryName, e)
+ }
+ }
+ await storage.close()
+ }
+ return migratedCount
+ }, [me?.id])
+
+ return migrate
+}
+
+/**
+ * A react hook to use the vault for a specific owner entity and key
+ * It will automatically handle the vault lifecycle and value updates
+ * @param {*} owner - the owner entity with id and type or __typename (must extend VaultOwner in the graphql schema)
+ * @param {*} key - the key to store and retrieve the value
+ * @param {*} defaultValue - the default value to return when no value is found
+ *
+ * @returns {Array} - An array containing:
+ * @returns {any} 0 - The current value stored in the vault.
+ * @returns {function(any): Promise} 1 - A function to set a new value in the vault.
+ * @returns {function({onlyFromLocalStorage?: boolean}): Promise} 2 - A function to clear the value in the vault.
+ * @returns {function(): Promise} 3 - A function to refresh the value from the vault.
+ */
+export default function useVault (owner, key, defaultValue) {
+ const { me } = useMe()
+ const toaster = useToast()
+ const apollo = useApolloClient()
+
+ const [value, innerSetValue] = useState(undefined)
+ const vault = useRef(openVault(apollo, me, owner))
+
+ const setValue = useCallback(async (newValue) => {
+ innerSetValue(newValue)
+ return vault.current.set(key, newValue)
+ }, [key])
+
+ const clearValue = useCallback(async ({ onlyFromLocalStorage = false } = {}) => {
+ innerSetValue(defaultValue)
+ return vault.current.clear(key, { onlyFromLocalStorage })
+ }, [key, defaultValue])
+
+ const refreshData = useCallback(async () => {
+ innerSetValue(await vault.current.get(key))
+ }, [key])
+
+ useEffect(() => {
+ const currentVault = vault.current
+ const newVault = openVault(apollo, me, owner)
+ vault.current = newVault
+ if (currentVault)currentVault.close()
+ refreshData().catch(e => toaster.danger('failed to refresh vault data: ' + e.message))
+ return () => {
+ newVault.close()
+ }
+ }, [me, owner, key])
+
+ return [value, setValue, clearValue, refreshData]
+}
+
+/**
+ * Open the vault for the given user and owner entry
+ * @param {*} apollo - the apollo client
+ * @param {*} user - the user entry with id and privates.vaultKeyHash
+ * @param {*} owner - the owner entry with id and type or __typename (must extend VaultOwner in the graphql schema)
+ *
+ * @returns {Object} - An object containing:
+ * @returns {function(string, any): Promise} get - A function to get a value from the vault.
+ * @returns {function(string, any): Promise} set - A function to set a new value in the vault.
+ * @returns {function(string, {onlyFromLocalStorage?: boolean}): Promise} clear - A function to clear a value in the vault.
+ * @returns {function(): Promise} refresh - A function to refresh the value from the vault.
+ */
+export function openVault (apollo, user, owner) {
+ const userId = user?.id
+ const type = owner?.__typename || owner?.type
+ const id = owner?.id
+
+ const localOnly = !userId
+
+ let config = null
+ let localStore = null
+ const queue = createTaskQueue()
+
+ const waitInitialization = async () => {
+ if (!config) {
+ config = await openConfig(userId)
+ }
+ if (!localStore) {
+ localStore = type && id ? await openLocalStorage({ userId, database: localOnly ? 'local-vault' : 'vault', namespace: [type, id] }) : null
+ }
+ }
+
+ const getValue = async (key, defaultValue) => {
+ return await queue.enqueue(async () => {
+ await waitInitialization()
+ if (!localStore) return undefined
+
+ if (localOnly) {
+ // local only: we fetch from local storage and return
+ return ((await localStore.get(key)) || defaultValue)
+ }
+
+ const localVaultKey = await config.get('key')
+ if (!localVaultKey?.hash) {
+ // no vault key set: use local storage
+ return ((await localStore.get(key)) || defaultValue)
+ }
+
+ if ((!user.privates.vaultKeyHash && localVaultKey?.hash) || (localVaultKey?.hash !== user.privates.vaultKeyHash)) {
+ // no or different vault setup on server: use unencrypted local storage
+ // and clear local key if it exists
+ console.log('Vault key hash mismatch, clearing local key', localVaultKey, user.privates.vaultKeyHash)
+ await config.unset('key')
+ return ((await localStore.get(key)) || defaultValue)
+ }
+
+ // if vault key hash is set on the server and matches our local key, we try to fetch from the vault
+ {
+ const { data: queriedData, error: queriedError } = await apollo.query({
+ query: GET_ENTRY,
+ variables: { key, ownerId: id, ownerType: type },
+ nextFetchPolicy: 'no-cache',
+ fetchPolicy: 'no-cache'
+ })
+ console.log(queriedData)
+ if (queriedError) throw queriedError
+ const encryptedVaultValue = queriedData?.getVaultEntry?.value
+ if (encryptedVaultValue) {
+ try {
+ const vaultValue = await decryptData(localVaultKey.key, encryptedVaultValue)
+ // console.log('decrypted value from vault:', storageKey, encrypted, decrypted)
+ // remove local storage value if it exists
+ await localStore.unset(key)
+ return vaultValue
+ } catch (e) {
+ console.error('cannot read vault data:', key, e)
+ }
+ }
+ }
+
+ // fallback to local storage
+ return ((await localStore.get(key)) || defaultValue)
+ })
+ }
+
+ const setValue = async (key, newValue) => {
+ return await queue.enqueue(async () => {
+ await waitInitialization()
+
+ if (!localStore) {
+ return
+ }
+ const vaultKey = await config.get('key')
+
+ const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash
+
+ if (useVault && !localOnly) {
+ const encryptedValue = await encryptData(vaultKey.key, newValue)
+ console.log('store encrypted value in vault:', key)
+ await apollo.mutate({
+ mutation: SET_ENTRY,
+ variables: { key, value: encryptedValue, ownerId: id, ownerType: type }
+ })
+ // clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault)
+ await localStore.unset(key)
+ } else {
+ console.log('store value in local storage:', key)
+ // otherwise use local storage
+ await localStore.set(key, newValue)
+ }
+ })
+ }
+
+ const clearValue = async (key, { onlyFromLocalStorage } = {}) => {
+ return await queue.enqueue(async () => {
+ await waitInitialization()
+ if (!localStore) return
+
+ const vaultKey = await config.get('key')
+ const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash
+
+ if (!localOnly && useVault && !onlyFromLocalStorage) {
+ await apollo.mutate({
+ mutation: UNSET_ENTRY,
+ variables: { key, ownerId: id, ownerType: type }
+ })
+ }
+ // clear local storage
+ await localStore.unset(key)
+ })
+ }
+
+ const close = async () => {
+ return await queue.enqueue(async () => {
+ await config?.close()
+ await localStore?.close()
+ config = null
+ localStore = null
+ })
+ }
+
+ return { get: getValue, set: setValue, clear: clearValue, close }
+}
+
+function useConfig () {
+ return useLocalStorage({ database: 'vault-config', namespace: ['settings'], supportLegacy: false })
+}
+
+async function openConfig (userId) {
+ return await openLocalStorage({ userId, database: 'vault-config', namespace: ['settings'] })
+}
+
+/**
+ * Derive a key to be used for the vault encryption
+ * @param {string | number} userId - the id of the user (used for salting)
+ * @param {string} passphrase - the passphrase to derive the key from
+ * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash
+ */
+async function deriveKey (userId, passphrase) {
+ const enc = new TextEncoder()
+
+ const keyMaterial = await window.crypto.subtle.importKey(
+ 'raw',
+ enc.encode(passphrase),
+ { name: 'PBKDF2' },
+ false,
+ ['deriveKey']
+ )
+
+ const key = await window.crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt: enc.encode(`stacker${userId}`),
+ // 600,000 iterations is recommended by OWASP
+ // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
+ iterations: 600_000,
+ hash: 'SHA-256'
+ },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['encrypt', 'decrypt']
+ )
+
+ const rawKey = await window.crypto.subtle.exportKey('raw', key)
+ const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey))
+ const unextractableKey = await window.crypto.subtle.importKey(
+ 'raw',
+ rawKey,
+ { name: 'AES-GCM' },
+ false,
+ ['encrypt', 'decrypt']
+ )
+
+ return {
+ key: unextractableKey,
+ hash
+ }
+}
+
+/**
+ * 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
+ */
+async function encryptData (sharedKey, data) {
+ // 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
+ const iv = window.crypto.getRandomValues(new Uint8Array(12))
+ const encoded = new TextEncoder().encode(JSON.stringify(data))
+ const encrypted = await window.crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv
+ },
+ sharedKey,
+ encoded
+ )
+ return JSON.stringify({
+ iv: toHex(iv.buffer),
+ data: 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
+ * @returns {Promise
diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js
index 0343893f..08f6203d 100644
--- a/pages/settings/wallets/[wallet].js
+++ b/pages/settings/wallets/[wallet].js
@@ -22,7 +22,7 @@ export default function WalletSettings () {
const { wallet: name } = router.query
const wallet = useWallet(name)
- const initial = wallet.fields.reduce((acc, field) => {
+ const initial = wallet?.fields.reduce((acc, field) => {
// We still need to run over all wallet fields via reduce
// even though we use wallet.config as the initial value
// since wallet.config is empty when wallet is not configured.
@@ -30,27 +30,27 @@ export default function WalletSettings () {
// 'enabled' and 'priority' which are not defined in wallet.fields.
return {
...acc,
- [field.name]: wallet.config?.[field.name] || ''
+ [field.name]: wallet?.config?.[field.name] || ''
}
- }, wallet.config)
+ }, wallet?.config)
// check if wallet uses the form-level validation built into Formik or a Yup schema
- const validateProps = typeof wallet.fieldValidation === 'function'
- ? { validate: wallet.fieldValidation }
- : { schema: wallet.fieldValidation }
+ const validateProps = typeof wallet?.fieldValidation === 'function'
+ ? { validate: wallet?.fieldValidation }
+ : { schema: wallet?.fieldValidation }
return (
-