mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 13:10:34 +00:00
Timestamp is decoded from the ADVERT but never persisted to the nodes table. The validation was rejecting valid nodes with slightly-off clocks (28h future) and nodes broadcasting timestamp=4. No reason to gate on it.
364 lines
12 KiB
JavaScript
364 lines
12 KiB
JavaScript
/**
|
|
* MeshCore Packet Decoder
|
|
* Custom implementation — does NOT use meshcore-decoder library (known path_length bug).
|
|
*
|
|
* Packet layout:
|
|
* [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...]
|
|
*
|
|
* Header byte (LSB first):
|
|
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
|
|
* bits 5-2: payloadType
|
|
* bits 7-6: payloadVersion
|
|
*
|
|
* Path length byte:
|
|
* bits 5-0: hash_count (number of hops, 0-63)
|
|
* bits 7-6: (value >> 6) + 1 = hash_size (1-4 bytes per hop hash)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// --- Constants ---
|
|
|
|
const ROUTE_TYPES = {
|
|
0: 'TRANSPORT_FLOOD',
|
|
1: 'FLOOD',
|
|
2: 'DIRECT',
|
|
3: 'TRANSPORT_DIRECT',
|
|
};
|
|
|
|
const PAYLOAD_TYPES = {
|
|
0x00: 'REQ',
|
|
0x01: 'RESPONSE',
|
|
0x02: 'TXT_MSG',
|
|
0x03: 'ACK',
|
|
0x04: 'ADVERT',
|
|
0x05: 'GRP_TXT',
|
|
0x07: 'ANON_REQ',
|
|
0x08: 'PATH',
|
|
0x09: 'TRACE',
|
|
};
|
|
|
|
// Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
|
|
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
|
|
|
// --- Header parsing ---
|
|
|
|
function decodeHeader(byte) {
|
|
return {
|
|
routeType: byte & 0x03,
|
|
routeTypeName: ROUTE_TYPES[byte & 0x03] || 'UNKNOWN',
|
|
payloadType: (byte >> 2) & 0x0F,
|
|
payloadTypeName: PAYLOAD_TYPES[(byte >> 2) & 0x0F] || 'UNKNOWN',
|
|
payloadVersion: (byte >> 6) & 0x03,
|
|
};
|
|
}
|
|
|
|
// --- Path parsing ---
|
|
|
|
function decodePath(pathByte, buf, offset) {
|
|
const hashSize = (pathByte >> 6) + 1; // 1-4 bytes per hash
|
|
const hashCount = pathByte & 0x3F; // 0-63 hops
|
|
const totalBytes = hashSize * hashCount;
|
|
const hops = [];
|
|
|
|
for (let i = 0; i < hashCount; i++) {
|
|
const hopBuf = buf.subarray(offset + i * hashSize, offset + i * hashSize + hashSize);
|
|
hops.push(hopBuf.toString('hex').toUpperCase());
|
|
}
|
|
|
|
return {
|
|
hashSize,
|
|
hashCount,
|
|
hops,
|
|
bytesConsumed: totalBytes,
|
|
};
|
|
}
|
|
|
|
// --- Payload decoders ---
|
|
|
|
/** REQ / RESPONSE / TXT_MSG: dest(6) + src(6) + MAC(4) + encrypted */
|
|
function decodeEncryptedPayload(buf) {
|
|
if (buf.length < 16) return { error: 'too short', raw: buf.toString('hex') };
|
|
return {
|
|
destHash: buf.subarray(0, 6).toString('hex'),
|
|
srcHash: buf.subarray(6, 12).toString('hex'),
|
|
mac: buf.subarray(12, 16).toString('hex'),
|
|
encryptedData: buf.subarray(16).toString('hex'),
|
|
};
|
|
}
|
|
|
|
/** ACK: dest(6) + src(6) + extra(6) */
|
|
function decodeAck(buf) {
|
|
if (buf.length < 18) return { error: 'too short', raw: buf.toString('hex') };
|
|
return {
|
|
destHash: buf.subarray(0, 6).toString('hex'),
|
|
srcHash: buf.subarray(6, 12).toString('hex'),
|
|
extraHash: buf.subarray(12, 18).toString('hex'),
|
|
};
|
|
}
|
|
|
|
/** ADVERT: pubkey(32) + timestamp(4 LE) + signature(64) + appdata */
|
|
function decodeAdvert(buf) {
|
|
if (buf.length < 100) return { error: 'too short for advert', raw: buf.toString('hex') };
|
|
const pubKey = buf.subarray(0, 32).toString('hex');
|
|
const timestamp = buf.readUInt32LE(32);
|
|
const signature = buf.subarray(36, 100).toString('hex');
|
|
const appdata = buf.subarray(100);
|
|
|
|
const result = { pubKey, timestamp, timestampISO: new Date(timestamp * 1000).toISOString(), signature };
|
|
|
|
if (appdata.length > 0) {
|
|
const flags = appdata[0];
|
|
result.flags = {
|
|
raw: flags,
|
|
chat: !!(flags & 0x01),
|
|
repeater: !!(flags & 0x02),
|
|
room: !!(flags & 0x04),
|
|
sensor: !!(flags & 0x08),
|
|
hasLocation: !!(flags & 0x10),
|
|
hasName: !!(flags & 0x80),
|
|
};
|
|
|
|
let off = 1;
|
|
if (result.flags.hasLocation && appdata.length >= off + 8) {
|
|
result.lat = appdata.readInt32LE(off) / 1e6;
|
|
result.lon = appdata.readInt32LE(off + 4) / 1e6;
|
|
off += 8;
|
|
}
|
|
if (result.flags.hasName) {
|
|
result.name = appdata.subarray(off).toString('utf8');
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** GRP_TXT: channel_hash(1) + MAC(2) + encrypted */
|
|
function decodeGrpTxt(buf, channelKeys) {
|
|
if (buf.length < 3) return { error: 'too short', raw: buf.toString('hex') };
|
|
const channelHash = buf[0];
|
|
const mac = buf.subarray(1, 3).toString('hex');
|
|
const encryptedData = buf.subarray(3).toString('hex');
|
|
|
|
// Try decryption with known channel keys
|
|
if (channelKeys && encryptedData.length >= 10) {
|
|
try {
|
|
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
|
|
for (const [name, key] of Object.entries(channelKeys)) {
|
|
const result = ChannelCrypto.decryptGroupTextMessage(encryptedData, mac, key);
|
|
if (result.success && result.data) {
|
|
return {
|
|
type: 'CHAN',
|
|
channel: name,
|
|
channelHash,
|
|
sender: result.data.sender || null,
|
|
text: result.data.sender && result.data.message
|
|
? `${result.data.sender}: ${result.data.message}`
|
|
: result.data.message || '',
|
|
sender_timestamp: result.data.timestamp,
|
|
flags: result.data.flags,
|
|
};
|
|
}
|
|
}
|
|
} catch (e) { /* decryption failed, fall through */ }
|
|
}
|
|
|
|
return { type: 'GRP_TXT', channelHash, mac, encryptedData };
|
|
}
|
|
|
|
/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
|
|
function decodeAnonReq(buf) {
|
|
if (buf.length < 42) return { error: 'too short', raw: buf.toString('hex') };
|
|
return {
|
|
destHash: buf.subarray(0, 6).toString('hex'),
|
|
ephemeralPubKey: buf.subarray(6, 38).toString('hex'),
|
|
mac: buf.subarray(38, 42).toString('hex'),
|
|
encryptedData: buf.subarray(42).toString('hex'),
|
|
};
|
|
}
|
|
|
|
/** PATH: dest(6) + src(6) + MAC(4) + path_data */
|
|
function decodePath_payload(buf) {
|
|
if (buf.length < 16) return { error: 'too short', raw: buf.toString('hex') };
|
|
return {
|
|
destHash: buf.subarray(0, 6).toString('hex'),
|
|
srcHash: buf.subarray(6, 12).toString('hex'),
|
|
mac: buf.subarray(12, 16).toString('hex'),
|
|
pathData: buf.subarray(16).toString('hex'),
|
|
};
|
|
}
|
|
|
|
/** TRACE: flags(1) + tag(4) + dest(6) + src(1) */
|
|
function decodeTrace(buf) {
|
|
if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') };
|
|
return {
|
|
flags: buf[0],
|
|
tag: buf.readUInt32LE(1),
|
|
destHash: buf.subarray(5, 11).toString('hex'),
|
|
srcHash: buf.subarray(11, 12).toString('hex'),
|
|
};
|
|
}
|
|
|
|
// Dispatcher
|
|
function decodePayload(type, buf, channelKeys) {
|
|
switch (type) {
|
|
case 0x00: return { type: 'REQ', ...decodeEncryptedPayload(buf) };
|
|
case 0x01: return { type: 'RESPONSE', ...decodeEncryptedPayload(buf) };
|
|
case 0x02: return { type: 'TXT_MSG', ...decodeEncryptedPayload(buf) };
|
|
case 0x03: return { type: 'ACK', ...decodeAck(buf) };
|
|
case 0x04: return { type: 'ADVERT', ...decodeAdvert(buf) };
|
|
case 0x05: return { type: 'GRP_TXT', ...decodeGrpTxt(buf, channelKeys) };
|
|
case 0x07: return { type: 'ANON_REQ', ...decodeAnonReq(buf) };
|
|
case 0x08: return { type: 'PATH', ...decodePath_payload(buf) };
|
|
case 0x09: return { type: 'TRACE', ...decodeTrace(buf) };
|
|
default: return { type: 'UNKNOWN', raw: buf.toString('hex') };
|
|
}
|
|
}
|
|
|
|
// --- Main decoder ---
|
|
|
|
function decodePacket(hexString, channelKeys) {
|
|
const hex = hexString.replace(/\s+/g, '');
|
|
const buf = Buffer.from(hex, 'hex');
|
|
|
|
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
|
|
|
|
const header = decodeHeader(buf[0]);
|
|
const pathByte = buf[1];
|
|
let offset = 2;
|
|
|
|
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT
|
|
let transportCodes = null;
|
|
if (TRANSPORT_ROUTES.has(header.routeType)) {
|
|
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
|
|
transportCodes = {
|
|
nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
|
lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
|
};
|
|
offset += 4;
|
|
}
|
|
|
|
// Path
|
|
const path = decodePath(pathByte, buf, offset);
|
|
offset += path.bytesConsumed;
|
|
|
|
// Payload (rest of buffer)
|
|
const payloadBuf = buf.subarray(offset);
|
|
const payload = decodePayload(header.payloadType, payloadBuf, channelKeys);
|
|
|
|
return {
|
|
header: {
|
|
routeType: header.routeType,
|
|
routeTypeName: header.routeTypeName,
|
|
payloadType: header.payloadType,
|
|
payloadTypeName: header.payloadTypeName,
|
|
payloadVersion: header.payloadVersion,
|
|
},
|
|
transportCodes,
|
|
path: {
|
|
hashSize: path.hashSize,
|
|
hashCount: path.hashCount,
|
|
hops: path.hops,
|
|
},
|
|
payload,
|
|
raw: hex.toUpperCase(),
|
|
};
|
|
}
|
|
|
|
// --- ADVERT validation ---
|
|
|
|
const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']);
|
|
|
|
/**
|
|
* Validate decoded ADVERT data before upserting into the DB.
|
|
* Returns { valid: true } or { valid: false, reason: string }.
|
|
*/
|
|
function validateAdvert(advert) {
|
|
if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' };
|
|
|
|
// pubkey must be at least 16 hex chars (8 bytes) and not all zeros
|
|
const pk = advert.pubKey || '';
|
|
if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` };
|
|
if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' };
|
|
|
|
// lat/lon must be in valid ranges if present
|
|
if (advert.lat != null) {
|
|
if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) {
|
|
return { valid: false, reason: `invalid lat: ${advert.lat}` };
|
|
}
|
|
}
|
|
if (advert.lon != null) {
|
|
if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) {
|
|
return { valid: false, reason: `invalid lon: ${advert.lon}` };
|
|
}
|
|
}
|
|
|
|
// name must not contain control chars (except space) or be garbage
|
|
if (advert.name != null) {
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) {
|
|
return { valid: false, reason: 'name contains control characters' };
|
|
}
|
|
// Reject names that are mostly non-printable or suspiciously long
|
|
if (advert.name.length > 64) {
|
|
return { valid: false, reason: `name too long (${advert.name.length} chars)` };
|
|
}
|
|
}
|
|
|
|
// role derivation check — flags byte should produce a known role
|
|
if (advert.flags) {
|
|
const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion';
|
|
if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` };
|
|
}
|
|
|
|
// timestamp: decoded but not currently used for node storage — skip validation
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
module.exports = { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES };
|
|
|
|
// --- Tests ---
|
|
if (require.main === module) {
|
|
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
|
const pkt1 = decodePacket(
|
|
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
|
);
|
|
console.log(JSON.stringify(pkt1, null, 2));
|
|
console.log();
|
|
|
|
// Assertions
|
|
const assert = (cond, msg) => { if (!cond) throw new Error('ASSERT FAILED: ' + msg); };
|
|
assert(pkt1.header.routeTypeName === 'FLOOD', 'route should be FLOOD');
|
|
assert(pkt1.header.payloadTypeName === 'ADVERT', 'payload should be ADVERT');
|
|
assert(pkt1.path.hashSize === 2, 'hashSize should be 2');
|
|
assert(pkt1.path.hashCount === 5, 'hashCount should be 5');
|
|
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
|
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
|
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
|
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
|
console.log('✅ Test 1 passed\n');
|
|
|
|
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
|
// Build a minimal advert: header=0x11 (FLOOD+ADVERT), pathLen=0x00 (1-byte hashes, 0 hops)
|
|
// Then a minimal advert payload: 32-byte pubkey + 4-byte ts + 64-byte sig + flags(1)
|
|
const fakePubKey = '00'.repeat(32);
|
|
const fakeTs = '78563412'; // LE = 0x12345678
|
|
const fakeSig = 'AA'.repeat(64);
|
|
const flags = '00'; // no location, no name
|
|
const pkt2hex = '1100' + fakePubKey + fakeTs + fakeSig + flags;
|
|
const pkt2 = decodePacket(pkt2hex);
|
|
console.log(JSON.stringify(pkt2, null, 2));
|
|
console.log();
|
|
|
|
assert(pkt2.header.routeTypeName === 'FLOOD', 'route should be FLOOD');
|
|
assert(pkt2.header.payloadTypeName === 'ADVERT', 'payload should be ADVERT');
|
|
assert(pkt2.path.hashSize === 1, 'hashSize should be 1');
|
|
assert(pkt2.path.hashCount === 0, 'hashCount should be 0');
|
|
assert(pkt2.path.hops.length === 0, 'no hops');
|
|
assert(pkt2.payload.timestamp === 0x12345678, 'timestamp');
|
|
console.log('✅ Test 2 passed\n');
|
|
|
|
console.log('All tests passed ✅');
|
|
}
|