add Nostr-login package #217

Merged
enes merged 20 commits from nostr-login-9-30 into staging 2024-10-09 08:54:33 +00:00
24 changed files with 577 additions and 999 deletions
Showing only changes of commit 637e26bf35 - Show all commits

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController, NostrController } from './controllers' import { AuthController } from './controllers'
import { MainLayout } from './layouts/Main' import { MainLayout } from './layouts/Main'
import { import {
appPrivateRoutes, appPrivateRoutes,
@ -11,7 +11,6 @@ import {
recursiveRouteRenderer recursiveRouteRenderer
} from './routes' } from './routes'
import { State } from './store/rootReducer' import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
import './App.scss' import './App.scss'
const App = () => { const App = () => {
@ -25,23 +24,10 @@ const App = () => {
window.location.hostname = 'localhost' window.location.hostname = 'localhost'
} }
generateBunkerDelegatedKey()
const authController = new AuthController() const authController = new AuthController()
authController.checkSession() authController.checkSession()
}, []) }, [])
const generateBunkerDelegatedKey = () => {
const existingKey = getNsecBunkerDelegatedKey()
if (!existingKey) {
const nostrController = NostrController.getInstance()
const newDelegatedKey = nostrController.generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
}
}
const handleRootRedirect = () => { const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa( const callbackPathEncoded = btoa(

View File

@ -9,44 +9,28 @@ import {
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import {
setAuthState,
setMetadataEvent,
userLogOutAction
} from '../../store/actions'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store'
import Username from '../username' import Username from '../username'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { MetadataController, NostrController } from '../../controllers'
import { import {
appPublicRoutes, appPublicRoutes,
appPrivateRoutes, appPrivateRoutes,
getProfileRoute getProfileRoute
} from '../../routes' } from '../../routes'
import { import { getProfileUsername, hexToNpub } from '../../utils'
clearAuthToken,
getProfileUsername,
hexToNpub,
saveNsecBunkerDelegatedKey
} from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action'
import { Container } from '../Container' import { Container } from '../Container'
import { ButtonIcon } from '../ButtonIcon' import { ButtonIcon } from '../ButtonIcon'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClose } from '@fortawesome/free-solid-svg-icons' import { faClose } from '@fortawesome/free-solid-svg-icons'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useLogout } from '../../hooks/useLogout'
const metadataController = MetadataController.getInstance()
export const AppBar = () => { export const AppBar = () => {
const navigate = useNavigate() const navigate = useNavigate()
const logout = useLogout()
const dispatch: Dispatch = useDispatch()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [userAvatar, setUserAvatar] = useState('') const [userAvatar, setUserAvatar] = useState('')
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null) const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
@ -94,28 +78,7 @@ export const AppBar = () => {
const handleLogout = () => { const handleLogout = () => {
handleCloseUserMenu() handleCloseUserMenu()
dispatch( logout()
setAuthState({
keyPair: undefined,
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
dispatch(setUserRobotImage(null))
// clear authToken saved in local storage
clearAuthToken()
dispatch(userLogOutAction())
// update nsecBunker delegated key after logout
const nostrController = NostrController.getInstance()
const newDelegatedKey = nostrController.generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
navigate('/') navigate('/')
} }
const isAuthenticated = authState?.loggedIn === true const isAuthenticated = authState?.loggedIn === true

View File

@ -31,7 +31,7 @@ export class AuthController {
/** /**
* Function will authenticate user by signing an auth event * Function will authenticate user by signing an auth event
* which is done by calling the sign() function, where appropriate * which is done by calling the sign() function, where appropriate
* method will be chosen (extension, nsecbunker or keys) * method will be chosen (extension or keys)
* *
* @param pubkey of the user trying to login * @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull * @returns url to redirect if authentication successfull

View File

@ -1,194 +1,25 @@
import NDK, { import { EventTemplate, UnsignedEvent } from 'nostr-tools'
NDKEvent, import { WindowNostr } from 'nostr-tools/nip07'
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import {
Event,
EventTemplate,
UnsignedEvent,
finalizeEvent,
nip04,
nip19,
nip44
} from 'nostr-tools'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions' import { AuthState } from '../store/auth/types'
import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { SignedEvent } from '../types' import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' import { LoginMethodContext } from '../services/LoginMethodStrategy/loginMethodContext'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private constructor() { private constructor() {
super() super()
} }
private getNostrObject = () => { private getNostrObject = () => {
// fix: this is not picking up type declaration from src/system/index.d.ts if (window.nostr) return window.nostr as WindowNostr
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window.nostr) return window.nostr as any
throw new Error( throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.` `window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
) )
} }
public nsecBunkerInit = async (relays: string[]) => {
// Don't reinstantiate bunker NDK if exists with same relays
if (
this.bunkerNDK &&
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
)
return
this.bunkerNDK = new NDK({
explicitRelayUrls: relays
})
try {
await this.bunkerNDK
.connect(2000)
.then(() => {
console.log(
`Successfully connected to the nsecBunker relays: ${relays.join(
','
)}`
)
})
.catch((err) => {
console.error(
`Error connecting to the nsecBunker relays: ${relays.join(
','
)} ${err}`
)
})
} catch (err) {
console.error(err)
}
}
/**
* Creates nSecBunker signer instance for the given npub
* Or if npub omitted it will return existing signer
* If neither, error will be thrown
* @param npub nPub / public key in hex format
* @returns nsecBunker Signer instance
*/
public createNsecBunkerSigner = async (
npub: string | undefined
): Promise<NDKNip46Signer> => {
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
return new Promise((resolve, reject) => {
if (!nsecBunkerDelegatedKey) {
reject('nsecBunker delegated key is not found in the browser.')
return
}
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
if (!npub) {
if (this.remoteSigner) resolve(this.remoteSigner)
const npubFromStorage = (store.getState().auth as AuthState)
.nsecBunkerPubkey
if (npubFromStorage) {
npub = npubFromStorage
} else {
reject(
'No signer instance present, no npub provided by user or found in the browser.'
)
return
}
} else {
store.dispatch(updateNsecbunkerPubkey(npub))
}
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
const appPubkeyOrToken = npub.includes('npub')
? npub
: nip19.npubEncode(npub)
/**
* When creating and NDK instance we create new connection to the relay
* To prevent too much connections and hitting rate limits, if npub against which we sign
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
*/
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
this.remoteSigner = new NDKNip46Signer(
this.bunkerNDK!,
appPubkeyOrToken,
localSigner
)
}
/**
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
*/
if (this.remoteSigner.localSigner !== localSigner) {
this.remoteSigner = new NDKNip46Signer(
this.bunkerNDK!,
appPubkeyOrToken,
localSigner
)
}
resolve(this.remoteSigner)
})
}
/**
* Signs the nostr event and returns the sig and id or full raw nostr event
* @param npub stored in nsecBunker to sign with
* @param event to be signed
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
*/
public signWithNsecBunker = async (
npub: string | undefined,
event: NostrEvent,
returnFullEvent = true
): Promise<{ id: string; sig: string } | NostrEvent> => {
return new Promise((resolve, reject) => {
this.createNsecBunkerSigner(npub)
.then(async (signer) => {
const ndkEvent = new NDKEvent(undefined, event)
const timeout = setTimeout(() => {
reject('Timeout occurred while waiting for event signing')
}, 60000) // 60000 ms (1 min) = 1000 * 60
await ndkEvent.sign(signer).catch((err) => {
clearTimeout(timeout)
reject(err)
return
})
clearTimeout(timeout)
if (returnFullEvent) {
resolve(ndkEvent.rawEvent())
} else {
resolve({
id: ndkEvent.id,
sig: ndkEvent.sig!
})
}
})
.catch((err) => {
reject(err)
})
})
}
public static getInstance(): NostrController { public static getInstance(): NostrController {
if (!NostrController.instance) { if (!NostrController.instance) {
NostrController.instance = new NostrController() NostrController.instance = new NostrController()
@ -207,59 +38,10 @@ export class NostrController extends EventEmitter {
nip44Encrypt = async (receiver: string, content: string) => { nip44Encrypt = async (receiver: string, content: string) => {
// Retrieve the current login method from the application's redux state. // Retrieve the current login method from the application's redux state.
const loginMethod = (store.getState().auth as AuthState).loginMethod const loginMethod = (store.getState().auth as AuthState).loginMethod
const context = new LoginMethodContext(loginMethod)
// Handle encryption when the login method is via an extension. // Handle encryption when the login method is via an extension.
if (loginMethod === LoginMethods.extension) { return await context.nip44Encrypt(receiver, content)
const nostr = this.getNostrObject()
// Check if the nostr object supports NIP-44 encryption.
if (!nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Encrypt the content using NIP-44 provided by the nostr extension.
const encrypted = await nostr.nip44.encrypt(receiver, content)
return encrypted as string
}
// Handle encryption when the login method is via a private key.
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
receiver
)
// Encrypt the content using the generated conversation key.
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
return encrypted
}
// Throw an error if the login method is nsecBunker (not supported).
if (loginMethod === LoginMethods.nsecBunker) {
throw new Error(
`nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
)
}
// Throw an error if the login method is undefined or unsupported.
throw new Error('Login method is undefined')
} }
/** /**
@ -273,65 +55,15 @@ export class NostrController extends EventEmitter {
nip44Decrypt = async (sender: string, content: string) => { nip44Decrypt = async (sender: string, content: string) => {
// Retrieve the current login method from the application's redux state. // Retrieve the current login method from the application's redux state.
const loginMethod = (store.getState().auth as AuthState).loginMethod const loginMethod = (store.getState().auth as AuthState).loginMethod
const context = new LoginMethodContext(loginMethod)
// Handle decryption when the login method is via an extension. // Handle decryption
if (loginMethod === LoginMethods.extension) { return await context.nip44EDecrypt(sender, content)
const nostr = this.getNostrObject()
// Check if the nostr object supports NIP-44 decryption.
if (!nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Decrypt the content using NIP-44 provided by the nostr extension.
const decrypted = await nostr.nip44.decrypt(sender, content)
return decrypted as string
}
// Handle decryption when the login method is via a private key.
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
sender
)
// Decrypt the content using the generated conversation key.
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
return decrypted
}
// Throw an error if the login method is nsecBunker (not supported).
if (loginMethod === LoginMethods.nsecBunker) {
throw new Error(
`nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
)
}
// Throw an error if the login method is undefined or unsupported.
throw new Error('Login method is undefined')
} }
/** /**
* Signs an event with private key (if it is present in local storage) or * Signs an event with private key (if it is present in local storage) or
* with browser extension (if it is present) or * with browser extension (if it is present)
* with nSecBunker instance.
* @param event - unsigned nostr event. * @param event - unsigned nostr event.
* @returns - a promised that is resolved with signed nostr event. * @returns - a promised that is resolved with signed nostr event.
*/ */
@ -339,113 +71,16 @@ export class NostrController extends EventEmitter {
event: UnsignedEvent | EventTemplate event: UnsignedEvent | EventTemplate
): Promise<SignedEvent> => { ): Promise<SignedEvent> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod const loginMethod = (store.getState().auth as AuthState).loginMethod
const context = new LoginMethodContext(loginMethod)
if (!loginMethod) { return await context.signEvent(event)
return Promise.reject('No login method found in the browser storage')
}
if (loginMethod === LoginMethods.nsecBunker) {
// Check if nsecBunker is available
if (!this.bunkerNDK) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
if (!this.remoteSigner) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
const signedEvent = await this.signWithNsecBunker(
'',
event as NostrEvent
).catch((err) => {
throw err
})
return Promise.resolve(signedEvent as SignedEvent)
} else if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
return Promise.reject(
`Login method is ${loginMethod}, but keys are not found`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const signedEvent = finalizeEvent(event, privateKey)
verifySignedEvent(signedEvent)
return Promise.resolve(signedEvent)
} else if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
return (await nostr
.signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err
})) as Event
} else {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
)
}
} }
nip04Encrypt = async (receiver: string, content: string): Promise<string> => { nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod const loginMethod = (store.getState().auth as AuthState).loginMethod
const context = new LoginMethodContext(loginMethod)
if (loginMethod === LoginMethods.extension) { return await context.nip04Encrypt(receiver, content)
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const encrypted = await nostr.nip04.encrypt(receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const encrypted = await nip04.encrypt(privateKey, receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: receiver })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const encrypted = await this.remoteSigner.encrypt(user, content)
return encrypted
}
throw new Error('Login method is undefined')
} }
/** /**
@ -457,50 +92,9 @@ export class NostrController extends EventEmitter {
*/ */
nip04Decrypt = async (sender: string, content: string): Promise<string> => { nip04Decrypt = async (sender: string, content: string): Promise<string> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod const loginMethod = (store.getState().auth as AuthState).loginMethod
const context = new LoginMethodContext(loginMethod)
if (loginMethod === LoginMethods.extension) { return await context.nip04EDecrypt(sender, content)
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const decrypted = await nostr.nip04.decrypt(sender, content)
return decrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const decrypted = await nip04.decrypt(privateKey, sender, content)
return decrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: sender })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const decrypted = await this.remoteSigner.decrypt(user, content)
return decrypted
}
throw new Error('Login method is undefined')
} }
/** /**
@ -523,12 +117,4 @@ export class NostrController extends EventEmitter {
return Promise.resolve(pubKey) return Promise.resolve(pubKey)
} }
/**
* Generates NDK Private Signer
* @returns nSecBunker delegated key
*/
generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey!
}
} }

27
src/hooks/useLogout.tsx Normal file
View File

@ -0,0 +1,27 @@
import { logout as nostrLogout } from 'nostr-login'
import { clear } from '../utils/localStorage'
import { useDispatch } from 'react-redux'
import { Dispatch } from '../store/store'
import { userLogOutAction } from '../store/actions'
import { LoginMethod } from '../store/auth/types'
import { useAppSelector } from './store'
export const useLogout = () => {
const loginMethod = useAppSelector((state) => state.auth?.loginMethod)
const dispatch: Dispatch = useDispatch()
const logout = () => {
// Log out of the nostr-login
if (loginMethod === LoginMethod.nostrLogin) {
nostrLogout()
}
// Reset redux state with the logout
dispatch(userLogOutAction())
// Clear the local storage states
clear()
}
return logout
}

View File

@ -1,34 +1,42 @@
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
import { LoadingSpinner } from '../components/LoadingSpinner' import { LoadingSpinner } from '../components/LoadingSpinner'
import { MetadataController, NostrController } from '../controllers' import {
AuthController,
MetadataController,
NostrController
} from '../controllers'
import { import {
restoreState, restoreState,
setAuthState,
setMetadataEvent, setMetadataEvent,
updateLoginMethod,
updateNostrLoginAuthMethod,
updateUserAppData updateUserAppData
} from '../store/actions' } from '../store/actions'
import { LoginMethods } from '../store/auth/types'
import { State } from '../store/rootReducer' import { State } from '../store/rootReducer'
import { Dispatch } from '../store/store' import { Dispatch } from '../store/store'
import { setUserRobotImage } from '../store/userRobotImage/action' import { setUserRobotImage } from '../store/userRobotImage/action'
import { import {
clearAuthToken,
clearState,
getRoboHashPicture, getRoboHashPicture,
getUsersAppData, getUsersAppData,
loadState, loadState,
saveNsecBunkerDelegatedKey,
subscribeForSigits subscribeForSigits
} from '../utils' } from '../utils'
import { useAppSelector } from '../hooks' import { useAppSelector } from '../hooks'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useLogout } from '../hooks/useLogout'
import { LoginMethod } from '../store/auth/types'
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
import { init as initNostrLogin } from 'nostr-login'
export const MainLayout = () => { export const MainLayout = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const logout = useLogout()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
@ -37,51 +45,63 @@ export const MainLayout = () => {
// Ref to track if `subscribeForSigits` has been called // Ref to track if `subscribeForSigits` has been called
const hasSubscribed = useRef(false) const hasSubscribed = useRef(false)
useEffect(() => { const navigateAfterLogin = (path: string) => {
const metadataController = MetadataController.getInstance() const callbackPath = searchParams.get('callbackPath')
const logout = () => { if (callbackPath) {
dispatch( // base64 decoded path
setAuthState({ const path = atob(callbackPath)
keyPair: undefined, navigate(path)
loggedIn: false, return
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
// clear authToken saved in local storage
clearAuthToken()
clearState()
// update nsecBunker delegated key
const newDelegatedKey =
NostrController.getInstance().generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
} }
navigate(path)
}
const login = useCallback(async () => {
const nostrController = NostrController.getInstance()
const authController = new AuthController()
const pubkey = await nostrController.capturePublicKey()
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) {
navigateAfterLogin(redirectPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
useEffect(() => {
const handleNostrAuth = (_: string, opts: NostrLoginAuthOptions) => {
console.log(opts.method)
if (opts.type === 'logout') {
logout()
} else {
dispatch(updateNostrLoginAuthMethod(opts.method))
login()
}
}
initNostrLogin({
darkMode: false,
noBanner: true,
onAuth: handleNostrAuth
})
const metadataController = MetadataController.getInstance()
const restoredState = loadState() const restoredState = loadState()
if (restoredState) { if (restoredState) {
dispatch(restoreState(restoredState)) dispatch(restoreState(restoredState))
const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } = const { loggedIn, loginMethod, usersPubkey } = restoredState.auth
restoredState.auth
if (loggedIn) { if (loggedIn) {
if (!loginMethod || !usersPubkey) return logout() if (!loginMethod || !usersPubkey) return logout()
if (loginMethod === LoginMethods.nsecBunker) {
if (!nsecBunkerRelays) return logout()
const nostrController = NostrController.getInstance()
nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
nostrController.createNsecBunkerSigner(usersPubkey)
})
}
const handleMetadataEvent = (event: Event) => { const handleMetadataEvent = (event: Event) => {
dispatch(setMetadataEvent(event)) dispatch(setMetadataEvent(event))
} }
@ -101,7 +121,8 @@ export const MainLayout = () => {
} else { } else {
setIsLoading(false) setIsLoading(false)
} }
}, [dispatch]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => { useEffect(() => {
if (authState.loggedIn && usersAppData) { if (authState.loggedIn && usersAppData) {

View File

@ -74,8 +74,6 @@ export const CreatePage = () => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([])
@ -183,10 +181,6 @@ export const CreatePage = () => {
} }
}) })
}, [metadata, users]) }, [metadata, users])
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
useEffect(() => { useEffect(() => {
if (uploadedFiles) { if (uploadedFiles) {
@ -761,17 +755,6 @@ export const CreatePage = () => {
} }
} }
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}

View File

@ -7,29 +7,14 @@ import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { import { AuthController } from '../../controllers'
AuthController, import { updateKeyPair, updateLoginMethod } from '../../store/actions'
MetadataController, import { LoginMethod } from '../../store/auth/types'
NostrController
} from '../../controllers'
import {
updateKeyPair,
updateLoginMethod,
updateNsecbunkerPubkey,
updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store' import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05, timeout } from '../../utils'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { NIP05_REGEX } from '../../constants'
import styles from './styles.module.scss' import styles from './styles.module.scss'
import { TimeoutError } from '../../types/errors/TimeoutError'
const EXTENSION_LOGIN_DELAY_SECONDS = 5
const EXTENSION_LOGIN_TIMEOUT_SECONDS = EXTENSION_LOGIN_DELAY_SECONDS + 55
export const Nostr = () => { export const Nostr = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@ -37,14 +22,23 @@ export const Nostr = () => {
const navigate = useNavigate() const navigate = useNavigate()
const authController = new AuthController() const authController = new AuthController()
const metadataController = MetadataController.getInstance()
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [isExtensionSlow, setIsExtensionSlow] = useState(false)
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] = const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
useState(false) useState(false)
@ -65,59 +59,6 @@ export const Nostr = () => {
} }
} }
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const loginWithExtension = async () => {
let waitTimeout: number | undefined
try {
// Wait EXTENSION_LOGIN_DELAY_SECONDS before showing extension delay message
waitTimeout = window.setTimeout(() => {
setIsExtensionSlow(true)
}, EXTENSION_LOGIN_DELAY_SECONDS * 1000)
setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
const pubkey = await nostrController.capturePublicKey()
dispatch(updateLoginMethod(LoginMethods.extension))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await Promise.race([
authController.authAndGetMetadataAndRelaysMap(pubkey),
timeout(EXTENSION_LOGIN_TIMEOUT_SECONDS * 1000)
])
if (redirectPath) {
navigateAfterLogin(redirectPath)
}
} catch (error) {
if (error instanceof TimeoutError) {
// Just log the error, no toast, user has already been notified with the loading screen
console.error("Extension didn't respond in time")
} else {
toast.error('Error capturing public key from nostr extension: ' + error)
}
} finally {
// Clear the wait timeout so we don't change the state unnecessarily
window.clearTimeout(waitTimeout)
setIsLoading(false)
setLoadingSpinnerDesc('')
setIsExtensionSlow(false)
}
}
/** /**
* Login with NSEC or HEX private key * Login with NSEC or HEX private key
* @param privateKey in HEX format * @param privateKey in HEX format
@ -153,7 +94,7 @@ export const Nostr = () => {
public: publickey public: publickey
}) })
) )
dispatch(updateLoginMethod(LoginMethods.privateKey)) dispatch(updateLoginMethod(LoginMethod.privateKey))
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
@ -171,182 +112,10 @@ export const Nostr = () => {
setLoadingSpinnerDesc('') setLoadingSpinnerDesc('')
} }
const loginWithNsecBunker = async () => {
let relays: string[] | undefined
let pubkey: string | undefined
setIsLoading(true)
const displayError = (message: string) => {
toast.error(message)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
if (inputValue.match(NIP05_REGEX)) {
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
pubkey = nip05Profile.pubkey
relays = nip05Profile.relays
}
} else if (inputValue.startsWith('npub')) {
pubkey = nip19.decode(inputValue).data as string
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch(() => {
return null
})
if (!metadataEvent) {
return displayError('metadata not found!')
}
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (!metadataContent?.nip05) {
return displayError('nip05 not present in metadata')
}
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
if (nip05Profile.pubkey !== pubkey) {
return displayError(
'pubkey in nip05 does not match with provided npub'
)
}
relays = nip05Profile.relays
}
}
if (!relays || relays.length === 0) {
return displayError('No relay found for nsecbunker')
}
if (!pubkey) {
return displayError('pubkey not found')
}
setLoadingSpinnerDesc('Initializing nsecBunker')
await nostrController.nsecBunkerInit(relays)
setLoadingSpinnerDesc('Creating nsecbunker singer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const loginWithBunkerConnectionString = async () => {
// Extract the key
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
const keyEndIndex = inputValue.indexOf('?relay=')
const key = inputValue.substring(keyStartIndex, keyEndIndex)
const pubkey = npubToHex(key)
if (!pubkey) {
toast.error('Invalid pubkey in bunker connection string.')
setIsLoading(false)
return
}
// Extract the relay value
const relayIndex = inputValue.indexOf('relay=')
const relay = inputValue.substring(
relayIndex + 'relay='.length,
inputValue.length
)
setIsLoading(true)
setLoadingSpinnerDesc('Initializing bunker NDK')
await nostrController.nsecBunkerInit([relay])
setLoadingSpinnerDesc('Creating remote signer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const login = () => { const login = () => {
if (inputValue.startsWith('bunker://')) {
return loginWithBunkerConnectionString()
}
if (inputValue.startsWith('nsec')) { if (inputValue.startsWith('nsec')) {
return loginWithNsec() return loginWithNsec()
} }
if (inputValue.startsWith('npub')) {
return loginWithNsecBunker()
}
if (inputValue.match(NIP05_REGEX)) {
return loginWithNsecBunker()
}
// Check if maybe hex nsec // Check if maybe hex nsec
try { try {
@ -358,74 +127,15 @@ export const Nostr = () => {
console.warn('err', err) console.warn('err', err)
} }
toast.error( toast.error('Invalid format, please use: private key (hex or nsec)')
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
)
return return
} }
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return ( return (
<> <>
{isLoading && ( {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<LoadingSpinner desc={loadingSpinnerDesc}>
{isExtensionSlow && (
<>
<p>
Your nostr extension is not responding. Check these
alternatives:{' '}
<a href="https://github.com/aljazceru/awesome-nostr?tab=readme-ov-file#nip-07-browser-extensions">
https://github.com/aljazceru/awesome-nostr
</a>
</p>
<br />
<Button
fullWidth
variant="contained"
onClick={() => {
setLoadingSpinnerDesc('')
setIsLoading(false)
setIsExtensionSlow(false)
}}
>
Close
</Button>
</>
)}
</LoadingSpinner>
)}
{isNostrExtensionAvailable && ( {isNostrExtensionAvailable && (
<> <>
<label className={styles.label} htmlFor="extension-login">
Login by using a browser extension
</label>
<Button
id="extension-login"
onClick={loginWithExtension}
variant="contained"
>
Extension Login
</Button>
<Divider
sx={{
fontSize: '16px'
}}
>
or
</Divider>
</>
)}
<label className={styles.label} htmlFor="extension-login"> <label className={styles.label} htmlFor="extension-login">
Login by using a{' '} Login by using a{' '}
<a <a
@ -452,16 +162,20 @@ export const Nostr = () => {
> >
or or
</Divider> </Divider>
</>
)}
<form autoComplete="off">
<TextField <TextField
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
label="nip05 login / nip46 bunker string" label="Private key (Not recommended)"
helperText="Private key (Not recommended)" type="password"
autoComplete="off"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
fullWidth fullWidth
margin="dense" margin="dense"
/> />
</form>
<Button <Button
disabled={!inputValue} disabled={!inputValue}
onClick={login} onClick={login}

View File

@ -2,25 +2,23 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
import CachedIcon from '@mui/icons-material/Cached' import CachedIcon from '@mui/icons-material/Cached'
import RouterIcon from '@mui/icons-material/Router' import RouterIcon from '@mui/icons-material/Router'
import { useTheme } from '@mui/material' import { ListItem, useTheme } from '@mui/material'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon' import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import ListSubheader from '@mui/material/ListSubheader' import ListSubheader from '@mui/material/ListSubheader'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom' import { Link } from 'react-router-dom'
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes' import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { Footer } from '../../components/Footer/Footer' import { Footer } from '../../components/Footer/Footer'
import ExtensionIcon from '@mui/icons-material/Extension'
import { LoginMethod } from '../../store/auth/types'
export const SettingsPage = () => { export const SettingsPage = () => {
const theme = useTheme() const theme = useTheme()
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth)
const navigate = useNavigate()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const listItem = (label: string, disabled = false) => { const listItem = (label: string, disabled = false) => {
return ( return (
<> <>
@ -57,43 +55,40 @@ export const SettingsPage = () => {
fontSize: '1.5rem', fontSize: '1.5rem',
borderBottom: '0.5px solid', borderBottom: '0.5px solid',
paddingBottom: 2, paddingBottom: 2,
paddingTop: 2 paddingTop: 2,
zIndex: 2
}} }}
> >
Settings Settings
</ListSubheader> </ListSubheader>
} }
> >
<ListItemButton <ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}>
onClick={() => {
navigate(getProfileSettingsRoute(usersPubkey!))
}}
>
<ListItemIcon> <ListItemIcon>
<AccountCircleIcon /> <AccountCircleIcon />
</ListItemIcon> </ListItemIcon>
{listItem('Profile')} {listItem('Profile')}
</ListItemButton> </ListItem>
<ListItemButton <ListItem component={Link} to={appPrivateRoutes.relays}>
onClick={() => {
navigate(appPrivateRoutes.relays)
}}
>
<ListItemIcon> <ListItemIcon>
<RouterIcon /> <RouterIcon />
</ListItemIcon> </ListItemIcon>
{listItem('Relays')} {listItem('Relays')}
</ListItemButton> </ListItem>
<ListItemButton <ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
onClick={() => {
navigate(appPrivateRoutes.cacheSettings)
}}
>
<ListItemIcon> <ListItemIcon>
<CachedIcon /> <CachedIcon />
</ListItemIcon> </ListItemIcon>
{listItem('Local Cache')} {listItem('Local Cache')}
</ListItemButton> </ListItem>
{loginMethod === LoginMethod.nostrLogin && (
<ListItem component={Link} to={appPrivateRoutes.nostrLogin}>
<ListItemIcon>
<ExtensionIcon />
</ListItemIcon>
{listItem('Nostr Login')}
</ListItem>
)}
</List> </List>
</Container> </Container>
<Footer /> <Footer />

View File

@ -66,7 +66,8 @@ export const CacheSettingsPage = () => {
fontSize: '1.5rem', fontSize: '1.5rem',
borderBottom: '0.5px solid', borderBottom: '0.5px solid',
paddingBottom: 2, paddingBottom: 2,
paddingTop: 2 paddingTop: 2,
zIndex: 2
}} }}
> >
Cache Setting Cache Setting

View File

@ -0,0 +1,71 @@
import {
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
useTheme
} from '@mui/material'
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { Container } from '../../../components/Container'
import PeopleIcon from '@mui/icons-material/People'
import ImportExportIcon from '@mui/icons-material/ImportExport'
export const NostrLoginPage = () => {
const theme = useTheme()
return (
<Container>
<List
sx={{
width: '100%',
bgcolor: 'background.paper'
}}
subheader={
<ListSubheader
sx={{
fontSize: '1.5rem',
borderBottom: '0.5px solid',
paddingBottom: 2,
paddingTop: 2,
zIndex: 2
}}
>
Nostr Settings
</ListSubheader>
}
>
<ListItemButton
onClick={() => {
launchNostrLoginDialog('switch-account')
}}
>
<ListItemIcon>
<PeopleIcon />
</ListItemIcon>
<ListItemText
primary={'Nostr Login Accounts'}
sx={{
color: theme.palette.text.primary
}}
/>
</ListItemButton>
<ListItemButton
onClick={() => {
launchNostrLoginDialog('import')
}}
>
<ListItemIcon>
<ImportExportIcon />
</ListItemIcon>
<ListItemText
primary={'Import / Export Keys'}
sx={{
color: theme.palette.text.primary
}}
/>
</ListItemButton>
</List>
</Container>
)
}

View File

@ -24,7 +24,7 @@ import { LoadingButton } from '@mui/lab'
import { Dispatch } from '../../../store/store' import { Dispatch } from '../../../store/store'
import { setMetadataEvent } from '../../../store/actions' import { setMetadataEvent } from '../../../store/actions'
import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethods } from '../../../store/auth/types' import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { import {
getNostrJoiningBlockNumber, getNostrJoiningBlockNumber,
@ -33,6 +33,8 @@ import {
} from '../../../utils' } from '../../../utils'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
import { Footer } from '../../../components/Footer/Footer' import { Footer } from '../../../components/Footer/Footer'
import LaunchIcon from '@mui/icons-material/Launch'
import { launch as launchNostrLoginDialog } from 'nostr-login'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
const theme = useTheme() const theme = useTheme()
@ -51,7 +53,9 @@ export const ProfileSettingsPage = () => {
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const metadataState = useSelector((state: State) => state.metadata) const metadataState = useSelector((state: State) => state.metadata)
const keys = useSelector((state: State) => state.auth?.keyPair) const keys = useSelector((state: State) => state.auth?.keyPair)
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth) const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useSelector(
(state: State) => state.auth
)
const userRobotImage = useSelector((state: State) => state.userRobotImage) const userRobotImage = useSelector((state: State) => state.userRobotImage)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
@ -287,7 +291,8 @@ export const ProfileSettingsPage = () => {
sx={{ sx={{
paddingBottom: 1, paddingBottom: 1,
paddingTop: 1, paddingTop: 1,
fontSize: '1.5rem' fontSize: '1.5rem',
zIndex: 2
}} }}
className={styles.subHeader} className={styles.subHeader}
> >
@ -363,7 +368,7 @@ export const ProfileSettingsPage = () => {
<> <>
{usersPubkey && {usersPubkey &&
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
{loginMethod === LoginMethods.privateKey && {loginMethod === LoginMethod.privateKey &&
keys && keys &&
keys.private && keys.private &&
copyItem( copyItem(
@ -373,6 +378,33 @@ export const ProfileSettingsPage = () => {
)} )}
</> </>
)} )}
{isUsersOwnProfile && (
<>
{loginMethod === LoginMethod.nostrLogin &&
nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
<ListItem
sx={{ marginTop: 1 }}
onClick={() => {
launchNostrLoginDialog('import')
}}
>
<TextField
label="Private Key (nostr-login)"
defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••"
size="small"
className={styles.textField}
disabled
type={'password'}
InputProps={{
endAdornment: (
<LaunchIcon className={styles.copyItem} />
)
}}
/>
</ListItem>
)}
</>
)}
</div> </div>
)} )}
</List> </List>

View File

@ -131,7 +131,6 @@ export const SignPage = () => {
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>()
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>( const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[] []
@ -295,11 +294,6 @@ export const SignPage = () => {
const { keys, sender } = parsedKeysJson const { keys, sender } = parsedKeysJson
for (const key of keys) { for (const key of keys) {
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
// decrypt the encryptionKey, with timeout (duration = 60 seconds) // decrypt the encryptionKey, with timeout (duration = 60 seconds)
const encryptionKey = await Promise.race([ const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key), nostrController.nip04Decrypt(sender, key),
@ -312,9 +306,6 @@ export const SignPage = () => {
console.log('err :>> ', err) console.log('err :>> ', err)
return null return null
}) })
.finally(() => {
setAuthUrl(undefined) // Clear authentication URL
})
// Return if encryption failed // Return if encryption failed
if (!encryptionKey) continue if (!encryptionKey) continue
@ -888,17 +879,6 @@ export const SignPage = () => {
} }
} }
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
if (isLoading) { if (isLoading) {
return <LoadingSpinner desc={loadingSpinnerDesc} /> return <LoadingSpinner desc={loadingSpinnerDesc} />
} }

View File

@ -14,6 +14,7 @@
border-bottom: 0.5px solid; border-bottom: 0.5px solid;
padding: 8px 16px; padding: 8px 16px;
font-size: 1.5rem; font-size: 1.5rem;
z-index: 2;
} }
.filesWrapper { .filesWrapper {

View File

@ -8,6 +8,7 @@ import { ProfilePage } from '../pages/profile'
import { Register } from '../pages/register' import { Register } from '../pages/register'
import { SettingsPage } from '../pages/settings/Settings' import { SettingsPage } from '../pages/settings/Settings'
import { CacheSettingsPage } from '../pages/settings/cache' import { CacheSettingsPage } from '../pages/settings/cache'
import { NostrLoginPage } from '../pages/settings/nostrLogin'
import { ProfileSettingsPage } from '../pages/settings/profile' import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays' import { RelaysPage } from '../pages/settings/relays'
import { SignPage } from '../pages/sign' import { SignPage } from '../pages/sign'
@ -22,7 +23,8 @@ export const appPrivateRoutes = {
settings: '/settings', settings: '/settings',
profileSettings: '/settings/profile/:npub', profileSettings: '/settings/profile/:npub',
cacheSettings: '/settings/cache', cacheSettings: '/settings/cache',
relays: '/settings/relays' relays: '/settings/relays',
nostrLogin: '/settings/nostrLogin'
} }
export const appPublicRoutes = { export const appPublicRoutes = {
@ -147,5 +149,9 @@ export const privateRoutes = [
{ {
path: appPrivateRoutes.relays, path: appPrivateRoutes.relays,
element: <RelaysPage /> element: <RelaysPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />
} }
] ]

View File

@ -0,0 +1,75 @@
import { Event, UnsignedEvent, EventTemplate, NostrEvent } from 'nostr-tools'
import { SignedEvent } from '../../types'
import { LoginMethodStrategy } from './loginMethodStrategy'
import { WindowNostr } from 'nostr-tools/nip07'
export class NostrLoginStrategy extends LoginMethodStrategy {
private nostr: WindowNostr
constructor() {
super()
if (!window.nostr) {
throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
)
}
this.nostr = window.nostr as WindowNostr
}
async nip04Encrypt(receiver: string, content: string): Promise<string> {
if (!this.nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const encrypted = await this.nostr.nip04.encrypt(receiver, content)
return encrypted
}
async nip04EDecrypt(sender: string, content: string): Promise<string> {
if (!this.nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const decrypted = await this.nostr.nip04.decrypt(sender, content)
return decrypted
}
async nip44Encrypt(receiver: string, content: string): Promise<string> {
if (!this.nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Encrypt the content using NIP-44 provided by the nostr extension.
const encrypted = await this.nostr.nip44.encrypt(receiver, content)
return encrypted as string
}
async nip44EDecrypt(sender: string, content: string): Promise<string> {
if (!this.nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Decrypt the content using NIP-44 provided by the nostr extension.
const decrypted = await this.nostr.nip44.decrypt(sender, content)
return decrypted as string
}
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return (await this.nostr
.signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err
})) as Event
}
}

View File

@ -0,0 +1,118 @@
import {
UnsignedEvent,
EventTemplate,
nip19,
nip44,
finalizeEvent,
nip04
} from 'nostr-tools'
import { SignedEvent } from '../../types'
import store from '../../store/store'
import { AuthState, LoginMethod } from '../../store/auth/types'
import { LoginMethodStrategy } from './loginMethodStrategy'
import { verifySignedEvent } from '../../utils/nostr'
export class PrivateKeyStrategy extends LoginMethodStrategy {
async nip04Encrypt(receiver: string, content: string): Promise<string> {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const encrypted = await nip04.encrypt(privateKey, receiver, content)
return encrypted
}
async nip04EDecrypt(sender: string, content: string): Promise<string> {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const decrypted = await nip04.decrypt(privateKey, sender, content)
return decrypted
}
async nip44Encrypt(receiver: string, content: string): Promise<string> {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
receiver
)
// Encrypt the content using the generated conversation key.
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
return encrypted
}
async nip44EDecrypt(sender: string, content: string): Promise<string> {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
sender
)
// Decrypt the content using the generated conversation key.
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
return decrypted
}
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
return Promise.reject(
`Login method is ${LoginMethod.privateKey}, but keys are not found`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const signedEvent = finalizeEvent(event, privateKey)
verifySignedEvent(signedEvent)
return Promise.resolve(signedEvent)
}
}

View File

@ -0,0 +1,43 @@
import { UnsignedEvent, EventTemplate } from 'nostr-tools'
import { SignedEvent } from '../../types'
import {
LoginMethodStrategy,
LoginMethodOperations
} from './loginMethodStrategy'
import { LoginMethod } from '../../store/auth/types'
import { NostrLoginStrategy } from './NostrLoginStrategy'
import { PrivateKeyStrategy } from './PrivateKeyStrategy'
export class LoginMethodContext implements LoginMethodOperations {
private strategy: LoginMethodStrategy
constructor(loginMethod?: LoginMethod) {
switch (loginMethod) {
case LoginMethod.nostrLogin:
this.strategy = new NostrLoginStrategy()
break
case LoginMethod.privateKey:
this.strategy = new PrivateKeyStrategy()
break
default:
this.strategy = new LoginMethodStrategy()
break
}
}
nip04Encrypt(receiver: string, content: string): Promise<string> {
return this.strategy.nip04Encrypt(receiver, content)
}
nip04EDecrypt(sender: string, content: string): Promise<string> {
return this.strategy.nip04EDecrypt(sender, content)
}
nip44Encrypt(receiver: string, content: string): Promise<string> {
return this.strategy.nip44Encrypt(receiver, content)
}
nip44EDecrypt(sender: string, content: string): Promise<string> {
return this.strategy.nip44EDecrypt(sender, content)
}
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return this.strategy.signEvent(event)
}
}

View File

@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
import { SignedEvent } from '../../types/nostr'
export interface LoginMethodOperations {
nip04Encrypt(receiver: string, content: string): Promise<string>
nip04EDecrypt(sender: string, content: string): Promise<string>
nip44Encrypt(receiver: string, content: string): Promise<string>
nip44EDecrypt(sender: string, content: string): Promise<string>
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent>
}
export class LoginMethodStrategy implements LoginMethodOperations {
async nip04Encrypt(_receiver: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip04EDecrypt(_sender: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip44Encrypt(_receiver: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip44EDecrypt(_sender: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async signEvent(_event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
)
}
}

View File

@ -6,8 +6,6 @@ export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD' export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD' export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR' export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'

View File

@ -2,12 +2,10 @@ import * as ActionTypes from '../actionTypes'
import { import {
AuthState, AuthState,
Keys, Keys,
LoginMethods, LoginMethod,
SetAuthState, SetAuthState,
UpdateKeyPair, UpdateKeyPair,
UpdateLoginMethod, UpdateLoginMethod,
UpdateNsecBunkerPubkey,
UpdateNsecBunkerRelays,
NostrLoginAuthMethod, NostrLoginAuthMethod,
UpdateNostrLoginAuthMethod UpdateNostrLoginAuthMethod
} from './types' } from './types'
@ -18,7 +16,7 @@ export const setAuthState = (payload: AuthState): SetAuthState => ({
}) })
export const updateLoginMethod = ( export const updateLoginMethod = (
payload: LoginMethods | undefined payload: LoginMethod | undefined
): UpdateLoginMethod => ({ ): UpdateLoginMethod => ({
type: ActionTypes.UPDATE_LOGIN_METHOD, type: ActionTypes.UPDATE_LOGIN_METHOD,
payload payload
@ -35,17 +33,3 @@ export const updateKeyPair = (payload: Keys | undefined): UpdateKeyPair => ({
type: ActionTypes.UPDATE_KEYPAIR, type: ActionTypes.UPDATE_KEYPAIR,
payload payload
}) })
export const updateNsecbunkerPubkey = (
payload: string | undefined
): UpdateNsecBunkerPubkey => ({
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
payload
})
export const updateNsecbunkerRelays = (
payload: string[] | undefined
): UpdateNsecBunkerRelays => ({
type: ActionTypes.UPDATE_NSECBUNKER_RELAYS,
payload
})

View File

@ -11,13 +11,12 @@ const reducer = (
): AuthState | null => { ): AuthState | null => {
switch (action.type) { switch (action.type) {
case ActionTypes.SET_AUTH_STATE: { case ActionTypes.SET_AUTH_STATE: {
const { loginMethod, keyPair, nsecBunkerPubkey, nsecBunkerRelays } = state const { loginMethod, nostrLoginAuthMethod, keyPair } = state
return { return {
loginMethod, loginMethod,
nostrLoginAuthMethod,
keyPair, keyPair,
nsecBunkerPubkey,
nsecBunkerRelays,
...action.payload ...action.payload
} }
} }
@ -48,24 +47,6 @@ const reducer = (
} }
} }
case ActionTypes.UPDATE_NSECBUNKER_PUBKEY: {
const { payload } = action
return {
...state,
nsecBunkerPubkey: payload
}
}
case ActionTypes.UPDATE_NSECBUNKER_RELAYS: {
const { payload } = action
return {
...state,
nsecBunkerRelays: payload
}
}
case ActionTypes.RESTORE_STATE: case ActionTypes.RESTORE_STATE:
return action.payload.auth return action.payload.auth

View File

@ -9,10 +9,9 @@ export enum NostrLoginAuthMethod {
OTP = 'otp' OTP = 'otp'
} }
export enum LoginMethods { export enum LoginMethod {
extension = 'extension', nostrLogin = 'nostrLogin',
privateKey = 'privateKey', privateKey = 'privateKey',
nsecBunker = 'nsecBunker',
register = 'register' register = 'register'
} }
@ -24,11 +23,9 @@ export interface Keys {
export interface AuthState { export interface AuthState {
loggedIn: boolean loggedIn: boolean
usersPubkey?: string usersPubkey?: string
loginMethod?: LoginMethods loginMethod?: LoginMethod
nostrLoginAuthMethod?: NostrLoginAuthMethod nostrLoginAuthMethod?: NostrLoginAuthMethod
keyPair?: Keys keyPair?: Keys
nsecBunkerPubkey?: string
nsecBunkerRelays?: string[]
} }
export interface SetAuthState { export interface SetAuthState {
@ -38,7 +35,7 @@ export interface SetAuthState {
export interface UpdateLoginMethod { export interface UpdateLoginMethod {
type: typeof ActionTypes.UPDATE_LOGIN_METHOD type: typeof ActionTypes.UPDATE_LOGIN_METHOD
payload: LoginMethods | undefined payload: LoginMethod | undefined
} }
export interface UpdateNostrLoginAuthMethod { export interface UpdateNostrLoginAuthMethod {
@ -51,22 +48,10 @@ export interface UpdateKeyPair {
payload: Keys | undefined payload: Keys | undefined
} }
export interface UpdateNsecBunkerPubkey {
type: typeof ActionTypes.UPDATE_NSECBUNKER_PUBKEY
payload: string | undefined
}
export interface UpdateNsecBunkerRelays {
type: typeof ActionTypes.UPDATE_NSECBUNKER_RELAYS
payload: string[] | undefined
}
export type AuthDispatchTypes = export type AuthDispatchTypes =
| RestoreState | RestoreState
| SetAuthState | SetAuthState
| UpdateLoginMethod | UpdateLoginMethod
| UpdateNostrLoginAuthMethod | UpdateNostrLoginAuthMethod
| UpdateKeyPair | UpdateKeyPair
| UpdateNsecBunkerPubkey
| UpdateNsecBunkerRelays
| UserLogout | UserLogout

View File

@ -26,14 +26,6 @@ export const clearState = () => {
localStorage.removeItem('state') localStorage.removeItem('state')
} }
export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
localStorage.setItem('nsecbunker-delegated-key', privateKey)
}
export const getNsecBunkerDelegatedKey = () => {
return localStorage.getItem('nsecbunker-delegated-key')
}
export const saveVisitedLink = (pathname: string, search: string) => { export const saveVisitedLink = (pathname: string, search: string) => {
localStorage.setItem( localStorage.setItem(
'visitedLink', 'visitedLink',
@ -69,3 +61,8 @@ export const getAuthToken = () => {
export const clearAuthToken = () => { export const clearAuthToken = () => {
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
} }
export const clear = () => {
clearAuthToken()
clearState()
}