Skip to content

fix(mqtt): attribute same-key channels by packet hash, not sort order#3754

Merged
Yeraze merged 2 commits into
mainfrom
fix/mqtt-same-key-channel-attribution
Jun 25, 2026
Merged

fix(mqtt): attribute same-key channels by packet hash, not sort order#3754
Yeraze merged 2 commits into
mainfrom
fix/mqtt-same-key-channel-attribution

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

Multiple channel_database rows can share a key — most commonly the default AQ== key (e.g. an auto-seeded LongFast plus a user's custom channel). The server-side decryption service walked channels in sort order and let the first one whose key decrypted claim the packet, so whichever same-key channel sorted first absorbed all default-key traffic. Packets actually sent on the custom channel were attributed to the wrong row — and therefore the wrong channel permissions — which (among other things) hid those nodes from anonymous users who only had viewOnMap on the real channel. This makes attribution follow the packet's channel hash so the correct same-key channel is credited.

Changes

  • channelDecryptionService: compute the Meshtastic channel hash (xorHash(name) ^ xorHash(psk)) for every cached channel, not only name-validated ones.
  • tryDecrypt() now tries hash-matching channels first (preserving sortOrder within each group), then the rest. It's a preference, not a hard skip: if nothing matches (1-byte hash collision across keys, or the real channel isn't in the DB) it still falls back and decrypts, so decryption is never lost — only attribution improves. enforceNameValidation remains an independent hard skip, unchanged.
  • Retroactive: existing deployments are corrected with no DB edit, because attribution now follows the packet hash rather than row order.
  • mqttIngestion bootstrap seed left at enforceNameValidation: false on purpose, with a comment: enforcing it would hard-skip the LongFast-named seed for non-LongFast modem presets (whose default channel hashes as MediumSlow/ShortFast/…), silently dropping their default-channel decryption. Hash-aware attribution achieves the same disambiguation without that regression.
  • Docs: docs/features/channel-database.md updated to describe hash-aware priority and the narrowed role of Enforce Name Validation.

Issues Resolved

None (reported directly by a deployment operator: same-key LongFast/Custom channels caused MQTT-bridge nodes to be misattributed and hidden from anonymous users).

Documentation Updates

  • docs/features/channel-database.md — "Channel Priority and Ordering" now explains hash-matching-first selection; "Enforce Name Validation" clarified as a strict hard-reject (no longer needed merely to attribute same-key channels), with leaving it off as the safer default.

Testing

  • Unit tests pass — full main suite green (success: true, 7553 passed, 0 failed, 0 suite failures; sibling .claude/worktrees/ checkout excluded)
  • TypeScript: no new errors in changed files (tsc -p tsconfig.server.json only shows pre-existing TelemetryChart.tsx frontend noise)
  • New Hash-aware attribution (same-key channels) test block: the LongFast/Custom bug, the reverse direction, fallback-when-no-match (non-LongFast preset safety), fallback past a hash-colliding wrong-key channel, and hard-skip-still-wins
  • Reviewer: confirm a same-key (AQ==) LongFast+Custom setup attributes packets to the channel matching each packet's hash, and that default-key traffic on a non-LongFast preset still decrypts

🤖 Generated with Claude Code

Yeraze and others added 2 commits June 25, 2026 12:08
Multiple channel_database rows can share a key (commonly the default
"AQ==" key — e.g. an auto-seeded "LongFast" plus a user's custom
channel). channelDecryptionService.tryDecrypt() walked channels in
sortOrder and let the first one whose key decrypted claim the packet, so
whichever same-key channel sorted first absorbed ALL default-key traffic.
Packets actually sent on the custom channel were attributed to the wrong
channel_database row — and therefore the wrong channel permissions —
which (among other things) hid those nodes from anonymous users who only
had viewOnMap on the real channel.

Fix: compute the Meshtastic channel hash (xorHash(name) ^ xorHash(psk))
for every cached channel, and in tryDecrypt() try hash-matching channels
first (preserving sortOrder within each group) before falling back to the
rest. This is a preference, not a hard skip: if no hash-matching channel
decrypts (1-byte hash collision across keys, or the real channel isn't in
the DB), we still fall back, so decryption is never lost — only
attribution improves. enforceNameValidation remains an independent hard
skip. The fix is retroactive: existing deployments are corrected without
any DB edit.

Deliberately did NOT flip the MQTT seed's enforceNameValidation to true:
that would hard-skip the "LongFast"-named seed for non-LongFast modem
presets (whose default channel hashes as "MediumSlow"/"ShortFast"/…),
silently dropping their default-channel decryption. Hash-aware
attribution achieves the same disambiguation without that regression; the
seed stays validation-off with a comment explaining why.

Tests: new "Hash-aware attribution (same-key channels)" block covering the
LongFast/Custom bug, the reverse direction, fallback-when-no-match (the
non-LongFast safety case), fallback past a hash-colliding wrong-key
channel, and hard-skip-still-wins. Full main suite green (7553 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
Channel Priority now reflects that a packet's channel hash selects the
correct same-PSK channel before sort order, and that sort order is only
the tiebreaker (decryption is never lost). Clarify that Enforce Name
Validation is no longer needed merely to attribute same-key channels —
it now strictly *rejects* non-matching packets — and note leaving it off
is the safer default across modem presets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(mqtt): attribute same-key channels by packet hash, not sort order

Tasks:

  • Gather context and understand the PR
  • Review channelDecryptionService.ts
  • Review channelDecryptionService.test.ts
  • Review mqttIngestion.ts
  • Review docs/features/channel-database.md
  • Post comprehensive feedback

Summary

This is a well-designed fix for a real attribution bug. The core logic is sound, tests are thorough, and the fallback-not-hard-skip design avoids the regression that enforcing a hard channel hash match would cause on non-LongFast modem presets. The Meshtastic channel hash algorithm (xorHash(name) ^ xorHash(psk) using the expanded key) is implemented correctly.


Issues Found

🔴 Bug: import statements after function body in mqttIngestion.ts

src/server/mqttIngestion.ts:75-94 — The new bootstrapMqttChannelDatabase function (lines 40–74) is placed before the import statements it depends on. The logger import is at line 94, but logger.info is called at line 64. This is technically legal in ES modules (imports are hoisted), but it is a clear code organization error — especially notable since the function uses logger before its import appears in the file. The imports should be at the top of the file.

// Lines 64-66 use logger, but logger isn't imported until line 94:
logger.info(
  `MQTT source ${sourceId} bootstrapped channel_database entry "${ch.name}" with default key`,
);
// ...
// Line 94:
import { logger } from '../utils/logger.js';

Action required: Move the bootstrapMqttChannelDatabase function and the new top-of-file imports so that all imports appear first, or move the function below the existing imports.


🟡 Issue: Hash collision false-preference with same-hash, same-key channels

In tryDecrypt() (line 419), the filter candidates.filter((c) => c.expectedChannelHash === channelHash) groups all channels with a matching hash into the "prefer" group — including channels that share the same key. If two same-key channels have the same hash (highly unlikely but theoretically possible: their names XOR to the same bytes), both end up in the preferred group and the first by sortOrder wins, which is the pre-fix behavior for that sub-case. This edge case is already acknowledged by the PR's "1-byte hash collision" fallback test, so it's an accepted limitation, but worth noting.


🟡 Issue: Missing test for sortOrder tie-breaking within the same-hash group

The new test block (Hash-aware attribution (same-key channels)) confirms hash-matching channels are tried first, but there's no test verifying that within the expectedChannelHash === channelHash group, sortOrder is still the tiebreaker. The pre-sort at cache-refresh time (sortedChannels) ensures this, but an explicit test would prevent a regression if the ordering logic is ever changed.


🟡 Minor: encryptTestData helper is duplicated three times

encryptTestData (the nonce-building + AES-CTR encrypt helper) is defined in three separate describe blocks (Decryption, Decryption with Specific Channel, Channel Hash Validation, and a fourth cleaner signature in Hash-aware attribution). Extracting it to a single file-level helper would reduce 30+ lines of duplication.


🟢 Observation: computeChannelHash export is good for testability

Exporting xorHash and computeChannelHash at the bottom of the service file (line 510) is the right call — the new test block uses them directly. This is clean.


🟢 Observation: Hash algorithm matches firmware

The xorHash(name) ^ xorHash(expandedPsk) algorithm is confirmed correct against the Meshtastic firmware's Channels.cpp — and the test at line 621 correctly uses expandShorthandPsk before computing the reference hash, matching how the service caches it. This is the most important correctness check and it passes.


🟢 Observation: enforceNameValidation remains orthogonal

The hard-skip logic at lines 403–411 is unchanged in semantics — it filters candidates before the preference-ordering step. The new test hard-skip still wins over preference (line 735) correctly validates that adding hash-preference didn't weaken the existing hard-skip guarantee. Well done.


Minor Documentation Nit

docs/features/channel-database.md:143 — "How Server-Side Decryption Works" step 3 still says "in sort order" without mentioning hash-preferred order. Step 3 now reads: "If your node cannot decrypt it, MeshMonitor tries each enabled Channel Database entry in sort order". This should be updated to reflect the new hash-preferred behavior, e.g., "hash-matched channels first, then remaining channels in sort order." The other sections (Channel Priority and Ordering tip, Enforce Name Validation) are already updated, so this is the only stale reference.


Verdict

The fix is correct and production-ready. The critical bug (imports-after-function-body in mqttIngestion.ts) should be addressed before merge. The other findings are non-blocking.

@Yeraze Yeraze merged commit 320f17a into main Jun 25, 2026
19 checks passed
@Yeraze Yeraze deleted the fix/mqtt-same-key-channel-attribution branch June 25, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant