mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 14:54:42 +00:00
fc57433f27
## Summary Closes #978 — analytics channels duplicated by encrypted/decrypted split + rainbow-table collisions. ## Root cause Two distinct bugs in `computeAnalyticsChannels` (`cmd/server/store.go`): 1. **Encrypted/decrypted split**: The grouping key included the decoded channel name (`hash + "_" + channel`), so packets from observers that could decrypt a channel created a separate bucket from packets where decryption failed. Same physical channel, two entries. 2. **Rainbow-table collisions**: Some observers' lookup tables map hash bytes to wrong channel names. E.g., hash `72` incorrectly claimed to be `#wardriving` (real hash is `129`). This created ghost 1-message entries. ## Fix 1. **Always group by hash byte alone** (drop `_channel` suffix from `chKey`). When any packet decrypts successfully, upgrade the bucket's display name from placeholder (`chN`) to the real name (first-decrypter-wins for stability). 2. **Validate channel names** against the firmware hash invariant: `SHA256(SHA256("#name")[:16])[0] == channelHash`. Mismatches are treated as encrypted (placeholder name, no trust in decoded channel). Guard is in the analytics handler (not the ingestor) to avoid breaking other surfaces that use the decoded field for display. ## Verification (e2e-fixture.db) | Metric | BEFORE | AFTER | |--------|--------|-------| | Total channels | 22 | 19 | | Duplicate hash bytes | 3 (hashes 217, 202, 17) | 0 | ## Tests added - `TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted` — same hash, mixed encrypted/decrypted → ONE bucket - `TestComputeAnalyticsChannels_RejectsRainbowTableMismatch` — hash 72 claimed as `#wardriving` (real=129) → rejected, stays `ch72` - `TestChannelNameMatchesHash` — unit test for hash validation helper - `TestIsPlaceholderName` — unit test for placeholder detection Anti-tautology gate: both main tests fail when their respective fix lines are reverted. Co-authored-by: you <you@example.com>
169 lines
5.4 KiB
Go
169 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var _ = time.Second // suppress unused import
|
|
|
|
// Helper to create a minimal PacketStore with GRP_TXT packets for channel analytics testing.
|
|
func newChannelTestStore(packets []*StoreTx) *PacketStore {
|
|
ps := &PacketStore{
|
|
packets: packets,
|
|
byHash: make(map[string]*StoreTx),
|
|
byTxID: make(map[int]*StoreTx),
|
|
byObsID: make(map[int]*StoreObs),
|
|
byObserver: make(map[string][]*StoreObs),
|
|
byNode: make(map[string][]*StoreTx),
|
|
byPathHop: make(map[string][]*StoreTx),
|
|
nodeHashes: make(map[string]map[string]bool),
|
|
byPayloadType: make(map[int][]*StoreTx),
|
|
rfCache: make(map[string]*cachedResult),
|
|
topoCache: make(map[string]*cachedResult),
|
|
hashCache: make(map[string]*cachedResult),
|
|
collisionCache: make(map[string]*cachedResult),
|
|
chanCache: make(map[string]*cachedResult),
|
|
distCache: make(map[string]*cachedResult),
|
|
subpathCache: make(map[string]*cachedResult),
|
|
spIndex: make(map[string]int),
|
|
spTxIndex: make(map[string][]*StoreTx),
|
|
advertPubkeys: make(map[string]int),
|
|
lastSeenTouched: make(map[string]time.Time),
|
|
clockSkew: NewClockSkewEngine(),
|
|
}
|
|
ps.byPayloadType[5] = packets
|
|
return ps
|
|
}
|
|
|
|
func makeGrpTx(channelHash int, channel, text, sender string) *StoreTx {
|
|
decoded := map[string]interface{}{
|
|
"type": "CHAN",
|
|
"channelHash": float64(channelHash),
|
|
"channel": channel,
|
|
"text": text,
|
|
"sender": sender,
|
|
}
|
|
b, _ := json.Marshal(decoded)
|
|
pt := 5
|
|
return &StoreTx{
|
|
ID: 1,
|
|
DecodedJSON: string(b),
|
|
FirstSeen: "2026-05-01T12:00:00Z",
|
|
PayloadType: &pt,
|
|
}
|
|
}
|
|
|
|
// TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted verifies that packets
|
|
// with the same hash byte but different decryption status merge into ONE bucket.
|
|
func TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted(t *testing.T) {
|
|
// Hash 129 is the real hash for #wardriving: SHA256(SHA256("#wardriving")[:16])[0] = 129
|
|
// Some packets are decrypted (have channel name), some are not (encrypted)
|
|
packets := []*StoreTx{
|
|
makeGrpTx(129, "#wardriving", "hello", "alice"),
|
|
makeGrpTx(129, "#wardriving", "world", "bob"),
|
|
makeGrpTx(129, "", "", ""), // encrypted — no channel name
|
|
makeGrpTx(129, "", "", ""), // encrypted
|
|
}
|
|
|
|
store := newChannelTestStore(packets)
|
|
result := store.computeAnalyticsChannels("")
|
|
|
|
channels := result["channels"].([]map[string]interface{})
|
|
if len(channels) != 1 {
|
|
t.Fatalf("expected 1 channel bucket, got %d: %+v", len(channels), channels)
|
|
}
|
|
ch := channels[0]
|
|
if ch["name"] != "#wardriving" {
|
|
t.Errorf("expected name '#wardriving', got %q", ch["name"])
|
|
}
|
|
if ch["messages"] != 4 {
|
|
t.Errorf("expected 4 messages, got %v", ch["messages"])
|
|
}
|
|
if ch["encrypted"] != false {
|
|
t.Errorf("expected encrypted=false (some packets decrypted), got %v", ch["encrypted"])
|
|
}
|
|
}
|
|
|
|
// TestComputeAnalyticsChannels_RejectsRainbowTableMismatch verifies that a packet
|
|
// with channelHash=72 but channel="#wardriving" (mismatch) does NOT create a
|
|
// "#wardriving" bucket — it falls into "ch72" instead.
|
|
func TestComputeAnalyticsChannels_RejectsRainbowTableMismatch(t *testing.T) {
|
|
// Hash 72 is NOT the correct hash for #wardriving (which is 129).
|
|
// This simulates a rainbow-table collision/mismatch.
|
|
packets := []*StoreTx{
|
|
makeGrpTx(72, "#wardriving", "ghost", "eve"), // mismatch: hash 72 != wardriving's real hash
|
|
makeGrpTx(129, "#wardriving", "real", "alice"), // correct match
|
|
}
|
|
|
|
store := newChannelTestStore(packets)
|
|
result := store.computeAnalyticsChannels("")
|
|
|
|
channels := result["channels"].([]map[string]interface{})
|
|
if len(channels) != 2 {
|
|
t.Fatalf("expected 2 channel buckets, got %d: %+v", len(channels), channels)
|
|
}
|
|
|
|
// Find the buckets
|
|
var ch72, ch129 map[string]interface{}
|
|
for _, ch := range channels {
|
|
if ch["hash"] == "72" {
|
|
ch72 = ch
|
|
} else if ch["hash"] == "129" {
|
|
ch129 = ch
|
|
}
|
|
}
|
|
|
|
if ch72 == nil {
|
|
t.Fatal("expected a bucket for hash 72")
|
|
}
|
|
if ch129 == nil {
|
|
t.Fatal("expected a bucket for hash 129")
|
|
}
|
|
|
|
// ch72 should NOT be named "#wardriving" — it should be the placeholder
|
|
if ch72["name"] == "#wardriving" {
|
|
t.Errorf("hash 72 bucket should NOT be named '#wardriving' (rainbow-table mismatch rejected)")
|
|
}
|
|
if ch72["name"] != "ch72" {
|
|
t.Errorf("expected hash 72 bucket named 'ch72', got %q", ch72["name"])
|
|
}
|
|
|
|
// ch129 should be named "#wardriving"
|
|
if ch129["name"] != "#wardriving" {
|
|
t.Errorf("expected hash 129 bucket named '#wardriving', got %q", ch129["name"])
|
|
}
|
|
}
|
|
|
|
// TestChannelNameMatchesHash verifies the hash validation function.
|
|
func TestChannelNameMatchesHash(t *testing.T) {
|
|
// #wardriving hashes to 129
|
|
if !channelNameMatchesHash("#wardriving", "129") {
|
|
t.Error("expected #wardriving to match hash 129")
|
|
}
|
|
if channelNameMatchesHash("#wardriving", "72") {
|
|
t.Error("expected #wardriving to NOT match hash 72")
|
|
}
|
|
// Without leading # should also work
|
|
if !channelNameMatchesHash("wardriving", "129") {
|
|
t.Error("expected wardriving (without #) to match hash 129")
|
|
}
|
|
}
|
|
|
|
// TestIsPlaceholderName verifies placeholder detection.
|
|
func TestIsPlaceholderName(t *testing.T) {
|
|
if !isPlaceholderName("ch129") {
|
|
t.Error("ch129 should be placeholder")
|
|
}
|
|
if !isPlaceholderName("ch0") {
|
|
t.Error("ch0 should be placeholder")
|
|
}
|
|
if isPlaceholderName("#wardriving") {
|
|
t.Error("#wardriving should NOT be placeholder")
|
|
}
|
|
if isPlaceholderName("Public") {
|
|
t.Error("Public should NOT be placeholder")
|
|
}
|
|
}
|