From 6a32ec2b2d7dc27429985cffdb3dedb2ad7a7a14 Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Fri, 3 Jul 2026 04:20:14 +0200 Subject: [PATCH] fix(#1729): preserve firmware-default Public channel (0x11) in analytics (#1817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes #1729 The firmware-default **Public** channel (channel-hash byte `0x11` = 17) was rendered as an opaque **"Encrypted (0x11)"** row at the bottom of the analytics Channels tab, despite the key being well-known and builtin. ## Root cause `computeAnalyticsChannels` applied the #978 rainbow-table validation (`SHA256(SHA256("#name")[:16])[0]`, the **hashtag** hash scheme) to every decoded channel name. The Public channel is a **PSK** channel whose hash byte is key-derived (`SHA256(key)[0]` = 17), not hashtag-derived (`186` for `#Public`). So the ingestor-decoded name `"Public"` failed the hashtag check and was discarded, the row forced to `encrypted=true, name="ch17"`. ## Fix Trust the ingestor's `decryptionStatus`. The ingestor already persists `decryptionStatus:"decrypted"` when it decoded a packet with a real key (PSK), and `"no_key"` / `"decryption_failed"` otherwise. When the packet is `decrypted`, skip the hashtag hash check and keep the name — it came from a key-based decryption, not a rainbow-table lookup. The #978 mismatch rejection still applies to non-decrypted packets, so rainbow-table collisions are still caught. Frontend needs no change: `encrypted=false, name="Public"` lands in the "Network" group (top), not "Encrypted". ## Tests - `makeGrpTx` gains `makeGrpTxWithStatus` companion to set `decryptionStatus`. - `TestComputeAnalyticsChannels_PublicChannelPreserved`: hash 17 / "Public" / `decrypted` → name stays `"Public"`, `encrypted=false`. - `TestComputeAnalyticsChannels_UndecryptedNameStillValidated`: a non-`decrypted` name failing the hashtag check is still downgraded to `ch17` (#978 regression guard). All channel-analytics tests pass; `go build ./...` clean. Co-authored-by: Waydroid Builder Co-authored-by: Claude --- cmd/server/channel_analytics_test.go | 71 ++++++++++++++++++++++++++++ cmd/server/store.go | 27 +++++++---- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/cmd/server/channel_analytics_test.go b/cmd/server/channel_analytics_test.go index a6cc92eb..67203f5f 100644 --- a/cmd/server/channel_analytics_test.go +++ b/cmd/server/channel_analytics_test.go @@ -38,6 +38,14 @@ func newChannelTestStore(packets []*StoreTx) *PacketStore { } func makeGrpTx(channelHash int, channel, text, sender string) *StoreTx { + return makeGrpTxWithStatus(channelHash, channel, text, sender, "") +} + +// makeGrpTxWithStatus is like makeGrpTx but lets the caller set the ingestor's +// decryptionStatus ("decrypted" / "no_key" / "decryption_failed" / ""), which +// computeAnalyticsChannels consults to decide whether a channel name is +// trustworthy (see #1729). +func makeGrpTxWithStatus(channelHash int, channel, text, sender, decryptionStatus string) *StoreTx { decoded := map[string]interface{}{ "type": "CHAN", "channelHash": float64(channelHash), @@ -45,6 +53,9 @@ func makeGrpTx(channelHash int, channel, text, sender string) *StoreTx { "text": text, "sender": sender, } + if decryptionStatus != "" { + decoded["decryptionStatus"] = decryptionStatus + } b, _ := json.Marshal(decoded) pt := 5 return &StoreTx{ @@ -166,3 +177,63 @@ func TestIsPlaceholderName(t *testing.T) { t.Error("Public should NOT be placeholder") } } + +// TestComputeAnalyticsChannels_PublicChannelPreserved is the regression test for +// #1729: the firmware-default "Public" channel (channel-hash byte 0x11 = 17, +// key-derived via SHA256(key)[0], NOT the hashtag scheme's 186) was being +// discarded by the #978 rainbow-table validation and rendered as +// "Encrypted (0x11)". The ingestor decrypts Public packets with the builtin +// well-known key and marks them decryptionStatus:"decrypted"; the server must +// trust that name instead of re-applying the hashtag hash check. +func TestComputeAnalyticsChannels_PublicChannelPreserved(t *testing.T) { + packets := []*StoreTx{ + makeGrpTxWithStatus(17, "Public", "hello net", "alice", "decrypted"), + makeGrpTxWithStatus(17, "Public", "morning", "bob", "decrypted"), + } + + store := newChannelTestStore(packets) + result := store.computeAnalyticsChannels("", "", TimeWindow{}) + + 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"] != "Public" { + t.Errorf("expected name 'Public' preserved, got %q (must not be downgraded to ch17)", ch["name"]) + } + if ch["encrypted"] != false { + t.Errorf("expected encrypted=false for decrypted Public channel, got %v", ch["encrypted"]) + } + if ch["hash"] != "17" { + t.Errorf("expected hash '17', got %v", ch["hash"]) + } +} + +// TestComputeAnalyticsChannels_UndecryptedNameStillValidated ensures the #1729 +// fix does not weaken #978: when the ingestor did NOT mark the packet +// "decrypted" (no_key / decryption_failed / absent), a channel name that fails +// the hashtag hash check is still discarded to "chNN", even if text/sender +// happen to be present (e.g. a stale/foreign rainbow-table hit). +func TestComputeAnalyticsChannels_UndecryptedNameStillValidated(t *testing.T) { + // Hash 17 is NOT the hashtag hash of #Public (that is 186). Without a + // "decrypted" status, the name must be rejected. + packets := []*StoreTx{ + makeGrpTxWithStatus(17, "#Public", "leaked", "eve", ""), + } + + store := newChannelTestStore(packets) + result := store.computeAnalyticsChannels("", "", TimeWindow{}) + + 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"] != "ch17" { + t.Errorf("expected undecrypted mismatch downgraded to 'ch17', got %q", ch["name"]) + } + if ch["encrypted"] != true { + t.Errorf("expected encrypted=true for rejected rainbow-table name, got %v", ch["encrypted"]) + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 44eec9bb..bfed9374 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5644,12 +5644,13 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW } type decodedGrp struct { - Type string `json:"type"` - Channel string `json:"channel"` - ChannelHash interface{} `json:"channelHash"` - ChannelHash2 string `json:"channel_hash"` - Text string `json:"text"` - Sender string `json:"sender"` + Type string `json:"type"` + Channel string `json:"channel"` + ChannelHash interface{} `json:"channelHash"` + ChannelHash2 string `json:"channel_hash"` + Text string `json:"text"` + Sender string `json:"sender"` + DecryptionStatus string `json:"decryptionStatus,omitempty"` } // Convert channelHash (number or string in JSON) to string @@ -5727,9 +5728,17 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW } encrypted := decoded.Text == "" && decoded.Sender == "" - // Bug #978 fix: validate channel name against hash to reject rainbow-table mismatches. - // If the claimed channel name doesn't hash to the observed channelHash byte, discard it. - if name != "" && name != "ch"+hash && !channelNameMatchesHash(name, hash) { + // Bug #978 fix: validate channel name against hash to reject rainbow-table + // mismatches. If the claimed channel name doesn't hash to the observed + // channelHash byte, discard it — UNLESS the ingestor marked the packet + // `decryptionStatus:"decrypted"`. That flag means the name came from a + // successful key-based (PSK) decryption, not a rainbow-table lookup, so + // it is trustworthy regardless of the hashtag-derived hash check. This + // keeps the firmware-default Public channel (0x11, key-derived hash + // SHA256(key)[0]=17, not the hashtag scheme's 186) from being wrongly + // discarded and rendered as "Encrypted (0x11)". See #1729. + ingestorDecrypted := decoded.DecryptionStatus == "decrypted" + if name != "" && name != "ch"+hash && !ingestorDecrypted && !channelNameMatchesHash(name, hash) { name = "ch" + hash encrypted = true }