From 75c3c6b614e569a34a5c05668c11fa5c1ece3c06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:41:14 +0000 Subject: [PATCH 1/8] Initial plan From 408e8f3b415c2364e7bf66129334d0ff94af306c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:46:54 +0000 Subject: [PATCH 2/8] Add channel ping repeater echo tracking feature Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 227 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 220 insertions(+), 7 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index bc9596e..2a5bb51 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -5,7 +5,7 @@ // - Manual "Send Ping" and Auto mode (interval selectable: 15/30/60s) // - Acquire wake lock during auto mode to keep screen awake -import { WebBleConnection } from "./mc/index.js"; // your BLE client +import { WebBleConnection, Constants, Packet } from "./mc/index.js"; // your BLE client // ---- Debug Configuration ---- // Enable debug logging via URL parameter (?debug=true) or set default here @@ -44,6 +44,7 @@ const STATUS_UPDATE_DELAY_MS = 100; // Brief delay to ensure "Ping se const MAP_REFRESH_DELAY_MS = 1000; // Delay after API post to ensure backend updated const MIN_PAUSE_THRESHOLD_MS = 1000; // Minimum timer value (1 second) to pause const MAX_REASONABLE_TIMER_MS = 5 * 60 * 1000; // Maximum reasonable timer value (5 minutes) to handle clock skew +const RX_LOG_LISTEN_WINDOW_MS = 7000; // Listen window for repeater echoes (7 seconds) // Ottawa Geofence Configuration const OTTAWA_CENTER_LAT = 45.4215; // Parliament Hill latitude @@ -100,7 +101,15 @@ const state = { skipReason: null, // Reason for skipping a ping - internal value only (e.g., "gps too old") pausedAutoTimerRemainingMs: null, // Remaining time when auto ping timer was paused by manual ping lastSuccessfulPingLocation: null, // { lat, lon } of the last successful ping (Mesh + API) - distanceUpdateTimer: null // Timer for updating distance display + distanceUpdateTimer: null, // Timer for updating distance display + repeaterTracking: { + isListening: false, // Whether we're currently listening for echoes + sentTimestamp: null, // Timestamp when the ping was sent + sentPayload: null, // The payload text that was sent + repeaters: new Map(), // Map + listenTimeout: null, // Timeout handle for 7-second window + rxLogHandler: null, // Handler function for rx_log events + } }; // ---- UI helpers ---- @@ -972,6 +981,169 @@ function scheduleApiPostAndMapRefresh(lat, lon, accuracy) { }, MESHMAPPER_DELAY_MS); } +// ---- Repeater Echo Tracking ---- + +/** + * Start listening for repeater echoes via rx_log + * @param {string} payload - The ping payload that was sent + * @param {number} channelIdx - The channel index where the ping was sent + */ +function startRepeaterTracking(payload, channelIdx) { + debugLog(`Starting repeater echo tracking for ping: "${payload}" on channel ${channelIdx}`); + debugLog(`7-second rx_log listening window opened at ${new Date().toISOString()}`); + + // Clear any existing tracking state + stopRepeaterTracking(); + + // Initialize tracking state + state.repeaterTracking.isListening = true; + state.repeaterTracking.sentTimestamp = Date.now(); + state.repeaterTracking.sentPayload = payload; + state.repeaterTracking.repeaters.clear(); + + // Create the rx_log handler + const rxLogHandler = (data) => { + handleRxLogEvent(data, payload, channelIdx); + }; + + // Store the handler so we can remove it later + state.repeaterTracking.rxLogHandler = rxLogHandler; + + // Listen for rx_log events + if (state.connection) { + state.connection.on(Constants.PushCodes.LogRxData, rxLogHandler); + debugLog(`Registered LogRxData event handler`); + } + + // Set timeout to stop listening after 7 seconds + state.repeaterTracking.listenTimeout = setTimeout(() => { + debugLog(`7-second rx_log listening window closed at ${new Date().toISOString()}`); + stopRepeaterTracking(); + }, RX_LOG_LISTEN_WINDOW_MS); +} + +/** + * Handle an rx_log event and check if it's a repeater echo of our ping + * @param {Object} data - The LogRxData event data (contains lastSnr, lastRssi, raw) + * @param {string} originalPayload - The payload we sent + * @param {number} channelIdx - The channel index where we sent the ping + */ +function handleRxLogEvent(data, originalPayload, channelIdx) { + try { + debugLog(`Received rx_log entry: SNR=${data.lastSnr}, RSSI=${data.lastRssi}`); + + // Parse the packet from raw data + const packet = Packet.fromBytes(data.raw); + + debugLog(`Parsed packet: route_type=${packet.route_type_string}, payload_type=${packet.payload_type_string}, path_len=${packet.path.length}`); + + // Check if this is a channel message (GRP_TXT) + if (packet.payload_type !== Packet.PAYLOAD_TYPE_GRP_TXT) { + debugLog(`Ignoring rx_log entry: not a channel message (payload_type=${packet.payload_type})`); + return; + } + + // For channel messages, the path contains repeater hops + // Each hop in the path is 1 byte (repeater ID) + if (packet.path.length === 0) { + debugLog(`Ignoring rx_log entry: no path (direct transmission, not a repeater echo)`); + return; + } + + // Extract repeater ID from the last hop in the path (most recent repeater) + // The path is a Uint8Array where each byte represents a hop + const lastHopIndex = packet.path.length - 1; + const repeaterId = packet.path[lastHopIndex].toString(16).padStart(2, '0'); + + debugLog(`Detected potential repeater echo: repeaterId=${repeaterId}, SNR=${data.lastSnr}, path_length=${packet.path.length}`); + + // Check if we already have this repeater + if (state.repeaterTracking.repeaters.has(repeaterId)) { + const existing = state.repeaterTracking.repeaters.get(repeaterId); + debugLog(`Repeater ${repeaterId} already seen (existing SNR=${existing.snr}, new SNR=${data.lastSnr})`); + + // Keep the best (highest) SNR + if (data.lastSnr > existing.snr) { + debugLog(`Updating repeater ${repeaterId} with better SNR: ${existing.snr} -> ${data.lastSnr}`); + state.repeaterTracking.repeaters.set(repeaterId, { + snr: data.lastSnr, + seenCount: existing.seenCount + 1 + }); + } else { + debugLog(`Keeping existing SNR for repeater ${repeaterId} (existing ${existing.snr} >= new ${data.lastSnr})`); + // Still increment seen count + existing.seenCount++; + } + } else { + // New repeater + debugLog(`Adding new repeater echo: repeaterId=${repeaterId}, SNR=${data.lastSnr}`); + state.repeaterTracking.repeaters.set(repeaterId, { + snr: data.lastSnr, + seenCount: 1 + }); + } + } catch (error) { + debugError(`Error processing rx_log entry: ${error.message}`, error); + } +} + +/** + * Stop listening for repeater echoes and return the results + * @returns {Array<{repeaterId: string, snr: number}>} Array of repeater telemetry + */ +function stopRepeaterTracking() { + if (!state.repeaterTracking.isListening) { + return []; + } + + debugLog(`Stopping repeater echo tracking`); + + // Stop listening for rx_log events + if (state.connection && state.repeaterTracking.rxLogHandler) { + state.connection.off(Constants.PushCodes.LogRxData, state.repeaterTracking.rxLogHandler); + debugLog(`Unregistered LogRxData event handler`); + } + + // Clear timeout + if (state.repeaterTracking.listenTimeout) { + clearTimeout(state.repeaterTracking.listenTimeout); + state.repeaterTracking.listenTimeout = null; + } + + // Get the results + const repeaters = Array.from(state.repeaterTracking.repeaters.entries()).map(([id, data]) => ({ + repeaterId: id, + snr: data.snr + })); + + // Sort by repeater ID for deterministic output + repeaters.sort((a, b) => a.repeaterId.localeCompare(b.repeaterId)); + + debugLog(`Final aggregated repeater list: ${repeaters.length > 0 ? repeaters.map(r => `${r.repeaterId}(${r.snr}dB)`).join(', ') : 'none'}`); + + // Reset state + state.repeaterTracking.isListening = false; + state.repeaterTracking.sentTimestamp = null; + state.repeaterTracking.sentPayload = null; + state.repeaterTracking.repeaters.clear(); + state.repeaterTracking.rxLogHandler = null; + + return repeaters; +} + +/** + * Format repeater telemetry for output + * @param {Array<{repeaterId: string, snr: number}>} repeaters - Array of repeater telemetry + * @returns {string} Formatted repeater string (e.g., "a0(-112),b3(-109)" or "none") + */ +function formatRepeaterTelemetry(repeaters) { + if (repeaters.length === 0) { + return "none"; + } + + return repeaters.map(r => `${r.repeaterId}(${Math.round(r.snr)})`).join(','); +} + // ---- Ping ---- /** * Acquire fresh GPS coordinates and update state @@ -1087,26 +1259,55 @@ async function getGpsCoordinatesForPing(isAutoMode) { } /** - * Log ping information to the UI + * Log ping information to the UI with repeater telemetry + * Creates a session log entry that will be updated with repeater data * @param {string} payload - The ping message * @param {number} lat - Latitude * @param {number} lon - Longitude + * @returns {HTMLElement|null} The list item element for later updates, or null */ function logPingToUI(payload, lat, lon) { - const nowStr = new Date().toLocaleString(); + const nowStr = new Date().toISOString(); if (lastPingEl) { lastPingEl.textContent = `${nowStr} — ${payload}`; } if (sessionPingsEl) { - const line = `${nowStr} ${lat.toFixed(5)} ${lon.toFixed(5)}`; + // Create log entry with placeholder for repeater data + // Format: timestamp | lat,lon | repeaters + const line = `${nowStr} | ${lat.toFixed(5)},${lon.toFixed(5)} | ...`; const li = document.createElement('li'); li.textContent = line; + li.setAttribute('data-timestamp', nowStr); + li.setAttribute('data-lat', lat.toFixed(5)); + li.setAttribute('data-lon', lon.toFixed(5)); sessionPingsEl.appendChild(li); // Auto-scroll to bottom sessionPingsEl.scrollTop = sessionPingsEl.scrollHeight; + return li; } + + return null; +} + +/** + * Update a ping log entry with repeater telemetry + * @param {HTMLElement|null} logEntry - The log entry element to update + * @param {Array<{repeaterId: string, snr: number}>} repeaters - Array of repeater telemetry + */ +function updatePingLogWithRepeaters(logEntry, repeaters) { + if (!logEntry) return; + + const timestamp = logEntry.getAttribute('data-timestamp'); + const lat = logEntry.getAttribute('data-lat'); + const lon = logEntry.getAttribute('data-lon'); + const repeaterStr = formatRepeaterTelemetry(repeaters); + + // Update the log entry with final repeater data + logEntry.textContent = `${timestamp} | ${lat},${lon} | ${repeaterStr}`; + + debugLog(`Updated ping log entry with repeater telemetry: ${repeaterStr}`); } /** @@ -1199,6 +1400,11 @@ async function sendPing(manual = false) { debugLog(`Sending ping to channel: "${payload}"`); const ch = await ensureChannel(); + + // Start repeater echo tracking BEFORE sending the ping + debugLog(`Channel ping transmission: timestamp=${new Date().toISOString()}, channel=${ch.channelIdx}, payload="${payload}"`); + startRepeaterTracking(payload, ch.channelIdx); + await state.connection.sendChannelTextMessage(ch.channelIdx, payload); debugLog(`Ping sent successfully to channel ${ch.channelIdx}`); @@ -1226,8 +1432,14 @@ async function sendPing(manual = false) { // Schedule MeshMapper API post and map refresh scheduleApiPostAndMapRefresh(lat, lon, accuracy); - // Update UI with ping info - logPingToUI(payload, lat, lon); + // Create UI log entry with placeholder for repeater data + const logEntry = logPingToUI(payload, lat, lon); + + // Schedule repeater telemetry update after 7-second window + setTimeout(() => { + const repeaters = stopRepeaterTracking(); + updatePingLogWithRepeaters(logEntry, repeaters); + }, RX_LOG_LISTEN_WINDOW_MS); // Update distance display immediately after successful ping updateDistanceUi(); @@ -1389,6 +1601,7 @@ async function connect() { stopGeoWatch(); stopGpsAgeUpdater(); // Ensure age updater stops stopDistanceUpdater(); // Ensure distance updater stops + stopRepeaterTracking(); // Stop repeater echo tracking // Clean up all timers cleanupAllTimers(); From ad6ae20763e29ec821119de9becb95f2d5f08733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:48:15 +0000 Subject: [PATCH 3/8] Refine repeater path tracking and formatting Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index 2a5bb51..b1072a1 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -1050,34 +1050,36 @@ function handleRxLogEvent(data, originalPayload, channelIdx) { return; } - // Extract repeater ID from the last hop in the path (most recent repeater) - // The path is a Uint8Array where each byte represents a hop - const lastHopIndex = packet.path.length - 1; - const repeaterId = packet.path[lastHopIndex].toString(16).padStart(2, '0'); + // Convert entire path to hex string for the repeater identifier + // This represents the complete path this message took + // Example: path [0x25, 0x21] becomes "2521" + const pathHex = Array.from(packet.path) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); - debugLog(`Detected potential repeater echo: repeaterId=${repeaterId}, SNR=${data.lastSnr}, path_length=${packet.path.length}`); + debugLog(`Detected repeater echo: path=${pathHex}, SNR=${data.lastSnr}, path_length=${packet.path.length}`); - // Check if we already have this repeater - if (state.repeaterTracking.repeaters.has(repeaterId)) { - const existing = state.repeaterTracking.repeaters.get(repeaterId); - debugLog(`Repeater ${repeaterId} already seen (existing SNR=${existing.snr}, new SNR=${data.lastSnr})`); + // Check if we already have this path + if (state.repeaterTracking.repeaters.has(pathHex)) { + const existing = state.repeaterTracking.repeaters.get(pathHex); + debugLog(`Path ${pathHex} already seen (existing SNR=${existing.snr}, new SNR=${data.lastSnr})`); // Keep the best (highest) SNR if (data.lastSnr > existing.snr) { - debugLog(`Updating repeater ${repeaterId} with better SNR: ${existing.snr} -> ${data.lastSnr}`); - state.repeaterTracking.repeaters.set(repeaterId, { + debugLog(`Updating path ${pathHex} with better SNR: ${existing.snr} -> ${data.lastSnr}`); + state.repeaterTracking.repeaters.set(pathHex, { snr: data.lastSnr, seenCount: existing.seenCount + 1 }); } else { - debugLog(`Keeping existing SNR for repeater ${repeaterId} (existing ${existing.snr} >= new ${data.lastSnr})`); + debugLog(`Keeping existing SNR for path ${pathHex} (existing ${existing.snr} >= new ${data.lastSnr})`); // Still increment seen count existing.seenCount++; } } else { - // New repeater - debugLog(`Adding new repeater echo: repeaterId=${repeaterId}, SNR=${data.lastSnr}`); - state.repeaterTracking.repeaters.set(repeaterId, { + // New path + debugLog(`Adding new repeater echo: path=${pathHex}, SNR=${data.lastSnr}`); + state.repeaterTracking.repeaters.set(pathHex, { snr: data.lastSnr, seenCount: 1 }); @@ -1134,13 +1136,14 @@ function stopRepeaterTracking() { /** * Format repeater telemetry for output * @param {Array<{repeaterId: string, snr: number}>} repeaters - Array of repeater telemetry - * @returns {string} Formatted repeater string (e.g., "a0(-112),b3(-109)" or "none") + * @returns {string} Formatted repeater string (e.g., "25(-112),2521(-109)" or "none") */ function formatRepeaterTelemetry(repeaters) { if (repeaters.length === 0) { return "none"; } + // Format as: path(snr), path(snr), ... return repeaters.map(r => `${r.repeaterId}(${Math.round(r.snr)})`).join(','); } From 9a4b70eefeb44e4a2de6cbd75913461eb26eedf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:53:35 +0000 Subject: [PATCH 4/8] Add message correlation via channel hash validation Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 67 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index b1072a1..b43f166 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -46,6 +46,21 @@ const MIN_PAUSE_THRESHOLD_MS = 1000; // Minimum timer value (1 second) const MAX_REASONABLE_TIMER_MS = 5 * 60 * 1000; // Maximum reasonable timer value (5 minutes) to handle clock skew const RX_LOG_LISTEN_WINDOW_MS = 7000; // Listen window for repeater echoes (7 seconds) +// Pre-computed channel hash for the wardriving channel +// This will be computed once at startup and used for message correlation +let WARDRIVING_CHANNEL_HASH = null; + +// Initialize the wardriving channel hash at startup +(async function initializeChannelHash() { + try { + const channelKey = await deriveChannelKey(CHANNEL_NAME); + WARDRIVING_CHANNEL_HASH = await computeChannelHash(channelKey); + debugLog(`Wardriving channel hash pre-computed at startup: 0x${WARDRIVING_CHANNEL_HASH.toString(16).padStart(2, '0')}`); + } catch (error) { + debugError(`Failed to pre-compute channel hash: ${error.message}`); + } +})(); + // Ottawa Geofence Configuration const OTTAWA_CENTER_LAT = 45.4215; // Parliament Hill latitude const OTTAWA_CENTER_LON = -75.6972; // Parliament Hill longitude @@ -106,6 +121,7 @@ const state = { isListening: false, // Whether we're currently listening for echoes sentTimestamp: null, // Timestamp when the ping was sent sentPayload: null, // The payload text that was sent + channelIdx: null, // Channel index for reference repeaters: new Map(), // Map listenTimeout: null, // Timeout handle for 7-second window rxLogHandler: null, // Handler function for rx_log events @@ -983,8 +999,20 @@ function scheduleApiPostAndMapRefresh(lat, lon, accuracy) { // ---- Repeater Echo Tracking ---- +/** + * Compute channel hash from channel secret (first byte of SHA-256) + * @param {Uint8Array} channelSecret - The 16-byte channel secret + * @returns {Promise} The channel hash (first byte of SHA-256) + */ +async function computeChannelHash(channelSecret) { + const hashBuffer = await crypto.subtle.digest('SHA-256', channelSecret); + const hashArray = new Uint8Array(hashBuffer); + return hashArray[0]; +} + /** * Start listening for repeater echoes via rx_log + * Uses the pre-computed WARDRIVING_CHANNEL_HASH for message correlation * @param {string} payload - The ping payload that was sent * @param {number} channelIdx - The channel index where the ping was sent */ @@ -992,18 +1020,27 @@ function startRepeaterTracking(payload, channelIdx) { debugLog(`Starting repeater echo tracking for ping: "${payload}" on channel ${channelIdx}`); debugLog(`7-second rx_log listening window opened at ${new Date().toISOString()}`); + // Verify we have the channel hash + if (WARDRIVING_CHANNEL_HASH === null) { + debugError(`Cannot start repeater tracking: channel hash not initialized`); + return; + } + // Clear any existing tracking state stopRepeaterTracking(); + debugLog(`Using pre-computed channel hash for correlation: 0x${WARDRIVING_CHANNEL_HASH.toString(16).padStart(2, '0')}`); + // Initialize tracking state state.repeaterTracking.isListening = true; state.repeaterTracking.sentTimestamp = Date.now(); state.repeaterTracking.sentPayload = payload; + state.repeaterTracking.channelIdx = channelIdx; state.repeaterTracking.repeaters.clear(); // Create the rx_log handler const rxLogHandler = (data) => { - handleRxLogEvent(data, payload, channelIdx); + handleRxLogEvent(data, payload, channelIdx, WARDRIVING_CHANNEL_HASH); }; // Store the handler so we can remove it later @@ -1027,8 +1064,9 @@ function startRepeaterTracking(payload, channelIdx) { * @param {Object} data - The LogRxData event data (contains lastSnr, lastRssi, raw) * @param {string} originalPayload - The payload we sent * @param {number} channelIdx - The channel index where we sent the ping + * @param {number} expectedChannelHash - The channel hash we expect (for message correlation) */ -function handleRxLogEvent(data, originalPayload, channelIdx) { +function handleRxLogEvent(data, originalPayload, channelIdx, expectedChannelHash) { try { debugLog(`Received rx_log entry: SNR=${data.lastSnr}, RSSI=${data.lastRssi}`); @@ -1043,6 +1081,23 @@ function handleRxLogEvent(data, originalPayload, channelIdx) { return; } + // CRITICAL: Validate this message is for our channel by comparing channel hash + // Channel message payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] + if (packet.payload.length < 3) { + debugLog(`Ignoring rx_log entry: payload too short to contain channel hash`); + return; + } + + const packetChannelHash = packet.payload[0]; + debugLog(`Message correlation check: packet_channel_hash=0x${packetChannelHash.toString(16).padStart(2, '0')}, expected=0x${expectedChannelHash.toString(16).padStart(2, '0')}`); + + if (packetChannelHash !== expectedChannelHash) { + debugLog(`Ignoring rx_log entry: channel hash mismatch (packet=0x${packetChannelHash.toString(16).padStart(2, '0')}, expected=0x${expectedChannelHash.toString(16).padStart(2, '0')})`); + return; + } + + debugLog(`Channel hash match confirmed - this is a message on our channel`); + // For channel messages, the path contains repeater hops // Each hop in the path is 1 byte (repeater ID) if (packet.path.length === 0) { @@ -1057,22 +1112,22 @@ function handleRxLogEvent(data, originalPayload, channelIdx) { .map(byte => byte.toString(16).padStart(2, '0')) .join(''); - debugLog(`Detected repeater echo: path=${pathHex}, SNR=${data.lastSnr}, path_length=${packet.path.length}`); + debugLog(`Repeater echo accepted: path=${pathHex}, SNR=${data.lastSnr}, path_length=${packet.path.length}`); // Check if we already have this path if (state.repeaterTracking.repeaters.has(pathHex)) { const existing = state.repeaterTracking.repeaters.get(pathHex); - debugLog(`Path ${pathHex} already seen (existing SNR=${existing.snr}, new SNR=${data.lastSnr})`); + debugLog(`Deduplication: path ${pathHex} already seen (existing SNR=${existing.snr}, new SNR=${data.lastSnr})`); // Keep the best (highest) SNR if (data.lastSnr > existing.snr) { - debugLog(`Updating path ${pathHex} with better SNR: ${existing.snr} -> ${data.lastSnr}`); + debugLog(`Deduplication decision: updating path ${pathHex} with better SNR: ${existing.snr} -> ${data.lastSnr}`); state.repeaterTracking.repeaters.set(pathHex, { snr: data.lastSnr, seenCount: existing.seenCount + 1 }); } else { - debugLog(`Keeping existing SNR for path ${pathHex} (existing ${existing.snr} >= new ${data.lastSnr})`); + debugLog(`Deduplication decision: keeping existing SNR for path ${pathHex} (existing ${existing.snr} >= new ${data.lastSnr})`); // Still increment seen count existing.seenCount++; } From b7bc4029cbc11c307bbb9ac793711d5b22e2d45d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 03:57:02 +0000 Subject: [PATCH 5/8] Address code review feedback and improve error handling Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index b43f166..5e200b2 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -57,7 +57,9 @@ let WARDRIVING_CHANNEL_HASH = null; WARDRIVING_CHANNEL_HASH = await computeChannelHash(channelKey); debugLog(`Wardriving channel hash pre-computed at startup: 0x${WARDRIVING_CHANNEL_HASH.toString(16).padStart(2, '0')}`); } catch (error) { - debugError(`Failed to pre-compute channel hash: ${error.message}`); + debugError(`CRITICAL: Failed to pre-compute channel hash: ${error.message}`); + debugError(`Repeater echo tracking will be disabled. Please reload the page.`); + // Channel hash remains null, which will be checked before starting tracking } })(); @@ -1199,6 +1201,7 @@ function formatRepeaterTelemetry(repeaters) { } // Format as: path(snr), path(snr), ... + // Round SNR to integers for cleaner output (matches meshcore-cli behavior) return repeaters.map(r => `${r.repeaterId}(${Math.round(r.snr)})`).join(','); } @@ -1325,19 +1328,21 @@ async function getGpsCoordinatesForPing(isAutoMode) { * @returns {HTMLElement|null} The list item element for later updates, or null */ function logPingToUI(payload, lat, lon) { - const nowStr = new Date().toISOString(); + // Use ISO format for data storage but user-friendly format for display + const now = new Date(); + const isoStr = now.toISOString(); if (lastPingEl) { - lastPingEl.textContent = `${nowStr} — ${payload}`; + lastPingEl.textContent = `${now.toLocaleString()} — ${payload}`; } if (sessionPingsEl) { // Create log entry with placeholder for repeater data - // Format: timestamp | lat,lon | repeaters - const line = `${nowStr} | ${lat.toFixed(5)},${lon.toFixed(5)} | ...`; + // Format: timestamp | lat,lon | repeaters (using ISO for consistency with requirements) + const line = `${isoStr} | ${lat.toFixed(5)},${lon.toFixed(5)} | ...`; const li = document.createElement('li'); li.textContent = line; - li.setAttribute('data-timestamp', nowStr); + li.setAttribute('data-timestamp', isoStr); li.setAttribute('data-lat', lat.toFixed(5)); li.setAttribute('data-lon', lon.toFixed(5)); sessionPingsEl.appendChild(li); @@ -1494,11 +1499,18 @@ async function sendPing(manual = false) { const logEntry = logPingToUI(payload, lat, lon); // Schedule repeater telemetry update after 7-second window - setTimeout(() => { + // Store timeout handle for cleanup if needed + const updateTimeout = setTimeout(() => { const repeaters = stopRepeaterTracking(); updatePingLogWithRepeaters(logEntry, repeaters); }, RX_LOG_LISTEN_WINDOW_MS); + // Note: This timeout is intentionally not stored in state because: + // - It's tied to a specific ping and log entry (logEntry) + // - It will complete naturally after 7 seconds + // - stopRepeaterTracking() will be called which cleans up the main listener + // - On disconnect, stopRepeaterTracking() is called directly which stops listening immediately + // Update distance display immediately after successful ping updateDistanceUi(); } catch (e) { From d0db25a9d7235d11b949613eee76c40e2c436316 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:10:46 +0000 Subject: [PATCH 6/8] Add strict header validation per specification (header=0x15) Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index 5e200b2..17a29f0 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -1075,15 +1075,28 @@ function handleRxLogEvent(data, originalPayload, channelIdx, expectedChannelHash // Parse the packet from raw data const packet = Packet.fromBytes(data.raw); - debugLog(`Parsed packet: route_type=${packet.route_type_string}, payload_type=${packet.payload_type_string}, path_len=${packet.path.length}`); + // VALIDATION STEP 1: Header validation (MUST occur before all other checks) + // Expected header for channel GroupText packets: 0x15 + // Binary: 00 0101 01 + // - Bits 0-1: Route Type = 01 (Flood) + // - Bits 2-5: Payload Type = 0101 (GroupText = 5) + // - Bits 6-7: Protocol Version = 00 + const EXPECTED_HEADER = 0x15; + if (packet.header !== EXPECTED_HEADER) { + debugLog(`Ignoring rx_log entry: header validation failed (header=0x${packet.header.toString(16).padStart(2, '0')}, expected=0x${EXPECTED_HEADER.toString(16).padStart(2, '0')})`); + return; + } + + debugLog(`Parsed packet: header=0x${packet.header.toString(16).padStart(2, '0')}, route_type=${packet.route_type_string}, payload_type=${packet.payload_type_string}, path_len=${packet.path.length}`); + debugLog(`Header validation passed: 0x${packet.header.toString(16).padStart(2, '0')}`); - // Check if this is a channel message (GRP_TXT) + // VALIDATION STEP 2: Verify payload type is GRP_TXT (redundant with header check but kept for clarity) if (packet.payload_type !== Packet.PAYLOAD_TYPE_GRP_TXT) { debugLog(`Ignoring rx_log entry: not a channel message (payload_type=${packet.payload_type})`); return; } - // CRITICAL: Validate this message is for our channel by comparing channel hash + // VALIDATION STEP 3: Validate this message is for our channel by comparing channel hash // Channel message payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] if (packet.payload.length < 3) { debugLog(`Ignoring rx_log entry: payload too short to contain channel hash`); From 8d621a57d83f8219a848b5033ea3f16ae9f4c5ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:19:03 +0000 Subject: [PATCH 7/8] Add message content verification with comprehensive debug logging Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 163 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 7 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index 17a29f0..8c9cf8c 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -46,20 +46,22 @@ const MIN_PAUSE_THRESHOLD_MS = 1000; // Minimum timer value (1 second) const MAX_REASONABLE_TIMER_MS = 5 * 60 * 1000; // Maximum reasonable timer value (5 minutes) to handle clock skew const RX_LOG_LISTEN_WINDOW_MS = 7000; // Listen window for repeater echoes (7 seconds) -// Pre-computed channel hash for the wardriving channel -// This will be computed once at startup and used for message correlation +// Pre-computed channel hash and key for the wardriving channel +// These will be computed once at startup and used for message correlation and decryption let WARDRIVING_CHANNEL_HASH = null; +let WARDRIVING_CHANNEL_KEY = null; -// Initialize the wardriving channel hash at startup +// Initialize the wardriving channel hash and key at startup (async function initializeChannelHash() { try { - const channelKey = await deriveChannelKey(CHANNEL_NAME); - WARDRIVING_CHANNEL_HASH = await computeChannelHash(channelKey); + WARDRIVING_CHANNEL_KEY = await deriveChannelKey(CHANNEL_NAME); + WARDRIVING_CHANNEL_HASH = await computeChannelHash(WARDRIVING_CHANNEL_KEY); debugLog(`Wardriving channel hash pre-computed at startup: 0x${WARDRIVING_CHANNEL_HASH.toString(16).padStart(2, '0')}`); + debugLog(`Wardriving channel key cached for message decryption (${WARDRIVING_CHANNEL_KEY.length} bytes)`); } catch (error) { - debugError(`CRITICAL: Failed to pre-compute channel hash: ${error.message}`); + debugError(`CRITICAL: Failed to pre-compute channel hash/key: ${error.message}`); debugError(`Repeater echo tracking will be disabled. Please reload the page.`); - // Channel hash remains null, which will be checked before starting tracking + // Channel hash and key remain null, which will be checked before starting tracking } })(); @@ -1012,6 +1014,122 @@ async function computeChannelHash(channelSecret) { return hashArray[0]; } +/** + * Decrypt GroupText payload and extract message text + * Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted data] + * Encrypted data: [4 bytes timestamp][1 byte flags][message text] + * @param {Uint8Array} payload - The packet payload + * @param {Uint8Array} channelKey - The 16-byte channel secret for decryption + * @returns {Promise} The decrypted message text, or null if decryption fails + */ +async function decryptGroupTextPayload(payload, channelKey) { + try { + debugLog(`[DECRYPT] Starting GroupText payload decryption`); + debugLog(`[DECRYPT] Payload length: ${payload.length} bytes`); + debugLog(`[DECRYPT] Channel key length: ${channelKey.length} bytes`); + + // Validate payload length + if (payload.length < 3) { + debugLog(`[DECRYPT] ABORT: Payload too short for decryption (${payload.length} bytes, need at least 3)`); + return null; + } + + // Extract components + const channelHash = payload[0]; + const cipherMAC = payload.slice(1, 3); + const encryptedData = payload.slice(3); + + debugLog(`[DECRYPT] Channel hash: 0x${channelHash.toString(16).padStart(2, '0')}`); + debugLog(`[DECRYPT] Cipher MAC: ${Array.from(cipherMAC).map(b => b.toString(16).padStart(2, '0')).join('')}`); + debugLog(`[DECRYPT] Encrypted data length: ${encryptedData.length} bytes`); + + if (encryptedData.length === 0) { + debugLog(`[DECRYPT] ABORT: No encrypted data to decrypt`); + return null; + } + + // Log first 32 bytes of encrypted data for debugging + const encPreview = Array.from(encryptedData.slice(0, Math.min(32, encryptedData.length))) + .map(b => b.toString(16).padStart(2, '0')).join(''); + debugLog(`[DECRYPT] Encrypted data preview (first 32 bytes): ${encPreview}...`); + + // Pad to 16-byte blocks if needed (AES block size) + const blockSize = 16; + const paddedLength = Math.ceil(encryptedData.length / blockSize) * blockSize; + const paddedData = new Uint8Array(paddedLength); + paddedData.set(encryptedData); + + if (paddedLength !== encryptedData.length) { + debugLog(`[DECRYPT] Padded encrypted data from ${encryptedData.length} to ${paddedLength} bytes`); + } + + // Web Crypto API doesn't support ECB mode directly + // We simulate ECB by using CBC with a zero IV + const iv = new Uint8Array(16); // Zero IV for ECB simulation + debugLog(`[DECRYPT] Using AES-CBC with zero IV to simulate ECB mode`); + + // Import the channel key for AES-CBC decryption + debugLog(`[DECRYPT] Importing channel key for AES-CBC decryption...`); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + channelKey, + { name: 'AES-CBC' }, + false, + ['decrypt'] + ); + debugLog(`[DECRYPT] Channel key imported successfully`); + + // Decrypt using AES-CBC with zero IV (simulates ECB) + debugLog(`[DECRYPT] Decrypting ${paddedData.length} bytes...`); + const decryptedBuffer = await crypto.subtle.decrypt( + { name: 'AES-CBC', iv: iv }, + cryptoKey, + paddedData + ); + debugLog(`[DECRYPT] Decryption completed successfully`); + + const decryptedBytes = new Uint8Array(decryptedBuffer); + debugLog(`[DECRYPT] Decrypted data length: ${decryptedBytes.length} bytes`); + + // Log decrypted bytes for debugging + const decPreview = Array.from(decryptedBytes.slice(0, Math.min(32, decryptedBytes.length))) + .map(b => b.toString(16).padStart(2, '0')).join(' '); + debugLog(`[DECRYPT] Decrypted data preview (first 32 bytes): ${decPreview}...`); + + // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] + if (decryptedBytes.length < 5) { + debugLog(`[DECRYPT] ABORT: Decrypted data too short (${decryptedBytes.length} bytes, need at least 5)`); + return null; + } + + // Extract timestamp (4 bytes, little-endian) + const timestamp = decryptedBytes[0] | (decryptedBytes[1] << 8) | (decryptedBytes[2] << 16) | (decryptedBytes[3] << 24); + debugLog(`[DECRYPT] Timestamp: ${timestamp} (${new Date(timestamp * 1000).toISOString()})`); + + // Extract flags (1 byte) + const flags = decryptedBytes[4]; + debugLog(`[DECRYPT] Flags: 0x${flags.toString(16).padStart(2, '0')}`); + + // Extract message (remaining bytes) + const messageBytes = decryptedBytes.slice(5); + debugLog(`[DECRYPT] Message bytes length: ${messageBytes.length}`); + + // Decode as UTF-8 and strip null terminators + const decoder = new TextDecoder('utf-8'); + const messageText = decoder.decode(messageBytes).replace(/\0+$/, '').trim(); + + debugLog(`[DECRYPT] ✅ Message decrypted successfully: "${messageText}"`); + debugLog(`[DECRYPT] Message length: ${messageText.length} characters`); + + return messageText; + + } catch (error) { + debugError(`[DECRYPT] ❌ Failed to decrypt GroupText payload: ${error.message}`); + debugError(`[DECRYPT] Error stack: ${error.stack}`); + return null; + } +} + /** * Start listening for repeater echoes via rx_log * Uses the pre-computed WARDRIVING_CHANNEL_HASH for message correlation @@ -1113,6 +1231,37 @@ function handleRxLogEvent(data, originalPayload, channelIdx, expectedChannelHash debugLog(`Channel hash match confirmed - this is a message on our channel`); + // VALIDATION STEP 4: Decrypt and verify message content matches what we sent + // This ensures we're tracking echoes of OUR specific ping, not other messages on the channel + debugLog(`[MESSAGE_CORRELATION] Starting message content verification...`); + + if (WARDRIVING_CHANNEL_KEY) { + debugLog(`[MESSAGE_CORRELATION] Channel key available, attempting decryption...`); + const decryptedMessage = await decryptGroupTextPayload(packet.payload, WARDRIVING_CHANNEL_KEY); + + if (decryptedMessage === null) { + debugLog(`[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message`); + return; + } + + debugLog(`[MESSAGE_CORRELATION] Decryption successful, comparing content...`); + debugLog(`[MESSAGE_CORRELATION] Decrypted: "${decryptedMessage}" (${decryptedMessage.length} chars)`); + debugLog(`[MESSAGE_CORRELATION] Expected: "${originalPayload}" (${originalPayload.length} chars)`); + + // Compare decrypted message with what we sent + if (decryptedMessage !== originalPayload) { + debugLog(`[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)`); + debugLog(`[MESSAGE_CORRELATION] This is a different message on the same channel`); + return; + } + + debugLog(`[MESSAGE_CORRELATION] ✅ Message content match confirmed - this is an echo of our ping!`); + } else { + debugWarn(`[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available`); + debugWarn(`[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)`); + } + + // VALIDATION STEP 5: Check path length (repeater echo vs direct transmission) // For channel messages, the path contains repeater hops // Each hop in the path is 1 byte (repeater ID) if (packet.path.length === 0) { From 0f5802e7eeb4fed495fa746a0567e6cb8810912f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:30:13 +0000 Subject: [PATCH 8/8] Add aes-js library for proper AES-ECB message decryption Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> --- content/wardrive.js | 73 +++++++++++++++++++++++++-------------------- index.html | 3 ++ 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/content/wardrive.js b/content/wardrive.js index 8c9cf8c..dac658c 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -1053,42 +1053,41 @@ async function decryptGroupTextPayload(payload, channelKey) { .map(b => b.toString(16).padStart(2, '0')).join(''); debugLog(`[DECRYPT] Encrypted data preview (first 32 bytes): ${encPreview}...`); - // Pad to 16-byte blocks if needed (AES block size) - const blockSize = 16; - const paddedLength = Math.ceil(encryptedData.length / blockSize) * blockSize; - const paddedData = new Uint8Array(paddedLength); - paddedData.set(encryptedData); + // Use aes-js library for proper AES-ECB decryption + debugLog(`[DECRYPT] Using aes-js library for AES-ECB decryption`); - if (paddedLength !== encryptedData.length) { - debugLog(`[DECRYPT] Padded encrypted data from ${encryptedData.length} to ${paddedLength} bytes`); + // Check if aes-js is available + if (typeof aesjs === 'undefined') { + debugError(`[DECRYPT] ABORT: aes-js library not loaded`); + return null; } - // Web Crypto API doesn't support ECB mode directly - // We simulate ECB by using CBC with a zero IV - const iv = new Uint8Array(16); // Zero IV for ECB simulation - debugLog(`[DECRYPT] Using AES-CBC with zero IV to simulate ECB mode`); + // Convert Uint8Array to regular array for aes-js + const keyArray = Array.from(channelKey); + const encryptedArray = Array.from(encryptedData); - // Import the channel key for AES-CBC decryption - debugLog(`[DECRYPT] Importing channel key for AES-CBC decryption...`); - const cryptoKey = await crypto.subtle.importKey( - 'raw', - channelKey, - { name: 'AES-CBC' }, - false, - ['decrypt'] - ); - debugLog(`[DECRYPT] Channel key imported successfully`); + debugLog(`[DECRYPT] Decrypting ${encryptedData.length} bytes with AES-ECB...`); - // Decrypt using AES-CBC with zero IV (simulates ECB) - debugLog(`[DECRYPT] Decrypting ${paddedData.length} bytes...`); - const decryptedBuffer = await crypto.subtle.decrypt( - { name: 'AES-CBC', iv: iv }, - cryptoKey, - paddedData - ); - debugLog(`[DECRYPT] Decryption completed successfully`); + // Create AES-ECB decryption instance + const aesCbc = new aesjs.ModeOfOperation.ecb(keyArray); + + // Decrypt block by block (ECB processes each 16-byte block independently) + const blockSize = 16; + const decryptedBytes = new Uint8Array(encryptedArray.length); + + for (let i = 0; i < encryptedArray.length; i += blockSize) { + const block = encryptedArray.slice(i, i + blockSize); + + // Pad last block if necessary + while (block.length < blockSize) { + block.push(0); + } + + const decryptedBlock = aesCbc.decrypt(block); + decryptedBytes.set(decryptedBlock, i); + } - const decryptedBytes = new Uint8Array(decryptedBuffer); + debugLog(`[DECRYPT] Decryption completed successfully`); debugLog(`[DECRYPT] Decrypted data length: ${decryptedBytes.length} bytes`); // Log decrypted bytes for debugging @@ -1248,14 +1247,22 @@ function handleRxLogEvent(data, originalPayload, channelIdx, expectedChannelHash debugLog(`[MESSAGE_CORRELATION] Decrypted: "${decryptedMessage}" (${decryptedMessage.length} chars)`); debugLog(`[MESSAGE_CORRELATION] Expected: "${originalPayload}" (${originalPayload.length} chars)`); - // Compare decrypted message with what we sent - if (decryptedMessage !== originalPayload) { + // Channel messages include sender name prefix: "SenderName: Message" + // Check if our expected message is contained in the decrypted text + // This handles both exact matches and messages with sender prefixes + const messageMatches = decryptedMessage === originalPayload || decryptedMessage.includes(originalPayload); + + if (!messageMatches) { debugLog(`[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)`); debugLog(`[MESSAGE_CORRELATION] This is a different message on the same channel`); return; } - debugLog(`[MESSAGE_CORRELATION] ✅ Message content match confirmed - this is an echo of our ping!`); + if (decryptedMessage === originalPayload) { + debugLog(`[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!`); + } else { + debugLog(`[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) - this is an echo of our ping!`); + } } else { debugWarn(`[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available`); debugWarn(`[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)`); diff --git a/index.html b/index.html index d7aef1c..94f7d42 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,9 @@ + + +