mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 14:27:24 +00:00
## Summary
Validates ed25519 signatures on ADVERT packets during MQTT ingest.
Packets with invalid signatures are rejected before storage, preventing
corrupt/truncated adverts from polluting the database.
## Changes
### Ingestor (`cmd/ingestor/`)
- **Signature validation on ingest**: After decoding an ADVERT, checks
`SignatureValid` from the decoder. Invalid signatures → packet dropped,
never stored.
- **Config flag**: `validateSignatures` (default `true`). Set to `false`
to disable validation for backward compatibility with existing installs.
- **`dropped_packets` table**: New SQLite table recording every rejected
packet with full attribution:
- `hash`, `raw_hex`, `reason`, `observer_id`, `observer_name`,
`node_pubkey`, `node_name`, `dropped_at`
- Indexed on `observer_id` and `node_pubkey` for investigation queries
- **`SignatureDrops` counter**: New atomic counter in `DBStats`, logged
in periodic stats output as `sig_drops=N`
- **Retention**: `dropped_packets` pruned alongside metrics on the same
`retention.metricsDays` schedule
### Server (`cmd/server/`)
- **`GET /api/dropped-packets`** (API key required): Returns recent
drops with optional `?observer=` and `?pubkey=` filters, `?limit=`
(default 100, max 500)
- **`signatureDrops`** field added to `/api/stats` response (count from
`dropped_packets` table)
### Tests (8 new)
| Test | What it verifies |
|------|-----------------|
| `TestSigValidation_ValidAdvertStored` | Valid advert passes validation
and is stored |
| `TestSigValidation_TamperedSignatureDropped` | Tampered signature →
dropped, recorded in `dropped_packets` with correct fields |
| `TestSigValidation_TruncatedAppdataDropped` | Truncated appdata
invalidates signature → dropped |
| `TestSigValidation_DisabledByConfig` | `validateSignatures: false`
skips validation, stores tampered packet |
| `TestSigValidation_DropCounterIncrements` | Counter increments
correctly across multiple drops |
| `TestSigValidation_LogContainsFields` | `dropped_packets` row contains
hash, reason, observer, pubkey, name |
| `TestPruneDroppedPackets` | Old entries pruned, recent entries
retained |
| `TestShouldValidateSignatures_Default` | Config helper returns correct
defaults |
### Config example
```json
{
"validateSignatures": true
}
```
Fixes #793
---------
Co-authored-by: you <you@example.com>
436 lines
14 KiB
Go
436 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// recentTS returns a timestamp string N hours ago, ensuring test data
|
|
// stays within the 7-day advert window used by computeNodeHashSizeInfo.
|
|
func recentTS(hoursAgo int) string {
|
|
return time.Now().UTC().Add(-time.Duration(hoursAgo) * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
|
}
|
|
|
|
// setupCapabilityTestDB creates a minimal in-memory DB with nodes table.
|
|
func setupCapabilityTestDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
conn.Exec(`CREATE TABLE nodes (
|
|
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
|
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
|
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
|
|
)`)
|
|
conn.Exec(`CREATE TABLE observers (
|
|
id TEXT PRIMARY KEY, name TEXT, iata TEXT, last_seen TEXT,
|
|
first_seen TEXT, packet_count INTEGER DEFAULT 0, model TEXT,
|
|
firmware TEXT, client_version TEXT, radio TEXT, battery_mv INTEGER,
|
|
uptime_secs INTEGER
|
|
)`)
|
|
return &DB{conn: conn}
|
|
}
|
|
|
|
// addTestPacket adds a StoreTx to the store's internal structures including
|
|
// the byPathHop index and byPayloadType index.
|
|
func addTestPacket(store *PacketStore, tx *StoreTx) {
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
tx.ID = len(store.packets) + 1
|
|
if tx.Hash == "" {
|
|
tx.Hash = fmt.Sprintf("test-hash-%d", tx.ID)
|
|
}
|
|
store.packets = append(store.packets, tx)
|
|
store.byHash[tx.Hash] = tx
|
|
store.byTxID[tx.ID] = tx
|
|
if tx.PayloadType != nil {
|
|
store.byPayloadType[*tx.PayloadType] = append(store.byPayloadType[*tx.PayloadType], tx)
|
|
}
|
|
addTxToPathHopIndex(store.byPathHop, tx)
|
|
}
|
|
|
|
// buildPathByte returns a 2-char hex string for the path byte with given
|
|
// hashSize (1-3) and hopCount.
|
|
func buildPathByte(hashSize, hopCount int) string {
|
|
b := byte(((hashSize - 1) & 0x3) << 6) | byte(hopCount&0x3F)
|
|
return fmt.Sprintf("%02x", b)
|
|
}
|
|
|
|
// makeTestAdvert creates a StoreTx representing a flood advert packet.
|
|
func makeTestAdvert(pubkey string, hashSize int) *StoreTx {
|
|
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey, "name": pubkey[:8]})
|
|
pt := 4
|
|
pathByte := buildPathByte(hashSize, 1)
|
|
prefix := strings.ToLower(pubkey[:hashSize*2])
|
|
rawHex := "01" + pathByte + prefix // flood header + path byte + hop prefix
|
|
return &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
DecodedJSON: string(decoded),
|
|
PathJSON: `["` + prefix + `"]`,
|
|
FirstSeen: recentTS(24),
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Confirmed tests that a repeater advertising
|
|
// with hash_size >= 2 is classified as "confirmed".
|
|
func TestMultiByteCapability_Confirmed(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepA", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Evidence != "advert" {
|
|
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
|
}
|
|
if caps[0].MaxHashSize != 2 {
|
|
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Suspected tests that a repeater whose prefix
|
|
// appears in a multi-byte path is classified as "suspected".
|
|
func TestMultiByteCapability_Suspected(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepB", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Non-advert packet with 2-byte hash in path, hop prefix matching node
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "suspected" {
|
|
t.Errorf("expected suspected, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Evidence != "path" {
|
|
t.Errorf("expected path evidence, got %s", caps[0].Evidence)
|
|
}
|
|
if caps[0].MaxHashSize != 2 {
|
|
t.Errorf("expected maxHashSize 2, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_Unknown tests that a repeater with only 1-byte
|
|
// adverts and no multi-byte path appearances is classified as "unknown".
|
|
func TestMultiByteCapability_Unknown(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepC", "repeater", recentTS(72))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Advert with 1-byte hash only
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "unknown" {
|
|
t.Errorf("expected unknown, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].MaxHashSize != 1 {
|
|
t.Errorf("expected maxHashSize 1, got %d", caps[0].MaxHashSize)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_PrefixCollision tests that when two repeaters
|
|
// share the same prefix, one confirmed via advert, the other gets
|
|
// suspected (not confirmed) from path data alone.
|
|
func TestMultiByteCapability_PrefixCollision(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
// Two repeaters sharing 1-byte prefix "aa"
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabb000000000001", "RepConfirmed", "repeater", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aacc000000000002", "RepOther", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// RepConfirmed has a 2-byte advert
|
|
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
|
|
|
// A packet with 2-byte path containing 1-byte hop "aa" — both share this prefix
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aa"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aa"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(caps))
|
|
}
|
|
|
|
capByName := map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
|
|
if capByName["RepConfirmed"].Status != "confirmed" {
|
|
t.Errorf("RepConfirmed expected confirmed, got %s", capByName["RepConfirmed"].Status)
|
|
}
|
|
if capByName["RepOther"].Status != "suspected" {
|
|
t.Errorf("RepOther expected suspected, got %s", capByName["RepOther"].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_TraceExcluded tests that TRACE packets (payload_type 8)
|
|
// do NOT contribute to "suspected" multi-byte capability. TRACE packets carry
|
|
// hash size in their own flags, so pre-1.14 repeaters can forward multi-byte
|
|
// TRACEs without actually supporting multi-byte hashes. See #714.
|
|
func TestMultiByteCapability_TraceExcluded(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepTrace", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// TRACE packet (payload_type 8) with 2-byte hash in path
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 8
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "unknown" {
|
|
t.Errorf("expected unknown (TRACE excluded), got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_NonTraceStillSuspected verifies that non-TRACE packets
|
|
// with 2-byte paths still correctly mark a repeater as "suspected".
|
|
func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepNonTrace", "repeater", recentTS(48))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// GRP_TXT packet (payload_type 1) with 2-byte hash in path
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "suspected" {
|
|
t.Errorf("expected suspected, got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion verifies that
|
|
// "confirmed" status from adverts is not affected by the TRACE exclusion.
|
|
func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepConfirmedTrace", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Advert with 2-byte hash (confirms capability)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
// TRACE packet also present — should not downgrade confirmed status
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 8
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_CompanionConfirmed tests that a companion with
|
|
// multi-byte advert is classified as "confirmed", not "unknown" (Bug 1, #754).
|
|
func TestMultiByteCapability_CompanionConfirmed(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "CompA", "companion", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(caps))
|
|
}
|
|
if caps[0].Status != "confirmed" {
|
|
t.Errorf("expected confirmed for companion, got %s", caps[0].Status)
|
|
}
|
|
if caps[0].Role != "companion" {
|
|
t.Errorf("expected role companion, got %s", caps[0].Role)
|
|
}
|
|
if caps[0].Evidence != "advert" {
|
|
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_RoleColumnPopulated tests that the Role field is
|
|
// populated for all node types (Bug 2, #754).
|
|
func TestMultiByteCapability_RoleColumnPopulated(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabb000000000001", "Rep1", "repeater", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"ccdd000000000002", "Comp1", "companion", recentTS(24))
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"eeff000000000003", "Room1", "room_server", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
|
|
addTestPacket(store, makeTestAdvert("ccdd000000000002", 2))
|
|
addTestPacket(store, makeTestAdvert("eeff000000000003", 1))
|
|
|
|
caps := store.computeMultiByteCapability(nil)
|
|
if len(caps) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(caps))
|
|
}
|
|
|
|
roleByName := map[string]string{}
|
|
for _, c := range caps {
|
|
roleByName[c.Name] = c.Role
|
|
}
|
|
if roleByName["Rep1"] != "repeater" {
|
|
t.Errorf("Rep1 role: expected repeater, got %s", roleByName["Rep1"])
|
|
}
|
|
if roleByName["Comp1"] != "companion" {
|
|
t.Errorf("Comp1 role: expected companion, got %s", roleByName["Comp1"])
|
|
}
|
|
if roleByName["Room1"] != "room_server" {
|
|
t.Errorf("Room1 role: expected room_server, got %s", roleByName["Room1"])
|
|
}
|
|
}
|
|
|
|
// TestMultiByteCapability_AdopterEvidenceTakesPrecedence tests that when
|
|
// adopter data shows hashSize >= 2 but path evidence says "suspected",
|
|
// the node is upgraded to "confirmed" (Bug 3, #754).
|
|
func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
|
|
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
|
|
"aabbccdd11223344", "RepAdopter", "repeater", recentTS(24))
|
|
|
|
store := NewPacketStore(db, nil)
|
|
|
|
// Only a path-based packet (no advert) — would normally be "suspected"
|
|
pathByte := buildPathByte(2, 1)
|
|
rawHex := "01" + pathByte + "aabb"
|
|
pt := 1
|
|
pkt := &StoreTx{
|
|
RawHex: rawHex,
|
|
PayloadType: &pt,
|
|
PathJSON: `["aabb"]`,
|
|
FirstSeen: recentTS(48),
|
|
}
|
|
addTestPacket(store, pkt)
|
|
|
|
// Without adopter data: should be suspected
|
|
caps := store.computeMultiByteCapability(nil)
|
|
capByName := map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
if capByName["RepAdopter"].Status != "suspected" {
|
|
t.Errorf("without adopter data: expected suspected, got %s", capByName["RepAdopter"].Status)
|
|
}
|
|
|
|
// With adopter data showing hashSize 2: should be confirmed
|
|
adopterHS := map[string]int{"aabbccdd11223344": 2}
|
|
caps = store.computeMultiByteCapability(adopterHS)
|
|
capByName = map[string]MultiByteCapEntry{}
|
|
for _, c := range caps {
|
|
capByName[c.Name] = c
|
|
}
|
|
if capByName["RepAdopter"].Status != "confirmed" {
|
|
t.Errorf("with adopter data: expected confirmed, got %s", capByName["RepAdopter"].Status)
|
|
}
|
|
if capByName["RepAdopter"].Evidence != "advert" {
|
|
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
|
|
}
|
|
}
|