diff --git a/cmd/ingestor/coverage_boost_test.go b/cmd/ingestor/coverage_boost_test.go index fa7315c0..d1a59b72 100644 --- a/cmd/ingestor/coverage_boost_test.go +++ b/cmd/ingestor/coverage_boost_test.go @@ -461,7 +461,7 @@ func TestDecodeAdvertLocationTruncated(t *testing.T) { buf[100] = 0x11 // Only 4 bytes after flags — not enough for full location (needs 8) - p := decodeAdvert(buf[:105]) + p := decodeAdvert(buf[:105], false) if p.Error != "" { t.Fatalf("error: %s", p.Error) } @@ -483,7 +483,7 @@ func TestDecodeAdvertFeat1Truncated(t *testing.T) { buf[100] = 0x21 // Only 1 byte after flags — not enough for feat1 (needs 2) - p := decodeAdvert(buf[:102]) + p := decodeAdvert(buf[:102], false) if p.Feat1 != nil { t.Error("feat1 should be nil with truncated data") } @@ -504,7 +504,7 @@ func TestDecodeAdvertFeat2Truncated(t *testing.T) { buf[102] = 0x00 // Only 1 byte left — not enough for feat2 - p := decodeAdvert(buf[:104]) + p := decodeAdvert(buf[:104], false) if p.Feat1 == nil { t.Error("feat1 should be set") } @@ -544,7 +544,7 @@ func TestDecodeAdvertSensorBadTelemetry(t *testing.T) { buf[105] = 0x20 buf[106] = 0x4E - p := decodeAdvert(buf[:107]) + p := decodeAdvert(buf[:107], false) if p.BatteryMv != nil { t.Error("battery_mv=0 should be nil") } @@ -740,7 +740,7 @@ func TestDecodeAdvertSensorNoName(t *testing.T) { buf[103] = 0xC4 buf[104] = 0x09 - p := decodeAdvert(buf[:105]) + p := decodeAdvert(buf[:105], false) if p.Error != "" { t.Fatalf("error: %s", p.Error) } @@ -835,7 +835,7 @@ func TestDecodePacketNoPathByteAfterHeader(t *testing.T) { // Non-transport route, but only header byte (no path byte) // Actually 0A alone = 1 byte, but we need >= 2 // Header + exactly at offset boundary - _, err := DecodePacket("0A", nil) + _, err := DecodePacket("0A", nil, false) if err == nil { t.Error("should error - too short") } @@ -856,7 +856,7 @@ func TestDecodeAdvertNameNoNull(t *testing.T) { // Name without null terminator — goes to end of buffer copy(buf[101:], []byte("LongNameNoNull")) - p := decodeAdvert(buf[:115]) + p := decodeAdvert(buf[:115], false) if p.Name != "LongNameNoNull" { t.Errorf("name=%q, want LongNameNoNull", p.Name) } diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index b9ef3f6d..c265cd59 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -576,7 +576,7 @@ func TestEndToEndIngest(t *testing.T) { // Simulate full pipeline: decode + insert rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" - decoded, err := DecodePacket(rawHex, nil) + decoded, err := DecodePacket(rawHex, nil, false) if err != nil { t.Fatal(err) } @@ -764,7 +764,7 @@ func TestInsertTransmissionNilSNRRSSI(t *testing.T) { func TestBuildPacketData(t *testing.T) { rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" - decoded, err := DecodePacket(rawHex, nil) + decoded, err := DecodePacket(rawHex, nil, false) if err != nil { t.Fatal(err) } @@ -818,7 +818,7 @@ func TestBuildPacketData(t *testing.T) { func TestBuildPacketDataWithHops(t *testing.T) { // A packet with actual hops in the path raw := "0505AABBCCDDEE" + strings.Repeat("00", 10) - decoded, err := DecodePacket(raw, nil) + decoded, err := DecodePacket(raw, nil, false) if err != nil { t.Fatal(err) } @@ -834,7 +834,7 @@ func TestBuildPacketDataWithHops(t *testing.T) { } func TestBuildPacketDataNilSNRRSSI(t *testing.T) { - decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil) + decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false) msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)} pkt := BuildPacketData(msg, decoded, "", "") @@ -1624,7 +1624,7 @@ func TestObsTimestampIndexMigration(t *testing.T) { func TestBuildPacketDataScoreAndDirection(t *testing.T) { rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976" - decoded, err := DecodePacket(rawHex, nil) + decoded, err := DecodePacket(rawHex, nil, false) if err != nil { t.Fatal(err) } @@ -1647,7 +1647,7 @@ func TestBuildPacketDataScoreAndDirection(t *testing.T) { } func TestBuildPacketDataNilScoreDirection(t *testing.T) { - decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil) + decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false) msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)} pkt := BuildPacketData(msg, decoded, "", "") diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index 9d29746b..c5a4ddff 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -2,6 +2,7 @@ package main import ( "crypto/aes" + "crypto/ed25519" "crypto/hmac" "crypto/sha256" "encoding/binary" @@ -109,6 +110,7 @@ type Payload struct { Timestamp uint32 `json:"timestamp,omitempty"` TimestampISO string `json:"timestampISO,omitempty"` Signature string `json:"signature,omitempty"` + SignatureValid *bool `json:"signatureValid,omitempty"` Flags *AdvertFlags `json:"flags,omitempty"` Lat *float64 `json:"lat,omitempty"` Lon *float64 `json:"lon,omitempty"` @@ -215,7 +217,27 @@ func decodeAck(buf []byte) Payload { } } -func decodeAdvert(buf []byte) Payload { +func validateAdvertSignature(pubKeyHex, signatureHex string, timestamp uint32, appdata []byte) (bool, error) { + pubKey, err := hex.DecodeString(pubKeyHex) + if err != nil || len(pubKey) != 32 { + return false, fmt.Errorf("invalid pubkey") + } + + signature, err := hex.DecodeString(signatureHex) + if err != nil || len(signature) != 64 { + return false, fmt.Errorf("invalid signature") + } + + // Signed data: pubKey (32) + timestamp (4 LE) + appdata + message := make([]byte, 32+4+len(appdata)) + copy(message[0:32], pubKey) + binary.LittleEndian.PutUint32(message[32:36], timestamp) + copy(message[36:], appdata) + + return ed25519.Verify(ed25519.PublicKey(pubKey), message, signature), nil +} + +func decodeAdvert(buf []byte, validateSignatures bool) Payload { if len(buf) < 100 { return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)} } @@ -233,6 +255,15 @@ func decodeAdvert(buf []byte) Payload { Signature: signature, } + if validateSignatures { + valid, err := validateAdvertSignature(pubKey, signature, timestamp, appdata) + if err != nil { + p.SignatureValid = &[]bool{false}[0] // false + } else { + p.SignatureValid = &valid + } + } + if len(appdata) > 0 { flags := appdata[0] advType := int(flags & 0x0F) @@ -506,7 +537,7 @@ func decodeTrace(buf []byte) Payload { return p } -func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload { +func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, validateSignatures bool) Payload { switch payloadType { case PayloadREQ: return decodeEncryptedPayload("REQ", buf) @@ -517,7 +548,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P case PayloadACK: return decodeAck(buf) case PayloadADVERT: - return decodeAdvert(buf) + return decodeAdvert(buf, validateSignatures) case PayloadGRP_TXT: return decodeGrpTxt(buf, channelKeys) case PayloadANON_REQ: @@ -532,7 +563,7 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) P } // DecodePacket decodes a hex-encoded MeshCore packet. -func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPacket, error) { +func DecodePacket(hexString string, channelKeys map[string]string, validateSignatures bool) (*DecodedPacket, error) { hexString = strings.ReplaceAll(hexString, " ", "") hexString = strings.ReplaceAll(hexString, "\n", "") hexString = strings.ReplaceAll(hexString, "\r", "") @@ -570,7 +601,7 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack offset += bytesConsumed payloadBuf := buf[offset:] - payload := decodePayload(header.PayloadType, payloadBuf, channelKeys) + payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures) // 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 diff --git a/cmd/ingestor/decoder_test.go b/cmd/ingestor/decoder_test.go index 22697525..eafe7c9b 100644 --- a/cmd/ingestor/decoder_test.go +++ b/cmd/ingestor/decoder_test.go @@ -55,7 +55,7 @@ func TestDecodeHeaderPayloadTypes(t *testing.T) { func TestDecodePathZeroHops(t *testing.T) { // 0x00: 0 hops, 1-byte hashes - pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) + pkt, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false) if err != nil { t.Fatal(err) } @@ -72,7 +72,7 @@ func TestDecodePathZeroHops(t *testing.T) { func TestDecodePath1ByteHashes(t *testing.T) { // 0x05: 5 hops, 1-byte hashes → 5 path bytes - pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil) + pkt, err := DecodePacket("0505"+"AABBCCDDEE"+strings.Repeat("00", 10), nil, false) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestDecodePath1ByteHashes(t *testing.T) { func TestDecodePath2ByteHashes(t *testing.T) { // 0x45: 5 hops, 2-byte hashes - pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil) + pkt, err := DecodePacket("0545"+"AA11BB22CC33DD44EE55"+strings.Repeat("00", 10), nil, false) if err != nil { t.Fatal(err) } @@ -112,7 +112,7 @@ func TestDecodePath2ByteHashes(t *testing.T) { func TestDecodePath3ByteHashes(t *testing.T) { // 0x8A: 10 hops, 3-byte hashes - pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil) + pkt, err := DecodePacket("058A"+strings.Repeat("AA11FF", 10)+strings.Repeat("00", 10), nil, false) if err != nil { t.Fatal(err) } @@ -131,7 +131,7 @@ 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) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatal(err) } @@ -149,7 +149,7 @@ func TestTransportCodes(t *testing.T) { } // Route type 1 (FLOOD) should NOT have transport codes - pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil) + pkt2, err := DecodePacket("0500"+strings.Repeat("00", 10), nil, false) if err != nil { t.Fatal(err) } @@ -169,7 +169,7 @@ func TestDecodeAdvertFull(t *testing.T) { name := "546573744E6F6465" // "TestNode" hex := "1200" + pubkey + timestamp + signature + flags + lat + lon + name - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatal(err) } @@ -227,7 +227,7 @@ 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) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatal(err) } @@ -272,7 +272,7 @@ func hexDigit(v byte) byte { func TestDecodeAdvertNoLocationNoName(t *testing.T) { hex := "1200" + strings.Repeat("CC", 32) + "00000000" + strings.Repeat("DD", 64) + "02" - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatal(err) } @@ -291,7 +291,7 @@ func TestDecodeAdvertNoLocationNoName(t *testing.T) { } func TestGoldenFixtureTxtMsg(t *testing.T) { - pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil) + pkt, err := DecodePacket("0A00D69FD7A5A7475DB07337749AE61FA53A4788E976", nil, false) if err != nil { t.Fatal(err) } @@ -314,7 +314,7 @@ func TestGoldenFixtureTxtMsg(t *testing.T) { func TestGoldenFixtureAdvert(t *testing.T) { rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52" - pkt, err := DecodePacket(rawHex, nil) + pkt, err := DecodePacket(rawHex, nil, false) if err != nil { t.Fatal(err) } @@ -337,7 +337,7 @@ func TestGoldenFixtureAdvert(t *testing.T) { func TestGoldenFixtureUnicodeAdvert(t *testing.T) { rawHex := "120073CFF971E1CB5754A742C152B2D2E0EB108A19B246D663ED8898A72C4A5AD86EA6768E66694B025EDF6939D5C44CFF719C5D5520E5F06B20680A83AD9C2C61C3227BBB977A85EE462F3553445FECF8EDD05C234ECE217272E503F14D6DF2B1B9B133890C923CDF3002F8FDC1F85045414BF09F8CB3" - pkt, err := DecodePacket(rawHex, nil) + pkt, err := DecodePacket(rawHex, nil, false) if err != nil { t.Fatal(err) } @@ -354,14 +354,14 @@ func TestGoldenFixtureUnicodeAdvert(t *testing.T) { } func TestDecodePacketTooShort(t *testing.T) { - _, err := DecodePacket("FF", nil) + _, err := DecodePacket("FF", nil, false) if err == nil { t.Error("expected error for 1-byte packet") } } func TestDecodePacketInvalidHex(t *testing.T) { - _, err := DecodePacket("ZZZZ", nil) + _, err := DecodePacket("ZZZZ", nil, false) if err == nil { t.Error("expected error for invalid hex") } @@ -568,7 +568,7 @@ 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) + pkt, err := DecodePacket("260001807dca00000000007d547d", nil, false) if err != nil { t.Fatalf("DecodePacket error: %v", err) } @@ -590,7 +590,7 @@ func TestDecodeTracePathParsing(t *testing.T) { } func TestDecodeAdvertShort(t *testing.T) { - p := decodeAdvert(make([]byte, 50)) + p := decodeAdvert(make([]byte, 50), false) if p.Error != "too short for advert" { t.Errorf("expected 'too short for advert' error, got %q", p.Error) } @@ -628,7 +628,7 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) { func TestDecodePayloadGRPData(t *testing.T) { buf := []byte{0x01, 0x02, 0x03} - p := decodePayload(PayloadGRP_DATA, buf, nil) + p := decodePayload(PayloadGRP_DATA, buf, nil, false) if p.Type != "UNKNOWN" { t.Errorf("type=%s, want UNKNOWN", p.Type) } @@ -639,7 +639,7 @@ func TestDecodePayloadGRPData(t *testing.T) { func TestDecodePayloadRAWCustom(t *testing.T) { buf := []byte{0xFF, 0xFE} - p := decodePayload(PayloadRAW_CUSTOM, buf, nil) + p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false) if p.Type != "UNKNOWN" { t.Errorf("type=%s, want UNKNOWN", p.Type) } @@ -647,49 +647,49 @@ func TestDecodePayloadRAWCustom(t *testing.T) { func TestDecodePayloadAllTypes(t *testing.T) { // REQ - p := decodePayload(PayloadREQ, make([]byte, 10), nil) + p := decodePayload(PayloadREQ, make([]byte, 10), nil, false) if p.Type != "REQ" { t.Errorf("REQ: type=%s", p.Type) } // RESPONSE - p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil) + p = decodePayload(PayloadRESPONSE, make([]byte, 10), nil, false) if p.Type != "RESPONSE" { t.Errorf("RESPONSE: type=%s", p.Type) } // TXT_MSG - p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil) + p = decodePayload(PayloadTXT_MSG, make([]byte, 10), nil, false) if p.Type != "TXT_MSG" { t.Errorf("TXT_MSG: type=%s", p.Type) } // ACK - p = decodePayload(PayloadACK, make([]byte, 10), nil) + p = decodePayload(PayloadACK, make([]byte, 10), nil, false) if p.Type != "ACK" { t.Errorf("ACK: type=%s", p.Type) } // GRP_TXT - p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil) + p = decodePayload(PayloadGRP_TXT, make([]byte, 10), nil, false) if p.Type != "GRP_TXT" { t.Errorf("GRP_TXT: type=%s", p.Type) } // ANON_REQ - p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil) + p = decodePayload(PayloadANON_REQ, make([]byte, 40), nil, false) if p.Type != "ANON_REQ" { t.Errorf("ANON_REQ: type=%s", p.Type) } // PATH - p = decodePayload(PayloadPATH, make([]byte, 10), nil) + p = decodePayload(PayloadPATH, make([]byte, 10), nil, false) if p.Type != "PATH" { t.Errorf("PATH: type=%s", p.Type) } // TRACE - p = decodePayload(PayloadTRACE, make([]byte, 20), nil) + p = decodePayload(PayloadTRACE, make([]byte, 20), nil, false) if p.Type != "TRACE" { t.Errorf("TRACE: type=%s", p.Type) } @@ -925,7 +925,7 @@ func TestComputeContentHashLongFallback(t *testing.T) { 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) + pkt, err := DecodePacket(raw, nil, false) if err != nil { t.Fatal(err) } @@ -936,7 +936,7 @@ func TestDecodePacketWithWhitespace(t *testing.T) { func TestDecodePacketWithNewlines(t *testing.T) { raw := "0A00\nD69F\r\nD7A5A7475DB07337749AE61FA53A4788E976" - pkt, err := DecodePacket(raw, nil) + pkt, err := DecodePacket(raw, nil, false) if err != nil { t.Fatal(err) } @@ -947,7 +947,7 @@ func TestDecodePacketWithNewlines(t *testing.T) { func TestDecodePacketTransportRouteTooShort(t *testing.T) { // TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes - _, err := DecodePacket("1400", nil) + _, err := DecodePacket("1400", nil, false) if err == nil { t.Error("expected error for transport route with too-short buffer") } @@ -1007,7 +1007,7 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) { func TestDecodePayloadMultipart(t *testing.T) { // MULTIPART (0x0A) falls through to default → UNKNOWN - p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil) + p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false) if p.Type != "UNKNOWN" { t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type) } @@ -1015,7 +1015,7 @@ func TestDecodePayloadMultipart(t *testing.T) { func TestDecodePayloadControl(t *testing.T) { // CONTROL (0x0B) falls through to default → UNKNOWN - p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil) + p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false) if p.Type != "UNKNOWN" { t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type) } @@ -1039,7 +1039,7 @@ func TestDecodePathTruncatedBuffer(t *testing.T) { func TestDecodeFloodAdvert5Hops(t *testing.T) { // From test-decoder.js Test 1 raw := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172" - pkt, err := DecodePacket(raw, nil) + pkt, err := DecodePacket(raw, nil, false) if err != nil { t.Fatal(err) } @@ -1410,7 +1410,7 @@ func TestDecodeAdvertWithTelemetry(t *testing.T) { name + nullTerm + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - pkt, err := DecodePacket(hexStr, nil) + pkt, err := DecodePacket(hexStr, nil, false) if err != nil { t.Fatal(err) } @@ -1449,7 +1449,7 @@ func TestDecodeAdvertWithTelemetryNegativeTemp(t *testing.T) { name + nullTerm + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - pkt, err := DecodePacket(hexStr, nil) + pkt, err := DecodePacket(hexStr, nil, false) if err != nil { t.Fatal(err) } @@ -1476,7 +1476,7 @@ func TestDecodeAdvertWithoutTelemetry(t *testing.T) { name := hex.EncodeToString([]byte("Node1")) hexStr := "1200" + pubkey + timestamp + signature + flags + name - pkt, err := DecodePacket(hexStr, nil) + pkt, err := DecodePacket(hexStr, nil, false) if err != nil { t.Fatal(err) } @@ -1503,7 +1503,7 @@ func TestDecodeAdvertNonSensorIgnoresTelemetryBytes(t *testing.T) { extraBytes := "B40ED403" // battery-like and temp-like bytes hexStr := "1200" + pubkey + timestamp + signature + flags + name + nullTerm + extraBytes - pkt, err := DecodePacket(hexStr, nil) + pkt, err := DecodePacket(hexStr, nil, false) if err != nil { t.Fatal(err) } @@ -1531,7 +1531,7 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) { name + nullTerm + hex.EncodeToString(batteryLE) + hex.EncodeToString(tempLE) - pkt, err := DecodePacket(hexStr, nil) + pkt, err := DecodePacket(hexStr, nil, false) if err != nil { t.Fatal(err) } @@ -1555,7 +1555,7 @@ func TestZeroHopDirectHashSize(t *testing.T) { // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 // pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0 hex := "02" + "00" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -1568,7 +1568,7 @@ func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) { // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 // pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0 hex := "02" + "40" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -1581,7 +1581,7 @@ func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) { // FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01 // pathByte=0x00 → non-DIRECT should keep HashSize=1 hex := "01" + "00" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -1594,7 +1594,7 @@ func TestDirectNonZeroHopKeepsHashSize(t *testing.T) { // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 // pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1 hex := "02" + "01" + repeatHex("BB", 21) - pkt, err := DecodePacket(hex, nil) + pkt, err := DecodePacket(hex, nil, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 0beb3ec2..b930557e 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -248,7 +248,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, // Format 1: Raw packet (meshcoretomqtt / Cisien format) rawHex, _ := msg["raw"].(string) if rawHex != "" { - decoded, err := DecodePacket(rawHex, channelKeys) + decoded, err := DecodePacket(rawHex, channelKeys, false) if err != nil { log.Printf("MQTT [%s] decode error: %v", tag, err) return diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index 3c3f8106..0d0bc0fb 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ed25519" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -92,6 +93,7 @@ type Payload struct { Timestamp uint32 `json:"timestamp,omitempty"` TimestampISO string `json:"timestampISO,omitempty"` Signature string `json:"signature,omitempty"` + SignatureValid *bool `json:"signatureValid,omitempty"` Flags *AdvertFlags `json:"flags,omitempty"` Lat *float64 `json:"lat,omitempty"` Lon *float64 `json:"lon,omitempty"` @@ -188,7 +190,27 @@ func decodeAck(buf []byte) Payload { } } -func decodeAdvert(buf []byte) Payload { +func validateAdvertSignature(pubKeyHex, signatureHex string, timestamp uint32, appdata []byte) (bool, error) { + pubKey, err := hex.DecodeString(pubKeyHex) + if err != nil || len(pubKey) != 32 { + return false, fmt.Errorf("invalid pubkey") + } + + signature, err := hex.DecodeString(signatureHex) + if err != nil || len(signature) != 64 { + return false, fmt.Errorf("invalid signature") + } + + // Signed data: pubKey (32) + timestamp (4 LE) + appdata + message := make([]byte, 32+4+len(appdata)) + copy(message[0:32], pubKey) + binary.LittleEndian.PutUint32(message[32:36], timestamp) + copy(message[36:], appdata) + + return ed25519.Verify(ed25519.PublicKey(pubKey), message, signature), nil +} + +func decodeAdvert(buf []byte, validateSignatures bool) Payload { if len(buf) < 100 { return Payload{Type: "ADVERT", Error: "too short for advert", RawHex: hex.EncodeToString(buf)} } @@ -206,6 +228,15 @@ func decodeAdvert(buf []byte) Payload { Signature: signature, } + if validateSignatures { + valid, err := validateAdvertSignature(pubKey, signature, timestamp, appdata) + if err != nil { + p.SignatureValid = &[]bool{false}[0] // false + } else { + p.SignatureValid = &valid + } + } + if len(appdata) > 0 { flags := appdata[0] advType := int(flags & 0x0F) @@ -308,7 +339,7 @@ func decodeTrace(buf []byte) Payload { return p } -func decodePayload(payloadType int, buf []byte) Payload { +func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload { switch payloadType { case PayloadREQ: return decodeEncryptedPayload("REQ", buf) @@ -319,7 +350,7 @@ func decodePayload(payloadType int, buf []byte) Payload { case PayloadACK: return decodeAck(buf) case PayloadADVERT: - return decodeAdvert(buf) + return decodeAdvert(buf, validateSignatures) case PayloadGRP_TXT: return decodeGrpTxt(buf) case PayloadANON_REQ: @@ -334,7 +365,7 @@ func decodePayload(payloadType int, buf []byte) Payload { } // DecodePacket decodes a hex-encoded MeshCore packet. -func DecodePacket(hexString string) (*DecodedPacket, error) { +func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, error) { hexString = strings.ReplaceAll(hexString, " ", "") hexString = strings.ReplaceAll(hexString, "\n", "") hexString = strings.ReplaceAll(hexString, "\r", "") @@ -372,7 +403,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { offset += bytesConsumed payloadBuf := buf[offset:] - payload := decodePayload(header.PayloadType, payloadBuf) + payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures) // 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 diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index 67b6b999..44b6c73a 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -65,7 +65,7 @@ func TestDecodePacket_TransportFloodHasCodes(t *testing.T) { // Path byte: 0x00 (hashSize=1, hashCount=0) // Payload: at least some bytes for GRP_TXT hex := "14AABBCCDD00112233445566778899" - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -85,7 +85,7 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) { // Path byte: 0x00 (no hops) // Some payload bytes hex := "110011223344556677889900AABBCCDD" - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -240,7 +240,7 @@ func TestZeroHopDirectHashSize(t *testing.T) { // pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0 // Need at least a few payload bytes after pathByte. hex := "02" + "00" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -254,7 +254,7 @@ func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) { // pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0 // because hash_count is zero (lower 6 bits are 0). hex := "02" + "40" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -267,7 +267,7 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) { // TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03 // 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0 hex := "03" + "11223344" + "00" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -280,7 +280,7 @@ func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) { // TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03 // 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0 hex := "03" + "11223344" + "C0" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -293,7 +293,7 @@ func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) { // FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01 // pathByte=0x00 → even though hash_count=0, non-DIRECT should keep HashSize=1 hex := "01" + "00" + repeatHex("AA", 20) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -307,7 +307,7 @@ func TestDirectNonZeroHopKeepsHashSize(t *testing.T) { // pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1 // Need 1 hop hash byte after pathByte. hex := "02" + "01" + repeatHex("BB", 21) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket failed: %v", err) } @@ -336,7 +336,7 @@ func TestDecodePacket_TraceHopsCompleted(t *testing.T) { "00" + // flags = 0 "DEADBEEF" // 4 hops (1-byte hash each) - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket error: %v", err) } @@ -365,7 +365,7 @@ func TestDecodePacket_TraceNoSNR(t *testing.T) { "00" + // flags "AABBCC" // 3 hops intended - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket error: %v", err) } @@ -389,7 +389,7 @@ func TestDecodePacket_TraceFullyCompleted(t *testing.T) { "00" + // flags "DDEEFF" // 3 hops intended - pkt, err := DecodePacket(hex) + pkt, err := DecodePacket(hex, false) if err != nil { t.Fatalf("DecodePacket error: %v", err) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index cd4b65d0..e6833abf 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -930,7 +930,7 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) { writeError(w, 400, "hex is required") return } - decoded, err := DecodePacket(hexStr) + decoded, err := DecodePacket(hexStr, true) if err != nil { writeError(w, 400, err.Error()) return @@ -962,7 +962,7 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) { writeError(w, 400, "hex is required") return } - decoded, err := DecodePacket(hexStr) + decoded, err := DecodePacket(hexStr, false) if err != nil { writeError(w, 400, err.Error()) return diff --git a/public/packets.js b/public/packets.js index 95a98b52..e22fc91e 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2174,6 +2174,12 @@ html += kv(k, String(v)); } } + // Special handling for advert signature validation + if (h.payloadType === 4 && p.signatureValid !== undefined) { + const status = p.signatureValid ? 'Valid' : 'Invalid'; + const badgeClass = p.signatureValid ? 'badge-success' : 'badge-danger'; + html += kv('Signature', `${status}`); + } html += ''; // Raw hex