diff --git a/internal/db/db.go b/internal/db/db.go index ccd2342..8a4ac99 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -616,6 +616,10 @@ func RunMaintenance() { {"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, {"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, + // Pete adventure-news queue — reap delivered rows (kept only for + // idempotency); undelivered rows stay so the sender can retry/park them. + {"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}}, + // Rate limits — purge entries older than today {"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}}, @@ -979,6 +983,14 @@ CREATE TABLE IF NOT EXISTS news_realm_firsts ( PRIMARY KEY (kind, target) ); +-- Durable runtime config for adventure news (e.g. the emission kill-switch). +-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after +-- 7 days, which would silently revert an operator's !news off back to on. +CREATE TABLE IF NOT EXISTS news_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + -- Birthdays CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index d05dda6..87951a3 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -30,7 +30,7 @@ import ( // only names permitted to appear in Pete's rendered output. See // pete_adventure_news_voice.md for the field contract. type Fact struct { - GUID string `json:"guid"` // stable idempotency key, e.g. "death::" + GUID string `json:"guid"` // stable idempotency key, e.g. "death::"; prefix == event_type EventType string `json:"event_type"` Tier string `json:"tier"` // "priority" | "bulletin" Actors []string `json:"actors"` @@ -72,7 +72,7 @@ var std *Client const ( senderTick = 15 * time.Second senderBatch = 20 - maxAttempts = 8 // ~ up to a few hours of backoff, then park + maxAttempts = 8 // ~ up to a few hours of backoff, then park backoffBase = 30 * time.Second backoffCapSec = 3600 sendTimeout = 15 * time.Second @@ -175,6 +175,12 @@ func (c *Client) drain(ctx context.Context) { return } if err := c.send(ctx, []byte(it.payload)); err != nil { + if ctx.Err() != nil { + // Shutdown canceled the in-flight send — Pete didn't reject + // anything. Don't burn a durable retry attempt; the row is picked + // up on the next boot's drain. + return + } db.Exec("pete emit retry", `UPDATE pete_emit_queue SET attempts = attempts + 1, next_attempt_at = unixepoch() + ? diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index c8cff9e..6dd6099 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -140,7 +140,7 @@ func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achi } ts := nowUnix() emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("achv:%s:%s", userHash(userID), achievementID), + GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID), EventType: "milestone", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/adventure_duel.go b/internal/plugin/adventure_duel.go index e5aff19..86ae090 100644 --- a/internal/plugin/adventure_duel.go +++ b/internal/plugin/adventure_duel.go @@ -618,8 +618,9 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c // fires once). Character names only; no-op unless the seam is enabled. if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" { ts := nowUnix() + disc := fmt.Sprintf("rival:%d", ts) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("rival:%s:%s:%d", userHash(winnerID), userHash(loserID), ts), + GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts), EventType: "rival_result", Tier: "bulletin", Subject: wn, diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go index 05370a9..d1fac95 100644 --- a/internal/plugin/bootstrap_pete_news.go +++ b/internal/plugin/bootstrap_pete_news.go @@ -93,12 +93,9 @@ func (p *AdventurePlugin) backfillZoneFirsts() int { continue } zone := zoneOrFallback(ZoneID(f.zoneID)) - lvl := 0 - if dc, _ := LoadDnDCharacter(uid); dc != nil { - lvl = dc.Level - } + lvl := charLevel(uid) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(uid), f.zoneID, ts.Unix()), + GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()), EventType: "zone_first", Tier: "priority", Subject: name, @@ -150,12 +147,9 @@ func backfillDeaths() int { if err != nil { continue } - lvl := 0 - if dc, _ := LoadDnDCharacter(uid); dc != nil { - lvl = dc.Level - } + lvl := charLevel(uid) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("death:%s:%s", userHash(uid), d.date), + GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date), EventType: "death", Tier: "priority", Subject: name, @@ -219,7 +213,7 @@ func (p *AdventurePlugin) backfillSoloAchievements() int { ts = nowUnix() } emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("achv:%s:%s", userHash(uid), s.achID), + GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID), EventType: "milestone", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 1c621b2..dce4f11 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -587,13 +587,10 @@ func emitDeathNews(userID id.UserID, location string) { if name == "" { return } - lvl := 0 - if dc, _ := LoadDnDCharacter(userID); dc != nil { - lvl = dc.Level - } + lvl := charLevel(userID) ts := nowUnix() emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("death:%s:%d", userHash(userID), ts), + GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts), EventType: "death", Tier: "priority", Subject: name, diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index f0914db..2de2d05 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -297,7 +297,7 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { if name := charName(ctx.Sender); name != "" { ts := nowUnix() emitFact(peteclient.Fact{ - GUID: "arrival:" + userHash(ctx.Sender), + GUID: "arrival:" + eventToken(ctx.Sender, "arrival"), EventType: "arrival", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index 70faf7d..96fede6 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -1,10 +1,13 @@ package plugin import ( + "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "strings" + "sync" "time" "gogobee/internal/db" @@ -19,17 +22,22 @@ import ( // — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md. const ( - newsEnabledKey = "adventure_news_enabled" - // foreverTTL makes CacheGet a persistent flag: the row never "expires". - foreverTTL = 100 * 365 * 24 * 3600 - anonName = "an adventurer" + newsEnabledKey = "adventure_news_enabled" + newsGUIDSaltKey = "adventure_news_guid_salt" + anonName = "an adventurer" ) // newsEmissionOn is the runtime kill-switch (default on). An operator flips it // with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level -// master switch checked separately by peteclient.Enabled. +// master switch checked separately by peteclient.Enabled. Persisted in the +// durable news_config table (NOT api_cache, which RunMaintenance prunes). func newsEmissionOn() bool { - return db.CacheGet(newsEnabledKey, foreverTTL) != "0" + var v string + err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v) + if err != nil { + return true // no row → default on + } + return v != "0" } // setNewsEmission persists the runtime kill-switch. @@ -38,7 +46,10 @@ func setNewsEmission(on bool) { if !on { v = "0" } - db.CacheSet(newsEnabledKey, v) + db.Exec("news emission set", + `INSERT INTO news_config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + newsEnabledKey, v) } // isNewsOptedOut reports whether a player asked not to be named in the news. @@ -61,12 +72,66 @@ func setNewsOptout(userID id.UserID, out bool) { db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID)) } -// userHash is a stable, one-way digest of a Matrix user id. It keys per-player -// event GUIDs (which become PUBLIC permalink paths on Pete) WITHOUT leaking the -// Matrix handle. Not reversible, stable across restarts. -func userHash(userID id.UserID) string { - sum := sha256.Sum256([]byte(userID)) - return hex.EncodeToString(sum[:6]) // 12 hex chars — ample for 4–thousands of players +var ( + guidSaltOnce sync.Once + guidSalt []byte +) + +// newsGUIDSalt returns the secret, server-side salt that keys every public event +// token. It is 32 random bytes, generated once and persisted in the durable +// news_config table, cached in-process. Secret from anyone outside the server +// (external users never see it), stable across restarts (so a re-emit or the +// cold-start backfill reproduces the same GUID). No operator action needed. +func newsGUIDSalt() []byte { + guidSaltOnce.Do(func() { + var stored string + if err := db.Get().QueryRow( + `SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil { + if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 { + guidSalt = b + return + } + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // crypto/rand should never fail; if it does, refuse to fall back to a + // predictable salt (that would reopen the enumeration attack). Leave the + // salt empty and let eventToken sort it out. + return + } + salt := hex.EncodeToString(b) + // Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then + // re-read the row so every caller agrees on the same salt. + db.Exec("news guid salt seed", + `INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt) + _ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt) + guidSalt, _ = hex.DecodeString(salt) + }) + return guidSalt +} + +// eventToken is the per-event, one-way identifier embedded in a public GUID +// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the +// Matrix user id AND an event-specific discriminator, keyed by the secret +// newsGUIDSalt. +// +// Two properties matter and both come from HMAC being a PRF under a secret key: +// - Not computable from a Matrix handle. An attacker who knows @alice:server +// cannot derive any of her tokens without the salt — so her events (including +// ones anonymized after `!news optout`) can't be enumerated from her handle. +// - Per-event, so tokens are mutually unlinkable. Even for the same user, two +// events yield unrelated tokens; a story that names her (opted-in, or a duel +// opponent) exposes only that one event's token and can't be walked back to +// her other, anonymized events. +// +// The discriminator must uniquely identify the logical event so a re-emit or the +// backfill reproduces the same token (idempotency rides on the GUID). +func eventToken(userID id.UserID, discriminator string) string { + mac := hmac.New(sha256.New, newsGUIDSalt()) + mac.Write([]byte(userID)) + mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding + mac.Write([]byte(discriminator)) + return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible } // charName returns a player's in-game character name, never their Matrix handle. @@ -76,6 +141,15 @@ func charName(userID id.UserID) string { return strings.TrimSpace(name) } +// charLevel returns a player's current character level for a news Fact, or 0 if +// no character loads (Level is omitempty, so 0 is dropped from the payload). +func charLevel(userID id.UserID) int { + if dc, _ := LoadDnDCharacter(userID); dc != nil { + return dc.Level + } + return 0 +} + // classRaceLabel renders a character's race + class for the arrival fact, e.g. // "Elf Ranger". func classRaceLabel(c *DnDCharacter) string { @@ -184,7 +258,9 @@ func claimRealmFirst(kind, target string) bool { } // emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The -// realm's first clear of a zone is PRIORITY; later clears are BULLETIN. Character +// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a +// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so +// Pete templates and labels a repeat as a repeat, not a first-ever. Character // name only; no-op unless the seam is enabled. func emitZoneClearNews(userID id.UserID, exp *Expedition) { if !peteclient.Enabled() || !newsEmissionOn() { @@ -201,18 +277,16 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { region = r.Name } } - lvl := 0 - if dc, _ := LoadDnDCharacter(userID); dc != nil { - lvl = dc.Level - } - tier := "bulletin" + lvl := charLevel(userID) + eventType, tier := "zone_clear", "bulletin" if claimRealmFirst("zone", string(exp.ZoneID)) { - tier = "priority" + eventType, tier = "zone_first", "priority" } ts := nowUnix() + disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(userID), exp.ZoneID, ts), - EventType: "zone_first", + GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts), + EventType: eventType, Tier: tier, Subject: name, Zone: zone.Display, diff --git a/internal/plugin/pete_test.go b/internal/plugin/pete_test.go index ea6a11c..bc45a3f 100644 --- a/internal/plugin/pete_test.go +++ b/internal/plugin/pete_test.go @@ -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()