mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-31 04:05:50 +00:00
Agent-Logs-Url: https://github.com/Kpa-clawbot/meshcore-analyzer/sessions/1c2af64b-0e8a-4dd0-ae80-e296f70437e9 Co-authored-by: KpaBap <746025+KpaBap@users.noreply.github.com>
1509 lines
44 KiB
Go
1509 lines
44 KiB
Go
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
|
|
hex := "1400" + "AABB" + "CCDD" + "1A" + 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.NextHop != "AABB" {
|
|
t.Errorf("nextHop=%s, want AABB", pkt.TransportCodes.NextHop)
|
|
}
|
|
if pkt.TransportCodes.LastHop != "CCDD" {
|
|
t.Errorf("lastHop=%s, want CCDD", pkt.TransportCodes.LastHop)
|
|
}
|
|
|
|
// 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)
|
|
buf[0] = 0x00
|
|
buf[1] = 0x01 // tag LE uint32 = 1
|
|
buf[5] = 0xAA // destHash start
|
|
buf[11] = 0xBB
|
|
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.Type != "TRACE" {
|
|
t.Errorf("type=%s, want TRACE", p.Type)
|
|
}
|
|
}
|
|
|
|
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 no path hops + 4 transport code bytes
|
|
// header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops)
|
|
// transport codes = 4 bytes, then payload
|
|
hex := "1400" + "AABBCCDD" + 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
|
|
// total buffer too short for transport codes + path
|
|
// header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes
|
|
// payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes
|
|
hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
|
hash := ComputeContentHash(hex)
|
|
// payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work
|
|
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 3 bytes total → too short for transport codes
|
|
_, err := DecodePacket("140011", 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, 0xEE, 0xFF}
|
|
p := decodeAck(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.ExtraHash != "ccddeeff" {
|
|
t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|