Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

configurable nostr signer #1757

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3d224ec
configurable signer
riccardobl Dec 22, 2024
300ccd0
handle nip46 auth challenge
riccardobl Dec 23, 2024
bf80ce4
restore nip46 connection when possible
riccardobl Dec 23, 2024
8694626
ensure signer is ready
riccardobl Dec 23, 2024
5525a6d
handle undefined values
riccardobl Dec 23, 2024
2063894
default to nip07
riccardobl Dec 23, 2024
462af88
verify signer on settings save
riccardobl Dec 23, 2024
3a8f8d0
remove connection delay, do not attempt to restore nip05 connections
riccardobl Dec 23, 2024
3f3c5d2
doc
riccardobl Dec 23, 2024
c7a07ff
Merge branch 'master' into nostrsigner
riccardobl Dec 23, 2024
c6f36c0
use password input for nip46 bunker token
riccardobl Dec 23, 2024
ac313ff
Update components/use-encrypted-privates.js
riccardobl Dec 23, 2024
2dcc22b
Update components/use-encrypted-privates.js
riccardobl Dec 23, 2024
e964ab9
Merge branch 'master' into nostrsigner
riccardobl Dec 24, 2024
eaccc4f
unused import
riccardobl Jan 6, 2025
95867aa
invalid prop
riccardobl Jan 6, 2025
7ff5480
disable sonarjs/no-identical-functions
riccardobl Jan 6, 2025
e3c6eab
avoid potential global shadowing
riccardobl Jan 6, 2025
1468363
remove useless default assignments
riccardobl Jan 6, 2025
a01ba07
remove useless assignment
riccardobl Jan 6, 2025
2f5cae4
remove dead code
riccardobl Jan 6, 2025
97ea15b
make invalid prop valid
riccardobl Jan 6, 2025
90297c5
refactor try catch
riccardobl Jan 6, 2025
ae9d7a7
remove eslint-disable
riccardobl Jan 6, 2025
5a4d50a
eslint
riccardobl Jan 6, 2025
faace30
Merge branch 'master' into nostrsigner
riccardobl Jan 6, 2025
5ffa992
fix merge conflict
riccardobl Jan 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema, encryptedPrivates, encryptedPrivatesVaultEntriesSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
import { viewGroup } from './growth'
Expand Down Expand Up @@ -677,6 +677,33 @@ export default {
throw error
}
},
setEncryptedSettings: async (parent, { settings }, { me, models }) => {
if (!me) throw new GqlAuthenticationError()
await validateSchema(encryptedPrivatesVaultEntriesSchema, settings)
for (const vaultEntry of settings) {
const { key, iv, value } = vaultEntry
await models.vaultEntry.upsert({
where: {
userId_key: {
userId: me.id,
key
}
},
update: {
iv,
value
},
create: {
userId: me.id,
key,
iv,
value
}
})
}
const user = await models.user.findUnique({ where: { id: me.id } })
return user
},
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
Expand Down Expand Up @@ -894,6 +921,18 @@ export default {

return user
},
encryptedPrivates: async (user, args, { me, models }) => {
if (!me || me.id !== user.id) return null
const vaultEntries = await models.vaultEntry.findMany({
where: {
userId: user.id,
key: {
in: encryptedPrivates
}
}
})
return vaultEntries
},
optional: user => user,
meSubscriptionPosts: async (user, args, { me, models }) => {
if (!me) return false
Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default gql`
extend type Mutation {
setName(name: String!): String
setSettings(settings: SettingsInput!): User
setEncryptedSettings(settings: [VaultEntryInput!]!): User
setPhoto(photoId: ID!): Int!
upsertBio(text: String!): ItemPaidAction!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
Expand Down Expand Up @@ -66,6 +67,7 @@ export default gql`

optional: UserOptional!
privates: UserPrivates
encryptedPrivates: [VaultEntry!]

meMute: Boolean!
meSubscriptionPosts: Boolean!
Expand Down
4 changes: 4 additions & 0 deletions components/nav/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from '@/wallets/index'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { useEncryptedPrivates } from '@/components/use-encrypted-privates'

export function Brand ({ className }) {
return (
Expand Down Expand Up @@ -266,6 +267,8 @@ function LogoutObstacle ({ onClose }) {
const { removeLocalWallets } = useWallets()
const { multiAuthSignout } = useAccounts()
const router = useRouter()
const { me } = useMe()
const { clearLocalEncryptedPrivates } = useEncryptedPrivates({ me })

return (
<div className='d-flex m-auto flex-column w-fit-content'>
Expand Down Expand Up @@ -296,6 +299,7 @@ function LogoutObstacle ({ onClose }) {
}

removeLocalWallets()
clearLocalEncryptedPrivates()

await signOut({ callbackUrl: '/' })
}}
Expand Down
210 changes: 128 additions & 82 deletions components/nostr-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Button, Container } from 'react-bootstrap'
import { Form, Input, SubmitButton } from '@/components/form'
import Moon from '@/svgs/moon-fill.svg'
import styles from './lightning-auth.module.css'
import { useShowModal } from '@/components/modal'

const sanitizeURL = (s) => {
try {
Expand All @@ -33,7 +34,13 @@ function NostrError ({ message }) {
)
}

export function NostrAuth ({ text, callbackUrl, multiAuth }) {
export function useNostrAuthState ({
challengeTitle = 'Waiting for confirmation',
challengeMessage = 'Please confirm this action on your remote signer',
challengeButtonLabel = 'open signer'
} = {}) {
const toaster = useToast()

const [status, setStatus] = useState({
msg: '',
error: false,
Expand All @@ -42,35 +49,105 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
button: undefined
})

const [suggestion, setSuggestion] = useState(null)
const suggestionTimeout = useRef(null)
const toaster = useToast()
// print an error message
const setError = useCallback((e) => {
console.error(e)
toaster.danger(e.message || e.toString())
setStatus({
msg: e.message || e.toString(),
error: true,
loading: false
})
}, [])

const challengeResolver = useCallback(async (challenge) => {
const challengeUrl = sanitizeURL(challenge)
if (challengeUrl) {
setStatus({
title: 'Waiting for confirmation',
msg: 'Please confirm this action on your remote signer',
title: challengeTitle,
msg: challengeMessage,
error: false,
loading: true,
button: {
label: 'open signer',
label: challengeButtonLabel,
action: () => {
window.open(challengeUrl, '_blank')
}
}
})
} else {
setStatus({
title: 'Waiting for confirmation',
title: challengeTitle,
msg: challenge,
error: false,
loading: true
})
}
}, [])

return { status, setStatus, setError, challengeResolver }
}

export function useNostrAuthStateModal ({
...args
}) {
const showModal = useShowModal()

const { status, setStatus, setError, challengeResolver } = useNostrAuthState(args)
const closeModalRef = useRef(null)

useEffect(() => {
closeModalRef?.current?.()
if (status.loading) {
showModal(onClose => {
closeModalRef.current = onClose
return (
<>
<h3 className='w-100 pb-2'>{status.title}</h3>
<NostrAuthStatus status={status} />
</>
)
})
}
}, [status])

return { status, setStatus, setError, challengeResolver }
}

export function NostrAuthStatus ({ status, suggestion }) {
return (
<>
{status.error && <NostrError message={status.msg} />}
{status.loading &&
(
<>
<div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'>
<Moon className='spin fill-grey flex-shrink-0' width='30' height='30' />
{status.msg}
</div>
{status.button && (
<Button
className='w-100' variant='primary'
onClick={() => status.button.action()}
>
{status.button.label}
</Button>
)}
{suggestion && (
<div className='text-muted text-center small pt-2'>{suggestion}</div>
)}
</>
)}
</>
)
}

export function NostrAuth ({ text, callbackUrl, multiAuth }) {
const { status, setStatus, setError, challengeResolver } = useNostrAuthState()

const [suggestion, setSuggestion] = useState(null)
const suggestionTimeout = useRef(null)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this file, I've separated the logic related to the NIP-46 authentication challenge into dedicated hooks and components, making it reusable for the crosspost's NIP-46 authentication.

// create auth challenge
const [createAuth] = useMutation(gql`
mutation createAuth {
Expand All @@ -82,17 +159,6 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
fetchPolicy: 'no-cache'
})

// print an error message
const setError = useCallback((e) => {
console.error(e)
toaster.danger(e.message || e.toString())
setStatus({
msg: e.message || e.toString(),
error: true,
loading: false
})
}, [])

const clearSuggestionTimer = () => {
if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current)
}
Expand Down Expand Up @@ -127,7 +193,7 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
if (!k1) throw new Error('Error generating challenge') // should never happen

const useExtension = !nip46token
const signer = nostr.getSigner({ nip46token, supportNip07: useExtension })
const signer = nostr.getSigner({ nip46token, nip07: useExtension })
if (!signer && useExtension) throw new Error('No extension found')

if (signer instanceof NDKNip46Signer) {
Expand Down Expand Up @@ -170,69 +236,49 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {

return (
<>
{status.error && <NostrError message={status.msg} />}
{status.loading
? (
<>
<div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'>
<Moon className='spin fill-grey flex-shrink-0' width='30' height='30' />
{status.msg}
<NostrAuthStatus status={status} suggestion={suggestion} />
{!status.loading && (
<>
<Form
initial={{ token: '' }}
onSubmit={values => {
if (!values.token) {
setError(new Error('Token or NIP-05 address is required'))
} else {
auth(values.token)
}
}}
>
<Input
label='Connect with token or NIP-05 address'
name='token'
placeholder='bunker://... or NIP-05 address'
required
autoFocus
/>
<div className='mt-2'>
<SubmitButton className='w-100' variant='primary'>
{text || 'Login'} with token or NIP-05
</SubmitButton>
</div>
{status.button && (
<Button
className='w-100' variant='primary'
onClick={() => status.button.action()}
>
{status.button.label}
</Button>
)}
{suggestion && (
<div className='text-muted text-center small pt-2'>{suggestion}</div>
)}
</>
)
: (
<>
<Form
initial={{ token: '' }}
onSubmit={values => {
if (!values.token) {
setError(new Error('Token or NIP-05 address is required'))
} else {
auth(values.token)
}
}}
>
<Input
label='Connect with token or NIP-05 address'
name='token'
placeholder='bunker://... or NIP-05 address'
required
autoFocus
/>
<div className='mt-2'>
<SubmitButton className='w-100' variant='primary'>
{text || 'Login'} with token or NIP-05
</SubmitButton>
</div>
</Form>
<div className='text-center text-muted fw-bold my-2'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</>
)}
</Form>
<div className='text-center text-muted fw-bold my-2'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</>
)}
</>
)
}
Expand Down
Loading
Loading