@@ -301,6 +305,7 @@ function LogoutObstacle ({ onClose }) {
}
removeLocalWallets()
+ clearLocalEncryptedPrivates()
await signOut({ callbackUrl: '/' })
}}
diff --git a/components/nostr-auth.js b/components/nostr-auth.js
index b655327cd..1aa5d1df2 100644
--- a/components/nostr-auth.js
+++ b/components/nostr-auth.js
@@ -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 {
@@ -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,
@@ -42,20 +49,27 @@ 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')
}
@@ -63,7 +77,7 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
})
} else {
setStatus({
- title: 'Waiting for confirmation',
+ title: challengeTitle,
msg: challenge,
error: false,
loading: true
@@ -71,6 +85,69 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
}
}, [])
+ 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 (
+ <>
+
+ >
+ )
+ })
+ }
+ }, [status])
+
+ return { status, setStatus, setError, challengeResolver }
+}
+
+export function NostrAuthStatus ({ status, suggestion }) {
+ return (
+ <>
+ {status.error &&
+ )}
+ >
+ )}
+ >
+ )
+}
+
+export function NostrAuth ({ text, callbackUrl, multiAuth }) {
+ const { status, setStatus, setError, challengeResolver } = useNostrAuthState()
+
+ const [suggestion, setSuggestion] = useState(null)
+ const suggestionTimeout = useRef(null)
+
// create auth challenge
const [createAuth] = useMutation(gql`
mutation createAuth {
@@ -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)
}
@@ -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) {
@@ -170,69 +236,49 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
return (
<>
- {status.error &&
-
- {status.msg}
+
+ {!status.loading && (
+ <>
+
-
or
-
- >
- )}
+
+
or
+
+ >
+ )}
>
)
}
diff --git a/components/use-crossposter.js b/components/use-crossposter.js
index 560887ff5..9b75fedc2 100644
--- a/components/use-crossposter.js
+++ b/components/use-crossposter.js
@@ -5,6 +5,10 @@ import Nostr, { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
import { SETTINGS } from '@/fragments/users'
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items'
+import { useMe } from '@/components/me'
+import { useEncryptedPrivates } from '@/components/use-encrypted-privates'
+import { NDKNip46Signer } from '@nostr-dev-kit/ndk'
+import { useNostrAuthStateModal } from '@/components/nostr-auth'
function itemToContent (item, { includeTitle = true } = {}) {
let content = includeTitle ? item.title : ''
@@ -86,6 +90,12 @@ export default function useCrossposter () {
const { data } = useQuery(SETTINGS)
const userRelays = data?.settings?.privates?.nostrRelays || []
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...userRelays]
+ const { me } = useMe()
+ const { encryptedPrivates } = useEncryptedPrivates({ me })
+
+ const { challengeResolver: nostrAuthChallengeResolver, setStatus: nostrAuthSetStatus } = useNostrAuthStateModal({
+ challengeTitle: 'Crossposting to Nostr'
+ })
const [fetchItem] = useLazyQuery(
gql`
@@ -193,52 +203,64 @@ export default function useCrossposter () {
}
}
- const crosspostItem = async item => {
+ const crosspostItem = useCallback(async item => {
let failedRelays
let allSuccessful = false
let noteId
const event = await handleEventCreation(item)
if (!event) return { allSuccessful, noteId }
+ const nostr = new Nostr()
+ try {
+ const signer = nostr.getSigner({
+ userPreferences: encryptedPrivates || {},
+ // if the user has no signer preference, we fallback to NIP-07
+ nip07: true
+ })
+ if (signer instanceof NDKNip46Signer) {
+ signer.once('authUrl', nostrAuthChallengeResolver)
+ }
+ await signer.blockUntilReady()
+ nostrAuthSetStatus({ success: true })
- do {
- const nostr = new Nostr()
- try {
- const result = await nostr.crosspost(event, { relays: failedRelays || relays })
+ do {
+ try {
+ const result = await nostr.crosspost(event, { relays: failedRelays || relays, signer })
- if (result.error) {
- failedRelays = []
- throw new Error(result.error)
- }
+ if (result.error) {
+ failedRelays = []
+ throw new Error(result.error)
+ }
- noteId = result.noteId
- failedRelays = result?.failedRelays?.map(relayObj => relayObj.relay) || []
+ noteId = result.noteId
+ failedRelays = result?.failedRelays?.map(relayObj => relayObj.relay) || []
- if (failedRelays.length > 0) {
- const userAction = await relayError(failedRelays)
+ if (failedRelays.length > 0) {
+ const userAction = await relayError(failedRelays)
- if (userAction === 'skip') {
- toaster.success('Skipped failed relays.')
- // wait 2 seconds then break
- await new Promise(resolve => setTimeout(resolve, 2000))
- break
+ if (userAction === 'skip') {
+ toaster.success('Skipped failed relays.')
+ // wait 2 seconds then break
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ break
+ }
+ } else {
+ allSuccessful = true
}
- } else {
- allSuccessful = true
+ } catch (error) {
+ await crosspostError(error.message)
+
+ // wait 2 seconds to show error then break
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ return { allSuccessful, noteId }
}
- } catch (error) {
- await crosspostError(error.message)
-
- // wait 2 seconds to show error then break
- await new Promise(resolve => setTimeout(resolve, 2000))
- return { allSuccessful, noteId }
- } finally {
- nostr.close()
- }
- } while (failedRelays.length > 0)
+ } while (failedRelays.length > 0)
+ } finally {
+ nostr.close()
+ }
return { allSuccessful, noteId }
- }
+ }, [relays, encryptedPrivates])
const handleCrosspost = useCallback(async (itemId) => {
let noteId
diff --git a/components/use-encrypted-privates.js b/components/use-encrypted-privates.js
new file mode 100644
index 000000000..8cc7b385d
--- /dev/null
+++ b/components/use-encrypted-privates.js
@@ -0,0 +1,125 @@
+import useVault from '@/components/vault/use-vault'
+import { encryptedPrivates, validateSchema, encryptedPrivatesSchema } from '@/lib/validate'
+import { setValue as setLocalValue, getValue as getLocalValue, clearValue as clearLocalValue } from '@/components/use-local-state'
+import { SET_ENCRYPTED_SETTINGS } from '@/fragments/users'
+import { useMutation } from '@apollo/client'
+import { useState, useCallback, useEffect } from 'react'
+
+export function useEncryptedPrivates ({ me }) {
+ const { isActive, decrypt: decryptVault, encrypt: encryptVault, key: vaultKey } = useVault()
+
+ const [innerSetEncryptedSettings] = useMutation(SET_ENCRYPTED_SETTINGS, {
+ update (cache, { data: { setSettings } }) {
+ cache.modify({
+ id: 'ROOT_QUERY',
+ fields: {
+ settings () {
+ return setSettings
+ }
+ }
+ })
+ }
+ })
+
+ const [innerEncryptedPrivatesState, innerSetEncryptedPrivatesState] = useState({})
+ const storageKey = (key) => `privates-${key}-${me?.id}`
+
+ const clearLocalEncryptedPrivates = useCallback(() => {
+ for (const key of encryptedPrivates) {
+ clearLocalValue(storageKey(key))
+ }
+ }, [encryptedPrivates])
+
+ /**
+ * Get and decrypt signer data from vaultEntries if vault is active, or from local storage if vault is not active
+ */
+ const getEncryptedPrivates = useCallback(async () => {
+ const decryptedEntries = {}
+ if (isActive && me?.encryptedPrivates?.length) {
+ // async decrypt all vault entries
+ const vaultEntriesPromises = await Promise.allSettled(me?.encryptedPrivates
+ .filter(e => encryptedPrivates.includes(e.key))
+ .map(async entry => {
+ const decryptedValue = await decryptVault({ iv: entry.iv, value: entry.value })
+ return { key: entry.key, value: decryptedValue }
+ })
+ )
+
+ // merge to the decryptedEntries object
+ for (const p of vaultEntriesPromises) {
+ if (p.status === 'fulfilled') {
+ const { key, value } = p.value
+ decryptedEntries[key] = value
+ }
+ }
+ }
+
+ // get missing entries from the local storage
+ for (const key of encryptedPrivates) {
+ if (!decryptedEntries[key]) {
+ const localValue = getLocalValue(storageKey(key))
+ if (localValue) decryptedEntries[key] = localValue
+ }
+ }
+ await validateSchema(encryptedPrivatesSchema, decryptedEntries)
+ return decryptedEntries
+ }, [isActive, me, vaultKey, decryptVault])
+
+ /**
+ * Save encrypted signer data to vaultEntries in userSettings, or to local storage if vault is not active
+ */
+ const setEncryptedSettings = useCallback(async (entries) => {
+ await validateSchema(encryptedPrivatesSchema, entries)
+ if (isActive) {
+ const encryptedPrivates = await Promise.all(Object.entries(entries).map(async ([key, value]) => {
+ return {
+ key,
+ ...await encryptVault(value)
+ }
+ }))
+ innerSetEncryptedSettings({ variables: { settings: encryptedPrivates } })
+ } else {
+ for (const [key, value] of Object.entries(entries)) {
+ setLocalValue(storageKey(key), value)
+ }
+ }
+ }, [isActive, encryptVault, innerSetEncryptedSettings])
+
+ const refreshEncryptedPrivates = useCallback(async () => {
+ const privates = await getEncryptedPrivates()
+ innerSetEncryptedPrivatesState(privates)
+ }, [innerSetEncryptedPrivatesState, getEncryptedPrivates])
+
+ useEffect(() => {
+ refreshEncryptedPrivates()
+ }, [me, refreshEncryptedPrivates])
+
+ const onVaultKeySet = useCallback(async (encrypt) => {
+ const entriesToSync = encryptedPrivates
+ .map(k => {
+ const value = getLocalValue(storageKey(k))
+ return value ? { key: k, value } : null
+ }).filter(v => v)
+ if (encrypt && entriesToSync.length > 0) {
+ const encryptedSettings = await Promise.all(entriesToSync
+ .map(async ({ key, value }) => {
+ return {
+ key,
+ ...await encrypt(value)
+ }
+ }))
+ innerSetEncryptedSettings({ variables: { settings: encryptedSettings } })
+ clearLocalEncryptedPrivates()
+ }
+ }, [getLocalValue, setEncryptedSettings, clearLocalEncryptedPrivates])
+
+ const beforeDisconnectVault = useCallback(async () => {
+ const vaultEncryptedPrivates = await getEncryptedPrivates()
+ for (const [key, value] of Object.entries(vaultEncryptedPrivates)) {
+ setLocalValue(storageKey(key), value)
+ }
+ refreshEncryptedPrivates()
+ }, [getEncryptedPrivates, refreshEncryptedPrivates])
+
+ return { encryptedPrivates: innerEncryptedPrivatesState, getEncryptedPrivates, setEncryptedSettings, clearLocalEncryptedPrivates, onVaultKeySet, beforeDisconnectVault, refreshEncryptedPrivates }
+}
diff --git a/components/use-local-state.js b/components/use-local-state.js
index fb9d0a576..e84362afe 100644
--- a/components/use-local-state.js
+++ b/components/use-local-state.js
@@ -1,20 +1,34 @@
import { SSR } from '@/lib/constants'
import { useCallback, useState } from 'react'
+export function setValue (storageKey, value) {
+ if (SSR) return
+ if (value === undefined) value = null
+ window.localStorage.setItem(storageKey, JSON.stringify(value))
+}
+
+export function getValue (storageKey, defaultValue) {
+ if (SSR) return null
+ return JSON.parse(window.localStorage.getItem(storageKey)) || defaultValue
+}
+
+export function clearValue (storageKey) {
+ if (SSR) return
+ window.localStorage.removeItem(storageKey)
+}
+
export default function useLocalState (storageKey, defaultValue) {
- const [value, innerSetValue] = useState(
- (SSR ? null : JSON.parse(window.localStorage.getItem(storageKey))) || defaultValue
- )
+ const [value, innerSetValue] = useState(getValue(storageKey, defaultValue))
- const setValue = useCallback((newValue) => {
- window.localStorage.setItem(storageKey, JSON.stringify(newValue))
+ const set = useCallback((newValue) => {
+ setValue(storageKey, newValue)
innerSetValue(newValue)
}, [storageKey])
- const clearValue = useCallback(() => {
- window.localStorage.removeItem(storageKey)
+ const clear = useCallback(() => {
+ clearValue(storageKey)
innerSetValue(null)
}, [storageKey])
- return [value, setValue, clearValue]
+ return [value, set, clear]
}
diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js
index a3269f5a4..20c72135d 100644
--- a/components/vault/use-vault-configurator.js
+++ b/components/vault/use-vault-configurator.js
@@ -34,7 +34,7 @@ export function useVaultConfigurator ({ onVaultKeySet, beforeDisconnectVault } =
const disconnectVault = useCallback(async () => {
console.log('disconnecting vault')
- beforeDisconnectVault?.()
+ await beforeDisconnectVault?.()
await remove('key')
keyReactiveVar(null)
}, [remove, keyReactiveVar, beforeDisconnectVault])
diff --git a/fragments/users.js b/fragments/users.js
index c65cc2a06..c881475ce 100644
--- a/fragments/users.js
+++ b/fragments/users.js
@@ -2,6 +2,7 @@ import { gql } from '@apollo/client'
import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { SUB_FULL_FIELDS } from './subs'
+import { VAULT_ENTRY_FIELDS } from './vault'
export const STREAK_FIELDS = gql`
fragment StreakFields on User {
@@ -15,6 +16,7 @@ export const STREAK_FIELDS = gql`
export const ME = gql`
${STREAK_FIELDS}
+${VAULT_ENTRY_FIELDS}
{
me {
id
@@ -55,6 +57,9 @@ ${STREAK_FIELDS}
proxyReceive
directReceive
}
+ encryptedPrivates {
+ ...VaultEntryFields
+ }
optional {
isContributor
stacked
@@ -120,6 +125,11 @@ export const SETTINGS_FIELDS = gql`
receiveCreditsBelowSats
sendCreditsBelowSats
}
+ encryptedPrivates {
+ key
+ value
+ iv
+ }
}`
export const SETTINGS = gql`
@@ -138,6 +148,14 @@ export const SET_SETTINGS = gql`
}
}`
+export const SET_ENCRYPTED_SETTINGS = gql`
+ ${SETTINGS_FIELDS}
+ mutation setEncryptedSettings($settings: [VaultEntryInput!]!) {
+ setEncryptedSettings(settings: $settings) {
+ ...SettingsFields
+ }
+ }`
+
export const DELETE_WALLET = gql`
mutation removeWallet {
removeWallet
diff --git a/lib/nostr.js b/lib/nostr.js
index 349d2efe0..87bdda80a 100644
--- a/lib/nostr.js
+++ b/lib/nostr.js
@@ -1,6 +1,7 @@
import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
-import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
+import NDK, { NDKUser, NDKEvent, NDKNostrRpc, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
+import { withTimeout } from './time'
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
@@ -34,15 +35,15 @@ export default class Nostr {
* @type {NDK}
*/
_ndk = null
- static globalInstance = null
- constructor ({ privKey, defaultSigner, relays, nip46token, supportNip07 = false, ...ndkOptions } = {}) {
+ static _globalInstance = null
+
+ constructor ({ relays, ...ndkOptions } = {}) {
this._ndk = new NDK({
explicitRelayUrls: relays,
blacklistRelayUrls: RELAYS_BLACKLIST,
autoConnectUserRelays: false,
autoFetchUserMutelist: false,
clientName: 'stacker.news',
- signer: defaultSigner ?? this.getSigner({ privKey, supportNip07, nip46token }),
...ndkOptions
})
}
@@ -51,10 +52,10 @@ export default class Nostr {
* @type {NDK}
*/
static get () {
- if (!Nostr.globalInstance) {
- Nostr.globalInstance = new Nostr()
+ if (!Nostr._globalInstance) {
+ Nostr._globalInstance = new Nostr()
}
- return Nostr.globalInstance
+ return Nostr._globalInstance
}
/**
@@ -66,16 +67,31 @@ export default class Nostr {
/**
*
- * @param {Object} param0
+ * @param {Object} args
+ * @param {string} [args.userPreferences] - user given preferences
+ * @param {string} [args.userPreferences.signerType] - signer type to use for signing (nip07, nip46)
+ * @param {string} [args.userPreferences.signer] - signer config to use for signing
+ * @param {string} [args.userPreferences.signerInstanceKey] - private key used by the local signer to sign auth events
* @param {string} [args.privKey] - private key to use for signing
* @param {string} [args.nip46token] - NIP-46 token to use for signing
- * @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available
+ * @param {boolean} [args.nip07] - whether to use NIP-07 signer if available
* @returns {NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | null} - a signer instance
*/
- getSigner ({ privKey, nip46token, supportNip07 = true } = {}) {
+ getSigner ({ userPreferences: { signerType: userSignerTypePreference, signer: userSignerPreference, signerInstanceKey } = {}, privKey, nip46token, nip07, nip46LocalSigner } = {}) {
+ switch (userSignerTypePreference) {
+ case 'nip07':
+ nip07 = true
+ break
+ case 'nip46':
+ nip46token = userSignerPreference
+ if (signerInstanceKey) nip46LocalSigner = new NDKPrivateKeySigner(signerInstanceKey)
+ break
+ default:
+ console.warn('Unknown user preferences:', userSignerTypePreference, userSignerPreference)
+ }
if (privKey) return new NDKPrivateKeySigner(privKey)
- if (nip46token) return new NDKNip46SignerURLPatch(this.ndk, nip46token)
- if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer()
+ if (nip46token) return new NDKNip46SignerPatched(this.ndk, nip46token, nip46LocalSigner)
+ if (nip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer()
return null
}
@@ -141,7 +157,9 @@ export default class Nostr {
async crosspost ({ created_at, content, tags = [], kind }, { relays = DEFAULT_CROSSPOSTING_RELAYS, signer, timeout } = {}) {
try {
- signer ??= this.getSigner({ supportNip07: true })
+ // If no signer is provided, we default to NIP-07, since that's the saner default for crossposting.
+ // We can't default the whole ndk instance to nip-07, since that would cause the extension popup to show up randomly
+ signer ??= this.getSigner({ nip07: true })
const { event: signedEvent, successfulRelays, failedRelays } = await this.publish({ created_at, content, tags, kind }, { relays, signer, timeout })
let noteId = null
@@ -195,9 +213,69 @@ export function nostrZapDetails (zap) {
}
// workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636)
-class NDKNip46SignerURLPatch extends NDKNip46Signer {
+// workaround NDK restore nip46 connection
+class NDKNip46SignerPatched extends NDKNip46Signer {
connectionTokenInit (connectionToken) {
connectionToken = connectionToken.replace('bunker://', 'http://')
return super.connectionTokenInit(connectionToken)
}
+
+ async blockUntilReady () {
+ if (this.nip05 && !this.userPubkey) {
+ const user = await NDKUser.fromNip05(this.nip05, this.ndk)
+ if (user) {
+ this._user = user
+ this.userPubkey = user.pubkey
+ this.relayUrls = user.nip46Urls
+ this.rpc = new NDKNostrRpc(this.ndk, this.localSigner, this.debug, this.relayUrls)
+ }
+ }
+ if (!this.bunkerPubkey && this.userPubkey) {
+ this.bunkerPubkey = this.userPubkey
+ } else if (!this.bunkerPubkey) {
+ throw new Error('Bunker pubkey not set')
+ }
+ await this.startListening()
+ this.rpc.on('authUrl', (...props) => {
+ this.emit('authUrl', ...props)
+ })
+ return new Promise((resolve, reject) => {
+ const connectParams = [this.userPubkey ?? '']
+ if (this.secret) connectParams.push(this.secret)
+ if (!this.bunkerPubkey) throw new Error('Bunker pubkey not set')
+
+ const confirm = async (resolve) => {
+ return withTimeout(this.getPublicKey().then((pubkey) => {
+ this._user = new NDKUser({ pubkey })
+ resolve(this._user)
+ }), 15_000)
+ }
+
+ // initiate a new connection
+ this.rpc.sendRequest(
+ this.bunkerPubkey,
+ 'connect',
+ connectParams,
+ 24133,
+ async (response) => {
+ try {
+ if (response.result === 'ack') {
+ await confirm(resolve)
+ } else {
+ throw new Error(response.error)
+ }
+ } catch (e) {
+ reject(e)
+ }
+ }
+ )
+
+ if (!this.nip05) {
+ // try to restore the connection
+ // One of the two will fail, either the new connection or the restore attempt.
+ confirm(resolve)
+ .catch((err) => console.error('Error restoring connection:', err))
+ }
+ })
+ }
}
diff --git a/lib/validate.js b/lib/validate.js
index 12bce77a8..8e34a6944 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -196,6 +196,19 @@ export const vaultEntrySchema = key => object({
value: string().required('required').hex().min(2).max(1024 * 10)
})
+export const encryptedPrivates = ['signer', 'signerType', 'signerInstanceKey']
+
+export const encryptedPrivatesVaultEntriesSchema = array().of(object({
+ key: string().required('required').oneOf(encryptedPrivates, `expected one of ${encryptedPrivates.join(', ')}`),
+ iv: string().required('required').hex().length(24, 'must be 24 characters long'),
+ value: string().required('required').hex().min(2).max(1024 * 10)
+}))
+
+export const encryptedPrivatesSchema = object({
+ signer: string(),
+ signerType: string().oneOf(['nip07', 'nip46', 'nsec'])
+})
+
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: lightningAddressValidator.required('required'),
diff --git a/pages/settings/index.js b/pages/settings/index.js
index 4f1e912d9..957df896f 100644
--- a/pages/settings/index.js
+++ b/pages/settings/index.js
@@ -1,10 +1,10 @@
-import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '@/components/form'
+import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput, PasswordInput } from '@/components/form'
import Alert from 'react-bootstrap/Alert'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import Nav from 'react-bootstrap/Nav'
import Layout from '@/components/layout'
-import { useState, useMemo } from 'react'
+import { useState, useEffect } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { getGetServerSideProps } from '@/api/ssrApollo'
import LoginButton from '@/components/login-button'
@@ -16,21 +16,26 @@ import Info from '@/components/info'
import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
import { bech32 } from 'bech32'
-import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
+import Nostr, { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '@/lib/validate'
import { SUPPORTED_CURRENCIES } from '@/lib/currency'
import PageLoading from '@/components/page-loading'
import { useShowModal } from '@/components/modal'
import { authErrorMessage } from '@/components/login'
-import { NostrAuth } from '@/components/nostr-auth'
+import { NostrAuth, useNostrAuthStateModal } from '@/components/nostr-auth'
import { useToast } from '@/components/toast'
import { useServiceWorkerLogger } from '@/components/logger'
import { useMe } from '@/components/me'
import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
-import { useField } from 'formik'
+import { useField, useFormikContext } from 'formik'
import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
+import { useEncryptedPrivates } from '@/components/use-encrypted-privates'
+import { generateSecretKey } from 'nostr-tools'
+import { bytesToHex } from '@noble/hashes/utils'
+import { NDKNip46Signer } from '@nostr-dev-kit/ndk'
+import { callWithTimeout } from '@/lib/time'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@@ -90,6 +95,8 @@ export function SettingsHeader () {
export default function Settings ({ ssrData }) {
const toaster = useToast()
const { me } = useMe()
+ const { encryptedPrivates, setEncryptedSettings, refreshEncryptedPrivates } = useEncryptedPrivates({ me })
+
const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) {
cache.modify({
@@ -105,7 +112,20 @@ export default function Settings ({ ssrData }) {
const logger = useServiceWorkerLogger()
const { data } = useQuery(SETTINGS)
- const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData])
+ const [settings, setSettingsState] = useState(() => (data ?? ssrData)?.settings?.privates)
+
+ const { challengeResolver: nostrAuthChallengeResolver, setStatus: nostrAuthSetStatus } = useNostrAuthStateModal({
+ challengeTitle: 'Configuring crosspost to Nostr'
+ })
+
+ useEffect(() => {
+ let settings = (data ?? ssrData)?.settings?.privates
+ if (!settings) return
+ if (encryptedPrivates) {
+ settings = { ...settings, ...encryptedPrivates }
+ }
+ setSettingsState(settings)
+ }, [data, ssrData, encryptedPrivates])
// if we switched to anon, me is null before the page is reloaded
if ((!data && !ssrData) || !me) return
@@ -161,6 +181,8 @@ export default function Settings ({ ssrData }) {
noReferralLinks: settings?.noReferralLinks,
proxyReceive: settings?.proxyReceive,
directReceive: settings?.directReceive,
+ signerType: settings?.signerType || 'nip07',
+ signer: settings?.signer,
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
sendCreditsBelowSats: settings?.sendCreditsBelowSats
}}
@@ -168,6 +190,7 @@ export default function Settings ({ ssrData }) {
onSubmit={async ({
tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault,
zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter,
+ signer, signerType,
receiveCreditsBelowSats, sendCreditsBelowSats,
...values
}) => {
@@ -202,6 +225,42 @@ export default function Settings ({ ssrData }) {
}
}
})
+
+ let signerInstanceKey = settings.signerInstanceKey
+ if (signer !== settings.signer || signerType !== settings.signerType) {
+ // if the signer changes, we regenerate the signerInstanceKey.
+ // this is used to identify the app instance by nip46 and permit
+ // token reuse
+ signerInstanceKey = bytesToHex(generateSecretKey())
+
+ // we create the initial connection for the nip46 signer here
+ // because it makes for better ux (no confirmation delay on crosspost)
+ // and because we can test immediately if the connection works
+ if (signerType === 'nip46' && signer) {
+ const nostr = new Nostr()
+ try {
+ await callWithTimeout(async () => {
+ const testSignerInstance = nostr.getSigner({
+ userPreferences: { signer, signerType, signerInstanceKey }
+ })
+ if (testSignerInstance instanceof NDKNip46Signer) {
+ testSignerInstance.once('authUrl', nostrAuthChallengeResolver)
+ }
+ await testSignerInstance.blockUntilReady()
+ nostrAuthSetStatus({ success: true })
+ }, 60_000 * 5) // after some time we give up, likely the signer is not responding at this point
+ } catch (err) {
+ toaster.danger('invalid nostr signer: ' + err.message)
+ throw err
+ } finally {
+ nostr.close()
+ }
+ }
+ }
+
+ await setEncryptedSettings({ signer, signerType, signerInstanceKey })
+ await refreshEncryptedPrivates()
+
toaster.success('saved settings')
} catch (err) {
console.error(err)
@@ -645,6 +704,7 @@ export default function Settings ({ ssrData }) {
clear
hint={
used for NIP-05}
/>
+
relays optional>}
name='nostrRelays'
@@ -667,6 +727,44 @@ export default function Settings ({ ssrData }) {
)
}
+const SignerSettings = () => {
+ const { values } = useFormikContext()
+ const TypeSelector = (args) => {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+ {(values?.signerType === 'nip46')
+ ? (
+ )
+ : null}
+ >
+ )
+}
+
const DropBolt11sCheckbox = ({ ssrData, ...props }) => {
const showModal = useShowModal()
const { data } = useQuery(gql`{ numBolt11s }`)
diff --git a/pages/settings/passphrase/index.js b/pages/settings/passphrase/index.js
index 79cedac82..a388e309c 100644
--- a/pages/settings/passphrase/index.js
+++ b/pages/settings/passphrase/index.js
@@ -11,14 +11,25 @@ import RefreshIcon from '@/svgs/refresh-line.svg'
import { useCallback, useEffect, useState } from 'react'
import { useToast } from '@/components/toast'
import { useWallets } from '@/wallets/index'
+import { useEncryptedPrivates } from '@/components/use-encrypted-privates'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function DeviceSync ({ ssrData }) {
const { me } = useMe()
- const { onVaultKeySet, beforeDisconnectVault } = useWallets()
+ const { onVaultKeySet: walletOnVaultKeySet, beforeDisconnectVault: walletBeforeDisconnectVault } = useWallets()
+ const { onVaultKeySet: userOnVaultKeySet, beforeDisconnectVault: userBeforeDisconnectVault } = useEncryptedPrivates({ me })
const { key, setVaultKey, clearVault, disconnectVault } =
- useVaultConfigurator({ onVaultKeySet, beforeDisconnectVault })
+ useVaultConfigurator({
+ onVaultKeySet: async (encrypt) => {
+ walletOnVaultKeySet(encrypt).catch(console.error)
+ userOnVaultKeySet(encrypt).catch(console.error)
+ },
+ beforeDisconnectVault: async () => {
+ walletBeforeDisconnectVault()
+ userBeforeDisconnectVault().catch(console.error)
+ }
+ })
const [passphrase, setPassphrase] = useState()
const setSeedPassphrase = useCallback(async (passphrase) => {
diff --git a/worker/nostr.js b/worker/nostr.js
index cc59c98be..430fda12b 100644
--- a/worker/nostr.js
+++ b/worker/nostr.js
@@ -47,6 +47,8 @@ export async function nip57 ({ data: { hash }, boss, lnd, models }) {
console.log('zap note', e, relays)
const nostr = Nostr.get()
const signer = nostr.getSigner({ privKey: process.env.NOSTR_PRIVATE_KEY })
+ await signer.blockUntilReady()
+
await nostr.publish(e, {
relays,
signer,