Files
minibits_wallet/src/services/nostrService.ts
T
2024-01-25 22:17:09 +01:00

453 lines
12 KiB
TypeScript

import { finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import {
nip19,
getEventHash,
getSignature,
SimplePool,
Filter as NostrFilter,
Event as NostrEvent,
validateEvent,
UnsignedEvent as NostrUnsignedEvent,
utils
} from 'nostr-tools/core'
import QuickCrypto from 'react-native-quick-crypto'
import {secp256k1} from '@noble/curves/secp256k1'
import {
MINIBITS_RELAY_URL,
} from '@env'
import {KeyChain, KeyPair} from './keyChain'
import {log} from './logService'
import AppError, { Err } from '../utils/AppError'
import { MinibitsClient } from './minibitsService'
import { rootStoreInstance } from '../models'
// refresh
export type NostrProfile = {
pubkey: string
npub: string
name?: string
about?: string
picture?: string
nip05?: string
}
export type Nip05VerificationRecord = {
names: {
[key: string]: string
},
relays: {
[key: string]: string[]
}
}
// TODO cleanup
const _defaultPublicRelays: string[] = ['wss://relay.damus.io', 'wss://nostr.mom']
const _minibitsRelays: string[] = [MINIBITS_RELAY_URL]
let _pool: any = undefined
const {relaysStore} = rootStoreInstance
const getRelayPool = function () {
if(!_pool) {
_pool = new SimplePool({eoseSubTimeout: 10000})
return _pool
}
return _pool
}
const getDefaultRelays = function () {
return _defaultPublicRelays
}
const getMinibitsRelays = function () {
return _minibitsRelays
}
/* const getRandom = function(list: string[]) {
return list[Math.floor((Math.random()*list.length))]
} */
const getOrCreateKeyPair = async function (): Promise<KeyPair> {
let keyPair: KeyPair | null = null
keyPair = await KeyChain.loadNostrKeyPair() as KeyPair
if (!keyPair) {
keyPair = KeyChain.generateNostrKeyPair() as KeyPair
await KeyChain.saveNostrKeyPair(keyPair)
log.trace('[getOrCreateKeyPair]', 'Created and saved new NOSTR keypair')
}
return keyPair
}
const getNpubkey = function (publicKey: string): string {
return nip19.npubEncode(publicKey)
}
const getHexkey = function (key: string): string {
try {
const decoded = nip19.decode(key)
if(decoded) {
return decoded.data as string
}
throw new Error('Invalid npub key.')
} catch (e: any) {
throw new AppError(Err.VALIDATION_ERROR, e.message)
}
}
const encryptNip04 = async function (
receiverPubkey: string,
content: string
): Promise<string> {
try {
const keys: KeyPair = await getOrCreateKeyPair()
const key = secp256k1.getSharedSecret(keys.privateKey, '02' + receiverPubkey)
const normalizedKey = getNormalizedX(key)
const iv = QuickCrypto.randomFillSync(new Uint8Array(16))
const cipher = QuickCrypto.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey),
iv
)
let encryptedMessage = cipher.update(content, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
let ivBase64 = Buffer.from(iv.buffer).toString('base64')
return encryptedMessage + '?iv=' + ivBase64
} catch (e: any) {
throw new AppError(Err.KEYCHAIN_ERROR, e.message)
}
}
const decryptNip04 = async function(
senderPubKey: string,
encryptedContent: string
): Promise<string> {
const keys: KeyPair = await getOrCreateKeyPair()
const key = secp256k1.getSharedSecret(keys.privateKey, '02' + senderPubKey)
const normalizedKey = getNormalizedX(key)
const parts = encryptedContent.split('?')
if (parts.length !== 2) {
throw new Error('Invalid encrypted content format')
}
const ciphertext = Buffer.from(parts[0], 'base64')
const iv = Buffer.from(parts[1].substring(3), 'base64') // remove 'iv='
const decipher = QuickCrypto.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey),
iv
)
let decryptedText = decipher.update(ciphertext, 'base64', 'utf8')
decryptedText += decipher.final('utf8')
return decryptedText as string
}
const publish = async function (
event: NostrUnsignedEvent,
relays: string[],
): Promise<Event | undefined> {
const keys: KeyPair = await getOrCreateKeyPair()
let signedEvent = finalizeEvent(event, Uint8Array.from(Buffer.from(keys.privateKey, 'hex')))
if(!validateEvent(signedEvent)) {
throw new AppError(Err.VALIDATION_ERROR, 'Event is invalid and could not be published', event)
}
log.trace('Event to be published', signedEvent, 'publish')
const pool = getRelayPool()
let pubs = pool.publish(relays, signedEvent)
await delay(1000)
// await Promise.all(pubs)
const published: NostrEvent = await pool.get(relays, {
ids: [signedEvent.id]
})
if(published) {
log.trace('Event successfully published', published, 'NostrClient.publish')
// pool.close(relays)
return published
}
// pool.close(relays)
return undefined
}
const getEvent = async function (
relays: string[],
filter: NostrFilter
): Promise<Event | null> {
const pool = getRelayPool()
const event: Event = await pool.get(relays, filter)
if(event) {
log.trace('Event received', event, 'getEvent')
return event
}
// pool.close(relays)
return null
}
const getEvents = async function (
relays: string[],
filters: NostrFilter[]
): Promise<Event[]> {
const pool = getRelayPool()
const events: Event[] = await pool.list(relays, filters)
if(events && events.length > 0) {
return events
}
// pool.close(relays)
return []
}
const getNip05Record = async function (nip05: string) {
const nip05Domain = NostrClient.getDomainFromNip05(nip05)
const nip05Name = NostrClient.getNameFromNip05(nip05)
try {
if(!nip05Domain || !nip05Name) {
throw new AppError(Err.VALIDATION_ERROR, 'Contact does not have a valid nip05 identifier.', {nip05})
}
const url = `https://${nip05Domain}/.well-known/nostr.json?name=${nip05Name}`
const method = 'GET'
const headers = MinibitsClient.getPublicHeaders()
const nip05Record: Nip05VerificationRecord = await MinibitsClient.fetchApi(url, {
method,
headers,
})
log.trace('[getNip05Record]', `Got response`, nip05Record || null)
return nip05Record
} catch(e: any) {
log.trace('Error', e)
if(e.name === Err.NOTFOUND_ERROR) {
e.message = `${nip05Name} could not be found on the ${nip05Domain} address server. Your contact might have changed the profile name, please get in touch.`
throw e
} else {
throw e // Propagate other errors upstream
}
}
}
const verifyNip05 = async function (nip05: string, pubkey: string) {
const nip05Record = await getNip05Record(nip05) // throws
const nip05Name = getNameFromNip05(nip05)
if (nip05Record && nip05Record.names[nip05Name as string] === pubkey) {
return true
}
throw new AppError(
Err.VALIDATION_ERROR,
`${nip05} is no longer linked to the same public key as in your contacts. Please get in touch with the intended recipient to make sure the address is valid. Then, remove the contact and add it again.`)
}
const getNip05PubkeyAndRelays = async function (nip05: string) {
const nip05Record = await getNip05Record(nip05) // throws
const nip05Name = getNameFromNip05(nip05)
let nip05Pubkey: string = ''
let nip05Relays: string[] = []
if (nip05Record && nip05Record.names[nip05Name as string]) {
nip05Pubkey = nip05Record.names[nip05Name as string]
} else {
throw new AppError(
Err.VALIDATION_ERROR,
`Could not get public key from NOSTR address verification server`, {nip05, nip05Record})
}
// retrieve recommended relays
if(nip05Record.relays && nip05Record.relays[nip05Pubkey].length > 0) {
nip05Relays = nip05Record.relays[nip05Pubkey]
log.trace('Got relays from server', nip05Relays, 'getNip05PubkeyAndRelays')
}
return {nip05Pubkey, nip05Relays}
}
const getProfileFromRelays = async function (pubkey: string, relays: string[]) {
// get profile from the relays for pubkey linked to nip05
const filters: NostrFilter[] = [{
authors: [pubkey],
kinds: [0],
}]
const events: NostrEvent[] = await NostrClient.getEvents(relays, filters)
if(!events || events.length === 0) {
throw new AppError(Err.SERVER_ERROR, 'Could not get profile event from the relays.', {relays})
}
const profile: NostrProfile = JSON.parse(events[events.length - 1].content)
profile.pubkey = events[events.length - 1].pubkey // pubkey might not be in ev.content
log.trace('[getProfileFromRelays]', {profile})
return profile
}
const getNormalizedNostrProfile = async function (nip05: string, relays: string[]) {
let relaysToConnect: string[] = []
relaysToConnect.push(...relays)
// get nip05 record from the .well-known server
const {nip05Pubkey, nip05Relays} = await getNip05PubkeyAndRelays(nip05)
if(nip05Relays.length > 0) {
for (const relay of nip05Pubkey) {
if(!relays.includes(relay)) {
relaysToConnect.push(relay)
relaysStore.addOrUpdateRelay({
url: relay,
status: WebSocket.CLOSED
})
}
}
}
const profile: NostrProfile = await NostrClient.getProfileFromRelays(nip05Pubkey, relaysToConnect)
if(!profile) {
throw new AppError(Err.NOTFOUND_ERROR, `Profile could not be found on Nostr relays.`, {nip05})
}
if(profile.nip05 !== nip05) {
throw new AppError(Err.VALIDATION_ERROR, 'Profile from the relay does not match the given nip05 identifier', {nip05, profile})
}
if(!profile.name) {
profile.name = getNameFromNip05(nip05) as string
}
if(!profile.pubkey) {
profile.pubkey = nip05Pubkey
}
const npub = NostrClient.getNpubkey(profile.pubkey)
return {...profile, npub} as NostrProfile
}
const getDomainFromNip05 = function(nip05: string) {
const atIndex = nip05.lastIndexOf('@')
if (atIndex !== -1) {
const domain = nip05.slice(atIndex + 1)
return domain
}
// Invalid email or no domain found
return null
}
const getNameFromNip05 = function(nip05: string) {
const atIndex = nip05.indexOf('@')
if (atIndex !== -1) {
const name = nip05.slice(0, atIndex);
return name
}
// Invalid email or no "@" symbol found
return null
}
const delay = function (ms: number) {
return new Promise(resolve => {
setTimeout(() => { resolve('') }, ms);
})
}
const getNormalizedX = function (key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}
const deleteKeyPair = async function (): Promise<void> {
await KeyChain.removeNostrKeypair()
}
const getNormalizedRelayUrl = function (url: string): string {
try {
return utils.normalizeURL(url)
} catch (e: any) {
throw new AppError(Err.VALIDATION_ERROR, `Invalid relay URL: ${e.message}`)
}
}
export const NostrClient = { // TODO split helper functions to separate module
getRelayPool,
getDefaultRelays,
getMinibitsRelays,
getOrCreateKeyPair,
getNpubkey,
getHexkey,
encryptNip04,
decryptNip04,
getDomainFromNip05,
getNameFromNip05,
publish,
getEvent,
getEvents,
getNip05Record,
verifyNip05,
getNip05PubkeyAndRelays,
getProfileFromRelays,
getNormalizedRelayUrl,
getNormalizedNostrProfile,
deleteKeyPair,
}