sigit.io/src/utils/nostr.ts

618 lines
16 KiB
TypeScript
Raw Normal View History

import axios from 'axios'
2024-06-28 09:24:14 +00:00
import {
Event,
EventTemplate,
Filter,
SimplePool,
UnsignedEvent,
finalizeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
kinds,
nip19,
nip44,
verifyEvent
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { NIP05_REGEX } from '../constants'
2024-06-28 09:24:14 +00:00
import { MetadataController, NostrController } from '../controllers'
import { AuthState } from '../store/auth/types'
import { RelaysState } from '../store/relays/types'
import store from '../store/store'
import { Meta, Rumor, Sigit, SignedEvent } from '../types'
import { getHash } from './hash'
import { parseJson } from './string'
/**
* @param hexKey hex private or public key
* @returns whether or not is key valid
*/
const validateHex = (hexKey: string) => {
return hexKey.match(/^[a-f0-9]{64}$/)
}
/**
* NPUB provided - it will convert NPUB to HEX
* HEX provided - it will return HEX
*
* @param pubKey in NPUB, HEX format
* @returns HEX format
*/
export const npubToHex = (pubKey: string): string | null => {
// If key is NPUB
if (pubKey.startsWith('npub1')) {
try {
return nip19.decode(pubKey).data as string
} catch (error) {
return null
}
}
// valid hex key
if (validateHex(pubKey)) return pubKey
// Not a valid hex key
return null
}
/**
* If NSEC key is provided function will convert it to HEX
* If HEX key Is provided function will validate the HEX format and return
*
* @param nsec or private key in HEX format
*/
export const nsecToHex = (nsec: string): string | null => {
// If key is NSEC
if (nsec.startsWith('nsec')) {
try {
return nip19.decode(nsec).data as string
} catch (error) {
return null
}
}
// since it's not NSEC key we check if it's a valid hex key
if (validateHex(nsec)) return nsec
return null
}
export const hexToNpub = (hexPubkey: string): `npub1${string}` => {
if (hexPubkey.startsWith('npub1')) return hexPubkey as `npub1${string}`
2024-03-01 10:16:35 +00:00
return nip19.npubEncode(hexPubkey)
}
export const verifySignedEvent = (event: SignedEvent) => {
const isGood = verifyEvent(event)
if (!isGood) {
throw new Error(
'Signed event did not pass verification. Check sig, id and pubkey.'
)
}
}
/**
* Function to query NIP-05 data and return the public key and relays.
*
* @param {string} nip05 - The NIP-05 identifier in the format "name@domain".
* @returns {Promise<{ pubkey: string, relays: string[] }>} - The public key and an array of relay URLs.
* @throws Will throw an error if the NIP-05 identifier is invalid or if there is an issue with the network request.
*/
export const queryNip05 = async (
nip05: string
): Promise<{
pubkey: string
relays: string[]
}> => {
const match = nip05.match(NIP05_REGEX)
// Throw an error if the NIP-05 identifier is invalid
if (!match) throw new Error('Invalid nip05')
// Destructure the match result, assigning default value '_' to name if not provided
const [_, name = '_', domain] = match
// Construct the URL to query the NIP-05 data
const url = `https://${domain}/.well-known/nostr.json?name=${name}`
// Perform the network request to get the NIP-05 data
const res = await axios(url)
.then((res) => {
return res.data
})
.catch((err) => {
console.log('err :>> ', err)
throw err
})
// Extract the public key from the response data
const pubkey = res.names[name]
const relays: string[] = []
// If a public key is found
if (pubkey) {
// Function to add relays if they exist and are not empty
const addRelays = (relayList?: string[]) => {
if (relayList && relayList.length > 0) {
relays.push(...relayList)
}
}
// Check for user-specific relays in the NIP-46 section of the response data
if (res.nip46) {
addRelays(res.nip46[pubkey] as string[])
}
// Check for user-specific relays in the relays section of the response data if not found in NIP-46
if (relays.length === 0 && res.relays) {
addRelays(res.relays[pubkey] as string[])
}
// If no user-specific relays are found, check for root user relays
if (relays.length === 0) {
const root = res.names['_']
if (root) {
// Check for root user relays in both NIP-46 and relays sections
addRelays(res.nip46?.[root] as string[])
addRelays(res.relays?.[root] as string[])
}
}
}
// Return the public key and the array of relays
return {
pubkey,
relays
}
}
2024-03-19 10:27:18 +00:00
export const base64EncodeSignedEvent = (event: SignedEvent) => {
try {
const authEventSerialized = JSON.stringify(event)
const token = btoa(authEventSerialized)
return token
} catch (error) {
throw new Error('An error occurred in JSON.stringify of signedAuthEvent')
}
}
export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
const decodedToken = atob(authToken)
try {
const signedEvent = JSON.parse(decodedToken)
return signedEvent
} catch (error) {
throw new Error('An error occurred in JSON.parse of the auth token')
}
}
/**
* @param pubkey in hex or npub format
* @returns robohash.org url for the avatar
*/
2024-05-17 11:35:37 +00:00
export const getRoboHashPicture = (
pubkey?: string,
set: number = 1
): string => {
if (!pubkey) return ''
const npub = hexToNpub(pubkey)
return `https://robohash.org/${npub}.png?set=set${set}`
}
2024-06-28 09:24:14 +00:00
const TWO_DAYS = 2 * 24 * 60 * 60
export const now = () => Math.round(Date.now() / 1000)
export const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
/**
* Generate nip44 conversation key
* @param privateKey
* @param publicKey
* @returns
*/
export const nip44ConversationKey = (
privateKey: Uint8Array,
publicKey: string
) => nip44.v2.utils.getConversationKey(privateKey, publicKey)
export const nip44Encrypt = (
data: EventTemplate,
privateKey: Uint8Array,
publicKey: string
) =>
nip44.v2.encrypt(
JSON.stringify(data),
nip44ConversationKey(privateKey, publicKey)
)
export const nip44Decrypt = (data: Event, privateKey: Uint8Array) =>
JSON.parse(
nip44.v2.decrypt(
data.content,
nip44ConversationKey(privateKey, data.pubkey)
)
)
// Function to count leading zero bits
export const countLeadingZeroes = (hex: string) => {
let count = 0
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 4
} else {
count += Math.clz32(nibble) - 28
break
}
}
return count
}
/**
* Function to create a wrapped event with PoW
* @param event Original event to be wrapped
* @param receiver Public key of the receiver
* @param difficulty PoW difficulty level (default is 20)
* @returns
*/
//
export const createWrap = (
event: Event,
receiver: string,
difficulty: number = 10
) => {
// Generate a random secret key and its corresponding public key
const randomKey = generateSecretKey()
const pubkey = getPublicKey(randomKey)
// Encrypt the event content using nip44 encryption
const content = nip44Encrypt(event, randomKey, receiver)
// Initialize nonce and leadingZeroes for PoW calculation
let nonce = 0
let leadingZeroes = 0
// Loop until a valid PoW hash is found
// eslint-disable-next-line no-constant-condition
while (true) {
// Create an unsigned event with the necessary fields
const unsignedEvent: UnsignedEvent = {
kind: 1059, // Event kind
content, // Encrypted content
pubkey, // Public key of the creator
created_at: randomNow(), // Current timestamp
tags: [
// Tags including receiver and nonce
['p', receiver],
['nonce', nonce.toString(), difficulty.toString()]
]
}
// Calculate the SHA-256 hash of the unsigned event
const hash = getEventHash(unsignedEvent)
// Count the number of leading zero bits in the hash
leadingZeroes = countLeadingZeroes(hash)
// Check if the leading zero bits meet the required difficulty
if (leadingZeroes >= difficulty) {
// Finalize the event (sign it) and return the result
return finalizeEvent(unsignedEvent, randomKey)
}
// Increment the nonce for the next iteration
nonce++
}
}
export const getUsersAppData = async () => {
const relays: string[] = []
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const relayMap = store.getState().relays?.map
const nostrController = NostrController.getInstance()
// check if relaysMap in redux store is undefined
if (!relayMap) {
// todo: use metadata controller
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(usersPubkey)
.catch((err) => {
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
err
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.write.length === 0) return
relays.push(...relaySet.write)
} else {
// filter write relays from user's relayMap stored in redux store
const writeRelays = Object.keys(relayMap).filter(
(key) => relayMap[key].write
)
relays.push(...writeRelays)
}
// generate an identifier for user's nip78
const hash = await getHash('sigit' + usersPubkey)
if (!hash) return null
const filter: Filter = {
kinds: [kinds.Application],
'#d': [hash]
}
const encryptedContent = await nostrController
.getEvent(filter, relays)
.then((event) => {
if (event) return event.content
// if person is using sigit for first time its possible that event is null
// so we'll return empty stringified object
return '{}'
})
.catch((err) => {
console.log(`An error occurred in finding kind 30078 event`, err)
toast.error(
'An error occurred in finding kind 30078 event for data storage'
)
return null
})
if (!encryptedContent) return null
if (encryptedContent === '{}') {
return {}
}
const decrypted = await nostrController
.nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => {
console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data')
return null
})
if (!decrypted) return null
const parsedContent = await parseJson<{
[uuid: string]: Sigit
}>(decrypted).catch((err) => {
console.log(
'An error occurred in parsing the content of kind 30078 event',
err
)
toast.error('An error occurred in parsing the content of kind 30078 event')
return null
})
if (!parsedContent) return null
return parsedContent
}
export const updateUsersAppData = async (fileUrl: string, meta: Meta) => {
const appData = await getUsersAppData()
if (!appData) return null
if (meta.uuid in appData) {
// update meta only if income meta is more recent than already existing one
const existingMeta = appData[meta.uuid].meta
if (existingMeta.modifiedAt < meta.modifiedAt) {
appData[meta.uuid] = {
fileUrl,
meta
}
}
} else {
appData[meta.uuid] = {
fileUrl,
meta
}
}
appData[meta.uuid] = {
fileUrl,
meta
}
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const nostrController = NostrController.getInstance()
const encryptedContent = await nostrController
.nip04Encrypt(usersPubkey, JSON.stringify(appData))
.catch((err) => {
console.log(
'An error occurred in encryption of content for app data',
err
)
toast.error(
err.message || 'An error occurred in encryption of content for app data'
)
return null
})
if (!encryptedContent) return null
// generate the identifier for user's appData event
const hash = await getHash('sigit' + usersPubkey)
if (!hash) return null
const updatedEvent: UnsignedEvent = {
kind: kinds.Application,
pubkey: usersPubkey!,
created_at: now(),
tags: [['d', hash]],
content: encryptedContent
}
const signedEvent = await nostrController
.signEvent(updatedEvent)
.catch((err) => {
console.log('An error occurred in signing event', err)
toast.error(err.message || 'An error occurred in signing event')
return null
})
if (!signedEvent) return null
const relayMap = (store.getState().relays as RelaysState).map!
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
const publishResult = await nostrController
.publishEvent(signedEvent, writeRelays)
.catch((errResults) => {
console.log('err :>> ', errResults)
toast.error('An error occurred while publishing App data')
errResults.forEach((errResult: any) => {
toast.error(
`Publishing to ${errResult.relay} caused the following error: ${errResult.error}`
)
})
return null
})
if (!publishResult) return null
return signedEvent
}
export const subscribeForSigits = async (pubkey: string) => {
// Get relay list metadata
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(pubkey)
.catch((err) => {
console.log(
2024-06-28 09:25:41 +00:00
`An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`,
2024-06-28 09:24:14 +00:00
err
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.read.length === 0) return
const filter: Filter = {
kinds: [1059],
'#p': [pubkey]
}
const pool = new SimplePool()
pool.subscribeMany(relaySet.read, [filter], {
onevent: (event) => {
processReceivedEvent(event)
}
})
}
const processReceivedEvent = async (event: Event, difficulty: number = 10) => {
// validate PoW
// Count the number of leading zero bits in the hash
const leadingZeroes = countLeadingZeroes(event.id)
if (leadingZeroes < difficulty) return
const nostrController = NostrController.getInstance()
const stringifiedSealEvent = await nostrController.nip44Decrypt(
event.pubkey,
event.content
)
const sealEvent = await parseJson<Event>(stringifiedSealEvent)
const stringifiedRumor = await nostrController.nip44Decrypt(
sealEvent.pubkey,
sealEvent.content
)
const rumor = await parseJson<Rumor>(stringifiedRumor)
if (rumor.content === 'sigit-notification') {
const uTag = rumor.tags.find((tag) => tag[0] === 'u')
if (!uTag) return
const fileUrl = uTag[1]
const mTag = rumor.tags.find((tag) => tag[0] === 'm')
if (!mTag) return
const metaString = mTag[1]
const meta = await parseJson<Meta>(metaString)
updateUsersAppData(fileUrl, meta)
}
}
export const sendNotification = async (
receiver: string,
meta: Meta,
fileUrl: string
) => {
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const unsignedEvent: UnsignedEvent = {
kind: kinds.ShortTextNote,
pubkey: usersPubkey,
content: 'sigit-notification',
tags: [
['u', fileUrl],
['m', JSON.stringify(meta)]
],
created_at: now()
}
const rumor: Rumor = {
...unsignedEvent,
id: getEventHash(unsignedEvent)
}
const nostrController = NostrController.getInstance()
const seal = await nostrController.createSeal(rumor, receiver)
const wrappedEvent = createWrap(seal, receiver)
// Get relay list metadata
const metadataController = new MetadataController()
const relaySet = await metadataController
.findRelayListMetadata(receiver)
.catch((err) => {
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
err
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.read.length === 0) return
// Publish the notification event to the recipient's read relays
await nostrController
.publishEvent(wrappedEvent, relaySet.read)
.catch((errResults) => {
console.log(
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
errResults
)
return null
})
}