diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 34e2aa402..73d1a616f 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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' @@ -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() @@ -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 diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index bfefe7e3d..67828d236 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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 @@ -66,6 +67,7 @@ export default gql` optional: UserOptional! privates: UserPrivates + encryptedPrivates: [VaultEntry!] meMute: Boolean! meSubscriptionPosts: Boolean! diff --git a/components/nav/common.js b/components/nav/common.js index 7f0fc32fd..08e6e1a9d 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -24,7 +24,9 @@ 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' import { numWithUnits } from '@/lib/format' + export function Brand ({ className }) { return ( @@ -271,6 +273,8 @@ function LogoutObstacle ({ onClose }) { const { removeLocalWallets } = useWallets() const { multiAuthSignout } = useAccounts() const router = useRouter() + const { me } = useMe() + const { clearLocalEncryptedPrivates } = useEncryptedPrivates({ me }) return (
@@ -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.title}

+ + + ) + }) + } + }, [status]) + + return { status, setStatus, setError, challengeResolver } +} + +export function NostrAuthStatus ({ status, suggestion }) { + return ( + <> + {status.error && } + {status.loading && + ( + <> +
+ + {status.msg} +
+ {status.button && ( + + )} + {suggestion && ( +
{suggestion}
+ )} + + )} + + ) +} + +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.loading - ? ( - <> -
- - {status.msg} + + {!status.loading && ( + <> +
{ + if (!values.token) { + setError(new Error('Token or NIP-05 address is required')) + } else { + auth(values.token) + } + }} + > + +
+ + {text || 'Login'} with token or NIP-05 +
- {status.button && ( - - )} - {suggestion && ( -
{suggestion}
- )} - - ) - : ( - <> - { - if (!values.token) { - setError(new Error('Token or NIP-05 address is required')) - } else { - auth(values.token) - } - }} - > - -
- - {text || 'Login'} with token or NIP-05 - -
-
-
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 ( +