fix: matchScope reports unknown instead of guessing on HMAC collision

transport_code_1 is only 16 bits, so with enough configured hashRegions
a different, unrelated region's HMAC can coincidentally also match a
packet's real code1 (expected rate ~= len(regionKeys)/65534 per
packet). matchScope used to return the first match found while
iterating the region-key map, whose order Go randomizes per process —
so an ambiguous packet's assigned scope was effectively a coin flip
that could even change across ingestor restarts.

Confirmed on stg.meshview.dk (1098 configured regions): a live GRP_TXT
message's code1=C417 matched both "#dk" (the sender's true region) and
"#dk1906" (an unrelated collider), and was observed resolving to
either name across sends.

matchScope now scans every configured region and only returns a name
when exactly one matches; two or more matches are reported as
unknown-scoped ("") rather than guessed, with a log line for operator
visibility. Doesn't fix the root collision-probability cause (that
needs fewer/coarser configured regions) but stops confidently
displaying a wrong single answer.
This commit is contained in:
dborup
2026-07-17 15:01:13 +02:00
parent 350bf7eeee
commit 8765de71e0
2 changed files with 58 additions and 2 deletions
+21 -2
View File
@@ -1454,10 +1454,24 @@ func loadRegionKeys(cfg *Config) map[string][]byte {
// matchScope performs one HMAC-SHA256 per configured region. Expected
// len(regionKeys) ≤ 50; beyond that, consider a pre-indexed lookup table.
//
// code1 is only 16 bits (65534 usable values after the 0x0000/0xFFFF
// remap), so with enough configured regions a *different*, unrelated
// region's HMAC can coincidentally also produce the packet's real code1
// (birthday-paradox collision — expected rate ≈ len(regionKeys)/65534
// per packet). Silently returning the first map-iteration match would
// make the guess a coin flip between the true region and the collider,
// and Go's randomized map order means the same ambiguous packet could
// even resolve differently across ingestor restarts. Instead we scan
// every region and only return a name when exactly one matches; two or
// more matches means the code1 doesn't uniquely identify a region for
// this payload, so we report unknown-scoped ("") rather than guess.
func matchScope(regionKeys map[string][]byte, payloadType byte, payloadRaw []byte, code1 string) string {
if code1 == "0000" || len(regionKeys) == 0 || len(payloadRaw) == 0 {
return ""
}
var match string
matchCount := 0
for name, key := range regionKeys {
mac := hmac.New(sha256.New, key)
mac.Write([]byte{payloadType})
@@ -1471,10 +1485,15 @@ func matchScope(regionKeys map[string][]byte, payloadType byte, payloadRaw []byt
}
codeBytes := [2]byte{byte(code & 0xFF), byte(code >> 8)}
if strings.ToUpper(hex.EncodeToString(codeBytes[:])) == code1 {
return name
match = name
matchCount++
}
}
return ""
if matchCount > 1 {
log.Printf("[regions] ambiguous scope match for code1=%s: %d regions collide (including %q) — reporting unknown-scoped instead of guessing", code1, matchCount, match)
return ""
}
return match
}
// Version info (set via ldflags)
+37
View File
@@ -880,6 +880,43 @@ func TestMatchScope(t *testing.T) {
}
}
// TestMatchScope_AmbiguousCollisionReturnsUnknown is a real-world regression
// vector captured on stg.meshview.dk (1098 configured hashRegions): a single
// GRP_TXT packet (payloadType=5, code1=C417) whose HMAC coincidentally
// matched BOTH "#dk" (the sender's true region) and "#dk1906" (an unrelated
// region that happened to collide in the 16-bit code1 space). Before this
// fix, matchScope returned whichever name Go's randomized map iteration
// visited first — the same ambiguous packet could resolve to either name
// across restarts. It must now report unknown-scoped ("") instead of
// guessing when more than one region matches.
func TestMatchScope_AmbiguousCollisionReturnsUnknown(t *testing.T) {
dkKey, _ := hex.DecodeString("4a447e7539b0418bd14cf28aa8241529")
dk1906Key, _ := hex.DecodeString("dd24ec5e2aa5221030147ebec77ebd7c")
regionKeys := map[string][]byte{"#dk": dkKey, "#dk1906": dk1906Key}
payloadRaw, err := hex.DecodeString("D9A740B10D5BA91A9E5D5156DB534888AD454D")
if err != nil {
t.Fatalf("decode payloadRaw: %v", err)
}
// Sanity: each key matches C417 individually (proves the vector is a
// real collision, not a bug in the test itself).
if got := matchScope(map[string][]byte{"#dk": dkKey}, 5, payloadRaw, "C417"); got != "#dk" {
t.Fatalf("precondition: #dk alone should match C417, got %q", got)
}
if got := matchScope(map[string][]byte{"#dk1906": dk1906Key}, 5, payloadRaw, "C417"); got != "#dk1906" {
t.Fatalf("precondition: #dk1906 alone should match C417, got %q", got)
}
// With both configured, the collision must resolve to unknown ("")
// rather than nondeterministically picking one.
for i := 0; i < 20; i++ {
if got := matchScope(regionKeys, 5, payloadRaw, "C417"); got != "" {
t.Errorf("ambiguous match: matchScope = %q, want empty (unknown-scoped)", got)
}
}
}
func TestBuildPacketDataScopeMatching(t *testing.T) {
// Fixed known-answer packet: TRANSPORT_FLOOD, payloadType=5, payload="hello",
// Code1=2AB5 (pre-computed for region "#test").