Harden MeshCore packet decoding: extract routeType, validate paths, guard compat rewrite

- Extract and persist routeType from wire header byte (was always undefined)
- Log warnings on truncated path data and reserved hash-size modes
- Guard buildCompatRawHex against pathByteLength > 63 overflow
- Extract transport codes (4 bytes) when present in FLOOD/DIRECT+codes packets
- Validate path hash hex lengths against wire-format hash size in resolver
- Add routeType to LivePacket type for downstream consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BenandClaude Opus 4.6 committed 2026-03-08 11:49:49 +00:00
1 parent f0c2248c18
commit 954b3ce853
5 files changed
+62 -7

No files matched your search

+5 -2
View File
@@ -311,6 +311,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
let decodedHash: string | undefined;
let decodedHops: number | undefined;
let decodedPathHashSizeBytes: number | undefined;
let decodedRouteType: number | undefined;
let summary: string | undefined;
let srcNodeId: string | undefined;
let advertCount: number | undefined;
@@ -319,13 +320,14 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
if (rawHex) {
try {
const { decoded, pathHashes, pathHashCount, pathHashSize } = decodePacketCompat(rawHex, keyStore);
const { decoded, pathHashes, pathHashCount, pathHashSize, routeType: decodedRT } = decodePacketCompat(rawHex, keyStore);
if (decoded) {
resolvedPacketType = decoded.payloadType ?? resolvedPacketType;
decodedHash = decoded.messageHash;
decodedHops = pathHashCount ?? decoded.pathLength;
decodedPathHashSizeBytes = pathHashSize;
decodedRouteType = decodedRT;
if (pathHashes && pathHashes.length > 0) {
path = pathHashes;
}
@@ -443,6 +445,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
topic,
network,
packetType: resolvedPacketType,
routeType: decodedRouteType,
hopCount: decodedHops,
pathHashSizeBytes: decodedPathHashSizeBytes,
direction,
@@ -462,7 +465,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
srcNodeId,
topic,
packetType: resolvedPacketType,
routeType: undefined,
routeType: decodedRouteType,
hopCount: decodedHops,
pathHashSizeBytes: decodedPathHashSizeBytes,
rssi,
+40 -3
View File
@@ -3,6 +3,8 @@ import { MeshCoreDecoder } from '@michaelhart/meshcore-decoder';
type KeyStore = ReturnType<typeof MeshCoreDecoder.createKeyStore>;
type PathMeta = {
routeType: number;
transportCodes: string | undefined;
encodedLengthByte: number;
pathLengthOffset: number;
pathHashCount: number;
@@ -16,6 +18,8 @@ export type CompatDecodedPacket = {
pathHashes?: string[];
pathHashCount?: number;
pathHashSize?: number;
routeType?: number;
transportCodes?: string;
};
function bytesToHex(bytes: Uint8Array): string {
@@ -34,18 +38,38 @@ function parsePathMeta(rawHex: string): PathMeta | null {
const routeType = bytes[0]! & 0x03;
let offset = 1;
if (routeType === 0 || routeType === 3) offset += 4;
// Extract transport codes when present (routeType 0 = FLOOD, 3 = DIRECT with codes)
let transportCodes: string | undefined;
if (routeType === 0 || routeType === 3) {
if (bytes.length < offset + 4) return null;
transportCodes = bytesToHex(bytes.subarray(offset, offset + 4));
offset += 4;
}
if (bytes.length <= offset) return null;
const encodedLengthByte = bytes[offset]!;
const pathHashCount = encodedLengthByte & 0x3f;
const pathHashSize = (encodedLengthByte >> 6) + 1;
if (pathHashSize > 3) return null;
// Reserved hash-size mode (0b11 → 4 bytes) — reject fully (#3)
if (pathHashSize > 3) {
console.warn(`[decode] reserved path hash size mode ${pathHashSize} in packet ${rawHex.slice(0, 16)}…`);
return null;
}
const pathByteLength = pathHashCount * pathHashSize;
const pathStart = offset + 1;
const pathEnd = pathStart + pathByteLength;
if (bytes.length < pathEnd) return null;
// Warn on truncated path data (#2)
if (bytes.length < pathEnd) {
console.warn(
`[decode] truncated path: need ${pathByteLength} path bytes but only ${bytes.length - pathStart} available in packet ${rawHex.slice(0, 16)}…`,
);
return null;
}
const pathHashes: string[] = [];
for (let i = 0; i < pathHashCount; i++) {
@@ -55,6 +79,8 @@ function parsePathMeta(rawHex: string): PathMeta | null {
}
return {
routeType,
transportCodes,
encodedLengthByte,
pathLengthOffset: offset,
pathHashCount,
@@ -66,6 +92,15 @@ function parsePathMeta(rawHex: string): PathMeta | null {
function buildCompatRawHex(rawHex: string, meta: PathMeta): string {
if (meta.encodedLengthByte < 64) return rawHex;
// Guard: pathByteLength > 63 would bleed into upper 2 bits, corrupting the compat byte (#6)
if (meta.pathByteLength > 63) {
console.warn(
`[decode] pathByteLength ${meta.pathByteLength} exceeds 63, skipping compat rewrite for packet ${rawHex.slice(0, 16)}…`,
);
return rawHex;
}
const bytes = hexToBytes(rawHex);
if (!bytes) return rawHex;
const compat = new Uint8Array(bytes);
@@ -82,5 +117,7 @@ export function decodePacketCompat(rawHex: string, keyStore: KeyStore): CompatDe
pathHashes: meta?.pathHashes ?? (decoded.path as string[] | null) ?? undefined,
pathHashCount: meta?.pathHashCount ?? decoded.pathLength ?? undefined,
pathHashSize: meta?.pathHashSize ?? 1,
routeType: meta?.routeType ?? decoded.routeType ?? undefined,
transportCodes: meta?.transportCodes,
};
}
+15 -2
View File
@@ -1038,7 +1038,7 @@ export type BetaResolvedPayload = {
export async function resolveBetaPathForPacketHash(packetHash: string, network: string, observer?: string): Promise<BetaResolvedPayload | null> {
const [packetResult, observerHopResult] = await Promise.all([
query<PathPacket>(
`SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes
`SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes, path_hash_size_bytes
FROM packets
WHERE packet_hash = $1
AND ($2 = 'all' OR network = $2)
@@ -1095,7 +1095,20 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
const src = packet.src_node_id ? (context.nodesById.get(packet.src_node_id) ?? null) : null;
const hashes = packet.path_hashes ?? [];
const hops = packet.hop_count != null ? hashes.slice(0, Math.max(0, packet.hop_count)) : hashes;
// Validate path hash lengths against wire-format hash size when available (#4)
const expectedHexLen = packet.path_hash_size_bytes != null ? packet.path_hash_size_bytes * 2 : null;
const validatedHashes = expectedHexLen != null
? hashes.filter((h) => {
if (h.length !== expectedHexLen) {
console.warn(`${logPrefix} hash length mismatch: expected ${expectedHexLen} hex chars, got ${h.length} ("${h}")`);
return false;
}
return true;
})
: hashes;
const hops = packet.hop_count != null ? validatedHashes.slice(0, Math.max(0, packet.hop_count)) : validatedHashes;
const forceIncludeSource = packet.packet_type === 4;
const currentHopCount = Number(packet.hop_count ?? 0);
const observerHopHints: ObserverHopHint[] = currentHopCount > 0 && packet.rx_node_id
+1
View File
@@ -37,6 +37,7 @@ export type PathPacket = {
packet_type: number | null;
hop_count: number | null;
path_hashes: string[] | null;
path_hash_size_bytes: number | null;
};
export type ObserverHopHint = {
+1
View File
@@ -56,6 +56,7 @@ export interface LivePacket {
topic: string;
network?: string;
packetType?: number;
routeType?: number; // 0=FLOOD, 1=DIRECT, 2=FLOOD+codes, 3=DIRECT+codes
hopCount?: number;
pathHashSizeBytes?: number;
direction?: string; // 'rx' | 'tx' from mctomqtt