mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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:<hash>:<ts>"
|
||||
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
@@ -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() + ?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -20,16 +23,21 @@ import (
|
||||
|
||||
const (
|
||||
newsEnabledKey = "adventure_news_enabled"
|
||||
// foreverTTL makes CacheGet a persistent flag: the row never "expires".
|
||||
foreverTTL = 100 * 365 * 24 * 3600
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user