Skip to content

Store hwid data safely: retention, disclosure, and a Ban Evasion admin page - #54

Open
12problems wants to merge 4 commits into
mqttfrom
feat/ban-evasion-and-hwid-safety
Open

Store hwid data safely: retention, disclosure, and a Ban Evasion admin page#54
12problems wants to merge 4 commits into
mqttfrom
feat/ban-evasion-and-hwid-safety

Conversation

@12problems

Copy link
Copy Markdown

Context

Builds on the launcher-integrity hwid-binding fix (already on main in BalatroMultiplayerServerInternal, independently landed there - see that repo's history). This PR is the "store it safely" half: player_hardware_fingerprints was already a well-designed table (hashed-only, per-component upsert, FK to players, 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 anonymizes steamIdHash/discordIdHash 12 months after account deletion (no active ban). It now also deletes that player's player_hardware_fingerprints rows under the identical condition, via a new deletePlayerHardwareFingerprints().

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; new GET /webadmin/ban-evasion (same admin-or-moderator gate as the rest of webadmin) exposes it; new /admin/ban-evasion page lists results with a component-reliability legend (spoofability varies a lot - a registry value vs. a TPM-backed key are not equally trustworthy). /admin/users gets 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 - activeChallenge clears synchronously before this function's first await, and mqtt.service.ts dispatches via a plain non-async EventEmitter callback. 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

12problems and others added 2 commits September 5, 2026 14:54
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>
@12problems

Copy link
Copy Markdown
Author

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:

  1. Found and fixed a real bug this way - the initial `web` build failed outright: `useSearchParams()` (added to `/admin/users` for the `?playerId=` deep link) needs a Suspense boundary for Next's static export. `tsc --noEmit` never catches this - only a real `next build` does. Fixed in a follow-up commit.
  2. Seeded two real players sharing 3 hardware components (`disk_serial`, `machine_guid`, `volume_serial`) plus one extra component unique to the second, and a real admin session via `/api/auth/dev/impersonate`.
  3. Logged into the actual running site, opened Users & Bans, and issued a real ban on one of them through the real "Add Ban" button (account-type, no expiry) - confirmed via the API afterward that a real `player_bans` row exists with the right `banType`/`reason`, and the UI reflects it (ban badge, Bans card, working Lift button).
  4. Opened the new Ban Evasion page - correctly shows the banned player matched against the other one, with the right 3 shared components listed (not the 4th, unique one) and a match count of 3.
  5. Confirmed the round-trip: clicking the banned player's name on the Ban Evasion page correctly deep-links to `/admin/users?playerId=...`, auto-selects that player, and shows the "N possible hardware match(es) - view in Ban Evasion" pointer badge.

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>
@12problems

Copy link
Copy Markdown
Author

Follow-up UI enhancement on top of the ban-evasion work already in this PR, per additional request:

  • Users & Bans page: new collapsible "Hardware Fingerprint" card below Privileges — an Expand button reveals each captured component's name, hashed value, platform, and first/last-seen timestamps for that player.
  • Ban Evasion page: the Matches table's "Shared components" column now shows each match's actual hashed value next to the component label (previously label-only).
  • Ban Evasion page: new "Total IDs Captured" card — overall row count plus a per-platform breakdown (row/player counts and which components each platform has contributed).

Backend: getPlayerHardwareFingerprints() / getHardwareFingerprintStats() added to launcher-integrity.gateway.ts; findBanEvasionMatches() now returns each match's component hash, not just its name. Two new surfaces on the existing admin-or-moderator webadmin gate — no new privilege tier.

Verified end-to-end against the local dev stack: rebuilt bmp-api/bmp-web images, hit all three endpoints directly, then confirmed all three UI pieces render/expand correctly in the browser against the existing DemoAdmin/SuspiciousPlayerOne/SuspiciousPlayerTwo demo data (2 players sharing 3 of 4 captured components).

🤖 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>
@12problems

Copy link
Copy Markdown
Author

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 hardware_serial") that section is for.

serverside_connection_id: a new hardware-fingerprint component (added in new-launcher) that reproduces the old BalatroMultiplayer mod's own server_connection_ID() bit-for-bit — same raw-value chain, same unkeyed 32-bit FNV-1a hash — so it can be cross-referenced against whatever ban/abuse data exists on the old system. It's deliberately the one component in this whole system that isn't HMAC-pepper-keyed (matching the old value exactly requires reproducing its original unkeyed hash), which is now called out explicitly in the DB schema comment and the Ban Evasion page's component-reliability legend. No server-side code needed to specifically recognize it — the coverage listing and match table are already generic over component names — verified it shows up correctly in both, with a real captured value (c338482b, the old system's 8-hex format) shared between two impersonated identities on the same machine.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant