From cd5ec38606c9e3754349dd71f61552b9ab484a3b Mon Sep 17 00:00:00 2001 From: Joe WB3IHY Date: Tue, 18 Aug 2026 22:48:15 -0400 Subject: [PATCH 1/4] fix(mqtt): stamp MeshPacket.channel with the real channel hash, not the local slot index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meshtastic firmware's wire MeshPacket.channel field is an XOR-fold hash of the channel name + PSK bytes (Channels::generateHash), not the sender's local channel-slot index — the field's own protobuf doc calls the index "meaningless to send between nodes" once payloadVariant is 'encrypted'. publishEncryptedData() (and its JSON mirror) was stamping the local index instead. MQTT-only (no physical radio) clients therefore published messages that other MQTT-only mesh-client peers could still read (inbound ingest already prefers the MQTT topic name over this field), but that real radios/gateways downlinking MQTT->RF silently dropped, since RF has no topic to fall back on. Adds computeMeshtasticChannelHash() in src/shared/meshtasticChannelHash.ts (mirrors the existing meshcoreNodeHash.ts shared-helper convention) and wires it into publishEncryptedData/publishDecodedJsonMirror so both the encrypted packet and its JSON mirror agree on the real hash. --- docs/agents/mqtt.md | 2 +- src/main/mqtt-manager.test.ts | 175 +++++++++++++++++------ src/main/mqtt-manager.ts | 12 +- src/shared/meshtasticChannelHash.test.ts | 46 ++++++ src/shared/meshtasticChannelHash.ts | 27 ++++ 5 files changed, 214 insertions(+), 48 deletions(-) create mode 100644 src/shared/meshtasticChannelHash.test.ts create mode 100644 src/shared/meshtasticChannelHash.ts diff --git a/docs/agents/mqtt.md b/docs/agents/mqtt.md index 8613babff..5c21efacc 100644 --- a/docs/agents/mqtt.md +++ b/docs/agents/mqtt.md @@ -2,4 +2,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback) — that constant's own parity with real firmware's default-PSK expansion is a separate, unverified concern (see `isMeshtasticDefaultPublicPsk`/`MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` in `src/shared/meshtasticDefaultPublicPsk.ts`), not something this hash fix resolves; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index bdded22c7..6d46ad8d0 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -7,6 +7,7 @@ import * as mqtt from 'mqtt'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MQTTSettings } from '../renderer/lib/types'; +import { computeMeshtasticChannelHash } from '../shared/meshtasticChannelHash'; import { BAD_ENVELOPE_SIGNATURE_MAX, bufferListIncludesKey, @@ -46,6 +47,28 @@ const CUSTOM_PSK = Buffer.from([ 0x1e, 0x2f, 0x3a, 0x4b, 0x5c, 0x6d, 0x7e, 0x8f, 0x90, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, ]); +/** Wire a mock, already-connected `mqtt` client + settings onto `manager`; returns the publish spy. */ +function wireConnected(manager: MQTTManager): ReturnType { + const publish = vi.fn(); + (manager as unknown as { client: unknown }).client = { + on: vi.fn(), + end: vi.fn(), + removeAllListeners: vi.fn(), + connected: true, + publish, + subscribe: vi.fn(), + }; + (manager as unknown as { currentSettings: MQTTSettings }).currentSettings = { + server: 'localhost', + port: 1883, + username: '', + password: '', + topicPrefix: 'msh/US/', + autoLaunch: false, + }; + return publish; +} + /** Build the AES-128-CTR nonce used by Meshtastic: packetId (4 LE) + fromId (4 LE) + 8 zeros */ function makeNonce(packetId: number, fromId: number): Buffer { const nonce = Buffer.alloc(16, 0); @@ -282,27 +305,6 @@ describe('parseMeshtasticMqttEncryptedTopicGatewayId', () => { // ───────────────────────────────────────────────────────────────────────────── describe('publish — MQTT uplink JSON mirror', () => { - function wireConnected(manager: MQTTManager): ReturnType { - const publish = vi.fn(); - (manager as unknown as { client: unknown }).client = { - on: vi.fn(), - end: vi.fn(), - removeAllListeners: vi.fn(), - connected: true, - publish, - subscribe: vi.fn(), - }; - (manager as unknown as { currentSettings: MQTTSettings }).currentSettings = { - server: 'localhost', - port: 1883, - username: '', - password: '', - topicPrefix: 'msh/US/', - autoLaunch: false, - }; - return publish; - } - it('publishes only protobuf when publishJsonMirror is false', () => { const manager = new MQTTManager(); const publish = wireConnected(manager); @@ -368,6 +370,116 @@ describe('publish — MQTT uplink JSON mirror', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// publishEncryptedData — MeshPacket.channel is the wire hash, not the local slot index +// ───────────────────────────────────────────────────────────────────────────── + +describe('publish — MeshPacket.channel wire hash', () => { + function decodePublishedPacket(publish: ReturnType, callIndex = 0) { + const envelope = fromBinary( + ServiceEnvelopeSchema, + publish.mock.calls[callIndex][1] as Uint8Array, + ); + return envelope.packet; + } + + it('publish() stamps the name+PSK hash, not the local slot index, for the default channel', () => { + const manager = new MQTTManager(); + const publish = wireConnected(manager); + manager.publish({ + text: 'hi', + from: 0x11223344, + channel: 5, // local slot index — must not leak onto the wire + channelName: 'LongFast', + publishJsonMirror: false, + }); + const packet = decodePublishedPacket(publish); + expect(packet?.channel).toBe(computeMeshtasticChannelHash('LongFast', DEFAULT_PSK)); + expect(packet?.channel).not.toBe(5); + }); + + it('publish() stamps the name+PSK hash for a custom-PSK channel', () => { + const manager = new MQTTManager(); + const publish = wireConnected(manager); + manager.publish({ + text: 'hi', + from: 0x11223344, + channel: 1, + channelName: 'TGIFMESH', + pskBase64: CUSTOM_PSK.toString('base64'), + publishJsonMirror: false, + }); + const packet = decodePublishedPacket(publish); + expect(packet?.channel).toBe(computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK)); + }); + + it('publishNodeInfo() stamps the hash instead of its hardcoded 0', () => { + const manager = new MQTTManager(); + const publish = wireConnected(manager); + manager.publishNodeInfo( + 0x11223344, + 'Long Name', + 'LN', + 'TGIFMESH', + undefined, + false, + CUSTOM_PSK.toString('base64'), + ); + const packet = decodePublishedPacket(publish); + expect(packet?.channel).toBe(computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK)); + }); + + it('publishPosition() and publishWaypoint() also stamp the hash, not the passed slot index', () => { + const manager = new MQTTManager(); + const publish = wireConnected(manager); + manager.publishPosition( + 0x11223344, + 3, + 'TGIFMESH', + 450000000, + -900000000, + undefined, + false, + CUSTOM_PSK.toString('base64'), + ); + expect(decodePublishedPacket(publish)?.channel).toBe( + computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK), + ); + + manager.publishWaypoint( + 0x11223344, + 0xffffffff, + 3, + 'TGIFMESH', + { id: 1, latitudeI: 0, longitudeI: 0, name: 'x' }, + false, + CUSTOM_PSK.toString('base64'), + ); + expect(decodePublishedPacket(publish, 1)?.channel).toBe( + computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK), + ); + }); + + it('JSON mirror channel field matches the same hash as the encrypted packet, not the local slot', () => { + const manager = new MQTTManager(); + const publish = wireConnected(manager); + manager.publish({ + text: 'hi', + from: 0x11223344, + channel: 4, // local slot index — must not leak into either wire representation + channelName: 'TGIFMESH', + pskBase64: CUSTOM_PSK.toString('base64'), + publishJsonMirror: true, + }); + expect(publish).toHaveBeenCalledTimes(2); + const encryptedChannel = decodePublishedPacket(publish, 0)?.channel; + const jsonBody = JSON.parse(publish.mock.calls[1][1] as string) as Record; + const expectedHash = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); + expect(encryptedChannel).toBe(expectedHash); + expect(jsonBody.channel).toBe(expectedHash); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // emitMinimalNodeUpdate — cache name propagation // ───────────────────────────────────────────────────────────────────────────── @@ -1605,27 +1717,6 @@ describe('updateChannelKeys', () => { }); describe('publish — decrypt round-trip (explicit PSK)', () => { - function wireConnected(manager: MQTTManager): ReturnType { - const publish = vi.fn(); - (manager as unknown as { client: unknown }).client = { - on: vi.fn(), - end: vi.fn(), - removeAllListeners: vi.fn(), - connected: true, - publish, - subscribe: vi.fn(), - }; - (manager as unknown as { currentSettings: MQTTSettings }).currentSettings = { - server: 'localhost', - port: 1883, - username: '', - password: '', - topicPrefix: 'msh/US/', - autoLaunch: false, - }; - return publish; - } - it('decrypts broker echo of publish when pskBase64 is only passed on publish IPC', () => { const manager = new MQTTManager(); (manager as any)._doConnect = () => {}; diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index 7ab9c4be8..9e0461629 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -12,6 +12,7 @@ import { EventEmitter } from 'events'; import * as mqtt from 'mqtt'; import type { ChatMessage, MeshNode, MQTTSettings, MQTTStatus } from '../renderer/lib/types'; +import { computeMeshtasticChannelHash } from '../shared/meshtasticChannelHash'; import { splitChannelPskLine } from '../shared/meshtasticChannelPskLine'; import { isMeshtasticDefaultPublicPsk } from '../shared/meshtasticDefaultPublicPsk'; import { @@ -815,12 +816,13 @@ export class MQTTManager extends EventEmitter { const psk = this.resolvePskForChannel(channelName, explicitPsk); const cipher = createCipheriv(cipherForKey(psk), psk, nonce); const encrypted = Buffer.concat([cipher.update(Buffer.from(dataBytes)), cipher.final()]); + const channelHash = computeMeshtasticChannelHash(channelName, psk); const packet = create(MeshPacketSchema, { from: fromId, to: toId, id: packetId, - channel: channelId, + channel: channelHash, hopLimit: 3, payloadVariant: { case: 'encrypted', value: encrypted }, }); @@ -837,7 +839,7 @@ export class MQTTManager extends EventEmitter { const publishPayload = Buffer.from(toBinary(ServiceEnvelopeSchema, envelope)); this.logSampledDebug( `mqtt-publish:${channelName}`, - `[Meshtastic MQTT] Publish channel="${sanitizeLogMessage(channelName)}" rf=${channelId} pskBytes=${psk.length} dataBytes=${dataBytes.length} jsonMirror=${publishJsonMirror} encryptedBytes=${encrypted.length} topic="${sanitizeLogMessage(publishTopic)}"`, + `[Meshtastic MQTT] Publish channel="${sanitizeLogMessage(channelName)}" localSlot=${channelId} hash=${channelHash} pskBytes=${psk.length} dataBytes=${dataBytes.length} jsonMirror=${publishJsonMirror} encryptedBytes=${encrypted.length} topic="${sanitizeLogMessage(publishTopic)}"`, ); this.client.publish(publishTopic, publishPayload); @@ -846,7 +848,7 @@ export class MQTTManager extends EventEmitter { this.publishDecodedJsonMirror( fromId, toId, - channelId, + channelHash, channelName, gatewayId, packetId, @@ -870,7 +872,7 @@ export class MQTTManager extends EventEmitter { private publishDecodedJsonMirror( fromId: number, toId: number, - channelId: number, + channelHash: number, channelName: string, gatewayId: string, packetId: number, @@ -898,7 +900,7 @@ export class MQTTManager extends EventEmitter { timestamp: ts, to: toId >>> 0, from: fromId >>> 0, - channel: channelId >>> 0, + channel: channelHash >>> 0, sender: gatewayId, portnum: portNumEnumToProtoName(portnum), }; diff --git a/src/shared/meshtasticChannelHash.test.ts b/src/shared/meshtasticChannelHash.test.ts new file mode 100644 index 000000000..d3a5bce66 --- /dev/null +++ b/src/shared/meshtasticChannelHash.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { computeMeshtasticChannelHash } from './meshtasticChannelHash'; + +const CUSTOM_PSK = new Uint8Array([ + 0x1e, 0x2f, 0x3a, 0x4b, 0x5c, 0x6d, 0x7e, 0x8f, 0x90, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, +]); + +// This codebase's own default-channel key constant (see MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES / +// mqtt-manager DEFAULT_PSK). Whether that constant itself matches real firmware's default-PSK +// expansion is a separate, unverified question — this only pins THIS hash function's own +// behavior against THIS codebase's key, not firmware parity for the default channel. +const CODEBASE_DEFAULT_PSK = new Uint8Array([ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +describe('computeMeshtasticChannelHash', () => { + it('XOR-folds the channel name against the key (firmware Channels::generateHash shape)', () => { + // XOR-fold of "LongFast" bytes = 0x0a; XOR-fold of CODEBASE_DEFAULT_PSK bytes = 0x01. + expect(computeMeshtasticChannelHash('LongFast', CODEBASE_DEFAULT_PSK)).toBe(0x0b); + }); + + it('is deterministic — same name+PSK always hashes the same', () => { + const a = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); + const b = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); + expect(a).toBe(b); + }); + + it('changes when the channel name or PSK changes', () => { + const base = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); + expect(computeMeshtasticChannelHash('OtherName', CUSTOM_PSK)).not.toBe(base); + expect(computeMeshtasticChannelHash('TGIFMESH', CODEBASE_DEFAULT_PSK)).not.toBe(base); + }); + + it('always returns a single byte (0-255)', () => { + const h = computeMeshtasticChannelHash('LongFast', CUSTOM_PSK); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(255); + }); + + it('accepts a Node Buffer or a plain Uint8Array interchangeably', () => { + const asBuffer = computeMeshtasticChannelHash('TGIFMESH', Buffer.from(CUSTOM_PSK)); + const asUint8 = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); + expect(asBuffer).toBe(asUint8); + }); +}); diff --git a/src/shared/meshtasticChannelHash.ts b/src/shared/meshtasticChannelHash.ts new file mode 100644 index 000000000..10881f3bd --- /dev/null +++ b/src/shared/meshtasticChannelHash.ts @@ -0,0 +1,27 @@ +/** + * Meshtastic firmware `Channels::generateHash`: for an `encrypted` MeshPacket, the wire + * `channel` field is NOT the sender's local channel-slot index (that's "meaningless to send + * between nodes" per the field's own protobuf doc, `@meshtastic/protobufs` mesh_pb.d.ts) — it's + * an 8-bit XOR fold of the channel name bytes XORed with the XOR fold of the encryption key + * bytes. Real radios use this (and only this — there's no MQTT topic on RF) to pick which + * locally-known channel a packet belongs to. + */ + +/** XOR-fold every byte down to a single byte (0-255). */ +function xorFold(bytes: Uint8Array): number { + let h = 0; + for (const b of bytes) h ^= b; + return h & 0xff; +} + +/** + * Compute the Meshtastic wire channel hash for an encrypted MeshPacket. + * Pass the same key bytes used to encrypt the packet so hash and cipher stay consistent. + */ +export function computeMeshtasticChannelHash( + channelName: string, + psk: Uint8Array | Buffer, +): number { + const pskBytes = psk instanceof Uint8Array ? psk : new Uint8Array(psk); + return xorFold(new TextEncoder().encode(channelName)) ^ xorFold(pskBytes); +} From 25a0eecdfc56955c74224439de97ad5c12a34f7e Mon Sep 17 00:00:00 2001 From: Joe WB3IHY Date: Wed, 19 Aug 2026 09:37:36 -0400 Subject: [PATCH 2/4] fix(mqtt): expand Meshtastic PSK shorthand aliases to their real firmware key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parsePsk() (and the mirrored src/shared/meshtasticDefaultPublicPsk.ts helpers) zero-padded a one-byte PSK shorthand alias (e.g. "AQ==" = 0x01, the default channel key) instead of expanding it. Firmware's actual default channel key is Channels.h `defaultpsk` (d4f1bb3a20290759f0bcffabcf4e6901), not a zero-padded 0x01 — so every default/simple-preset-PSK channel (e.g. public LongFast) was being encrypted with, and hashed with, the wrong 16-byte key: wrong ciphertext for real radios to decrypt, and a wrong MeshPacket.channel hash on top of it. Adds expandMeshtasticPskAlias() implementing firmware's real shorthand table (1 = defaultpsk; 2-10 = defaultpsk with its last byte + (index-1); 0/undefined aliases keep the prior zero-pad fallback) and wires it into parsePsk() and normalizeMeshtasticPskTo16Bytes(). Corrects MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES and mqtt-manager DEFAULT_PSK to the real firmware key so callers automatically get the fix. Custom-PSK channels (already full 16/32-byte keys, no shorthand) are unaffected. Found by CodeRabbit on #876 (confirmed independently against firmware source); this was flagged there as a separate, unverified concern before the review, now fixed rather than deferred. --- docs/agents/mqtt.md | 2 +- src/main/mqtt-manager.test.ts | 29 +++++++++++++++---- src/main/mqtt-manager.ts | 21 ++++++++++---- src/shared/meshtasticChannelHash.test.ts | 20 ++++++------- src/shared/meshtasticDefaultPublicPsk.ts | 36 ++++++++++++++++++++---- 5 files changed, 80 insertions(+), 28 deletions(-) diff --git a/docs/agents/mqtt.md b/docs/agents/mqtt.md index 5c21efacc..122a7443f 100644 --- a/docs/agents/mqtt.md +++ b/docs/agents/mqtt.md @@ -2,4 +2,4 @@ Deep subsystem reference for AI assistants. Open this when a task touches Meshtastic/MeshCore MQTT ingest, channel key mapping, or the sticky BLE suppress. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback) — that constant's own parity with real firmware's default-PSK expansion is a separate, unverified concern (see `isMeshtasticDefaultPublicPsk`/`MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` in `src/shared/meshtasticDefaultPublicPsk.ts`), not something this hash fix resolves; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. +Meshtastic: `mqtt-manager.ts` (AES-128/256-CTR, Meshtastic nonce layout, channel keys, protobuf, dedup); inbound **TEXT_MESSAGE** ingest prefers **topic channel name** → `channelNameToIndex` (receiver-local slot); `MeshPacket.channel` is fallback when topic absent — sampled log when they disagree (`mqtt-channel-topic-mismatch:*`); **outbound** (`publishEncryptedData` and its JSON mirror, `publishDecodedJsonMirror`) stamps `channel` with `computeMeshtasticChannelHash(channelName, psk)` from `src/shared/meshtasticChannelHash.ts` (XOR-fold of name bytes ^ XOR-fold of key bytes, mirroring firmware `Channels::generateHash`) — **not** the local slot index, which is "meaningless to send between nodes" per the field's own protobuf doc and is silently dropped by real radios/gateways that rely on it (there's no MQTT topic on RF to fall back to, unlike inbound). This hashes whatever key `resolvePskForChannel` resolves (including its `DEFAULT_PSK` fallback). **PSK shorthand aliases** (a single decoded byte, e.g. `AQ==` = `0x01`) are firmware-defined channel-key presets, not raw key material — `parsePsk()` and `normalizeMeshtasticPskTo16Bytes()` expand them via `expandMeshtasticPskAlias()` in `src/shared/meshtasticDefaultPublicPsk.ts` (alias `1` → firmware `defaultpsk` `d4f1bb3a20290759f0bcffabcf4e6901`; `2`-`10` → that key's last byte `+ (index - 1)`; `0` / undefined aliases zero-pad, matching "no encryption"/unknown). Zero-padding the raw alias byte instead (the pre-fix behavior) silently produces the wrong AES key and the wrong channel hash for every default/simple-preset-PSK channel — always fix both `MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES` and main-process `DEFAULT_PSK` together (doc-commented as required to match) if this ever needs to change again; Connection panel **Channel PSKs** `ChannelName@index=` for MQTT-only slot mapping; `meshtasticMqttPublish.ts`; `meshtasticChannelPskInput.ts` + `src/shared/meshtasticChannelPskLine.ts`; `meshtasticMqttSettingsStorage.ts`; `meshtasticMqttIdentity.ts` (MQTT-only `from`); `mqtt-broker-client-id.ts`. After RF configure, `useMeshtasticRuntime` must **re-push** `resolvedChannelConfigs` via `mqtt.updateChannelKeys` (not only on MQTT status change) so cold-start MQTT before deviceStore channels still gets correct topic→slot maps (`[Meshtastic MQTT] channelNameToIndex updated`). MeshCore: `meshcore-mqtt-adapter.ts` (JSON v1); LetsMesh JWT `letsMeshJwt.ts`. **Sticky MeshCore BLE “Blue” suppress:** `connectedMeshcoreBleMac.ts` persists a valid MeshCore BLE MAC and pre-arms Meshtastic NodeDB ghost suppression across cold start, failed reconnect, and user disconnect; clear only on Forget or switching MeshCore to a non-BLE transport. diff --git a/src/main/mqtt-manager.test.ts b/src/main/mqtt-manager.test.ts index 6d46ad8d0..93469b0a9 100644 --- a/src/main/mqtt-manager.test.ts +++ b/src/main/mqtt-manager.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MQTTSettings } from '../renderer/lib/types'; import { computeMeshtasticChannelHash } from '../shared/meshtasticChannelHash'; +import { MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES } from '../shared/meshtasticDefaultPublicPsk'; import { BAD_ENVELOPE_SIGNATURE_MAX, bufferListIncludesKey, @@ -39,9 +40,10 @@ const { ServiceEnvelopeSchema } = MqttProto; const { UserSchema, PositionSchema, DataSchema, MeshPacketSchema } = Mesh; const { PortNum } = Portnums; -const DEFAULT_PSK = Buffer.from([ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]); +// The real firmware default channel key (Channels.h `defaultpsk`) — matches production +// MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES / mqtt-manager DEFAULT_PSK, not a hand-copied literal, +// so this fixture can't silently drift from the value parsePsk("AQ==") actually produces. +const DEFAULT_PSK = Buffer.from(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES); const CUSTOM_PSK = Buffer.from([ 0x1e, 0x2f, 0x3a, 0x4b, 0x5c, 0x6d, 0x7e, 0x8f, 0x90, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, @@ -188,11 +190,28 @@ describe('parsePsk', () => { expect(result!).toEqual(CUSTOM_PSK); }); - it('zero-pads a short key (1 byte) to 16 bytes', () => { + it('expands the default PSK shorthand alias (AQ== / 0x01) to the real firmware key, not zero-padded', () => { const result = parsePsk('AQ=='); // [0x01] expect(result).not.toBeNull(); expect(result!.length).toBe(16); - expect(result![0]).toBe(0x01); + expect(result).toEqual(Buffer.from(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES)); + }); + + it('expands "simple" preset shorthand aliases (0x02-0x0a) via the default key + offset', () => { + for (let index = 2; index <= 10; index++) { + const result = parsePsk(Buffer.from([index]).toString('base64')); + expect(result).not.toBeNull(); + const expected = Buffer.from(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES); + expected[15] = (expected[15] + (index - 1)) & 0xff; + expect(result).toEqual(expected); + } + }); + + it('zero-pads a 1-byte value outside the defined 0x01-0x0a alias range', () => { + const result = parsePsk(Buffer.from([200]).toString('base64')); + expect(result).not.toBeNull(); + expect(result!.length).toBe(16); + expect(result![0]).toBe(200); expect(result!.subarray(1).every((b) => b === 0)).toBe(true); }); diff --git a/src/main/mqtt-manager.ts b/src/main/mqtt-manager.ts index 9e0461629..a43987ceb 100644 --- a/src/main/mqtt-manager.ts +++ b/src/main/mqtt-manager.ts @@ -14,7 +14,11 @@ import * as mqtt from 'mqtt'; import type { ChatMessage, MeshNode, MQTTSettings, MQTTStatus } from '../renderer/lib/types'; import { computeMeshtasticChannelHash } from '../shared/meshtasticChannelHash'; import { splitChannelPskLine } from '../shared/meshtasticChannelPskLine'; -import { isMeshtasticDefaultPublicPsk } from '../shared/meshtasticDefaultPublicPsk'; +import { + expandMeshtasticPskAlias, + isMeshtasticDefaultPublicPsk, + MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES, +} from '../shared/meshtasticDefaultPublicPsk'; import { MQTT_DEFAULT_RECONNECT_ATTEMPTS, MQTT_MAX_RECONNECT_ATTEMPTS, @@ -59,14 +63,15 @@ const TelemetrySchema = const PaxcountSchema = (PaxCount as unknown as { PaxcountSchema?: unknown }).PaxcountSchema ?? null; const MapReportSchema = (Mqtt as unknown as { MapReportSchema?: unknown }).MapReportSchema ?? null; -// Default PSK for meshtastic: 0x01 followed by 15 zero bytes -const DEFAULT_PSK = Buffer.from([ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]); +// Default PSK for meshtastic: firmware Channels.h `defaultpsk` (shorthand alias 0x01 expands to +// this — see expandMeshtasticPskAlias). NOT a zero-padded literal of the alias byte itself. +const DEFAULT_PSK = Buffer.from(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES); /** * Parse a base64-encoded PSK for Meshtastic MQTT (AES-128-CTR or AES-256-CTR). - * Accepts exactly 16 or 32 decoded bytes; short keys (e.g. "AQ==") zero-pad to 16. + * Accepts exactly 16 or 32 decoded bytes. A single decoded byte is a firmware PSK shorthand + * alias (e.g. "AQ==" = 0x01, the default channel key) and is expanded via + * expandMeshtasticPskAlias — NOT zero-padded, which would produce the wrong key/channel hash. * Other lengths are rejected (returns null). */ export function parsePsk(b64: string): Buffer | null { @@ -80,6 +85,10 @@ export function parsePsk(b64: string): Buffer | null { } if (raw.length === 0) return null; if (raw.length === 16 || raw.length === 32) return raw; + if (raw.length === 1) { + const expanded = expandMeshtasticPskAlias(raw[0]); + if (expanded) return Buffer.from(expanded); + } if (raw.length < 16) { const out = Buffer.alloc(16, 0); raw.copy(out, 0, 0, raw.length); diff --git a/src/shared/meshtasticChannelHash.test.ts b/src/shared/meshtasticChannelHash.test.ts index d3a5bce66..2b4221190 100644 --- a/src/shared/meshtasticChannelHash.test.ts +++ b/src/shared/meshtasticChannelHash.test.ts @@ -1,23 +1,19 @@ import { describe, expect, it } from 'vitest'; import { computeMeshtasticChannelHash } from './meshtasticChannelHash'; +import { MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES } from './meshtasticDefaultPublicPsk'; const CUSTOM_PSK = new Uint8Array([ 0x1e, 0x2f, 0x3a, 0x4b, 0x5c, 0x6d, 0x7e, 0x8f, 0x90, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, ]); -// This codebase's own default-channel key constant (see MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES / -// mqtt-manager DEFAULT_PSK). Whether that constant itself matches real firmware's default-PSK -// expansion is a separate, unverified question — this only pins THIS hash function's own -// behavior against THIS codebase's key, not firmware parity for the default channel. -const CODEBASE_DEFAULT_PSK = new Uint8Array([ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]); - describe('computeMeshtasticChannelHash', () => { it('XOR-folds the channel name against the key (firmware Channels::generateHash shape)', () => { - // XOR-fold of "LongFast" bytes = 0x0a; XOR-fold of CODEBASE_DEFAULT_PSK bytes = 0x01. - expect(computeMeshtasticChannelHash('LongFast', CODEBASE_DEFAULT_PSK)).toBe(0x0b); + // Firmware reference vector: XOR-fold of "LongFast" bytes = 0x0a; XOR-fold of the real + // default channel key (d4f1bb3a20290759f0bcffabcf4e6901) = 0x02; hash = 0x0a ^ 0x02 = 0x08. + expect(computeMeshtasticChannelHash('LongFast', MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES)).toBe( + 0x08, + ); }); it('is deterministic — same name+PSK always hashes the same', () => { @@ -29,7 +25,9 @@ describe('computeMeshtasticChannelHash', () => { it('changes when the channel name or PSK changes', () => { const base = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK); expect(computeMeshtasticChannelHash('OtherName', CUSTOM_PSK)).not.toBe(base); - expect(computeMeshtasticChannelHash('TGIFMESH', CODEBASE_DEFAULT_PSK)).not.toBe(base); + expect(computeMeshtasticChannelHash('TGIFMESH', MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES)).not.toBe( + base, + ); }); it('always returns a single byte (0-255)', () => { diff --git a/src/shared/meshtasticDefaultPublicPsk.ts b/src/shared/meshtasticDefaultPublicPsk.ts index 627490a26..2212dffdf 100644 --- a/src/shared/meshtasticDefaultPublicPsk.ts +++ b/src/shared/meshtasticDefaultPublicPsk.ts @@ -1,15 +1,41 @@ /** - * 16-byte AES-128 key for the Meshtastic default public channel (base64 "AQ==", zero-padded). - * Must match main-process {@link parsePsk}("AQ==") and mqtt-manager DEFAULT_PSK. + * Firmware `defaultpsk` (Meshtastic `Channels.h`) — the 16-byte AES-128 key that one-byte PSK + * shorthand alias `0x01` (base64 "AQ==") expands to. This is the real channel-settings key for + * the default public channel ("LongFast"), not a zero-padded literal — firmware does not use the + * raw alias byte for encryption or channel-hashing, only as a compact on-device/QR-code stand-in. + * Must match main-process {@link parsePsk} and mqtt-manager `DEFAULT_PSK`. */ export const MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES = new Uint8Array([ - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01, ]); -/** Zero-pad short Meshtastic channel keys to 16 bytes (matches mqtt-manager parsePsk). */ +/** + * Expand a Meshtastic one-byte PSK shorthand alias (firmware `Channels::getKey`) to its full + * 16-byte key. Alias `1` is the standard default key above; `2`-`10` are "simple" presets + * derived by incrementing the default key's final byte by `(index - 1)`. `0` means "no + * encryption" (no key). Returns `null` for `0` and for any index outside the defined `1`-`10` + * range (not a firmware-recognized alias). + */ +export function expandMeshtasticPskAlias(index: number): Uint8Array | null { + if (index < 1 || index > 10) return null; + const lastIndex = MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES.length - 1; + return MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES.map((byte, i) => + i === lastIndex ? (byte + (index - 1)) & 0xff : byte, + ); +} + +/** + * Normalize a Meshtastic channel PSK to 16 bytes. A one-byte input is treated as a firmware + * shorthand alias and properly expanded (matches mqtt-manager `parsePsk`); any other short + * input is zero-padded (not a defined firmware shorthand, kept permissive for malformed input). + */ export function normalizeMeshtasticPskTo16Bytes(psk: Uint8Array | Buffer): Uint8Array { - const out = new Uint8Array(16); const src = psk instanceof Uint8Array ? psk : new Uint8Array(psk); + if (src.length === 1) { + const expanded = expandMeshtasticPskAlias(src.at(0) ?? 0); + if (expanded) return expanded; + } + const out = new Uint8Array(16); const len = Math.min(src.length, 16); out.set(src.subarray(0, len)); return out; From a38ad7828832ac920d5e8374e037eecb11212e2d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 07:59:48 -0600 Subject: [PATCH 3/4] test: add unit tests for expandMeshtasticPskAlias and normalizeMeshtasticPskTo16Bytes Both functions were added/reworked in this branch but had no dedicated coverage. Covers alias expansion (1-10, out-of-range), normalization of short/exact/long keys, one-byte alias routing, and Buffer interop. --- src/shared/meshtasticDefaultPublicPsk.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/shared/meshtasticDefaultPublicPsk.test.ts b/src/shared/meshtasticDefaultPublicPsk.test.ts index 10fd8e7e8..5dd76951e 100644 --- a/src/shared/meshtasticDefaultPublicPsk.test.ts +++ b/src/shared/meshtasticDefaultPublicPsk.test.ts @@ -2,10 +2,84 @@ import { describe, expect, it } from 'vitest'; import { + expandMeshtasticPskAlias, isMeshtasticDefaultPublicPsk, MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES, + normalizeMeshtasticPskTo16Bytes, } from './meshtasticDefaultPublicPsk'; +describe('expandMeshtasticPskAlias', () => { + it('returns the default key for alias 1', () => { + const key = expandMeshtasticPskAlias(1); + expect(key).toEqual(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES); + }); + + it('increments only the last byte for aliases 2-10', () => { + for (let i = 2; i <= 10; i++) { + const key = expandMeshtasticPskAlias(i)!; + expect(key).toHaveLength(16); + // First 15 bytes match the default key + expect(key.subarray(0, 15)).toEqual(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES.subarray(0, 15)); + // Last byte is default + (i - 1), wrapped to 8 bits + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- strict-shared requires non-null on indexed access + expect(key[15]).toBe((MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES[15]! + (i - 1)) & 0xff); + } + }); + + it('returns null for alias 0 (no encryption)', () => { + expect(expandMeshtasticPskAlias(0)).toBeNull(); + }); + + it('returns null for out-of-range aliases', () => { + expect(expandMeshtasticPskAlias(-1)).toBeNull(); + expect(expandMeshtasticPskAlias(11)).toBeNull(); + expect(expandMeshtasticPskAlias(255)).toBeNull(); + }); +}); + +describe('normalizeMeshtasticPskTo16Bytes', () => { + it('expands one-byte alias 0x01 to the real default key', () => { + expect(normalizeMeshtasticPskTo16Bytes(new Uint8Array([0x01]))).toEqual( + MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES, + ); + }); + + it('expands one-byte alias 0x02 to the simple preset 2', () => { + const result = normalizeMeshtasticPskTo16Bytes(new Uint8Array([0x02])); + expect(result).toEqual(expandMeshtasticPskAlias(2)); + }); + + it('zero-pads one-byte alias 0x00 (not a valid alias)', () => { + const result = normalizeMeshtasticPskTo16Bytes(new Uint8Array([0x00])); + expect(result).toEqual(new Uint8Array(16)); + }); + + it('zero-pads short keys that are not one-byte aliases', () => { + const short = new Uint8Array([0xaa, 0xbb, 0xcc]); + const result = normalizeMeshtasticPskTo16Bytes(short); + expect(result).toHaveLength(16); + expect(result.subarray(0, 3)).toEqual(short); + expect(result.subarray(3)).toEqual(new Uint8Array(13)); + }); + + it('passes through 16-byte keys unchanged', () => { + const full = new Uint8Array(16).fill(0x42); + expect(normalizeMeshtasticPskTo16Bytes(full)).toEqual(full); + }); + + it('truncates keys longer than 16 bytes', () => { + const long = new Uint8Array(32).fill(0xff); + const result = normalizeMeshtasticPskTo16Bytes(long); + expect(result).toHaveLength(16); + expect(result).toEqual(new Uint8Array(16).fill(0xff)); + }); + + it('accepts a Node Buffer', () => { + const buf = Buffer.from([0x01]); + expect(normalizeMeshtasticPskTo16Bytes(buf)).toEqual(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES); + }); +}); + describe('isMeshtasticDefaultPublicPsk', () => { it('returns true for AQ== padded 16-byte key material', () => { expect(isMeshtasticDefaultPublicPsk(MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES)).toBe(true); From b929f8fe3e0b39ba455d0c0040f9c3d229b04c41 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 19 Aug 2026 08:55:29 -0600 Subject: [PATCH 4/4] test(noble-ble): seed sessions in connect integration tests on Linux CI NobleBleManager skips session init on Linux (Web Bluetooth in renderer), so connect() integration tests must seed meshcore/meshtastic sessions before exercising GATT discovery fallback paths. --- src/main/noble-ble-manager.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/noble-ble-manager.test.ts b/src/main/noble-ble-manager.test.ts index 6554e1720..799f0b127 100644 --- a/src/main/noble-ble-manager.test.ts +++ b/src/main/noble-ble-manager.test.ts @@ -37,6 +37,16 @@ vi.mock('./ble-coexistence-coordinator', () => ({ }, })); +/** Noble skips session init on Linux (Web Bluetooth in renderer); seed both LoRa sessions for connect() tests. */ +function seedNobleSessions(manager: unknown): void { + const m = manager as { + sessions: Map; + createSessionState: () => unknown; + }; + m.sessions.set('meshtastic', m.createSessionState()); + m.sessions.set('meshcore', m.createSessionState()); +} + describe('NobleBleManager.startScanning (regression)', () => { it('preserves connected peripherals and re-emits deviceDiscovered when clearing knownPeripherals', () => { expect(SOURCE).toMatch(/stillConnected/); @@ -244,6 +254,7 @@ describe('NobleBleManager.connect — per-session UUID selection (regression)', // Avoid leaving active timers in the test process. manager.startLinkRssiPolling = vi.fn(); + seedNobleSessions(manager); (manager as any).adapterReady = true; manager.knownPeripherals.set(peripheralId, peripheral); @@ -322,6 +333,7 @@ describe('NobleBleManager.connect — per-session UUID selection (regression)', }; manager.startLinkRssiPolling = vi.fn(); + seedNobleSessions(manager); (manager as any).adapterReady = true; manager.knownPeripherals.set(peripheralId, peripheral); @@ -401,6 +413,7 @@ describe('NobleBleManager.connect — per-session UUID selection (regression)', }; manager.startLinkRssiPolling = vi.fn(); + seedNobleSessions(manager); (manager as any).adapterReady = true; manager.knownPeripherals.set(peripheralId, peripheral);