Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/features/webadmin/ban-evasion.route.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion apps/server/src/features/webadmin/players.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/features/webadmin/webadmin.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 10 additions & 7 deletions apps/server/src/infrastructure/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand All @@ -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(),
Expand Down
208 changes: 208 additions & 0 deletions apps/server/src/infrastructure/gateways/launcher-integrity.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { and, eq, gt, isNull, ne, or, sql } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import type {
ChallengeKind,
LauncherIntegrityFailureReason,
} from '../../shared/types/index.js'
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,
Expand Down Expand Up @@ -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<void> {
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<BanEvasionMatch[]> {
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<string, BanEvasionMatch>()
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<PlayerHardwareFingerprint[]> {
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<HardwareIdCoverage> {
const rows = await db
.selectDistinct({
platform: playerHardwareFingerprints.platform,
componentName: playerHardwareFingerprints.componentName,
})
.from(playerHardwareFingerprints)

const byPlatform = new Map<string, Set<string>>()
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)),
}
}
6 changes: 6 additions & 0 deletions apps/server/src/infrastructure/gateways/player.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -226,6 +227,11 @@ export async function purgeExpiredDeletedPlayerHashes(): Promise<number> {
.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
Expand Down
Loading