Compare commits
2 Commits
7d86ba3865
...
a9a566a79f
Author | SHA1 | Date | |
---|---|---|---|
|
a9a566a79f | ||
|
5d1be66eef |
@ -19,6 +19,7 @@ import chainFee from './chainFee'
|
|||||||
import { GraphQLScalarType, Kind } from 'graphql'
|
import { GraphQLScalarType, Kind } from 'graphql'
|
||||||
import { createIntScalar } from 'graphql-scalar'
|
import { createIntScalar } from 'graphql-scalar'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
const date = new GraphQLScalarType({
|
const date = new GraphQLScalarType({
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
@ -55,4 +56,4 @@ const limit = createIntScalar({
|
|||||||
|
|
||||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
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
115
api/resolvers/vault.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@ import admin from './admin'
|
|||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
import chainFee from './chainFee'
|
import chainFee from './chainFee'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
const common = gql`
|
const common = gql`
|
||||||
type Query {
|
type Query {
|
||||||
@ -38,4 +39,4 @@ const common = gql`
|
|||||||
`
|
`
|
||||||
|
|
||||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
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!
|
withdrawMaxFeeDefault: Int!
|
||||||
autoWithdrawThreshold: Int
|
autoWithdrawThreshold: Int
|
||||||
autoWithdrawMaxFeePercent: Float
|
autoWithdrawMaxFeePercent: Float
|
||||||
|
vaultKeyHash: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOptional {
|
type UserOptional {
|
||||||
|
22
api/typeDefs/vault.js
Normal file
22
api/typeDefs/vault.js
Normal 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
|
||||||
|
}
|
||||||
|
`
|
@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
|
|||||||
export default function CancelButton ({ onClick }) {
|
export default function CancelButton ({ onClick }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
return (
|
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
236
components/device-sync.js
Normal 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. 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 Info from './info'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import classNames from 'classnames'
|
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 {
|
export class SessionRequiredError extends Error {
|
||||||
constructor () {
|
constructor () {
|
||||||
@ -69,31 +75,41 @@ export function SubmitButton ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyInput (props) {
|
function CopyButton ({ value, icon, ...props }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await copy(props.placeholder)
|
await copy(value)
|
||||||
toaster.success('copied')
|
toaster.success('copied')
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(false), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toaster.danger('failed to copy')
|
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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
onClick={handleClick}
|
|
||||||
append={
|
append={
|
||||||
<Button
|
<CopyButton value={props.placeholder} size={props.size} />
|
||||||
className={styles.appendButton}
|
|
||||||
size={props.size}
|
|
||||||
onClick={handleClick}
|
|
||||||
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@ -711,10 +727,11 @@ export function InputUserSuggest ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input ({ label, groupClassName, ...props }) {
|
export function Input ({ label, groupClassName, under, ...props }) {
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<InputInner {...props} />
|
<InputInner {...props} />
|
||||||
|
{under}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) {
|
|||||||
>
|
>
|
||||||
{!showPass
|
{!showPass
|
||||||
? <Eye
|
? <Eye
|
||||||
fill='var(--bs-body-color)' height={20} width={20}
|
fill='var(--bs-body-color)' height={16} width={16}
|
||||||
/>
|
/>
|
||||||
: <EyeClose
|
: <EyeClose
|
||||||
fill='var(--bs-body-color)' height={20} width={20}
|
fill='var(--bs-body-color)' height={16} width={16}
|
||||||
/>}
|
/>}
|
||||||
</InputGroup.Text>
|
</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 [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 (
|
return (
|
||||||
<ClientInput
|
<ClientInput
|
||||||
{...props}
|
{...props}
|
||||||
|
className={styles.passwordInput}
|
||||||
type={showPass ? 'text' : 'password'}
|
type={showPass ? 'text' : 'password'}
|
||||||
autoComplete={newPass ? 'new-password' : 'current-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;
|
border-top-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea.passwordInput {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
.markdownInput textarea {
|
.markdownInput textarea {
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
font-size: 94%;
|
font-size: 94%;
|
||||||
@ -69,4 +73,16 @@
|
|||||||
0% {
|
0% {
|
||||||
opacity: 42%;
|
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])
|
}, [getCurrentContent, forceUpdate])
|
||||||
|
|
||||||
// this is called on every navigation due to below useEffect
|
// 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) {
|
while (modalStack.current.length) {
|
||||||
getCurrentContent()?.options?.onClose?.()
|
getCurrentContent()?.options?.onClose?.()
|
||||||
modalStack.current.pop()
|
modalStack.current.pop()
|
||||||
}
|
}
|
||||||
forceUpdate()
|
forceUpdate()
|
||||||
}, [])
|
}, [onBack])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -90,7 +97,7 @@ export default function useModal () {
|
|||||||
{overflow}
|
{overflow}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
</div>}
|
</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 className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
|
||||||
</div>
|
</div>
|
||||||
<Modal.Body className={className}>
|
<Modal.Body className={className}>
|
||||||
|
@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
|
|||||||
import { useWallets } from 'wallets'
|
import { useWallets } from 'wallets'
|
||||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
|
import { unsetLocalKey as resetVaultKey } from '@/components/use-vault'
|
||||||
|
|
||||||
export function Brand ({ className }) {
|
export function Brand ({ className }) {
|
||||||
return (
|
return (
|
||||||
@ -260,6 +261,7 @@ function LogoutObstacle ({ onClose }) {
|
|||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
const wallets = useWallets()
|
const wallets = useWallets()
|
||||||
const { multiAuthSignout } = useAccounts()
|
const { multiAuthSignout } = useAccounts()
|
||||||
|
const { me } = useMe()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex m-auto flex-column w-fit-content'>
|
<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 wallets.resetClient().catch(console.error)
|
||||||
|
await resetVaultKey(me?.id)
|
||||||
|
|
||||||
await signOut({ callbackUrl: '/' })
|
await signOut({ callbackUrl: '/' })
|
||||||
}}
|
}}
|
||||||
|
487
components/use-vault.js
Normal file
487
components/use-vault.js
Normal 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)
|
||||||
|
}
|
@ -517,10 +517,10 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
CONNECT: "localhost:8025"
|
CONNECT: "localhost:8025"
|
||||||
cpu_shares: "${CPU_SHARES_LOW}"
|
cpu_shares: "${CPU_SHARES_LOW}"
|
||||||
nwc:
|
nwc_send:
|
||||||
build:
|
build:
|
||||||
context: ./docker/nwc
|
context: ./docker/nwc
|
||||||
container_name: nwc
|
container_name: nwc_send
|
||||||
profiles:
|
profiles:
|
||||||
- wallets
|
- wallets
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@ -536,7 +536,7 @@ services:
|
|||||||
- 'nostr-wallet-connect-lnd'
|
- 'nostr-wallet-connect-lnd'
|
||||||
- '--relay'
|
- '--relay'
|
||||||
- 'wss://relay.primal.net'
|
- 'wss://relay.primal.net'
|
||||||
- '--macaroon-file'
|
- '--admin-macaroon-file'
|
||||||
- '/root/.lnd/regtest/admin.macaroon'
|
- '/root/.lnd/regtest/admin.macaroon'
|
||||||
- '--cert-file'
|
- '--cert-file'
|
||||||
- '/root/.lnd/tls.cert'
|
- '/root/.lnd/tls.cert'
|
||||||
@ -548,6 +548,42 @@ services:
|
|||||||
- '0'
|
- '0'
|
||||||
- '--daily-limit'
|
- '--daily-limit'
|
||||||
- '0'
|
- '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}"
|
cpu_shares: "${CPU_SHARES_LOW}"
|
||||||
lnbits:
|
lnbits:
|
||||||
image: lnbits/lnbits:0.12.5
|
image: lnbits/lnbits:0.12.5
|
||||||
|
BIN
docker/lnd/stacker/regtest/invoice.macaroon
Normal file
BIN
docker/lnd/stacker/regtest/invoice.macaroon
Normal file
Binary file not shown.
BIN
docker/lnd/stacker/regtest/readonly.macaroon
Normal file
BIN
docker/lnd/stacker/regtest/readonly.macaroon
Normal file
Binary file not shown.
@ -1,9 +1,11 @@
|
|||||||
FROM rust:1.78
|
FROM rust:1.78
|
||||||
|
|
||||||
RUN wget https://github.com/benthecarman/nostr-wallet-connect-lnd/archive/9d53490f0a0cf655030e4ef4d32b478d7f29af5b.zip \
|
ARG KEY_FILE
|
||||||
&& unzip 9d53490f0a0cf655030e4ef4d32b478d7f29af5b.zip
|
|
||||||
|
|
||||||
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 \
|
RUN apt-get update -y \
|
||||||
&& apt-get install -y cmake \
|
&& apt-get install -y cmake \
|
||||||
@ -11,4 +13,4 @@ RUN apt-get update -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
RUN cargo build --release && cargo install --path .
|
RUN cargo build --release && cargo install --path .
|
||||||
|
|
||||||
COPY keys.json .
|
COPY . .
|
||||||
|
5
docker/nwc/admin-keys.json
Normal file
5
docker/nwc/admin-keys.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"server_key": "ea7b559d5b49e6d4a22f57cc84a15fd3b87742ff91a85bb871242e09e6d0b0d7",
|
||||||
|
"user_key": "c8f7fcb4707863ba1cc1b32c8871585ddb1eb7a555925cd2818a6caf4a21fb90",
|
||||||
|
"sent_info": true
|
||||||
|
}
|
5
docker/nwc/invoice-keys.json
Normal file
5
docker/nwc/invoice-keys.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"server_key": "86e7b8a53c22677066d882618f28f8e1f39e4676114c0ae019e9d86518177e49",
|
||||||
|
"user_key": "87e73293804edb089e0be8bf01ab2f6f219591f91998479851a7a2d1daf1a617",
|
||||||
|
"sent_info": true
|
||||||
|
}
|
@ -1 +0,0 @@
|
|||||||
{"server_key":"4463b828d1950885de82b518efbfbe3dd6c35278e8ee5f6389721515b4c5e021","user_key":"0d1ef06059c9b1acf8c424cfe357c5ffe2d5f3594b9081695771a363ee716b67","sent_info":true}
|
|
@ -55,6 +55,7 @@ export const ME = gql`
|
|||||||
autoWithdrawMaxFeePercent
|
autoWithdrawMaxFeePercent
|
||||||
autoWithdrawThreshold
|
autoWithdrawThreshold
|
||||||
disableFreebies
|
disableFreebies
|
||||||
|
vaultKeyHash
|
||||||
}
|
}
|
||||||
optional {
|
optional {
|
||||||
isContributor
|
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
33
fragments/vault.js
Normal 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)
|
||||||
|
}
|
||||||
|
`
|
@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql'
|
|||||||
export const E_FORBIDDEN = 'E_FORBIDDEN'
|
export const E_FORBIDDEN = 'E_FORBIDDEN'
|
||||||
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
|
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
|
||||||
export const E_BAD_INPUT = 'E_BAD_INPUT'
|
export const E_BAD_INPUT = 'E_BAD_INPUT'
|
||||||
|
export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS'
|
||||||
|
|
||||||
export class GqlAuthorizationError extends GraphQLError {
|
export class GqlAuthorizationError extends GraphQLError {
|
||||||
constructor (message) {
|
constructor (message) {
|
||||||
@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GqlInputError extends GraphQLError {
|
export class GqlInputError extends GraphQLError {
|
||||||
constructor (message) {
|
constructor (message, code) {
|
||||||
super(message, { extensions: { code: E_BAD_INPUT } })
|
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 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 { useField } from 'formik'
|
||||||
import styles from './settings.module.css'
|
import styles from './settings.module.css'
|
||||||
import { AuthBanner } from '@/components/banners'
|
import { AuthBanner } from '@/components/banners'
|
||||||
|
import DeviceSync from '@/components/device-sync'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
<div className='form-label'>saturday newsletter</div>
|
<div className='form-label'>saturday newsletter</div>
|
||||||
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
||||||
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
||||||
|
<DeviceSync />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
39
prisma/migrations/20240922065504_vault/migration.sql
Normal file
39
prisma/migrations/20240922065504_vault/migration.sql
Normal 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();
|
@ -134,6 +134,8 @@ model User {
|
|||||||
ItemUserAgg ItemUserAgg[]
|
ItemUserAgg ItemUserAgg[]
|
||||||
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
||||||
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
||||||
|
vaultKeyHash String @default("")
|
||||||
|
vaultEntries Vault[] @relation("VaultEntries")
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -1099,6 +1101,19 @@ model Reminder {
|
|||||||
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
|
@@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 {
|
enum EarnType {
|
||||||
POST
|
POST
|
||||||
COMMENT
|
COMMENT
|
||||||
|
1
svgs/clipboard-line.svg
Normal file
1
svgs/clipboard-line.svg
Normal 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
1
svgs/qr-code-line.svg
Normal 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
1
svgs/qr-scan-line.svg
Normal 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
1
svgs/refresh-line.svg
Normal 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 |
@ -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.
|
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[]`
|
- `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).
|
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 { useCallback } from 'react'
|
||||||
import { useMe } from '@/components/me'
|
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 { useWalletLogger } from '@/components/wallet-logger'
|
||||||
import { SSR } from '@/lib/constants'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
|
||||||
import walletDefs from 'wallets/client'
|
import walletDefs from 'wallets/client'
|
||||||
@ -22,28 +21,44 @@ export const Status = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWallet (name) {
|
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 { me } = useMe()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||||
|
|
||||||
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
const wallet = getWalletByName(name)
|
||||||
const { logger, deleteLogs } = useWalletLogger(wallet)
|
const { logger, deleteLogs } = useWalletLogger(wallet)
|
||||||
|
|
||||||
const [config, saveConfig, clearConfig] = useConfig(wallet)
|
const [config, saveConfig, clearConfig] = useConfig(wallet)
|
||||||
const hasConfig = wallet?.fields.length > 0
|
const hasConfig = wallet?.fields.length > 0
|
||||||
const _isConfigured = isConfigured({ ...wallet, config })
|
const _isConfigured = isConfigured({ ...wallet, config })
|
||||||
|
|
||||||
const enablePayments = useCallback(() => {
|
const enablePayments = useCallback((updatedConfig) => {
|
||||||
enableWallet(name, me)
|
// 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')
|
logger.ok('payments enabled')
|
||||||
disableFreebies().catch(console.error)
|
disableFreebies().catch(console.error)
|
||||||
}, [name, me, logger])
|
}, [config, logger])
|
||||||
|
|
||||||
const disablePayments = useCallback(() => {
|
const disablePayments = useCallback((updatedConfig) => {
|
||||||
disableWallet(name, me)
|
saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true })
|
||||||
logger.info('payments disabled')
|
logger.info('payments disabled')
|
||||||
}, [name, me, logger])
|
}, [config, logger])
|
||||||
|
|
||||||
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
||||||
const enabled = status === Status.Enabled
|
const enabled = status === Status.Enabled
|
||||||
@ -65,7 +80,7 @@ export function useWallet (name) {
|
|||||||
const setPriority = useCallback(async (priority) => {
|
const setPriority = useCallback(async (priority) => {
|
||||||
if (_isConfigured && priority !== config.priority) {
|
if (_isConfigured && priority !== config.priority) {
|
||||||
try {
|
try {
|
||||||
await saveConfig({ ...config, priority }, { logger, priorityOnly: true })
|
await saveConfig({ ...config, priority }, { logger, skipTests: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
||||||
}
|
}
|
||||||
@ -85,7 +100,7 @@ export function useWallet (name) {
|
|||||||
logger.error(message)
|
logger.error(message)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}, [clearConfig, logger, disablePayments])
|
}, [clearConfig, logger])
|
||||||
|
|
||||||
const deleteLogs_ = useCallback(async (options) => {
|
const deleteLogs_ = useCallback(async (options) => {
|
||||||
// first argument is to override the wallet
|
// first argument is to override the wallet
|
||||||
@ -158,8 +173,9 @@ function extractServerConfig (fields, config) {
|
|||||||
function useConfig (wallet) {
|
function useConfig (wallet) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
|
||||||
const storageKey = getStorageKey(wallet?.name, me)
|
const storageKey = `wallet:${wallet.name}`
|
||||||
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
|
|
||||||
|
const [clientConfig, setClientConfig, clearClientConfig] = useVault(storageKey, {}, { localOnly: wallet.perDevice })
|
||||||
|
|
||||||
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
|
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
|
||||||
|
|
||||||
@ -181,7 +197,7 @@ function useConfig (wallet) {
|
|||||||
config.priority ||= priority
|
config.priority ||= priority
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveConfig = useCallback(async (newConfig, { logger, priorityOnly }) => {
|
const saveConfig = useCallback(async (newConfig, { logger, skipTests } = {}) => {
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// verifying the client/server configuration before saving it
|
// verifying the client/server configuration before saving it
|
||||||
// prevents unsetting just one configuration if both are set.
|
// prevents unsetting just one configuration if both are set.
|
||||||
@ -203,7 +219,7 @@ function useConfig (wallet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
if (priorityOnly) {
|
if (skipTests) {
|
||||||
setClientConfig(newClientConfig)
|
setClientConfig(newClientConfig)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@ -218,9 +234,12 @@ function useConfig (wallet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setClientConfig(newClientConfig)
|
setClientConfig(newClientConfig)
|
||||||
|
|
||||||
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
|
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
|
valid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) await setServerConfig(newServerConfig, { priorityOnly })
|
if (valid) await setServerConfig(newServerConfig, { priorityOnly: skipTests })
|
||||||
}
|
}
|
||||||
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
|
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
|
||||||
|
|
||||||
const clearConfig = useCallback(async ({ logger, clientOnly }) => {
|
const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => {
|
||||||
if (hasClientConfig) {
|
if (hasClientConfig) {
|
||||||
clearClientConfig()
|
clearClientConfig(options)
|
||||||
wallet.disablePayments()
|
wallet.disablePayments({})
|
||||||
logger.ok('wallet detached for payments')
|
logger.ok('wallet detached for payments')
|
||||||
}
|
}
|
||||||
if (hasServerConfig && !clientOnly) await clearServerConfig()
|
if (hasServerConfig && !clientOnly) await clearServerConfig(options)
|
||||||
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
||||||
|
|
||||||
return [config, saveConfig, clearConfig]
|
return [config, saveConfig, clearConfig]
|
||||||
@ -370,20 +389,6 @@ export function getWalletByType (type) {
|
|||||||
return walletDefs.find(def => def.walletType === 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) {
|
export function walletPrioritySort (w1, w2) {
|
||||||
const delta = w1.priority - w2.priority
|
const delta = w1.priority - w2.priority
|
||||||
// delta is NaN if either priority is undefined
|
// delta is NaN if either priority is undefined
|
||||||
@ -409,7 +414,7 @@ export function useWallets () {
|
|||||||
const resetClient = useCallback(async (wallet) => {
|
const resetClient = useCallback(async (wallet) => {
|
||||||
for (const w of wallets) {
|
for (const w of wallets) {
|
||||||
if (w.canSend) {
|
if (w.canSend) {
|
||||||
await w.delete({ clientOnly: true })
|
await w.delete({ clientOnly: true, onlyFromLocalStorage: true })
|
||||||
}
|
}
|
||||||
await w.deleteLogs({ clientOnly: true })
|
await w.deleteLogs({ clientOnly: true })
|
||||||
}
|
}
|
||||||
@ -417,29 +422,3 @@ export function useWallets () {
|
|||||||
|
|
||||||
return { wallets, resetClient }
|
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))
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
```
|
||||||
|
@ -3,6 +3,8 @@ import { useWallet } from 'wallets'
|
|||||||
|
|
||||||
export const name = 'webln'
|
export const name = 'webln'
|
||||||
|
|
||||||
|
export const perDevice = true
|
||||||
|
|
||||||
export const fields = []
|
export const fields = []
|
||||||
|
|
||||||
export const fieldValidation = ({ enabled }) => {
|
export const fieldValidation = ({ enabled }) => {
|
||||||
@ -35,6 +37,8 @@ export default function WebLnProvider ({ children }) {
|
|||||||
wallet.disablePayments()
|
wallet.disablePayments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!window.webln) onDisable()
|
||||||
|
|
||||||
window.addEventListener('webln:enabled', onEnable)
|
window.addEventListener('webln:enabled', onEnable)
|
||||||
// event is not fired by Alby browser extension but added here for sake of completeness
|
// event is not fired by Alby browser extension but added here for sake of completeness
|
||||||
window.addEventListener('webln:disabled', onDisable)
|
window.addEventListener('webln:disabled', onDisable)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user