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
16 changes: 14 additions & 2 deletions docs/ARCHITECTURE_LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,18 @@ await this.sendNodeInfoRequest(nodeNum, 0);

**Location**: `src/server/meshtasticManager.ts` β€” `processKeyRepairs()` and immediate purge path

### PKI Error Detection

**Problem**: PKI routing errors (`PKI_UNKNOWN_PUBKEY`, `PKI_SEND_FAIL_PUBLIC_KEY`, `PKI_FAILED`) should always flag `keyMismatchDetected` on the target node.

**Rule**: Never suppress PKI error detection based on device DB state. All three PKI errors must trigger key mismatch detection regardless of whether the target node is in the radio's local database. The mismatch flag clears naturally when keys are re-synced (via NodeInfo exchange or device sync).

**Anti-pattern**: Don't gate PKI error handling on `isNodeInDeviceDb()` β€” the radio not having the node is exactly the scenario where `PKI_UNKNOWN_PUBKEY` fires.

**Location**: `src/server/meshtasticManager.ts` β€” `processRoutingErrorMessage()`, both Path A (request packets) and Path B (message-tracked packets).

**Helper**: `isPkiError(errorReason)` in `src/server/constants/meshtastic.ts` classifies all three PKI error codes.

### Settings Allowlist

**Problem**: New settings silently fail to save if not added to the allowlist.
Expand All @@ -847,5 +859,5 @@ await this.sendNodeInfoRequest(nodeNum, 0);

---

**Last Updated**: 2026-03-13
**Related PRs**: #427, #429, #430, #431, #432, #433, #1359 (packet filtering), #1360 (protocol constants), #1404 (PostgreSQL support), #1405 (MySQL support), #1436 (async test fixes), #2243 (key mismatch detection), #2246 (neighbor info zoom setting)
**Last Updated**: 2026-03-22
**Related PRs**: #427, #429, #430, #431, #432, #433, #1359 (packet filtering), #1360 (protocol constants), #1404 (PostgreSQL support), #1405 (MySQL support), #1436 (async test fixes), #2243 (key mismatch detection), #2246 (neighbor info zoom setting), #2365 (key mismatch clearing), #2382 (PKI error detection)
61 changes: 60 additions & 1 deletion src/server/constants/meshtastic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { describe, it, expect } from 'vitest';
import { isViaMqtt, TransportMechanism, getTransportMechanismName } from './meshtastic.js';
import { isViaMqtt, TransportMechanism, getTransportMechanismName, RoutingError, isPkiError, getPortNumName } from './meshtastic.js';

describe('isViaMqtt', () => {
it('should return true for MQTT transport mechanism', () => {
Expand Down Expand Up @@ -73,3 +73,62 @@ describe('getTransportMechanismName', () => {
expect(getTransportMechanismName(99)).toBe('UNKNOWN_99');
});
});

describe('RoutingError', () => {
it('defines PKI error codes', () => {
expect(RoutingError.PKI_FAILED).toBe(34);
expect(RoutingError.PKI_UNKNOWN_PUBKEY).toBe(35);
expect(RoutingError.PKI_SEND_FAIL_PUBLIC_KEY).toBe(39);
});
});

describe('isPkiError', () => {
it('returns true for PKI_FAILED', () => {
expect(isPkiError(RoutingError.PKI_FAILED)).toBe(true);
});

it('returns true for PKI_UNKNOWN_PUBKEY', () => {
expect(isPkiError(RoutingError.PKI_UNKNOWN_PUBKEY)).toBe(true);
});

it('returns true for PKI_SEND_FAIL_PUBLIC_KEY', () => {
expect(isPkiError(RoutingError.PKI_SEND_FAIL_PUBLIC_KEY)).toBe(true);
});

it('returns false for non-PKI errors', () => {
expect(isPkiError(RoutingError.NONE)).toBe(false);
expect(isPkiError(RoutingError.NO_ROUTE)).toBe(false);
expect(isPkiError(RoutingError.NO_CHANNEL)).toBe(false);
});

it('returns false for zero (success/ACK)', () => {
expect(isPkiError(0)).toBe(false);
});

it('all three PKI errors should trigger key mismatch detection', () => {
// Documents behavior change from PR #2382:
// All PKI errors now flag keyMismatchDetected regardless of
// whether the target node is in the radio's device database.
const pkiErrors = [
RoutingError.PKI_FAILED,
RoutingError.PKI_UNKNOWN_PUBKEY,
RoutingError.PKI_SEND_FAIL_PUBLIC_KEY,
];
for (const err of pkiErrors) {
expect(isPkiError(err)).toBe(true);
}
});
});

describe('getPortNumName', () => {
it('returns name for known portnums', () => {
expect(getPortNumName(1)).toBe('TEXT_MESSAGE_APP');
expect(getPortNumName(3)).toBe('POSITION_APP');
expect(getPortNumName(67)).toBe('TELEMETRY_APP');
expect(getPortNumName(70)).toBe('TRACEROUTE_APP');
});

it('returns UNKNOWN for unknown portnums', () => {
expect(getPortNumName(999)).toBe('UNKNOWN_999');
});
});
65 changes: 24 additions & 41 deletions src/server/meshtasticManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5344,26 +5344,20 @@ class MeshtasticManager {

// PKI errors from our local node (couldn't encrypt to target)
if (isPkiError(errorReason) && fromNodeId === localNodeId) {
// PKI_UNKNOWN_PUBKEY when node isn't in the radio's DB is expected after
// factory reset or purge β€” don't flag it as a security issue
if ((errorReason === RoutingError.PKI_UNKNOWN_PUBKEY || errorReason === RoutingError.PKI_SEND_FAIL_PUBLIC_KEY) && !this.isNodeInDeviceDb(toNum)) {
logger.info(`πŸ” PKI key error for node ${toNodeId} β€” node not in radio's database (expected after factory reset/purge)`);
} else {
const errorDescription = errorReason === RoutingError.PKI_FAILED
? 'PKI encryption failed β€” your radio\'s stored key for this node may be outdated. Click "Exchange Node Info" to re-sync keys with the radio.'
: 'Your radio does not have this node\'s public key (even though MeshMonitor does). Click "Exchange Node Info" to push the key to your radio, or purge the node to force a fresh key exchange.';
const errorDescription = errorReason === RoutingError.PKI_FAILED
? 'PKI encryption failed β€” your radio\'s stored key for this node may be outdated. Click "Exchange Node Info" to re-sync keys with the radio.'
: 'Your radio does not have this node\'s public key (even though MeshMonitor does). Click "Exchange Node Info" to push the key to your radio, or purge the node to force a fresh key exchange.';

logger.warn(`πŸ” PKI error on request for node ${toNodeId}: ${errorDescription}`);
logger.warn(`πŸ” PKI error on request for node ${toNodeId}: ${errorDescription}`);

await databaseService.nodes.upsertNode({
nodeNum: toNum,
nodeId: toNodeId,
keyMismatchDetected: true,
keySecurityIssueDetails: errorDescription
});
dataEventEmitter.emitNodeUpdate(toNum, { keyMismatchDetected: true, keySecurityIssueDetails: errorDescription });
this.handlePkiError(toNum);
}
await databaseService.nodes.upsertNode({
nodeNum: toNum,
nodeId: toNodeId,
keyMismatchDetected: true,
keySecurityIssueDetails: errorDescription
});
dataEventEmitter.emitNodeUpdate(toNum, { keyMismatchDetected: true, keySecurityIssueDetails: errorDescription });
this.handlePkiError(toNum);
}

// NO_CHANNEL from the target node (it couldn't decrypt our request)
Expand Down Expand Up @@ -5397,35 +5391,24 @@ class MeshtasticManager {
// Detect PKI/encryption errors and flag the target node
// Only flag if the error is from our local radio (we couldn't encrypt to target)
if (isPkiError(errorReason) && fromNodeId === localNodeId) {
// PKI_FAILED or PKI_UNKNOWN_PUBKEY - indicates key mismatch
if (originalMessage.toNodeNum) {
const targetNodeNum = originalMessage.toNodeNum;

// PKI_UNKNOWN_PUBKEY when node isn't in the radio's DB is expected after
// factory reset or purge β€” don't flag it as a security issue
if ((errorReason === RoutingError.PKI_UNKNOWN_PUBKEY || errorReason === RoutingError.PKI_SEND_FAIL_PUBLIC_KEY) && !this.isNodeInDeviceDb(targetNodeNum)) {
logger.info(`πŸ” PKI key error for node ${targetNodeId} β€” node not in radio's database (expected after factory reset/purge)`);
} else {
const errorDescription = errorReason === RoutingError.PKI_FAILED
? 'PKI encryption failed β€” your radio\'s stored key for this node may be outdated. Click "Exchange Node Info" to re-sync keys with the radio.'
: 'Your radio does not have this node\'s public key (even though MeshMonitor does). Click "Exchange Node Info" to push the key to your radio, or purge the node to force a fresh key exchange.';
const errorDescription = errorReason === RoutingError.PKI_FAILED
? 'PKI encryption failed β€” your radio\'s stored key for this node may be outdated. Click "Exchange Node Info" to re-sync keys with the radio.'
: 'Your radio does not have this node\'s public key (even though MeshMonitor does). Click "Exchange Node Info" to push the key to your radio, or purge the node to force a fresh key exchange.';

logger.warn(`πŸ” PKI error detected for node ${targetNodeId}: ${errorDescription}`);

// Flag the node with the key security issue
await databaseService.nodes.upsertNode({
nodeNum: targetNodeNum,
nodeId: targetNodeId,
keyMismatchDetected: true,
keySecurityIssueDetails: errorDescription
});
logger.warn(`πŸ” PKI error detected for node ${targetNodeId}: ${errorDescription}`);

// Emit event to notify UI of the key issue
dataEventEmitter.emitNodeUpdate(targetNodeNum, { keyMismatchDetected: true, keySecurityIssueDetails: errorDescription });
await databaseService.nodes.upsertNode({
nodeNum: targetNodeNum,
nodeId: targetNodeId,
keyMismatchDetected: true,
keySecurityIssueDetails: errorDescription
});

// Penalize Link Quality for PKI error (-5)
this.handlePkiError(targetNodeNum);
}
dataEventEmitter.emitNodeUpdate(targetNodeNum, { keyMismatchDetected: true, keySecurityIssueDetails: errorDescription });
this.handlePkiError(targetNodeNum);
}
}

Expand Down
Loading