From 295fa0e6ee4da5d50ea1a09f4f11fdd2eeec9180 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:10:46 -0700 Subject: [PATCH] Fix #304: unblock decode endpoint and consolidate decoder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/ingestor/decoder.go | 794 +---- cmd/ingestor/go.mod | 3 + cmd/server/decoder.go | 584 +--- cmd/server/go.mod | 3 + cmd/server/routes.go | 5 +- go.mod | 3 + internal/decoder/decoder.go | 740 ++++ .../decoder}/decoder_test.go | 3088 ++++++++--------- 8 files changed, 2398 insertions(+), 2822 deletions(-) create mode 100644 go.mod create mode 100644 internal/decoder/decoder.go rename {cmd/ingestor => internal/decoder}/decoder_test.go (93%) diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index 78cc4f7..3ce96cc 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -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) +} diff --git a/cmd/ingestor/go.mod b/cmd/ingestor/go.mod index cc2098e..41d21f8 100644 --- a/cmd/ingestor/go.mod +++ b/cmd/ingestor/go.mod @@ -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 => ..\.. diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index c67fa9e..c017a56 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -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) +} diff --git a/cmd/server/go.mod b/cmd/server/go.mod index 1ef2c8a..1de431e 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -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 => ..\.. diff --git a/cmd/server/routes.go b/cmd/server/routes.go index bee1d25..25e8af3 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2e7c44d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/corescope + +go 1.22 diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go new file mode 100644 index 0000000..8824e9b --- /dev/null +++ b/internal/decoder/decoder.go @@ -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") +} diff --git a/cmd/ingestor/decoder_test.go b/internal/decoder/decoder_test.go similarity index 93% rename from cmd/ingestor/decoder_test.go rename to internal/decoder/decoder_test.go index 1b60bc9..b4482db 100644 --- a/cmd/ingestor/decoder_test.go +++ b/internal/decoder/decoder_test.go @@ -1,1544 +1,1544 @@ -package main - -import ( - "crypto/aes" - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "math" - "strings" - "testing" -) - -func TestDecodeHeaderRoutTypes(t *testing.T) { - tests := []struct { - b byte - rt int - name string - }{ - {0x00, 0, "TRANSPORT_FLOOD"}, - {0x01, 1, "FLOOD"}, - {0x02, 2, "DIRECT"}, - {0x03, 3, "TRANSPORT_DIRECT"}, - } - for _, tt := range tests { - h := decodeHeader(tt.b) - if h.RouteType != tt.rt { - t.Errorf("header 0x%02X: routeType=%d, want %d", tt.b, h.RouteType, tt.rt) - } - if h.RouteTypeName != tt.name { - t.Errorf("header 0x%02X: routeTypeName=%s, want %s", tt.b, h.RouteTypeName, tt.name) - } - } -} - -func TestDecodeHeaderPayloadTypes(t *testing.T) { - // 0x11 = 0b00_0100_01 → routeType=1(FLOOD), payloadType=4(ADVERT), version=0 - h := decodeHeader(0x11) - if h.RouteType != 1 { - t.Errorf("0x11: routeType=%d, want 1", h.RouteType) - } - if h.PayloadType != 4 { - t.Errorf("0x11: payloadType=%d, want 4", h.PayloadType) - } - if h.PayloadVersion != 0 { - t.Errorf("0x11: payloadVersion=%d, want 0", h.PayloadVersion) - } - if h.RouteTypeName != "FLOOD" { - t.Errorf("0x11: routeTypeName=%s, want FLOOD", h.RouteTypeName) - } - if h.PayloadTypeName != "ADVERT" { - t.Errorf("0x11: payloadTypeName=%s, want ADVERT", h.PayloadTypeName) - } -} - -func TestDecodePathZeroHops(t *testing.T) { - // 0x00: 0 hops, 1-byte hashes - pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) - if err != nil { - t.Fatal(err) - } - if pkt.Path.HashCount != 0 { - t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount) - } - if pkt.Path.HashSize != 1 { - t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize) - } - if len(pkt.Path.Hops) != 0 { - t.Errorf("hops=%d, want 0", len(pkt.Path.Hops)) - } -} - -func TestDecodePath1ByteHashes(t *testing.T) { - // 0x05: 5 hops, 1-byte hashes → 5 path bytes - pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil) - if err != nil { - t.Fatal(err) - } - if pkt.Path.HashCount != 5 { - t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) - } - if pkt.Path.HashSize != 1 { - t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize) - } - if len(pkt.Path.Hops) != 5 { - t.Fatalf("hops=%d, want 5", len(pkt.Path.Hops)) - } - if pkt.Path.Hops[0] != "AA" { - t.Errorf("hop[0]=%s, want AA", pkt.Path.Hops[0]) - } - if pkt.Path.Hops[4] != "EE" { - t.Errorf("hop[4]=%s, want EE", pkt.Path.Hops[4]) - } -} - -func TestDecodePath2ByteHashes(t *testing.T) { - // 0x45: 5 hops, 2-byte hashes - pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil) - if err != nil { - t.Fatal(err) - } - if pkt.Path.HashCount != 5 { - t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) - } - if pkt.Path.HashSize != 2 { - t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize) - } - if pkt.Path.Hops[0] != "AA11" { - t.Errorf("hop[0]=%s, want AA11", pkt.Path.Hops[0]) - } -} - -func TestDecodePath3ByteHashes(t *testing.T) { - // 0x8A: 10 hops, 3-byte hashes - pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil) - if err != nil { - t.Fatal(err) - } - if pkt.Path.HashCount != 10 { - t.Errorf("hashCount=%d, want 10", pkt.Path.HashCount) - } - if pkt.Path.HashSize != 3 { - t.Errorf("hashSize=%d, want 3", pkt.Path.HashSize) - } - if len(pkt.Path.Hops) != 10 { - t.Errorf("hops=%d, want 10", len(pkt.Path.Hops)) - } -} - -func TestTransportCodes(t *testing.T) { - // Route type 0 (TRANSPORT_FLOOD) should have transport codes - // Firmware order: header + transport_codes(4) + path_len + path + payload - hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10) - pkt, err := DecodePacket(hex, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Header.RouteType != 0 { - t.Errorf("routeType=%d, want 0", pkt.Header.RouteType) - } - if pkt.TransportCodes == nil { - t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD") - } - if pkt.TransportCodes.Code1 != "AABB" { - t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1) - } - if pkt.TransportCodes.Code2 != "CCDD" { - t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2) - } - - // Route type 1 (FLOOD) should NOT have transport codes - pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) - if err != nil { - t.Fatal(err) - } - if pkt2.TransportCodes != nil { - t.Error("FLOOD should not have transport codes") - } -} - -func TestDecodeAdvertFull(t *testing.T) { - pubkey := strings.Repeat("AA", 32) - timestamp := "78563412" // 0x12345678 LE - signature := strings.Repeat("BB", 64) - // flags: 0x92 = repeater(2) | hasLocation(0x10) | hasName(0x80) - flags := "92" - lat := "40933402" // ~37.0 - lon := "E0E6B8F8" // ~-122.1 - name := "546573744E6F6465" // "TestNode" - - hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name - pkt, err := DecodePacket(hex, nil) - if err != nil { - t.Fatal(err) - } - - if pkt.Payload.Type != "ADVERT" { - t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) - } - if pkt.Payload.PubKey != strings.ToLower(pubkey) { - t.Errorf("pubkey mismatch") - } - if pkt.Payload.Timestamp != 0x12345678 { - t.Errorf("timestamp=%d, want %d", pkt.Payload.Timestamp, 0x12345678) - } - - if pkt.Payload.Flags == nil { - t.Fatal("flags should not be nil") - } - if pkt.Payload.Flags.Raw != 0x92 { - t.Errorf("flags.raw=%d, want 0x92", pkt.Payload.Flags.Raw) - } - if pkt.Payload.Flags.Type != 2 { - t.Errorf("flags.type=%d, want 2", pkt.Payload.Flags.Type) - } - if !pkt.Payload.Flags.Repeater { - t.Error("flags.repeater should be true") - } - if pkt.Payload.Flags.Room { - t.Error("flags.room should be false") - } - if !pkt.Payload.Flags.HasLocation { - t.Error("flags.hasLocation should be true") - } - if !pkt.Payload.Flags.HasName { - t.Error("flags.hasName should be true") - } - - if pkt.Payload.Lat == nil { - t.Fatal("lat should not be nil") - } - if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 { - t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat) - } - if pkt.Payload.Lon == nil { - t.Fatal("lon should not be nil") - } - if math.Abs(*pkt.Payload.Lon-(-122.1)) > 0.001 { - t.Errorf("lon=%f, want ~-122.1", *pkt.Payload.Lon) - } - if pkt.Payload.Name != "TestNode" { - t.Errorf("name=%s, want TestNode", pkt.Payload.Name) - } -} - -func TestDecodeAdvertTypeEnums(t *testing.T) { - makeAdvert := func(flagsByte byte) *DecodedPacket { - hex := "1200" + strings.Repeat("AA", 32) + "00000000" + strings.Repeat("BB", 64) + - strings.ToUpper(string([]byte{hexDigit(flagsByte>>4), hexDigit(flagsByte & 0x0f)})) - pkt, err := DecodePacket(hex, nil) - if err != nil { - t.Fatal(err) - } - return pkt - } - - // type 1 = chat/companion - p1 := makeAdvert(0x01) - if p1.Payload.Flags.Type != 1 { - t.Errorf("type 1: flags.type=%d", p1.Payload.Flags.Type) - } - if !p1.Payload.Flags.Chat { - t.Error("type 1: chat should be true") - } - - // type 2 = repeater - p2 := makeAdvert(0x02) - if !p2.Payload.Flags.Repeater { - t.Error("type 2: repeater should be true") - } - - // type 3 = room - p3 := makeAdvert(0x03) - if !p3.Payload.Flags.Room { - t.Error("type 3: room should be true") - } - - // type 4 = sensor - p4 := makeAdvert(0x04) - if !p4.Payload.Flags.Sensor { - t.Error("type 4: sensor should be true") - } -} - -func hexDigit(v byte) byte { - v = v & 0x0f - if v < 10 { - return '0' + v - } - return 'a' + v - 10 -} - -func TestDecodeAdvertNoLocationNoName(t *testing.T) { - hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02" - pkt, err := DecodePacket(hex, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.Flags.HasLocation { - t.Error("hasLocation should be false") - } - if pkt.Payload.Flags.HasName { - t.Error("hasName should be false") - } - if pkt.Payload.Lat != nil { - t.Error("lat should be nil") - } - if pkt.Payload.Name != "" { - t.Errorf("name should be empty, got %s", pkt.Payload.Name) - } -} - -func TestGoldenFixtureTxtMsg(t *testing.T) { - pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil) - if err != nil { - t.Fatal(err) - } - if pkt.Header.PayloadType != PayloadTXT_MSG { - t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG) - } - if pkt.Header.RouteType != RouteDirect { - t.Errorf("routeType=%d, want %d", pkt.Header.RouteType, RouteDirect) - } - if pkt.Path.HashCount != 0 { - t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount) - } - if pkt.Payload.DestHash != "d6" { - t.Errorf("destHash=%s, want d6", pkt.Payload.DestHash) - } - if pkt.Payload.SrcHash != "9f" { - t.Errorf("srcHash=%s, want 9f", pkt.Payload.SrcHash) - } -} - -func TestGoldenFixtureAdvert(t *testing.T) { - rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" - pkt, err := DecodePacket(rawHex, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.Type != "ADVERT" { - t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) - } - if pkt.Payload.PubKey != "46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84" { - t.Errorf("pubKey mismatch: %s", pkt.Payload.PubKey) - } - if pkt.Payload.Flags == nil || !pkt.Payload.Flags.Repeater { - t.Error("should be repeater") - } - if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 { - t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat) - } - if pkt.Payload.Name != "MRR2-R" { - t.Errorf("name=%s, want MRR2-R", pkt.Payload.Name) - } -} - -func TestGoldenFixtureUnicodeAdvert(t *testing.T) { - rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3" - pkt, err := DecodePacket(rawHex, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.Type != "ADVERT" { - t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) - } - if !pkt.Payload.Flags.Repeater { - t.Error("should be repeater") - } - // Name contains emoji: PEAK🌳 - if !strings.HasPrefix(pkt.Payload.Name, "PEAK") { - t.Errorf("name=%s, expected to start with PEAK", pkt.Payload.Name) - } -} - -func TestDecodePacketTooShort(t *testing.T) { - _, err := DecodePacket("FF", nil) - if err == nil { - t.Error("expected error for 1-byte packet") - } -} - -func TestDecodePacketInvalidHex(t *testing.T) { - _, err := DecodePacket("ZZZZ", nil) - if err == nil { - t.Error("expected error for invalid hex") - } -} - -func TestComputeContentHash(t *testing.T) { - hash := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976") - if len(hash) != 16 { - t.Errorf("hash length=%d, want 16", len(hash)) - } - // Same content with different path should produce same hash - // (path bytes are stripped, only header + payload hashed) - - // Verify consistency - hash2 := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976") - if hash != hash2 { - t.Error("content hash not deterministic") - } -} - -func TestValidateAdvert(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - - // Good advert - good := &Payload{PubKey: goodPk, Flags: &AdvertFlags{Repeater: true}} - ok, _ := ValidateAdvert(good) - if !ok { - t.Error("good advert should validate") - } - - // Nil - ok, _ = ValidateAdvert(nil) - if ok { - t.Error("nil should fail") - } - - // Error payload - ok, _ = ValidateAdvert(&Payload{Error: "bad"}) - if ok { - t.Error("error payload should fail") - } - - // Short pubkey - ok, _ = ValidateAdvert(&Payload{PubKey: "aa"}) - if ok { - t.Error("short pubkey should fail") - } - - // All-zero pubkey - ok, _ = ValidateAdvert(&Payload{PubKey: strings.Repeat("0", 64)}) - if ok { - t.Error("all-zero pubkey should fail") - } - - // Invalid lat - badLat := 999.0 - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &badLat}) - if ok { - t.Error("invalid lat should fail") - } - - // Invalid lon - badLon := -999.0 - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &badLon}) - if ok { - t.Error("invalid lon should fail") - } - - // Control chars in name - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: "test\x00name"}) - if ok { - t.Error("control chars in name should fail") - } - - // Name too long - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: strings.Repeat("x", 65)}) - if ok { - t.Error("long name should fail") - } -} - -func TestDecodeGrpTxtShort(t *testing.T) { - p := decodeGrpTxt([]byte{0x01, 0x02}, nil) - if p.Error != "too short" { - t.Errorf("expected 'too short' error, got %q", p.Error) - } - if p.Type != "GRP_TXT" { - t.Errorf("type=%s, want GRP_TXT", p.Type) - } -} - -func TestDecodeGrpTxtValid(t *testing.T) { - p := decodeGrpTxt([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE}, nil) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.ChannelHash != 0xAA { - t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash) - } - if p.MAC != "bbcc" { - t.Errorf("mac=%s, want bbcc", p.MAC) - } - if p.EncryptedData != "ddee" { - t.Errorf("encryptedData=%s, want ddee", p.EncryptedData) - } -} - -func TestDecodeAnonReqShort(t *testing.T) { - p := decodeAnonReq(make([]byte, 10)) - if p.Error != "too short" { - t.Errorf("expected 'too short' error, got %q", p.Error) - } - if p.Type != "ANON_REQ" { - t.Errorf("type=%s, want ANON_REQ", p.Type) - } -} - -func TestDecodeAnonReqValid(t *testing.T) { - buf := make([]byte, 40) - buf[0] = 0xFF // destHash - for i := 1; i < 33; i++ { - buf[i] = byte(i) - } - buf[33] = 0xAA - buf[34] = 0xBB - p := decodeAnonReq(buf) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.DestHash != "ff" { - t.Errorf("destHash=%s, want ff", p.DestHash) - } - if p.MAC != "aabb" { - t.Errorf("mac=%s, want aabb", p.MAC) - } -} - -func TestDecodePathPayloadShort(t *testing.T) { - p := decodePathPayload([]byte{0x01, 0x02, 0x03}) - if p.Error != "too short" { - t.Errorf("expected 'too short' error, got %q", p.Error) - } - if p.Type != "PATH" { - t.Errorf("type=%s, want PATH", p.Type) - } -} - -func TestDecodePathPayloadValid(t *testing.T) { - buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} - p := decodePathPayload(buf) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.DestHash != "aa" { - t.Errorf("destHash=%s, want aa", p.DestHash) - } - if p.SrcHash != "bb" { - t.Errorf("srcHash=%s, want bb", p.SrcHash) - } - if p.PathData != "eeff" { - t.Errorf("pathData=%s, want eeff", p.PathData) - } -} - -func TestDecodeTraceShort(t *testing.T) { - p := decodeTrace(make([]byte, 5)) - if p.Error != "too short" { - t.Errorf("expected 'too short' error, got %q", p.Error) - } - if p.Type != "TRACE" { - t.Errorf("type=%s, want TRACE", p.Type) - } -} - -func TestDecodeTraceValid(t *testing.T) { - buf := make([]byte, 16) - // tag(4) + authCode(4) + flags(1) + pathData - binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1 - binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode - buf[8] = 0x02 // flags - buf[9] = 0xAA // path data - p := decodeTrace(buf) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.Tag != 1 { - t.Errorf("tag=%d, want 1", p.Tag) - } - if p.AuthCode != 0xDEADBEEF { - t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode) - } - if p.TraceFlags == nil || *p.TraceFlags != 2 { - t.Errorf("traceFlags=%v, want 2", p.TraceFlags) - } - if p.Type != "TRACE" { - t.Errorf("type=%s, want TRACE", p.Type) - } - if p.PathData == "" { - t.Error("pathData should not be empty") - } -} - -func TestDecodeTracePathParsing(t *testing.T) { - // Packet from issue #276: 260001807dca00000000007d547d - // Path byte 0x00 → hashSize=1, hops in payload at buf[9:] = 7d 54 7d - // Expected path: ["7D", "54", "7D"] - pkt, err := DecodePacket("260001807dca00000000007d547d", nil) - if err != nil { - t.Fatalf("DecodePacket error: %v", err) - } - if pkt.Payload.Type != "TRACE" { - t.Errorf("payload type=%s, want TRACE", pkt.Payload.Type) - } - want := []string{"7D", "54", "7D"} - if len(pkt.Path.Hops) != len(want) { - t.Fatalf("hops=%v, want %v", pkt.Path.Hops, want) - } - for i, h := range want { - if pkt.Path.Hops[i] != h { - t.Errorf("hops[%d]=%s, want %s", i, pkt.Path.Hops[i], h) - } - } - if pkt.Path.HashCount != 3 { - t.Errorf("hashCount=%d, want 3", pkt.Path.HashCount) - } -} - -func TestDecodeAdvertShort(t *testing.T) { - p := decodeAdvert(make([]byte, 50)) - if p.Error != "too short for advert" { - t.Errorf("expected 'too short for advert' error, got %q", p.Error) - } -} - -func TestDecodeEncryptedPayloadShort(t *testing.T) { - p := decodeEncryptedPayload("REQ", []byte{0x01, 0x02}) - if p.Error != "too short" { - t.Errorf("expected 'too short' error, got %q", p.Error) - } - if p.Type != "REQ" { - t.Errorf("type=%s, want REQ", p.Type) - } -} - -func TestDecodeEncryptedPayloadValid(t *testing.T) { - buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} - p := decodeEncryptedPayload("RESPONSE", buf) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.DestHash != "aa" { - t.Errorf("destHash=%s, want aa", p.DestHash) - } - if p.SrcHash != "bb" { - t.Errorf("srcHash=%s, want bb", p.SrcHash) - } - if p.MAC != "ccdd" { - t.Errorf("mac=%s, want ccdd", p.MAC) - } - if p.EncryptedData != "eeff" { - t.Errorf("encryptedData=%s, want eeff", p.EncryptedData) - } -} - -func TestDecodePayloadGRPData(t *testing.T) { - buf := []byte{0x01, 0x02, 0x03} - p := decodePayload(PayloadGRP_DATA, buf, nil) - if p.Type != "UNKNOWN" { - t.Errorf("type=%s, want UNKNOWN", p.Type) - } - if p.RawHex != "010203" { - t.Errorf("rawHex=%s, want 010203", p.RawHex) - } -} - -func TestDecodePayloadRAWCustom(t *testing.T) { - buf := []byte{0xFF, 0xFE} - p := decodePayload(PayloadRAW_CUSTOM, buf, nil) - if p.Type != "UNKNOWN" { - t.Errorf("type=%s, want UNKNOWN", p.Type) - } -} - -func TestDecodePayloadAllTypes(t *testing.T) { - // REQ - p := decodePayload(PayloadREQ, make([]byte, 10), nil) - if p.Type != "REQ" { - t.Errorf("REQ: type=%s", p.Type) - } - - // RESPONSE - p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil) - if p.Type != "RESPONSE" { - t.Errorf("RESPONSE: type=%s", p.Type) - } - - // TXT_MSG - p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil) - if p.Type != "TXT_MSG" { - t.Errorf("TXT_MSG: type=%s", p.Type) - } - - // ACK - p = decodePayload(PayloadACK, make([]byte, 10), nil) - if p.Type != "ACK" { - t.Errorf("ACK: type=%s", p.Type) - } - - // GRP_TXT - p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil) - if p.Type != "GRP_TXT" { - t.Errorf("GRP_TXT: type=%s", p.Type) - } - - // ANON_REQ - p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil) - if p.Type != "ANON_REQ" { - t.Errorf("ANON_REQ: type=%s", p.Type) - } - - // PATH - p = decodePayload(PayloadPATH, make([]byte, 10), nil) - if p.Type != "PATH" { - t.Errorf("PATH: type=%s", p.Type) - } - - // TRACE - p = decodePayload(PayloadTRACE, make([]byte, 20), nil) - if p.Type != "TRACE" { - t.Errorf("TRACE: type=%s", p.Type) - } -} - -func TestPayloadJSON(t *testing.T) { - p := &Payload{Type: "TEST", Name: "hello"} - j := PayloadJSON(p) - if j == "" || j == "{}" { - t.Errorf("PayloadJSON returned empty: %s", j) - } - if !strings.Contains(j, `"type":"TEST"`) { - t.Errorf("PayloadJSON missing type: %s", j) - } - if !strings.Contains(j, `"name":"hello"`) { - t.Errorf("PayloadJSON missing name: %s", j) - } -} - -func TestPayloadJSONNil(t *testing.T) { - // nil should not panic - j := PayloadJSON(nil) - if j != "null" && j != "{}" { - // json.Marshal(nil) returns "null" - t.Logf("PayloadJSON(nil) = %s", j) - } -} - -func TestValidateAdvertNaNLat(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - nanVal := math.NaN() - ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &nanVal}) - if ok { - t.Error("NaN lat should fail") - } - if !strings.Contains(reason, "lat") { - t.Errorf("reason should mention lat: %s", reason) - } -} - -func TestValidateAdvertInfLon(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - infVal := math.Inf(1) - ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &infVal}) - if ok { - t.Error("Inf lon should fail") - } - if !strings.Contains(reason, "lon") { - t.Errorf("reason should mention lon: %s", reason) - } -} - -func TestValidateAdvertNegInfLat(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - negInf := math.Inf(-1) - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &negInf}) - if ok { - t.Error("-Inf lat should fail") - } -} - -func TestValidateAdvertNaNLon(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - nan := math.NaN() - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &nan}) - if ok { - t.Error("NaN lon should fail") - } -} - -func TestValidateAdvertControlChars(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - tests := []struct { - name string - char string - }{ - {"null", "\x00"}, - {"bell", "\x07"}, - {"backspace", "\x08"}, - {"vtab", "\x0b"}, - {"formfeed", "\x0c"}, - {"shift out", "\x0e"}, - {"unit sep", "\x1f"}, - {"delete", "\x7f"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Name: "test" + tt.char + "name"}) - if ok { - t.Errorf("control char %q in name should fail", tt.char) - } - }) - } -} - -func TestValidateAdvertAllowedCharsInName(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - // Tab (\t = 0x09), newline (\n = 0x0a), carriage return (\r = 0x0d) are NOT blocked - ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Name: "hello\tworld", Flags: &AdvertFlags{Repeater: true}}) - if !ok { - t.Errorf("tab in name should be allowed, got reason: %s", reason) - } -} - -func TestValidateAdvertUnknownRole(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - // type=0 maps to companion via Chat=false, Repeater=false, Room=false, Sensor=false → companion - // type=5 (unknown) → companion (default), which IS a valid role - // But if all booleans are false AND type is 0, advertRole returns "companion" which is valid - // To get "unknown", we'd need a flags combo that doesn't match any valid role - // Actually advertRole always returns companion as default — so let's just test the validation path - flags := &AdvertFlags{Type: 5, Chat: false, Repeater: false, Room: false, Sensor: false} - ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Flags: flags}) - // advertRole returns "companion" for this, which is valid - if !ok { - t.Errorf("default companion role should be valid, got: %s", reason) - } -} - -func TestValidateAdvertValidLocation(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - lat := 45.0 - lon := -90.0 - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat, Lon: &lon, Flags: &AdvertFlags{Repeater: true}}) - if !ok { - t.Error("valid lat/lon should pass") - } -} - -func TestValidateAdvertBoundaryLat(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - // Exactly at boundary - lat90 := 90.0 - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat90}) - if !ok { - t.Error("lat=90 should pass") - } - latNeg90 := -90.0 - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &latNeg90}) - if !ok { - t.Error("lat=-90 should pass") - } - // Just over - lat91 := 90.001 - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat91}) - if ok { - t.Error("lat=90.001 should fail") - } -} - -func TestValidateAdvertBoundaryLon(t *testing.T) { - goodPk := strings.Repeat("aa", 32) - lon180 := 180.0 - ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lon180}) - if !ok { - t.Error("lon=180 should pass") - } - lonNeg180 := -180.0 - ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lonNeg180}) - if !ok { - t.Error("lon=-180 should pass") - } -} - -func TestComputeContentHashShortHex(t *testing.T) { - // Less than 16 hex chars and invalid hex - hash := ComputeContentHash("AB") - if hash != "AB" { - t.Errorf("short hex hash=%s, want AB", hash) - } - - // Exactly 16 chars invalid hex - hash = ComputeContentHash("ZZZZZZZZZZZZZZZZ") - if len(hash) != 16 { - t.Errorf("invalid hex hash length=%d, want 16", len(hash)) - } -} - -func TestComputeContentHashTransportRoute(t *testing.T) { - // Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops) - // header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00 - hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10) - hash := ComputeContentHash(hex) - if len(hash) != 16 { - t.Errorf("hash length=%d, want 16", len(hash)) - } -} - -func TestComputeContentHashPayloadBeyondBuffer(t *testing.T) { - // path claims more bytes than buffer has → fallback - // header=0x05 (FLOOD, REQ), pathByte=0x3F (63 hops of 1 byte = 63 path bytes) - // but total buffer is only 4 bytes - hex := "053F" + "AABB" - hash := ComputeContentHash(hex) - // payloadStart = 2 + 63 = 65, but buffer is only 4 bytes - // Should fallback — rawHex is 8 chars (< 16), so returns rawHex - if hash != hex { - t.Errorf("hash=%s, want %s", hash, hex) - } -} - -func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) { - // Same as above but with rawHex >= 16 chars → returns first 16 - hex := "053F" + strings.Repeat("AA", 20) // 44 chars total, but pathByte claims 63 hops - hash := ComputeContentHash(hex) - if len(hash) != 16 { - t.Errorf("hash length=%d, want 16", len(hash)) - } - if hash != hex[:16] { - t.Errorf("hash=%s, want %s", hash, hex[:16]) - } -} - -func TestComputeContentHashTransportBeyondBuffer(t *testing.T) { - // Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes - // header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash) - // offset=1+4+1+2=8, buffer needs to be >= 8 - hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes - hash := ComputeContentHash(hex) - if len(hash) != 16 { - t.Errorf("hash length=%d, want 16", len(hash)) - } -} - -func TestComputeContentHashLongFallback(t *testing.T) { - // Long rawHex (>= 16) but invalid → returns first 16 chars - longInvalid := "ZZZZZZZZZZZZZZZZZZZZZZZZ" - hash := ComputeContentHash(longInvalid) - if hash != longInvalid[:16] { - t.Errorf("hash=%s, want first 16 of input", hash) - } -} - -func TestDecodePacketWithWhitespace(t *testing.T) { - raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76" - pkt, err := DecodePacket(raw, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Header.PayloadType != PayloadTXT_MSG { - t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG) - } -} - -func TestDecodePacketWithNewlines(t *testing.T) { - raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976" - pkt, err := DecodePacket(raw, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.Type != "TXT_MSG" { - t.Errorf("type=%s, want TXT_MSG", pkt.Payload.Type) - } -} - -func TestDecodePacketTransportRouteTooShort(t *testing.T) { - // TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes - _, err := DecodePacket("1400", nil) - if err == nil { - t.Error("expected error for transport route with too-short buffer") - } - if !strings.Contains(err.Error(), "transport codes") { - t.Errorf("error should mention transport codes: %v", err) - } -} - -func TestDecodeAckShort(t *testing.T) { - p := decodeAck([]byte{0x01, 0x02, 0x03}) - if p.Error != "too short" { - t.Errorf("expected 'too short', got %q", p.Error) - } -} - -func TestDecodeAckValid(t *testing.T) { - buf := []byte{0xAA, 0xBB, 0xCC, 0xDD} - p := decodeAck(buf) - if p.Error != "" { - t.Errorf("unexpected error: %s", p.Error) - } - if p.ExtraHash != "ddccbbaa" { - t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash) - } - if p.DestHash != "" { - t.Errorf("destHash should be empty, got %s", p.DestHash) - } - if p.SrcHash != "" { - t.Errorf("srcHash should be empty, got %s", p.SrcHash) - } -} - -func TestIsTransportRoute(t *testing.T) { - if !isTransportRoute(RouteTransportFlood) { - t.Error("RouteTransportFlood should be transport") - } - if !isTransportRoute(RouteTransportDirect) { - t.Error("RouteTransportDirect should be transport") - } - if isTransportRoute(RouteFlood) { - t.Error("RouteFlood should not be transport") - } - if isTransportRoute(RouteDirect) { - t.Error("RouteDirect should not be transport") - } -} - -func TestDecodeHeaderUnknownTypes(t *testing.T) { - // Payload type that doesn't map to any known name - // bits 5-2 = 0x0C (12) is CONTROL but 0x0D (13) would be unknown - // byte = 0b00_1101_01 = 0x35 → routeType=1, payloadType=0x0D(13), version=0 - h := decodeHeader(0x35) - if h.PayloadTypeName != "UNKNOWN" { - t.Errorf("payloadTypeName=%s, want UNKNOWN for type 13", h.PayloadTypeName) - } -} - -func TestDecodePayloadMultipart(t *testing.T) { - // MULTIPART (0x0A) falls through to default → UNKNOWN - p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil) - if p.Type != "UNKNOWN" { - t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) - } -} - -func TestDecodePayloadControl(t *testing.T) { - // CONTROL (0x0B) falls through to default → UNKNOWN - p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil) - if p.Type != "UNKNOWN" { - t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type) - } -} - -func TestDecodePathTruncatedBuffer(t *testing.T) { - // path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available - path, consumed := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0) - if path.HashCount != 5 { - t.Errorf("hashCount=%d, want 5", path.HashCount) - } - // Should only decode 2 hops (4 bytes / 2 bytes per hop) - if len(path.Hops) != 2 { - t.Errorf("hops=%d, want 2 (truncated)", len(path.Hops)) - } - if consumed != 10 { - t.Errorf("consumed=%d, want 10 (full claimed size)", consumed) - } -} - -func TestDecodeFloodAdvert5Hops(t *testing.T) { - // From test-decoder.js Test 1 - raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172" - pkt, err := DecodePacket(raw, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Header.RouteTypeName != "FLOOD" { - t.Errorf("route=%s, want FLOOD", pkt.Header.RouteTypeName) - } - if pkt.Header.PayloadTypeName != "ADVERT" { - t.Errorf("payload=%s, want ADVERT", pkt.Header.PayloadTypeName) - } - if pkt.Path.HashSize != 2 { - t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize) - } - if pkt.Path.HashCount != 5 { - t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) - } - if pkt.Path.Hops[0] != "1000" { - t.Errorf("hop[0]=%s, want 1000", pkt.Path.Hops[0]) - } - if pkt.Path.Hops[1] != "D818" { - t.Errorf("hop[1]=%s, want D818", pkt.Path.Hops[1]) - } - if pkt.TransportCodes != nil { - t.Error("FLOOD should have no transport codes") - } -} - -// --- Channel decryption tests --- - -// buildTestCiphertext creates a valid AES-128-ECB encrypted GRP_TXT payload -// with a matching HMAC-SHA256 MAC for testing. -func buildTestCiphertext(channelKeyHex, senderMsg string, timestamp uint32) (ciphertextHex, macHex string) { - channelKey, _ := hex.DecodeString(channelKeyHex) - - // Build plaintext: timestamp(4 LE) + flags(1) + message - plain := make([]byte, 4+1+len(senderMsg)) - binary.LittleEndian.PutUint32(plain[0:4], timestamp) - plain[4] = 0x00 // flags - copy(plain[5:], senderMsg) - - // Pad to AES block boundary - pad := aes.BlockSize - (len(plain) % aes.BlockSize) - if pad != aes.BlockSize { - plain = append(plain, make([]byte, pad)...) - } - - // AES-128-ECB encrypt - block, _ := aes.NewCipher(channelKey) - ct := make([]byte, len(plain)) - for i := 0; i < len(plain); i += aes.BlockSize { - block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize]) - } - - // HMAC-SHA256 MAC (first 2 bytes) - secret := make([]byte, 32) - copy(secret, channelKey) - h := hmac.New(sha256.New, secret) - h.Write(ct) - mac := h.Sum(nil) - - return hex.EncodeToString(ct), hex.EncodeToString(mac[:2]) -} - -func TestDecryptChannelMessageValid(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(key, "Alice: Hello world", 1700000000) - - result, err := decryptChannelMessage(ctHex, macHex, key) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Sender != "Alice" { - t.Errorf("sender=%q, want Alice", result.Sender) - } - if result.Message != "Hello world" { - t.Errorf("message=%q, want 'Hello world'", result.Message) - } - if result.Timestamp != 1700000000 { - t.Errorf("timestamp=%d, want 1700000000", result.Timestamp) - } -} - -func TestDecryptChannelMessageMACFail(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, _ := buildTestCiphertext(key, "Alice: Hello", 100) - wrongMac := "ffff" - - _, err := decryptChannelMessage(ctHex, wrongMac, key) - if err == nil { - t.Fatal("expected MAC verification failure") - } - if !strings.Contains(err.Error(), "MAC") { - t.Errorf("error should mention MAC: %v", err) - } -} - -func TestDecryptChannelMessageWrongKey(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(key, "Alice: Hello", 100) - wrongKey := "deadbeefdeadbeefdeadbeefdeadbeef" - - _, err := decryptChannelMessage(ctHex, macHex, wrongKey) - if err == nil { - t.Fatal("expected error with wrong key") - } -} - -func TestDecryptChannelMessageNoSender(t *testing.T) { - key := "aaaabbbbccccddddaaaabbbbccccdddd" - ctHex, macHex := buildTestCiphertext(key, "Just a message", 500) - - result, err := decryptChannelMessage(ctHex, macHex, key) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Sender != "" { - t.Errorf("sender=%q, want empty", result.Sender) - } - if result.Message != "Just a message" { - t.Errorf("message=%q, want 'Just a message'", result.Message) - } -} - -func TestDecryptChannelMessageSenderWithBrackets(t *testing.T) { - key := "aaaabbbbccccddddaaaabbbbccccdddd" - ctHex, macHex := buildTestCiphertext(key, "[admin]: Not a sender", 500) - - result, err := decryptChannelMessage(ctHex, macHex, key) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Sender != "" { - t.Errorf("sender=%q, want empty (brackets disqualify)", result.Sender) - } - if result.Message != "[admin]: Not a sender" { - t.Errorf("message=%q", result.Message) - } -} - -func TestDecryptChannelMessageInvalidKey(t *testing.T) { - _, err := decryptChannelMessage("aabb", "cc", "ZZZZ") - if err == nil { - t.Fatal("expected error for invalid key hex") - } -} - -func TestDecryptChannelMessageShortKey(t *testing.T) { - _, err := decryptChannelMessage("aabb", "cc", "aabb") - if err == nil { - t.Fatal("expected error for short key") - } -} - -func TestDecodeGrpTxtWithDecryption(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(key, "Bob: Testing 123", 1700000000) - macBytes, _ := hex.DecodeString(macHex) - ctBytes, _ := hex.DecodeString(ctHex) - - // Build GRP_TXT payload: channelHash(1) + MAC(2) + encrypted - buf := []byte{0xAA} - buf = append(buf, macBytes...) - buf = append(buf, ctBytes...) - - keys := map[string]string{"#test": key} - p := decodeGrpTxt(buf, keys) - - if p.Type != "CHAN" { - t.Errorf("type=%s, want CHAN", p.Type) - } - if p.DecryptionStatus != "decrypted" { - t.Errorf("decryptionStatus=%s, want decrypted", p.DecryptionStatus) - } - if p.Channel != "#test" { - t.Errorf("channel=%s, want #test", p.Channel) - } - if p.Sender != "Bob" { - t.Errorf("sender=%q, want Bob", p.Sender) - } - if p.Text != "Bob: Testing 123" { - t.Errorf("text=%q, want 'Bob: Testing 123'", p.Text) - } - if p.ChannelHash != 0xAA { - t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash) - } - if p.ChannelHashHex != "AA" { - t.Errorf("channelHashHex=%s, want AA", p.ChannelHashHex) - } - if p.SenderTimestamp != 1700000000 { - t.Errorf("senderTimestamp=%d, want 1700000000", p.SenderTimestamp) - } -} - -func TestDecodeGrpTxtDecryptionFailed(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(key, "Hello", 100) - macBytes, _ := hex.DecodeString(macHex) - ctBytes, _ := hex.DecodeString(ctHex) - - buf := []byte{0xFF} - buf = append(buf, macBytes...) - buf = append(buf, ctBytes...) - - wrongKeys := map[string]string{"#wrong": "deadbeefdeadbeefdeadbeefdeadbeef"} - p := decodeGrpTxt(buf, wrongKeys) - - if p.Type != "GRP_TXT" { - t.Errorf("type=%s, want GRP_TXT", p.Type) - } - if p.DecryptionStatus != "decryption_failed" { - t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus) - } - if p.ChannelHashHex != "FF" { - t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex) - } -} - -func TestDecodeGrpTxtNoKey(t *testing.T) { - buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} - p := decodeGrpTxt(buf, nil) - - if p.Type != "GRP_TXT" { - t.Errorf("type=%s, want GRP_TXT", p.Type) - } - if p.DecryptionStatus != "no_key" { - t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus) - } - if p.ChannelHashHex != "03" { - t.Errorf("channelHashHex=%s, want 03", p.ChannelHashHex) - } -} - -func TestDecodeGrpTxtEmptyKeys(t *testing.T) { - buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} - p := decodeGrpTxt(buf, map[string]string{}) - - if p.DecryptionStatus != "no_key" { - t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus) - } -} - -func TestDecodeGrpTxtShortEncryptedNoDecryptAttempt(t *testing.T) { - // encryptedData < 5 bytes (10 hex chars) → should not attempt decryption - buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD} - keys := map[string]string{"#test": "2cc3d22840e086105ad73443da2cacb8"} - p := decodeGrpTxt(buf, keys) - - if p.DecryptionStatus != "no_key" { - t.Errorf("decryptionStatus=%s, want no_key (too short for decryption)", p.DecryptionStatus) - } -} - -func TestDecodeGrpTxtMultipleKeysTriesAll(t *testing.T) { - correctKey := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(correctKey, "Eve: Found it", 999) - macBytes, _ := hex.DecodeString(macHex) - ctBytes, _ := hex.DecodeString(ctHex) - - buf := []byte{0x01} - buf = append(buf, macBytes...) - buf = append(buf, ctBytes...) - - keys := map[string]string{ - "#wrong1": "deadbeefdeadbeefdeadbeefdeadbeef", - "#correct": correctKey, - "#wrong2": "11111111111111111111111111111111", - } - p := decodeGrpTxt(buf, keys) - - if p.Type != "CHAN" { - t.Errorf("type=%s, want CHAN", p.Type) - } - if p.Channel != "#correct" { - t.Errorf("channel=%s, want #correct", p.Channel) - } - if p.Sender != "Eve" { - t.Errorf("sender=%q, want Eve", p.Sender) - } -} - -func TestDecodeGrpTxtChannelHashHexZeroPad(t *testing.T) { - buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE} - p := decodeGrpTxt(buf, nil) - if p.ChannelHashHex != "03" { - t.Errorf("channelHashHex=%s, want 03 (zero-padded)", p.ChannelHashHex) - } -} - -func TestDecodeGrpTxtChannelHashHexFF(t *testing.T) { - buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE} - p := decodeGrpTxt(buf, nil) - if p.ChannelHashHex != "FF" { - t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex) - } -} - -// --- Garbage text detection (fixes #197) --- - -func TestDecryptChannelMessageGarbageText(t *testing.T) { - // Build ciphertext with binary garbage as the message - key := "2cc3d22840e086105ad73443da2cacb8" - garbage := "\x01\x02\x03\x80\x81" - ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000) - - _, err := decryptChannelMessage(ctHex, macHex, key) - if err == nil { - t.Fatal("expected error for garbage text, got nil") - } - if !strings.Contains(err.Error(), "non-printable") { - t.Errorf("error should mention non-printable: %v", err) - } -} - -func TestDecryptChannelMessageValidText(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - ctHex, macHex := buildTestCiphertext(key, "Alice: Hello\nworld", 1700000000) - - result, err := decryptChannelMessage(ctHex, macHex, key) - if err != nil { - t.Fatalf("unexpected error for valid text: %v", err) - } - if result.Sender != "Alice" { - t.Errorf("sender=%q, want Alice", result.Sender) - } - if result.Message != "Hello\nworld" { - t.Errorf("message=%q, want 'Hello\\nworld'", result.Message) - } -} - -func TestDecodeGrpTxtGarbageMarkedFailed(t *testing.T) { - key := "2cc3d22840e086105ad73443da2cacb8" - garbage := "\x01\x02\x03\x04\x05" - ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000) - - macBytes, _ := hex.DecodeString(macHex) - ctBytes, _ := hex.DecodeString(ctHex) - buf := make([]byte, 1+2+len(ctBytes)) - buf[0] = 0xFF // channel hash - buf[1] = macBytes[0] - buf[2] = macBytes[1] - copy(buf[3:], ctBytes) - - keys := map[string]string{"#general": key} - p := decodeGrpTxt(buf, keys) - - if p.DecryptionStatus != "decryption_failed" { - t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus) - } - if p.Type != "GRP_TXT" { - t.Errorf("type=%s, want GRP_TXT", p.Type) - } -} - -func TestDecodeAdvertWithTelemetry(t *testing.T) { - pubkey := strings.Repeat("AA", 32) - timestamp := "78563412" - signature := strings.Repeat("BB", 64) - flags := "94" // sensor(4) | hasLocation(0x10) | hasName(0x80) - lat := "40933402" - lon := "E0E6B8F8" - name := hex.EncodeToString([]byte("Sensor1")) - nullTerm := "00" - batteryLE := make([]byte, 2) - binary.LittleEndian.PutUint16(batteryLE, 3700) - tempLE := make([]byte, 2) - binary.LittleEndian.PutUint16(tempLE, uint16(int16(2850))) - - hexStr := "1200" + pubkey + timestamp + signature + flags + lat + lon + - name + nullTerm + - hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - - pkt, err := DecodePacket(hexStr, nil) - if err != nil { - t.Fatal(err) - } - - if pkt.Payload.Name != "Sensor1" { - t.Errorf("name=%s, want Sensor1", pkt.Payload.Name) - } - if pkt.Payload.BatteryMv == nil { - t.Fatal("battery_mv should not be nil") - } - if *pkt.Payload.BatteryMv != 3700 { - t.Errorf("battery_mv=%d, want 3700", *pkt.Payload.BatteryMv) - } - if pkt.Payload.TemperatureC == nil { - t.Fatal("temperature_c should not be nil") - } - if math.Abs(*pkt.Payload.TemperatureC-28.50) > 0.01 { - t.Errorf("temperature_c=%f, want 28.50", *pkt.Payload.TemperatureC) - } -} - -func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) { - pubkey := strings.Repeat("CC", 32) - timestamp := "00000000" - signature := strings.Repeat("DD", 64) - flags := "84" // sensor(4) | hasName(0x80), no location - name := hex.EncodeToString([]byte("Cold")) - nullTerm := "00" - batteryLE := make([]byte, 2) - binary.LittleEndian.PutUint16(batteryLE, 4200) - tempLE := make([]byte, 2) - var negTemp int16 = -550 - binary.LittleEndian.PutUint16(tempLE, uint16(negTemp)) - - hexStr := "1200" + pubkey + timestamp + signature + flags + - name + nullTerm + - hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - - pkt, err := DecodePacket(hexStr, nil) - if err != nil { - t.Fatal(err) - } - - if pkt.Payload.Name != "Cold" { - t.Errorf("name=%s, want Cold", pkt.Payload.Name) - } - if pkt.Payload.BatteryMv == nil || *pkt.Payload.BatteryMv != 4200 { - t.Errorf("battery_mv=%v, want 4200", pkt.Payload.BatteryMv) - } - if pkt.Payload.TemperatureC == nil { - t.Fatal("temperature_c should not be nil") - } - if math.Abs(*pkt.Payload.TemperatureC-(-5.50)) > 0.01 { - t.Errorf("temperature_c=%f, want -5.50", *pkt.Payload.TemperatureC) - } -} - -func TestDecodeAdvertWithoutTelemetry(t *testing.T) { - pubkey := strings.Repeat("EE", 32) - timestamp := "00000000" - signature := strings.Repeat("FF", 64) - flags := "82" // repeater(2) | hasName(0x80) - name := hex.EncodeToString([]byte("Node1")) - - hexStr := "1200" + pubkey + timestamp + signature + flags + name - pkt, err := DecodePacket(hexStr, nil) - if err != nil { - t.Fatal(err) - } - - if pkt.Payload.Name != "Node1" { - t.Errorf("name=%s, want Node1", pkt.Payload.Name) - } - if pkt.Payload.BatteryMv != nil { - t.Errorf("battery_mv should be nil for advert without telemetry, got %d", *pkt.Payload.BatteryMv) - } - if pkt.Payload.TemperatureC != nil { - t.Errorf("temperature_c should be nil for advert without telemetry, got %f", *pkt.Payload.TemperatureC) - } -} - -func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) { - // A repeater node with 4 trailing bytes after the name should NOT decode telemetry. - pubkey := strings.Repeat("AB", 32) - timestamp := "00000000" - signature := strings.Repeat("CD", 64) - flags := "82" // repeater(2) | hasName(0x80) - name := hex.EncodeToString([]byte("Rptr")) - nullTerm := "00" - extraBytes := "B40ED403" // battery-like and temp-like bytes - - hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes - pkt, err := DecodePacket(hexStr, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.BatteryMv != nil { - t.Errorf("battery_mv should be nil for non-sensor node, got %d", *pkt.Payload.BatteryMv) - } - if pkt.Payload.TemperatureC != nil { - t.Errorf("temperature_c should be nil for non-sensor node, got %f", *pkt.Payload.TemperatureC) - } -} - -func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) { - // 0°C is a valid temperature and must be emitted. - pubkey := strings.Repeat("12", 32) - timestamp := "00000000" - signature := strings.Repeat("34", 64) - flags := "84" // sensor(4) | hasName(0x80) - name := hex.EncodeToString([]byte("FreezeSensor")) - nullTerm := "00" - batteryLE := make([]byte, 2) - binary.LittleEndian.PutUint16(batteryLE, 3600) - tempLE := make([]byte, 2) // tempRaw=0 → 0°C - - hexStr := "1200" + pubkey + timestamp + signature + flags + - name + nullTerm + - hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - - pkt, err := DecodePacket(hexStr, nil) - if err != nil { - t.Fatal(err) - } - if pkt.Payload.TemperatureC == nil { - t.Fatal("temperature_c should not be nil for 0°C") - } - if *pkt.Payload.TemperatureC != 0.0 { - t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC) - } -} +package decoder + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "math" + "strings" + "testing" +) + +func TestDecodeHeaderRoutTypes(t *testing.T) { + tests := []struct { + b byte + rt int + name string + }{ + {0x00, 0, "TRANSPORT_FLOOD"}, + {0x01, 1, "FLOOD"}, + {0x02, 2, "DIRECT"}, + {0x03, 3, "TRANSPORT_DIRECT"}, + } + for _, tt := range tests { + h := decodeHeader(tt.b) + if h.RouteType != tt.rt { + t.Errorf("header 0x%02X: routeType=%d, want %d", tt.b, h.RouteType, tt.rt) + } + if h.RouteTypeName != tt.name { + t.Errorf("header 0x%02X: routeTypeName=%s, want %s", tt.b, h.RouteTypeName, tt.name) + } + } +} + +func TestDecodeHeaderPayloadTypes(t *testing.T) { + // 0x11 = 0b00_0100_01 ΓåÆ routeType=1(FLOOD), payloadType=4(ADVERT), version=0 + h := decodeHeader(0x11) + if h.RouteType != 1 { + t.Errorf("0x11: routeType=%d, want 1", h.RouteType) + } + if h.PayloadType != 4 { + t.Errorf("0x11: payloadType=%d, want 4", h.PayloadType) + } + if h.PayloadVersion != 0 { + t.Errorf("0x11: payloadVersion=%d, want 0", h.PayloadVersion) + } + if h.RouteTypeName != "FLOOD" { + t.Errorf("0x11: routeTypeName=%s, want FLOOD", h.RouteTypeName) + } + if h.PayloadTypeName != "ADVERT" { + t.Errorf("0x11: payloadTypeName=%s, want ADVERT", h.PayloadTypeName) + } +} + +func TestDecodePathZeroHops(t *testing.T) { + // 0x00: 0 hops, 1-byte hashes + pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) + if err != nil { + t.Fatal(err) + } + if pkt.Path.HashCount != 0 { + t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount) + } + if pkt.Path.HashSize != 1 { + t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize) + } + if len(pkt.Path.Hops) != 0 { + t.Errorf("hops=%d, want 0", len(pkt.Path.Hops)) + } +} + +func TestDecodePath1ByteHashes(t *testing.T) { + // 0x05: 5 hops, 1-byte hashes ΓåÆ 5 path bytes + pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil) + if err != nil { + t.Fatal(err) + } + if pkt.Path.HashCount != 5 { + t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) + } + if pkt.Path.HashSize != 1 { + t.Errorf("hashSize=%d, want 1", pkt.Path.HashSize) + } + if len(pkt.Path.Hops) != 5 { + t.Fatalf("hops=%d, want 5", len(pkt.Path.Hops)) + } + if pkt.Path.Hops[0] != "AA" { + t.Errorf("hop[0]=%s, want AA", pkt.Path.Hops[0]) + } + if pkt.Path.Hops[4] != "EE" { + t.Errorf("hop[4]=%s, want EE", pkt.Path.Hops[4]) + } +} + +func TestDecodePath2ByteHashes(t *testing.T) { + // 0x45: 5 hops, 2-byte hashes + pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil) + if err != nil { + t.Fatal(err) + } + if pkt.Path.HashCount != 5 { + t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) + } + if pkt.Path.HashSize != 2 { + t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize) + } + if pkt.Path.Hops[0] != "AA11" { + t.Errorf("hop[0]=%s, want AA11", pkt.Path.Hops[0]) + } +} + +func TestDecodePath3ByteHashes(t *testing.T) { + // 0x8A: 10 hops, 3-byte hashes + pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil) + if err != nil { + t.Fatal(err) + } + if pkt.Path.HashCount != 10 { + t.Errorf("hashCount=%d, want 10", pkt.Path.HashCount) + } + if pkt.Path.HashSize != 3 { + t.Errorf("hashSize=%d, want 3", pkt.Path.HashSize) + } + if len(pkt.Path.Hops) != 10 { + t.Errorf("hops=%d, want 10", len(pkt.Path.Hops)) + } +} + +func TestTransportCodes(t *testing.T) { + // Route type 0 (TRANSPORT_FLOOD) should have transport codes + // Firmware order: header + transport_codes(4) + path_len + path + payload + hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Header.RouteType != 0 { + t.Errorf("routeType=%d, want 0", pkt.Header.RouteType) + } + if pkt.TransportCodes == nil { + t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD") + } + if pkt.TransportCodes.Code1 != "AABB" { + t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1) + } + if pkt.TransportCodes.Code2 != "CCDD" { + t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2) + } + + // Route type 1 (FLOOD) should NOT have transport codes + pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) + if err != nil { + t.Fatal(err) + } + if pkt2.TransportCodes != nil { + t.Error("FLOOD should not have transport codes") + } +} + +func TestDecodeAdvertFull(t *testing.T) { + pubkey := strings.Repeat("AA", 32) + timestamp := "78563412" // 0x12345678 LE + signature := strings.Repeat("BB", 64) + // flags: 0x92 = repeater(2) | hasLocation(0x10) | hasName(0x80) + flags := "92" + lat := "40933402" // ~37.0 + lon := "E0E6B8F8" // ~-122.1 + name := "546573744E6F6465" // "TestNode" + + hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatal(err) + } + + if pkt.Payload.Type != "ADVERT" { + t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) + } + if pkt.Payload.PubKey != strings.ToLower(pubkey) { + t.Errorf("pubkey mismatch") + } + if pkt.Payload.Timestamp != 0x12345678 { + t.Errorf("timestamp=%d, want %d", pkt.Payload.Timestamp, 0x12345678) + } + + if pkt.Payload.Flags == nil { + t.Fatal("flags should not be nil") + } + if pkt.Payload.Flags.Raw != 0x92 { + t.Errorf("flags.raw=%d, want 0x92", pkt.Payload.Flags.Raw) + } + if pkt.Payload.Flags.Type != 2 { + t.Errorf("flags.type=%d, want 2", pkt.Payload.Flags.Type) + } + if !pkt.Payload.Flags.Repeater { + t.Error("flags.repeater should be true") + } + if pkt.Payload.Flags.Room { + t.Error("flags.room should be false") + } + if !pkt.Payload.Flags.HasLocation { + t.Error("flags.hasLocation should be true") + } + if !pkt.Payload.Flags.HasName { + t.Error("flags.hasName should be true") + } + + if pkt.Payload.Lat == nil { + t.Fatal("lat should not be nil") + } + if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 { + t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat) + } + if pkt.Payload.Lon == nil { + t.Fatal("lon should not be nil") + } + if math.Abs(*pkt.Payload.Lon-(-122.1)) > 0.001 { + t.Errorf("lon=%f, want ~-122.1", *pkt.Payload.Lon) + } + if pkt.Payload.Name != "TestNode" { + t.Errorf("name=%s, want TestNode", pkt.Payload.Name) + } +} + +func TestDecodeAdvertTypeEnums(t *testing.T) { + makeAdvert := func(flagsByte byte) *DecodedPacket { + hex := "1200" + strings.Repeat("AA", 32) + "00000000" + strings.Repeat("BB", 64) + + strings.ToUpper(string([]byte{hexDigit(flagsByte >> 4), hexDigit(flagsByte & 0x0f)})) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatal(err) + } + return pkt + } + + // type 1 = chat/companion + p1 := makeAdvert(0x01) + if p1.Payload.Flags.Type != 1 { + t.Errorf("type 1: flags.type=%d", p1.Payload.Flags.Type) + } + if !p1.Payload.Flags.Chat { + t.Error("type 1: chat should be true") + } + + // type 2 = repeater + p2 := makeAdvert(0x02) + if !p2.Payload.Flags.Repeater { + t.Error("type 2: repeater should be true") + } + + // type 3 = room + p3 := makeAdvert(0x03) + if !p3.Payload.Flags.Room { + t.Error("type 3: room should be true") + } + + // type 4 = sensor + p4 := makeAdvert(0x04) + if !p4.Payload.Flags.Sensor { + t.Error("type 4: sensor should be true") + } +} + +func hexDigit(v byte) byte { + v = v & 0x0f + if v < 10 { + return '0' + v + } + return 'a' + v - 10 +} + +func TestDecodeAdvertNoLocationNoName(t *testing.T) { + hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02" + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.Flags.HasLocation { + t.Error("hasLocation should be false") + } + if pkt.Payload.Flags.HasName { + t.Error("hasName should be false") + } + if pkt.Payload.Lat != nil { + t.Error("lat should be nil") + } + if pkt.Payload.Name != "" { + t.Errorf("name should be empty, got %s", pkt.Payload.Name) + } +} + +func TestGoldenFixtureTxtMsg(t *testing.T) { + pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil) + if err != nil { + t.Fatal(err) + } + if pkt.Header.PayloadType != PayloadTXT_MSG { + t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG) + } + if pkt.Header.RouteType != RouteDirect { + t.Errorf("routeType=%d, want %d", pkt.Header.RouteType, RouteDirect) + } + if pkt.Path.HashCount != 0 { + t.Errorf("hashCount=%d, want 0", pkt.Path.HashCount) + } + if pkt.Payload.DestHash != "d6" { + t.Errorf("destHash=%s, want d6", pkt.Payload.DestHash) + } + if pkt.Payload.SrcHash != "9f" { + t.Errorf("srcHash=%s, want 9f", pkt.Payload.SrcHash) + } +} + +func TestGoldenFixtureAdvert(t *testing.T) { + rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" + pkt, err := DecodePacket(rawHex, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.Type != "ADVERT" { + t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) + } + if pkt.Payload.PubKey != "46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84" { + t.Errorf("pubKey mismatch: %s", pkt.Payload.PubKey) + } + if pkt.Payload.Flags == nil || !pkt.Payload.Flags.Repeater { + t.Error("should be repeater") + } + if math.Abs(*pkt.Payload.Lat-37.0) > 0.001 { + t.Errorf("lat=%f, want ~37.0", *pkt.Payload.Lat) + } + if pkt.Payload.Name != "MRR2-R" { + t.Errorf("name=%s, want MRR2-R", pkt.Payload.Name) + } +} + +func TestGoldenFixtureUnicodeAdvert(t *testing.T) { + rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3" + pkt, err := DecodePacket(rawHex, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.Type != "ADVERT" { + t.Errorf("type=%s, want ADVERT", pkt.Payload.Type) + } + if !pkt.Payload.Flags.Repeater { + t.Error("should be repeater") + } + // Name contains emoji: PEAK≡ƒî│ + if !strings.HasPrefix(pkt.Payload.Name, "PEAK") { + t.Errorf("name=%s, expected to start with PEAK", pkt.Payload.Name) + } +} + +func TestDecodePacketTooShort(t *testing.T) { + _, err := DecodePacket("FF", nil) + if err == nil { + t.Error("expected error for 1-byte packet") + } +} + +func TestDecodePacketInvalidHex(t *testing.T) { + _, err := DecodePacket("ZZZZ", nil) + if err == nil { + t.Error("expected error for invalid hex") + } +} + +func TestComputeContentHash(t *testing.T) { + hash := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976") + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } + // Same content with different path should produce same hash + // (path bytes are stripped, only header + payload hashed) + + // Verify consistency + hash2 := ComputeContentHash("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976") + if hash != hash2 { + t.Error("content hash not deterministic") + } +} + +func TestValidateAdvert(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + + // Good advert + good := &Payload{PubKey: goodPk, Flags: &AdvertFlags{Repeater: true}} + ok, _ := ValidateAdvert(good) + if !ok { + t.Error("good advert should validate") + } + + // Nil + ok, _ = ValidateAdvert(nil) + if ok { + t.Error("nil should fail") + } + + // Error payload + ok, _ = ValidateAdvert(&Payload{Error: "bad"}) + if ok { + t.Error("error payload should fail") + } + + // Short pubkey + ok, _ = ValidateAdvert(&Payload{PubKey: "aa"}) + if ok { + t.Error("short pubkey should fail") + } + + // All-zero pubkey + ok, _ = ValidateAdvert(&Payload{PubKey: strings.Repeat("0", 64)}) + if ok { + t.Error("all-zero pubkey should fail") + } + + // Invalid lat + badLat := 999.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &badLat}) + if ok { + t.Error("invalid lat should fail") + } + + // Invalid lon + badLon := -999.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &badLon}) + if ok { + t.Error("invalid lon should fail") + } + + // Control chars in name + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: "test\x00name"}) + if ok { + t.Error("control chars in name should fail") + } + + // Name too long + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Name: strings.Repeat("x", 65)}) + if ok { + t.Error("long name should fail") + } +} + +func TestDecodeGrpTxtShort(t *testing.T) { + p := decodeGrpTxt([]byte{0x01, 0x02}, nil) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "GRP_TXT" { + t.Errorf("type=%s, want GRP_TXT", p.Type) + } +} + +func TestDecodeGrpTxtValid(t *testing.T) { + p := decodeGrpTxt([]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE}, nil) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.ChannelHash != 0xAA { + t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash) + } + if p.MAC != "bbcc" { + t.Errorf("mac=%s, want bbcc", p.MAC) + } + if p.EncryptedData != "ddee" { + t.Errorf("encryptedData=%s, want ddee", p.EncryptedData) + } +} + +func TestDecodeAnonReqShort(t *testing.T) { + p := decodeAnonReq(make([]byte, 10)) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "ANON_REQ" { + t.Errorf("type=%s, want ANON_REQ", p.Type) + } +} + +func TestDecodeAnonReqValid(t *testing.T) { + buf := make([]byte, 40) + buf[0] = 0xFF // destHash + for i := 1; i < 33; i++ { + buf[i] = byte(i) + } + buf[33] = 0xAA + buf[34] = 0xBB + p := decodeAnonReq(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "ff" { + t.Errorf("destHash=%s, want ff", p.DestHash) + } + if p.MAC != "aabb" { + t.Errorf("mac=%s, want aabb", p.MAC) + } +} + +func TestDecodePathPayloadShort(t *testing.T) { + p := decodePathPayload([]byte{0x01, 0x02, 0x03}) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "PATH" { + t.Errorf("type=%s, want PATH", p.Type) + } +} + +func TestDecodePathPayloadValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + p := decodePathPayload(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "aa" { + t.Errorf("destHash=%s, want aa", p.DestHash) + } + if p.SrcHash != "bb" { + t.Errorf("srcHash=%s, want bb", p.SrcHash) + } + if p.PathData != "eeff" { + t.Errorf("pathData=%s, want eeff", p.PathData) + } +} + +func TestDecodeTraceShort(t *testing.T) { + p := decodeTrace(make([]byte, 5)) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "TRACE" { + t.Errorf("type=%s, want TRACE", p.Type) + } +} + +func TestDecodeTraceValid(t *testing.T) { + buf := make([]byte, 16) + // tag(4) + authCode(4) + flags(1) + pathData + binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1 + binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode + buf[8] = 0x02 // flags + buf[9] = 0xAA // path data + p := decodeTrace(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.Tag != 1 { + t.Errorf("tag=%d, want 1", p.Tag) + } + if p.AuthCode != 0xDEADBEEF { + t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode) + } + if p.TraceFlags == nil || *p.TraceFlags != 2 { + t.Errorf("traceFlags=%v, want 2", p.TraceFlags) + } + if p.Type != "TRACE" { + t.Errorf("type=%s, want TRACE", p.Type) + } + if p.PathData == "" { + t.Error("pathData should not be empty") + } +} + +func TestDecodeTracePathParsing(t *testing.T) { + // Packet from issue #276: 260001807dca00000000007d547d + // Path byte 0x00 ΓåÆ hashSize=1, hops in payload at buf[9:] = 7d 54 7d + // Expected path: ["7D", "54", "7D"] + pkt, err := DecodePacket("260001807dca00000000007d547d", nil) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if pkt.Payload.Type != "TRACE" { + t.Errorf("payload type=%s, want TRACE", pkt.Payload.Type) + } + want := []string{"7D", "54", "7D"} + if len(pkt.Path.Hops) != len(want) { + t.Fatalf("hops=%v, want %v", pkt.Path.Hops, want) + } + for i, h := range want { + if pkt.Path.Hops[i] != h { + t.Errorf("hops[%d]=%s, want %s", i, pkt.Path.Hops[i], h) + } + } + if pkt.Path.HashCount != 3 { + t.Errorf("hashCount=%d, want 3", pkt.Path.HashCount) + } +} + +func TestDecodeAdvertShort(t *testing.T) { + p := decodeAdvert(make([]byte, 50)) + if p.Error != "too short for advert" { + t.Errorf("expected 'too short for advert' error, got %q", p.Error) + } +} + +func TestDecodeEncryptedPayloadShort(t *testing.T) { + p := decodeEncryptedPayload("REQ", []byte{0x01, 0x02}) + if p.Error != "too short" { + t.Errorf("expected 'too short' error, got %q", p.Error) + } + if p.Type != "REQ" { + t.Errorf("type=%s, want REQ", p.Type) + } +} + +func TestDecodeEncryptedPayloadValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + p := decodeEncryptedPayload("RESPONSE", buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.DestHash != "aa" { + t.Errorf("destHash=%s, want aa", p.DestHash) + } + if p.SrcHash != "bb" { + t.Errorf("srcHash=%s, want bb", p.SrcHash) + } + if p.MAC != "ccdd" { + t.Errorf("mac=%s, want ccdd", p.MAC) + } + if p.EncryptedData != "eeff" { + t.Errorf("encryptedData=%s, want eeff", p.EncryptedData) + } +} + +func TestDecodePayloadGRPData(t *testing.T) { + buf := []byte{0x01, 0x02, 0x03} + p := decodePayload(PayloadGRP_DATA, buf, nil) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } + if p.RawHex != "010203" { + t.Errorf("rawHex=%s, want 010203", p.RawHex) + } +} + +func TestDecodePayloadRAWCustom(t *testing.T) { + buf := []byte{0xFF, 0xFE} + p := decodePayload(PayloadRAW_CUSTOM, buf, nil) + if p.Type != "UNKNOWN" { + t.Errorf("type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadAllTypes(t *testing.T) { + // REQ + p := decodePayload(PayloadREQ, make([]byte, 10), nil) + if p.Type != "REQ" { + t.Errorf("REQ: type=%s", p.Type) + } + + // RESPONSE + p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil) + if p.Type != "RESPONSE" { + t.Errorf("RESPONSE: type=%s", p.Type) + } + + // TXT_MSG + p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil) + if p.Type != "TXT_MSG" { + t.Errorf("TXT_MSG: type=%s", p.Type) + } + + // ACK + p = decodePayload(PayloadACK, make([]byte, 10), nil) + if p.Type != "ACK" { + t.Errorf("ACK: type=%s", p.Type) + } + + // GRP_TXT + p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil) + if p.Type != "GRP_TXT" { + t.Errorf("GRP_TXT: type=%s", p.Type) + } + + // ANON_REQ + p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil) + if p.Type != "ANON_REQ" { + t.Errorf("ANON_REQ: type=%s", p.Type) + } + + // PATH + p = decodePayload(PayloadPATH, make([]byte, 10), nil) + if p.Type != "PATH" { + t.Errorf("PATH: type=%s", p.Type) + } + + // TRACE + p = decodePayload(PayloadTRACE, make([]byte, 20), nil) + if p.Type != "TRACE" { + t.Errorf("TRACE: type=%s", p.Type) + } +} + +func TestPayloadJSON(t *testing.T) { + p := &Payload{Type: "TEST", Name: "hello"} + j := PayloadJSON(p) + if j == "" || j == "{}" { + t.Errorf("PayloadJSON returned empty: %s", j) + } + if !strings.Contains(j, `"type":"TEST"`) { + t.Errorf("PayloadJSON missing type: %s", j) + } + if !strings.Contains(j, `"name":"hello"`) { + t.Errorf("PayloadJSON missing name: %s", j) + } +} + +func TestPayloadJSONNil(t *testing.T) { + // nil should not panic + j := PayloadJSON(nil) + if j != "null" && j != "{}" { + // json.Marshal(nil) returns "null" + t.Logf("PayloadJSON(nil) = %s", j) + } +} + +func TestValidateAdvertNaNLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + nanVal := math.NaN() + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &nanVal}) + if ok { + t.Error("NaN lat should fail") + } + if !strings.Contains(reason, "lat") { + t.Errorf("reason should mention lat: %s", reason) + } +} + +func TestValidateAdvertInfLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + infVal := math.Inf(1) + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &infVal}) + if ok { + t.Error("Inf lon should fail") + } + if !strings.Contains(reason, "lon") { + t.Errorf("reason should mention lon: %s", reason) + } +} + +func TestValidateAdvertNegInfLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + negInf := math.Inf(-1) + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &negInf}) + if ok { + t.Error("-Inf lat should fail") + } +} + +func TestValidateAdvertNaNLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + nan := math.NaN() + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &nan}) + if ok { + t.Error("NaN lon should fail") + } +} + +func TestValidateAdvertControlChars(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + tests := []struct { + name string + char string + }{ + {"null", "\x00"}, + {"bell", "\x07"}, + {"backspace", "\x08"}, + {"vtab", "\x0b"}, + {"formfeed", "\x0c"}, + {"shift out", "\x0e"}, + {"unit sep", "\x1f"}, + {"delete", "\x7f"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Name: "test" + tt.char + "name"}) + if ok { + t.Errorf("control char %q in name should fail", tt.char) + } + }) + } +} + +func TestValidateAdvertAllowedCharsInName(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // Tab (\t = 0x09), newline (\n = 0x0a), carriage return (\r = 0x0d) are NOT blocked + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Name: "hello\tworld", Flags: &AdvertFlags{Repeater: true}}) + if !ok { + t.Errorf("tab in name should be allowed, got reason: %s", reason) + } +} + +func TestValidateAdvertUnknownRole(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // type=0 maps to companion via Chat=false, Repeater=false, Room=false, Sensor=false ΓåÆ companion + // type=5 (unknown) ΓåÆ companion (default), which IS a valid role + // But if all booleans are false AND type is 0, advertRole returns "companion" which is valid + // To get "unknown", we'd need a flags combo that doesn't match any valid role + // Actually advertRole always returns companion as default ΓÇö so let's just test the validation path + flags := &AdvertFlags{Type: 5, Chat: false, Repeater: false, Room: false, Sensor: false} + ok, reason := ValidateAdvert(&Payload{PubKey: goodPk, Flags: flags}) + // advertRole returns "companion" for this, which is valid + if !ok { + t.Errorf("default companion role should be valid, got: %s", reason) + } +} + +func TestValidateAdvertValidLocation(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + lat := 45.0 + lon := -90.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat, Lon: &lon, Flags: &AdvertFlags{Repeater: true}}) + if !ok { + t.Error("valid lat/lon should pass") + } +} + +func TestValidateAdvertBoundaryLat(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + // Exactly at boundary + lat90 := 90.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat90}) + if !ok { + t.Error("lat=90 should pass") + } + latNeg90 := -90.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &latNeg90}) + if !ok { + t.Error("lat=-90 should pass") + } + // Just over + lat91 := 90.001 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lat: &lat91}) + if ok { + t.Error("lat=90.001 should fail") + } +} + +func TestValidateAdvertBoundaryLon(t *testing.T) { + goodPk := strings.Repeat("aa", 32) + lon180 := 180.0 + ok, _ := ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lon180}) + if !ok { + t.Error("lon=180 should pass") + } + lonNeg180 := -180.0 + ok, _ = ValidateAdvert(&Payload{PubKey: goodPk, Lon: &lonNeg180}) + if !ok { + t.Error("lon=-180 should pass") + } +} + +func TestComputeContentHashShortHex(t *testing.T) { + // Less than 16 hex chars and invalid hex + hash := ComputeContentHash("AB") + if hash != "AB" { + t.Errorf("short hex hash=%s, want AB", hash) + } + + // Exactly 16 chars invalid hex + hash = ComputeContentHash("ZZZZZZZZZZZZZZZZ") + if len(hash) != 16 { + t.Errorf("invalid hex hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashTransportRoute(t *testing.T) { + // Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops) + // header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00 + hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10) + hash := ComputeContentHash(hex) + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashPayloadBeyondBuffer(t *testing.T) { + // path claims more bytes than buffer has ΓåÆ fallback + // header=0x05 (FLOOD, REQ), pathByte=0x3F (63 hops of 1 byte = 63 path bytes) + // but total buffer is only 4 bytes + hex := "053F" + "AABB" + hash := ComputeContentHash(hex) + // payloadStart = 2 + 63 = 65, but buffer is only 4 bytes + // Should fallback ΓÇö rawHex is 8 chars (< 16), so returns rawHex + if hash != hex { + t.Errorf("hash=%s, want %s", hash, hex) + } +} + +func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) { + // Same as above but with rawHex >= 16 chars ΓåÆ returns first 16 + hex := "053F" + strings.Repeat("AA", 20) // 44 chars total, but pathByte claims 63 hops + hash := ComputeContentHash(hex) + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } + if hash != hex[:16] { + t.Errorf("hash=%s, want %s", hash, hex[:16]) + } +} + +func TestComputeContentHashTransportBeyondBuffer(t *testing.T) { + // Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes + // header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash) + // offset=1+4+1+2=8, buffer needs to be >= 8 + hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes + hash := ComputeContentHash(hex) + if len(hash) != 16 { + t.Errorf("hash length=%d, want 16", len(hash)) + } +} + +func TestComputeContentHashLongFallback(t *testing.T) { + // Long rawHex (>= 16) but invalid ΓåÆ returns first 16 chars + longInvalid := "ZZZZZZZZZZZZZZZZZZZZZZZZ" + hash := ComputeContentHash(longInvalid) + if hash != longInvalid[:16] { + t.Errorf("hash=%s, want first 16 of input", hash) + } +} + +func TestDecodePacketWithWhitespace(t *testing.T) { + raw := "0A 00 D6 9F D7 A5 A7 47 5D B0 73 37 74 9A E6 1F A5 3A 47 88 E9 76" + pkt, err := DecodePacket(raw, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Header.PayloadType != PayloadTXT_MSG { + t.Errorf("payloadType=%d, want %d", pkt.Header.PayloadType, PayloadTXT_MSG) + } +} + +func TestDecodePacketWithNewlines(t *testing.T) { + raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976" + pkt, err := DecodePacket(raw, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.Type != "TXT_MSG" { + t.Errorf("type=%s, want TXT_MSG", pkt.Payload.Type) + } +} + +func TestDecodePacketTransportRouteTooShort(t *testing.T) { + // TRANSPORT_FLOOD (route=0) but only 2 bytes total ΓåÆ too short for transport codes + _, err := DecodePacket("1400", nil) + if err == nil { + t.Error("expected error for transport route with too-short buffer") + } + if !strings.Contains(err.Error(), "transport codes") { + t.Errorf("error should mention transport codes: %v", err) + } +} + +func TestDecodeAckShort(t *testing.T) { + p := decodeAck([]byte{0x01, 0x02, 0x03}) + if p.Error != "too short" { + t.Errorf("expected 'too short', got %q", p.Error) + } +} + +func TestDecodeAckValid(t *testing.T) { + buf := []byte{0xAA, 0xBB, 0xCC, 0xDD} + p := decodeAck(buf) + if p.Error != "" { + t.Errorf("unexpected error: %s", p.Error) + } + if p.ExtraHash != "ddccbbaa" { + t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash) + } + if p.DestHash != "" { + t.Errorf("destHash should be empty, got %s", p.DestHash) + } + if p.SrcHash != "" { + t.Errorf("srcHash should be empty, got %s", p.SrcHash) + } +} + +func TestIsTransportRoute(t *testing.T) { + if !isTransportRoute(RouteTransportFlood) { + t.Error("RouteTransportFlood should be transport") + } + if !isTransportRoute(RouteTransportDirect) { + t.Error("RouteTransportDirect should be transport") + } + if isTransportRoute(RouteFlood) { + t.Error("RouteFlood should not be transport") + } + if isTransportRoute(RouteDirect) { + t.Error("RouteDirect should not be transport") + } +} + +func TestDecodeHeaderUnknownTypes(t *testing.T) { + // Payload type that doesn't map to any known name + // bits 5-2 = 0x0C (12) is CONTROL but 0x0D (13) would be unknown + // byte = 0b00_1101_01 = 0x35 ΓåÆ routeType=1, payloadType=0x0D(13), version=0 + h := decodeHeader(0x35) + if h.PayloadTypeName != "UNKNOWN" { + t.Errorf("payloadTypeName=%s, want UNKNOWN for type 13", h.PayloadTypeName) + } +} + +func TestDecodePayloadMultipart(t *testing.T) { + // MULTIPART (0x0A) falls through to default ΓåÆ UNKNOWN + p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil) + if p.Type != "UNKNOWN" { + t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePayloadControl(t *testing.T) { + // CONTROL (0x0B) falls through to default ΓåÆ UNKNOWN + p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil) + if p.Type != "UNKNOWN" { + t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type) + } +} + +func TestDecodePathTruncatedBuffer(t *testing.T) { + // path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available + path, consumed := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0) + if path.HashCount != 5 { + t.Errorf("hashCount=%d, want 5", path.HashCount) + } + // Should only decode 2 hops (4 bytes / 2 bytes per hop) + if len(path.Hops) != 2 { + t.Errorf("hops=%d, want 2 (truncated)", len(path.Hops)) + } + if consumed != 10 { + t.Errorf("consumed=%d, want 10 (full claimed size)", consumed) + } +} + +func TestDecodeFloodAdvert5Hops(t *testing.T) { + // From test-decoder.js Test 1 + raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172" + pkt, err := DecodePacket(raw, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Header.RouteTypeName != "FLOOD" { + t.Errorf("route=%s, want FLOOD", pkt.Header.RouteTypeName) + } + if pkt.Header.PayloadTypeName != "ADVERT" { + t.Errorf("payload=%s, want ADVERT", pkt.Header.PayloadTypeName) + } + if pkt.Path.HashSize != 2 { + t.Errorf("hashSize=%d, want 2", pkt.Path.HashSize) + } + if pkt.Path.HashCount != 5 { + t.Errorf("hashCount=%d, want 5", pkt.Path.HashCount) + } + if pkt.Path.Hops[0] != "1000" { + t.Errorf("hop[0]=%s, want 1000", pkt.Path.Hops[0]) + } + if pkt.Path.Hops[1] != "D818" { + t.Errorf("hop[1]=%s, want D818", pkt.Path.Hops[1]) + } + if pkt.TransportCodes != nil { + t.Error("FLOOD should have no transport codes") + } +} + +// --- Channel decryption tests --- + +// buildTestCiphertext creates a valid AES-128-ECB encrypted GRP_TXT payload +// with a matching HMAC-SHA256 MAC for testing. +func buildTestCiphertext(channelKeyHex, senderMsg string, timestamp uint32) (ciphertextHex, macHex string) { + channelKey, _ := hex.DecodeString(channelKeyHex) + + // Build plaintext: timestamp(4 LE) + flags(1) + message + plain := make([]byte, 4+1+len(senderMsg)) + binary.LittleEndian.PutUint32(plain[0:4], timestamp) + plain[4] = 0x00 // flags + copy(plain[5:], senderMsg) + + // Pad to AES block boundary + pad := aes.BlockSize - (len(plain) % aes.BlockSize) + if pad != aes.BlockSize { + plain = append(plain, make([]byte, pad)...) + } + + // AES-128-ECB encrypt + block, _ := aes.NewCipher(channelKey) + ct := make([]byte, len(plain)) + for i := 0; i < len(plain); i += aes.BlockSize { + block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize]) + } + + // HMAC-SHA256 MAC (first 2 bytes) + secret := make([]byte, 32) + copy(secret, channelKey) + h := hmac.New(sha256.New, secret) + h.Write(ct) + mac := h.Sum(nil) + + return hex.EncodeToString(ct), hex.EncodeToString(mac[:2]) +} + +func TestDecryptChannelMessageValid(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(key, "Alice: Hello world", 1700000000) + + result, err := decryptChannelMessage(ctHex, macHex, key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Sender != "Alice" { + t.Errorf("sender=%q, want Alice", result.Sender) + } + if result.Message != "Hello world" { + t.Errorf("message=%q, want 'Hello world'", result.Message) + } + if result.Timestamp != 1700000000 { + t.Errorf("timestamp=%d, want 1700000000", result.Timestamp) + } +} + +func TestDecryptChannelMessageMACFail(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, _ := buildTestCiphertext(key, "Alice: Hello", 100) + wrongMac := "ffff" + + _, err := decryptChannelMessage(ctHex, wrongMac, key) + if err == nil { + t.Fatal("expected MAC verification failure") + } + if !strings.Contains(err.Error(), "MAC") { + t.Errorf("error should mention MAC: %v", err) + } +} + +func TestDecryptChannelMessageWrongKey(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(key, "Alice: Hello", 100) + wrongKey := "deadbeefdeadbeefdeadbeefdeadbeef" + + _, err := decryptChannelMessage(ctHex, macHex, wrongKey) + if err == nil { + t.Fatal("expected error with wrong key") + } +} + +func TestDecryptChannelMessageNoSender(t *testing.T) { + key := "aaaabbbbccccddddaaaabbbbccccdddd" + ctHex, macHex := buildTestCiphertext(key, "Just a message", 500) + + result, err := decryptChannelMessage(ctHex, macHex, key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Sender != "" { + t.Errorf("sender=%q, want empty", result.Sender) + } + if result.Message != "Just a message" { + t.Errorf("message=%q, want 'Just a message'", result.Message) + } +} + +func TestDecryptChannelMessageSenderWithBrackets(t *testing.T) { + key := "aaaabbbbccccddddaaaabbbbccccdddd" + ctHex, macHex := buildTestCiphertext(key, "[admin]: Not a sender", 500) + + result, err := decryptChannelMessage(ctHex, macHex, key) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Sender != "" { + t.Errorf("sender=%q, want empty (brackets disqualify)", result.Sender) + } + if result.Message != "[admin]: Not a sender" { + t.Errorf("message=%q", result.Message) + } +} + +func TestDecryptChannelMessageInvalidKey(t *testing.T) { + _, err := decryptChannelMessage("aabb", "cc", "ZZZZ") + if err == nil { + t.Fatal("expected error for invalid key hex") + } +} + +func TestDecryptChannelMessageShortKey(t *testing.T) { + _, err := decryptChannelMessage("aabb", "cc", "aabb") + if err == nil { + t.Fatal("expected error for short key") + } +} + +func TestDecodeGrpTxtWithDecryption(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(key, "Bob: Testing 123", 1700000000) + macBytes, _ := hex.DecodeString(macHex) + ctBytes, _ := hex.DecodeString(ctHex) + + // Build GRP_TXT payload: channelHash(1) + MAC(2) + encrypted + buf := []byte{0xAA} + buf = append(buf, macBytes...) + buf = append(buf, ctBytes...) + + keys := map[string]string{"#test": key} + p := decodeGrpTxt(buf, keys) + + if p.Type != "CHAN" { + t.Errorf("type=%s, want CHAN", p.Type) + } + if p.DecryptionStatus != "decrypted" { + t.Errorf("decryptionStatus=%s, want decrypted", p.DecryptionStatus) + } + if p.Channel != "#test" { + t.Errorf("channel=%s, want #test", p.Channel) + } + if p.Sender != "Bob" { + t.Errorf("sender=%q, want Bob", p.Sender) + } + if p.Text != "Bob: Testing 123" { + t.Errorf("text=%q, want 'Bob: Testing 123'", p.Text) + } + if p.ChannelHash != 0xAA { + t.Errorf("channelHash=%d, want 0xAA", p.ChannelHash) + } + if p.ChannelHashHex != "AA" { + t.Errorf("channelHashHex=%s, want AA", p.ChannelHashHex) + } + if p.SenderTimestamp != 1700000000 { + t.Errorf("senderTimestamp=%d, want 1700000000", p.SenderTimestamp) + } +} + +func TestDecodeGrpTxtDecryptionFailed(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(key, "Hello", 100) + macBytes, _ := hex.DecodeString(macHex) + ctBytes, _ := hex.DecodeString(ctHex) + + buf := []byte{0xFF} + buf = append(buf, macBytes...) + buf = append(buf, ctBytes...) + + wrongKeys := map[string]string{"#wrong": "deadbeefdeadbeefdeadbeefdeadbeef"} + p := decodeGrpTxt(buf, wrongKeys) + + if p.Type != "GRP_TXT" { + t.Errorf("type=%s, want GRP_TXT", p.Type) + } + if p.DecryptionStatus != "decryption_failed" { + t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus) + } + if p.ChannelHashHex != "FF" { + t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex) + } +} + +func TestDecodeGrpTxtNoKey(t *testing.T) { + buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} + p := decodeGrpTxt(buf, nil) + + if p.Type != "GRP_TXT" { + t.Errorf("type=%s, want GRP_TXT", p.Type) + } + if p.DecryptionStatus != "no_key" { + t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus) + } + if p.ChannelHashHex != "03" { + t.Errorf("channelHashHex=%s, want 03", p.ChannelHashHex) + } +} + +func TestDecodeGrpTxtEmptyKeys(t *testing.T) { + buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22} + p := decodeGrpTxt(buf, map[string]string{}) + + if p.DecryptionStatus != "no_key" { + t.Errorf("decryptionStatus=%s, want no_key", p.DecryptionStatus) + } +} + +func TestDecodeGrpTxtShortEncryptedNoDecryptAttempt(t *testing.T) { + // encryptedData < 5 bytes (10 hex chars) ΓåÆ should not attempt decryption + buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD} + keys := map[string]string{"#test": "2cc3d22840e086105ad73443da2cacb8"} + p := decodeGrpTxt(buf, keys) + + if p.DecryptionStatus != "no_key" { + t.Errorf("decryptionStatus=%s, want no_key (too short for decryption)", p.DecryptionStatus) + } +} + +func TestDecodeGrpTxtMultipleKeysTriesAll(t *testing.T) { + correctKey := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(correctKey, "Eve: Found it", 999) + macBytes, _ := hex.DecodeString(macHex) + ctBytes, _ := hex.DecodeString(ctHex) + + buf := []byte{0x01} + buf = append(buf, macBytes...) + buf = append(buf, ctBytes...) + + keys := map[string]string{ + "#wrong1": "deadbeefdeadbeefdeadbeefdeadbeef", + "#correct": correctKey, + "#wrong2": "11111111111111111111111111111111", + } + p := decodeGrpTxt(buf, keys) + + if p.Type != "CHAN" { + t.Errorf("type=%s, want CHAN", p.Type) + } + if p.Channel != "#correct" { + t.Errorf("channel=%s, want #correct", p.Channel) + } + if p.Sender != "Eve" { + t.Errorf("sender=%q, want Eve", p.Sender) + } +} + +func TestDecodeGrpTxtChannelHashHexZeroPad(t *testing.T) { + buf := []byte{0x03, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE} + p := decodeGrpTxt(buf, nil) + if p.ChannelHashHex != "03" { + t.Errorf("channelHashHex=%s, want 03 (zero-padded)", p.ChannelHashHex) + } +} + +func TestDecodeGrpTxtChannelHashHexFF(t *testing.T) { + buf := []byte{0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE} + p := decodeGrpTxt(buf, nil) + if p.ChannelHashHex != "FF" { + t.Errorf("channelHashHex=%s, want FF", p.ChannelHashHex) + } +} + +// --- Garbage text detection (fixes #197) --- + +func TestDecryptChannelMessageGarbageText(t *testing.T) { + // Build ciphertext with binary garbage as the message + key := "2cc3d22840e086105ad73443da2cacb8" + garbage := "\x01\x02\x03\x80\x81" + ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000) + + _, err := decryptChannelMessage(ctHex, macHex, key) + if err == nil { + t.Fatal("expected error for garbage text, got nil") + } + if !strings.Contains(err.Error(), "non-printable") { + t.Errorf("error should mention non-printable: %v", err) + } +} + +func TestDecryptChannelMessageValidText(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + ctHex, macHex := buildTestCiphertext(key, "Alice: Hello\nworld", 1700000000) + + result, err := decryptChannelMessage(ctHex, macHex, key) + if err != nil { + t.Fatalf("unexpected error for valid text: %v", err) + } + if result.Sender != "Alice" { + t.Errorf("sender=%q, want Alice", result.Sender) + } + if result.Message != "Hello\nworld" { + t.Errorf("message=%q, want 'Hello\\nworld'", result.Message) + } +} + +func TestDecodeGrpTxtGarbageMarkedFailed(t *testing.T) { + key := "2cc3d22840e086105ad73443da2cacb8" + garbage := "\x01\x02\x03\x04\x05" + ctHex, macHex := buildTestCiphertext(key, garbage, 1700000000) + + macBytes, _ := hex.DecodeString(macHex) + ctBytes, _ := hex.DecodeString(ctHex) + buf := make([]byte, 1+2+len(ctBytes)) + buf[0] = 0xFF // channel hash + buf[1] = macBytes[0] + buf[2] = macBytes[1] + copy(buf[3:], ctBytes) + + keys := map[string]string{"#general": key} + p := decodeGrpTxt(buf, keys) + + if p.DecryptionStatus != "decryption_failed" { + t.Errorf("decryptionStatus=%s, want decryption_failed", p.DecryptionStatus) + } + if p.Type != "GRP_TXT" { + t.Errorf("type=%s, want GRP_TXT", p.Type) + } +} + +func TestDecodeAdvertWithTelemetry(t *testing.T) { + pubkey := strings.Repeat("AA", 32) + timestamp := "78563412" + signature := strings.Repeat("BB", 64) + flags := "94" // sensor(4) | hasLocation(0x10) | hasName(0x80) + lat := "40933402" + lon := "E0E6B8F8" + name := hex.EncodeToString([]byte("Sensor1")) + nullTerm := "00" + batteryLE := make([]byte, 2) + binary.LittleEndian.PutUint16(batteryLE, 3700) + tempLE := make([]byte, 2) + binary.LittleEndian.PutUint16(tempLE, uint16(int16(2850))) + + hexStr := "1200" + pubkey + timestamp + signature + flags + lat + lon + + name + nullTerm + + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) + + pkt, err := DecodePacket(hexStr, nil) + if err != nil { + t.Fatal(err) + } + + if pkt.Payload.Name != "Sensor1" { + t.Errorf("name=%s, want Sensor1", pkt.Payload.Name) + } + if pkt.Payload.BatteryMv == nil { + t.Fatal("battery_mv should not be nil") + } + if *pkt.Payload.BatteryMv != 3700 { + t.Errorf("battery_mv=%d, want 3700", *pkt.Payload.BatteryMv) + } + if pkt.Payload.TemperatureC == nil { + t.Fatal("temperature_c should not be nil") + } + if math.Abs(*pkt.Payload.TemperatureC-28.50) > 0.01 { + t.Errorf("temperature_c=%f, want 28.50", *pkt.Payload.TemperatureC) + } +} + +func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) { + pubkey := strings.Repeat("CC", 32) + timestamp := "00000000" + signature := strings.Repeat("DD", 64) + flags := "84" // sensor(4) | hasName(0x80), no location + name := hex.EncodeToString([]byte("Cold")) + nullTerm := "00" + batteryLE := make([]byte, 2) + binary.LittleEndian.PutUint16(batteryLE, 4200) + tempLE := make([]byte, 2) + var negTemp int16 = -550 + binary.LittleEndian.PutUint16(tempLE, uint16(negTemp)) + + hexStr := "1200" + pubkey + timestamp + signature + flags + + name + nullTerm + + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) + + pkt, err := DecodePacket(hexStr, nil) + if err != nil { + t.Fatal(err) + } + + if pkt.Payload.Name != "Cold" { + t.Errorf("name=%s, want Cold", pkt.Payload.Name) + } + if pkt.Payload.BatteryMv == nil || *pkt.Payload.BatteryMv != 4200 { + t.Errorf("battery_mv=%v, want 4200", pkt.Payload.BatteryMv) + } + if pkt.Payload.TemperatureC == nil { + t.Fatal("temperature_c should not be nil") + } + if math.Abs(*pkt.Payload.TemperatureC-(-5.50)) > 0.01 { + t.Errorf("temperature_c=%f, want -5.50", *pkt.Payload.TemperatureC) + } +} + +func TestDecodeAdvertWithoutTelemetry(t *testing.T) { + pubkey := strings.Repeat("EE", 32) + timestamp := "00000000" + signature := strings.Repeat("FF", 64) + flags := "82" // repeater(2) | hasName(0x80) + name := hex.EncodeToString([]byte("Node1")) + + hexStr := "1200" + pubkey + timestamp + signature + flags + name + pkt, err := DecodePacket(hexStr, nil) + if err != nil { + t.Fatal(err) + } + + if pkt.Payload.Name != "Node1" { + t.Errorf("name=%s, want Node1", pkt.Payload.Name) + } + if pkt.Payload.BatteryMv != nil { + t.Errorf("battery_mv should be nil for advert without telemetry, got %d", *pkt.Payload.BatteryMv) + } + if pkt.Payload.TemperatureC != nil { + t.Errorf("temperature_c should be nil for advert without telemetry, got %f", *pkt.Payload.TemperatureC) + } +} + +func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) { + // A repeater node with 4 trailing bytes after the name should NOT decode telemetry. + pubkey := strings.Repeat("AB", 32) + timestamp := "00000000" + signature := strings.Repeat("CD", 64) + flags := "82" // repeater(2) | hasName(0x80) + name := hex.EncodeToString([]byte("Rptr")) + nullTerm := "00" + extraBytes := "B40ED403" // battery-like and temp-like bytes + + hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes + pkt, err := DecodePacket(hexStr, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.BatteryMv != nil { + t.Errorf("battery_mv should be nil for non-sensor node, got %d", *pkt.Payload.BatteryMv) + } + if pkt.Payload.TemperatureC != nil { + t.Errorf("temperature_c should be nil for non-sensor node, got %f", *pkt.Payload.TemperatureC) + } +} + +func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) { + // 0┬░C is a valid temperature and must be emitted. + pubkey := strings.Repeat("12", 32) + timestamp := "00000000" + signature := strings.Repeat("34", 64) + flags := "84" // sensor(4) | hasName(0x80) + name := hex.EncodeToString([]byte("FreezeSensor")) + nullTerm := "00" + batteryLE := make([]byte, 2) + binary.LittleEndian.PutUint16(batteryLE, 3600) + tempLE := make([]byte, 2) // tempRaw=0 ΓåÆ 0┬░C + + hexStr := "1200" + pubkey + timestamp + signature + flags + + name + nullTerm + + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) + + pkt, err := DecodePacket(hexStr, nil) + if err != nil { + t.Fatal(err) + } + if pkt.Payload.TemperatureC == nil { + t.Fatal("temperature_c should not be nil for 0┬░C") + } + if *pkt.Payload.TemperatureC != 0.0 { + t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC) + } +}