Skip to content

Commit 56f16d5

Browse files
authored
fix: prevent RF crosstalk via ServiceEnvelope payload mutation for virtual channels (#41)
* docs: amend v1.6.2 release notes * fix: prevent RF crosstalk via ServiceEnvelope payload mutation for virtual channels Virtual channel topic rewriting was insufficient to prevent RF rebroadcast. The radio firmware uses packet.channel (PSK hash) to look up the decryption key, not the MQTT topic string. Packets from extra MQTT roots sharing the same channel key (e.g. LongFast/AQ==) were being decrypted and rebroadcast over RF by the local node, bridging two independent MQTT server regions. Fix: mutate the ServiceEnvelope protobuf before radio injection: - envelope.channel_id -> virtual channel name (e.g. 'NC-LongFast') - packet.channel -> synthetic hash unique to the virtual channel name The radio firmware finds no matching PSK for the synthetic hash, cannot decrypt, and does not rebroadcast. The encrypted payload bytes are unchanged, so MeshMonitor can still decrypt using the original key via its Channel Database entry for the virtual channel name. Confirmed via live MQTT packet inspection: packet.channel carries the PSK hash (not a slot index), and radio firmware matches by hash, not by name. * docs: update Virtual Channels documentation with MeshMonitor Channel Database setup * fix: elevate virtual channel rewrite log to INFO; fix docs log example * chore: release v1.6.3 * docs: clarify MeshMonitor Channel DB matches by PSK not channel name * docs: add MeshMonitor Enforce Channel Name Validation guidance for shared-PSK channels * docs: remove confusing MeshMonitor custom name setup, channels work natively * fix: revert channel_id mutation, keep original names for native MeshMonitor support * docs: clarify Enforce Channel Name Validation usage for shared keys * docs: document known defect where MeshMonitor merges shared-key channels
1 parent a246a93 commit 56f16d5

File tree

7 files changed

+165
-26
lines changed

7 files changed

+165
-26
lines changed

CONFIG.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,26 +122,46 @@ BLE support requires custom implementation using the `bleak` library. See the [m
122122
123123
### Extra MQTT Roots (Virtual Channels)
124124

125-
Monitor encrypted traffic from additional regions beyond your primary root topic **without causing RF crosstalk**.
125+
Monitor encrypted traffic from additional MQTT servers or root topics beyond your primary node configuration **without causing RF crosstalk**.
126126

127127
**Configuration:**
128128
```env
129-
EXTRA_MQTT_ROOTS=msh/US/OH, msh/US/CA:California
129+
# Format: <root>:<alias>, ...
130+
# The alias becomes the channel prefix in MeshMonitor (e.g. NC-LongFast)
131+
EXTRA_MQTT_ROOTS=msh/US/NC:NC, msh/US/OH:Ohio
130132
```
131133

132-
When configured, the proxy automatically subscribes to `{root}/2/e/#` for each specified root in addition to your node's primary root.
134+
When configured, the proxy subscribes to `{root}/2/e/#` for each extra root in addition to your node's primary root.
133135

134-
**Topic Rewriting (Virtual Channels)**:
135-
To prevent "crosstalk" (where downloading Ohio's `LongFast` traffic inadvertently causes your local Michigan USB radio to broadcast it over RF), the proxy utilizes a **Virtual Channel** mapping.
136+
**How Virtual Channels Work:**
136137

137-
If a packet arrives from an extra root, its channel name is rewritten on-the-fly to include a prefix:
138-
- `msh/US/OH` defaults to the prefix `OH``OH-LongFast`
139-
- `msh/US/CA:California` uses custom prefix `California``California-LongFast`
138+
When a packet arrives from an extra root, the proxy performs a two-part rewrite before injecting it into the radio:
140139

141-
By rewriting the channel name to a "Virtual Channel" (e.g., `OH-LongFast`), your local USB radio ignores it. However, if you are using MeshMonitor's Channel Database, the encrypted payload is successfully decrypted, allowing you to seamlessly monitor cross-region traffic!
140+
1. **Topic rewrite:** The MQTT topic channel name is prefixed with the alias.
141+
- `msh/US/NC/2/e/LongFast/!abc123``msh/US/2/e/NC-LongFast/!abc123`
142+
143+
2. **Payload mutation (crosstalk prevention):** The proxy mutates the `packet.channel` field inside the `ServiceEnvelope` protobuf.
144+
- `packet.channel` integer (PSK hash) → a synthetic hash derived from the virtual channel name
145+
146+
The radio firmware uses this PSK hash to look up its decryption key. Since no local channel has the synthetic hash configured, the radio **cannot decrypt the packet and will not rebroadcast it over RF** — completely preventing cross-region crosstalk.
147+
148+
**MeshMonitor Channel Database Setup:**
149+
150+
Because the proxy preserves the original `channel_id` string and leaves the encrypted payload bytes untouched, MeshMonitor natively receives the exact original packet data.
151+
152+
You just need to ensure the original channel name and PSK exist in your MeshMonitor Channel Database. You do **not** need to create special `NC-` prefixed names.
153+
154+
> [!CAUTION]
155+
> **Known Limitation (Shared Keys):** If you use the same key for multiple channels (e.g., both your local `LongFast` and the virtual `LongFast` use `AQ==`), **MeshMonitor will decode traffic for both into the exact same channel display**.
156+
>
157+
> You cannot turn on "Enforce Channel Name Validation" in MeshMonitor to separate them. Because the proxy must fake the PSK hash to prevent the physical radio from transmitting the packet, MeshMonitor's strict "Enforce Name" validation will fail the hash check and refuse to decrypt entirely.
158+
>
159+
> As a result, virtual channel traffic will simply be merged into your existing local channel display if they share a key.
160+
> **Monitoring only:** Virtual Channels are strictly read-only. Because the hardware radio does not know about virtual channels, there is no way to send a reply on a virtual channel. This is by design — the feature is intended for safe, passive cross-region monitoring without bridging two networks.
161+
162+
> [!IMPORTANT]
163+
> **Loop Prevention:** When MeshMonitor echoes a virtual channel packet back to the proxy, the proxy's uplink filter automatically drops it (since the virtual channel is not defined on the physical radio). This prevents an infinite `proxy → MeshMonitor → MQTT → proxy` feedback loop.
142164
143-
> [!TIP]
144-
> **Loop Prevention & MeshMonitor Architecture:** Virtual Channels are intentionally sent to the proxy's transmission queue so they can be received natively over the socket connection by MeshMonitor (which acts as a Virtual Node Server). When MeshMonitor echoes the packed back to the proxy, the proxy automatically **drops** the Virtual Channel instead of republishing it. This breaks the infinite MQTT loop while keeping MeshMonitor informed!
145165

146166
## Meshtastic Node Configuration
147167

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A production-ready MQTT proxy for Meshtastic devices that enables bidirectional message forwarding between Meshtastic nodes and MQTT brokers. Supports TCP and Serial interface connections with a clean factory pattern architecture.
44

5-
**Version**: 1.6.2
5+
**Version**: 1.6.3
66

77
## Features
88

@@ -21,6 +21,7 @@ A production-ready MQTT proxy for Meshtastic devices that enables bidirectional
2121
-**Traffic Optimization** - Smart subscription strategy (`msh/2/e/#`) to prevent serial link saturation
2222
-**Hammering Prevention** - Correctly flags retained messages to avoid `NO_RESPONSE` storms
2323
-**Channel Filtering** - Respects `uplink_enabled` and `downlink_enabled` channel settings
24+
-**Virtual Channels** - Monitor cross-region MQTT traffic via `EXTRA_MQTT_ROOTS` without RF crosstalk (payload mutation prevents radio decryption & rebroadcast)
2425
-**MeshMonitor Compatible** - Seamless integration with MeshMonitor and other tools
2526

2627
**Note:** BLE interface is not currently supported. Use TCP or Serial interfaces.
@@ -437,28 +438,28 @@ Because this repository enforces **Pull Request requirements** for the `master`
437438

438439
1. **Create a Release Branch:**
439440
```bash
440-
git checkout -b release/v1.6.0
441+
git checkout -b release/v1.6.3
441442
```
442443

443444
2. **Run the Release Script:**
444445
Use the provided automation script to bump the version in `version.py` and `README.md`:
445446
```bash
446-
python scripts/release.py 1.6.0
447+
python scripts/release.py 1.6.3
447448
```
448-
*This will create a local "chore: release v1.6.0" commit and a local `v1.6.0` tag.*
449+
*This will create a local "chore: release v1.6.3" commit and a local `v1.6.3` tag.*
449450

450451
3. **Push and Open a PR:**
451452
Push the branch and open a Pull Request to `master`.
452453
```bash
453-
git push origin release/v1.6.0
454+
git push origin release/v1.6.3
454455
```
455456

456457
4. **Merge and Tag:**
457458
Once the PR is merged into `master`, push the local tag to GitHub to trigger the release pipeline:
458459
```bash
459460
git checkout master
460461
git pull
461-
git push origin v1.6.0
462+
git push origin v1.6.3
462463
```
463464

464465
The GitHub Actions will automatically detect the new tag, build the Windows executable, and publish the Docker images.
@@ -499,7 +500,7 @@ while the source code of this proxy is MIT licensed, it depends on third-party l
499500

500501
- **Issues**: [GitHub Issues](https://github.com/LN4CY/mqtt-proxy/issues)
501502
- **Meshtastic Discord**: [Join](https://discord.gg/meshtastic)
502-
- **Version**: 1.6.2
503+
- **Version**: 1.6.3
503504
- **Documentation**: [Configuration Guide](CONFIG.md) | [Architecture](ARCHITECTURE.md)
504505

505506
## Roadmap

RELEASE_NOTES.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
# Release v1.6.3
2+
3+
## Virtual Channel RF Crosstalk Prevention
4+
5+
This release fixes a critical bug where Virtual Channel packets injected into the local radio (via `EXTRA_MQTT_ROOTS`) could be decrypted and rebroadcast over RF to nearby nodes, effectively bridging two independent MQTT server regions against the user's intent.
6+
7+
## 🐛 Bug Fixes
8+
9+
* **Fix: Virtual Channel RF Crosstalk** (PR #41)
10+
* **Root Cause:** The radio firmware uses `packet.channel` — a PSK hash integer embedded in the `ServiceEnvelope` protobuf payload — to look up its decryption key. Because the topic-only rewrite (`NC-LongFast`) left the original PSK hash intact, the radio could still match its local channel key and decrypt the packet, causing it to rebroadcast over RF.
11+
* **Fix:** The proxy now mutates the `packet.channel` field in the protobuf payload before injecting it into the radio:
12+
* `packet.channel` — replaced with a synthetic hash unique to the virtual channel name that no local radio will ever have configured
13+
* The `packet.encrypted` bytes and original `channel_id` string are left completely untouched. MeshMonitor natively decrypts the message using its standard Channel Database. **Known Defect:** Because the PSK hash is faked, MeshMonitor's "Enforce Channel Name Validation" cannot be used. Consequently, if multiple channels share the exact same key, MeshMonitor will decode and merge their traffic into a single channel display.
14+
* Added `INFO`-level log entry on every virtual channel rewrite for easy discovery of exact virtual channel names.
15+
16+
## 📝 Documentation
17+
18+
* Updated `CONFIG.md` Virtual Channels section to accurately describe the payload mutation mechanism and added **MeshMonitor Channel Database setup instructions** explaining which channel name and key to configure for each virtual channel.
19+
20+
---
21+
122
# Release v1.6.2
223

324
This release introduces two major new features to handle advanced broker routing and to improve fidelity with Meshtastic node configurations: **Virtual Channels (Multi-Root)** and **Per-Channel Uplink/Downlink Filtering**.

handlers/mqtt.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,59 @@ def publish(self, topic, payload, retain=False):
131131
return False
132132
return False
133133

134+
def _compute_virtual_channel_hash(self, channel_name):
135+
"""
136+
Compute a synthetic PSK hash for a virtual channel name.
137+
138+
Meshtastic firmware uses packet.channel (a uint32 PSK hash) to look up
139+
the decryption key for incoming packets. By replacing the real hash with
140+
a deterministic but synthetic value derived from the virtual channel name,
141+
the radio will find no matching key and cannot decrypt or rebroadcast.
142+
143+
The hash is intentionally kept in the range 200-254 to avoid colliding
144+
with real channel hashes (LongFast=0, and most common values near 0).
145+
"""
146+
h = 0
147+
for b in channel_name.encode('utf-8'):
148+
h = (h * 31 + b) & 0xFF
149+
# Shift into 200-254 range to reduce collision likelihood with real channels
150+
return 200 + (h % 55)
151+
152+
def _mutate_virtual_channel_payload(self, payload, new_channel_name):
153+
"""
154+
Mutate ServiceEnvelope protobuf to rewrite the PSK hash.
155+
156+
Changes:
157+
- packet.channel : PSK hash → synthetic hash unique to the virtual channel
158+
159+
The string name (envelope.channel_id) and encrypted payload bytes
160+
(packet.encrypted) are left completely untouched.
161+
MeshMonitor can still decrypt the message using the original key and original
162+
channel name via its Channel Database entry.
163+
164+
This prevents the radio firmware from matching its local PSK and rebroadcasting
165+
the packet over RF, which would cause crosstalk between MQTT server regions.
166+
"""
167+
try:
168+
from meshtastic.protobuf import mqtt_pb2
169+
envelope = mqtt_pb2.ServiceEnvelope()
170+
envelope.ParseFromString(payload)
171+
172+
original_channel_hash = envelope.packet.channel
173+
174+
# Rewrite only the PSK hash to blind the radio firmware
175+
envelope.packet.channel = self._compute_virtual_channel_hash(new_channel_name)
176+
177+
mutated = envelope.SerializeToString()
178+
logger.debug(
179+
"🔒 Payload mutation: packet.channel %d→%d",
180+
original_channel_hash, envelope.packet.channel
181+
)
182+
return mutated
183+
except Exception as e:
184+
logger.warning("⚠️ Failed to mutate virtual channel payload, using original: %s", e)
185+
return payload
186+
134187
def _on_connect(self, client, userdata, flags, rc, props=None):
135188
logger.info("✅ MQTT Connected with result code: %s", rc)
136189
if rc == 0:
@@ -243,6 +296,7 @@ def _on_message(self, client, userdata, message):
243296
return
244297

245298
modified_topic = message.topic
299+
modified_payload = message.payload
246300

247301
# Virtual Channel mapping for Extra Roots
248302
extra_roots = getattr(self.config, 'extra_mqtt_roots', [])
@@ -260,17 +314,29 @@ def _on_message(self, client, userdata, message):
260314
new_channel_name = f"{er_prefix}-{channel_name}"
261315
parts[-2] = new_channel_name
262316
modified_topic = "/".join(parts)
263-
logger.debug("🔄 Virtual Channel Rewrite: %s -> %s for extra root %s",
264-
channel_name, new_channel_name, er_root)
317+
# CRITICAL: Also mutate the protobuf payload to change the channel
318+
# identity field (packet.channel PSK hash).
319+
# The radio firmware uses packet.channel (PSK hash) to look up
320+
# its decryption key. By replacing it with a synthetic hash that
321+
# no local radio has configured, the firmware cannot decrypt the
322+
# packet and will NOT rebroadcast it over RF.
323+
# The encrypted bytes and original channel_id string are left
324+
# untouched so MeshMonitor can still decrypt using the original
325+
# key and channel name via its Channel Database.
326+
modified_payload = self._mutate_virtual_channel_payload(
327+
message.payload, new_channel_name
328+
)
329+
logger.info("🔄 Virtual Channel Rewrite: %s -> %s (extra root: %s)",
330+
channel_name, new_channel_name, er_root)
265331
break
266332

267333
self.last_activity = time.time()
268334
self.rx_count += 1
269335

270-
logger.info("📥 MQTT->Node: Topic=%s Size=%d bytes Retained=%s", modified_topic, len(message.payload), message.retain)
336+
logger.info("📥 MQTT->Node: Topic=%s Size=%d bytes Retained=%s", modified_topic, len(modified_payload), message.retain)
271337

272338
if self.on_message_callback:
273-
self.on_message_callback(modified_topic, message.payload, message.retain)
339+
self.on_message_callback(modified_topic, modified_payload, message.retain)
274340

275341
except Exception as e:
276342
logger.error("❌ Error handling MQTT message: %s", e)

mqtt-proxy.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,15 @@ def _is_channel_downlink_enabled(self, channel_name):
234234
enabled = getattr(ch.settings, "downlink_enabled", True)
235235
return enabled
236236

237-
# Should we be strict? For now, we allow unknown channels to pass down to the "node"
238-
# so that MeshMonitor (acting as a virtual node) can see Virtual Channels.
239-
logger.debug("⚠️ Channel '%s' not found in node config, allowing by default", channel_name)
237+
# Virtual Channel Pass-Through:
238+
# If the channel is not defined on the physical radio, we still
239+
# forward the packet to the radio. The proxy has already mutated the packet.channel
240+
# PSK hash, so the radio has no matching key and CANNOT decrypt or rebroadcast it.
241+
# However, the raw encrypted packet is sent to all connected TCP clients
242+
# (e.g. MeshMonitor), which can natively decrypt it using its own Channel Database
243+
# based on the original channelname embedded in the packet.
244+
# This keeps the key off the node, preventing RF crosstalk while allowing monitoring.
245+
logger.debug("📡 Channel '%s' not defined on radio, forwarding for MeshMonitor (virtual channel passthrough)", channel_name)
240246
return True
241247

242248
def _is_channel_uplink_enabled(self, channel_name):

pr_body.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Problem
2+
3+
Virtual channel topic rewriting (renaming `LongFast``NC-LongFast` in the MQTT topic) was insufficient to prevent RF rebroadcast crosstalk.
4+
5+
The radio firmware uses **`packet.channel`** (a PSK hash integer inside the `ServiceEnvelope` protobuf payload) to look up the decryption key — **not** the MQTT topic string. When packets from extra MQTT roots shared the same PSK as a local channel (e.g. `LongFast` / `AQ==`), the radio successfully decrypted and rebroadcast them over RF, effectively bridging two independent MQTT server regions.
6+
7+
## Fix
8+
9+
Mutate the `ServiceEnvelope` protobuf **before** injecting to the radio:
10+
- `packet.channel` → synthetic hash derived from the virtual channel name (range 200-254)
11+
12+
The radio firmware finds no matching PSK for the synthetic hash → cannot decrypt → **does not rebroadcast over RF**
13+
14+
The `packet.encrypted` bytes and the `channel_id` string name are **completely untouched**. This allows MeshMonitor to decrypt the packet using its standard Channel Database natively.
15+
16+
**Known Defect:** Because the PSK hash is faked to prevent radio crosstalk, MeshMonitor's "Enforce Channel Name Validation" cannot be used (it strictly fails the hash check). Consequently, if multiple monitored channels share the exact same key, MeshMonitor will decode and merge their traffic into a single channel display.
17+
18+
## Setup for Users
19+
20+
Users just ensure the original PSK exists in their MeshMonitor Channel Database. No custom `NC-` prefixed entries are required. "Enforce Channel Name Validation" must remain OFF.
21+
22+
## Testing
23+
- All 67 existing tests pass
24+
- Live verified: `packet.channel` carries PSK hash (not slot index) via `inspect_payload.py`
25+
- Confirmed MeshMonitor successfully decrypts natively, with documentation updated on the shared-PSK defect.

version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Version definition for MQTT Proxy."""
22

3-
__version__ = "1.6.2"
3+
__version__ = "1.6.3"

0 commit comments

Comments
 (0)