From fc74c9680d5627bace0dacc94e95710a7ebb1403 Mon Sep 17 00:00:00 2001
From: 12problems
Date: Sat, 5 Sep 2026 14:54:36 -0400
Subject: [PATCH 1/4] Retain hwid data safely, disclose it, and build the
ban-evasion consumer
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
---
.../launcher-integrity.service.ts | 19 ++
.../features/webadmin/ban-evasion.route.ts | 19 ++
.../src/features/webadmin/webadmin.route.ts | 2 +
.../gateways/launcher-integrity.gateway.ts | 115 +++++++++++
.../infrastructure/gateways/player.gateway.ts | 6 +
.../launcher-integrity-ban-evasion.test.ts | 123 ++++++++++++
.../src/app/(home)/admin/ban-evasion/page.tsx | 184 ++++++++++++++++++
apps/web/src/app/(home)/admin/users/page.tsx | 33 +++-
apps/web/src/app/_components/mobile-menu.tsx | 8 +
apps/web/src/app/_components/nav-auth.tsx | 3 +
apps/web/src/app/notice/page.tsx | 8 +-
11 files changed, 516 insertions(+), 4 deletions(-)
create mode 100644 apps/server/src/features/webadmin/ban-evasion.route.ts
create mode 100644 apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
create mode 100644 apps/web/src/app/(home)/admin/ban-evasion/page.tsx
diff --git a/apps/server/src/features/launcher-integrity/launcher-integrity.service.ts b/apps/server/src/features/launcher-integrity/launcher-integrity.service.ts
index 768c037b..dcb74f8c 100644
--- a/apps/server/src/features/launcher-integrity/launcher-integrity.service.ts
+++ b/apps/server/src/features/launcher-integrity/launcher-integrity.service.ts
@@ -343,6 +343,25 @@ export function createLauncherIntegrityService(
)
return
+ // Rate-limiting note (investigated while adding safe hwid storage
+ // elsewhere in this pass): this line, and everything above it in this
+ // function, must stay synchronous - no `await` before this point.
+ // mqtt.service.ts's subscribeToPlayerChallengeResponses() dispatches
+ // each incoming message via a plain (non-async) EventEmitter
+ // 'message' handler that calls this function without awaiting it, so
+ // a burst of rapid-fire responses against the same challenge arrives
+ // as consecutive synchronous invocations, not genuinely concurrent
+ // ones. Because activeChallenge is cleared here before this
+ // function's first `await` (currently strategy.verify() below),
+ // each invocation's synchronous prefix runs to completion - clearing
+ // activeChallenge - before the next queued invocation's own prefix
+ // begins, so only the first response in a burst ever reaches
+ // strategy.verify()/failIntegrity(); every subsequent one hits the
+ // `!active` check above and returns immediately, with no DB write.
+ // This already closes the "spam wrong guesses against one issued
+ // challenge" gap structurally - moving the `session.activeChallenge
+ // = undefined` line below any future `await` added above it (e.g.
+ // a lookup before this point) would silently reopen it.
clearTimeout(active.timeoutTimer)
session.activeChallenge = undefined
diff --git a/apps/server/src/features/webadmin/ban-evasion.route.ts b/apps/server/src/features/webadmin/ban-evasion.route.ts
new file mode 100644
index 00000000..67fc7a02
--- /dev/null
+++ b/apps/server/src/features/webadmin/ban-evasion.route.ts
@@ -0,0 +1,19 @@
+import { Router } from 'express'
+import { findBanEvasionMatches } from '../../infrastructure/gateways/launcher-integrity.gateway.js'
+
+// Mounted under webadmin.route.ts's router, which already gates every route
+// here behind the shared admin-or-moderator `webAdmin` middleware - no
+// separate access check needed in this file, same as every other route
+// module registered there.
+const router = Router()
+
+router.get('/ban-evasion', async (_req, res, next) => {
+ try {
+ const matches = await findBanEvasionMatches()
+ res.json({ matches })
+ } catch (err) {
+ next(err)
+ }
+})
+
+export default router
diff --git a/apps/server/src/features/webadmin/webadmin.route.ts b/apps/server/src/features/webadmin/webadmin.route.ts
index f3458f66..c58010f6 100644
--- a/apps/server/src/features/webadmin/webadmin.route.ts
+++ b/apps/server/src/features/webadmin/webadmin.route.ts
@@ -2,6 +2,7 @@ import { Router } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { findPlayerById } from '../../infrastructure/gateways/player.gateway.js'
import { authenticate } from '../../middleware/authenticate.js'
+import banEvasionRouter from './ban-evasion.route.js'
import blogRouter from './blog.route.js'
import chatLogsRouter from './chat-logs.route.js'
import configRouter from './config.route.js'
@@ -37,6 +38,7 @@ function webAdmin(req: Request, res: Response, next: NextFunction) {
router.use(webAdmin)
router.use(playersRouter)
+router.use(banEvasionRouter)
router.use(chatLogsRouter)
router.use(reportsRouter)
router.use(seasonsRouter)
diff --git a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
index e9329069..906d9454 100644
--- a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
+++ b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
@@ -1,3 +1,5 @@
+import { and, eq, gt, isNull, ne, or, sql } from 'drizzle-orm'
+import { alias } from 'drizzle-orm/pg-core'
import type {
ChallengeKind,
LauncherIntegrityFailureReason,
@@ -5,9 +7,20 @@ import type {
import { db } from '../db/index.js'
import {
launcherIntegrityEvents,
+ playerBans,
playerHardwareFingerprints,
+ players,
} from '../db/schema.js'
+// A ban is active when it has not been lifted and has not expired - same
+// predicate ban.gateway.ts's own (unexported) activeCondition() uses, kept
+// in sync by hand since that one isn't exported for reuse.
+const activeBanCondition = () =>
+ and(
+ isNull(playerBans.liftedAt),
+ or(isNull(playerBans.expiresAt), gt(playerBans.expiresAt, sql`now()`)),
+ )
+
export async function insertEvent(
playerId: string,
kind: ChallengeKind,
@@ -54,3 +67,105 @@ export async function upsertHardwareComponents(
})
}
}
+
+// Part of the same retention policy purgeExpiredDeletedPlayerHashes()
+// (player.gateway.ts) already applies to steamIdHash/discordIdHash - called
+// from that same function, under the same "12+ months deleted, no active
+// ban" condition, so this table doesn't outlive the identifiers needed to
+// look a player up by in the first place. A hard delete (not an anonymizing
+// update, unlike the hash columns) - there's no "keep the row but blank it"
+// value here the way there is for players itself (playerBans still needs
+// the players row to exist; nothing references player_hardware_fingerprints
+// rows after the fact).
+export async function deletePlayerHardwareFingerprints(
+ playerId: string,
+): Promise {
+ await db
+ .delete(playerHardwareFingerprints)
+ .where(eq(playerHardwareFingerprints.playerId, playerId))
+}
+
+export interface BanEvasionMatch {
+ bannedPlayerId: string
+ bannedPlayerName: string
+ matchedPlayerId: string
+ matchedPlayerName: string
+ matchedPlayerHasActiveBan: boolean
+ matchedComponents: string[]
+}
+
+// Every player sharing >=1 hardware component with a currently-banned
+// player - the join player_hardware_fingerprints' own
+// (componentName, componentHash) index exists specifically to make this
+// cheap. "Banned" means the first player in a pair currently has an active
+// ban of any type; matchedPlayerHasActiveBan says whether the *other* one
+// also does, surfaced separately since a match between two already-banned
+// accounts is a different (lower-urgency) case than one flagging a live,
+// unbanned alt.
+//
+// Every component is weighted equally for now (this just counts distinct
+// matched component names) - see hardwarefingerprint.cpp's own components
+// for how widely spoofability actually varies between them (a registry
+// value vs. a TPM-backed key are not equally trustworthy signals). This is
+// the one spot a future per-component weight map would replace a plain
+// count with a weighted score, once there's enough real match data to set
+// sensible weights from - not guessed at now.
+export async function findBanEvasionMatches(): Promise {
+ const bannedPlayerIds = await db
+ .selectDistinct({ playerId: playerBans.playerId })
+ .from(playerBans)
+ .where(activeBanCondition())
+ const bannedIdList = bannedPlayerIds.map((row) => row.playerId)
+ const bannedIdSet = new Set(bannedIdList)
+ if (bannedIdSet.size === 0) {
+ return []
+ }
+
+ const mine = alias(playerHardwareFingerprints, 'mine')
+ const other = alias(playerHardwareFingerprints, 'other')
+ const bannedPlayer = alias(players, 'banned_player')
+ const matchedPlayer = alias(players, 'matched_player')
+
+ const rows = await db
+ .select({
+ bannedPlayerId: mine.playerId,
+ bannedPlayerName: bannedPlayer.steamName,
+ matchedPlayerId: other.playerId,
+ matchedPlayerName: matchedPlayer.steamName,
+ componentName: mine.componentName,
+ })
+ .from(mine)
+ .innerJoin(
+ other,
+ and(
+ eq(mine.componentName, other.componentName),
+ eq(mine.componentHash, other.componentHash),
+ ne(mine.playerId, other.playerId),
+ ),
+ )
+ .innerJoin(bannedPlayer, eq(bannedPlayer.id, mine.playerId))
+ .innerJoin(matchedPlayer, eq(matchedPlayer.id, other.playerId))
+ .where(sql`${mine.playerId} in ${bannedIdList}`)
+
+ const byPair = new Map()
+ for (const row of rows) {
+ const key = `${row.bannedPlayerId}:${row.matchedPlayerId}`
+ const existing = byPair.get(key)
+ if (existing) {
+ existing.matchedComponents.push(row.componentName)
+ continue
+ }
+ byPair.set(key, {
+ bannedPlayerId: row.bannedPlayerId,
+ bannedPlayerName: row.bannedPlayerName,
+ matchedPlayerId: row.matchedPlayerId,
+ matchedPlayerName: row.matchedPlayerName,
+ matchedPlayerHasActiveBan: bannedIdSet.has(row.matchedPlayerId),
+ matchedComponents: [row.componentName],
+ })
+ }
+
+ return [...byPair.values()].sort(
+ (a, b) => b.matchedComponents.length - a.matchedComponents.length,
+ )
+}
diff --git a/apps/server/src/infrastructure/gateways/player.gateway.ts b/apps/server/src/infrastructure/gateways/player.gateway.ts
index 90e2955d..01b6723d 100644
--- a/apps/server/src/infrastructure/gateways/player.gateway.ts
+++ b/apps/server/src/infrastructure/gateways/player.gateway.ts
@@ -2,6 +2,7 @@ import { and, eq, isNotNull, lt } from 'drizzle-orm'
import { db } from '../db/index.js'
import { players } from '../db/schema.js'
import { getActiveBans } from './ban.gateway.js'
+import { deletePlayerHardwareFingerprints } from './launcher-integrity.gateway.js'
const DELETED_HASH_RETENTION_MS = 365 * 24 * 60 * 60 * 1000
@@ -226,6 +227,11 @@ export async function purgeExpiredDeletedPlayerHashes(): Promise {
.update(players)
.set({ steamIdHash: null, discordIdHash: null, updatedAt: new Date() })
.where(eq(players.id, candidate.id))
+ // Same retention window/ban condition as the hash-clearing above -
+ // a hardware fingerprint is exactly the kind of stable per-machine
+ // identifier the rest of this function already exists to stop
+ // retaining once it's no longer needed for enforcing an active ban.
+ await deletePlayerHardwareFingerprints(candidate.id)
purged++
}
return purged
diff --git a/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts b/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
new file mode 100644
index 00000000..3ebd88ff
--- /dev/null
+++ b/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it, vi } from 'vitest'
+import { db } from '../../infrastructure/db/index.js'
+import { findBanEvasionMatches } from '../../infrastructure/gateways/launcher-integrity.gateway.js'
+
+// findBanEvasionMatches() makes exactly two db calls: a selectDistinct for
+// currently-banned player IDs, then (only if that's non-empty) a select with
+// a self-join for the actual matches. Mocked at that level, same
+// chain-mocking convention mods-gateway.test.ts already uses for this
+// codebase's other multi-step Drizzle queries - the query itself was
+// separately verified against a real local Postgres instance while
+// implementing this (real shared-component/active-ban/unrelated-player
+// cases all behaved correctly), so this test's job is the function's own
+// grouping/sorting logic once rows come back, not the SQL itself.
+function mockBannedIds(ids: string[]) {
+ ;(db as any).selectDistinct = vi.fn().mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue(ids.map((playerId) => ({ playerId }))),
+ }),
+ })
+}
+
+function mockJoinRows(rows: unknown[]) {
+ ;(db as any).select = vi.fn().mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ innerJoin: vi.fn().mockReturnThis(),
+ where: vi.fn().mockResolvedValue(rows),
+ }),
+ })
+}
+
+describe('findBanEvasionMatches', () => {
+ it('returns no matches and skips the join query when nobody is currently banned', async () => {
+ mockBannedIds([])
+ const joinSpy = vi.fn()
+ ;(db as any).select = joinSpy
+
+ const matches = await findBanEvasionMatches()
+
+ expect(matches).toEqual([])
+ expect(joinSpy).not.toHaveBeenCalled()
+ })
+
+ it('groups rows by (bannedPlayerId, matchedPlayerId), collecting every shared component', async () => {
+ mockBannedIds(['p1'])
+ mockJoinRows([
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'Banned',
+ matchedPlayerId: 'p2',
+ matchedPlayerName: 'Matched',
+ componentName: 'machine_guid',
+ },
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'Banned',
+ matchedPlayerId: 'p2',
+ matchedPlayerName: 'Matched',
+ componentName: 'disk_serial',
+ },
+ ])
+
+ const matches = await findBanEvasionMatches()
+
+ expect(matches).toHaveLength(1)
+ expect(matches[0]).toMatchObject({
+ bannedPlayerId: 'p1',
+ matchedPlayerId: 'p2',
+ matchedPlayerHasActiveBan: false,
+ })
+ expect(matches[0].matchedComponents.sort()).toEqual([
+ 'disk_serial',
+ 'machine_guid',
+ ])
+ })
+
+ it('marks matchedPlayerHasActiveBan true when the matched player is also currently banned', async () => {
+ mockBannedIds(['p1', 'p2'])
+ mockJoinRows([
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'Banned',
+ matchedPlayerId: 'p2',
+ matchedPlayerName: 'AlsoBanned',
+ componentName: 'mac_address',
+ },
+ ])
+
+ const matches = await findBanEvasionMatches()
+
+ expect(matches[0].matchedPlayerHasActiveBan).toBe(true)
+ })
+
+ it('sorts matches by shared-component count, descending', async () => {
+ mockBannedIds(['p1'])
+ mockJoinRows([
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'B',
+ matchedPlayerId: 'weak',
+ matchedPlayerName: 'Weak',
+ componentName: 'mac_address',
+ },
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'B',
+ matchedPlayerId: 'strong',
+ matchedPlayerName: 'Strong',
+ componentName: 'machine_guid',
+ },
+ {
+ bannedPlayerId: 'p1',
+ bannedPlayerName: 'B',
+ matchedPlayerId: 'strong',
+ matchedPlayerName: 'Strong',
+ componentName: 'disk_serial',
+ },
+ ])
+
+ const matches = await findBanEvasionMatches()
+
+ expect(matches.map((m) => m.matchedPlayerId)).toEqual(['strong', 'weak'])
+ })
+})
diff --git a/apps/web/src/app/(home)/admin/ban-evasion/page.tsx b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
new file mode 100644
index 00000000..bd0b7e2c
--- /dev/null
+++ b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
@@ -0,0 +1,184 @@
+'use client'
+
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { apiFetch } from '@/lib/api'
+import { useAuth } from '@/lib/auth'
+import { useQuery } from '@tanstack/react-query'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useEffect } from 'react'
+
+interface BanEvasionMatch {
+ bannedPlayerId: string
+ bannedPlayerName: string
+ matchedPlayerId: string
+ matchedPlayerName: string
+ matchedPlayerHasActiveBan: boolean
+ matchedComponents: string[]
+}
+
+interface BanEvasionResponse {
+ matches: BanEvasionMatch[]
+}
+
+// Component reliability legend, kept next to the table rather than only in
+// engineering docs - see hardwarefingerprint.cpp for the actual collectors.
+// Every match below is weighted equally today (a plain shared-component
+// count) regardless of this table, since there isn't yet enough real match
+// data to justify specific weights - this is context for *reading* a match,
+// not something the current sort/count uses.
+const COMPONENT_RELIABILITY: { label: string; components: string[] }[] = [
+ {
+ label: 'Hard to fake on real hardware',
+ components: ['hardware_serial', 'platform_uuid', 'tpm_ek_hash'],
+ },
+ {
+ label: 'Needs 3rd-party tooling to fake',
+ components: ['disk_serial', 'board_serial', 'system_uuid', 'gpu_id'],
+ },
+ {
+ label: 'Trivial to fake (a registry edit or one command)',
+ components: ['machine_guid', 'machine_id', 'mac_address', 'volume_serial'],
+ },
+ {
+ label:
+ 'Not spoofable, but also not distinguishing (common CPU model) or not a hardware signal at all (a new account, by definition)',
+ components: ['cpu_id', 'steam_id'],
+ },
+]
+
+export default function BanEvasionPage() {
+ const { isAdmin, isModerator, pending } = useAuth()
+ const router = useRouter()
+ const canAccess = isAdmin || isModerator
+
+ useEffect(() => {
+ if (!pending && !canAccess) router.replace('/')
+ }, [pending, canAccess, router])
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-ban-evasion'],
+ queryFn: () => apiFetch('/webadmin/ban-evasion'),
+ enabled: canAccess,
+ })
+
+ if (pending) {
+ return
+ Currently-banned players who share a hardware/device identifier with
+ another account. A shared component is a hint, not proof - see the
+ reliability notes below before acting on a match.
+
We record game action logs during matches. These contain in-game actions and your account identifier, no additional personal data. They are used for moderation, replay, and spectating features.
+
Hardware/device fingerprint (Ranked mode only):
+
If you play Ranked, our companion launcher (BET) collects a set of hardware and device identifiers from your machine — things like a disk/board serial, network adapter address, and a randomly-generated identifier the launcher creates and stores locally on first run. Each one is one-way hashed (HMAC-SHA256, with a secret pepper) on your own machine before it is ever sent anywhere; we never receive or store a raw, reversible identifier, only the hash. This is used solely to detect ban evasion (the same banned person creating a new account on the same machine) — never for advertising, fingerprinting across other services, or any purpose beyond Ranked anti-cheat. It is not collected for Casual play.
+
Cookies:
We use a session cookie for Steam OAuth authentication. This cookie is strictly necessary for the service to function. We do not use tracking, analytics, or advertising cookies.
@@ -54,7 +57,7 @@ export default function NoticePage() {
We process your data under the following legal bases (per GDPR Article 6(1)):
Contractual necessity (Art. 6(1)(b)): Account data and gameplay logs are necessary to provide the service you signed up for.
-
Legitimate interest (Art. 6(1)(f)): Hashed IP addresses, hashed Steam IDs, and moderation records are processed to maintain the security and integrity of the service, prevent abuse, and enforce bans. You may object to processing under this basis (see Your Rights).
+
Legitimate interest (Art. 6(1)(f)): Hashed IP addresses, hashed Steam IDs, hashed hardware/device identifiers (Ranked only), and moderation records are processed to maintain the security and integrity of the service, prevent abuse, and enforce bans. You may object to processing under this basis (see Your Rights).
Legal obligation (Art. 6(1)(c)): We may process and retain data where required by law, including child safety reporting obligations.
Consent (Art. 6(1)(a)): Optional features such as Discord account linking are based on your consent, which you can withdraw at any time by unlinking.
@@ -65,7 +68,7 @@ export default function NoticePage() {
Operate the service and manage your account.
Moderate behavior using gameplay logs and chat records.
-
Enforce bans and prevent evasion using hashed identifiers.
+
Enforce bans and prevent evasion using hashed identifiers, including hashed hardware/device identifiers for Ranked play.
Provide replay and spectating features using gameplay logs.
Comply with legal obligations, including child safety reporting.
@@ -93,6 +96,7 @@ export default function NoticePage() {
Account data (Steam display name, Discord display name, hashed IP, Discord link, age flag): retained for the life of your account. Deleted within 30 days of account deletion.
Peppered Steam ID hash: Retained after account deletion for the sole purpose of enforcing bans and preventing ban evasion. Reviewed and purged if no associated ban exists after 12 months post-deletion.
+
Hashed hardware/device identifiers (Ranked only): Same policy as the Steam ID hash above — retained only for as long as needed to enforce a ban or detect evasion, and deleted (not just anonymized) if no associated ban exists 12 months after account deletion.
Chat message logs: 90 days from the date sent, then permanently deleted.
Gameplay logs: 180 days from the date of the match, then permanently deleted.
Moderation records (bans, flags, evidence): Duration of the ban plus 12 months. For permanent bans: retained indefinitely.
From f199e575683262d09c625402ec2fd69b9c55ccf4 Mon Sep 17 00:00:00 2001
From: 12problems
Date: Sat, 5 Sep 2026 15:48:04 -0400
Subject: [PATCH 2/4] Wrap useSearchParams() in a Suspense boundary
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
---
apps/web/src/app/(home)/admin/users/page.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/app/(home)/admin/users/page.tsx b/apps/web/src/app/(home)/admin/users/page.tsx
index 321a4913..14c36276 100644
--- a/apps/web/src/app/(home)/admin/users/page.tsx
+++ b/apps/web/src/app/(home)/admin/users/page.tsx
@@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
-import { useEffect, useState } from 'react'
+import { Suspense, useEffect, useState } from 'react'
import type { BanType, Privilege } from '@bmp/types'
import { apiFetch } from '@/lib/api'
import { useAuth } from '@/lib/auth'
@@ -48,7 +48,19 @@ interface PlayerDetailResponse {
bans: Ban[]
}
+// useSearchParams() (below, for the Ban Evasion page's ?playerId= deep link)
+// requires a Suspense boundary for Next's static export of this page - the
+// default export just provides that; all the real page logic stays in this
+// inner component, unchanged otherwise.
export default function AdminUsersPage() {
+ return (
+ Loading…}>
+
+
+ )
+}
+
+function AdminUsersPageInner() {
const { isAdmin, isModerator, pending } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
From 126088f153b26a0a6fe50f0df8726fd2b6716aec Mon Sep 17 00:00:00 2001
From: 12problems
Date: Sat, 5 Sep 2026 16:06:43 -0400
Subject: [PATCH 3/4] Show raw hashed hwid values and capture stats on admin
pages
- 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
---
.../features/webadmin/ban-evasion.route.ts | 18 ++-
.../src/features/webadmin/players.route.ts | 4 +-
.../gateways/launcher-integrity.gateway.ts | 107 +++++++++++++++-
.../launcher-integrity-ban-evasion.test.ts | 18 ++-
.../src/app/(home)/admin/ban-evasion/page.tsx | 117 ++++++++++++++++--
.../components/hardware-fingerprint-card.tsx | 103 +++++++++++++++
apps/web/src/app/(home)/admin/users/page.tsx | 8 +-
7 files changed, 353 insertions(+), 22 deletions(-)
create mode 100644 apps/web/src/app/(home)/admin/users/components/hardware-fingerprint-card.tsx
diff --git a/apps/server/src/features/webadmin/ban-evasion.route.ts b/apps/server/src/features/webadmin/ban-evasion.route.ts
index 67fc7a02..c4cd90f7 100644
--- a/apps/server/src/features/webadmin/ban-evasion.route.ts
+++ b/apps/server/src/features/webadmin/ban-evasion.route.ts
@@ -1,5 +1,8 @@
import { Router } from 'express'
-import { findBanEvasionMatches } from '../../infrastructure/gateways/launcher-integrity.gateway.js'
+import {
+ findBanEvasionMatches,
+ getHardwareFingerprintStats,
+} from '../../infrastructure/gateways/launcher-integrity.gateway.js'
// Mounted under webadmin.route.ts's router, which already gates every route
// here behind the shared admin-or-moderator `webAdmin` middleware - no
@@ -16,4 +19,17 @@ router.get('/ban-evasion', async (_req, res, next) => {
}
})
+// "Total IDs Captured" summary on the same page - a separate call rather
+// than folding into the response above, since it's conceptually distinct
+// (global collection coverage vs. specific suspected-alt matches) and only
+// needs recomputing on page load, not on every match-list refresh.
+router.get('/hardware-fingerprint-stats', async (_req, res, next) => {
+ try {
+ const stats = await getHardwareFingerprintStats()
+ res.json(stats)
+ } catch (err) {
+ next(err)
+ }
+})
+
export default router
diff --git a/apps/server/src/features/webadmin/players.route.ts b/apps/server/src/features/webadmin/players.route.ts
index 2e366740..f44b5a36 100644
--- a/apps/server/src/features/webadmin/players.route.ts
+++ b/apps/server/src/features/webadmin/players.route.ts
@@ -4,6 +4,7 @@ import { db } from '../../infrastructure/db/index.js'
import { playerBans, players } from '../../infrastructure/db/schema.js'
import { findPlayerById } from '../../infrastructure/gateways/player.gateway.js'
import { insertBan, isBanType, liftBan, listBans } from '../../infrastructure/gateways/ban.gateway.js'
+import { getPlayerHardwareFingerprints } from '../../infrastructure/gateways/launcher-integrity.gateway.js'
import { kickClient } from '../../infrastructure/emqx/emqx-admin.service.js'
import { mqttService } from '../../infrastructure/mqtt/mqtt.service.js'
import { getSession } from '../../state/index.js'
@@ -67,7 +68,8 @@ router.get('/players/:id', async (req, res, next) => {
const player = await findPlayerById(req.params.id)
if (!player) throw new AppError('Player not found', 404)
const bans = await listBans(req.params.id)
- res.json({ player, bans })
+ const hardwareFingerprints = await getPlayerHardwareFingerprints(req.params.id)
+ res.json({ player, bans, hardwareFingerprints })
} catch (err) {
next(err)
}
diff --git a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
index 906d9454..9c0e5539 100644
--- a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
+++ b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
@@ -85,13 +85,18 @@ export async function deletePlayerHardwareFingerprints(
.where(eq(playerHardwareFingerprints.playerId, playerId))
}
+export interface MatchedComponent {
+ componentName: string
+ componentHash: string
+}
+
export interface BanEvasionMatch {
bannedPlayerId: string
bannedPlayerName: string
matchedPlayerId: string
matchedPlayerName: string
matchedPlayerHasActiveBan: boolean
- matchedComponents: string[]
+ matchedComponents: MatchedComponent[]
}
// Every player sharing >=1 hardware component with a currently-banned
@@ -133,6 +138,12 @@ export async function findBanEvasionMatches(): Promise {
matchedPlayerId: other.playerId,
matchedPlayerName: matchedPlayer.steamName,
componentName: mine.componentName,
+ // The hash itself, not just which component matched - an admin
+ // comparing two suspected-alt accounts wants to see the actual
+ // value that lined up, not just take "disk_serial matched" on
+ // faith. Both sides are identical by construction (that's what
+ // the join condition below requires), so either row's hash works.
+ componentHash: mine.componentHash,
})
.from(mine)
.innerJoin(
@@ -150,9 +161,13 @@ export async function findBanEvasionMatches(): Promise {
const byPair = new Map()
for (const row of rows) {
const key = `${row.bannedPlayerId}:${row.matchedPlayerId}`
+ const component = {
+ componentName: row.componentName,
+ componentHash: row.componentHash,
+ }
const existing = byPair.get(key)
if (existing) {
- existing.matchedComponents.push(row.componentName)
+ existing.matchedComponents.push(component)
continue
}
byPair.set(key, {
@@ -161,7 +176,7 @@ export async function findBanEvasionMatches(): Promise {
matchedPlayerId: row.matchedPlayerId,
matchedPlayerName: row.matchedPlayerName,
matchedPlayerHasActiveBan: bannedIdSet.has(row.matchedPlayerId),
- matchedComponents: [row.componentName],
+ matchedComponents: [component],
})
}
@@ -169,3 +184,89 @@ export async function findBanEvasionMatches(): Promise {
(a, b) => b.matchedComponents.length - a.matchedComponents.length,
)
}
+
+export interface PlayerHardwareFingerprint {
+ componentName: string
+ componentHash: string
+ platform: string
+ firstSeenAt: Date
+ lastSeenAt: Date
+}
+
+// Backs the "Hardware Fingerprint" card on the per-player admin detail view -
+// see admin/users/page.tsx. Raw hash values, not just component names -
+// deliberate: this is one-way HMAC-SHA256 output, not a raw hardware
+// identifier (see hardwarefingerprint.cpp - the server never receives
+// anything else), so showing it to admin/moderator staff investigating a
+// specific player isn't exposing PII, just a stable correlation value they
+// already have the access level to cross-reference via the Ban Evasion page
+// anyway.
+export async function getPlayerHardwareFingerprints(
+ playerId: string,
+): Promise {
+ return db
+ .select({
+ componentName: playerHardwareFingerprints.componentName,
+ componentHash: playerHardwareFingerprints.componentHash,
+ platform: playerHardwareFingerprints.platform,
+ firstSeenAt: playerHardwareFingerprints.firstSeenAt,
+ lastSeenAt: playerHardwareFingerprints.lastSeenAt,
+ })
+ .from(playerHardwareFingerprints)
+ .where(eq(playerHardwareFingerprints.playerId, playerId))
+ .orderBy(playerHardwareFingerprints.componentName)
+}
+
+export interface HardwareFingerprintStats {
+ totalRows: number
+ byPlatform: {
+ platform: string
+ rowCount: number
+ playerCount: number
+ components: string[]
+ }[]
+}
+
+// "Total IDs Captured" summary on the Ban Evasion page - what's actually
+// being collected, in aggregate, right now. Aggregated in application code
+// rather than a grouped SQL query: this table is small admin-tooling data
+// (same reasoning findBanEvasionMatches() already relies on), and a plain
+// per-row scan is simpler to get right than three separate GROUP BY shapes
+// (row count, distinct player count, distinct component names) for what's
+// still a cheap one-time read.
+export async function getHardwareFingerprintStats(): Promise {
+ const rows = await db
+ .select({
+ platform: playerHardwareFingerprints.platform,
+ componentName: playerHardwareFingerprints.componentName,
+ playerId: playerHardwareFingerprints.playerId,
+ })
+ .from(playerHardwareFingerprints)
+
+ const byPlatform = new Map<
+ string,
+ { rowCount: number; playerIds: Set; components: Set }
+ >()
+ for (const row of rows) {
+ let entry = byPlatform.get(row.platform)
+ if (!entry) {
+ entry = { rowCount: 0, playerIds: new Set(), components: new Set() }
+ byPlatform.set(row.platform, entry)
+ }
+ entry.rowCount++
+ entry.playerIds.add(row.playerId)
+ entry.components.add(row.componentName)
+ }
+
+ return {
+ totalRows: rows.length,
+ byPlatform: [...byPlatform.entries()]
+ .map(([platform, entry]) => ({
+ platform,
+ rowCount: entry.rowCount,
+ playerCount: entry.playerIds.size,
+ components: [...entry.components].sort(),
+ }))
+ .sort((a, b) => b.rowCount - a.rowCount),
+ }
+}
diff --git a/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts b/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
index 3ebd88ff..82290547 100644
--- a/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
+++ b/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts
@@ -40,7 +40,7 @@ describe('findBanEvasionMatches', () => {
expect(joinSpy).not.toHaveBeenCalled()
})
- it('groups rows by (bannedPlayerId, matchedPlayerId), collecting every shared component', async () => {
+ it('groups rows by (bannedPlayerId, matchedPlayerId), collecting every shared component with its hash', async () => {
mockBannedIds(['p1'])
mockJoinRows([
{
@@ -49,6 +49,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'p2',
matchedPlayerName: 'Matched',
componentName: 'machine_guid',
+ componentHash: 'hash-guid',
},
{
bannedPlayerId: 'p1',
@@ -56,6 +57,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'p2',
matchedPlayerName: 'Matched',
componentName: 'disk_serial',
+ componentHash: 'hash-disk',
},
])
@@ -67,9 +69,13 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'p2',
matchedPlayerHasActiveBan: false,
})
- expect(matches[0].matchedComponents.sort()).toEqual([
- 'disk_serial',
- 'machine_guid',
+ expect(
+ [...matches[0].matchedComponents].sort((a, b) =>
+ a.componentName.localeCompare(b.componentName),
+ ),
+ ).toEqual([
+ { componentName: 'disk_serial', componentHash: 'hash-disk' },
+ { componentName: 'machine_guid', componentHash: 'hash-guid' },
])
})
@@ -82,6 +88,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'p2',
matchedPlayerName: 'AlsoBanned',
componentName: 'mac_address',
+ componentHash: 'hash-mac',
},
])
@@ -99,6 +106,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'weak',
matchedPlayerName: 'Weak',
componentName: 'mac_address',
+ componentHash: 'hash-mac',
},
{
bannedPlayerId: 'p1',
@@ -106,6 +114,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'strong',
matchedPlayerName: 'Strong',
componentName: 'machine_guid',
+ componentHash: 'hash-guid',
},
{
bannedPlayerId: 'p1',
@@ -113,6 +122,7 @@ describe('findBanEvasionMatches', () => {
matchedPlayerId: 'strong',
matchedPlayerName: 'Strong',
componentName: 'disk_serial',
+ componentHash: 'hash-disk',
},
])
diff --git a/apps/web/src/app/(home)/admin/ban-evasion/page.tsx b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
index bd0b7e2c..fab9f4cd 100644
--- a/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
+++ b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
@@ -17,19 +17,36 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
+interface MatchedComponent {
+ componentName: string
+ componentHash: string
+}
+
interface BanEvasionMatch {
bannedPlayerId: string
bannedPlayerName: string
matchedPlayerId: string
matchedPlayerName: string
matchedPlayerHasActiveBan: boolean
- matchedComponents: string[]
+ matchedComponents: MatchedComponent[]
}
interface BanEvasionResponse {
matches: BanEvasionMatch[]
}
+interface PlatformStats {
+ platform: string
+ rowCount: number
+ playerCount: number
+ components: string[]
+}
+
+interface HardwareFingerprintStatsResponse {
+ totalRows: number
+ byPlatform: PlatformStats[]
+}
+
// Component reliability legend, kept next to the table rather than only in
// engineering docs - see hardwarefingerprint.cpp for the actual collectors.
// Every match below is weighted equally today (a plain shared-component
@@ -71,6 +88,17 @@ export default function BanEvasionPage() {
enabled: canAccess,
})
+ // Separate call from the matches above - this is a different question
+ // (what's actually being collected, in aggregate, right now) than
+ // specific suspected-alt pairs, and only needs to load once per visit,
+ // not whenever the match list itself would refresh.
+ const { data: stats, isLoading: statsLoading } =
+ useQuery({
+ queryKey: ['admin-hardware-fingerprint-stats'],
+ queryFn: () => apiFetch('/webadmin/hardware-fingerprint-stats'),
+ enabled: canAccess,
+ })
+
if (pending) {
return
Loading…
}
@@ -89,6 +117,62 @@ export default function BanEvasionPage() {
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/app/(home)/admin/users/page.tsx b/apps/web/src/app/(home)/admin/users/page.tsx
index 14c36276..4e5ee3f6 100644
--- a/apps/web/src/app/(home)/admin/users/page.tsx
+++ b/apps/web/src/app/(home)/admin/users/page.tsx
@@ -10,6 +10,7 @@ import { useAuth } from '@/lib/auth'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BanList } from './components/ban-list'
+import { HardwareFingerprintCard, type HardwareFingerprint } from './components/hardware-fingerprint-card'
import { IssueBanForm } from './components/issue-ban-form'
import { PlayerList } from './components/player-list'
import { PrivilegeManager } from './components/privilege-manager'
@@ -46,6 +47,7 @@ interface Ban {
interface PlayerDetailResponse {
player: AdminPlayer
bans: Ban[]
+ hardwareFingerprints: HardwareFingerprint[]
}
// useSearchParams() (below, for the Ban Evasion page's ?playerId= deep link)
@@ -94,7 +96,9 @@ function AdminUsersPageInner() {
queryFn: () => apiFetch(`/webadmin/players/${selectedId}`),
enabled: !!selectedId,
})
- const detail = detailResp ? { ...detailResp.player, bans: detailResp.bans } : null
+ const detail = detailResp
+ ? { ...detailResp.player, bans: detailResp.bans, hardwareFingerprints: detailResp.hardwareFingerprints }
+ : null
// Lightweight pointer to the Ban Evasion page rather than a duplicate
// match-list UI here - see that page's own component for the real list.
@@ -257,6 +261,8 @@ function AdminUsersPageInner() {
)}
+
+
)}
From 56b531276176ce366ec75c66f17177e506ec9421 Mon Sep 17 00:00:00 2001
From: 12problems
Date: Sat, 5 Sep 2026 17:35:00 -0400
Subject: [PATCH 4/4] Ban Evasion: coverage listing instead of counts, legacy
connection-ID 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
---
.../features/webadmin/ban-evasion.route.ts | 17 ++--
apps/server/src/infrastructure/db/schema.ts | 17 ++--
.../gateways/launcher-integrity.gateway.ts | 54 +++++------
.../src/app/(home)/admin/ban-evasion/page.tsx | 95 ++++++++-----------
4 files changed, 81 insertions(+), 102 deletions(-)
diff --git a/apps/server/src/features/webadmin/ban-evasion.route.ts b/apps/server/src/features/webadmin/ban-evasion.route.ts
index c4cd90f7..a31ca3ad 100644
--- a/apps/server/src/features/webadmin/ban-evasion.route.ts
+++ b/apps/server/src/features/webadmin/ban-evasion.route.ts
@@ -1,7 +1,7 @@
import { Router } from 'express'
import {
findBanEvasionMatches,
- getHardwareFingerprintStats,
+ getHardwareIdCoverage,
} from '../../infrastructure/gateways/launcher-integrity.gateway.js'
// Mounted under webadmin.route.ts's router, which already gates every route
@@ -19,14 +19,15 @@ router.get('/ban-evasion', async (_req, res, next) => {
}
})
-// "Total IDs Captured" summary on the same page - a separate call rather
-// than folding into the response above, since it's conceptually distinct
-// (global collection coverage vs. specific suspected-alt matches) and only
-// needs recomputing on page load, not on every match-list refresh.
-router.get('/hardware-fingerprint-stats', async (_req, res, next) => {
+// "ID types captured, per platform" summary on the same page - a separate
+// call rather than folding into the response above, since it's
+// conceptually distinct (global collection coverage vs. specific
+// suspected-alt matches) and only needs recomputing on page load, not on
+// every match-list refresh.
+router.get('/hardware-id-coverage', async (_req, res, next) => {
try {
- const stats = await getHardwareFingerprintStats()
- res.json(stats)
+ const coverage = await getHardwareIdCoverage()
+ res.json(coverage)
} catch (err) {
next(err)
}
diff --git a/apps/server/src/infrastructure/db/schema.ts b/apps/server/src/infrastructure/db/schema.ts
index 1f52ecc8..2b622005 100644
--- a/apps/server/src/infrastructure/db/schema.ts
+++ b/apps/server/src/infrastructure/db/schema.ts
@@ -752,13 +752,16 @@ export const launcherIntegrityEvents = pgTable(
// One row per (player, hardware component) the launcher has ever attested to,
// submitted only alongside a launcher-integrity LOGIN challenge (never
// periodic -- see launcher-integrity.service.ts's handleChallengeResponse)
-// and only once that challenge's signature has already verified. Each
-// componentHash is itself an HMAC-SHA256 the launcher computed locally
+// and only once that challenge's signature has already verified. Almost
+// every componentHash is an HMAC-SHA256 the launcher computed locally
// (hardwarefingerprint.cpp) -- the raw hardware identifier never leaves the
-// player's machine, this table only ever sees the hash. Storage only for
-// now: no cross-player fuzzy-match/ban-evasion query is built on top of this
-// yet, but componentName+componentHash is indexed so that join is cheap to
-// add later ("N of M components match a previously-banned player").
+// player's machine, this table only ever sees the hash -- except
+// 'serverside_connection_id', a deliberate exception: it reproduces the old
+// BalatroMultiplayer mod's own unkeyed hash byte-for-byte so it can be
+// cross-referenced against that legacy system's own ban data (see
+// hardwarefingerprint.cpp's legacyEncryptString() comment for why). Indexed
+// on componentName+componentHash for the cross-player fuzzy-match/ban-
+// evasion query this backs (findBanEvasionMatches()).
export const playerHardwareFingerprints = pgTable(
'player_hardware_fingerprints',
{
@@ -768,7 +771,7 @@ export const playerHardwareFingerprints = pgTable(
.references(() => players.id),
platform: varchar('platform', { length: 16 }).notNull(), // 'windows' | 'macos' | 'linux'
componentName: varchar('component_name', { length: 32 }).notNull(), // e.g. 'steam_id', 'disk_serial'
- componentHash: varchar('component_hash', { length: 64 }).notNull(), // hex HMAC-SHA256
+ componentHash: varchar('component_hash', { length: 64 }).notNull(), // hex HMAC-SHA256 (unkeyed FNV-1a for 'serverside_connection_id' - see table comment)
firstSeenAt: timestamp('first_seen_at', { withTimezone: true })
.notNull()
.defaultNow(),
diff --git a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
index 9c0e5539..2b5f803e 100644
--- a/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
+++ b/apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
@@ -217,56 +217,48 @@ export async function getPlayerHardwareFingerprints(
.orderBy(playerHardwareFingerprints.componentName)
}
-export interface HardwareFingerprintStats {
- totalRows: number
+export interface HardwareIdCoverage {
byPlatform: {
platform: string
- rowCount: number
- playerCount: number
components: string[]
}[]
}
-// "Total IDs Captured" summary on the Ban Evasion page - what's actually
-// being collected, in aggregate, right now. Aggregated in application code
-// rather than a grouped SQL query: this table is small admin-tooling data
-// (same reasoning findBanEvasionMatches() already relies on), and a plain
-// per-row scan is simpler to get right than three separate GROUP BY shapes
-// (row count, distinct player count, distinct component names) for what's
-// still a cheap one-time read.
-export async function getHardwareFingerprintStats(): Promise {
+// "ID types captured, per platform" on the Ban Evasion page - a coverage
+// listing (which component types this platform's launcher actually
+// produces), not a count. Deliberately not "N IDs captured" - a row/player
+// count reads as a meaningful metric but isn't really one here (it's just
+// "how many players have connected", restated), where a plain admin/
+// moderator question this answers directly is "does macOS actually give us
+// anything besides hardware_serial." Aggregated in application code rather
+// than a grouped SQL query - same reasoning findBanEvasionMatches() already
+// relies on: this table is small admin-tooling data, and a plain per-row
+// scan is simpler to get right than a GROUP BY + array_agg for what's still
+// a cheap one-time read.
+export async function getHardwareIdCoverage(): Promise {
const rows = await db
- .select({
+ .selectDistinct({
platform: playerHardwareFingerprints.platform,
componentName: playerHardwareFingerprints.componentName,
- playerId: playerHardwareFingerprints.playerId,
})
.from(playerHardwareFingerprints)
- const byPlatform = new Map<
- string,
- { rowCount: number; playerIds: Set; components: Set }
- >()
+ const byPlatform = new Map>()
for (const row of rows) {
- let entry = byPlatform.get(row.platform)
- if (!entry) {
- entry = { rowCount: 0, playerIds: new Set(), components: new Set() }
- byPlatform.set(row.platform, entry)
+ let components = byPlatform.get(row.platform)
+ if (!components) {
+ components = new Set()
+ byPlatform.set(row.platform, components)
}
- entry.rowCount++
- entry.playerIds.add(row.playerId)
- entry.components.add(row.componentName)
+ components.add(row.componentName)
}
return {
- totalRows: rows.length,
byPlatform: [...byPlatform.entries()]
- .map(([platform, entry]) => ({
+ .map(([platform, components]) => ({
platform,
- rowCount: entry.rowCount,
- playerCount: entry.playerIds.size,
- components: [...entry.components].sort(),
+ components: [...components].sort(),
}))
- .sort((a, b) => b.rowCount - a.rowCount),
+ .sort((a, b) => a.platform.localeCompare(b.platform)),
}
}
diff --git a/apps/web/src/app/(home)/admin/ban-evasion/page.tsx b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
index fab9f4cd..6bd6350f 100644
--- a/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
+++ b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx
@@ -35,16 +35,13 @@ interface BanEvasionResponse {
matches: BanEvasionMatch[]
}
-interface PlatformStats {
+interface PlatformCoverage {
platform: string
- rowCount: number
- playerCount: number
components: string[]
}
-interface HardwareFingerprintStatsResponse {
- totalRows: number
- byPlatform: PlatformStats[]
+interface HardwareIdCoverageResponse {
+ byPlatform: PlatformCoverage[]
}
// Component reliability legend, kept next to the table rather than only in
@@ -71,6 +68,11 @@ const COMPONENT_RELIABILITY: { label: string; components: string[] }[] = [
'Not spoofable, but also not distinguishing (common CPU model) or not a hardware signal at all (a new account, by definition)',
components: ['cpu_id', 'steam_id'],
},
+ {
+ label:
+ "Legacy bridge to the old BalatroMultiplayer mod's own connection ID - trivial to fake AND, unlike every other component above, not HMAC-keyed either (matching the old system's value exactly required reproducing its original unkeyed hash)",
+ components: ['serverside_connection_id'],
+ },
]
export default function BanEvasionPage() {
@@ -89,13 +91,13 @@ export default function BanEvasionPage() {
})
// Separate call from the matches above - this is a different question
- // (what's actually being collected, in aggregate, right now) than
+ // (which ID types this platform's launcher actually produces) than
// specific suspected-alt pairs, and only needs to load once per visit,
// not whenever the match list itself would refresh.
- const { data: stats, isLoading: statsLoading } =
- useQuery({
- queryKey: ['admin-hardware-fingerprint-stats'],
- queryFn: () => apiFetch('/webadmin/hardware-fingerprint-stats'),
+ const { data: coverage, isLoading: coverageLoading } =
+ useQuery({
+ queryKey: ['admin-hardware-id-coverage'],
+ queryFn: () => apiFetch('/webadmin/hardware-id-coverage'),
enabled: canAccess,
})
@@ -119,56 +121,37 @@ export default function BanEvasionPage() {
- Total IDs Captured
+
+ ID Types Captured, by Platform
+
- {statsLoading ? (
+ {coverageLoading ? (