sigit.io/src/controllers/NostrController.ts

489 lines
14 KiB
TypeScript
Raw Normal View History

import NDK, {
NDKEvent,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import {
Event,
EventTemplate,
SimplePool,
UnsignedEvent,
Filter,
finalizeEvent,
nip04,
nip19,
kinds
} 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, RelayMap } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
export class NostrController extends EventEmitter {
private static instance: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private constructor() {
super()
}
private getNostrObject = () => {
if (window.nostr) return window.nostr
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<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 {
if (!NostrController.instance) {
NostrController.instance = new NostrController()
}
return NostrController.instance
}
2024-03-01 15:16:35 +05:00
/**
* Function will publish provided event to the provided relays
2024-03-01 15:16:35 +05:00
*/
publishEvent = async (event: Event, relays: string[]) => {
const simplePool = new SimplePool()
const promises = simplePool.publish(relays, event)
const results = await Promise.allSettled(promises)
const publishedRelays: string[] = []
console.log('results of publish event :>> ', results)
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
publishedRelays.push(relays[index])
}
})
2024-05-13 13:11:52 +02:00
if (publishedRelays.length === 0) {
const failedPublishes: any[] = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
2024-05-13 13:11:52 +02:00
results.forEach((res, index) => {
if (res.status === 'rejected') {
2024-05-15 11:50:21 +03:00
failedPublishes.push({
relay: relays[index],
error: res.reason
? res.reason.message || fallbackRejectionReason
: fallbackRejectionReason
2024-05-15 11:50:21 +03:00
})
2024-05-13 13:11:52 +02:00
}
})
throw failedPublishes
}
2024-03-01 15:16:35 +05:00
return publishedRelays
2024-03-01 15:16:35 +05:00
}
/**
* 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<SignedEvent> => {
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`
)
}
2024-03-05 14:08:18 +05:00
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
2024-03-05 14:08:18 +05:00
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: any) => {
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) => {
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')
}
/**
* 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<string> => {
const nostr = this.getNostrObject()
const pubKey = await nostr.getPublicKey().catch((err: any) => {
return Promise.reject(err.message)
})
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!
}
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated: number }> => {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const popularRelayURIs = [
this.specialMetadataRelay,
...hardcodedPopularRelays
]
const pool = new SimplePool()
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await pool.get(popularRelayURIs, eventFilter).catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.reject('User relays were not found.')
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
* @param npub - user's npub.
* @returns - promise that resolves into a string representing publishing result.
*/
publishRelayMap = async (
relayMap: RelayMap,
npub: string
): Promise<string> => {
const timestamp = Math.floor(Date.now() / 1000)
const relayURIs = Object.keys(relayMap)
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const tags: string[][] = relayURIs.map((relayURI) =>
[
'r',
relayURI,
relayMap[relayURI].read && relayMap[relayURI].write
? ''
: relayMap[relayURI].write
? 'write'
: 'read'
].filter((value) => value !== '')
)
const newRelayMapEvent: UnsignedEvent = {
kind: kinds.RelayList,
tags,
content: '',
pubkey: npub,
created_at: timestamp
}
const signedEvent = await this.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
relaysToPublish = [this.specialMetadataRelay, ...hardcodedPopularRelays]
}
await this.publishEvent(signedEvent, relaysToPublish)
return Promise.resolve(
`Relay Map published on: ${relaysToPublish.join('\n')}`
)
}
}