snstr
Version:
Secure Nostr Software Toolkit for Renegades - A comprehensive TypeScript library for Nostr protocol implementation
502 lines (501 loc) • 21.2 kB
JavaScript
;
/**
* 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})`);
}
}