Use more faithful packet frame parsing

This commit is contained in:
Jack Kingsman
2026-03-07 22:35:53 -08:00
parent 48dab293ae
commit 34318e4814
11 changed files with 310 additions and 245 deletions
+2 -39
View File
@@ -5,44 +5,7 @@ import type { RawPacket, Channel } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
/**
* Extract the payload from a raw packet hex string, skipping header and path.
* Returns the payload as a hex string, or null if malformed.
*/
function extractPayload(packetHex: string): string | null {
if (packetHex.length < 4) return null; // Need at least 2 bytes
try {
const header = parseInt(packetHex.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2; // 1 byte = 2 hex chars
// Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if (routeType === 0x00 || routeType === 0x03) {
if (packetHex.length < offset + 8) return null; // Need 4 more bytes
offset += 8; // 4 bytes = 8 hex chars
}
// Get path byte (packed as [hash_mode:2][hop_count:6])
if (packetHex.length < offset + 2) return null;
const pathByte = parseInt(packetHex.slice(offset, offset + 2), 16);
offset += 2;
const hashMode = (pathByte >> 6) & 0x03;
const hopCount = pathByte & 0x3f;
const hashSize = hashMode < 3 ? hashMode + 1 : 1;
const pathHexChars = hopCount * hashSize * 2;
// Skip path data
if (packetHex.length < offset + pathHexChars) return null;
offset += pathHexChars;
// Rest is payload
return packetHex.slice(offset);
} catch {
return null;
}
}
import { extractPacketPayloadHex } from '../utils/pathUtils';
interface CrackedRoom {
roomName: string;
@@ -180,7 +143,7 @@ export function CrackerPanel({
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
// Extract payload and check for duplicates
const payload = extractPayload(packet.data);
const payload = extractPacketPayloadHex(packet.data);
if (payload && seenPayloadsRef.current.has(payload)) {
// Skip - we already have a packet with this payload queued
newSkipped++;
+23
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
parsePathHops,
extractPacketPayloadHex,
findContactsByPrefix,
calculateDistance,
resolvePath,
@@ -107,6 +108,28 @@ describe('parsePathHops', () => {
});
});
describe('extractPacketPayloadHex', () => {
it('extracts payload from legacy 1-byte-hop packet', () => {
expect(extractPacketPayloadHex('0902AABB48656C6C6F')).toBe('48656C6C6F');
});
it('extracts payload from 2-byte-hop packet', () => {
expect(extractPacketPayloadHex('0942AABBCCDD48656C6C6F')).toBe('48656C6C6F');
});
it('rejects reserved mode 3', () => {
expect(extractPacketPayloadHex('09C1AABBCCDDEEFF')).toBeNull();
});
it('rejects oversized path encoding', () => {
expect(extractPacketPayloadHex(`09BF${'AA'.repeat(189)}4869`)).toBeNull();
});
it('rejects packets with no payload after path', () => {
expect(extractPacketPayloadHex('0902AABB')).toBeNull();
});
});
describe('findContactsByPrefix', () => {
const contacts: Contact[] = [
createContact({
+57
View File
@@ -1,6 +1,8 @@
import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
const MAX_PATH_BYTES = 64;
export interface PathHop {
prefix: string; // Hex hop identifier (e.g., "1A" for 1-byte, "1A2B" for 2-byte)
matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
@@ -64,6 +66,61 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
return hops;
}
/**
* Extract the payload portion from a raw packet hex string using firmware-equivalent
* path-byte validation. Returns null for malformed or payload-less packets.
*/
export function extractPacketPayloadHex(packetHex: string): string | null {
if (packetHex.length < 4) {
return null;
}
try {
const normalized = packetHex.toUpperCase();
const header = parseInt(normalized.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2;
if (routeType === 0x00 || routeType === 0x03) {
if (normalized.length < offset + 8) {
return null;
}
offset += 8;
}
if (normalized.length < offset + 2) {
return null;
}
const pathByte = parseInt(normalized.slice(offset, offset + 2), 16);
offset += 2;
const hashMode = (pathByte >> 6) & 0x03;
if (hashMode === 0x03) {
return null;
}
const hopCount = pathByte & 0x3f;
const hashSize = hashMode + 1;
const pathByteLen = hopCount * hashSize;
if (pathByteLen > MAX_PATH_BYTES) {
return null;
}
const pathHexChars = pathByteLen * 2;
if (normalized.length < offset + pathHexChars) {
return null;
}
offset += pathHexChars;
if (offset >= normalized.length) {
return null;
}
return normalized.slice(offset);
} catch {
return null;
}
}
/**
* Find contacts matching first 2 chars of public key (repeaters only for intermediate hops)
*/