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..a31ca3ad --- /dev/null +++ b/apps/server/src/features/webadmin/ban-evasion.route.ts @@ -0,0 +1,36 @@ +import { Router } from 'express' +import { + findBanEvasionMatches, + getHardwareIdCoverage, +} 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) + } +}) + +// "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 coverage = await getHardwareIdCoverage() + res.json(coverage) + } 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/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/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 e9329069..2b5f803e 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,198 @@ 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 MatchedComponent { + componentName: string + componentHash: string +} + +export interface BanEvasionMatch { + bannedPlayerId: string + bannedPlayerName: string + matchedPlayerId: string + matchedPlayerName: string + matchedPlayerHasActiveBan: boolean + matchedComponents: MatchedComponent[] +} + +// 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, + // 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( + 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 component = { + componentName: row.componentName, + componentHash: row.componentHash, + } + const existing = byPair.get(key) + if (existing) { + existing.matchedComponents.push(component) + continue + } + byPair.set(key, { + bannedPlayerId: row.bannedPlayerId, + bannedPlayerName: row.bannedPlayerName, + matchedPlayerId: row.matchedPlayerId, + matchedPlayerName: row.matchedPlayerName, + matchedPlayerHasActiveBan: bannedIdSet.has(row.matchedPlayerId), + matchedComponents: [component], + }) + } + + return [...byPair.values()].sort( + (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 HardwareIdCoverage { + byPlatform: { + platform: string + components: string[] + }[] +} + +// "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 + .selectDistinct({ + platform: playerHardwareFingerprints.platform, + componentName: playerHardwareFingerprints.componentName, + }) + .from(playerHardwareFingerprints) + + const byPlatform = new Map>() + for (const row of rows) { + let components = byPlatform.get(row.platform) + if (!components) { + components = new Set() + byPlatform.set(row.platform, components) + } + components.add(row.componentName) + } + + return { + byPlatform: [...byPlatform.entries()] + .map(([platform, components]) => ({ + platform, + components: [...components].sort(), + })) + .sort((a, b) => a.platform.localeCompare(b.platform)), + } +} 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..82290547 --- /dev/null +++ b/apps/server/src/tests/gateways/launcher-integrity-ban-evasion.test.ts @@ -0,0 +1,133 @@ +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 with its hash', async () => { + mockBannedIds(['p1']) + mockJoinRows([ + { + bannedPlayerId: 'p1', + bannedPlayerName: 'Banned', + matchedPlayerId: 'p2', + matchedPlayerName: 'Matched', + componentName: 'machine_guid', + componentHash: 'hash-guid', + }, + { + bannedPlayerId: 'p1', + bannedPlayerName: 'Banned', + matchedPlayerId: 'p2', + matchedPlayerName: 'Matched', + componentName: 'disk_serial', + componentHash: 'hash-disk', + }, + ]) + + const matches = await findBanEvasionMatches() + + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ + bannedPlayerId: 'p1', + matchedPlayerId: 'p2', + matchedPlayerHasActiveBan: false, + }) + 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' }, + ]) + }) + + 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', + componentHash: 'hash-mac', + }, + ]) + + 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', + componentHash: 'hash-mac', + }, + { + bannedPlayerId: 'p1', + bannedPlayerName: 'B', + matchedPlayerId: 'strong', + matchedPlayerName: 'Strong', + componentName: 'machine_guid', + componentHash: 'hash-guid', + }, + { + bannedPlayerId: 'p1', + bannedPlayerName: 'B', + matchedPlayerId: 'strong', + matchedPlayerName: 'Strong', + componentName: 'disk_serial', + componentHash: 'hash-disk', + }, + ]) + + 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..6bd6350f --- /dev/null +++ b/apps/web/src/app/(home)/admin/ban-evasion/page.tsx @@ -0,0 +1,260 @@ +'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 MatchedComponent { + componentName: string + componentHash: string +} + +interface BanEvasionMatch { + bannedPlayerId: string + bannedPlayerName: string + matchedPlayerId: string + matchedPlayerName: string + matchedPlayerHasActiveBan: boolean + matchedComponents: MatchedComponent[] +} + +interface BanEvasionResponse { + matches: BanEvasionMatch[] +} + +interface PlatformCoverage { + platform: string + components: string[] +} + +interface HardwareIdCoverageResponse { + byPlatform: PlatformCoverage[] +} + +// 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'], + }, + { + 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() { + 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, + }) + + // Separate call from the matches above - this is a different question + // (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: coverage, isLoading: coverageLoading } = + useQuery({ + queryKey: ['admin-hardware-id-coverage'], + queryFn: () => apiFetch('/webadmin/hardware-id-coverage'), + enabled: canAccess, + }) + + if (pending) { + return
Loading…
+ } + if (!canAccess) return null + + const matches = data?.matches ?? [] + + return ( +
+
+

Ban Evasion

+

+ 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. +

+
+ + + + + ID Types Captured, by Platform + + + + {coverageLoading ? ( +

Loading…

+ ) : (coverage?.byPlatform ?? []).length === 0 ? ( +

Nothing captured yet.

+ ) : ( +
+ {coverage?.byPlatform.map((p) => ( +
+ {p.platform} +
+ {p.components.map((c) => ( + + {c} + + ))} +
+
+ ))} +
+ )} +
+
+ + + + Component reliability + + + {COMPONENT_RELIABILITY.map((tier) => ( +
+ {tier.label}: + {tier.components.map((c) => ( + + {c} + + ))} +
+ ))} +
+
+ + + + Matches + + + {isLoading ? ( +

Loading…

+ ) : matches.length === 0 ? ( +

+ No currently-banned player shares a hardware/device identifier + with another account. +

+ ) : ( + + + + Banned player + Matched account + Shared components (hashed value) + Count + + + + {matches.map((m) => ( + + + + {m.bannedPlayerName} + + + + + {m.matchedPlayerName} + + {m.matchedPlayerHasActiveBan && ( + + also banned + + )} + + +
+ {m.matchedComponents.map((c) => ( +
+ + {c.componentName} + + + {c.componentHash} + +
+ ))} +
+
+ {m.matchedComponents.length} +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/app/(home)/admin/users/components/hardware-fingerprint-card.tsx b/apps/web/src/app/(home)/admin/users/components/hardware-fingerprint-card.tsx new file mode 100644 index 00000000..b9b8294c --- /dev/null +++ b/apps/web/src/app/(home)/admin/users/components/hardware-fingerprint-card.tsx @@ -0,0 +1,103 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useState } from 'react' + +export interface HardwareFingerprint { + componentName: string + componentHash: string + platform: string + firstSeenAt: string + lastSeenAt: string +} + +// Collapsed by default - these are 64-char hashes, not something to dump +// on screen the instant a player is selected. Raw hash values, not just +// component names - deliberate, see launcher-integrity.gateway.ts's own +// comment on why this is safe to show admin/moderator staff (a one-way +// HMAC-SHA256 output, not a raw hardware identifier). +export function HardwareFingerprintCard({ + fingerprints, +}: { + fingerprints: HardwareFingerprint[] +}) { + const [expanded, setExpanded] = useState(false) + + return ( + + + + Hardware Fingerprint + + ({fingerprints.length} ID{fingerprints.length === 1 ? '' : 's'}{' '} + captured) + + + + + {expanded && ( + + {fingerprints.length === 0 ? ( +

+ No hardware/device IDs captured for this player. +

+ ) : ( + + + + Component + Hashed value + Platform + First seen + Last seen + + + + {fingerprints.map((f) => ( + + + + {f.componentName} + + + + {f.componentHash} + + + {f.platform} + + + {new Date(f.firstSeenAt).toLocaleDateString()} + + + {new Date(f.lastSeenAt).toLocaleDateString()} + + + ))} + +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/app/(home)/admin/users/page.tsx b/apps/web/src/app/(home)/admin/users/page.tsx index f8cf7886..4e5ee3f6 100644 --- a/apps/web/src/app/(home)/admin/users/page.tsx +++ b/apps/web/src/app/(home)/admin/users/page.tsx @@ -1,13 +1,16 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useState } from 'react' import type { BanType, Privilege } from '@bmp/types' import { apiFetch } from '@/lib/api' 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' @@ -44,16 +47,32 @@ interface Ban { interface PlayerDetailResponse { player: AdminPlayer bans: Ban[] + hardwareFingerprints: HardwareFingerprint[] } +// 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() const qc = useQueryClient() const canAccess = isAdmin || isModerator const [search, setSearch] = useState('') const [page, setPage] = useState(1) - const [selectedId, setSelectedId] = useState(null) + // Seeded from ?playerId=... (the Ban Evasion page links here this way) + // rather than requiring a manual search - see admin/ban-evasion/page.tsx. + const [selectedId, setSelectedId] = useState(() => searchParams.get('playerId')) const [banReason, setBanReason] = useState('') const [banType, setBanType] = useState('chat') const [banExpiry, setBanExpiry] = useState('') @@ -77,7 +96,25 @@ export default function AdminUsersPage() { 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. + // The dataset (currently-banned players and their matches) is small + // admin-tooling data, so filtering this client-side rather than adding a + // per-player query param to the endpoint is fine. + const { data: banEvasionResp } = useQuery<{ + matches: { bannedPlayerId: string; matchedPlayerId: string }[] + }>({ + queryKey: ['admin-ban-evasion'], + queryFn: () => apiFetch('/webadmin/ban-evasion'), + enabled: !!selectedId, + }) + const banEvasionMatchCount = (banEvasionResp?.matches ?? []).filter( + (m) => m.bannedPlayerId === selectedId || m.matchedPlayerId === selectedId, + ).length const refresh = () => { qc.invalidateQueries({ queryKey: ['admin-player-detail', selectedId] }) @@ -151,6 +188,14 @@ export default function AdminUsersPage() {

{detail.id}

+ {banEvasionMatchCount > 0 && ( + + + {banEvasionMatchCount} possible hardware match{banEvasionMatchCount === 1 ? '' : 'es'} - view in Ban Evasion + + + )} + Bans @@ -216,6 +261,8 @@ export default function AdminUsersPage() { )} + + )} diff --git a/apps/web/src/app/_components/mobile-menu.tsx b/apps/web/src/app/_components/mobile-menu.tsx index 544b2d98..63ec12c3 100644 --- a/apps/web/src/app/_components/mobile-menu.tsx +++ b/apps/web/src/app/_components/mobile-menu.tsx @@ -18,6 +18,7 @@ import { Puzzle, Settings, Shield, + ShieldAlert, Trophy, User, } from 'lucide-react' @@ -175,6 +176,13 @@ function MobileAdminSection({ onClose }: { onClose: () => void }) { > Users & Bans + } + onClick={onClose} + > + Ban Evasion + } diff --git a/apps/web/src/app/_components/nav-auth.tsx b/apps/web/src/app/_components/nav-auth.tsx index 4a7b885b..0f7af1c8 100644 --- a/apps/web/src/app/_components/nav-auth.tsx +++ b/apps/web/src/app/_components/nav-auth.tsx @@ -42,6 +42,9 @@ export function NavAuth() { Users & Bans + + Ban Evasion + Lobbies diff --git a/apps/web/src/app/notice/page.tsx b/apps/web/src/app/notice/page.tsx index 8f04fa14..96fa76ce 100644 --- a/apps/web/src/app/notice/page.tsx +++ b/apps/web/src/app/notice/page.tsx @@ -45,6 +45,9 @@ export default function NoticePage() {

Gameplay logs:

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.