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>
This commit is contained in:
parent
5d1be66eef
commit
a9a566a79f
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -182,6 +182,7 @@ export default gql`
|
|||
withdrawMaxFeeDefault: Int!
|
||||
autoWithdrawThreshold: Int
|
||||
autoWithdrawMaxFeePercent: Float
|
||||
vaultKeyHash: String
|
||||
}
|
||||
|
||||
type UserOptional {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
`
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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. 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.'}
|
||||
</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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.qr {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
div.qr>svg {
|
||||
justify-self: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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: '/' })
|
||||
}}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
`
|
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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).
|
||||
|
|
105
wallets/index.js
105
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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue