@@ -64,19 +64,39 @@ const MIN_PAUSE_THRESHOLD_MS = 1000; // Minimum timer value (1 second)
6464const MAX_REASONABLE_TIMER_MS = 5 * 60 * 1000 ; // Maximum reasonable timer value (5 minutes) to handle clock skew
6565const RX_LOG_LISTEN_WINDOW_MS = 6000 ; // Listen window for repeater echoes (6 seconds)
6666const CHANNEL_GROUP_TEXT_HEADER = 0x15 ; // Header byte for Meshtastic GroupText packets (0x15) - used exclusively for Session Log echo detection
67+ const ADVERT_HEADER = 0x11 ; // Header byte for ADVERT packets (0x11)
68+
69+ // RX Packet Filter Configuration
70+ const MAX_RX_PATH_LENGTH = 9 ; // Maximum path length for RX packets (drop if exceeded to filter corrupted packets)
71+ const RX_ALLOWED_CHANNELS = [ '#wardriving' , '#public' , '#testing' , '#ottawa' ] ; // Allowed channels for RX wardriving
72+ const RX_PRINTABLE_THRESHOLD = 0.80 ; // Minimum printable character ratio for GRP_TXT (80%)
6773
6874// Pre-computed channel hash and key for the wardriving channel
6975// These will be computed once at startup and used for message correlation and decryption
7076let WARDRIVING_CHANNEL_HASH = null ;
7177let WARDRIVING_CHANNEL_KEY = null ;
7278
73- // Initialize the wardriving channel hash and key at startup
79+ // Pre-computed channel hashes and keys for all allowed RX channels
80+ const RX_CHANNEL_MAP = new Map ( ) ; // Map<channelHash, {name, key}>
81+
82+ // Initialize channel hashes and keys at startup
7483( async function initializeChannelHash ( ) {
7584 try {
85+ // Initialize wardriving channel (for TX tracking)
7686 WARDRIVING_CHANNEL_KEY = await deriveChannelKey ( CHANNEL_NAME ) ;
7787 WARDRIVING_CHANNEL_HASH = await computeChannelHash ( WARDRIVING_CHANNEL_KEY ) ;
7888 debugLog ( `[INIT] Wardriving channel hash pre-computed at startup: 0x${ WARDRIVING_CHANNEL_HASH . toString ( 16 ) . padStart ( 2 , '0' ) } ` ) ;
7989 debugLog ( `[INIT] Wardriving channel key cached for message decryption (${ WARDRIVING_CHANNEL_KEY . length } bytes)` ) ;
90+
91+ // Initialize all allowed RX channels
92+ debugLog ( `[INIT] Pre-computing hashes/keys for ${ RX_ALLOWED_CHANNELS . length } allowed RX channels...` ) ;
93+ for ( const channelName of RX_ALLOWED_CHANNELS ) {
94+ const key = await deriveChannelKey ( channelName ) ;
95+ const hash = await computeChannelHash ( key ) ;
96+ RX_CHANNEL_MAP . set ( hash , { name : channelName , key : key } ) ;
97+ debugLog ( `[INIT] ${ channelName } -> hash=0x${ hash . toString ( 16 ) . padStart ( 2 , '0' ) } ` ) ;
98+ }
99+ debugLog ( `[INIT] ✅ All RX channel hashes/keys initialized successfully` ) ;
80100 } catch ( error ) {
81101 debugError ( `[INIT] CRITICAL: Failed to pre-compute channel hash/key: ${ error . message } ` ) ;
82102 debugError ( `[INIT] Repeater echo tracking will be disabled. Please reload the page.` ) ;
@@ -199,6 +219,7 @@ const txLogState = {
199219// RX log state (passive observations)
200220const rxLogState = {
201221 entries : [ ] , // Array of parsed RX log entries
222+ dropCount : 0 , // Count of dropped/filtered packets
202223 isExpanded : false ,
203224 autoScroll : true ,
204225 maxEntries : 100 // Limit to prevent memory issues
@@ -2004,6 +2025,177 @@ async function decryptGroupTextPayload(payload, channelKey) {
20042025 }
20052026}
20062027
2028+ /**
2029+ * Check if a string is printable ASCII (basic ASCII only, no extended chars)
2030+ * @param {string } str - String to check
2031+ * @returns {boolean } True if all characters are printable ASCII (32-126)
2032+ */
2033+ function isStrictAscii ( str ) {
2034+ for ( let i = 0 ; i < str . length ; i ++ ) {
2035+ const code = str . charCodeAt ( i ) ;
2036+ if ( code < 32 || code > 126 ) {
2037+ return false ;
2038+ }
2039+ }
2040+ return true ;
2041+ }
2042+
2043+ /**
2044+ * Calculate ratio of printable characters in a string
2045+ * @param {string } str - String to analyze
2046+ * @returns {number } Ratio of printable chars (0.0 to 1.0)
2047+ */
2048+ function getPrintableRatio ( str ) {
2049+ if ( str . length === 0 ) return 0 ;
2050+ let printableCount = 0 ;
2051+ for ( let i = 0 ; i < str . length ; i ++ ) {
2052+ const code = str . charCodeAt ( i ) ;
2053+ // Printable: ASCII 32-126 or common whitespace (9=tab, 10=newline, 13=CR)
2054+ if ( ( code >= 32 && code <= 126 ) || code === 9 || code === 10 || code === 13 ) {
2055+ printableCount ++ ;
2056+ }
2057+ }
2058+ return printableCount / str . length ;
2059+ }
2060+
2061+ /**
2062+ * Parse and validate ADVERT packet name field
2063+ * @param {Uint8Array } payload - Encrypted payload from metadata
2064+ * @returns {Object } {valid: boolean, name: string, reason: string}
2065+ */
2066+ function parseAdvertName ( payload ) {
2067+ try {
2068+ // ADVERT structure: [32 bytes pubkey][4 bytes timestamp][64 bytes signature][1 byte flags][name...]
2069+ const PUBKEY_SIZE = 32 ;
2070+ const TIMESTAMP_SIZE = 4 ;
2071+ const SIGNATURE_SIZE = 64 ;
2072+ const FLAGS_SIZE = 1 ;
2073+ const NAME_OFFSET = PUBKEY_SIZE + TIMESTAMP_SIZE + SIGNATURE_SIZE + FLAGS_SIZE ;
2074+
2075+ if ( payload . length <= NAME_OFFSET ) {
2076+ return { valid : false , name : '' , reason : 'payload too short for name' } ;
2077+ }
2078+
2079+ const nameBytes = payload . slice ( NAME_OFFSET ) ;
2080+ const decoder = new TextDecoder ( 'utf-8' , { fatal : false } ) ;
2081+ const name = decoder . decode ( nameBytes ) . replace ( / \0 + $ / , '' ) . trim ( ) ;
2082+
2083+ debugLog ( `[RX FILTER] ADVERT name extracted: "${ name } " (${ name . length } chars)` ) ;
2084+
2085+ if ( name . length === 0 ) {
2086+ return { valid : false , name : '' , reason : 'name empty' } ;
2087+ }
2088+
2089+ // Check if name is printable
2090+ const printableRatio = getPrintableRatio ( name ) ;
2091+ debugLog ( `[RX FILTER] ADVERT name printable ratio: ${ ( printableRatio * 100 ) . toFixed ( 1 ) } %` ) ;
2092+
2093+ if ( printableRatio < 0.9 ) {
2094+ return { valid : false , name : name , reason : 'name not printable' } ;
2095+ }
2096+
2097+ // Check strict ASCII (no extended characters)
2098+ if ( ! isStrictAscii ( name ) ) {
2099+ return { valid : false , name : name , reason : 'name contains non-ASCII chars' } ;
2100+ }
2101+
2102+ return { valid : true , name : name , reason : 'kept' } ;
2103+
2104+ } catch ( error ) {
2105+ debugError ( `[RX FILTER] Error parsing ADVERT name: ${ error . message } ` ) ;
2106+ return { valid : false , name : '' , reason : 'parse error' } ;
2107+ }
2108+ }
2109+
2110+ /**
2111+ * Validate RX packet for wardriving logging
2112+ * @param {Object } metadata - Parsed metadata from parseRxPacketMetadata()
2113+ * @returns {Promise<Object> } {valid: boolean, reason: string, channelName?: string, plaintext?: string}
2114+ */
2115+ async function validateRxPacket ( metadata ) {
2116+ try {
2117+ // Log raw packet for debugging
2118+ const rawHex = Array . from ( metadata . raw ) . map ( b => b . toString ( 16 ) . padStart ( 2 , '0' ) . toUpperCase ( ) ) . join ( ' ' ) ;
2119+ debugLog ( `[RX FILTER] ========== VALIDATING PACKET ==========` ) ;
2120+ debugLog ( `[RX FILTER] Raw packet (${ metadata . raw . length } bytes): ${ rawHex } ` ) ;
2121+ debugLog ( `[RX FILTER] Header: 0x${ metadata . header . toString ( 16 ) . padStart ( 2 , '0' ) } | PathLength: ${ metadata . pathLength } | SNR: ${ metadata . snr } ` ) ;
2122+
2123+ // VALIDATION 1: Check path length
2124+ if ( metadata . pathLength > MAX_RX_PATH_LENGTH ) {
2125+ debugLog ( `[RX FILTER] ❌ DROPPED: pathLen>${ MAX_RX_PATH_LENGTH } (${ metadata . pathLength } hops)` ) ;
2126+ return { valid : false , reason : `pathLen>${ MAX_RX_PATH_LENGTH } ` } ;
2127+ }
2128+ debugLog ( `[RX FILTER] ✓ Path length OK (${ metadata . pathLength } ≤ ${ MAX_RX_PATH_LENGTH } )` ) ;
2129+
2130+ // VALIDATION 2: Check packet type (only ADVERT and GRP_TXT)
2131+ if ( metadata . header === CHANNEL_GROUP_TEXT_HEADER ) {
2132+ debugLog ( `[RX FILTER] Packet type: GRP_TXT (0x15)` ) ;
2133+
2134+ // GRP_TXT validation
2135+ if ( metadata . encryptedPayload . length < 3 ) {
2136+ debugLog ( `[RX FILTER] ❌ DROPPED: GRP_TXT payload too short (${ metadata . encryptedPayload . length } bytes)` ) ;
2137+ return { valid : false , reason : 'payload too short' } ;
2138+ }
2139+
2140+ const channelHash = metadata . encryptedPayload [ 0 ] ;
2141+ debugLog ( `[RX FILTER] Channel hash: 0x${ channelHash . toString ( 16 ) . padStart ( 2 , '0' ) } ` ) ;
2142+
2143+ // Check if channel is in allowed list
2144+ const channelInfo = RX_CHANNEL_MAP . get ( channelHash ) ;
2145+ if ( ! channelInfo ) {
2146+ debugLog ( `[RX FILTER] ❌ DROPPED: Unknown channel hash 0x${ channelHash . toString ( 16 ) . padStart ( 2 , '0' ) } ` ) ;
2147+ return { valid : false , reason : 'unknown channel hash' } ;
2148+ }
2149+
2150+ debugLog ( `[RX FILTER] ✓ Channel matched: ${ channelInfo . name } ` ) ;
2151+
2152+ // Decrypt message
2153+ const plaintext = await decryptGroupTextPayload ( metadata . encryptedPayload , channelInfo . key ) ;
2154+ if ( ! plaintext ) {
2155+ debugLog ( `[RX FILTER] ❌ DROPPED: Decryption failed` ) ;
2156+ return { valid : false , reason : 'decrypt failed' } ;
2157+ }
2158+
2159+ debugLog ( `[RX FILTER] Decrypted message (${ plaintext . length } chars): "${ plaintext . substring ( 0 , 60 ) } ${ plaintext . length > 60 ? '...' : '' } "}` ) ;
2160+
2161+ // Check printable ratio
2162+ const printableRatio = getPrintableRatio ( plaintext ) ;
2163+ debugLog ( `[RX FILTER] Printable ratio: ${ ( printableRatio * 100 ) . toFixed ( 1 ) } % (threshold: ${ ( RX_PRINTABLE_THRESHOLD * 100 ) . toFixed ( 1 ) } %)` ) ;
2164+
2165+ if ( printableRatio < RX_PRINTABLE_THRESHOLD ) {
2166+ debugLog ( `[RX FILTER] ❌ DROPPED: plaintext not printable` ) ;
2167+ return { valid : false , reason : 'plaintext not printable' } ;
2168+ }
2169+
2170+ debugLog ( `[RX FILTER] ✅ KEPT: GRP_TXT passed all validations` ) ;
2171+ return { valid : true , reason : 'kept' , channelName : channelInfo . name , plaintext : plaintext } ;
2172+
2173+ } else if ( metadata . header === ADVERT_HEADER ) {
2174+ debugLog ( `[RX FILTER] Packet type: ADVERT (0x11)` ) ;
2175+
2176+ // ADVERT validation
2177+ const nameResult = parseAdvertName ( metadata . encryptedPayload ) ;
2178+
2179+ if ( ! nameResult . valid ) {
2180+ debugLog ( `[RX FILTER] ❌ DROPPED: ${ nameResult . reason } ` ) ;
2181+ return { valid : false , reason : nameResult . reason } ;
2182+ }
2183+
2184+ debugLog ( `[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${ nameResult . name } ")` ) ;
2185+ return { valid : true , reason : 'kept' } ;
2186+
2187+ } else {
2188+ // Unsupported packet type
2189+ debugLog ( `[RX FILTER] ❌ DROPPED: unsupported ptype (header=0x${ metadata . header . toString ( 16 ) . padStart ( 2 , '0' ) } )` ) ;
2190+ return { valid : false , reason : 'unsupported ptype' } ;
2191+ }
2192+
2193+ } catch ( error ) {
2194+ debugError ( `[RX FILTER] ❌ Validation error: ${ error . message } ` ) ;
2195+ return { valid : false , reason : 'validation error' } ;
2196+ }
2197+ }
2198+
20072199/**
20082200 * Start listening for repeater echoes via rx_log
20092201 * Uses the pre-computed WARDRIVING_CHANNEL_HASH for message correlation
@@ -2289,21 +2481,37 @@ async function handleRxLogging(metadata, data) {
22892481 // Packets with no path are direct transmissions (node-to-node) and don't provide
22902482 // information about repeater coverage, so we skip them for RX wardriving purposes.
22912483 if ( metadata . pathLength === 0 ) {
2484+ rxLogState . dropCount ++ ;
2485+ updateRxLogSummary ( ) ;
22922486 debugLog ( `[RX LOG] Ignoring: no path (direct transmission, not via repeater)` ) ;
22932487 return ;
22942488 }
22952489
2490+ // Get current GPS location (must have GPS before further validation)
2491+ if ( ! state . lastFix ) {
2492+ rxLogState . dropCount ++ ;
2493+ updateRxLogSummary ( ) ;
2494+ debugLog ( `[RX LOG] No GPS fix available, skipping entry` ) ;
2495+ return ;
2496+ }
2497+
2498+ // PACKET FILTER: Validate packet before logging
2499+ const validation = await validateRxPacket ( metadata ) ;
2500+ if ( ! validation . valid ) {
2501+ rxLogState . dropCount ++ ;
2502+ updateRxLogSummary ( ) ;
2503+ const rawHex = Array . from ( metadata . raw ) . map ( b => b . toString ( 16 ) . padStart ( 2 , '0' ) . toUpperCase ( ) ) . join ( ' ' ) ;
2504+ debugLog ( `[RX LOG] ❌ Packet dropped: ${ validation . reason } ` ) ;
2505+ debugLog ( `[RX LOG] Dropped packet hex: ${ rawHex } ` ) ;
2506+ return ;
2507+ }
2508+
22962509 // Extract LAST hop from path (the repeater that directly delivered to us)
22972510 const lastHopId = metadata . lastHop ;
22982511 const repeaterId = lastHopId . toString ( 16 ) . padStart ( 2 , '0' ) ;
22992512
23002513 debugLog ( `[RX LOG] Packet heard via last hop: ${ repeaterId } , SNR=${ metadata . snr } , path_length=${ metadata . pathLength } ` ) ;
2301-
2302- // Get current GPS location
2303- if ( ! state . lastFix ) {
2304- debugLog ( `[RX LOG] No GPS fix available, skipping entry` ) ;
2305- return ;
2306- }
2514+ debugLog ( `[RX LOG] ✅ Packet validated and passed filter` ) ;
23072515
23082516 const lat = state . lastFix . lat ;
23092517 const lon = state . lastFix . lon ;
@@ -2945,7 +3153,9 @@ function updateRxLogSummary() {
29453153 if ( ! rxLogCount || ! rxLogLastTime || ! rxLogLastRepeater ) return ;
29463154
29473155 const count = rxLogState . entries . length ;
2948- rxLogCount . textContent = count === 1 ? '1 observation' : `${ count } observations` ;
3156+ const dropText = `${ rxLogState . dropCount } dropped` ;
3157+ const obsText = count === 1 ? '1 observation' : `${ count } observations` ;
3158+ rxLogCount . textContent = `${ obsText } , ${ dropText } ` ;
29493159
29503160 if ( count === 0 ) {
29513161 rxLogLastTime . textContent = 'No data' ;
@@ -4167,6 +4377,7 @@ async function connect() {
41674377 updateTxLogSummary ( ) ;
41684378
41694379 rxLogState . entries = [ ] ;
4380+ rxLogState . dropCount = 0 ;
41704381 renderRxLogEntries ( true ) ;
41714382 updateRxLogSummary ( ) ;
41724383
0 commit comments