UNPKG

snstr

Version:

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

502 lines (501 loc) 21.2 kB
"use strict"; /** * Centralized Security Validation Utilities * * This module provides comprehensive security validation for the SNSTR library, * addressing the critical vulnerabilities identified in the security audit. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SecurityValidationError = exports.SECURITY_LIMITS = void 0; exports.secureRandomBytes = secureRandomBytes; exports.secureRandomHex = secureRandomHex; exports.getSecureRandom = getSecureRandom; exports.sanitizeString = sanitizeString; exports.validateArrayAccess = validateArrayAccess; exports.safeArrayAccess = safeArrayAccess; exports.validateNumber = validateNumber; exports.validateEventContent = validateEventContent; exports.validateTags = validateTags; exports.validateFilter = validateFilter; exports.validateFilters = validateFilters; exports.validateEvent = validateEvent; exports.checkRateLimit = checkRateLimit; exports.secureStringZero = secureStringZero; exports.secureBufferZero = secureBufferZero; exports.secureKeyCleanup = secureKeyCleanup; exports.validatePrivateKey = validatePrivateKey; exports.enforceMemoryLimits = enforceMemoryLimits; // Security Constants exports.SECURITY_LIMITS = { // Content size limits (prevent DoS via large payloads) MAX_CONTENT_SIZE: 100000, // 100KB MAX_TAG_SIZE: 1000, MAX_TAG_COUNT: 100, MAX_TAG_ELEMENT_SIZE: 512, // Filter limits (prevent DoS via complex filters) MAX_FILTER_COUNT: 20, MAX_FILTER_IDS: 1000, MAX_FILTER_AUTHORS: 1000, MAX_FILTER_KINDS: 100, MAX_FILTER_TAG_VALUES: 1000, MAX_SEARCH_LENGTH: 500, // Array access safety MAX_ARRAY_SIZE: 10000, MAX_OBJECT_DEPTH: 10, // String limits MAX_STRING_LENGTH: 100000, MAX_URL_LENGTH: 2048, MAX_PUBKEY_LENGTH: 64, MAX_SIGNATURE_LENGTH: 128, MAX_ID_LENGTH: 64, // Numeric limits MIN_KIND: 0, MAX_KIND: 65535, MIN_CREATED_AT: 946684800, // Jan 1, 2000 MAX_CREATED_AT: 4102444800, // Jan 1, 2100 MIN_LIMIT: 0, MAX_LIMIT: 5000, MIN_SINCE: 946684800, // Jan 1, 2000 MAX_SINCE: 4102444800, // Jan 1, 2100 MIN_UNTIL: 946684800, // Jan 1, 2000 MAX_UNTIL: 4102444800, // Jan 1, 2100 // Memory limits for relay buffers (prevent memory exhaustion) MAX_RELAY_EVENT_BUFFERS: 1000, // Maximum number of event buffers per relay MAX_EVENTS_PER_BUFFER: 100, // Maximum events per buffer MAX_REPLACEABLE_EVENT_PUBKEYS: 10000, // Maximum pubkeys to track replaceable events MAX_REPLACEABLE_EVENTS_PER_PUBKEY: 50, // Maximum replaceable events per pubkey MAX_ADDRESSABLE_EVENTS: 50000, // Maximum addressable events to store }; // Security error types class SecurityValidationError extends Error { constructor(message, code, field) { super(message); this.code = code; this.field = field; this.name = "SecurityValidationError"; } } exports.SecurityValidationError = SecurityValidationError; /** * Core secure random generation implementation * Detects available crypto sources and provides unified interface * @returns Object with crypto methods for consistent random generation * @throws SecurityValidationError if no secure random source is available */ function getSecureCrypto() { if (typeof crypto !== "undefined" && crypto.getRandomValues) { // Browser crypto API return { randomBytes: (length) => { const array = new Uint8Array(length); crypto.getRandomValues(array); return array; }, randomUint32: () => { const array = new Uint32Array(1); crypto.getRandomValues(array); return array[0]; }, }; } else if (typeof process !== "undefined" && process.versions && process.versions.node) { // Node.js crypto API (loaded via eval to avoid Metro/Expo static resolution) // Use direct eval so local CommonJS require is accessible under Jest/Node. // eslint-disable-next-line no-eval const nodeCrypto = eval('require("crypto")'); return { randomBytes: (length) => nodeCrypto.randomBytes(length), randomUint32: () => nodeCrypto.randomInt(0, 0x100000000), }; } else { throw new SecurityValidationError("No secure random source available", "NO_SECURE_RANDOM"); } } // Secure random bytes generation function secureRandomBytes(length) { const crypto = getSecureCrypto(); return crypto.randomBytes(length); } /** * Generate a secure random hex string * Used for subscription IDs, nonces, and other cryptographic identifiers * @param length Number of hex characters to generate (not bytes) * @returns A secure random hex string of the specified length * @throws SecurityValidationError if secure random generation is not available * * @example * secureRandomHex(16) // Returns 16-character hex string (8 bytes) * secureRandomHex(64) // Returns 64-character hex string (32 bytes) */ function secureRandomHex(length) { if (typeof length !== "number" || length < 1 || !Number.isInteger(length)) { throw new SecurityValidationError("Length must be a positive integer", "INVALID_LENGTH"); } const bytes = secureRandomBytes(Math.ceil(length / 2)); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")) .join("") .slice(0, length); } /** * Generate a secure random number between 0 and 1 * Used for jitter generation and other timing operations * @returns A secure random float between 0 (inclusive) and 1 (exclusive) * @throws SecurityValidationError if secure random generation is not available */ function getSecureRandom() { const crypto = getSecureCrypto(); return crypto.randomUint32() / (0xffffffff + 1); } // Input sanitization function sanitizeString(input, maxLength = exports.SECURITY_LIMITS.MAX_STRING_LENGTH) { if (typeof input !== "string") { throw new SecurityValidationError("Input must be a string", "INVALID_TYPE", "string"); } if (input.length > maxLength) { throw new SecurityValidationError(`String exceeds maximum length of ${maxLength}`, "STRING_TOO_LONG", "length"); } return input; } // Bounds checking for array access function validateArrayAccess(array, index, context = "array") { if (!Array.isArray(array)) { throw new SecurityValidationError(`${context} must be an array`, "NOT_ARRAY", context); } if (array.length > exports.SECURITY_LIMITS.MAX_ARRAY_SIZE) { throw new SecurityValidationError(`${context} exceeds maximum size of ${exports.SECURITY_LIMITS.MAX_ARRAY_SIZE}`, "ARRAY_TOO_LARGE", context); } if (index < 0 || index >= array.length) { throw new SecurityValidationError(`Array index ${index} out of bounds for ${context} with length ${array.length}`, "INDEX_OUT_OF_BOUNDS", context); } return array[index]; } // Safe array access with bounds checking function safeArrayAccess(array, index, defaultValue) { if (!Array.isArray(array) || index < 0 || index >= array.length) { return defaultValue; } return array[index]; } // Numeric validation function validateNumber(value, min, max, field) { if (typeof value !== "number" || isNaN(value) || !Number.isFinite(value)) { throw new SecurityValidationError(`${field} must be a valid finite number`, "INVALID_NUMBER", field); } if (value < min || value > max) { throw new SecurityValidationError(`${field} must be between ${min} and ${max}`, "NUMBER_OUT_OF_RANGE", field); } return value; } // Event content validation function validateEventContent(content) { const contentStr = sanitizeString(content, exports.SECURITY_LIMITS.MAX_CONTENT_SIZE); // Additional content validation could go here // e.g., checking for suspicious patterns, encoding issues, etc. return contentStr; } // Tag validation function validateTags(tags) { if (!Array.isArray(tags)) { throw new SecurityValidationError("Tags must be an array", "INVALID_TAGS_TYPE", "tags"); } if (tags.length > exports.SECURITY_LIMITS.MAX_TAG_COUNT) { throw new SecurityValidationError(`Too many tags: ${tags.length} (max ${exports.SECURITY_LIMITS.MAX_TAG_COUNT})`, "TOO_MANY_TAGS", "tags"); } return tags.map((tag, tagIndex) => { if (!Array.isArray(tag)) { throw new SecurityValidationError(`Tag at index ${tagIndex} must be an array`, "INVALID_TAG_TYPE", `tags[${tagIndex}]`); } if (tag.length > exports.SECURITY_LIMITS.MAX_TAG_SIZE) { throw new SecurityValidationError(`Tag at index ${tagIndex} has too many elements: ${tag.length} (max ${exports.SECURITY_LIMITS.MAX_TAG_SIZE})`, "TAG_TOO_LARGE", `tags[${tagIndex}]`); } return tag.map((element, _elementIndex) => { const elementStr = sanitizeString(element, exports.SECURITY_LIMITS.MAX_TAG_ELEMENT_SIZE); return elementStr; }); }); } // Filter validation function validateFilter(filter) { if (!filter || typeof filter !== "object") { throw new SecurityValidationError("Filter must be an object", "INVALID_FILTER_TYPE", "filter"); } const f = filter; const validatedFilter = {}; // Validate ids if (f.ids !== undefined) { if (!Array.isArray(f.ids)) { throw new SecurityValidationError("Filter ids must be an array", "INVALID_FILTER_IDS_TYPE", "filter.ids"); } if (f.ids.length > exports.SECURITY_LIMITS.MAX_FILTER_IDS) { throw new SecurityValidationError(`Too many filter ids: ${f.ids.length} (max ${exports.SECURITY_LIMITS.MAX_FILTER_IDS})`, "TOO_MANY_FILTER_IDS", "filter.ids"); } validatedFilter.ids = f.ids.map((id, index) => { const idStr = sanitizeString(id, exports.SECURITY_LIMITS.MAX_ID_LENGTH); if (!/^[0-9a-f]+$/i.test(idStr)) { throw new SecurityValidationError(`Invalid ID format at index ${index}: ${idStr}`, "INVALID_ID_FORMAT", `filter.ids[${index}]`); } return idStr; }); } // Validate authors if (f.authors !== undefined) { if (!Array.isArray(f.authors)) { throw new SecurityValidationError("Filter authors must be an array", "INVALID_FILTER_AUTHORS_TYPE", "filter.authors"); } if (f.authors.length > exports.SECURITY_LIMITS.MAX_FILTER_AUTHORS) { throw new SecurityValidationError(`Too many filter authors: ${f.authors.length} (max ${exports.SECURITY_LIMITS.MAX_FILTER_AUTHORS})`, "TOO_MANY_FILTER_AUTHORS", "filter.authors"); } validatedFilter.authors = f.authors.map((author, index) => { const authorStr = sanitizeString(author, exports.SECURITY_LIMITS.MAX_PUBKEY_LENGTH); if (!/^[0-9a-f]+$/i.test(authorStr)) { throw new SecurityValidationError(`Invalid author format at index ${index}: ${authorStr}`, "INVALID_AUTHOR_FORMAT", `filter.authors[${index}]`); } return authorStr; }); } // Validate kinds if (f.kinds !== undefined) { if (!Array.isArray(f.kinds)) { throw new SecurityValidationError("Filter kinds must be an array", "INVALID_FILTER_KINDS_TYPE", "filter.kinds"); } if (f.kinds.length > exports.SECURITY_LIMITS.MAX_FILTER_KINDS) { throw new SecurityValidationError(`Too many filter kinds: ${f.kinds.length} (max ${exports.SECURITY_LIMITS.MAX_FILTER_KINDS})`, "TOO_MANY_FILTER_KINDS", "filter.kinds"); } validatedFilter.kinds = f.kinds.map((kind, index) => { return validateNumber(kind, exports.SECURITY_LIMITS.MIN_KIND, exports.SECURITY_LIMITS.MAX_KIND, `filter.kinds[${index}]`); }); } // Validate limit if (f.limit !== undefined) { validatedFilter.limit = validateNumber(f.limit, exports.SECURITY_LIMITS.MIN_LIMIT, exports.SECURITY_LIMITS.MAX_LIMIT, "filter.limit"); } // Validate since if (f.since !== undefined) { validatedFilter.since = validateNumber(f.since, exports.SECURITY_LIMITS.MIN_SINCE, exports.SECURITY_LIMITS.MAX_SINCE, "filter.since"); } // Validate until if (f.until !== undefined) { validatedFilter.until = validateNumber(f.until, exports.SECURITY_LIMITS.MIN_UNTIL, exports.SECURITY_LIMITS.MAX_UNTIL, "filter.until"); } // Validate logical relationship between since and until if (validatedFilter.since !== undefined && validatedFilter.until !== undefined) { if (validatedFilter.since >= validatedFilter.until) { throw new SecurityValidationError(`Filter 'since' (${validatedFilter.since}) must be strictly less than 'until' (${validatedFilter.until})`, "INVALID_TIME_RANGE", "filter.since_until"); } } // Validate search if (f.search !== undefined) { validatedFilter.search = sanitizeString(f.search, exports.SECURITY_LIMITS.MAX_SEARCH_LENGTH); } // Validate tag filters (#e, #p, etc.) for (const [key, value] of Object.entries(f)) { if (key.startsWith("#") && key.length === 2) { if (!Array.isArray(value)) { throw new SecurityValidationError(`Filter tag ${key} must be an array`, "INVALID_FILTER_TAG_TYPE", `filter.${key}`); } if (value.length > exports.SECURITY_LIMITS.MAX_FILTER_TAG_VALUES) { throw new SecurityValidationError(`Too many filter tag values for ${key}: ${value.length} (max ${exports.SECURITY_LIMITS.MAX_FILTER_TAG_VALUES})`, "TOO_MANY_FILTER_TAG_VALUES", `filter.${key}`); } validatedFilter[key] = value.map((tagValue, _index) => { return sanitizeString(tagValue, exports.SECURITY_LIMITS.MAX_TAG_ELEMENT_SIZE); }); } } return validatedFilter; } // Validate array of filters function validateFilters(filters) { if (!Array.isArray(filters)) { throw new SecurityValidationError("Filters must be an array", "INVALID_FILTERS_TYPE", "filters"); } if (filters.length > exports.SECURITY_LIMITS.MAX_FILTER_COUNT) { throw new SecurityValidationError(`Too many filters: ${filters.length} (max ${exports.SECURITY_LIMITS.MAX_FILTER_COUNT})`, "TOO_MANY_FILTERS", "filters"); } return filters.map((filter, index) => { try { return validateFilter(filter); } catch (error) { if (error instanceof SecurityValidationError) { throw new SecurityValidationError(`Filter at index ${index}: ${error.message}`, error.code, `filters[${index}].${error.field || "unknown"}`); } throw error; } }); } // Event validation function validateEvent(event) { if (!event || typeof event !== "object") { throw new SecurityValidationError("Event must be an object", "INVALID_EVENT_TYPE", "event"); } const e = event; // Validate required fields const id = sanitizeString(e.id, exports.SECURITY_LIMITS.MAX_ID_LENGTH); const pubkey = sanitizeString(e.pubkey, exports.SECURITY_LIMITS.MAX_PUBKEY_LENGTH); const sig = sanitizeString(e.sig, exports.SECURITY_LIMITS.MAX_SIGNATURE_LENGTH); const content = validateEventContent(e.content); const tags = validateTags(e.tags); const kind = validateNumber(e.kind, exports.SECURITY_LIMITS.MIN_KIND, exports.SECURITY_LIMITS.MAX_KIND, "event.kind"); const created_at = validateNumber(e.created_at, exports.SECURITY_LIMITS.MIN_CREATED_AT, exports.SECURITY_LIMITS.MAX_CREATED_AT, "event.created_at"); // Validate formats if (!/^[0-9a-f]{64}$/i.test(id)) { throw new SecurityValidationError("Event ID must be 64-character hex", "INVALID_EVENT_ID_FORMAT", "event.id"); } if (!/^[0-9a-f]{64}$/i.test(pubkey)) { throw new SecurityValidationError("Event pubkey must be 64-character hex", "INVALID_EVENT_PUBKEY_FORMAT", "event.pubkey"); } if (!/^[0-9a-f]{128}$/i.test(sig)) { throw new SecurityValidationError("Event signature must be 128-character hex", "INVALID_EVENT_SIGNATURE_FORMAT", "event.sig"); } return { id, pubkey, sig, content, tags, kind, created_at, }; } function checkRateLimit(state, limit, windowMs, now = Date.now()) { // Reset window if expired if (now - state.windowStart >= windowMs) { state.count = 0; state.windowStart = now; state.blocked = false; } // Check if blocked if (state.blocked) { const retryAfter = Math.ceil((windowMs - (now - state.windowStart)) / 1000); return { allowed: false, retryAfter }; } // Check limit if (state.count >= limit) { state.blocked = true; const retryAfter = Math.ceil((windowMs - (now - state.windowStart)) / 1000); return { allowed: false, retryAfter }; } // Allow and increment state.count++; return { allowed: true }; } // SECURE KEY ZEROIZATION - Critical for private key security function secureStringZero(str) { // Note: JavaScript strings are immutable, so we can't actually zero them // However, we can minimize their lifetime and suggest garbage collection if (typeof str !== "string") { return; } // Force the string to be processed immediately to trigger potential GC // This is a best-effort approach since JS doesn't provide direct memory control try { // Attempt to trigger garbage collection if available (Node.js) if (typeof global !== "undefined" && global.gc) { global.gc(); } } catch { // GC not available, continue } } // Secure buffer zeroization (adapted from NIP-44) function secureBufferZero(buffer) { if (!buffer || !(buffer instanceof Uint8Array)) { return; } try { // First fill with cryptographically secure random data const secureCrypto = getSecureCrypto(); const randomData = secureCrypto.randomBytes(buffer.length); buffer.set(randomData); // Then overwrite with zeros buffer.fill(0); // Finally, attempt to trigger GC try { if (typeof global !== "undefined" && global.gc) { global.gc(); } } catch { // GC not available, continue } } catch (error) { // Fallback: at least zero the buffer buffer.fill(0); } } // Secure key derivation cleanup function secureKeyCleanup(privateKey) { if (!privateKey || typeof privateKey !== "string") { return; } // Convert to buffer for secure zeroing if possible try { const keyBuffer = new TextEncoder().encode(privateKey); secureBufferZero(keyBuffer); } catch { // Fallback to string zero attempt secureStringZero(privateKey); } } // Memory-conscious private key validator function validatePrivateKey(privateKey, field = "privateKey") { if (typeof privateKey !== "string") { throw new SecurityValidationError("Private key must be a string", "INVALID_PRIVATE_KEY_TYPE", field); } const keyStr = privateKey.trim(); if (keyStr.length !== 64) { throw new SecurityValidationError("Private key must be exactly 64 characters (32 bytes hex)", "INVALID_PRIVATE_KEY_LENGTH", field); } if (!/^[0-9a-f]{64}$/i.test(keyStr)) { throw new SecurityValidationError("Private key must be valid hex (64 characters)", "INVALID_PRIVATE_KEY_FORMAT", field); } return keyStr; } // Memory limits for relay buffers function enforceMemoryLimits(map, maxSize, accessTracker, context = "memory") { if (map.size <= maxSize) { return; } const initialSize = map.size; let removedCount = 0; if (accessTracker && accessTracker.size > 0) { // Remove least recently accessed items first const entries = Array.from(accessTracker.entries()); entries.sort((a, b) => a[1] - b[1]); // Sort by access time (oldest first) for (const [key] of entries) { if (map.size <= maxSize) { break; // Already at target size } if (map.delete(key)) { accessTracker.delete(key); removedCount++; } } } // If still over limit, remove oldest entries from the map (FIFO) if (map.size > maxSize) { const keys = Array.from(map.keys()); for (const key of keys) { if (map.size <= maxSize) { break; // Reached target size } if (map.delete(key)) { // Also remove from accessTracker if it exists if (accessTracker) { accessTracker.delete(key); } removedCount++; } } } if (typeof console !== "undefined" && console.warn && removedCount > 0) { console.warn(`Security: Enforced memory limit for ${context}, removed ${removedCount} entries (${initialSize} -> ${map.size})`); } }