diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index da1172f3..034dbce8 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -1,6 +1,6 @@ import Main from "./services/main/index.js" import Nostr from "./services/nostr/index.js" -import { NostrEvent, NostrSend, NostrSettings } from "./services/nostr/handler.js" +import { NostrEvent, NostrSend, NostrSettings } from "./services/nostr/nostrPool.js" import * as Types from '../proto/autogenerated/ts/types.js' import NewNostrTransport, { NostrRequest } from '../proto/autogenerated/ts/nostr_transport.js'; import { ERROR, getLogger } from "./services/helpers/logger.js"; @@ -50,6 +50,10 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett return } if (event.kind === 21001) { + if (event.relayConstraint === 'provider') { + log("got noffer request on provider only relay, ignoring") + return + } const offerReq = j as NofferData log("🎯 [NOSTR EVENT] Received offer request (kind 21001)", { fromPub: event.pub, @@ -60,18 +64,33 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett mainHandler.offerManager.handleClinkOffer(offerReq, event) return } else if (event.kind === 21002) { + if (event.relayConstraint === 'provider') { + log("got debit request on provider only relay, ignoring") + return + } const debitReq = j as NdebitData mainHandler.debitManager.handleNip68Debit(debitReq, event) return } else if (event.kind === 21003) { + if (event.relayConstraint === 'provider') { + log("got management request on provider only relay, ignoring") + return + } const nmanageReq = j as NmanageRequest mainHandler.managementManager.handleRequest(nmanageReq, event); return; } if (!j.rpcName) { + if (event.relayConstraint === 'service') { + log("got relay response on service only relay, ignoring") + } onClientEvent(j as { requestId: string }, event.pub) return } + if (event.relayConstraint === 'provider') { + log("got service request on provider only relay, ignoring") + return + } if (j.authIdentifier !== event.pub) { log(ERROR, "authIdentifier does not match", j.authIdentifier || "--", event.pub) return diff --git a/src/services/nostr/handler.ts b/src/services/nostr/handler.ts index 06c83c08..d1fa46ec 100644 --- a/src/services/nostr/handler.ts +++ b/src/services/nostr/handler.ts @@ -1,35 +1,37 @@ //import { SimplePool, Sub, Event, UnsignedEvent, getEventHash, signEvent } from 'nostr-tools' -import WebSocket from 'ws' -Object.assign(global, { WebSocket: WebSocket }); -import crypto from 'crypto' -import { SimplePool, Event, UnsignedEvent, finalizeEvent, Relay, nip44, Filter } from 'nostr-tools' +/* import WebSocket from 'ws' +Object.assign(global, { WebSocket: WebSocket }); */ +/* import crypto from 'crypto' +import { SimplePool, Event, UnsignedEvent, finalizeEvent, Relay, nip44, Filter } from 'nostr-tools' */ import { ERROR, getLogger } from '../helpers/logger.js' -import { nip19 } from 'nostr-tools' -import { encrypt as encryptV1, decrypt as decryptV1, getSharedSecret as getConversationKeyV1 } from './nip44v1.js' +/* import { nip19 } from 'nostr-tools' +import { encrypt as encryptV1, decrypt as decryptV1, getSharedSecret as getConversationKeyV1 } from './nip44v1.js' */ import { ProcessMetrics, ProcessMetricsCollector } from '../storage/tlv/processMetricsCollector.js' -import { Subscription } from 'nostr-tools/lib/types/abstract-relay.js'; +/* import { Subscription } from 'nostr-tools/lib/types/abstract-relay.js'; const { nprofileEncode } = nip19 const { v2 } = nip44 const { encrypt: encryptV2, decrypt: decryptV2, utils } = v2 -const { getConversationKey: getConversationKeyV2 } = utils -const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL +const { getConversationKey: getConversationKeyV2 } = utils */ +/* const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string } type ClientInfo = { clientId: string, publicKey: string, privateKey: string, name: string } type SendDataContent = { type: "content", content: string, pub: string } type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } export type SendData = SendDataContent | SendDataEvent export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } -export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void +export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void */ +import { NostrPool } from './nostrPool.js' +import { NostrSettings, SendInitiator, SendData, NostrEvent, NostrSend } from './nostrPool.js' -export type NostrSettings = { +/* export type NostrSettings = { apps: AppInfo[] relays: string[] clients: ClientInfo[] maxEventContentLength: number providerDestinationPub: string -} +} */ -export type NostrEvent = { +/* export type NostrEvent = { id: string pub: string content: string @@ -37,7 +39,8 @@ export type NostrEvent = { startAtNano: string startAtMs: number kind: number -} + relayConstraint?: 'service' | 'provider' +} */ type SettingsRequest = { type: 'settings' @@ -88,7 +91,7 @@ const send = (message: ChildProcessResponse) => { }) } } -let subProcessHandler: Handler | undefined +let subProcessHandler: NostrPool | undefined process.on("message", (message: ChildProcessRequest) => { switch (message.type) { case 'settings': @@ -108,11 +111,15 @@ process.on("message", (message: ChildProcessRequest) => { const handleNostrSettings = (settings: NostrSettings) => { if (subProcessHandler) { getLogger({ component: "nostrMiddleware" })("got new nostr setting, resetting nostr handler") - subProcessHandler.Stop() - initNostrHandler(settings) + subProcessHandler.UpdateSettings(settings) + // initNostrHandler(settings) return } - initNostrHandler(settings) + subProcessHandler = new NostrPool(event => { + send(event) + }) + subProcessHandler.UpdateSettings(settings) + // initNostrHandler(settings) new ProcessMetricsCollector((metrics) => { send({ type: 'processMetrics', @@ -120,14 +127,11 @@ const handleNostrSettings = (settings: NostrSettings) => { }) }) } -const initNostrHandler = (settings: NostrSettings) => { - subProcessHandler = new Handler(settings, event => { - send({ - type: 'event', - event: event - }) +/* const initNostrHandler = (settings: NostrSettings) => { + subProcessHandler = new NostrPool(event => { + send(event) }) -} +} */ const sendToNostr: NostrSend = (initiator, data, relays) => { if (!subProcessHandler) { getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized") @@ -137,7 +141,7 @@ const sendToNostr: NostrSend = (initiator, data, relays) => { } send({ type: 'ready' }) -const supportedKinds = [21000, 21001, 21002, 21003] +/* const supportedKinds = [21000, 21001, 21002, 21003] export default class Handler { pool = new SimplePool() settings: NostrSettings @@ -382,4 +386,4 @@ const splitContent = (content: string, maxLength: number) => { parts.push(content.slice(i, i + maxLength)) } return parts -} \ No newline at end of file +} */ \ No newline at end of file diff --git a/src/services/nostr/index.ts b/src/services/nostr/index.ts index 68773ee5..3dce7b41 100644 --- a/src/services/nostr/index.ts +++ b/src/services/nostr/index.ts @@ -1,13 +1,11 @@ import { ChildProcess, fork } from 'child_process' -import { NostrSettings, NostrEvent, ChildProcessRequest, ChildProcessResponse, SendData, SendInitiator } from "./handler.js" +import { NostrSettings, NostrEvent, SendData, SendInitiator } from "./nostrPool.js" +import { ChildProcessRequest, ChildProcessResponse } from "./handler.js" import { Utils } from '../helpers/utilsWrapper.js' import { getLogger, ERROR } from '../helpers/logger.js' type EventCallback = (event: NostrEvent) => void type BeaconCallback = (beacon: { content: string, pub: string }) => void - - - export default class NostrSubprocess { childProcess: ChildProcess utils: Utils diff --git a/src/services/nostr/nostrPool.ts b/src/services/nostr/nostrPool.ts new file mode 100644 index 00000000..80c1b0c6 --- /dev/null +++ b/src/services/nostr/nostrPool.ts @@ -0,0 +1,300 @@ +import WebSocket from 'ws' +Object.assign(global, { WebSocket: WebSocket }); +import crypto from 'crypto' +import { SimplePool, Event, UnsignedEvent, finalizeEvent, Relay, nip44, Filter } from 'nostr-tools' +import { ERROR, getLogger, PubLogger } from '../helpers/logger.js' +import { nip19 } from 'nostr-tools' +import { encrypt as encryptV1, decrypt as decryptV1, getSharedSecret as getConversationKeyV1 } from './nip44v1.js' +import { Subscription } from 'nostr-tools/lib/types/abstract-relay.js'; +import { RelayConnection, RelaySettings } from './nostrRelayConnection.js' +const { nprofileEncode } = nip19 +const { v2 } = nip44 +const { encrypt: encryptV2, decrypt: decryptV2, utils } = v2 +const { getConversationKey: getConversationKeyV2 } = utils +// const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL +export type SendDataContent = { type: "content", content: string, pub: string } +export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } +export type SendData = SendDataContent | SendDataEvent +export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } +export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void + +export type LinkedProviderInfo = { pubDestination: string, clientId: string, relayUrl: string } +export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo } +// export type ClientInfo = { clientId: string, publicKey: string, privateKey: string, name: string } +export type NostrSettings = { + apps: AppInfo[] + relays: string[] + // clients: ClientInfo[] + maxEventContentLength: number + // providerDestinationPub: string +} + +export type NostrEvent = { + id: string + pub: string + content: string + appId: string + startAtNano: string + startAtMs: number + kind: number + relayConstraint?: 'service' | 'provider' +} +type RelayEvent = { type: 'event', event: NostrEvent } | { type: 'beacon', content: string, pub: string } +type RelayEventCallback = (event: RelayEvent) => void +const splitContent = (content: string, maxLength: number) => { + const parts = [] + for (let i = 0; i < content.length; i += maxLength) { + parts.push(content.slice(i, i + maxLength)) + } + return parts +} +const actionKinds = [21000, 21001, 21002, 21003] +const beaconKind = 30078 +const appTag = "Lightning.Pub" +export class NostrPool { + relays: Record = {} + apps: Record = {} + maxEventContentLength: number + // providerDestinationPub: string | undefined + eventCallback: RelayEventCallback + log = getLogger({ component: "nostrMiddleware" }) + handledEvents: Map = new Map() // add expiration handler + providerInfo: (LinkedProviderInfo & { appPub: string }) | undefined = undefined + cleanupInterval: NodeJS.Timeout | undefined = undefined + constructor(eventCallback: RelayEventCallback) { + this.eventCallback = eventCallback + } + + StartCleanupInterval() { + this.cleanupInterval = setInterval(() => { + this.handledEvents.forEach((value, key) => { + if (Date.now() - value.handledAt > 1000 * 60 * 60 * 2) { + this.handledEvents.delete(key) + } + }) + }, 1000 * 60 * 60 * 1) + } + + UpdateSettings(settings: NostrSettings) { + Object.values(this.relays).forEach(relay => relay.Stop()) + settings.apps.forEach(app => { + this.log("appId:", app.appId, "pubkey:", app.publicKey, "nprofile:", nprofileEncode({ pubkey: app.publicKey, relays: settings.relays })) + }) + this.maxEventContentLength = settings.maxEventContentLength + const { apps, rSettings, providerInfo } = processApps(settings) + this.providerInfo = providerInfo + this.apps = apps + this.relays = {} + for (const r of rSettings) { + this.relays[r.relayUrl] = new RelayConnection(r, (e, r) => this.onEvent(e, r)) + } + + } + + private onEvent = (e: Event, relay: RelayConnection) => { + const validated = this.validateEvent(e, relay) + if (!validated || this.handledEvents.has(e.id)) { + return + } + this.handledEvents.set(e.id, { handledAt: Date.now() }) + if (validated.type === 'beacon') { + this.eventCallback({ type: 'beacon', content: e.content, pub: validated.pub }) + return + } + const { app } = validated + + const startAtMs = Date.now() + const startAtNano = process.hrtime.bigint().toString() + let content = "" + try { + if (e.kind === 21000) { + content = decryptV1(e.content, getConversationKeyV1(app.privateKey, e.pubkey)) + } else { + content = decryptV2(e.content, getConversationKeyV2(Buffer.from(app.privateKey, 'hex'), e.pubkey)) + } + } catch (e: any) { + this.log(ERROR, "failed to decrypt event", e.message, e.content) + return + } + const relayConstraint = relay.getConstraint() + const nostrEvent: NostrEvent = { id: e.id, content, pub: e.pubkey, appId: app.appId, startAtNano, startAtMs, kind: e.kind, relayConstraint } + this.eventCallback({ type: 'event', event: nostrEvent }) + } + + private validateEvent(e: Event, relay: RelayConnection): { type: 'event', pub: string, app: AppInfo } | { type: 'beacon', content: string, pub: string } | null { + if (e.kind === 30078 && this.providerInfo && relay.isProviderRelay() && e.pubkey === this.providerInfo.pubDestination) { + return { type: 'beacon', content: e.content, pub: e.pubkey } + } + if (!actionKinds.includes(e.kind) || !e.pubkey) { + return null + } + const pubTags = e.tags.find(tags => tags && tags.length > 1 && tags[0] === 'p') + if (!pubTags) { + return null + } + const app = this.apps[pubTags[1]] + if (!app) { + return null + } + return { type: 'event', pub: e.pubkey, app } + } + + async Send(initiator: SendInitiator, data: SendData, relays?: string[]) { + try { + const keys = this.getSendKeys(initiator) + const privateKey = Buffer.from(keys.privateKey, 'hex') + const toSign = await this.handleSend(data, keys) + await Promise.all(toSign.map(ue => this.sendEvent(ue, keys, relays))) + } catch (e: any) { + this.log(ERROR, "failed to send event", e.message || e) + throw e + } + + } + + private async handleSend(data: SendData, keys: { name: string, privateKey: string, publicKey: string }): Promise { + if (data.type === 'content') { + const parts = splitContent(data.content, this.maxEventContentLength) + if (parts.length > 1) { + const shardsId = crypto.randomBytes(16).toString('hex') + const totalShards = parts.length + const ues = await Promise.all(parts.map((part, index) => this.handleSendDataContent({ ...data, content: JSON.stringify({ part, index, totalShards, shardsId }) }, keys))) + return ues + } + return [await this.handleSendDataContent(data, keys)] + } + const ue = await this.handleSendDataEvent(data, keys) + return [ue] + } + + private async handleSendDataContent(data: SendDataContent, keys: { name: string, privateKey: string, publicKey: string }): Promise { + let content = encryptV1(data.content, getConversationKeyV1(keys.privateKey, data.pub)) + return { + content, + created_at: Math.floor(Date.now() / 1000), + kind: 21000, + pubkey: keys.publicKey, + tags: [['p', data.pub]], + } + } + + private async handleSendDataEvent(data: SendDataEvent, keys: { name: string, privateKey: string, publicKey: string }): Promise { + const toSign = data.event + if (data.encrypt) { + toSign.content = encryptV2(data.event.content, getConversationKeyV2(Buffer.from(keys.privateKey, 'hex'), data.encrypt.toPub)) + } + if (!toSign.pubkey) { + toSign.pubkey = keys.publicKey + } + return toSign + } + private getServiceRelays() { + return Object.values(this.relays).filter(r => r.isServiceRelay()).map(r => r.GetUrl()) + } + + private async sendEvent(event: UnsignedEvent, keys: { name: string, privateKey: string }, relays?: string[]) { + const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex')) + let sent = false + const log = getLogger({ appName: keys.name }) + const r = relays ? relays : this.getServiceRelays() + const pool = new SimplePool() + await Promise.all(pool.publish(r, signed).map(async p => { + try { + await p + sent = true + } catch (e: any) { + console.log(e) + log(e) + } + })) + if (!sent) { + log("failed to send event") + } else { + //log("sent event") + } + } + + private getSendKeys(initiator: SendInitiator) { + if (initiator.type === 'app') { + const { appId } = initiator + const found = Object.values(this.apps).find((info: AppInfo) => info.appId === appId) + if (!found) { + throw new Error("unkown app") + } + return { name: found.name, publicKey: found.publicKey, privateKey: found.privateKey } + } else if (initiator.type === 'client') { + const { clientId } = initiator + const providerApp = this.apps[this.providerInfo?.appPub || ""] + if (this.providerInfo && this.providerInfo.clientId === clientId && providerApp) { + return { name: providerApp.name, publicKey: providerApp.publicKey, privateKey: providerApp.privateKey } + } + throw new Error("unkown client") + } + throw new Error("unkown initiator type") + } +} + +const processApps = (settings: NostrSettings) => { + const apps: Record = {} + let providerInfo: (LinkedProviderInfo & { appPub: string }) | undefined = undefined + for (const app of settings.apps) { + apps[app.publicKey] = app + if (app.provider) { + if (providerInfo) { + throw new Error("found more than one provider") + } + providerInfo = { ...app.provider, appPub: app.publicKey } + } + } + let providerAssigned = false + const rSettings: RelaySettings[] = [] + new Set(settings.relays).forEach(r => { + const filters = [getServiceFilter(apps)] + if (providerInfo && providerInfo.relayUrl === r) { + providerAssigned = true + filters.push(getBeaconFilter(providerInfo.pubDestination)) + } + rSettings.push({ + relayUrl: r, + serviceRelay: true, + providerRelay: r === providerInfo?.relayUrl, + filters: filters, + }) + }) + if (!providerAssigned && providerInfo) { + rSettings.push({ + relayUrl: providerInfo.relayUrl, + providerRelay: true, + serviceRelay: false, + filters: [ + getProviderFilter(providerInfo.appPub, providerInfo.pubDestination), + getBeaconFilter(providerInfo.pubDestination), + ], + }) + } + return { apps, rSettings, providerInfo } +} + +const getServiceFilter = (apps: Record): Filter => { + return { + since: Math.ceil(Date.now() / 1000), + kinds: actionKinds, + '#p': Object.keys(apps), + } +} + +const getProviderFilter = (appPub: string, providerPub: string): Filter => { + return { + since: Math.ceil(Date.now() / 1000), + kinds: actionKinds, + '#p': [appPub], + authors: [providerPub] + } +} + +const getBeaconFilter = (providerPub: string): Filter => { + return { + kinds: [beaconKind], '#d': [appTag], + authors: [providerPub] + } +} diff --git a/src/services/nostr/nostrRelayConnection.ts b/src/services/nostr/nostrRelayConnection.ts new file mode 100644 index 00000000..7d2d3056 --- /dev/null +++ b/src/services/nostr/nostrRelayConnection.ts @@ -0,0 +1,152 @@ +import WebSocket from 'ws' +Object.assign(global, { WebSocket: WebSocket }); +import { Event, UnsignedEvent, Relay, Filter } from 'nostr-tools' +import { ERROR, getLogger, PubLogger } from '../helpers/logger.js' +import { Subscription } from 'nostr-tools/lib/types/abstract-relay.js'; +// const handledEvents: string[] = [] // TODO: - big memory leak here, add TTL +/* export type SendDataContent = { type: "content", content: string, pub: string } +export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } +export type SendData = SendDataContent | SendDataEvent +export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } +export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void */ + +/* export type LinkedProviderInfo = { pubDestination: string, clientId: string, relayUrl: string } +export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo } */ +// export type ClientInfo = { clientId: string, publicKey: string, privateKey: string, name: string } +/* export type NostrSettings = { + apps: AppInfo[] + relays: string[] + // clients: ClientInfo[] + maxEventContentLength: number + // providerDestinationPub: string +} + +export type NostrEvent = { + id: string + pub: string + content: string + appId: string + startAtNano: string + startAtMs: number + kind: number + relayConstraint?: 'service' | 'provider' +} */ + +type RelayCallback = (event: Event, relay: RelayConnection) => void +export type RelaySettings = { relayUrl: string, filters: Filter[], serviceRelay: boolean, providerRelay: boolean } + + +export class RelayConnection { + eventCallback: RelayCallback + log: PubLogger + relay: Relay | null = null + sub: Subscription | null = null + // relayUrl: string + stopped = false + // filters: Filter[] + settings: RelaySettings + constructor(settings: RelaySettings, eventCallback: RelayCallback, autoconnect = true) { + this.log = getLogger({ component: "relay:" + settings.relayUrl }) + // this.relayUrl = relayUrl + // this.filters = filters + this.settings = settings + this.eventCallback = eventCallback + if (autoconnect) { + this.ConnectLoop() + } + } + + GetUrl() { + return this.settings.relayUrl + } + + Stop() { + this.stopped = true + this.sub?.close() + this.relay?.close() + this.relay = null + this.sub = null + } + + isServiceRelay() { + return this.settings.serviceRelay + } + isProviderRelay() { + return this.settings.providerRelay + } + + getConstraint(): 'service' | 'provider' | undefined { + if (this.isProviderRelay() && !this.isServiceRelay()) { + return 'provider' + } + if (this.isServiceRelay() && !this.isProviderRelay()) { + return 'service' + } + return undefined + } + + async ConnectLoop() { + let failures = 0 + while (!this.stopped) { + await this.ConnectPromise() + const pow = Math.pow(2, failures) + const delay = Math.min(pow, 900) + this.log("connection failed, will try again in", delay, "seconds (failures:", failures, ")") + await new Promise(resolve => setTimeout(resolve, delay * 1000)) + failures++ + } + this.log("nostr handler stopped") + } + + async ConnectPromise() { + return new Promise(async (res) => { + this.relay = await this.GetRelay() + if (!this.relay) { + res() + return + } + this.sub = this.Subscribe(this.relay) + this.relay.onclose = (() => { + this.log("disconnected") + this.sub?.close() + if (this.relay) { + this.relay.onclose = null + this.relay.close() + this.relay = null + } + this.sub = null + res() + }) + }) + } + + async GetRelay(): Promise { + try { + const relay = await Relay.connect(this.settings.relayUrl) + if (!relay.connected) { + throw new Error("failed to connect to relay") + } + return relay + } catch (err: any) { + this.log("failed to connect to relay", err.message || err) + return null + } + } + + Subscribe(relay: Relay) { + this.log("🔍 subscribing...") + return relay.subscribe(this.settings.filters, { + oneose: () => this.log("is ready"), + onevent: (e) => this.eventCallback(e, this) + }) + } + + Send(e: Event) { + if (!this.relay) { + throw new Error("relay not connected") + } + return this.relay.publish(e) + } + + +} \ No newline at end of file