Pete news: salted per-event GUID token + zone_clear taxonomy

Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.

A. Privacy — the public GUID no longer leaks a stable per-player id.
   Replaced userHash(userID)=sha256(userID)[:6] with
   eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
   The salt is 32 random bytes, auto-generated once and persisted in the
   durable news_config table (cached via sync.Once). Because each event
   uses a distinct HMAC message, tokens are a PRF output and are BOTH
   uncomputable from a Matrix handle (no enumeration of a player's
   events, incl. ones anonymized after !news optout) AND mutually
   unlinkable (a named event can't be walked back to a player's other,
   anonymized events). Updated all emit sites: pete.go zone, dnd_combat
   death, adventure_duel rival, dnd_setup arrival, achievements
   milestone, bootstrap x3.

B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
   zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
   the invariant GUID-prefix == event_type, which also fixes latent
   permalink mislabels (achv->milestone, rival->rival_result rendered a
   neutral "Dispatch" on their permalink pages).

Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.

Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.

Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.
This commit is contained in:
prosolis
2026-07-11 07:43:57 -07:00
parent b42beec348
commit ae7ff38996
9 changed files with 206 additions and 45 deletions

View File

@@ -1,7 +1,10 @@
package plugin
import (
"crypto/sha256"
"encoding/hex"
"os"
"sync"
"testing"
"gogobee/internal/db"
@@ -98,10 +101,10 @@ func TestPeteNewsBackfill(t *testing.T) {
if got := queuedCount(t, "death:%"); got != 1 {
t.Errorf("death dispatches queued = %d, want 1", got)
}
if got := queuedCount(t, "achv:%"); got != 1 {
if got := queuedCount(t, "milestone:%"); got != 1 {
t.Errorf("achievement dispatches queued = %d, want 1", got)
}
if got := queuedCount(t, "zone:%"); got != 1 {
if got := queuedCount(t, "zone_first:%"); got != 1 {
t.Errorf("zone-first dispatches queued = %d, want 1", got)
}
// Realm-first ledger seeded so a later live clear tiers as a repeat.
@@ -116,6 +119,80 @@ func TestPeteNewsBackfill(t *testing.T) {
}
}
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
// can recompute from the handle), stable per logical event (idempotency), and
// per-event so a user's tokens don't link to each other.
func TestEventToken(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
// The salt is cached process-wide via sync.Once; reset it so it derives (and
// persists) against this test's fresh DB rather than an earlier test's.
guidSaltOnce = sync.Once{}
guidSalt = nil
u := id.UserID("@alice:example.org")
// Not the old unsalted digest: an attacker with the handle can't recompute it.
bare := sha256.Sum256([]byte(u))
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
}
// Stable for the same (user, discriminator): a re-emit dedups.
if eventToken(u, "arrival") != eventToken(u, "arrival") {
t.Error("token not stable for the same event — idempotency broken")
}
// Per-event: two different events for the same user yield unrelated tokens, so
// a named event can't be walked back to the user's anonymized events.
if eventToken(u, "arrival") == eventToken(u, "death:123") {
t.Error("tokens collide across events — linkage attack reopened")
}
// Different users, same discriminator, still differ.
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
t.Error("token collides across users")
}
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
var salt string
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
t.Fatalf("guid salt not persisted: %v", err)
}
}
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
// GUID prefix, so Pete never files a repeat as a first-ever.
func TestEmitZoneClearTaxonomy(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
enablePeteSeam(t)
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
"@zapp:x", "Zapp")
exp := &Expedition{ZoneID: "goblin_warren"}
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
if got := queuedCount(t, "zone_first:%"); got != 1 {
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
}
if got := queuedCount(t, "zone_clear:%"); got != 1 {
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
}
}
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
func TestNewsEmissionKillSwitch(t *testing.T) {
dir := t.TempDir()