mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import {
|
|
MeshCoreDecoder,
|
|
PayloadType,
|
|
Utils,
|
|
type DecodedPacket,
|
|
type DecryptionOptions,
|
|
type HeaderBreakdown,
|
|
type PacketStructure,
|
|
} from '@michaelhart/meshcore-decoder';
|
|
|
|
import type { Channel, RawPacket } from '../types';
|
|
|
|
export interface RawPacketSummary {
|
|
summary: string;
|
|
routeType: string;
|
|
details?: string;
|
|
}
|
|
|
|
export interface PacketByteField {
|
|
id: string;
|
|
scope: 'packet' | 'payload';
|
|
name: string;
|
|
description: string;
|
|
value: string;
|
|
decryptedMessage?: string;
|
|
startByte: number;
|
|
endByte: number;
|
|
absoluteStartByte: number;
|
|
absoluteEndByte: number;
|
|
headerBreakdown?: HeaderBreakdown;
|
|
}
|
|
|
|
export interface RawPacketInspection {
|
|
decoded: DecodedPacket | null;
|
|
structure: PacketStructure | null;
|
|
routeTypeName: string;
|
|
payloadTypeName: string;
|
|
payloadVersionName: string;
|
|
pathTokens: string[];
|
|
summary: RawPacketSummary;
|
|
validationErrors: string[];
|
|
packetFields: PacketByteField[];
|
|
payloadFields: PacketByteField[];
|
|
}
|
|
|
|
export function formatHexByHop(hex: string, hashSize: number | null | undefined): string {
|
|
const normalized = hex.trim().toUpperCase();
|
|
if (!normalized || !hashSize || hashSize < 1) {
|
|
return normalized;
|
|
}
|
|
|
|
const charsPerHop = hashSize * 2;
|
|
if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) {
|
|
return normalized;
|
|
}
|
|
|
|
const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g'));
|
|
return hops && hops.length > 1 ? hops.join(' → ') : normalized;
|
|
}
|
|
|
|
export function describeCiphertextStructure(
|
|
payloadType: PayloadType,
|
|
byteLength: number,
|
|
fallbackDescription: string
|
|
): string {
|
|
switch (payloadType) {
|
|
case PayloadType.GroupText:
|
|
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
|
• Timestamp (4 bytes) - send time as unix timestamp
|
|
• Flags (1 byte) - channel-message flags byte
|
|
• Message (remaining bytes) - UTF-8 channel message text`;
|
|
case PayloadType.TextMessage:
|
|
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
|
• Timestamp (4 bytes) - send time as unix timestamp
|
|
• Message (remaining bytes) - UTF-8 direct message text`;
|
|
case PayloadType.Response:
|
|
return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
|
• Tag (4 bytes) - request/response correlation tag
|
|
• Content (remaining bytes) - response body`;
|
|
default:
|
|
return fallbackDescription;
|
|
}
|
|
}
|
|
|
|
function getPathTokens(decoded: DecodedPacket): string[] {
|
|
const tracePayload =
|
|
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
|
? (decoded.payload.decoded as { pathHashes?: string[] })
|
|
: null;
|
|
return tracePayload?.pathHashes || decoded.path || [];
|
|
}
|
|
|
|
function formatUnixTimestamp(timestamp: number): string {
|
|
return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`;
|
|
}
|
|
|
|
function createPacketField(
|
|
scope: 'packet' | 'payload',
|
|
id: string,
|
|
field: {
|
|
name: string;
|
|
description: string;
|
|
value: string;
|
|
decryptedMessage?: string;
|
|
startByte: number;
|
|
endByte: number;
|
|
headerBreakdown?: HeaderBreakdown;
|
|
},
|
|
absoluteOffset: number
|
|
): PacketByteField {
|
|
return {
|
|
id,
|
|
scope,
|
|
name: field.name,
|
|
description: field.description,
|
|
value: field.value,
|
|
decryptedMessage: field.decryptedMessage,
|
|
startByte: field.startByte,
|
|
endByte: field.endByte,
|
|
absoluteStartByte: absoluteOffset + field.startByte,
|
|
absoluteEndByte: absoluteOffset + field.endByte,
|
|
headerBreakdown: field.headerBreakdown,
|
|
};
|
|
}
|
|
|
|
export function createDecoderOptions(
|
|
channels: Channel[] | null | undefined
|
|
): DecryptionOptions | undefined {
|
|
const channelSecrets =
|
|
channels
|
|
?.map((channel) => channel.key?.trim())
|
|
.filter((key): key is string => Boolean(key && key.length > 0)) ?? [];
|
|
|
|
if (channelSecrets.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }),
|
|
attemptDecryption: true,
|
|
};
|
|
}
|
|
|
|
function safeValidate(hexData: string): string[] {
|
|
try {
|
|
const validation = MeshCoreDecoder.validate(hexData);
|
|
return validation.errors ?? [];
|
|
} catch (error) {
|
|
return [error instanceof Error ? error.message : 'Packet validation failed'];
|
|
}
|
|
}
|
|
|
|
export function decodePacketSummary(
|
|
packet: RawPacket,
|
|
decoderOptions?: DecryptionOptions
|
|
): RawPacketSummary {
|
|
try {
|
|
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
|
|
|
if (!decoded.isValid) {
|
|
return { summary: 'Invalid packet', routeType: 'Unknown' };
|
|
}
|
|
|
|
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
|
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
|
const pathTokens = getPathTokens(decoded);
|
|
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : '';
|
|
|
|
let summary = payloadTypeName;
|
|
let details: string | undefined;
|
|
|
|
switch (decoded.payloadType) {
|
|
case PayloadType.TextMessage: {
|
|
const payload = decoded.payload.decoded as {
|
|
destinationHash?: string;
|
|
sourceHash?: string;
|
|
} | null;
|
|
if (payload?.sourceHash && payload?.destinationHash) {
|
|
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
|
|
} else {
|
|
summary = `DM${pathStr}`;
|
|
}
|
|
break;
|
|
}
|
|
case PayloadType.GroupText: {
|
|
const payload = decoded.payload.decoded as {
|
|
channelHash?: string;
|
|
decrypted?: { sender?: string; message?: string };
|
|
} | null;
|
|
if (packet.decrypted_info?.channel_name) {
|
|
if (packet.decrypted_info.sender) {
|
|
summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`;
|
|
} else {
|
|
summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`;
|
|
}
|
|
} else if (payload?.decrypted?.sender) {
|
|
summary = `GT from ${payload.decrypted.sender}${pathStr}`;
|
|
} else if (payload?.decrypted?.message) {
|
|
summary = `GT decrypted${pathStr}`;
|
|
} else if (payload?.channelHash) {
|
|
summary = `GT ch:${payload.channelHash}${pathStr}`;
|
|
} else {
|
|
summary = `GroupText${pathStr}`;
|
|
}
|
|
break;
|
|
}
|
|
case PayloadType.Advert: {
|
|
const payload = decoded.payload.decoded as {
|
|
publicKey?: string;
|
|
appData?: { name?: string; deviceRole?: number };
|
|
} | null;
|
|
if (payload?.appData?.name) {
|
|
const role =
|
|
payload.appData.deviceRole !== undefined
|
|
? Utils.getDeviceRoleName(payload.appData.deviceRole)
|
|
: '';
|
|
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
|
|
} else if (payload?.publicKey) {
|
|
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
|
|
} else {
|
|
summary = `Advert${pathStr}`;
|
|
}
|
|
break;
|
|
}
|
|
case PayloadType.Ack:
|
|
summary = `ACK${pathStr}`;
|
|
break;
|
|
case PayloadType.Request:
|
|
summary = `Request${pathStr}`;
|
|
break;
|
|
case PayloadType.Response:
|
|
summary = `Response${pathStr}`;
|
|
break;
|
|
case PayloadType.Trace:
|
|
summary = `Trace${pathStr}`;
|
|
break;
|
|
case PayloadType.Path:
|
|
summary = `Path${pathStr}`;
|
|
break;
|
|
default:
|
|
summary = `${payloadTypeName}${pathStr}`;
|
|
break;
|
|
}
|
|
|
|
return { summary, routeType, details };
|
|
} catch {
|
|
return { summary: 'Decode error', routeType: 'Unknown' };
|
|
}
|
|
}
|
|
|
|
export function inspectRawPacket(packet: RawPacket): RawPacketInspection {
|
|
return inspectRawPacketWithOptions(packet);
|
|
}
|
|
|
|
export function inspectRawPacketWithOptions(
|
|
packet: RawPacket,
|
|
decoderOptions?: DecryptionOptions
|
|
): RawPacketInspection {
|
|
const summary = decodePacketSummary(packet, decoderOptions);
|
|
const validationErrors = safeValidate(packet.data);
|
|
|
|
let decoded: DecodedPacket | null = null;
|
|
let structure: PacketStructure | null = null;
|
|
|
|
try {
|
|
decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
|
} catch {
|
|
decoded = null;
|
|
}
|
|
|
|
try {
|
|
structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions);
|
|
} catch {
|
|
structure = null;
|
|
}
|
|
|
|
const routeTypeName = decoded?.isValid
|
|
? Utils.getRouteTypeName(decoded.routeType)
|
|
: summary.routeType;
|
|
const payloadTypeName = decoded?.isValid
|
|
? Utils.getPayloadTypeName(decoded.payloadType)
|
|
: packet.payload_type;
|
|
const payloadVersionName = decoded?.isValid
|
|
? Utils.getPayloadVersionName(decoded.payloadVersion)
|
|
: 'Unknown';
|
|
const pathTokens = decoded?.isValid ? getPathTokens(decoded) : [];
|
|
|
|
const packetFields =
|
|
structure?.segments
|
|
.map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0))
|
|
.map((field) => {
|
|
if (field.name !== 'Path Data') {
|
|
return field;
|
|
}
|
|
const hashSize =
|
|
decoded?.pathHashSize ??
|
|
(decoded?.pathLength && decoded.pathLength > 0
|
|
? Math.max(1, field.value.length / 2 / decoded.pathLength)
|
|
: null);
|
|
return {
|
|
...field,
|
|
value: formatHexByHop(field.value, hashSize),
|
|
};
|
|
}) ?? [];
|
|
|
|
const payloadFields =
|
|
structure == null
|
|
? []
|
|
: (structure.payload.segments.length > 0
|
|
? structure.payload.segments
|
|
: structure.payload.hex.length > 0
|
|
? [
|
|
{
|
|
name: 'Payload Bytes',
|
|
description:
|
|
'Field-level payload breakdown is not available for this packet type.',
|
|
startByte: 0,
|
|
endByte: Math.max(0, structure.payload.hex.length / 2 - 1),
|
|
value: structure.payload.hex,
|
|
},
|
|
]
|
|
: []
|
|
).map((segment, index) =>
|
|
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
|
|
);
|
|
|
|
const enrichedPayloadFields =
|
|
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
|
|
? payloadFields.map((field) => {
|
|
if (field.name !== 'Ciphertext') {
|
|
return field;
|
|
}
|
|
const payload = decoded.payload.decoded as {
|
|
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
|
};
|
|
if (!payload.decrypted?.message) {
|
|
return field;
|
|
}
|
|
const detailLines = [
|
|
payload.decrypted.timestamp != null
|
|
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
|
: null,
|
|
payload.decrypted.flags != null
|
|
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
|
: null,
|
|
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
|
`Message: ${payload.decrypted.message}`,
|
|
].filter((line): line is string => line !== null);
|
|
return {
|
|
...field,
|
|
description: describeCiphertextStructure(
|
|
decoded.payloadType,
|
|
field.endByte - field.startByte + 1,
|
|
field.description
|
|
),
|
|
decryptedMessage: detailLines.join('\n'),
|
|
};
|
|
})
|
|
: payloadFields.map((field) => {
|
|
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
|
return field;
|
|
}
|
|
return {
|
|
...field,
|
|
description: describeCiphertextStructure(
|
|
decoded.payloadType,
|
|
field.endByte - field.startByte + 1,
|
|
field.description
|
|
),
|
|
};
|
|
});
|
|
|
|
return {
|
|
decoded,
|
|
structure,
|
|
routeTypeName,
|
|
payloadTypeName,
|
|
payloadVersionName,
|
|
pathTokens,
|
|
summary,
|
|
validationErrors:
|
|
validationErrors.length > 0
|
|
? validationErrors
|
|
: (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])),
|
|
packetFields,
|
|
payloadFields: enrichedPayloadFields,
|
|
};
|
|
}
|