validation.ts raw

   1  /**
   2   * Validation functions for the Distributed Directory Consensus Protocol
   3   * 
   4   * This module provides validation matching the Go implementation in
   5   * pkg/protocol/directory/validation.go
   6   */
   7  
   8  import type { IdentityTag } from './types.js';
   9  import { TrustLevel, KeyPurpose, ReplicationStatus } from './types.js';
  10  
  11  /**
  12   * Validation error class
  13   */
  14  export class ValidationError extends Error {
  15    constructor(message: string) {
  16      super(message);
  17      this.name = 'ValidationError';
  18    }
  19  }
  20  
  21  // Regular expressions for validation
  22  const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
  23  const NPUB_REGEX = /^npub1[0-9a-z]+$/;
  24  const WS_URL_REGEX = /^wss?:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?(?:\/.*)?$/;
  25  
  26  /**
  27   * Validates that a string is a valid 64-character hex key
  28   */
  29  export function validateHexKey(key: string): void {
  30    if (!HEX_KEY_REGEX.test(key)) {
  31      throw new ValidationError('invalid hex key format: must be 64 hex characters');
  32    }
  33  }
  34  
  35  /**
  36   * Validates that a string is a valid npub-encoded public key
  37   */
  38  export function validateNPub(npub: string): void {
  39    if (!NPUB_REGEX.test(npub)) {
  40      throw new ValidationError('invalid npub format');
  41    }
  42    
  43    // Additional validation would require bech32 decoding
  44    // which should be handled by applesauce-core utilities
  45  }
  46  
  47  /**
  48   * Validates that a string is a valid WebSocket URL
  49   */
  50  export function validateWebSocketURL(url: string): void {
  51    if (!WS_URL_REGEX.test(url)) {
  52      throw new ValidationError('invalid WebSocket URL format');
  53    }
  54    
  55    try {
  56      const parsed = new URL(url);
  57      
  58      if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
  59        throw new ValidationError('URL must use ws:// or wss:// scheme');
  60      }
  61      
  62      if (!parsed.host) {
  63        throw new ValidationError('URL must have a host');
  64      }
  65      
  66      // Ensure trailing slash for canonical format
  67      if (!url.endsWith('/')) {
  68        throw new ValidationError('Canonical WebSocket URL must end with /');
  69      }
  70    } catch (err) {
  71      if (err instanceof ValidationError) {
  72        throw err;
  73      }
  74      throw new ValidationError(`invalid URL: ${err instanceof Error ? err.message : String(err)}`);
  75    }
  76  }
  77  
  78  /**
  79   * Validates a nonce meets minimum security requirements
  80   */
  81  export function validateNonce(nonce: string): void {
  82    const MIN_NONCE_SIZE = 16; // bytes
  83    
  84    if (nonce.length < MIN_NONCE_SIZE * 2) { // hex encoding doubles length
  85      throw new ValidationError(`nonce must be at least ${MIN_NONCE_SIZE} bytes (${MIN_NONCE_SIZE * 2} hex characters)`);
  86    }
  87    
  88    if (!/^[0-9a-fA-F]+$/.test(nonce)) {
  89      throw new ValidationError('nonce must be valid hex');
  90    }
  91  }
  92  
  93  /**
  94   * Validates trust level value
  95   */
  96  export function validateTrustLevel(level: string): void {
  97    if (!Object.values(TrustLevel).includes(level as TrustLevel)) {
  98      throw new ValidationError(`invalid trust level: must be one of ${Object.values(TrustLevel).join(', ')}`);
  99    }
 100  }
 101  
 102  /**
 103   * Validates key purpose value
 104   */
 105  export function validateKeyPurpose(purpose: string): void {
 106    if (!Object.values(KeyPurpose).includes(purpose as KeyPurpose)) {
 107      throw new ValidationError(`invalid key purpose: must be one of ${Object.values(KeyPurpose).join(', ')}`);
 108    }
 109  }
 110  
 111  /**
 112   * Validates replication status value
 113   */
 114  export function validateReplicationStatus(status: string): void {
 115    if (!Object.values(ReplicationStatus).includes(status as ReplicationStatus)) {
 116      throw new ValidationError(`invalid replication status: must be one of ${Object.values(ReplicationStatus).join(', ')}`);
 117    }
 118  }
 119  
 120  /**
 121   * Validates confidence value (must be between 0.0 and 1.0)
 122   */
 123  export function validateConfidence(confidence: number): void {
 124    if (confidence < 0.0 || confidence > 1.0) {
 125      throw new ValidationError('confidence must be between 0.0 and 1.0');
 126    }
 127  }
 128  
 129  /**
 130   * Validates an identity tag structure
 131   * 
 132   * Note: This performs structural validation only. Signature verification
 133   * requires cryptographic operations and should be done separately.
 134   */
 135  export function validateIdentityTagStructure(tag: IdentityTag): void {
 136    if (!tag.identity) {
 137      throw new ValidationError('identity tag must have an identity field');
 138    }
 139    
 140    validateHexKey(tag.identity);
 141    
 142    if (!tag.delegate) {
 143      throw new ValidationError('identity tag must have a delegate field');
 144    }
 145    
 146    validateHexKey(tag.delegate);
 147    
 148    if (!tag.signature) {
 149      throw new ValidationError('identity tag must have a signature field');
 150    }
 151    
 152    validateHexKey(tag.signature);
 153    
 154    if (tag.relayHint) {
 155      validateWebSocketURL(tag.relayHint);
 156    }
 157  }
 158  
 159  /**
 160   * Validates event content is valid JSON
 161   */
 162  export function validateJSONContent(content: string): void {
 163    if (!content || content.trim() === '') {
 164      return; // Empty content is valid
 165    }
 166    
 167    try {
 168      JSON.parse(content);
 169    } catch (err) {
 170      throw new ValidationError(`invalid JSON content: ${err instanceof Error ? err.message : String(err)}`);
 171    }
 172  }
 173  
 174  /**
 175   * Validates a timestamp is in the past
 176   */
 177  export function validatePastTimestamp(timestamp: Date | number): void {
 178    const now = Date.now();
 179    const ts = timestamp instanceof Date ? timestamp.getTime() : timestamp * 1000;
 180    
 181    if (ts > now) {
 182      throw new ValidationError('timestamp must be in the past');
 183    }
 184  }
 185  
 186  /**
 187   * Validates a timestamp is in the future
 188   */
 189  export function validateFutureTimestamp(timestamp: Date | number): void {
 190    const now = Date.now();
 191    const ts = timestamp instanceof Date ? timestamp.getTime() : timestamp * 1000;
 192    
 193    if (ts <= now) {
 194      throw new ValidationError('timestamp must be in the future');
 195    }
 196  }
 197  
 198  /**
 199   * Validates an expiry timestamp (must be in the future if provided)
 200   */
 201  export function validateExpiry(expiry?: Date | number): void {
 202    if (expiry === undefined || expiry === null) {
 203      return; // No expiry is valid
 204    }
 205    
 206    validateFutureTimestamp(expiry);
 207  }
 208  
 209  /**
 210   * Validates a BIP32 derivation path
 211   */
 212  export function validateDerivationPath(path: string): void {
 213    // Basic validation - should start with m/ and contain numbers/apostrophes
 214    if (!/^m(\/\d+'?)*$/.test(path)) {
 215      throw new ValidationError('invalid BIP32 derivation path format');
 216    }
 217  }
 218  
 219  /**
 220   * Validates a key index is non-negative
 221   */
 222  export function validateKeyIndex(index: number): void {
 223    if (!Number.isInteger(index) || index < 0) {
 224      throw new ValidationError('key index must be a non-negative integer');
 225    }
 226  }
 227  
 228  /**
 229   * Validates event kinds array is not empty
 230   */
 231  export function validateEventKinds(kinds: number[]): void {
 232    if (!Array.isArray(kinds) || kinds.length === 0) {
 233      throw new ValidationError('event kinds array must not be empty');
 234    }
 235    
 236    for (const kind of kinds) {
 237      if (!Number.isInteger(kind) || kind < 0) {
 238        throw new ValidationError(`invalid event kind: ${kind}`);
 239      }
 240    }
 241  }
 242  
 243  /**
 244   * Validates authors array contains valid pubkeys
 245   */
 246  export function validateAuthors(authors: string[]): void {
 247    if (!Array.isArray(authors)) {
 248      throw new ValidationError('authors must be an array');
 249    }
 250    
 251    for (const author of authors) {
 252      validateHexKey(author);
 253    }
 254  }
 255  
 256  /**
 257   * Validates limit is positive
 258   */
 259  export function validateLimit(limit: number): void {
 260    if (!Number.isInteger(limit) || limit <= 0) {
 261      throw new ValidationError('limit must be a positive integer');
 262    }
 263  }
 264  
 265