Compare commits

...

2 Commits

Author SHA1 Message Date
Riccardo Balbo a9a566a79f
Encrypted device sync (#1373)
* user vault

* code cleanup and fixes

* improve ui

* prevent name collisions between users on the same device

* some improvements

* implement storage migration

* comments and cleanup

* make connect button primary instead of warning

* move show passphrase in new line (improvement for small screen devices)

* make show passphrase field readOnly

* fixes

* fix vault key unsync

* implicit migration

* move device sync under  general tab

* fix locally disabled wallets and default wallet selection

* improve text

* remove useless SSR check

* add auth checks

* Rename variables

* Fix missing await

* Refactor local<>vault storage interface

I've changed quite some things here. Attempt of a summary:

* storageKey is now only controlled by useVaultStorageState

I've noticed that dealing with how storage keys are generated (to apply user scope) was handled in two places: the existing wallet code and in the new vault code.

This was confusing and error-prone. I've fixed that by completely relying on the new vault code to generate correct storage keys.

* refactored migration

Migration now simply encrypts any existing local wallets and sends them to the server. On success, the local unencrypted version is deleted.

The previous code seemed to unnecessarily generate new local entries prefixed by 'vault:'.

However, since we either use unencrypted local state OR use the encrypted vault on the server for the data, I didn't see any need for these.

Migration seems to work just as well as before.

* removed unnecessary state

In the <DeviceSync> component, enabled & connected were using a unnecessary combo of useState+useEffect.

They were only using variables that are always available during render so simple assignments were enough.

* other minor changes include:

  * early returns
  * remove unnecessary SSR checks in useEffect or useCallback
  * formatting, comments
  * remove unnecessary me? to expose possible bugs

* Fix missing dependency for useZap

This didn't cause any bugs because useWallet returns everything we need on first render.

This caused a bug with E2EE device sync branch though since there the wallet is loaded async.

This meant that during payment, the wallet config was undefined.

* Assume JSON during encryption and decryption

* Fix stale value from cache served on next fetches

* Add wallet.perDevice field

This adds 'perDevice' as a new wallet field to force local storage. For example, WebLN should not be synced across devices.

* Remove debug buttons

* Rename userVault -> vault

* Update console.log's

* revert some of the migration and key handling changes. restore debug buttons for testing

* Fix existing wallets not loaded

* Pass in localOnly and generate localStorageKey once

* Small refactor of migration

* Fix wallet drag and drop

* Add passphrase copy button

* Fix priorityOnly -> skipTests

* Disable autocompletion for reset confirmation prompt

* Show wrong passphrase as input error

* Move code into components/device-sync.js

* Import/export passphrase via QR code

* Fix modal back button invisible in light mode

* Fix modal closed even on connect error

* Use me-2 for cancel/close button

* Some rephrasing

* Fix wallet detach

* Remove debug buttons

* Fix QR code scan in dark mode

* Don't allow custom passphrases

* More rephrasing

* Only use schema if not enabled

* Fix typo in comment

* Replace 'generate passphrase' button with reload icon

* Add comment about IV reuse in GCM

* Use 600k iterations as recommended by OWASP

* Set extractable to false where not needed

* use-vault fallbacks to local storage only for anonymous users

* fix localStorage reset on logout

* add copy button

* move reset out of modals

* hide server side errors

* hardened passphrase storage

* do not show passphrase even if hardened storage is disabled (ie. indexeddb not supported)

* show qr code button on passphrase creation

* use toast for serverside error

* Move key (de)serialization burden to get/setLocalKey functions

* password textarea and remove qr

* don't print plaintext vault values into console

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-10-01 14:55:01 -05:00
ekzyis 5d1be66eef
Add nwc_recv container (#1442) 2024-10-01 09:14:29 -05:00
34 changed files with 1261 additions and 99 deletions

View File

@ -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]

115
api/resolvers/vault.js Normal file
View File

@ -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
}
}
}

View File

@ -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]

View File

@ -182,6 +182,7 @@ export default gql`
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
vaultKeyHash: String
}
type UserOptional {

22
api/typeDefs/vault.js Normal file
View File

@ -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
}
`

View File

@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
export default function CancelButton ({ onClick }) {
const router = useRouter()
return (
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
<Button className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
)
}

236
components/device-sync.js Normal file
View File

@ -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) => (
<div>
<h2>Device sync is enabled!</h2>
<p>
Sensitive data (like wallet credentials) is now securely synced between all connected devices.
</p>
<p className='text-muted text-sm'>
Disconnect to prevent this device from syncing data or to reset your passphrase.
</p>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto gap-2'>
<Button className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClose}>close</Button>
<Button
variant='primary'
onClick={() => {
disconnectVault()
onClose()
}}
>disconnect
</Button>
</div>
</div>
</div>
))
} else {
showModal((onClose) => (
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
))
}
}, [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) => (
<div>
<h2>Reset device sync</h2>
<p>
This will delete all encrypted data on the server and disconnect all devices.
</p>
<p>
You will need to enter a new passphrase on this and all other devices to sync data again.
</p>
<Form
className='mt-3'
initial={{ confirm: '' }}
schema={schema}
onSubmit={async values => {
await clearVault()
onClose()
}}
>
<Input
label='This action cannot be undone. Type `yes` to confirm.'
name='confirm'
placeholder=''
required
autoFocus
autoComplete='off'
/>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto'>
<CancelButton onClick={onClose} />
<SubmitButton variant='danger'>
continue
</SubmitButton>
</div>
</div>
</Form>
</div>
))
}, [])
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 (
<>
<div className='form-label mt-3'>device sync</div>
<div className='mt-2 d-flex align-items-center'>
<div>
<Button
variant='secondary'
onClick={manage}
>
{enabled ? (connected ? 'Manage ' : 'Connect to ') : 'Enable '}
device sync
</Button>
</div>
<Info>
<p>
Device sync uses end-to-end encryption to securely synchronize your data across devices.
</p>
<p className='text-muted text-sm'>
Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase.
</p>
</Info>
</div>
{enabled && !connected && (
<div className='mt-2 d-flex align-items-center'>
<div>
<Button
variant='danger'
onClick={reset}
>
Reset device sync data
</Button>
</div>
<Info>
<p>
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.
</p>
<p className='text-muted text-sm'>
This action cannot be undone.
</p>
</Info>
</div>
)}
</>
)
}
const generatePassphrase = (n = 12) => {
const rand = new Uint32Array(n)
window.crypto.getRandomValues(rand)
return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ')
}
function 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 (
<div>
<h2>{!enabled ? 'Enable device sync' : 'Input your passphrase'}</h2>
<p>
{!enabled
? 'Enable secure sync of sensitive data (like wallet credentials) between your devices. Youll 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.'}
</p>
<Form
schema={enabled ? undefined : deviceSyncSchema}
initial={{ passphrase }}
enableReinitialize
onSubmit={async (values, formik) => {
try {
await onConnect(values, formik)
onClose()
} catch {}
}}
>
<PasswordInput
label='passphrase'
name='passphrase'
placeholder=''
required
autoFocus
as='textarea'
rows={3}
readOnly={!enabled}
copy={!enabled}
append={
!enabled && (
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
<RefreshIcon width={16} height={16} />
</InputGroup.Text>
)
}
/>
<p className='text-muted text-sm'>
{
!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.'
}
</p>
<div className='mt-3'>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto gap-2'>
<CancelButton onClick={onClose} />
<SubmitButton variant='primary'>{enabled ? 'connect' : 'enable'}</SubmitButton>
</div>
</div>
</div>
</Form>
</div>
)
}

View File

@ -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 (
<InputGroup.Text style={{ cursor: 'pointer' }} onClick={handleClick}>
<Clipboard height={20} width={20} />
</InputGroup.Text>
)
}
return (
<Button className={styles.appendButton} {...props} onClick={handleClick}>
{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
)
}
export function CopyInput (props) {
return (
<Input
onClick={handleClick}
append={
<Button
className={styles.appendButton}
size={props.size}
onClick={handleClick}
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
<CopyButton value={props.placeholder} size={props.size} />
}
{...props}
/>
@ -711,10 +727,11 @@ export function InputUserSuggest ({
)
}
export function Input ({ label, groupClassName, ...props }) {
export function Input ({ label, groupClassName, under, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
<InputInner {...props} />
{under}
</FormGroup>
)
}
@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) {
>
{!showPass
? <Eye
fill='var(--bs-body-color)' height={20} width={20}
fill='var(--bs-body-color)' height={16} width={16}
/>
: <EyeClose
fill='var(--bs-body-color)' height={20} width={20}
fill='var(--bs-body-color)' height={16} width={16}
/>}
</InputGroup.Text>
)
}
export function PasswordInput ({ newPass, ...props }) {
function QrPassword ({ value }) {
const showModal = useShowModal()
const toaster = useToast()
const showQr = useCallback(() => {
showModal(close => (
<div className={styles.qr}>
<p>You can import this passphrase into another device by scanning this QR code</p>
<QRCode value={value} renderAs='svg' />
</div>
))
}, [toaster, value, showModal])
return (
<>
<InputGroup.Text
style={{ cursor: 'pointer' }}
onClick={showQr}
>
<QrIcon height={16} width={16} />
</InputGroup.Text>
</>
)
}
function PasswordScanner ({ onDecode }) {
const showModal = useShowModal()
const toaster = useToast()
const ref = useRef(false)
return (
<InputGroup.Text
style={{ cursor: 'pointer' }}
onClick={() => {
showModal(onClose => {
return (
<QrScanner
onDecode={(decoded) => {
onDecode(decoded)
// avoid accidentally calling onClose multiple times
if (ref?.current) return
ref.current = true
onClose({ back: 1 })
}}
onError={(error) => {
if (error instanceof DOMException) return
toaster.danger('qr scan error:', error.message || error.toString?.())
onClose({ back: 1 })
}}
/>
)
})
}}
>
<QrScanIcon
height={20} width={20} fill='var(--bs-body-color)'
/>
</InputGroup.Text>
)
}
export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) {
const [showPass, setShowPass] = useState(false)
const [field] = useField(props)
const Append = useMemo(() => {
return (
<>
<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />
{copy && (
<CopyButton icon value={field?.value} />
)}
{qr && (readOnly
? <QrPassword value={field?.value} />
: <PasswordScanner
onDecode={decoded => {
// Formik helpers don't seem to work in another modal.
// I assume it's because we unmount the Formik component
// when replace it with another modal.
window.localStorage.setItem('qr:passphrase', decoded)
}}
/>)}
{append}
</>
)
}, [showPass, copy, field?.value, qr, readOnly, append])
const maskedValue = !showPass && props.as === 'textarea' ? field?.value?.replace(/./g, '•') : field?.value
return (
<ClientInput
{...props}
className={styles.passwordInput}
type={showPass ? 'text' : 'password'}
autoComplete={newPass ? 'new-password' : 'current-password'}
append={<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />}
readOnly={readOnly}
append={props.as === 'textarea' ? undefined : Append}
value={maskedValue}
under={props.as === 'textarea'
? (
<div className='mt-2 d-flex justify-content-end' style={{ gap: '8px' }}>
{Append}
</div>)
: undefined}
/>
)
}

View File

@ -2,6 +2,10 @@
border-top-left-radius: 0;
}
textarea.passwordInput {
resize: none;
}
.markdownInput textarea {
margin-top: -1px;
font-size: 94%;
@ -70,3 +74,15 @@
opacity: 42%;
}
}
div.qr {
display: grid;
}
div.qr>svg {
justify-self: center;
width: 100%;
height: auto;
padding: 1rem;
background-color: white;
}

View File

@ -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}
</ActionDropdown>
</div>}
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} className='fill-white' /></div> : null}
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} /></div> : null}
<div className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
</div>
<Modal.Body className={className}>

View File

@ -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 (
<div className='d-flex m-auto flex-column w-fit-content'>
@ -288,6 +290,7 @@ function LogoutObstacle ({ onClose }) {
}
await wallets.resetClient().catch(console.error)
await resetVaultKey(me?.id)
await signOut({ callbackUrl: '/' })
}}

487
components/use-vault.js Normal file
View File

@ -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)
}

View File

@ -517,10 +517,10 @@ services:
labels:
CONNECT: "localhost:8025"
cpu_shares: "${CPU_SHARES_LOW}"
nwc:
nwc_send:
build:
context: ./docker/nwc
container_name: nwc
container_name: nwc_send
profiles:
- wallets
restart: unless-stopped
@ -536,7 +536,7 @@ services:
- 'nostr-wallet-connect-lnd'
- '--relay'
- 'wss://relay.primal.net'
- '--macaroon-file'
- '--admin-macaroon-file'
- '/root/.lnd/regtest/admin.macaroon'
- '--cert-file'
- '/root/.lnd/tls.cert'
@ -548,6 +548,42 @@ services:
- '0'
- '--daily-limit'
- '0'
- '--keys-file'
- 'admin-keys.json'
cpu_shares: "${CPU_SHARES_LOW}"
nwc_recv:
build:
context: ./docker/nwc
container_name: nwc_recv
profiles:
- wallets
restart: unless-stopped
depends_on:
stacker_lnd:
condition: service_healthy
restart: true
volumes:
- ./docker/lnd/stacker:/root/.lnd
environment:
- RUST_LOG=info
entrypoint:
- 'nostr-wallet-connect-lnd'
- '--relay'
- 'wss://relay.primal.net'
- '--invoice-macaroon-file'
- '/root/.lnd/regtest/invoice.macaroon'
- '--cert-file'
- '/root/.lnd/tls.cert'
- '--lnd-host'
- 'stacker_lnd'
- '--lnd-port'
- '10009'
- '--max-amount'
- '0'
- '--daily-limit'
- '0'
- '--keys-file'
- 'invoice-keys.json'
cpu_shares: "${CPU_SHARES_LOW}"
lnbits:
image: lnbits/lnbits:0.12.5

Binary file not shown.

Binary file not shown.

View File

@ -1,9 +1,11 @@
FROM rust:1.78
RUN wget https://github.com/benthecarman/nostr-wallet-connect-lnd/archive/9d53490f0a0cf655030e4ef4d32b478d7f29af5b.zip \
&& unzip 9d53490f0a0cf655030e4ef4d32b478d7f29af5b.zip
ARG KEY_FILE
WORKDIR nostr-wallet-connect-lnd-9d53490f0a0cf655030e4ef4d32b478d7f29af5b
RUN wget https://github.com/ekzyis/nostr-wallet-connect-lnd/archive/a02939c350191f8a6750a72d2456fbdf567e5848.zip \
&& unzip a02939c350191f8a6750a72d2456fbdf567e5848.zip
WORKDIR nostr-wallet-connect-lnd-a02939c350191f8a6750a72d2456fbdf567e5848
RUN apt-get update -y \
&& apt-get install -y cmake \
@ -11,4 +13,4 @@ RUN apt-get update -y \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN cargo build --release && cargo install --path .
COPY keys.json .
COPY . .

View File

@ -0,0 +1,5 @@
{
"server_key": "ea7b559d5b49e6d4a22f57cc84a15fd3b87742ff91a85bb871242e09e6d0b0d7",
"user_key": "c8f7fcb4707863ba1cc1b32c8871585ddb1eb7a555925cd2818a6caf4a21fb90",
"sent_info": true
}

View File

@ -0,0 +1,5 @@
{
"server_key": "86e7b8a53c22677066d882618f28f8e1f39e4676114c0ae019e9d86518177e49",
"user_key": "87e73293804edb089e0be8bf01ab2f6f219591f91998479851a7a2d1daf1a617",
"sent_info": true
}

View File

@ -1 +0,0 @@
{"server_key":"4463b828d1950885de82b518efbfbe3dd6c35278e8ee5f6389721515b4c5e021","user_key":"0d1ef06059c9b1acf8c424cfe357c5ffe2d5f3594b9081695771a363ee716b67","sent_info":true}

View File

@ -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)
}
`

33
fragments/vault.js Normal file
View File

@ -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)
}
`

View File

@ -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 } })
}
}

View File

@ -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
})
})

View File

@ -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 }) {
<div className='form-label'>saturday newsletter</div>
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
<DeviceSync />
</div>
</div>
</Layout>

View File

@ -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();

View File

@ -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

1
svgs/clipboard-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z"></path></svg>

After

Width:  |  Height:  |  Size: 310 B

1
svgs/qr-code-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 17V16H13V13H16V15H18V17H17V19H15V21H13V18H15V17H16ZM21 21H17V19H19V17H21V21ZM3 3H11V11H3V3ZM5 5V9H9V5H5ZM13 3H21V11H13V3ZM15 5V9H19V5H15ZM3 13H11V21H3V13ZM5 15V19H9V15H5ZM18 13H21V15H18V13ZM6 6H8V8H6V6ZM6 16H8V18H6V16ZM16 6H18V8H16V6Z"></path></svg>

After

Width:  |  Height:  |  Size: 342 B

1
svgs/qr-scan-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 16V21H3V16H5V19H19V16H21ZM3 11H21V13H3V11ZM21 8H19V5H5V8H3V3H21V8Z"></path></svg>

After

Width:  |  Height:  |  Size: 174 B

1
svgs/refresh-line.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -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).

View File

@ -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))
}

View File

@ -1,9 +1,13 @@
Use this NWC string to attach the wallet for payments:
Use these NWC strings to attach the wallet
* sending:
```
nostr+walletconnect://5224c44600696216595a70982ee7387a04bd66248b97fefb803f4ed6d4af1972?relay=wss%3A%2F%2Frelay.primal.net&secret=0d1ef06059c9b1acf8c424cfe357c5ffe2d5f3594b9081695771a363ee716b67
nostr+walletconnect://b7dcc7aca6e27ec2bc2374eef1a3ce1f975b76ea8ebc806fcbb9e4d359ced47e?relay=wss%3A%2F%2Frelay.primal.net&secret=c8f7fcb4707863ba1cc1b32c8871585ddb1eb7a555925cd2818a6caf4a21fb90
```
This won't work for receives since it allows `pay_invoice`.
- receiving:
TODO: generate NWC string with only `make_invoice` as permission
```
nostr+walletconnect://ed77e8af26fee9d179443505ad7d11d5a535e1767eb3058b01673c3f56f08191?relay=wss%3A%2F%2Frelay.primal.net&secret=87e73293804edb089e0be8bf01ab2f6f219591f91998479851a7a2d1daf1a617
```

View File

@ -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)