Store hwid data safely: retention, disclosure, and a Ban Evasion admin page - #54
Store hwid data safely: retention, disclosure, and a Ban Evasion admin page#5412problems wants to merge 4 commits into
Conversation
Three related additions, all building on the launcher-integrity/hwid work landed separately (BalatroMultiplayerServerInternal's challenge-strategy.ts, merged on main there) and its already-solid player_hardware_fingerprints schema: 1. Retention: purgeExpiredDeletedPlayerHashes() (player.gateway.ts) already anonymizes steamIdHash/discordIdHash 12 months after account deletion, provided no active ban exists - it now also deletes that player's player_hardware_fingerprints rows under the exact same condition, via a new deletePlayerHardwareFingerprints() (launcher-integrity.gateway.ts). Hardware fingerprint data no longer outlives the identifiers needed to look a player up by in the first place. 2. Privacy notice (apps/web/src/app/notice/page.tsx): hardware/device fingerprint collection had zero disclosure despite every other data category being itemized with purpose, legal basis, and retention. Added a "What we collect" entry, extended the existing Art. 6(1)(f) legitimate- interest bullet, a "What we do with it" bullet, and a retention entry matching the Steam ID hash's own 12-month/active-ban policy. This is a real legal/compliance document - flagging it here for human (ideally legal) review before this ships, not auto-publishing it as-is. 3. Ban Evasion admin page: player_hardware_fingerprints was indexed for cross-player matching from the start (see its own schema comment) but had zero consumer. New findBanEvasionMatches() (launcher-integrity.gateway.ts) self-joins the table on (componentName, componentHash) for every currently-banned player, new GET /webadmin/ban-evasion (admin-or- moderator gated, same pattern as the rest of webadmin) exposes it, and a new /admin/ban-evasion page lists the results with a component- reliability legend (spoofability varies enormously between e.g. a registry-value component and a TPM-backed one - see hardwarefingerprint.cpp in new-launcher). The existing /admin/users page gets a small pointer badge rather than a duplicate match-list UI, and now accepts ?playerId= to jump straight to a specific player - what the new page's own links use. Every component is weighted equally for now (a plain shared-component count, not a weighted score) - deliberate, not an oversight: there isn't enough real match data yet to justify specific weights, and the code leaves an explicit comment marking where a weight map would slot in later instead of guessing now. Also investigated (but didn't end up changing) rate-limiting the MQTT challenge-response path: a burst of rapid responses against one issued challenge already can't reach strategy.verify() more than once, because activeChallenge is cleared synchronously before this function's first `await` and mqtt.service.ts dispatches messages via a plain (non-async) EventEmitter callback - added a comment on that exact invariant instead of new (redundant) rate-limiting code, so a future edit doesn't silently reopen it by adding an early await. Verified against the real local dev stack (docker), not mocks alone: findBanEvasionMatches() and the retention purge's new hardware-fingerprint deletion were both exercised against a live local Postgres with seeded test players (shared components + active ban -> match found and correctly scoped; unrelated player -> never appears; no-ban candidate past retention -> fingerprint rows deleted; active-ban candidate -> rows survive) before being covered by the permanent unit test included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found by actually building the web image (not just tsc --noEmit, which doesn't catch this) - Next's static export of this page fails outright without it: "useSearchParams() should be wrapped in a suspense boundary." Standard fix - split the page into a thin default-export wrapper providing the Suspense boundary and an inner component holding all the actual page logic, unchanged otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live end-to-end verification (real UI, real DB, real admin session)Rebuilt both the `api` and `web` docker images on the local dev stack and drove the actual browser UI, not just the API:
All demo/seed data removed afterward - nothing left in the local dev DB. 🤖 Generated with Claude Code |
- Users & Bans: new collapsible "Hardware Fingerprint" card below Privileges, showing each captured component's name, hashed value, platform, and first/last-seen timestamps behind an Expand button. - Ban Evasion: the Matches table's "Shared components" column now shows each match's actual hashed value alongside the component label, not just the label. - Ban Evasion: new "Total IDs Captured" summary card - overall row count plus a per-platform breakdown (row/player counts and which components that platform has contributed). Backend: launcher-integrity.gateway.ts gains getPlayerHardwareFingerprints() and getHardwareFingerprintStats(); findBanEvasionMatches() now also returns each matched component's hash, not just its name. New GET /players/:id field (hardwareFingerprints) and GET /hardware-fingerprint-stats route, both behind the existing admin-or-moderator webadmin gate - same raw-hash exposure already accepted for the ban-evasion match list, now extended consistently to both surfaces. Verified end-to-end against the local dev stack (rebuilt bmp-api/ bmp-web images, live demo data with two players sharing 3 of 4 components). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up UI enhancement on top of the ban-evasion work already in this PR, per additional request:
Backend: Verified end-to-end against the local dev stack: rebuilt 🤖 Generated with Claude Code |
… bridge - "Total IDs Captured" -> "ID Types Captured, by Platform": a simple per-platform list of which component types this platform's launcher actually produces, not a count of rows/players (renamed getHardwareFingerprintStats -> getHardwareIdCoverage, /hardware-fingerprint-stats -> /hardware-id-coverage). A row/player count read as a meaningful metric but wasn't really one here - the question this actually answers is "does macOS give us anything besides hardware_serial," not "how many IDs exist." - Recognizes the new 'serverside_connection_id' component (added in new-launcher's hardwarefingerprint.cpp, a bit-for-bit bridge to the old BalatroMultiplayer mod's own connection ID) in the coverage list and the Matches table automatically (both are already generic over component names) - added its own entry to the component-reliability legend explaining it's the one component that isn't HMAC-pepper-keyed, by design, since matching the old system's value required reproducing its original unkeyed hash exactly. - Updated player_hardware_fingerprints' schema comments to reflect that exception and to stop saying no ban-evasion query exists yet (one has, since the earlier commit on this branch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Two more fixes on this same feature, both verified live against the local stack: "Total IDs Captured" → "ID Types Captured, by Platform": this was showing row/player counts, which read as a meaningful metric but wasn't really answering anything useful (it's just "how many players have connected," restated). Replaced with a plain per-platform list of which component types actually get captured there — the real question ("does macOS give us anything besides
🤖 Generated with Claude Code |
Context
Builds on the launcher-integrity hwid-binding fix (already on
maininBalatroMultiplayerServerInternal, independently landed there - see that repo's history). This PR is the "store it safely" half:player_hardware_fingerprintswas already a well-designed table (hashed-only, per-component upsert, FK toplayers, pre-indexed for cross-player matching per its own schema comment) but had gaps in retention, disclosure, and a completely unused index - and per explicit direction, this also builds the actual ban-evasion consumer rather than leaving the index unused.What's here
1. Retention -
purgeExpiredDeletedPlayerHashes()already anonymizessteamIdHash/discordIdHash12 months after account deletion (no active ban). It now also deletes that player'splayer_hardware_fingerprintsrows under the identical condition, via a newdeletePlayerHardwareFingerprints().2. Privacy notice (
apps/web/src/app/notice/page.tsx) - hardware/device fingerprinting had zero disclosure despite every other category being itemized with purpose/legal basis/retention. Added matching entries. This is real legal copy - please have it (ideally legally) reviewed before it ships, not auto-published as-is.3. Ban Evasion admin page -
findBanEvasionMatches()self-joins the fingerprints table on(componentName, componentHash)for every currently-banned player; newGET /webadmin/ban-evasion(same admin-or-moderator gate as the rest of webadmin) exposes it; new/admin/ban-evasionpage lists results with a component-reliability legend (spoofability varies a lot - a registry value vs. a TPM-backed key are not equally trustworthy)./admin/usersgets a small pointer badge instead of a duplicate list, and now accepts?playerId=to jump to a specific player.Every match is a plain shared-component count for now, not a weighted score - deliberate (not enough real match data yet to justify weights), with a comment marking where a future weight map would go.
Investigated, deliberately not changed: rate-limiting the MQTT challenge-response path. A rapid burst against one issued challenge already can't call
strategy.verify()more than once -activeChallengeclears synchronously before this function's firstawait, andmqtt.service.tsdispatches via a plain non-asyncEventEmittercallback. Added an explicit comment on that invariant instead of redundant rate-limiting code, so a future edit doesn't silently reopen it.Verification
findBanEvasionMatches()and the retention purge's new deletion path were both exercised against a real local Postgres (docker) with seeded test players before being covered by the included unit test - shared components + active ban → match found and correctly scoped; unrelated player → never appears; no-ban candidate past retention → fingerprint rows deleted; active-ban candidate → rows survive.🤖 Generated with Claude Code