diff --git a/api/resolvers/index.js b/api/resolvers/index.js index afeae521..eccfaf1d 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -19,6 +19,7 @@ import chainFee from './chainFee' import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' +import vault from './vault' const date = new GraphQLScalarType({ name: 'Date', @@ -55,4 +56,4 @@ const limit = createIntScalar({ export default [user, item, message, wallet, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction] + { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js new file mode 100644 index 00000000..af9e96ac --- /dev/null +++ b/api/resolvers/vault.js @@ -0,0 +1,115 @@ +import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' + +export default { + Query: { + getVaultEntry: async (parent, { key }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + if (!key) { + throw new GqlInputError('must have key') + } + const k = await models.vault.findUnique({ + where: { + userId_key: { + key, + userId: me.id + } + } + }) + return k + } + }, + + Mutation: { + setVaultKeyHash: async (parent, { hash }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + if (!hash) { + throw new GqlInputError('hash required') + } + const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + if (oldKeyHash) { + if (oldKeyHash !== hash) { + throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) + } else { + return true + } + } else { + await models.user.update({ + where: { id: me.id }, + data: { vaultKeyHash: hash } + }) + } + return true + }, + setVaultEntry: async (parent, { key, value, skipIfSet }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + if (!key) { + throw new GqlInputError('must have key') + } + if (!value) { + throw new GqlInputError('must have value') + } + if (skipIfSet) { + const existing = await models.vault.findUnique({ + where: { + userId_key: { + userId: me.id, + key + } + } + }) + if (existing) { + return false + } + } + await models.vault.upsert({ + where: { + userId_key: { + userId: me.id, + key + } + }, + update: { + value + }, + create: { + key, + value, + userId: me.id + } + }) + return true + }, + unsetVaultEntry: async (parent, { key }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + if (!key) { + throw new GqlInputError('must have key') + } + await models.vault.deleteMany({ + where: { + userId: me.id, + key + } + }) + return true + }, + clearVault: async (parent, args, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + await models.user.update({ + where: { id: me.id }, + data: { vaultKeyHash: '' } + }) + await models.vault.deleteMany({ where: { userId: me.id } }) + return true + } + } +} diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 29ed7dda..eb4e1e42 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -18,6 +18,7 @@ import admin from './admin' import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' +import vault from './vault' const common = gql` type Query { @@ -38,4 +39,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index dee9719d..f584ca7c 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -182,6 +182,7 @@ export default gql` withdrawMaxFeeDefault: Int! autoWithdrawThreshold: Int autoWithdrawMaxFeePercent: Float + vaultKeyHash: String } type UserOptional { diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js new file mode 100644 index 00000000..1429a8da --- /dev/null +++ b/api/typeDefs/vault.js @@ -0,0 +1,22 @@ +import { gql } from 'graphql-tag' + +export default gql` + type Vault { + id: ID! + key: String! + value: String! + createdAt: Date! + updatedAt: Date! + } + + extend type Query { + getVaultEntry(key: String!): Vault + } + + extend type Mutation { + setVaultEntry(key: String!, value: String!, skipIfSet: Boolean): Boolean + unsetVaultEntry(key: String!): Boolean + clearVault: Boolean + setVaultKeyHash(hash: String!): String + } +` diff --git a/components/cancel-button.js b/components/cancel-button.js index 1f4a4cc3..e9848d28 100644 --- a/components/cancel-button.js +++ b/components/cancel-button.js @@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button' export default function CancelButton ({ onClick }) { const router = useRouter() return ( - + ) } diff --git a/components/device-sync.js b/components/device-sync.js new file mode 100644 index 00000000..f076a1cb --- /dev/null +++ b/components/device-sync.js @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from 'react' +import { useMe } from './me' +import { useShowModal } from './modal' +import { useVaultConfigurator, useVaultMigration } from './use-vault' +import { Button, InputGroup } from 'react-bootstrap' +import { Form, Input, PasswordInput, SubmitButton } from './form' +import bip39Words from '@/lib/bip39-words' +import Info from './info' +import CancelButton from './cancel-button' +import * as yup from 'yup' +import { deviceSyncSchema } from '@/lib/validate' +import RefreshIcon from '@/svgs/refresh-line.svg' + +export default function DeviceSync () { + const { me } = useMe() + const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator() + const showModal = useShowModal() + + const enabled = !!me?.privates?.vaultKeyHash + const connected = !!value?.key + + const migrate = useVaultMigration() + + const manage = useCallback(async () => { + if (enabled && connected) { + showModal((onClose) => ( +
+

Device sync is enabled!

+

+ Sensitive data (like wallet credentials) is now securely synced between all connected devices. +

+

+ Disconnect to prevent this device from syncing data or to reset your passphrase. +

+
+
+ + +
+
+
+ )) + } else { + showModal((onClose) => ( + + )) + } + }, [migrate, enabled, connected, value]) + + const reset = useCallback(async () => { + const schema = yup.object().shape({ + confirm: yup.string() + .oneOf(['yes'], 'you must confirm by typing "yes"') + .required('required') + }) + showModal((onClose) => ( +
+

Reset device sync

+

+ This will delete all encrypted data on the server and disconnect all devices. +

+

+ You will need to enter a new passphrase on this and all other devices to sync data again. +

+
{ + await clearVault() + onClose() + }} + > + +
+
+ + + continue + +
+
+
+
+ )) + }, []) + + const onConnect = useCallback(async (values, formik) => { + if (values.passphrase) { + try { + await setVaultKey(values.passphrase) + await migrate() + } catch (e) { + formik?.setErrors({ passphrase: e.message }) + throw e + } + } + }, [setVaultKey, migrate]) + + return ( + <> +
device sync
+
+
+ +
+ +

+ Device sync uses end-to-end encryption to securely synchronize your data across devices. +

+

+ Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase. +

+
+
+ {enabled && !connected && ( +
+
+ +
+ +

+ If you have lost your passphrase or wish to erase all encrypted data from the server, you can reset the device sync data and start over. +

+

+ This action cannot be undone. +

+
+
+ )} + + ) +} + +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 ConnectForm ({ onClose, onConnect, enabled }) { + const [passphrase, setPassphrase] = useState(!enabled ? generatePassphrase : '') + + useEffect(() => { + const scannedPassphrase = window.localStorage.getItem('qr:passphrase') + if (scannedPassphrase) { + setPassphrase(scannedPassphrase) + window.localStorage.removeItem('qr:passphrase') + } + }) + + const newPassphrase = useCallback(() => { + setPassphrase(() => generatePassphrase(12)) + }, []) + + return ( +
+

{!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.'} +

+
{ + try { + await onConnect(values, formik) + onClose() + } catch {} + }} + > + + + + ) + } + /> +

+ { + !enabled + ? 'This passphrase is stored only on your device and cannot be shown again.' + : 'If you have forgotten your passphrase, you can reset and start over.' + } +

+
+
+
+ + {enabled ? 'connect' : 'enable'} +
+
+
+ +
+ ) +} diff --git a/components/form.js b/components/form.js index 26a956af..9a26091e 100644 --- a/components/form.js +++ b/components/form.js @@ -33,6 +33,12 @@ import EyeClose from '@/svgs/eye-close-line.svg' import Info from './info' import { useMe } from './me' import classNames from 'classnames' +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' export class SessionRequiredError extends Error { constructor () { @@ -69,31 +75,41 @@ export function SubmitButton ({ ) } -export function CopyInput (props) { +function CopyButton ({ value, icon, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) - const handleClick = async () => { + const handleClick = useCallback(async () => { try { - await copy(props.placeholder) + await copy(value) toaster.success('copied') setCopied(true) setTimeout(() => setCopied(false), 1500) } catch (err) { toaster.danger('failed to copy') } + }, [toaster, value]) + + if (icon) { + return ( + + + + ) } + return ( + + ) +} + +export function CopyInput (props) { return ( {copied ? : 'copy'} - + } {...props} /> @@ -711,10 +727,11 @@ export function InputUserSuggest ({ ) } -export function Input ({ label, groupClassName, ...props }) { +export function Input ({ label, groupClassName, under, ...props }) { return ( + {under} ) } @@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) { > {!showPass ? : } ) } -export function PasswordInput ({ newPass, ...props }) { +function QrPassword ({ value }) { + const showModal = useShowModal() + const toaster = useToast() + + const showQr = useCallback(() => { + showModal(close => ( +
+

You can import this passphrase into another device by scanning this QR code

+ +
+ )) + }, [toaster, value, showModal]) + + return ( + <> + + + + + ) +} + +function PasswordScanner ({ onDecode }) { + 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 }) + }} + onError={(error) => { + if (error instanceof DOMException) return + toaster.danger('qr scan error:', error.message || error.toString?.()) + onClose({ back: 1 }) + }} + /> + ) + }) + }} + > + + + ) +} + +export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) { const [showPass, setShowPass] = useState(false) + const [field] = useField(props) + + const Append = useMemo(() => { + return ( + <> + setShowPass(!showPass)} /> + {copy && ( + + )} + {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) + }} + />)} + {append} + + ) + }, [showPass, copy, field?.value, qr, readOnly, append]) + + const maskedValue = !showPass && props.as === 'textarea' ? field?.value?.replace(/./g, '•') : field?.value return ( setShowPass(!showPass)} />} + readOnly={readOnly} + append={props.as === 'textarea' ? undefined : Append} + value={maskedValue} + under={props.as === 'textarea' + ? ( +
+ {Append} +
) + : 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 c5344688..71680625 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 ( @@ -260,6 +261,7 @@ function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const wallets = useWallets() const { multiAuthSignout } = useAccounts() + const { me } = useMe() return (
@@ -288,6 +290,7 @@ function LogoutObstacle ({ onClose }) { } await wallets.resetClient().catch(console.error) + await resetVaultKey(me?.id) await signOut({ callbackUrl: '/' }) }} diff --git a/components/use-vault.js b/components/use-vault.js new file mode 100644 index 00000000..e65df387 --- /dev/null +++ b/components/use-vault.js @@ -0,0 +1,487 @@ +import { useCallback, useState, useEffect } from 'react' +import { useMe } from '@/components/me' +import { useMutation, useQuery } from '@apollo/client' +import { GET_ENTRY, SET_ENTRY, UNSET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault' +import { E_VAULT_KEY_EXISTS } from '@/lib/error' +import { SSR } from '@/lib/constants' +import { useToast } from '@/components/toast' + +const USE_INDEXEDDB = true + +export function useVaultConfigurator () { + const { me } = useMe() + const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH) + const toaster = useToast() + + // vault key stored locally + const [vaultKey, innerSetVaultKey] = useState(null) + + useEffect(() => { + if (!me) return + (async () => { + let localVaultKey = await getLocalKey(me.id) + + if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) { + // We can tell that another device has reset the vault if the values + // on the server are encrypted with a different key or no key exists anymore. + // In that case, our local key is no longer valid and our device needs to be connected + // to the vault again by entering the correct passphrase. + console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash) + localVaultKey = null + await unsetLocalKey(me.id) + } + + innerSetVaultKey(localVaultKey) + })() + }, [me?.privates?.vaultKeyHash]) + + // clear vault: remove everything and reset the key + const [clearVault] = useMutation(CLEAR_VAULT, { + onCompleted: async () => { + await unsetLocalKey(me.id) + 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 setLocalKey(me.id, vaultKey) + }, [setVaultKeyHash]) + + // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that) + const disconnectVault = useCallback(async () => { + await unsetLocalKey(me.id) + innerSetVaultKey(null) + }, [innerSetVaultKey]) + + return [vaultKey, setVaultKey, clearVault, disconnectVault] +} + +export function useVaultMigration () { + const { me } = useMe() + const [setVaultEntry] = useMutation(SET_ENTRY) + + // migrate local storage to vault + const migrate = useCallback(async () => { + const vaultKey = await getLocalKey(me.id) + if (!vaultKey) throw new Error('vault key not found') + + let migratedCount = 0 + + for (const migratableKey of retrieveMigratableKeys(me.id)) { + try { + const value = JSON.parse(window.localStorage.getItem(migratableKey.localStorageKey)) + if (!value) throw new Error('no value found in local storage') + + const encrypted = await encryptJSON(vaultKey, value) + + const { data } = await setVaultEntry({ variables: { key: migratableKey.vaultStorageKey, value: encrypted, skipIfSet: true } }) + if (data?.setVaultEntry) { + window.localStorage.removeItem(migratableKey.localStorageKey) + migratedCount++ + console.log('migrated to vault:', migratableKey) + } else { + throw new Error('could not set vault entry') + } + } catch (e) { + console.error('failed migrate to vault:', migratableKey, e) + } + } + + return migratedCount + }, [me?.id]) + + return migrate +} + +// used to get and set values in the vault +export default function useVault (vaultStorageKey, defaultValue, options = { localOnly: false }) { + const { me } = useMe() + const localOnly = options.localOnly || !me + + // This is the key that we will use in local storage whereas vaultStorageKey is the key that we + // will use on the server ("the vault"). + const localStorageKey = getLocalStorageKey(vaultStorageKey, me?.id, localOnly) + + const [setVaultValue] = useMutation(SET_ENTRY) + const [value, innerSetValue] = useState(undefined) + const [clearVaultValue] = useMutation(UNSET_ENTRY) + const { data: vaultData, refetch: refetchVaultValue } = useQuery(GET_ENTRY, { + variables: { key: vaultStorageKey }, + // fetchPolicy only applies to first execution on mount so we also need to + // set nextFetchPolicy to make sure we don't serve stale values from cache + nextFetchPolicy: 'no-cache', + fetchPolicy: 'no-cache' + }) + + useEffect(() => { + (async () => { + if (localOnly) { + innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue) + return + } + + const localVaultKey = await getLocalKey(me?.id) + + if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) { + // no or different vault setup on server + // use unencrypted local storage + await unsetLocalKey(me.id) + innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue) + return + } + + // if vault key hash is set on the server, vault entry exists and vault key is set on the device + // decrypt and use the value from the server + const encrypted = vaultData?.getVaultEntry?.value + if (encrypted) { + try { + const decrypted = await decryptJSON(localVaultKey, encrypted) + // console.log('decrypted value from vault:', storageKey, encrypted, decrypted) + innerSetValue(decrypted) + // remove local storage value if it exists + await unsetLocalStorage(localStorageKey) + return + } catch (e) { + console.error('cannot read vault data:', vaultStorageKey, e) + } + } + + // fallback to local storage + innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue) + })() + }, [vaultData, me?.privates?.vaultKeyHash, localOnly]) + + const setValue = useCallback(async (newValue) => { + const vaultKey = await getLocalKey(me?.id) + + const useVault = vaultKey && vaultKey.hash === me.privates.vaultKeyHash + + if (useVault && !localOnly) { + const encryptedValue = await encryptJSON(vaultKey, newValue) + await setVaultValue({ variables: { key: vaultStorageKey, value: encryptedValue } }) + console.log('stored encrypted value in vault:', vaultStorageKey, encryptedValue) + // clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault) + await unsetLocalStorage(localStorageKey) + } else { + console.log('stored value in local storage:', localStorageKey, newValue) + // otherwise use local storage + await setLocalStorage(localStorageKey, newValue) + } + // refresh in-memory value + innerSetValue(newValue) + }, [me?.privates?.vaultKeyHash, localStorageKey, vaultStorageKey, localOnly]) + + const clearValue = useCallback(async ({ onlyFromLocalStorage }) => { + // unset a value + // clear server + if (!localOnly && !onlyFromLocalStorage) { + await clearVaultValue({ variables: { key: vaultStorageKey } }) + await refetchVaultValue() + } + // clear local storage + await unsetLocalStorage(localStorageKey) + // clear in-memory value + innerSetValue(undefined) + }, [vaultStorageKey, localStorageKey, localOnly]) + + return [value, setValue, clearValue, refetchVaultValue] +} + +function retrieveMigratableKeys (userId) { + // get all the local storage keys that can be migrated + const out = [] + + for (const key of Object.keys(window.localStorage)) { + if (key.includes(':local-only:')) continue + if (!key.endsWith(`:${userId}`)) continue + + if (key.startsWith('vault:')) { + out.push({ + vaultStorageKey: key.substring('vault:'.length, key.length - `:${userId}`.length), + localStorageKey: key + }) + } + + // required for backwards compatibility with keys that were stored before we had the vault + if (key.startsWith('wallet:')) { + out.push({ + vaultStorageKey: key.substring(0, key.length - `:${userId}`.length), + localStorageKey: key + }) + } + } + return out +} + +async function getLocalStorageBackend (useIndexedDb) { + if (SSR) return null + if (USE_INDEXEDDB && useIndexedDb && window.indexedDB && !window.snVaultIDB) { + try { + const storage = await new Promise((resolve, reject) => { + const db = window.indexedDB.open('sn-vault', 1) + db.onupgradeneeded = (event) => { + const db = event.target.result + db.createObjectStore('vault', { keyPath: 'key' }) + } + db.onsuccess = () => { + if (!db?.result?.transaction) reject(new Error('unsupported implementation')) + else resolve(db.result) + } + db.onerror = reject + }) + window.snVaultIDB = storage + } catch (e) { + console.error('could not use indexedDB:', e) + } + } + + const isIDB = useIndexedDb && !!window.snVaultIDB + + return { + isIDB, + set: async (key, value) => { + if (isIDB) { + const tx = window.snVaultIDB.transaction(['vault'], 'readwrite') + const objectStore = tx.objectStore('vault') + objectStore.add({ key, value }) + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + } else { + window.localStorage.setItem(key, JSON.stringify(value)) + } + }, + get: async (key) => { + if (isIDB) { + const tx = window.snVaultIDB.transaction(['vault'], 'readonly') + const objectStore = tx.objectStore('vault') + const request = objectStore.get(key) + return await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result?.value) + request.onerror = reject + }) + } else { + const v = window.localStorage.getItem(key) + return v ? JSON.parse(v) : null + } + }, + clear: async (key) => { + if (isIDB) { + const tx = window.snVaultIDB.transaction(['vault'], 'readwrite') + const objectStore = tx.objectStore('vault') + objectStore.delete(key) + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + } else { + window.localStorage.removeItem(key) + } + } + } +} + +function getLocalStorageKey (key, userId, localOnly) { + if (!userId) userId = 'anon' + // We prefix localStorageKey with 'vault:' so we know which + // keys we need to migrate to the vault when device sync is enabled. + let localStorageKey = `vault:${key}` + + // wallets like WebLN don't make sense to share across devices since they rely on a browser extension. + // We check for this ':local-only:' tag during migration to skip any keys that contain it. + if (localOnly) { + localStorageKey = `vault:local-only:${key}` + } + + // always scope to user to avoid messing with wallets of other users on same device that might exist + return `${localStorageKey}:${userId}` +} + +async function setLocalKey (userId, localKey) { + if (SSR) return + if (!userId) userId = 'anon' + const storage = await getLocalStorageBackend(true) + const k = `vault-key:local-only:${userId}` + const { key, hash } = localKey + + const rawKey = await window.crypto.subtle.exportKey('raw', key) + if (storage.isIDB) { + let nonExtractableKey + // if IDB, we ensure the key is non extractable + if (localKey.extractable) { + nonExtractableKey = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + } else { + nonExtractableKey = localKey.key + } + // and we store it + return await storage.set(k, { key: nonExtractableKey, hash, extractable: false }) + } else { + // if non IDB we need to serialize the key to store it + const keyHex = toHex(rawKey) + return await storage.set(k, { key: keyHex, hash, extractable: true }) + } +} + +async function getLocalKey (userId) { + if (SSR) return null + if (!userId) userId = 'anon' + const storage = await getLocalStorageBackend(true) + const key = await storage.get(`vault-key:local-only:${userId}`) + if (!key) return null + if (!storage.isIDB) { + // if non IDB we need to deserialize the key + const rawKey = fromHex(key.key) + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + key.key = keyMaterial + key.extractable = true + } + return key +} + +export async function unsetLocalKey (userId) { + if (SSR) return + if (!userId) userId = 'anon' + const storage = await getLocalStorageBackend(true) + return await storage.clear(`vault-key:local-only:${userId}`) +} + +async function setLocalStorage (key, value) { + if (SSR) return + const storage = await getLocalStorageBackend(false) + await storage.set(key, value) +} + +async function getLocalStorage (key) { + if (SSR) return null + const storage = await getLocalStorageBackend(false) + let v = await storage.get(key) + + // ensure backwards compatible with wallet keys that we used before we had the vault + if (!v) { + const oldKey = key.replace(/vault:(local-only:)?/, '') + v = await storage.get(oldKey) + } + + return v +} + +async function unsetLocalStorage (key) { + if (SSR) return + const storage = await getLocalStorageBackend(false) + await storage.clear(key) +} + +function toHex (buffer) { + const byteArray = new Uint8Array(buffer) + const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('') + return hexString +} + +function fromHex (hex) { + const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))) + return byteArray.buffer +} + +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 rawHash = await window.crypto.subtle.digest('SHA-256', rawKey) + return { + key, + hash: toHex(rawHash), + extractable: true + } +} + +async function encryptJSON (localKey, jsonData) { + const { key } = localKey + + // 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(jsonData)) + + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encoded + ) + + return JSON.stringify({ + iv: toHex(iv.buffer), + data: toHex(encrypted) + }) +} + +async function decryptJSON (localKey, encryptedData) { + const { key } = localKey + + let { iv, data } = JSON.parse(encryptedData) + + iv = fromHex(iv) + data = fromHex(data) + + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv + }, + key, + data + ) + + const decoded = new TextDecoder().decode(decrypted) + + return JSON.parse(decoded) +} diff --git a/fragments/users.js b/fragments/users.js index dfdb2df6..0383dcd4 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -55,6 +55,7 @@ export const ME = gql` autoWithdrawMaxFeePercent autoWithdrawThreshold disableFreebies + vaultKeyHash } optional { isContributor @@ -390,3 +391,9 @@ export const USER_STATS = gql` } } }` + +export const SET_VAULT_KEY_HASH = gql` + mutation setVaultKeyHash($hash: String!) { + setVaultKeyHash(hash: $hash) + } +` diff --git a/fragments/vault.js b/fragments/vault.js new file mode 100644 index 00000000..6c313a6a --- /dev/null +++ b/fragments/vault.js @@ -0,0 +1,33 @@ +import { gql } from '@apollo/client' + +export const GET_ENTRY = gql` + query GetVaultEntry($key: String!) { + getVaultEntry(key: $key) { + value + } + } +` + +export const SET_ENTRY = gql` + mutation SetVaultEntry($key: String!, $value: String!, $skipIfSet: Boolean) { + setVaultEntry(key: $key, value: $value, skipIfSet: $skipIfSet) + } +` + +export const UNSET_ENTRY = gql` + mutation UnsetVaultEntry($key: String!) { + unsetVaultEntry(key: $key) + } +` + +export const CLEAR_VAULT = gql` + mutation ClearVault { + clearVault + } +` + +export const SET_VAULT_KEY_HASH = gql` + mutation SetVaultKeyHash($hash: String!) { + setVaultKeyHash(hash: $hash) + } +` diff --git a/lib/error.js b/lib/error.js index fcbade2b..3ba45521 100644 --- a/lib/error.js +++ b/lib/error.js @@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql' export const E_FORBIDDEN = 'E_FORBIDDEN' export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED' export const E_BAD_INPUT = 'E_BAD_INPUT' +export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS' export class GqlAuthorizationError extends GraphQLError { constructor (message) { @@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError { } export class GqlInputError extends GraphQLError { - constructor (message) { - super(message, { extensions: { code: E_BAD_INPUT } }) + constructor (message, code) { + super(message, { extensions: { code: code || E_BAD_INPUT } }) } } diff --git a/lib/validate.js b/lib/validate.js index cb52cdb4..584226b7 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -840,3 +840,23 @@ export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE } export const toPositiveNumber = (x) => toNumber(x, 0) + +export const deviceSyncSchema = object().shape({ + passphrase: string().required('required') + .test(async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + + if (words.length < 12) { + return context.createError({ message: 'needs at least 12 words' }) + } + + return true + }) +}) diff --git a/pages/settings/index.js b/pages/settings/index.js index 10f043e2..f35504c0 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -31,6 +31,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { useField } from 'formik' import styles from './settings.module.css' import { AuthBanner } from '@/components/banners' +import DeviceSync from '@/components/device-sync' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
saturday newsletter
{settings?.authMethods && } +
diff --git a/prisma/migrations/20240922065504_vault/migration.sql b/prisma/migrations/20240922065504_vault/migration.sql new file mode 100644 index 00000000..163024f8 --- /dev/null +++ b/prisma/migrations/20240922065504_vault/migration.sql @@ -0,0 +1,39 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT ''; + +-- CreateTable +CREATE TABLE "Vault" ( + "id" SERIAL NOT NULL, + "key" VARCHAR(64) NOT NULL, + "value" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Vault_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Vault.userId_index" ON "Vault"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Vault_userId_key_key" ON "Vault"("userId", "key"); + +-- AddForeignKey +ALTER TABLE "Vault" ADD CONSTRAINT "Vault_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- avoid spam +CREATE OR REPLACE FUNCTION enforce_vault_limit() +RETURNS TRIGGER AS $$ +BEGIN + IF (SELECT COUNT(*) FROM "Vault" WHERE "userId" = NEW."userId") >= 100 THEN + RAISE EXCEPTION 'vault limit of 100 entries per user reached'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_vault_limit_trigger +BEFORE INSERT ON "Vault" +FOR EACH ROW +EXECUTE FUNCTION enforce_vault_limit(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1a9a3795..6d8e26b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,6 +134,8 @@ model User { ItemUserAgg ItemUserAgg[] oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") + vaultKeyHash String @default("") + vaultEntries Vault[] @relation("VaultEntries") @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -1099,6 +1101,19 @@ model Reminder { @@index([userId, remindAt], map: "Reminder.userId_reminderAt_index") } +model Vault { + id Int @id @default(autoincrement()) + key String @db.VarChar(64) + value String @db.Text + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@unique([userId, key]) + @@index([userId], map: "Vault.userId_index") +} + enum EarnType { POST COMMENT diff --git a/svgs/clipboard-line.svg b/svgs/clipboard-line.svg new file mode 100644 index 00000000..6362c172 --- /dev/null +++ b/svgs/clipboard-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/qr-code-line.svg b/svgs/qr-code-line.svg new file mode 100644 index 00000000..7fbcea98 --- /dev/null +++ b/svgs/qr-code-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/qr-scan-line.svg b/svgs/qr-scan-line.svg new file mode 100644 index 00000000..6b9de9a4 --- /dev/null +++ b/svgs/qr-scan-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/refresh-line.svg b/svgs/refresh-line.svg new file mode 100644 index 00000000..05cf9868 --- /dev/null +++ b/svgs/refresh-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/README.md b/wallets/README.md index 5fc96020..71713516 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -57,6 +57,10 @@ This acts as an ID for this wallet on the client. It therefore must be unique ac Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead. +- `perDevice?: boolean` + +This is an optional value. Set this to true if your wallet needs to be configured per device and should thus not be synced across devices. + - `fields: WalletField[]` Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits). diff --git a/wallets/index.js b/wallets/index.js index a1349a85..321a19aa 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,8 +1,7 @@ import { useCallback } from 'react' import { useMe } from '@/components/me' -import useClientConfig from '@/components/use-local-state' +import useVault from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' -import { SSR } from '@/lib/constants' import { bolt11Tags } from '@/lib/bolt11' import walletDefs from 'wallets/client' @@ -22,28 +21,44 @@ export const Status = { } export function useWallet (name) { + if (!name) { + const defaultWallet = walletDefs + .filter(def => !!def.sendPayment && !!def.name) + .map(def => { + const w = useWallet(def.name) + return w + }) + .filter((wallet) => { + return wallet?.enabled + }) + .sort(walletPrioritySort)[0] + return defaultWallet + } + const { me } = useMe() const showModal = useShowModal() const toaster = useToast() const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) - const wallet = name ? getWalletByName(name) : getEnabledWallet(me) + const wallet = getWalletByName(name) const { logger, deleteLogs } = useWalletLogger(wallet) const [config, saveConfig, clearConfig] = useConfig(wallet) const hasConfig = wallet?.fields.length > 0 const _isConfigured = isConfigured({ ...wallet, config }) - const enablePayments = useCallback(() => { - enableWallet(name, me) + const enablePayments = useCallback((updatedConfig) => { + // config might have been updated in the same render we call this function + // so we allow to pass in the updated config to not overwrite it a stale one + saveConfig({ ...(updatedConfig || config), enabled: true }, { skipTests: true }) logger.ok('payments enabled') disableFreebies().catch(console.error) - }, [name, me, logger]) + }, [config, logger]) - const disablePayments = useCallback(() => { - disableWallet(name, me) + const disablePayments = useCallback((updatedConfig) => { + saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true }) logger.info('payments disabled') - }, [name, me, logger]) + }, [config, logger]) const status = config?.enabled ? Status.Enabled : Status.Initialized const enabled = status === Status.Enabled @@ -65,7 +80,7 @@ export function useWallet (name) { const setPriority = useCallback(async (priority) => { if (_isConfigured && priority !== config.priority) { try { - await saveConfig({ ...config, priority }, { logger, priorityOnly: true }) + await saveConfig({ ...config, priority }, { logger, skipTests: true }) } catch (err) { toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) } @@ -85,7 +100,7 @@ export function useWallet (name) { logger.error(message) throw err } - }, [clearConfig, logger, disablePayments]) + }, [clearConfig, logger]) const deleteLogs_ = useCallback(async (options) => { // first argument is to override the wallet @@ -158,8 +173,9 @@ function extractServerConfig (fields, config) { function useConfig (wallet) { const { me } = useMe() - const storageKey = getStorageKey(wallet?.name, me) - const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {}) + const storageKey = `wallet:${wallet.name}` + + const [clientConfig, setClientConfig, clearClientConfig] = useVault(storageKey, {}, { localOnly: wallet.perDevice }) const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) @@ -181,7 +197,7 @@ function useConfig (wallet) { config.priority ||= priority } - const saveConfig = useCallback(async (newConfig, { logger, priorityOnly }) => { + const saveConfig = useCallback(async (newConfig, { logger, skipTests } = {}) => { // NOTE: // verifying the client/server configuration before saving it // prevents unsetting just one configuration if both are set. @@ -203,7 +219,7 @@ function useConfig (wallet) { } if (valid) { - if (priorityOnly) { + if (skipTests) { setClientConfig(newClientConfig) } else { try { @@ -218,9 +234,12 @@ function useConfig (wallet) { } setClientConfig(newClientConfig) + logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments') - if (newConfig.enabled) wallet.enablePayments() - else wallet.disablePayments() + + // we only call enable / disable for the side effects + if (newConfig.enabled) wallet.enablePayments(newClientConfig) + else wallet.disablePayments(newClientConfig) } } } @@ -238,17 +257,17 @@ function useConfig (wallet) { valid = false } - if (valid) await setServerConfig(newServerConfig, { priorityOnly }) + if (valid) await setServerConfig(newServerConfig, { priorityOnly: skipTests }) } }, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet]) - const clearConfig = useCallback(async ({ logger, clientOnly }) => { + const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => { if (hasClientConfig) { - clearClientConfig() - wallet.disablePayments() + clearClientConfig(options) + wallet.disablePayments({}) logger.ok('wallet detached for payments') } - if (hasServerConfig && !clientOnly) await clearServerConfig() + if (hasServerConfig && !clientOnly) await clearServerConfig(options) }, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet]) return [config, saveConfig, clearConfig] @@ -370,20 +389,6 @@ export function getWalletByType (type) { return walletDefs.find(def => def.walletType === type) } -export function getEnabledWallet (me) { - return walletDefs - .filter(def => !!def.sendPayment) - .map(def => { - // populate definition with properties from useWallet that are required for sorting - const key = getStorageKey(def.name, me) - const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key)) - const priority = config?.priority - return { ...def, config, priority } - }) - .filter(({ config }) => config?.enabled) - .sort(walletPrioritySort)[0] -} - export function walletPrioritySort (w1, w2) { const delta = w1.priority - w2.priority // delta is NaN if either priority is undefined @@ -409,7 +414,7 @@ export function useWallets () { const resetClient = useCallback(async (wallet) => { for (const w of wallets) { if (w.canSend) { - await w.delete({ clientOnly: true }) + await w.delete({ clientOnly: true, onlyFromLocalStorage: true }) } await w.deleteLogs({ clientOnly: true }) } @@ -417,29 +422,3 @@ export function useWallets () { return { wallets, resetClient } } - -function getStorageKey (name, me) { - let storageKey = `wallet:${name}` - - // WebLN has no credentials we need to scope to users - // so we can use the same storage key for all users - if (me && name !== 'webln') { - storageKey = `${storageKey}:${me.id}` - } - - return storageKey -} - -function enableWallet (name, me) { - const key = getStorageKey(name, me) - const config = JSON.parse(window.localStorage.getItem(key)) || {} - config.enabled = true - window.localStorage.setItem(key, JSON.stringify(config)) -} - -function disableWallet (name, me) { - const key = getStorageKey(name, me) - const config = JSON.parse(window.localStorage.getItem(key)) || {} - config.enabled = false - window.localStorage.setItem(key, JSON.stringify(config)) -} diff --git a/wallets/webln/index.js b/wallets/webln/index.js index 6bfb26d5..7c10f7a4 100644 --- a/wallets/webln/index.js +++ b/wallets/webln/index.js @@ -3,6 +3,8 @@ import { useWallet } from 'wallets' export const name = 'webln' +export const perDevice = true + export const fields = [] export const fieldValidation = ({ enabled }) => { @@ -35,6 +37,8 @@ export default function WebLnProvider ({ children }) { wallet.disablePayments() } + if (!window.webln) onDisable() + window.addEventListener('webln:enabled', onEnable) // event is not fired by Alby browser extension but added here for sake of completeness window.addEventListener('webln:disabled', onDisable)