mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-19 14:25:04 +00:00
85e97d2f37
**RED commit:** `65d9f57b` (CI run will appear at https://github.com/Kpa-clawbot/CoreScope/actions after PR opens) Fixes #1211 ## Root cause `decodePath()` returns `bytesConsumed = hash_size * hash_count` where both come straight from the wire-supplied `pathByte` (upper 2 bits → `hash_size`, lower 6 bits → `hash_count`). Max claimable: 4 × 63 = 252 bytes. A malformed packet on the wire claimed `pathByte=0xF6` (hash_size=4, hash_count=54 → 216 path bytes) inside a 15-byte buffer. The inner hop-extraction loop in `decodePath` did break early on overflow — but `bytesConsumed` was still returned at face value (216). `DecodePacket` then did `offset += 216` (offset=218) and `payloadBuf := buf[offset:]` panicked with the prod-observed signature: ``` runtime error: slice bounds out of range [218:15] ``` The handler-level `defer/recover` at `cmd/ingestor/main.go:258-263` caught it, but the message was silently dropped with no usable diagnostic. ## Fix Add a `if offset > len(buf)` guard at BOTH decoder sites (same pattern, same panic potential): - `cmd/ingestor/decoder.go` — DecodePacket after decodePath - `cmd/server/decoder.go` — DecodePacket after decodePath Return a descriptive error citing the claimed length and pathByte hex so operators can reproduce. Also: `cmd/ingestor/main.go` decode-error log now includes `topic`, `observer`, and `rawHexLen` so future malformed packets are reproducible without needing to attach a debugger. ## Tests (TDD red → green) Both packages got two new tests: - **`TestDecodePacketBoundsFromWire_Issue1211`** — feeds the exact wire shape from the prod log (`pathByte=0xF6` inside a 15-byte buf). Asserts `DecodePacket` does NOT panic and returns an error. - **`TestDecodePacketFuzzTruncated_Issue1211`** — sweeps every `(header, pathByte)` combination with tails 0..19 bytes (≈1.3M inputs). Asserts zero panics. ### Red commit proof On commit `65d9f57b` (RED), both tests fail with the panic: ``` === RUN TestDecodePacketBoundsFromWire_Issue1211 decoder_test.go:1996: DecodePacket panicked on malformed input: runtime error: slice bounds out of range [218:15] --- FAIL: TestDecodePacketBoundsFromWire_Issue1211 (0.00s) === RUN TestDecodePacketFuzzTruncated_Issue1211 decoder_test.go:2010: DecodePacket panicked during fuzz: runtime error: slice bounds out of range [3:2] --- FAIL: TestDecodePacketFuzzTruncated_Issue1211 (0.01s) ``` On commit `7a6ae52c` (GREEN), full suites pass: - `cmd/ingestor`: `ok 53.988s` - `cmd/server`: `ok 29.456s` ## Acceptance criteria - [x] Identify the slice op producing `[218:15]` — `payloadBuf := buf[offset:]` in `DecodePacket` (decoder.go), where `offset` had been advanced by an unchecked `bytesConsumed` from `decodePath()`. - [x] Bounds check added at the identified site(s) — both ingestor and server decoders. - [x] Test with crafted payload (length-field > remaining buffer) — `TestDecodePacketBoundsFromWire_Issue1211`. - [x] Log topic, observer ID, payload byte length on drop — updated `MQTT [%s] decode error` log line. - [x] Existing tests stay green — confirmed both packages. ## Out of scope Reconnect-after-disconnect (#1212) — handled by a separate subagent. This PR touches NO reconnect logic. --------- Co-authored-by: corescope-bot <bot@corescope.local> Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: corescope-bot <bot@corescope>
577 lines
19 KiB
Go
577 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"testing"
|
|
)
|
|
|
|
func TestDecodeHeader_TransportFlood(t *testing.T) {
|
|
// Route type 0 = TRANSPORT_FLOOD, payload type 5 = GRP_TXT, version 0
|
|
// Header byte: (0 << 6) | (5 << 2) | 0 = 0x14
|
|
h := decodeHeader(0x14)
|
|
if h.RouteType != RouteTransportFlood {
|
|
t.Errorf("expected RouteTransportFlood (0), got %d", h.RouteType)
|
|
}
|
|
if h.RouteTypeName != "TRANSPORT_FLOOD" {
|
|
t.Errorf("expected TRANSPORT_FLOOD, got %s", h.RouteTypeName)
|
|
}
|
|
if h.PayloadType != PayloadGRP_TXT {
|
|
t.Errorf("expected PayloadGRP_TXT (5), got %d", h.PayloadType)
|
|
}
|
|
}
|
|
|
|
func TestDecodeHeader_TransportDirect(t *testing.T) {
|
|
// Route type 3 = TRANSPORT_DIRECT, payload type 2 = TXT_MSG, version 0
|
|
// Header byte: (0 << 6) | (2 << 2) | 3 = 0x0B
|
|
h := decodeHeader(0x0B)
|
|
if h.RouteType != RouteTransportDirect {
|
|
t.Errorf("expected RouteTransportDirect (3), got %d", h.RouteType)
|
|
}
|
|
if h.RouteTypeName != "TRANSPORT_DIRECT" {
|
|
t.Errorf("expected TRANSPORT_DIRECT, got %s", h.RouteTypeName)
|
|
}
|
|
}
|
|
|
|
func TestDecodeHeader_Flood(t *testing.T) {
|
|
// Route type 1 = FLOOD, payload type 4 = ADVERT
|
|
// Header byte: (0 << 6) | (4 << 2) | 1 = 0x11
|
|
h := decodeHeader(0x11)
|
|
if h.RouteType != RouteFlood {
|
|
t.Errorf("expected RouteFlood (1), got %d", h.RouteType)
|
|
}
|
|
if h.RouteTypeName != "FLOOD" {
|
|
t.Errorf("expected FLOOD, got %s", h.RouteTypeName)
|
|
}
|
|
}
|
|
|
|
func TestIsTransportRoute(t *testing.T) {
|
|
if !isTransportRoute(RouteTransportFlood) {
|
|
t.Error("expected RouteTransportFlood to be transport")
|
|
}
|
|
if !isTransportRoute(RouteTransportDirect) {
|
|
t.Error("expected RouteTransportDirect to be transport")
|
|
}
|
|
if isTransportRoute(RouteFlood) {
|
|
t.Error("expected RouteFlood to NOT be transport")
|
|
}
|
|
if isTransportRoute(RouteDirect) {
|
|
t.Error("expected RouteDirect to NOT be transport")
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TransportFloodHasCodes(t *testing.T) {
|
|
// Build a minimal TRANSPORT_FLOOD packet:
|
|
// Header 0x14 (route=0/T_FLOOD, payload=5/GRP_TXT)
|
|
// Transport codes: AABB CCDD (4 bytes)
|
|
// Path byte: 0x00 (hashSize=1, hashCount=0)
|
|
// Payload: at least some bytes for GRP_TXT
|
|
hex := "14AABBCCDD00112233445566778899"
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if pkt.TransportCodes == nil {
|
|
t.Fatal("expected transport codes to be present")
|
|
}
|
|
if pkt.TransportCodes.Code1 != "AABB" {
|
|
t.Errorf("expected Code1=AABB, got %s", pkt.TransportCodes.Code1)
|
|
}
|
|
if pkt.TransportCodes.Code2 != "CCDD" {
|
|
t.Errorf("expected Code2=CCDD, got %s", pkt.TransportCodes.Code2)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
|
|
// Header 0x11 (route=1/FLOOD, payload=4/ADVERT)
|
|
// Path byte: 0x00 (no hops)
|
|
// Some payload bytes
|
|
hex := "110011223344556677889900AABBCCDD"
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if pkt.TransportCodes != nil {
|
|
t.Error("expected no transport codes for FLOOD route")
|
|
}
|
|
}
|
|
|
|
|
|
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
|
|
// Need at least a few payload bytes after pathByte.
|
|
hex := "02" + "00" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
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
|
|
// because hash_count is zero (lower 6 bits are 0).
|
|
hex := "02" + "40" + repeatHex("AA", 20)
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
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, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 0 {
|
|
t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
|
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
|
|
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
|
|
// because hash_size==4 is reserved. Go decoder must mirror that — even
|
|
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
|
|
// silently accepted; firmware never emits hash_size==4.
|
|
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
|
_, err := DecodePacket(hex, false)
|
|
if err == nil {
|
|
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
|
|
}
|
|
}
|
|
|
|
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, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("FLOOD zero pathByte: want HashSize=1 (unchanged), got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
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
|
|
// Need 1 hop hash byte after pathByte.
|
|
hex := "02" + "01" + repeatHex("BB", 21)
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket failed: %v", err)
|
|
}
|
|
if pkt.Path.HashSize != 1 {
|
|
t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func repeatHex(byteHex string, n int) string {
|
|
s := ""
|
|
for i := 0; i < n; i++ {
|
|
s += byteHex
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestDecodePacket_TraceHopsCompleted(t *testing.T) {
|
|
// Build a TRACE packet:
|
|
// header: route=FLOOD(1), payload=TRACE(9), version=0 → (0<<6)|(9<<2)|1 = 0x25
|
|
// path_length: hash_size bits=0b00 (1-byte), hash_count=2 (2 SNR bytes) → 0x02
|
|
// path: 2 SNR bytes: 0xAA, 0xBB
|
|
// payload: tag(4 LE) + authCode(4 LE) + flags(1) + 4 hop hashes (1 byte each)
|
|
hex := "2502AABB" + // header + path_length + 2 SNR bytes
|
|
"01000000" + // tag = 1
|
|
"02000000" + // authCode = 2
|
|
"00" + // flags = 0
|
|
"DEADBEEF" // 4 hops (1-byte hash each)
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Payload.Type != "TRACE" {
|
|
t.Fatalf("expected TRACE, got %s", pkt.Payload.Type)
|
|
}
|
|
// Full intended route = 4 hops from payload
|
|
if len(pkt.Path.Hops) != 4 {
|
|
t.Errorf("expected 4 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
|
}
|
|
// HopsCompleted = 2 (from header path SNR count)
|
|
if pkt.Path.HopsCompleted == nil {
|
|
t.Fatal("expected HopsCompleted to be set")
|
|
}
|
|
if *pkt.Path.HopsCompleted != 2 {
|
|
t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted)
|
|
}
|
|
// FLOOD routing for TRACE is anomalous
|
|
if pkt.Anomaly == "" {
|
|
t.Error("expected anomaly flag for FLOOD-routed TRACE")
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceNoSNR(t *testing.T) {
|
|
// TRACE with 0 SNR bytes (trace hasn't been forwarded yet)
|
|
// path_length: hash_size=0b00 (1-byte), hash_count=0 → 0x00
|
|
hex := "2500" + // header + path_length (0 hops in header)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"00" + // flags
|
|
"AABBCC" // 3 hops intended
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Path.HopsCompleted == nil {
|
|
t.Fatal("expected HopsCompleted to be set")
|
|
}
|
|
if *pkt.Path.HopsCompleted != 0 {
|
|
t.Errorf("expected HopsCompleted=0, got %d", *pkt.Path.HopsCompleted)
|
|
}
|
|
if len(pkt.Path.Hops) != 3 {
|
|
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceFullyCompleted(t *testing.T) {
|
|
// TRACE where all hops completed (SNR count = hop count)
|
|
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
|
|
hex := "2503AABBCC" + // header + path_length + 3 SNR bytes
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"00" + // flags
|
|
"DDEEFF" // 3 hops intended
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Path.HopsCompleted == nil {
|
|
t.Fatal("expected HopsCompleted to be set")
|
|
}
|
|
if *pkt.Path.HopsCompleted != 3 {
|
|
t.Errorf("expected HopsCompleted=3, got %d", *pkt.Path.HopsCompleted)
|
|
}
|
|
if len(pkt.Path.Hops) != 3 {
|
|
t.Errorf("expected 3 hops, got %d", len(pkt.Path.Hops))
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceFlags1_TwoBytePathSz(t *testing.T) {
|
|
// TRACE with flags=1 → path_sz = 1 << (1 & 0x03) = 2-byte hashes
|
|
// Firmware always sends TRACE as DIRECT (route_type=2), so header byte =
|
|
// (0<<6)|(9<<2)|2 = 0x26. path_length 0x00 = 0 SNR bytes.
|
|
hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"01" + // flags = 1 → path_sz = 2
|
|
"AABBCCDD" // 4 bytes = 2 hops of 2-byte each
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if len(pkt.Path.Hops) != 2 {
|
|
t.Errorf("expected 2 hops (2-byte path_sz), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
|
}
|
|
if pkt.Path.HashSize != 2 {
|
|
t.Errorf("expected HashSize=2, got %d", pkt.Path.HashSize)
|
|
}
|
|
if pkt.Anomaly != "" {
|
|
t.Errorf("expected no anomaly for DIRECT TRACE, got %q", pkt.Anomaly)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceFlags2_FourBytePathSz(t *testing.T) {
|
|
// TRACE with flags=2 → path_sz = 1 << (2 & 0x03) = 4-byte hashes
|
|
// DIRECT route_type (0x26)
|
|
hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"02" + // flags = 2 → path_sz = 4
|
|
"AABBCCDD11223344" // 8 bytes = 2 hops of 4-byte each
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if len(pkt.Path.Hops) != 2 {
|
|
t.Errorf("expected 2 hops (4-byte path_sz), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
|
}
|
|
if pkt.Path.HashSize != 4 {
|
|
t.Errorf("expected HashSize=4, got %d", pkt.Path.HashSize)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TracePathSzUnevenPayload(t *testing.T) {
|
|
// TRACE with flags=1 → path_sz=2, but 5 bytes of path data (not evenly divisible)
|
|
// Should produce 2 hops (4 bytes) and ignore the trailing byte
|
|
hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"01" + // flags = 1 → path_sz = 2
|
|
"AABBCCDDEE" // 5 bytes → 2 hops, 1 byte remainder ignored
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if len(pkt.Path.Hops) != 2 {
|
|
t.Errorf("expected 2 hops (trailing byte ignored), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceTransportDirect(t *testing.T) {
|
|
// TRACE via TRANSPORT_DIRECT (route_type=3) — includes 4 transport code bytes
|
|
// header: (0<<6)|(9<<2)|3 = 0x27
|
|
hex := "27" + // header (TRANSPORT_DIRECT+TRACE)
|
|
"AABB" + "CCDD" + // transport codes (2+2 bytes)
|
|
"02" + // path_length: hash_count=2 SNR bytes
|
|
"EEFF" + // 2 SNR bytes
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"00" + // flags = 0 → path_sz = 1
|
|
"112233" // 3 hops (1-byte each)
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.TransportCodes == nil {
|
|
t.Fatal("expected transport codes for TRANSPORT_DIRECT")
|
|
}
|
|
if pkt.TransportCodes.Code1 != "AABB" {
|
|
t.Errorf("expected Code1=AABB, got %s", pkt.TransportCodes.Code1)
|
|
}
|
|
if len(pkt.Path.Hops) != 3 {
|
|
t.Errorf("expected 3 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops)
|
|
}
|
|
if pkt.Path.HopsCompleted == nil || *pkt.Path.HopsCompleted != 2 {
|
|
t.Errorf("expected HopsCompleted=2, got %v", pkt.Path.HopsCompleted)
|
|
}
|
|
if pkt.Anomaly != "" {
|
|
t.Errorf("expected no anomaly for TRANSPORT_DIRECT TRACE, got %q", pkt.Anomaly)
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceFloodRouteAnomaly(t *testing.T) {
|
|
// TRACE via FLOOD (route_type=1) — anomalous per firmware (firmware only
|
|
// sends TRACE as DIRECT). Should still parse but flag the anomaly.
|
|
hex := "2500" + // header (FLOOD+TRACE) + path_length (0 SNR)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"01" + // flags = 1 → path_sz = 2
|
|
"AABBCCDD" // 4 bytes = 2 hops of 2-byte each
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("should not crash on anomalous FLOOD+TRACE: %v", err)
|
|
}
|
|
if len(pkt.Path.Hops) != 2 {
|
|
t.Errorf("expected 2 hops even for anomalous FLOOD route, got %d", len(pkt.Path.Hops))
|
|
}
|
|
if pkt.Anomaly == "" {
|
|
t.Error("expected anomaly flag for FLOOD-routed TRACE, got empty string")
|
|
}
|
|
}
|
|
|
|
func TestDecodeAdvertSignatureValidation(t *testing.T) {
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var timestamp uint32 = 1234567890
|
|
appdata := []byte{0x02} // flags: repeater, no extras
|
|
|
|
// Build signed message: pubKey(32) + timestamp(4 LE) + appdata
|
|
msg := make([]byte, 32+4+len(appdata))
|
|
copy(msg[0:32], pub)
|
|
binary.LittleEndian.PutUint32(msg[32:36], timestamp)
|
|
copy(msg[36:], appdata)
|
|
sig := ed25519.Sign(priv, msg)
|
|
|
|
// Build a raw advert buffer: pubKey(32) + timestamp(4) + signature(64) + appdata
|
|
buf := make([]byte, 100+len(appdata))
|
|
copy(buf[0:32], pub)
|
|
binary.LittleEndian.PutUint32(buf[32:36], timestamp)
|
|
copy(buf[36:100], sig)
|
|
copy(buf[100:], appdata)
|
|
|
|
// With validation enabled
|
|
p := decodeAdvert(buf, true)
|
|
if p.SignatureValid == nil {
|
|
t.Fatal("expected SignatureValid to be set")
|
|
}
|
|
if !*p.SignatureValid {
|
|
t.Error("expected valid signature")
|
|
}
|
|
if p.PubKey != hex.EncodeToString(pub) {
|
|
t.Errorf("pubkey mismatch: got %s", p.PubKey)
|
|
}
|
|
|
|
// Tamper with signature → invalid
|
|
buf[40] ^= 0xFF
|
|
p = decodeAdvert(buf, true)
|
|
if p.SignatureValid == nil {
|
|
t.Fatal("expected SignatureValid to be set")
|
|
}
|
|
if *p.SignatureValid {
|
|
t.Error("expected invalid signature after tampering")
|
|
}
|
|
|
|
// Without validation → SignatureValid should be nil
|
|
p = decodeAdvert(buf, false)
|
|
if p.SignatureValid != nil {
|
|
t.Error("expected SignatureValid to be nil when validation disabled")
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceSNRValues(t *testing.T) {
|
|
// TRACE packet with 3 SNR bytes in header path:
|
|
// SNR byte 0: 0x14 = int8(20) → 20/4.0 = 5.0 dB
|
|
// SNR byte 1: 0xF4 = int8(-12) → -12/4.0 = -3.0 dB
|
|
// SNR byte 2: 0x08 = int8(8) → 8/4.0 = 2.0 dB
|
|
// header: DIRECT+TRACE = (0<<6)|(9<<2)|2 = 0x26
|
|
// path_length: hash_size=0b00 (1-byte), hash_count=3 → 0x03
|
|
hex := "2603" + "14F408" + // header + path_byte + 3 SNR bytes
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"00" + // flags=0 → path_sz=1
|
|
"AABBCCDD" // 4 route hops (1-byte each)
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if pkt.Payload.SNRValues == nil {
|
|
t.Fatal("expected SNRValues to be populated")
|
|
}
|
|
if len(pkt.Payload.SNRValues) != 3 {
|
|
t.Fatalf("expected 3 SNR values, got %d", len(pkt.Payload.SNRValues))
|
|
}
|
|
expected := []float64{5.0, -3.0, 2.0}
|
|
for i, want := range expected {
|
|
if pkt.Payload.SNRValues[i] != want {
|
|
t.Errorf("SNRValues[%d] = %v, want %v", i, pkt.Payload.SNRValues[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodePacket_TraceNoSNRValues(t *testing.T) {
|
|
// TRACE with 0 SNR bytes → SNRValues should be nil/empty
|
|
hex := "2600" + // header + path_byte (0 hops)
|
|
"01000000" + // tag
|
|
"02000000" + // authCode
|
|
"00" + // flags
|
|
"AABB" // 2 route hops
|
|
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket error: %v", err)
|
|
}
|
|
if len(pkt.Payload.SNRValues) != 0 {
|
|
t.Errorf("expected empty SNRValues, got %v", pkt.Payload.SNRValues)
|
|
}
|
|
}
|
|
|
|
// TestDecodePacketBoundsFromWire_Issue1211 — mirror of ingestor test.
|
|
// Malformed pathByte=0xF6 inside a 15-byte buffer triggered
|
|
// `slice bounds out of range [218:15]`.
|
|
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
|
|
raw := "12F6"
|
|
for i := 0; i < 13; i++ {
|
|
raw += "AA"
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
|
|
}
|
|
}()
|
|
pkt, err := DecodePacket(raw, false)
|
|
if err == nil {
|
|
t.Fatalf("expected error for malformed packet, got nil; pkt=%+v", pkt)
|
|
}
|
|
}
|
|
|
|
// Adv M2: see cmd/ingestor/decoder_test.go — sweep gated on !testing.Short();
|
|
// FuzzDecodePacketTruncated below is the real fuzzing target.
|
|
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
|
|
}
|
|
}()
|
|
if testing.Short() {
|
|
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
|
|
}
|
|
for hdr := 0; hdr < 256; hdr++ {
|
|
for pb := 0; pb < 256; pb++ {
|
|
for tail := 0; tail < 20; tail++ {
|
|
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)})
|
|
for i := 0; i < tail; i++ {
|
|
raw += "00"
|
|
}
|
|
_, _ = DecodePacket(raw, false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// FuzzDecodePacketTruncated — native go fuzz target. Zero panics required.
|
|
// Run with: go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/server
|
|
func FuzzDecodePacketTruncated(f *testing.F) {
|
|
seeds := [][]byte{
|
|
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
|
|
{0x12, 0x00},
|
|
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
|
|
}
|
|
for _, s := range seeds {
|
|
f.Add(s)
|
|
}
|
|
f.Fuzz(func(t *testing.T, data []byte) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
|
|
}
|
|
}()
|
|
_, _ = DecodePacket(hex.EncodeToString(data), false)
|
|
})
|
|
}
|
|
|
|
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
|
|
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
|
|
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
|
|
// carrying >32 bytes of name data is adversarial — the Go decoder must not
|
|
// surface attacker-controlled bytes beyond what firmware would ever emit.
|
|
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
|
|
pubkey := repeatHex("AA", 32)
|
|
timestamp := "78563412"
|
|
signature := repeatHex("BB", 64)
|
|
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
|
|
// 64-byte ASCII 'X' name (firmware buffer is only 32 bytes).
|
|
name := repeatHex("58", 64)
|
|
hex := "1200" + pubkey + timestamp + signature + flags + name
|
|
pkt, err := DecodePacket(hex, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket: %v", err)
|
|
}
|
|
if got := len(pkt.Payload.Name); got > 32 {
|
|
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
|
|
}
|
|
}
|