UNPKG

snstr

Version:

Secure Nostr Software Toolkit for Renegades - A comprehensive TypeScript library for Nostr protocol implementation

750 lines (749 loc) 32 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NostrRelay = void 0; const events_1 = __importDefault(require("events")); const ws_1 = require("ws"); const event_1 = require("../nip01/event"); const nip44_1 = require("../nip44"); const security_validator_1 = require("./security-validator"); /** * Validates if a string is a valid 32-byte hex string (case-insensitive). * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. */ function isValid32ByteHex(hex) { return /^[0-9a-fA-F]{64}$/.test(hex); } /** * Validates if a string is a valid 64-byte hex string (case-insensitive). * Unlike isValidPublicKeyPoint, this accepts both uppercase and lowercase hex. */ function isValid64ByteHex(hex) { return /^[0-9a-fA-F]{128}$/.test(hex); } /* ================ [ Configuration ] ================ */ const HOST = "ws://localhost"; const DEBUG = process.env["DEBUG"] === "true"; const VERBOSE = process.env["VERBOSE"] === "true" || DEBUG; console.log("output mode:", DEBUG ? "debug" : VERBOSE ? "verbose" : "silent"); /* ================ [ Server Class ] ================ */ class NostrRelay { constructor(port, purge_ival) { this._isClosing = false; this._purgeTimer = null; this._actualPort = null; this._cache = []; this._emitter = new events_1.default(); this._port = port; this._purge = purge_ival ?? null; this._subs = new Map(); this._wss = null; this.conn = 0; this._actualPort = null; } get cache() { return this._cache; } get subs() { return this._subs; } get url() { const port = this._actualPort || this._port; return `${HOST}:${port}`; } get wss() { if (this._wss === null) { throw new Error("websocket server not initialized"); } return this._wss; } async start() { this._wss = new ws_1.WebSocketServer({ port: this._port }); this._isClosing = false; this.wss.on("connection", (socket) => { const instance = new ClientSession(this, socket); socket.on("message", (msg) => instance._handler(msg.toString())); socket.on("error", (err) => instance._onerr(err)); socket.on("close", (code) => instance._cleanup(code)); this.conn += 1; }); return new Promise((res) => { this.wss.on("listening", () => { // Capture the actual assigned port when port 0 was used const address = this.wss.address(); if (address && typeof address === "object" && "port" in address) { this._actualPort = address.port; } DEBUG && console.log("[ relay ] running on port:", this._actualPort || this._port); if (this._purge !== null) { DEBUG && console.log(`[ relay ] purging events every ${this._purge} seconds`); this._purgeTimer = setInterval(() => { this._cache = []; }, this._purge * 1000); } this._emitter.emit("connected"); res(this); }); }); } onconnect(cb) { this._emitter.on("connected", cb); } close() { return new Promise((resolve) => { if (this._isClosing) { DEBUG && console.log("[ relay ] already closing, skipping duplicate close call"); resolve(); return; } this._isClosing = true; // Clear any listeners on the emitter this._emitter.removeAllListeners(); if (this._purgeTimer) { clearInterval(this._purgeTimer); this._purgeTimer = null; } if (this._wss) { // Clean up clients first if (this._wss.clients && this._wss.clients.size > 0) { // Keep track of clients to make sure they all close const clientsToClose = this._wss.clients.size; let closedClients = 0; this._wss.clients.forEach((client) => { try { // Add close handler to track when clients are closed client.once("close", () => { closedClients++; DEBUG && console.log(`[ relay ] client closed (${closedClients}/${clientsToClose})`); }); client.close(1000, "Server shutting down"); } catch (e) { // Count error closures as closed closedClients++; DEBUG && console.log(`[ relay ] error closing client: ${e}`); } }); } // Clear state this._subs.clear(); this._cache = []; // Close server with timeout that self-cancels (unref) const timeout = setTimeout(() => { DEBUG && console.log("[ relay ] server close timed out, forcing cleanup"); this._wss = null; resolve(); }, 1000).unref(); // Use unref to avoid keeping the process alive const wss = this._wss; this._wss = null; try { wss.close(() => { clearTimeout(timeout); // Final cleanup process.nextTick(() => { // Ensure everything is fully cleaned up before resolving try { wss.removeAllListeners(); } catch (e) { // Ignore errors during cleanup } resolve(); }); }); } catch (e) { // Handle errors during close DEBUG && console.log(`[ relay ] error during server close: ${e}`); clearTimeout(timeout); resolve(); } } else { resolve(); } }); } store(event) { const isSimpleReplaceable = event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000); const isParameterizedReplaceable = event.kind >= 30000 && event.kind < 40000; let shouldAddEvent = true; if (isSimpleReplaceable) { // Check if a newer or equally new (by ID) event already exists for (const cachedEvent of this._cache) { if (cachedEvent.pubkey === event.pubkey && cachedEvent.kind === event.kind) { if (cachedEvent.created_at > event.created_at) { shouldAddEvent = false; // Cached is strictly newer break; } if (cachedEvent.created_at === event.created_at && cachedEvent.id > event.id // Cached has larger ID (is newer) ) { shouldAddEvent = false; // Cached is same age but wins by ID break; } } } if (shouldAddEvent) { // Remove all existing events of this kind and pubkey before adding the new one this._cache = this._cache.filter((cachedEvent) => !(cachedEvent.pubkey === event.pubkey && cachedEvent.kind === event.kind)); DEBUG && console.log(`[ relay ] replacing existing events for kind ${event.kind}, pubkey ${event.pubkey} with new event ${event.id}.`); } } else if (isParameterizedReplaceable) { const dTagValue = event.tags.find((tag) => tag[0] === "d")?.[1] || ""; for (const cachedEvent of this._cache) { if (cachedEvent.pubkey === event.pubkey && cachedEvent.kind === event.kind) { const cachedDTagValue = cachedEvent.tags.find((tag) => tag[0] === "d")?.[1] || ""; if (cachedDTagValue === dTagValue) { if (cachedEvent.created_at > event.created_at) { shouldAddEvent = false; // Cached is strictly newer break; } if (cachedEvent.created_at === event.created_at && cachedEvent.id > event.id // Cached has larger ID (is newer) ) { shouldAddEvent = false; // Cached is same age but wins by ID break; } } } } if (shouldAddEvent) { // Remove all existing events of this kind, pubkey, and dTagValue this._cache = this._cache.filter((cachedEvent) => !(cachedEvent.pubkey === event.pubkey && cachedEvent.kind === event.kind && (cachedEvent.tags.find((tag) => tag[0] === "d")?.[1] || "") === dTagValue)); DEBUG && console.log(`[ relay ] replacing existing events for kind ${event.kind}, pubkey ${event.pubkey}, dTag ${dTagValue} with new event ${event.id}.`); } } if (shouldAddEvent) { this._cache.push(event); // Sort the cache: // 1. created_at timestamp (descending - newer events first) // 2. event id (lexicographically larger for ties - NIP-01 tie-breaking) this._cache.sort((a, b) => { if (a.created_at !== b.created_at) { return b.created_at - a.created_at; // Newer events first } // If created_at is the same, sort by id (lexicographically larger id is newer) return b.id.localeCompare(a.id); }); VERBOSE && console.log(`[ relay ] Stored event ${event.id}. Cache size: ${this._cache.length}`); } else { DEBUG && console.log(`[ relay ] Discarding event ${event.id} as a newer version already exists or it's older.`); } } } exports.NostrRelay = NostrRelay; /* ================ [ Instance Class ] ================ */ class ClientSession { constructor(relay, socket) { this._relay = relay; // Generate cryptographically secure session ID if (typeof crypto !== "undefined" && crypto.getRandomValues) { const array = new Uint8Array(3); crypto.getRandomValues(array); this._sid = Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); } else if (typeof process !== "undefined" && process.versions && process.versions.node) { try { // Try to use require for CommonJS environments // eslint-disable-next-line @typescript-eslint/no-var-requires const nodeCrypto = require("crypto"); this._sid = nodeCrypto.randomBytes(3).toString("hex"); } catch (requireError) { // If require fails (ESM environment), generate a fallback ID // that will remain immutable for the session lifetime const tempArray = new Uint8Array(3); // Use Math.random as permanent fallback for (let i = 0; i < tempArray.length; i++) { tempArray[i] = Math.floor(Math.random() * 256); } this._sid = Array.from(tempArray, (byte) => byte.toString(16).padStart(2, "0")).join(""); // Log warning but keep the generated ID immutable console.warn("Using Math.random for session ID generation. For production use, ensure crypto module is available."); } } else { // As a last resort, use Math.random with a timestamp component // to ensure uniqueness even without crypto const timestamp = Date.now(); const random = Math.floor(Math.random() * 0xffffff); this._sid = ((timestamp & 0xffffff) ^ random).toString(16).padStart(6, "0"); } this._socket = socket; this._subs = new Set(); this.log.client("client connected"); } get sid() { return this._sid; } get relay() { return this._relay; } get socket() { return this._socket; } _cleanup(code) { try { // First remove all subscriptions associated with this client for (const subId of this._subs) { this.remSub(subId); } this._subs.clear(); // Close the socket if it's still open if (this.socket.readyState === ws_1.WebSocket.OPEN) { this.socket.close(); } this.relay.conn -= 1; this.log.client(`[ ${this._sid} ]`, "client disconnected with code:", code); } catch (e) { DEBUG && console.error(`[ client ][ ${this._sid} ]`, "error during cleanup:", e); } } _handler(message) { try { // Try to parse as JSON const parsed = JSON.parse(message); // Handle NIP-46 messages (which might not follow standard Nostr format) if (parsed && Array.isArray(parsed) && parsed.length > 0) { // Check if it's a standard Nostr message if (["EVENT", "REQ", "CLOSE"].includes(parsed[0])) { const verb = parsed[0]; switch (verb) { case "EVENT": if (parsed.length !== 2) { DEBUG && console.log(`[ ${this._sid} ]`, "EVENT message missing params:", parsed); return this.send([ "NOTICE", "invalid: EVENT message missing params", ]); } return this._onevent(parsed[1]); case "REQ": if (parsed.length < 2) { DEBUG && console.log(`[ ${this._sid} ]`, "REQ message missing params:", parsed); return this.send([ "NOTICE", "invalid: REQ message missing params", ]); } { const sub_id = parsed[1]; const filters = parsed.slice(2); return this._onreq(sub_id, filters); } case "CLOSE": if (parsed.length !== 2) { DEBUG && console.log(`[ ${this._sid} ]`, "CLOSE message missing params:", parsed); return this.send([ "NOTICE", "invalid: CLOSE message missing params", ]); } return this._onclose(parsed[1]); } } else { // This could be a direct NIP-46 message, broadcast it to other clients try { this.relay.wss.clients.forEach((client) => { if (client !== this.socket && client.readyState === ws_1.WebSocket.OPEN) { client.send(message); } }); return; } catch (e) { DEBUG && console.error("Error broadcasting message:", e); return; } } } this.log.debug("unhandled message format:", message); return this.send(["NOTICE", "Unable to handle message"]); } catch (e) { this.log.debug("failed to parse message:\n\n", message); return this.send(["NOTICE", "Unable to parse message"]); } } _onclose(sub_id) { this.log.info("closed subscription:", sub_id); this.remSub(sub_id); } _onerr(err) { this.log.info("socket encountered an error:\n\n", err); } async _onevent(event) { try { // Special handling for NIP-46 events (kind 24133) if (event.kind === 24133) { // Validate basic structure but with NIP-46 specific validation if (!(await this.validateNIP46Event(event))) { this.log.debug("NIP-46 event failed validation:", event); this.send([ "OK", event.id, false, "NIP-46 event failed validation", ]); return; } this.relay.store(event); // Find subscriptions that match this event for (const [uid, sub] of this.relay.subs.entries()) { for (const filter of sub.filters) { if (filter.kinds?.includes(24133)) { // Check for #p tag filter - safe array access const pTags = event.tags .filter((tag) => { try { return ((0, security_validator_1.validateArrayAccess)(tag, 0) && (0, security_validator_1.safeArrayAccess)(tag, 0) === "p"); } catch { return false; } }) .map((tag) => { try { return (0, security_validator_1.safeArrayAccess)(tag, 1); } catch { return null; } }) .filter((val) => typeof val === "string"); const pFilters = filter["#p"] || []; // If there's a #p filter, make sure the event matches it if (pFilters.length > 0 && !pTags.some((tag) => pFilters.includes(tag))) { continue; } // Send to matching subscription - safe array access try { const uidParts = uid.split("/"); if ((0, security_validator_1.validateArrayAccess)(uidParts, 1)) { const subId = (0, security_validator_1.safeArrayAccess)(uidParts, 1); sub.instance.send([ "EVENT", subId, event, ]); break; } } catch (error) { if (error instanceof security_validator_1.SecurityValidationError) { this.log.debug(`Bounds checking error in subscription routing: ${error.message}`); } continue; } } } } // Send OK message this.send(["OK", event.id, true, ""]); return; } // Standard event processing this.log.client("received event id:", event.id); this.log.debug("event:", event); // Standard event processing - wrap validateEvent in try-catch try { if (!(await (0, event_1.validateEvent)(event))) { this.log.debug("event failed validation (returned false):", event); this.send([ "OK", event.id, false, "event failed validation: validateEvent returned false", ]); return; } } catch (validationError) { // If validateEvent itself throws (e.g. NostrValidationError from getEventHash) let errorMessage = "event validation error"; if (validationError instanceof Error) { errorMessage = validationError.message; } this.log.debug(`event failed validation (threw error): ${errorMessage}`, event); this.send([ "OK", event.id, false, `invalid: ${errorMessage}`, ]); return; } this.send(["OK", event.id, true, ""]); this.relay.store(event); for (const { filters, instance, sub_id } of this.relay.subs.values()) { for (const filter of filters) { if (match_filter(event, filter)) { instance.log.client(`event matched subscription: ${sub_id}`); instance.send(["EVENT", sub_id, event]); } } } } catch (e) { DEBUG && console.error("Error processing event:", e); } } _onreq(sub_id, filters) { if (filters.length === 0) { this.log.client("request has no filters"); return; } this.log.client("received subscription request:", sub_id); this.log.debug("filters:", filters); // Add subscription this.addSub(sub_id, ...filters); // For each filter let count = 0; for (const filter of filters) { // Set the limit count, if any let limitCount = filter.limit; for (const event of this.relay.cache) { // If limit is reached, stop sending events if (limitCount !== undefined && limitCount <= 0) break; // Check if event matches filter if (match_filter(event, filter)) { this.send(["EVENT", sub_id, event]); count++; this.log.client(`event matched in cache: ${event.id}`); this.log.client(`event matched subscription: ${sub_id}`); // Update limit counter if (limitCount !== undefined) limitCount--; } } } DEBUG && this.log.debug(`sent ${count} matching events from cache`); // Send EOSE this.send(["EOSE", sub_id]); } get log() { return { client: (...msg) => VERBOSE && console.log(`[ client ][ ${this._sid} ]`, ...msg), debug: (...msg) => DEBUG && console.log(`[ debug ][ ${this._sid} ]`, ...msg), info: (...msg) => VERBOSE && console.log(`[ info ][ ${this._sid} ]`, ...msg), }; } addSub(sub_id, ...filters) { const uid = `${this.sid}/${sub_id}`; this.relay.subs.set(uid, { filters, instance: this, sub_id }); this._subs.add(sub_id); } remSub(subId) { try { const uid = `${this.sid}/${subId}`; this.relay.subs.delete(uid); this._subs.delete(subId); } catch (e) { // Ignore errors } } send(message) { try { if (this.socket.readyState === ws_1.WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } } catch (e) { DEBUG && console.error(`Failed to send message to client ${this._sid}:`, e); } } // Method to validate NIP-46 events async validateNIP46Event(event) { // Check required fields exist with proper types if (!(0, nip44_1.isValidPublicKeyPoint)(event.pubkey)) { this.log.debug("NIP-46 validation failed: invalid pubkey"); return false; } if (!event.created_at || typeof event.created_at !== "number") { this.log.debug("NIP-46 validation failed: invalid created_at"); return false; } if (event.kind !== 24133) { this.log.debug("NIP-46 validation failed: invalid kind"); return false; } if (!Array.isArray(event.tags)) { this.log.debug("NIP-46 validation failed: invalid tags"); return false; } // For NIP-46, we need to have at least one p tag with a valid pubkey const hasPTag = event.tags.some((tag) => { try { return (Array.isArray(tag) && (0, security_validator_1.validateArrayAccess)(tag, 0) && (0, security_validator_1.validateArrayAccess)(tag, 1) && (0, security_validator_1.safeArrayAccess)(tag, 0) === "p" && typeof (0, security_validator_1.safeArrayAccess)(tag, 1) === "string" && (0, nip44_1.isValidPublicKeyPoint)((0, security_validator_1.safeArrayAccess)(tag, 1))); } catch (error) { // If bounds checking fails, this tag is invalid return false; } }); if (!hasPTag) { // For debugging, log the tags structure this.log.debug("NIP-46 validation failed: no valid p tag found", JSON.stringify(event.tags)); return false; } if (typeof event.content !== "string") { this.log.debug("NIP-46 validation failed: invalid content"); return false; } if (!event.sig || typeof event.sig !== "string" || !isValid64ByteHex(event.sig)) { this.log.debug("NIP-46 validation failed: invalid signature"); return false; } // Verify signature for NIP-46 events using the canonical validateEvent try { if (!(await (0, event_1.validateEvent)(event))) { this.log.debug("NIP-46 validation failed: invalid signature verification"); return false; } } catch (error) { this.log.debug("NIP-46 validation failed: error during signature verification", error); return false; } // Validate event.id: must be 64-char hex (case-insensitive) if (!isValid32ByteHex(event.id)) { this.log.debug("NIP-46 validation failed: invalid id format"); return false; } // For NIP-46, we've passed all the validation checks return true; } } /* ================ [ Methods ] ================ */ function match_filter(event, filter = {}) { const { authors, ids, kinds, since, until, search, ...rest } = filter; // Extract all tag filters from rest const tag_filters = Object.entries(rest) .filter((e) => e[0].startsWith("#")) .map((e) => [e[0].slice(1), ...e[1]]); if (ids !== undefined && !ids.includes(event.id)) { return false; } else if (since !== undefined && event.created_at < since) { return false; } else if (until !== undefined && event.created_at > until) { return false; } else if (authors !== undefined && !authors.includes(event.pubkey)) { return false; } else if (kinds !== undefined && !kinds.includes(event.kind)) { return false; } else if (search !== undefined && search.length > 0) { const query = search.toLowerCase(); const contentMatch = event.content.toLowerCase().includes(query); const tagMatch = event.tags.some((tag) => tag.some((v) => v.toLowerCase().includes(query))); if (!contentMatch && !tagMatch) return false; return tag_filters.length > 0 ? match_tags(tag_filters, event.tags) : true; } else if (tag_filters.length > 0) { return match_tags(tag_filters, event.tags); } else { return true; } } function match_tags(filters, tags) { // For each filter, we need to find at least one match in event tags for (const filter of filters) { let filterMatched = false; // Safe access to filter elements try { if (!(0, security_validator_1.validateArrayAccess)(filter, 0)) { filterMatched = true; // Empty filter matches everything continue; } const key = (0, security_validator_1.safeArrayAccess)(filter, 0); const terms = filter.slice(1); // Skip empty filter terms if (terms.length === 0) { filterMatched = true; continue; } // For each tag that matches the filter key for (const tag of tags) { try { if (!(0, security_validator_1.validateArrayAccess)(tag, 0) || (0, security_validator_1.safeArrayAccess)(tag, 0) !== key) { continue; } const params = tag.slice(1); // For each term in the filter for (const term of terms) { // If any term matches any parameter, this filter condition is satisfied if (params.includes(term)) { filterMatched = true; break; } } // If we found a match for this filter, we can stop checking tags if (filterMatched) break; } catch (error) { // Skip malformed tags continue; } } } catch (error) { // Skip malformed filters continue; } // If no match was found for this filter condition, event doesn't match if (!filterMatched) return false; } // All filter conditions were satisfied return true; }