mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-19 00:46:15 +00:00
Fix #304: unblock decode endpoint and consolidate decoder
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,739 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Route type constants (header bits 1-0)
|
||||
const (
|
||||
RouteTransportFlood = 0
|
||||
RouteFlood = 1
|
||||
RouteDirect = 2
|
||||
RouteTransportDirect = 3
|
||||
)
|
||||
|
||||
// Payload type constants (header bits 5-2)
|
||||
const (
|
||||
PayloadREQ = 0x00
|
||||
PayloadRESPONSE = 0x01
|
||||
PayloadTXT_MSG = 0x02
|
||||
PayloadACK = 0x03
|
||||
PayloadADVERT = 0x04
|
||||
PayloadGRP_TXT = 0x05
|
||||
PayloadGRP_DATA = 0x06
|
||||
PayloadANON_REQ = 0x07
|
||||
PayloadPATH = 0x08
|
||||
PayloadTRACE = 0x09
|
||||
PayloadMULTIPART = 0x0A
|
||||
PayloadCONTROL = 0x0B
|
||||
PayloadRAW_CUSTOM = 0x0F
|
||||
)
|
||||
|
||||
var routeTypeNames = map[int]string{
|
||||
0: "TRANSPORT_FLOOD",
|
||||
1: "FLOOD",
|
||||
2: "DIRECT",
|
||||
3: "TRANSPORT_DIRECT",
|
||||
}
|
||||
|
||||
var payloadTypeNames = map[int]string{
|
||||
0x00: "REQ",
|
||||
0x01: "RESPONSE",
|
||||
0x02: "TXT_MSG",
|
||||
0x03: "ACK",
|
||||
0x04: "ADVERT",
|
||||
0x05: "GRP_TXT",
|
||||
0x06: "GRP_DATA",
|
||||
0x07: "ANON_REQ",
|
||||
0x08: "PATH",
|
||||
0x09: "TRACE",
|
||||
0x0A: "MULTIPART",
|
||||
0x0B: "CONTROL",
|
||||
0x0F: "RAW_CUSTOM",
|
||||
}
|
||||
|
||||
// Header is the decoded packet header.
|
||||
type Header struct {
|
||||
RouteType int `json:"routeType"`
|
||||
RouteTypeName string `json:"routeTypeName"`
|
||||
PayloadType int `json:"payloadType"`
|
||||
PayloadTypeName string `json:"payloadTypeName"`
|
||||
PayloadVersion int `json:"payloadVersion"`
|
||||
}
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
type Path struct {
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
}
|
||||
|
||||
// AdvertFlags holds decoded advert flag bits.
|
||||
type AdvertFlags struct {
|
||||
Raw int `json:"raw"`
|
||||
Type int `json:"type"`
|
||||
Chat bool `json:"chat"`
|
||||
Repeater bool `json:"repeater"`
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
// Payload is a generic decoded payload. Fields are populated depending on type.
|
||||
type Payload struct {
|
||||
Type string `json:"type"`
|
||||
DestHash string `json:"destHash,omitempty"`
|
||||
SrcHash string `json:"srcHash,omitempty"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
EncryptedData string `json:"encryptedData,omitempty"`
|
||||
ExtraHash string `json:"extraHash,omitempty"`
|
||||
PubKey string `json:"pubKey,omitempty"`
|
||||
Timestamp uint32 `json:"timestamp,omitempty"`
|
||||
TimestampISO string `json:"timestampISO,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Flags *AdvertFlags `json:"flags,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,omitempty"`
|
||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
type DecodedPacket struct {
|
||||
Header Header `json:"header"`
|
||||
TransportCodes *TransportCodes `json:"transportCodes"`
|
||||
Path Path `json:"path"`
|
||||
Payload Payload `json:"payload"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
func decodeHeader(b byte) Header {
|
||||
rt := int(b & 0x03)
|
||||
pt := int((b >> 2) & 0x0F)
|
||||
pv := int((b >> 6) & 0x03)
|
||||
|
||||
rtName := routeTypeNames[rt]
|
||||
if rtName == "" {
|
||||
rtName = "UNKNOWN"
|
||||
}
|
||||
ptName := payloadTypeNames[pt]
|
||||
if ptName == "" {
|
||||
ptName = "UNKNOWN"
|
||||
}
|
||||
|
||||
return Header{
|
||||
RouteType: rt,
|
||||
RouteTypeName: rtName,
|
||||
PayloadType: pt,
|
||||
PayloadTypeName: ptName,
|
||||
PayloadVersion: pv,
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
for i := 0; i < hashCount; i++ {
|
||||
start := offset + i*hashSize
|
||||
end := start + hashSize
|
||||
if end > len(buf) {
|
||||
break
|
||||
}
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
|
||||
}
|
||||
|
||||
return Path{
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}
|
||||
|
||||
func isTransportRoute(routeType int) bool {
|
||||
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
|
||||
}
|
||||
|
||||
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: typeName,
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
EncryptedData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvert(buf []byte) Payload {
|
||||
if len(buf) < 100 {
|
||||
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
pubKey := hex.EncodeToString(buf[0:32])
|
||||
timestamp := binary.LittleEndian.Uint32(buf[32:36])
|
||||
signature := hex.EncodeToString(buf[36:100])
|
||||
appdata := buf[100:]
|
||||
|
||||
p := Payload{
|
||||
Type: "ADVERT",
|
||||
PubKey: pubKey,
|
||||
Timestamp: timestamp,
|
||||
TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)),
|
||||
Signature: signature,
|
||||
}
|
||||
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
Chat: advType == 1,
|
||||
Repeater: advType == 2,
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
off := 1
|
||||
if p.Flags.HasLocation && len(appdata) >= off+8 {
|
||||
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
|
||||
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
|
||||
lat := float64(latRaw) / 1e6
|
||||
lon := float64(lonRaw) / 1e6
|
||||
p.Lat = &lat
|
||||
p.Lon = &lon
|
||||
off += 8
|
||||
}
|
||||
if hasFeat1 && len(appdata) >= off+2 {
|
||||
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat1 = &feat1
|
||||
off += 2
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat2 = &feat2
|
||||
off += 2
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
nameEnd := len(appdata)
|
||||
for i := off; i < len(appdata); i++ {
|
||||
if appdata[i] == 0x00 {
|
||||
nameEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
name := string(appdata[off:nameEnd])
|
||||
name = sanitizeName(name)
|
||||
p.Name = name
|
||||
off = nameEnd
|
||||
// Skip null terminator(s)
|
||||
for off < len(appdata) && appdata[off] == 0x00 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
tempC := float64(tempRaw) / 100.0
|
||||
if batteryMv > 0 && batteryMv <= 10000 {
|
||||
p.BatteryMv = &batteryMv
|
||||
}
|
||||
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
|
||||
if tempRaw >= -5000 && tempRaw <= 10000 {
|
||||
p.TemperatureC = &tempC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// channelDecryptResult holds the decrypted channel message fields.
|
||||
type channelDecryptResult struct {
|
||||
Timestamp uint32
|
||||
Flags byte
|
||||
Sender string
|
||||
Message string
|
||||
}
|
||||
|
||||
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
|
||||
func countNonPrintable(s string) int {
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r < 0x20 && r != '\n' && r != '\t' {
|
||||
count++
|
||||
} else if r == utf8.RuneError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// decryptChannelMessage implements MeshCore channel decryption:
|
||||
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
|
||||
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt (block-by-block, no padding)
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AES cipher: %w", err)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
|
||||
if len(plaintext) < 5 {
|
||||
return nil, fmt.Errorf("decrypted content too short")
|
||||
}
|
||||
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
flags := plaintext[4]
|
||||
messageText := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
|
||||
messageText = messageText[:idx]
|
||||
}
|
||||
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
|
||||
return nil, fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
|
||||
|
||||
// Parse "sender: message" format
|
||||
colonIdx := strings.Index(messageText, ": ")
|
||||
if colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := messageText[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
result.Sender = potentialSender
|
||||
result.Message = messageText[colonIdx+2:]
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
result, err := decryptChannelMessage(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := result.Message
|
||||
if result.Sender != "" && result.Message != "" {
|
||||
text = result.Sender + ": " + result.Message
|
||||
}
|
||||
return Payload{
|
||||
Type: "CHAN",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Sender: result.Sender,
|
||||
Text: text,
|
||||
SenderTimestamp: result.Timestamp,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "ANON_REQ",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
|
||||
MAC: hex.EncodeToString(buf[33:35]),
|
||||
EncryptedData: hex.EncodeToString(buf[35:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodePathPayload(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "PATH",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
PathData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 9 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||
switch payloadType {
|
||||
case PayloadREQ:
|
||||
return decodeEncryptedPayload("REQ", buf)
|
||||
case PayloadRESPONSE:
|
||||
return decodeEncryptedPayload("RESPONSE", buf)
|
||||
case PayloadTXT_MSG:
|
||||
return decodeEncryptedPayload("TXT_MSG", buf)
|
||||
case PayloadACK:
|
||||
return decodeAck(buf)
|
||||
case PayloadADVERT:
|
||||
return decodeAdvert(buf)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf, channelKeys)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
}
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
}
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
Raw: strings.ToUpper(hexString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
|
||||
// It hashes the header byte + payload (skipping path bytes) to produce a
|
||||
// path-independent identifier for the same transmission.
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
buf, err := hex.DecodeString(rawHex)
|
||||
if err != nil || len(buf) < 2 {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
headerByte := buf[0]
|
||||
offset := 1
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
payloadStart := offset + pathBytes
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
payload := buf[payloadStart:]
|
||||
toHash := append([]byte{headerByte}, payload...)
|
||||
|
||||
h := sha256.Sum256(toHash)
|
||||
return hex.EncodeToString(h[:])[:16]
|
||||
}
|
||||
|
||||
// PayloadJSON serializes the payload to JSON for DB storage.
|
||||
func PayloadJSON(p *Payload) string {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ValidateAdvert checks decoded advert data before DB insertion.
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
if p == nil || p.Error != "" {
|
||||
reason := "null advert"
|
||||
if p != nil {
|
||||
reason = p.Error
|
||||
}
|
||||
return false, reason
|
||||
}
|
||||
|
||||
pk := p.PubKey
|
||||
if len(pk) < 16 {
|
||||
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
|
||||
}
|
||||
allZero := true
|
||||
for _, c := range pk {
|
||||
if c != '0' {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
return false, "pubkey is all zeros"
|
||||
}
|
||||
|
||||
if p.Lat != nil {
|
||||
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
|
||||
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
|
||||
}
|
||||
}
|
||||
if p.Lon != nil {
|
||||
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
|
||||
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
if p.Name != "" {
|
||||
for _, c := range p.Name {
|
||||
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
|
||||
return false, "name contains control characters"
|
||||
}
|
||||
}
|
||||
if len(p.Name) > 64 {
|
||||
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
|
||||
func sanitizeName(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, c := range s {
|
||||
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
return "sensor"
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
// Go time from Unix epoch
|
||||
t := unixTime(int64(epoch))
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
package main
|
||||
|
||||
import dec "github.com/corescope/internal/decoder"
|
||||
|
||||
const (
|
||||
RouteTransportFlood = dec.RouteTransportFlood
|
||||
RouteFlood = dec.RouteFlood
|
||||
RouteDirect = dec.RouteDirect
|
||||
RouteTransportDirect = dec.RouteTransportDirect
|
||||
|
||||
PayloadREQ = dec.PayloadREQ
|
||||
PayloadRESPONSE = dec.PayloadRESPONSE
|
||||
PayloadTXT_MSG = dec.PayloadTXT_MSG
|
||||
PayloadACK = dec.PayloadACK
|
||||
PayloadADVERT = dec.PayloadADVERT
|
||||
PayloadGRP_TXT = dec.PayloadGRP_TXT
|
||||
PayloadGRP_DATA = dec.PayloadGRP_DATA
|
||||
PayloadANON_REQ = dec.PayloadANON_REQ
|
||||
PayloadPATH = dec.PayloadPATH
|
||||
PayloadTRACE = dec.PayloadTRACE
|
||||
PayloadMULTIPART = dec.PayloadMULTIPART
|
||||
PayloadCONTROL = dec.PayloadCONTROL
|
||||
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
|
||||
)
|
||||
|
||||
type Header = dec.Header
|
||||
type TransportCodes = dec.TransportCodes
|
||||
type Path = dec.Path
|
||||
type AdvertFlags = dec.AdvertFlags
|
||||
type Payload = dec.Payload
|
||||
type DecodedPacket = dec.DecodedPacket
|
||||
|
||||
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
|
||||
return dec.DecodePacket(hexString, channelKeys)
|
||||
}
|
||||
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
return dec.ComputeContentHash(rawHex)
|
||||
}
|
||||
|
||||
func PayloadJSON(p *Payload) string {
|
||||
return dec.PayloadJSON(p)
|
||||
}
|
||||
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
return dec.ValidateAdvert(p)
|
||||
}
|
||||
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
return dec.AdvertRole(f)
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
return dec.EpochToISO(epoch)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/corescope/ingestor
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/corescope v0.0.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
@@ -21,3 +22,5 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/corescope => ..\..
|
||||
|
||||
@@ -1,537 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Route type constants (header bits 1-0)
|
||||
const (
|
||||
RouteTransportFlood = 0
|
||||
RouteFlood = 1
|
||||
RouteDirect = 2
|
||||
RouteTransportDirect = 3
|
||||
)
|
||||
|
||||
// Payload type constants (header bits 5-2)
|
||||
const (
|
||||
PayloadREQ = 0x00
|
||||
PayloadRESPONSE = 0x01
|
||||
PayloadTXT_MSG = 0x02
|
||||
PayloadACK = 0x03
|
||||
PayloadADVERT = 0x04
|
||||
PayloadGRP_TXT = 0x05
|
||||
PayloadGRP_DATA = 0x06
|
||||
PayloadANON_REQ = 0x07
|
||||
PayloadPATH = 0x08
|
||||
PayloadTRACE = 0x09
|
||||
PayloadMULTIPART = 0x0A
|
||||
PayloadCONTROL = 0x0B
|
||||
PayloadRAW_CUSTOM = 0x0F
|
||||
)
|
||||
|
||||
var routeTypeNames = map[int]string{
|
||||
0: "TRANSPORT_FLOOD",
|
||||
1: "FLOOD",
|
||||
2: "DIRECT",
|
||||
3: "TRANSPORT_DIRECT",
|
||||
}
|
||||
|
||||
// Header is the decoded packet header.
|
||||
type Header struct {
|
||||
RouteType int `json:"routeType"`
|
||||
RouteTypeName string `json:"routeTypeName"`
|
||||
PayloadType int `json:"payloadType"`
|
||||
PayloadTypeName string `json:"payloadTypeName"`
|
||||
PayloadVersion int `json:"payloadVersion"`
|
||||
}
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
type Path struct {
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
}
|
||||
|
||||
// AdvertFlags holds decoded advert flag bits.
|
||||
type AdvertFlags struct {
|
||||
Raw int `json:"raw"`
|
||||
Type int `json:"type"`
|
||||
Chat bool `json:"chat"`
|
||||
Repeater bool `json:"repeater"`
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
// Payload is a generic decoded payload. Fields are populated depending on type.
|
||||
type Payload struct {
|
||||
Type string `json:"type"`
|
||||
DestHash string `json:"destHash,omitempty"`
|
||||
SrcHash string `json:"srcHash,omitempty"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
EncryptedData string `json:"encryptedData,omitempty"`
|
||||
ExtraHash string `json:"extraHash,omitempty"`
|
||||
PubKey string `json:"pubKey,omitempty"`
|
||||
Timestamp uint32 `json:"timestamp,omitempty"`
|
||||
TimestampISO string `json:"timestampISO,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Flags *AdvertFlags `json:"flags,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
type DecodedPacket struct {
|
||||
Header Header `json:"header"`
|
||||
TransportCodes *TransportCodes `json:"transportCodes"`
|
||||
Path Path `json:"path"`
|
||||
Payload Payload `json:"payload"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
func decodeHeader(b byte) Header {
|
||||
rt := int(b & 0x03)
|
||||
pt := int((b >> 2) & 0x0F)
|
||||
pv := int((b >> 6) & 0x03)
|
||||
|
||||
rtName := routeTypeNames[rt]
|
||||
if rtName == "" {
|
||||
rtName = "UNKNOWN"
|
||||
}
|
||||
ptName := payloadTypeNames[pt]
|
||||
if ptName == "" {
|
||||
ptName = "UNKNOWN"
|
||||
}
|
||||
|
||||
return Header{
|
||||
RouteType: rt,
|
||||
RouteTypeName: rtName,
|
||||
PayloadType: pt,
|
||||
PayloadTypeName: ptName,
|
||||
PayloadVersion: pv,
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
for i := 0; i < hashCount; i++ {
|
||||
start := offset + i*hashSize
|
||||
end := start + hashSize
|
||||
if end > len(buf) {
|
||||
break
|
||||
}
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
|
||||
}
|
||||
|
||||
return Path{
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}
|
||||
|
||||
func isTransportRoute(routeType int) bool {
|
||||
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
|
||||
}
|
||||
|
||||
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: typeName,
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
EncryptedData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvert(buf []byte) Payload {
|
||||
if len(buf) < 100 {
|
||||
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
pubKey := hex.EncodeToString(buf[0:32])
|
||||
timestamp := binary.LittleEndian.Uint32(buf[32:36])
|
||||
signature := hex.EncodeToString(buf[36:100])
|
||||
appdata := buf[100:]
|
||||
|
||||
p := Payload{
|
||||
Type: "ADVERT",
|
||||
PubKey: pubKey,
|
||||
Timestamp: timestamp,
|
||||
TimestampISO: fmt.Sprintf("%s", epochToISO(timestamp)),
|
||||
Signature: signature,
|
||||
}
|
||||
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
Chat: advType == 1,
|
||||
Repeater: advType == 2,
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
off := 1
|
||||
if p.Flags.HasLocation && len(appdata) >= off+8 {
|
||||
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
|
||||
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
|
||||
lat := float64(latRaw) / 1e6
|
||||
lon := float64(lonRaw) / 1e6
|
||||
p.Lat = &lat
|
||||
p.Lon = &lon
|
||||
off += 8
|
||||
}
|
||||
if hasFeat1 && len(appdata) >= off+2 {
|
||||
off += 2 // skip feat1 bytes (reserved for future use)
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
off += 2 // skip feat2 bytes (reserved for future use)
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
name := string(appdata[off:])
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
name = sanitizeName(name)
|
||||
p.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: int(buf[0]),
|
||||
MAC: hex.EncodeToString(buf[1:3]),
|
||||
EncryptedData: hex.EncodeToString(buf[3:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "ANON_REQ",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
|
||||
MAC: hex.EncodeToString(buf[33:35]),
|
||||
EncryptedData: hex.EncodeToString(buf[35:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodePathPayload(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "PATH",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
PathData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 9 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte) Payload {
|
||||
switch payloadType {
|
||||
case PayloadREQ:
|
||||
return decodeEncryptedPayload("REQ", buf)
|
||||
case PayloadRESPONSE:
|
||||
return decodeEncryptedPayload("RESPONSE", buf)
|
||||
case PayloadTXT_MSG:
|
||||
return decodeEncryptedPayload("TXT_MSG", buf)
|
||||
case PayloadACK:
|
||||
return decodeAck(buf)
|
||||
case PayloadADVERT:
|
||||
return decodeAdvert(buf)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
}
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
}
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
Raw: strings.ToUpper(hexString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
buf, err := hex.DecodeString(rawHex)
|
||||
if err != nil || len(buf) < 2 {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
headerByte := buf[0]
|
||||
offset := 1
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
payloadStart := offset + pathBytes
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
payload := buf[payloadStart:]
|
||||
toHash := append([]byte{headerByte}, payload...)
|
||||
|
||||
h := sha256.Sum256(toHash)
|
||||
return hex.EncodeToString(h[:])[:16]
|
||||
}
|
||||
|
||||
// PayloadJSON serializes the payload to JSON for DB storage.
|
||||
func PayloadJSON(p *Payload) string {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ValidateAdvert checks decoded advert data before DB insertion.
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
if p == nil || p.Error != "" {
|
||||
reason := "null advert"
|
||||
if p != nil {
|
||||
reason = p.Error
|
||||
}
|
||||
return false, reason
|
||||
}
|
||||
|
||||
pk := p.PubKey
|
||||
if len(pk) < 16 {
|
||||
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
|
||||
}
|
||||
allZero := true
|
||||
for _, c := range pk {
|
||||
if c != '0' {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
return false, "pubkey is all zeros"
|
||||
}
|
||||
|
||||
if p.Lat != nil {
|
||||
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
|
||||
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
|
||||
}
|
||||
}
|
||||
if p.Lon != nil {
|
||||
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
|
||||
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
if p.Name != "" {
|
||||
for _, c := range p.Name {
|
||||
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
|
||||
return false, "name contains control characters"
|
||||
}
|
||||
}
|
||||
if len(p.Name) > 64 {
|
||||
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
|
||||
func sanitizeName(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, c := range s {
|
||||
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
return "sensor"
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
t := time.Unix(int64(epoch), 0)
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
package main
|
||||
|
||||
import dec "github.com/corescope/internal/decoder"
|
||||
|
||||
const (
|
||||
RouteTransportFlood = dec.RouteTransportFlood
|
||||
RouteFlood = dec.RouteFlood
|
||||
RouteDirect = dec.RouteDirect
|
||||
RouteTransportDirect = dec.RouteTransportDirect
|
||||
|
||||
PayloadREQ = dec.PayloadREQ
|
||||
PayloadRESPONSE = dec.PayloadRESPONSE
|
||||
PayloadTXT_MSG = dec.PayloadTXT_MSG
|
||||
PayloadACK = dec.PayloadACK
|
||||
PayloadADVERT = dec.PayloadADVERT
|
||||
PayloadGRP_TXT = dec.PayloadGRP_TXT
|
||||
PayloadGRP_DATA = dec.PayloadGRP_DATA
|
||||
PayloadANON_REQ = dec.PayloadANON_REQ
|
||||
PayloadPATH = dec.PayloadPATH
|
||||
PayloadTRACE = dec.PayloadTRACE
|
||||
PayloadMULTIPART = dec.PayloadMULTIPART
|
||||
PayloadCONTROL = dec.PayloadCONTROL
|
||||
PayloadRAW_CUSTOM = dec.PayloadRAW_CUSTOM
|
||||
)
|
||||
|
||||
type Header = dec.Header
|
||||
type TransportCodes = dec.TransportCodes
|
||||
type Path = dec.Path
|
||||
type AdvertFlags = dec.AdvertFlags
|
||||
type Payload = dec.Payload
|
||||
type DecodedPacket = dec.DecodedPacket
|
||||
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
return dec.DecodePacket(hexString, nil)
|
||||
}
|
||||
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
return dec.ComputeContentHash(rawHex)
|
||||
}
|
||||
|
||||
func PayloadJSON(p *Payload) string {
|
||||
return dec.PayloadJSON(p)
|
||||
}
|
||||
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
return dec.ValidateAdvert(p)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/corescope/server
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/corescope v0.0.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
modernc.org/sqlite v1.34.5
|
||||
@@ -19,3 +20,5 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/corescope => ..\..
|
||||
|
||||
@@ -1308,12 +1308,13 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
|
||||
hash := mux.Vars(r)["hash"]
|
||||
limit := queryInt(r, "limit", 100)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
region := normalizeRegionParam(r.URL.Query().Get("region"))
|
||||
if s.store != nil {
|
||||
messages, total := s.store.GetChannelMessages(hash, limit, offset)
|
||||
messages, total := s.store.GetChannelMessages(hash, limit, offset, region)
|
||||
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
|
||||
return
|
||||
}
|
||||
messages, total, err := s.db.GetChannelMessages(hash, limit, offset)
|
||||
messages, total, err := s.db.GetChannelMessages(hash, limit, offset, region)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
|
||||
740
internal/decoder/decoder.go
Normal file
740
internal/decoder/decoder.go
Normal file
@@ -0,0 +1,740 @@
|
||||
package decoder
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Route type constants (header bits 1-0)
|
||||
const (
|
||||
RouteTransportFlood = 0
|
||||
RouteFlood = 1
|
||||
RouteDirect = 2
|
||||
RouteTransportDirect = 3
|
||||
)
|
||||
|
||||
// Payload type constants (header bits 5-2)
|
||||
const (
|
||||
PayloadREQ = 0x00
|
||||
PayloadRESPONSE = 0x01
|
||||
PayloadTXT_MSG = 0x02
|
||||
PayloadACK = 0x03
|
||||
PayloadADVERT = 0x04
|
||||
PayloadGRP_TXT = 0x05
|
||||
PayloadGRP_DATA = 0x06
|
||||
PayloadANON_REQ = 0x07
|
||||
PayloadPATH = 0x08
|
||||
PayloadTRACE = 0x09
|
||||
PayloadMULTIPART = 0x0A
|
||||
PayloadCONTROL = 0x0B
|
||||
PayloadRAW_CUSTOM = 0x0F
|
||||
)
|
||||
|
||||
var routeTypeNames = map[int]string{
|
||||
0: "TRANSPORT_FLOOD",
|
||||
1: "FLOOD",
|
||||
2: "DIRECT",
|
||||
3: "TRANSPORT_DIRECT",
|
||||
}
|
||||
|
||||
var payloadTypeNames = map[int]string{
|
||||
0x00: "REQ",
|
||||
0x01: "RESPONSE",
|
||||
0x02: "TXT_MSG",
|
||||
0x03: "ACK",
|
||||
0x04: "ADVERT",
|
||||
0x05: "GRP_TXT",
|
||||
0x06: "GRP_DATA",
|
||||
0x07: "ANON_REQ",
|
||||
0x08: "PATH",
|
||||
0x09: "TRACE",
|
||||
0x0A: "MULTIPART",
|
||||
0x0B: "CONTROL",
|
||||
0x0F: "RAW_CUSTOM",
|
||||
}
|
||||
|
||||
// Header is the decoded packet header.
|
||||
type Header struct {
|
||||
RouteType int `json:"routeType"`
|
||||
RouteTypeName string `json:"routeTypeName"`
|
||||
PayloadType int `json:"payloadType"`
|
||||
PayloadTypeName string `json:"payloadTypeName"`
|
||||
PayloadVersion int `json:"payloadVersion"`
|
||||
}
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
type Path struct {
|
||||
HashSize int `json:"hashSize"`
|
||||
HashCount int `json:"hashCount"`
|
||||
Hops []string `json:"hops"`
|
||||
}
|
||||
|
||||
// AdvertFlags holds decoded advert flag bits.
|
||||
type AdvertFlags struct {
|
||||
Raw int `json:"raw"`
|
||||
Type int `json:"type"`
|
||||
Chat bool `json:"chat"`
|
||||
Repeater bool `json:"repeater"`
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
// Payload is a generic decoded payload. Fields are populated depending on type.
|
||||
type Payload struct {
|
||||
Type string `json:"type"`
|
||||
DestHash string `json:"destHash,omitempty"`
|
||||
SrcHash string `json:"srcHash,omitempty"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
EncryptedData string `json:"encryptedData,omitempty"`
|
||||
ExtraHash string `json:"extraHash,omitempty"`
|
||||
PubKey string `json:"pubKey,omitempty"`
|
||||
Timestamp uint32 `json:"timestamp,omitempty"`
|
||||
TimestampISO string `json:"timestampISO,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Flags *AdvertFlags `json:"flags,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,omitempty"`
|
||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
type DecodedPacket struct {
|
||||
Header Header `json:"header"`
|
||||
TransportCodes *TransportCodes `json:"transportCodes"`
|
||||
Path Path `json:"path"`
|
||||
Payload Payload `json:"payload"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
func decodeHeader(b byte) Header {
|
||||
rt := int(b & 0x03)
|
||||
pt := int((b >> 2) & 0x0F)
|
||||
pv := int((b >> 6) & 0x03)
|
||||
|
||||
rtName := routeTypeNames[rt]
|
||||
if rtName == "" {
|
||||
rtName = "UNKNOWN"
|
||||
}
|
||||
ptName := payloadTypeNames[pt]
|
||||
if ptName == "" {
|
||||
ptName = "UNKNOWN"
|
||||
}
|
||||
|
||||
return Header{
|
||||
RouteType: rt,
|
||||
RouteTypeName: rtName,
|
||||
PayloadType: pt,
|
||||
PayloadTypeName: ptName,
|
||||
PayloadVersion: pv,
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
for i := 0; i < hashCount; i++ {
|
||||
start := offset + i*hashSize
|
||||
end := start + hashSize
|
||||
if end > len(buf) {
|
||||
break
|
||||
}
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(buf[start:end])))
|
||||
}
|
||||
|
||||
return Path{
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}
|
||||
|
||||
func isTransportRoute(routeType int) bool {
|
||||
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
|
||||
}
|
||||
|
||||
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: typeName,
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
EncryptedData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvert(buf []byte) Payload {
|
||||
if len(buf) < 100 {
|
||||
return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
pubKey := hex.EncodeToString(buf[0:32])
|
||||
timestamp := binary.LittleEndian.Uint32(buf[32:36])
|
||||
signature := hex.EncodeToString(buf[36:100])
|
||||
appdata := buf[100:]
|
||||
|
||||
p := Payload{
|
||||
Type: "ADVERT",
|
||||
PubKey: pubKey,
|
||||
Timestamp: timestamp,
|
||||
TimestampISO: fmt.Sprintf("%s", EpochToISO(timestamp)),
|
||||
Signature: signature,
|
||||
}
|
||||
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
Chat: advType == 1,
|
||||
Repeater: advType == 2,
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
off := 1
|
||||
if p.Flags.HasLocation && len(appdata) >= off+8 {
|
||||
latRaw := int32(binary.LittleEndian.Uint32(appdata[off : off+4]))
|
||||
lonRaw := int32(binary.LittleEndian.Uint32(appdata[off+4 : off+8]))
|
||||
lat := float64(latRaw) / 1e6
|
||||
lon := float64(lonRaw) / 1e6
|
||||
p.Lat = &lat
|
||||
p.Lon = &lon
|
||||
off += 8
|
||||
}
|
||||
if hasFeat1 && len(appdata) >= off+2 {
|
||||
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat1 = &feat1
|
||||
off += 2
|
||||
}
|
||||
if hasFeat2 && len(appdata) >= off+2 {
|
||||
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
p.Feat2 = &feat2
|
||||
off += 2
|
||||
}
|
||||
if p.Flags.HasName {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
nameEnd := len(appdata)
|
||||
for i := off; i < len(appdata); i++ {
|
||||
if appdata[i] == 0x00 {
|
||||
nameEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
name := string(appdata[off:nameEnd])
|
||||
name = sanitizeName(name)
|
||||
p.Name = name
|
||||
off = nameEnd
|
||||
// Skip null terminator(s)
|
||||
for off < len(appdata) && appdata[off] == 0x00 {
|
||||
off++
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
tempC := float64(tempRaw) / 100.0
|
||||
if batteryMv > 0 && batteryMv <= 10000 {
|
||||
p.BatteryMv = &batteryMv
|
||||
}
|
||||
// Raw int16 / 100 → °C; accept -50°C to 100°C (raw: -5000 to 10000)
|
||||
if tempRaw >= -5000 && tempRaw <= 10000 {
|
||||
p.TemperatureC = &tempC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// channelDecryptResult holds the decrypted channel message fields.
|
||||
type channelDecryptResult struct {
|
||||
Timestamp uint32
|
||||
Flags byte
|
||||
Sender string
|
||||
Message string
|
||||
}
|
||||
|
||||
// countNonPrintable counts characters that are non-printable (< 0x20 except \n, \t).
|
||||
func countNonPrintable(s string) int {
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
if r < 0x20 && r != '\n' && r != '\t' {
|
||||
count++
|
||||
} else if r == utf8.RuneError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// decryptChannelMessage implements MeshCore channel decryption:
|
||||
// HMAC-SHA256 MAC verification followed by AES-128-ECB decryption.
|
||||
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
|
||||
// 32-byte channel secret: 16-byte key + 16 zero bytes
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
|
||||
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calculatedMac := h.Sum(nil)
|
||||
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
|
||||
// AES-128-ECB decrypt (block-by-block, no padding)
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AES cipher: %w", err)
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
|
||||
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
|
||||
if len(plaintext) < 5 {
|
||||
return nil, fmt.Errorf("decrypted content too short")
|
||||
}
|
||||
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
|
||||
flags := plaintext[4]
|
||||
messageText := string(plaintext[5:])
|
||||
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
|
||||
messageText = messageText[:idx]
|
||||
}
|
||||
|
||||
// Validate decrypted text is printable UTF-8 (not binary garbage)
|
||||
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
|
||||
return nil, fmt.Errorf("decrypted text contains non-printable characters")
|
||||
}
|
||||
|
||||
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
|
||||
|
||||
// Parse "sender: message" format
|
||||
colonIdx := strings.Index(messageText, ": ")
|
||||
if colonIdx > 0 && colonIdx < 50 {
|
||||
potentialSender := messageText[:colonIdx]
|
||||
if !strings.ContainsAny(potentialSender, ":[]") {
|
||||
result.Sender = potentialSender
|
||||
result.Message = messageText[colonIdx+2:]
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
} else {
|
||||
result.Message = messageText
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
// Match Node.js: only attempt decryption if encrypted data >= 5 bytes (10 hex chars)
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
result, err := decryptChannelMessage(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := result.Message
|
||||
if result.Sender != "" && result.Message != "" {
|
||||
text = result.Sender + ": " + result.Message
|
||||
}
|
||||
return Payload{
|
||||
Type: "CHAN",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Sender: result.Sender,
|
||||
Text: text,
|
||||
SenderTimestamp: result.Timestamp,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_TXT",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "ANON_REQ",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
EphemeralPubKey: hex.EncodeToString(buf[1:33]),
|
||||
MAC: hex.EncodeToString(buf[33:35]),
|
||||
EncryptedData: hex.EncodeToString(buf[35:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodePathPayload(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: "PATH", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "PATH",
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
MAC: hex.EncodeToString(buf[2:4]),
|
||||
PathData: hex.EncodeToString(buf[4:]),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 9 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||
switch payloadType {
|
||||
case PayloadREQ:
|
||||
return decodeEncryptedPayload("REQ", buf)
|
||||
case PayloadRESPONSE:
|
||||
return decodeEncryptedPayload("RESPONSE", buf)
|
||||
case PayloadTXT_MSG:
|
||||
return decodeEncryptedPayload("TXT_MSG", buf)
|
||||
case PayloadACK:
|
||||
return decodeAck(buf)
|
||||
case PayloadADVERT:
|
||||
return decodeAdvert(buf)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf, channelKeys)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
}
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
// path field. The header path byte still encodes hashSize in bits 6-7, which
|
||||
// we use to split the payload path data into individual hop prefixes.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize {
|
||||
hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize])))
|
||||
}
|
||||
path.Hops = hops
|
||||
path.HashCount = len(hops)
|
||||
}
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
TransportCodes: tc,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
Raw: strings.ToUpper(hexString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
|
||||
// It hashes the header byte + payload (skipping path bytes) to produce a
|
||||
// path-independent identifier for the same transmission.
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
buf, err := hex.DecodeString(rawHex)
|
||||
if err != nil || len(buf) < 2 {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
headerByte := buf[0]
|
||||
offset := 1
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
payloadStart := offset + pathBytes
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
}
|
||||
return rawHex
|
||||
}
|
||||
|
||||
payload := buf[payloadStart:]
|
||||
toHash := append([]byte{headerByte}, payload...)
|
||||
|
||||
h := sha256.Sum256(toHash)
|
||||
return hex.EncodeToString(h[:])[:16]
|
||||
}
|
||||
|
||||
// PayloadJSON serializes the payload to JSON for DB storage.
|
||||
func PayloadJSON(p *Payload) string {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ValidateAdvert checks decoded advert data before DB insertion.
|
||||
func ValidateAdvert(p *Payload) (bool, string) {
|
||||
if p == nil || p.Error != "" {
|
||||
reason := "null advert"
|
||||
if p != nil {
|
||||
reason = p.Error
|
||||
}
|
||||
return false, reason
|
||||
}
|
||||
|
||||
pk := p.PubKey
|
||||
if len(pk) < 16 {
|
||||
return false, fmt.Sprintf("pubkey too short (%d hex chars)", len(pk))
|
||||
}
|
||||
allZero := true
|
||||
for _, c := range pk {
|
||||
if c != '0' {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
return false, "pubkey is all zeros"
|
||||
}
|
||||
|
||||
if p.Lat != nil {
|
||||
if math.IsInf(*p.Lat, 0) || math.IsNaN(*p.Lat) || *p.Lat < -90 || *p.Lat > 90 {
|
||||
return false, fmt.Sprintf("invalid lat: %f", *p.Lat)
|
||||
}
|
||||
}
|
||||
if p.Lon != nil {
|
||||
if math.IsInf(*p.Lon, 0) || math.IsNaN(*p.Lon) || *p.Lon < -180 || *p.Lon > 180 {
|
||||
return false, fmt.Sprintf("invalid lon: %f", *p.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
if p.Name != "" {
|
||||
for _, c := range p.Name {
|
||||
if (c >= 0x00 && c <= 0x08) || c == 0x0b || c == 0x0c || (c >= 0x0e && c <= 0x1f) || c == 0x7f {
|
||||
return false, "name contains control characters"
|
||||
}
|
||||
}
|
||||
if len(p.Name) > 64 {
|
||||
return false, fmt.Sprintf("name too long (%d chars)", len(p.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if p.Flags != nil {
|
||||
role := AdvertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// sanitizeName strips non-printable characters (< 0x20 except tab/newline) and DEL.
|
||||
func sanitizeName(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, c := range s {
|
||||
if c == '\t' || c == '\n' || (c >= 0x20 && c != 0x7f) {
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func AdvertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
return "sensor"
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func EpochToISO(epoch uint32) string {
|
||||
// Go time from Unix epoch
|
||||
t := time.Unix(int64(epoch), 0)
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user