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 WalletLogsContext = createContext()
const initIndexedDB = async (storeName) => {
const initIndexedDB = async (dbName, storeName) => {
return new Promise((resolve, reject) => {
if (!window.indexedDB) {
return reject(new Error('IndexedDB not supported'))
}
// 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
request.onupgradeneeded = () => {
@ -159,7 +159,12 @@ const initIndexedDB = async (storeName) => {
}
const WalletLoggerProvider = ({ children }) => {
const me = useMe()
const [logs, setLogs] = useState([])
let dbName = 'app:storage'
if (me) {
dbName = `${dbName}:${me.id}`
}
const idbStoreName = 'wallet_logs'
const idb = useRef()
const logQueue = useRef([])
@ -211,7 +216,7 @@ const WalletLoggerProvider = ({ children }) => {
}, [])
useEffect(() => {
initIndexedDB(idbStoreName)
initIndexedDB(dbName, idbStoreName)
.then(db => {
idb.current = db

View File

@ -23,6 +23,7 @@ import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes'
import { useWalletLogger } from '../logger'
import { useWebLNConfigurator } from '../webln'
export function Brand ({ className }) {
return (
@ -162,8 +163,6 @@ export function NavWalletSummary ({ className }) {
export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { deleteLogs } = useWalletLogger()
return (
<div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'>
@ -202,22 +201,7 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link>
</div>
<Dropdown.Divider />
<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) {
// 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>
<LogoutDropdownItem />
</Dropdown.Menu>
</Dropdown>
{!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 () {
return (
<>

View File

@ -2,9 +2,7 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link'
import { useServiceWorker } from '@/components/serviceworker'
import { signOut } from 'next-auth/react'
import { LoginButtons, NavWalletSummary } from '../common'
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css'
import classNames from 'classnames'
@ -14,7 +12,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
const handleClose = () => setShow(false)
const handleShow = () => setShow(true)
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const MeImage = ({ onClick }) => me
? (
@ -78,22 +75,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link>
</div>
<Dropdown.Divider />
<Dropdown.Item
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>
<LogoutDropdownItem />
</>
)
: <LoginButtons />}

View File

@ -33,6 +33,15 @@ export const Status = {
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 }) {
const lnbits = useLNbits()
const nwc = useNWC()
@ -114,8 +123,14 @@ function RawWebLNProvider ({ children }) {
})
}, [setEnabledProviders])
const clearConfig = useCallback(async () => {
lnbits.clearConfig()
nwc.clearConfig()
await lnc.clearConfig()
}, [])
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}
</WebLNContext.Provider>
)

View File

@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger'
import { Status } from '.'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
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
@ -65,13 +66,17 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
}
export function LNbitsProvider ({ children }) {
const me = useMe()
const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('')
const [status, setStatus] = useState()
const { logger } = useWalletLogger(Wallet.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 response = await getWallet(url, adminKey)
@ -110,11 +115,18 @@ export function LNbitsProvider ({ children }) {
}, [logger, url, adminKey])
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized)
if (!configStr) {
logger.info('no existing config found')
return
if (me) {
// backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) {
logger.info('no existing config found')
return
}
}
const config = JSON.parse(configStr)
@ -141,7 +153,7 @@ export function LNbitsProvider ({ children }) {
logger.info('wallet disabled')
throw err
}
}, [logger])
}, [me, logger])
const saveConfig = useCallback(async (config) => {
// 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 { useWalletLogger } from '../logger'
import LNC from '@lightninglabs/lnc-web'
import { Status } from '.'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
import useModal from '../modal'
import { Form, PasswordInput, SubmitButton } from '../form'
import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const LNCContext = createContext()
const mutex = new Mutex()
async function getLNC () {
async function getLNC ({ me }) {
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
}
@ -33,6 +36,7 @@ function validateNarrowPerms (lnc) {
}
export function LNCProvider ({ children }) {
const me = useMe()
const { logger } = useWalletLogger(Wallet.LNC)
const [config, setConfig] = useState({})
const [lnc, setLNC] = useState()
@ -165,7 +169,7 @@ export function LNCProvider ({ children }) {
useEffect(() => {
(async () => {
try {
const lnc = await getLNC()
const lnc = await getLNC({ me })
setLNC(lnc)
setStatus(Status.Initialized)
if (lnc.credentials.isPaired) {
@ -185,7 +189,7 @@ export function LNCProvider ({ children }) {
setStatus(Status.Error)
}
})()
}, [setStatus, setConfig, logger])
}, [me, setStatus, setConfig, logger])
return (
<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 { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
import { Status } from '.'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const NWCContext = createContext()
export function NWCProvider ({ children }) {
const me = useMe()
const [nwcUrl, setNwcUrl] = useState('')
const [walletPubkey, setWalletPubkey] = useState()
const [relayUrl, setRelayUrl] = useState()
@ -19,7 +21,10 @@ export function NWCProvider ({ children }) {
const { logger } = useWalletLogger(Wallet.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) => {
logger.info(`requesting info event from ${relayUrl}`)
@ -97,11 +102,18 @@ export function NWCProvider ({ children }) {
}, [logger])
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized)
if (!configStr) {
logger.info('no existing config found')
return
if (me) {
// backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) {
logger.info('no existing config found')
return
}
}
const config = JSON.parse(configStr)
@ -130,7 +142,7 @@ export function NWCProvider ({ children }) {
logger.info('wallet disabled')
throw err
}
}, [validateParams, logger])
}, [me, validateParams, logger])
const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid