import NDK, { NDKEvent, 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 { updateNsecbunkerPubkey } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' import { SignedEvent } from '../types' import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' export class NostrController extends EventEmitter { private static instance: NostrController private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined private constructor() { super() } private getNostrObject = () => { // fix: this is not picking up type declaration from src/system/index.d.ts // eslint-disable-next-line @typescript-eslint/no-explicit-any if (window.nostr) return window.nostr as any throw new Error( `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 => { 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 { if (!NostrController.instance) { NostrController.instance = new NostrController() } return NostrController.instance } /** * Encrypts the given content for the specified receiver using NIP-44 encryption. * * @param receiver The public key of the receiver. * @param content The content to be encrypted. * @returns The encrypted content as a string. * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported. */ nip44Encrypt = async (receiver: string, content: string) => { // Retrieve the current login method from the application's redux state. const loginMethod = (store.getState().auth as AuthState).loginMethod // Handle encryption when the login method is via an extension. if (loginMethod === LoginMethods.extension) { 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') } /** * Decrypts the given content from the specified sender using NIP-44 decryption. * * @param sender The public key of the sender. * @param content The encrypted content to be decrypted. * @returns The decrypted content as a string. * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported. */ nip44Decrypt = async (sender: string, content: string) => { // Retrieve the current login method from the application's redux state. const loginMethod = (store.getState().auth as AuthState).loginMethod // Handle decryption when the login method is via an extension. if (loginMethod === LoginMethods.extension) { 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 * with browser extension (if it is present) or * with nSecBunker instance. * @param event - unsigned nostr event. * @returns - a promised that is resolved with signed nostr event. */ signEvent = async ( event: UnsignedEvent | EventTemplate ): Promise => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (!loginMethod) { 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 => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { 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') } /** * Decrypts a given content based on the current login method. * * @param sender - The sender's public key. * @param content - The encrypted content to decrypt. * @returns A promise that resolves to the decrypted content. */ nip04Decrypt = async (sender: string, content: string): Promise => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { 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') } /** * Function will capture the public key from the nostr extension or if no extension present * function wil capture the public key from the local storage */ capturePublicKey = async (): Promise => { const nostr = this.getNostrObject() const pubKey = await nostr.getPublicKey().catch((err: unknown) => { if (err instanceof Error) { return Promise.reject(err.message) } else { return Promise.reject(JSON.stringify(err)) } }) if (!pubKey) { return Promise.reject('Error getting public key, user canceled') } return Promise.resolve(pubKey) } /** * Generates NDK Private Signer * @returns nSecBunker delegated key */ generateDelegatedKey = (): string => { return NDKPrivateKeySigner.generate().privateKey! } }