Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/mqtt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). **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.
204 changes: 157 additions & 47 deletions src/main/mqtt-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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 { MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES } from '../shared/meshtasticDefaultPublicPsk';
import {
BAD_ENVELOPE_SIGNATURE_MAX,
bufferListIncludesKey,
Expand Down Expand Up @@ -38,14 +40,37 @@ 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,
]);

/** Wire a mock, already-connected `mqtt` client + settings onto `manager`; returns the publish spy. */
function wireConnected(manager: MQTTManager): ReturnType<typeof vi.fn> {
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);
Expand Down Expand Up @@ -165,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);
});

Expand Down Expand Up @@ -282,27 +324,6 @@ describe('parseMeshtasticMqttEncryptedTopicGatewayId', () => {
// ─────────────────────────────────────────────────────────────────────────────

describe('publish — MQTT uplink JSON mirror', () => {
function wireConnected(manager: MQTTManager): ReturnType<typeof vi.fn> {
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);
Expand Down Expand Up @@ -368,6 +389,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<typeof vi.fn>, 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<string, unknown>;
const expectedHash = computeMeshtasticChannelHash('TGIFMESH', CUSTOM_PSK);
expect(encryptedChannel).toBe(expectedHash);
expect(jsonBody.channel).toBe(expectedHash);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// emitMinimalNodeUpdate — cache name propagation
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1605,27 +1736,6 @@ describe('updateChannelKeys', () => {
});

describe('publish — decrypt round-trip (explicit PSK)', () => {
function wireConnected(manager: MQTTManager): ReturnType<typeof vi.fn> {
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 = () => {};
Expand Down
33 changes: 22 additions & 11 deletions src/main/mqtt-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ 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 {
expandMeshtasticPskAlias,
isMeshtasticDefaultPublicPsk,
MESHTASTIC_DEFAULT_PUBLIC_PSK_BYTES,
} from '../shared/meshtasticDefaultPublicPsk';
import {
MQTT_DEFAULT_RECONNECT_ATTEMPTS,
MQTT_MAX_RECONNECT_ATTEMPTS,
Expand Down Expand Up @@ -58,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 {
Expand All @@ -79,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);
Expand Down Expand Up @@ -815,12 +825,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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
hopLimit: 3,
payloadVariant: { case: 'encrypted', value: encrypted },
});
Expand All @@ -837,7 +848,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);

Expand All @@ -846,7 +857,7 @@ export class MQTTManager extends EventEmitter {
this.publishDecodedJsonMirror(
fromId,
toId,
channelId,
channelHash,
channelName,
gatewayId,
packetId,
Expand All @@ -870,7 +881,7 @@ export class MQTTManager extends EventEmitter {
private publishDecodedJsonMirror(
fromId: number,
toId: number,
channelId: number,
channelHash: number,
channelName: string,
gatewayId: string,
packetId: number,
Expand Down Expand Up @@ -898,7 +909,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),
};
Expand Down
Loading