diff --git a/.env.example b/.env.example index 5c239b1..9e229f9 100644 --- a/.env.example +++ b/.env.example @@ -62,8 +62,13 @@ RECFLARE_DOMAIN=rec.example.com # ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus # network shares one address. This is the one to raise (or zero out) if real players # report being locked out. +# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash, +# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the +# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script. +# Signups that post no device id (the website's) aren't counted. # RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3 # RECFLARE_MAX_ACCOUNTS_PER_IP=3 +# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3 # How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a # comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 5ff53d4..2b13552 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { z } from 'zod' import { + countAccountsByDeviceId, countAccountsBySignupIp, createAccount, GAME_VERSION, @@ -116,24 +117,32 @@ const FAKE_OCULUS_CACHED_LOGIN = { * Signup caps, enforced on create_account only (never on login — an existing account * always stays reachable, however many accounts its owner has since accumulated). * - * Two independent arms, because they fail in opposite ways: + * Three independent arms, because they fail in different ways: * - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be * spoofed and can't be reset by changing networks. Only binds on a platform * create_account — the password/anonymous path has no platform identity to count, - * so the IP arm is the only thing standing between it and bulk signup. + * so the other two arms are all that stands between it and bulk signup. * - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many * legitimate players behind one address, so this WILL be the arm that produces false * positives. It counts `signupIp` (immutable), so an abuser can't reset their own - * count by hopping networks. + * count by hopping networks — but a VPN gives them a fresh counter per exit, which + * is exactly how one player made 150 accounts, six per address, in a weekend. + * - Per client device id. Narrower than an IP (a household shares an address, not an + * install) and stable across VPN hops, which is the hole in the IP arm it covers. It + * counts the last-seen `deviceId`, which a login moves; see the field's notes in + * accounts-db for why an immutable copy isn't worth keeping. Client-chosen and + * unverified, so it catches a stock client, not a script that fabricates ids. Skipped + * when the signup posts no `device_id` (the website's don't). * - * These are the defaults. An operator overrides either arm with the matching worker var - * (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and - * setting one to 0 disables that arm entirely — which a small private server that trusts - * everyone it invites will want, and which the IP arm in particular is worth reaching for - * if a shared network is being locked out. + * These are the defaults. An operator overrides any arm with the matching worker var + * (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID` + * in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a + * small private server that trusts everyone it invites will want, and which the IP arm in + * particular is worth reaching for if a shared network is being locked out. */ const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3 const DEFAULT_MAX_ACCOUNTS_PER_IP = 3 +const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3 /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 @@ -608,9 +617,10 @@ const app = new Hono() '', '**`create_account`** — mints a new account with an auto-assigned random username', '(players do not pick one initially) and places it in the Orientation room. A posted', - '`password` becomes the login credential. Subject to two independent signup caps,', - 'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /', - '`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a', + '`password` becomes the login credential. Subject to three independent signup caps,', + 'per verified platform id, per signup IP and per client `device_id`', + '(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;', + 'any disabled by setting it to 0). If it asserts a', '`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`', 'must verify.', '', @@ -872,7 +882,7 @@ const app = new Hono() // Signup caps. Checked before minting anything, so a rejected signup leaves no // account behind. Each arm is skipped when it's disabled (var <= 0) or when its - // identity is unknown (no verified platform id / no client IP) — an unattributable + // identity is unknown (no verified platform id / no client IP / no device id) — an unattributable // signup can't be counted against anyone, and lumping them together would lock out // real players. The disabled check comes first so a disabled arm costs no D1 read. const maxPerPlatformId = intVar( @@ -880,6 +890,10 @@ const app = new Hono() DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID ) const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) + const maxPerDeviceId = intVar( + c.env.MAX_ACCOUNTS_PER_DEVICE_ID, + DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID + ) if ( maxPerPlatformId > 0 && verifiedPlatformId !== null && @@ -914,6 +928,20 @@ const app = new Hono() 400 ) } + if ( + maxPerDeviceId > 0 && + deviceId !== '' && + (await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId + ) { + logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp }) + return c.json( + { + error: 'invalid_grant', + error_description: 'too many accounts created from this device', + }, + 400 + ) + } // Bind the platform identity ONLY when the platform proved it (a Steam ticket or // a Meta-validated nonce). A password/anonymous create_account (no platform) diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 1509a89..6698e6c 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -26,6 +26,7 @@ export type Env = SharedHonoEnv & { // read them through `intVar`, never as a bare number. MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number MAX_ACCOUNTS_PER_IP?: string | number + MAX_ACCOUNTS_PER_DEVICE_ID?: string | number /** * Which linked arms a ban is enforced through, as a comma-separated list out of `ip` * and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 4226ed1..dc6fb33 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1035,6 +1035,47 @@ describe('auth worker routes', () => { expect(other.status).toBe(200) }) + test('POST /connect/token caps the accounts created from one device', async () => { + // One install, many networks: the VPN-hopping signup the IP cap can't see. Each + // signup posts a fresh IP so only the device arm can be what refuses it. + const deviceId = 'dev-capped-install' + for (let i = 0; i < 3; i++) { + const ok = await postToken( + `grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`, + `198.51.100.${40 + i}` + ) + expect(ok.status).toBe(200) + } + // The 4th signup from that device is refused — the cap is 3. + const capped = await postToken( + `grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`, + '198.51.100.43' + ) + expect(capped.status).toBe(400) + expect(capped.json.error).toBe('invalid_grant') + expect(capped.json.error_description).toMatch(/device/) + + // Another device is unaffected, and a signup that posts no device id (the website's) + // can't be attributed to one, so the arm skips it rather than lumping it in. + const other = await postToken( + 'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2', + '198.51.100.44' + ) + expect(other.status).toBe(200) + const deviceless = await postToken( + 'grant_type=create_account&platform_id=steam-devcap5', + '198.51.100.45' + ) + expect(deviceless.status).toBe(200) + + // The capped device can still LOG IN to what it has — the cap is on signup only. + const login = await postToken( + `grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`, + '198.51.100.46' + ) + expect(login.status).toBe(200) + }) + test('the signup caps come from vars, and 0 disables an arm', async () => { // The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in // auth.app.ts is only the fallback. `env` is shared by every test in this file, so the @@ -1056,6 +1097,24 @@ describe('auth worker routes', () => { } finally { env.MAX_ACCOUNTS_PER_IP = original } + + // The device arm reads its own var the same way. + const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID + try { + env.MAX_ACCOUNTS_PER_DEVICE_ID = 1 + const device = '&device_id=dev-var-install&device_class=2' + const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`) + expect(first.status).toBe(200) + const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`) + expect(capped.status).toBe(400) + expect(capped.json.error_description).toMatch(/device/) + + env.MAX_ACCOUNTS_PER_DEVICE_ID = 0 + const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`) + expect(uncapped.status).toBe(200) + } finally { + env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice + } }) test('POST /connect/token does not cap logins, only signups', async () => { diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 875f4e8..aab20b2 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -76,14 +76,18 @@ export interface Account { * * Kept for ban EVASION: an account logging in from the same install as a banned one is * the sharpest of the linked arms after a proven platform identity, and much narrower - * than the IP arm (a household shares an address, not an install). Nothing consumes it - * yet — see `getAccountsByDeviceId`, and the arms in the api worker's bans-db, which - * this is not one of yet. + * than the IP arm (a household shares an address, not an install). The per-device + * signup cap counts it (`countAccountsByDeviceId`); the ban-evasion arms in the api + * worker's bans-db do not yet. * * ONE value, overwritten each login: this is the account's LAST-SEEN device, not a * history of every device it has used. So evasion matching on it will catch a player * who evades from the install they were last banned on, and miss one who has since - * logged the banned account in somewhere else. + * logged the banned account in somewhere else — and the signup cap can be walked back + * the same way, by logging old accounts in from another install. That costs the + * abuser a login per freed slot, which is the point; and since the id is client-chosen, + * a scripted signup can fabricate one anyway, so an immutable signup-time copy (as the + * IP arm keeps in `signupIp`) would buy nothing here. */ deviceId?: string /** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */ @@ -301,6 +305,29 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P return parseAll(results) } +/** + * How many accounts were last seen on a given client device — the count the per-device + * signup cap ("no more than N accounts per install") is enforced against. Counts the + * last-seen `deviceId`, the only device the account records (see the field's notes for + * why that is enough here). + * + * An empty id counts 0, as `countAccountsBySignupIp` does for an empty ip: a signup that + * posts no `device_id` (the website's, for one) can't be attributed to an install, and + * lumping every such account together would lock out real players. + * + * Narrower than an IP — a household shares an address, not an install — but still an + * unverified client value, so a cap here catches the stock client making accounts, not + * a script that fabricates ids. + */ +export async function countAccountsByDeviceId(db: D1Database, deviceId: string): Promise { + if (deviceId === '') return 0 + const row = await db + .prepare("SELECT COUNT(*) AS n FROM account WHERE json_extract(data, '$.deviceId') = ?1") + .bind(deviceId) + .first<{ n: number }>() + return row?.n ?? 0 +} + /** Record the account's most recent successful login time (ISO-8601). */ export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise { await db