Fix unintended sharing of wallets and logs (#1127)

* Suffix localStorage key for attached wallets with me.id

* Suffix IndexedDB database name with me.id

* Fix TypeError: Cannot destructure property of 'config' as it is null

* Detach wallet on logout

* Migrate to new storage keys

* Use Promise.catch for togglePushSubscription on logout

It's more concise

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-05-03 16:42:00 -05:00 committed by GitHub
parent 72c27e339c
commit 5c593ce280
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 98 additions and 59 deletions

View File

@ -126,14 +126,14 @@ export function useServiceWorkerLogger () {
const WalletLoggerContext = createContext() const WalletLoggerContext = createContext()
const WalletLogsContext = createContext() const WalletLogsContext = createContext()
const initIndexedDB = async (storeName) => { const initIndexedDB = async (dbName, storeName) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.indexedDB) { if (!window.indexedDB) {
return reject(new Error('IndexedDB not supported')) return reject(new Error('IndexedDB not supported'))
} }
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
const request = window.indexedDB.open('app:storage', 1) const request = window.indexedDB.open(dbName, 1)
let db let db
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
@ -159,7 +159,12 @@ const initIndexedDB = async (storeName) => {
} }
const WalletLoggerProvider = ({ children }) => { const WalletLoggerProvider = ({ children }) => {
const me = useMe()
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
let dbName = 'app:storage'
if (me) {
dbName = `${dbName}:${me.id}`
}
const idbStoreName = 'wallet_logs' const idbStoreName = 'wallet_logs'
const idb = useRef() const idb = useRef()
const logQueue = useRef([]) const logQueue = useRef([])
@ -211,7 +216,7 @@ const WalletLoggerProvider = ({ children }) => {
}, []) }, [])
useEffect(() => { useEffect(() => {
initIndexedDB(idbStoreName) initIndexedDB(dbName, idbStoreName)
.then(db => { .then(db => {
idb.current = db idb.current = db

View File

@ -23,6 +23,7 @@ import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg' import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes' import { useHasNewNotes } from '../use-has-new-notes'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { useWebLNConfigurator } from '../webln'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
@ -162,8 +163,6 @@ export function NavWalletSummary ({ className }) {
export function MeDropdown ({ me, dropNavKey }) { export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null if (!me) return null
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { deleteLogs } = useWalletLogger()
return ( return (
<div className='position-relative'> <div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
@ -202,22 +201,7 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link> </Link>
</div> </div>
<Dropdown.Divider /> <Dropdown.Divider />
<Dropdown.Item <LogoutDropdownItem />
onClick={async () => {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
// don't prevent signout because of an unsubscription error
await togglePushSubscription().catch(console.error)
}
// delete client wallet logs to prevent leak of private data if a shared device was used
await deleteLogs(Wallet.NWC).catch(console.error)
await deleteLogs(Wallet.LNbits).catch(console.error)
await deleteLogs(Wallet.LNC).catch(console.error)
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
{!me.bioId && {!me.bioId &&
@ -271,6 +255,31 @@ export default function LoginButton ({ className }) {
) )
} }
export function LogoutDropdownItem () {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const webLN = useWebLNConfigurator()
const { deleteLogs } = useWalletLogger()
return (
<Dropdown.Item
onClick={async () => {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription().catch(console.error)
}
// detach wallets
await webLN.clearConfig().catch(console.error)
// delete client wallet logs to prevent leak of private data if a shared device was used
await deleteLogs(Wallet.NWC).catch(console.error)
await deleteLogs(Wallet.LNbits).catch(console.error)
await deleteLogs(Wallet.LNC).catch(console.error)
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
)
}
export function LoginButtons () { export function LoginButtons () {
return ( return (
<> <>

View File

@ -2,9 +2,7 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap' import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link' import Link from 'next/link'
import { useServiceWorker } from '@/components/serviceworker' import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import { signOut } from 'next-auth/react'
import { LoginButtons, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css' import styles from './footer.module.css'
import classNames from 'classnames' import classNames from 'classnames'
@ -14,7 +12,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
const handleClose = () => setShow(false) const handleClose = () => setShow(false)
const handleShow = () => setShow(true) const handleShow = () => setShow(true)
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const MeImage = ({ onClick }) => me const MeImage = ({ onClick }) => me
? ( ? (
@ -78,22 +75,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link> </Link>
</div> </div>
<Dropdown.Divider /> <Dropdown.Divider />
<Dropdown.Item <LogoutDropdownItem />
onClick={async () => {
try {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription()
}
} catch (err) {
// don't prevent signout because of an unsubscription error
console.error(err)
}
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</> </>
) )
: <LoginButtons />} : <LoginButtons />}

View File

@ -33,6 +33,15 @@ export const Status = {
Error: 'Error' Error: 'Error'
} }
export function migrateLocalStorage (oldStorageKey, newStorageKey) {
const item = window.localStorage.getItem(oldStorageKey)
if (item) {
window.localStorage.setItem(newStorageKey, item)
window.localStorage.removeItem(oldStorageKey)
}
return item
}
function RawWebLNProvider ({ children }) { function RawWebLNProvider ({ children }) {
const lnbits = useLNbits() const lnbits = useLNbits()
const nwc = useNWC() const nwc = useNWC()
@ -114,8 +123,14 @@ function RawWebLNProvider ({ children }) {
}) })
}, [setEnabledProviders]) }, [setEnabledProviders])
const clearConfig = useCallback(async () => {
lnbits.clearConfig()
nwc.clearConfig()
await lnc.clearConfig()
}, [])
return ( return (
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider }}> <WebLNContext.Provider value={{ provider: isEnabled(provider) ? { sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
{children} {children}
</WebLNContext.Provider> </WebLNContext.Provider>
) )

View File

@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts // Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
@ -65,13 +66,17 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
} }
export function LNbitsProvider ({ children }) { export function LNbitsProvider ({ children }) {
const me = useMe()
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('') const [adminKey, setAdminKey] = useState('')
const [status, setStatus] = useState() const [status, setStatus] = useState()
const { logger } = useWalletLogger(Wallet.LNbits) const { logger } = useWalletLogger(Wallet.LNbits)
const name = 'LNbits' const name = 'LNbits'
const storageKey = 'webln:provider:lnbits' let storageKey = 'webln:provider:lnbits'
if (me) {
storageKey = `${storageKey}:${me.id}`
}
const getInfo = useCallback(async () => { const getInfo = useCallback(async () => {
const response = await getWallet(url, adminKey) const response = await getWallet(url, adminKey)
@ -110,12 +115,19 @@ export function LNbitsProvider ({ children }) {
}, [logger, url, adminKey]) }, [logger, url, adminKey])
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey) let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (!configStr) {
if (me) {
// backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) { if (!configStr) {
logger.info('no existing config found') logger.info('no existing config found')
return return
} }
}
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
@ -141,7 +153,7 @@ export function LNbitsProvider ({ children }) {
logger.info('wallet disabled') logger.info('wallet disabled')
throw err throw err
} }
}, [logger]) }, [me, logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid

View File

@ -1,20 +1,23 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import LNC from '@lightninglabs/lnc-web' import LNC from '@lightninglabs/lnc-web'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import useModal from '../modal' import useModal from '../modal'
import { Form, PasswordInput, SubmitButton } from '../form' import { Form, PasswordInput, SubmitButton } from '../form'
import CancelButton from '../cancel-button' import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex' import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const LNCContext = createContext() const LNCContext = createContext()
const mutex = new Mutex() const mutex = new Mutex()
async function getLNC () { async function getLNC ({ me }) {
if (window.lnc) return window.lnc if (window.lnc) return window.lnc
window.lnc = new LNC({ }) // backwards compatibility: migrate to new storage key
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:${me.id}`)
window.lnc = new LNC({ namespace: me?.id })
return window.lnc return window.lnc
} }
@ -33,6 +36,7 @@ function validateNarrowPerms (lnc) {
} }
export function LNCProvider ({ children }) { export function LNCProvider ({ children }) {
const me = useMe()
const { logger } = useWalletLogger(Wallet.LNC) const { logger } = useWalletLogger(Wallet.LNC)
const [config, setConfig] = useState({}) const [config, setConfig] = useState({})
const [lnc, setLNC] = useState() const [lnc, setLNC] = useState()
@ -165,7 +169,7 @@ export function LNCProvider ({ children }) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const lnc = await getLNC() const lnc = await getLNC({ me })
setLNC(lnc) setLNC(lnc)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (lnc.credentials.isPaired) { if (lnc.credentials.isPaired) {
@ -185,7 +189,7 @@ export function LNCProvider ({ children }) {
setStatus(Status.Error) setStatus(Status.Error)
} }
})() })()
}, [setStatus, setConfig, logger]) }, [me, setStatus, setConfig, logger])
return ( return (
<LNCContext.Provider value={{ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}> <LNCContext.Provider value={{ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}>

View File

@ -4,13 +4,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { Relay, finalizeEvent, nip04 } from 'nostr-tools' import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '@/lib/url' import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const NWCContext = createContext() const NWCContext = createContext()
export function NWCProvider ({ children }) { export function NWCProvider ({ children }) {
const me = useMe()
const [nwcUrl, setNwcUrl] = useState('') const [nwcUrl, setNwcUrl] = useState('')
const [walletPubkey, setWalletPubkey] = useState() const [walletPubkey, setWalletPubkey] = useState()
const [relayUrl, setRelayUrl] = useState() const [relayUrl, setRelayUrl] = useState()
@ -19,7 +21,10 @@ export function NWCProvider ({ children }) {
const { logger } = useWalletLogger(Wallet.NWC) const { logger } = useWalletLogger(Wallet.NWC)
const name = 'NWC' const name = 'NWC'
const storageKey = 'webln:provider:nwc' let storageKey = 'webln:provider:nwc'
if (me) {
storageKey = `${storageKey}:${me.id}`
}
const getInfo = useCallback(async (relayUrl, walletPubkey) => { const getInfo = useCallback(async (relayUrl, walletPubkey) => {
logger.info(`requesting info event from ${relayUrl}`) logger.info(`requesting info event from ${relayUrl}`)
@ -97,12 +102,19 @@ export function NWCProvider ({ children }) {
}, [logger]) }, [logger])
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey) let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (!configStr) {
if (me) {
// backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) { if (!configStr) {
logger.info('no existing config found') logger.info('no existing config found')
return return
} }
}
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
@ -130,7 +142,7 @@ export function NWCProvider ({ children }) {
logger.info('wallet disabled') logger.info('wallet disabled')
throw err throw err
} }
}, [validateParams, logger]) }, [me, validateParams, logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid