enforce steam ticket validation

This commit is contained in:
Devin Zuczek
2026-07-10 17:40:49 -04:00
parent 2a1e9d5d0a
commit aad184181b
15 changed files with 540 additions and 43 deletions
@@ -0,0 +1,8 @@
-- Link accounts to their platform-native identity (e.g. a SteamID64 for platform 0)
-- and index it so /cachedlogin/forplatformid can look accounts up by platform id.
-- platformId lives in the JSON blob (stored as a string — a SteamID64 exceeds 2^53
-- and would lose precision as a number); this exposes it as an indexed generated
-- column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
ALTER TABLE accounts ADD COLUMN platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_accounts_platform_id ON accounts (platform_id);
@@ -0,0 +1,7 @@
-- Rename the `accounts` table to `account`, matching the singular naming of the
-- other tables (`room`, `interaction`, `room_instance`, `image`, `club`). SQLite
-- carries the generated columns and the idx_accounts_* indexes over to the renamed
-- table automatically, so this is the whole change. SCHEMA_DDL in @repo/domain's
-- accounts-db.ts already creates `account`.
ALTER TABLE accounts RENAME TO account;
+118 -10
View File
@@ -3,9 +3,12 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
createAccount,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getPasswordHash,
RoomInstanceType,
setLastLoginTime,
setPasswordHash,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -13,7 +16,9 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo
import { hashPassword, verifyPassword } from './password'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Account } from '@repo/domain'
import type { Context } from 'hono'
import type { App } from './context'
@@ -105,6 +110,22 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the platform_auth ticket) is the credential for a cached login — no prompt.
*/
function toCachedLogin(account: Account) {
return {
platform: account.platform ?? 0,
platformId: account.platformId ?? '',
accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false,
}
}
const app = new Hono<App>()
.use(
'*',
@@ -122,19 +143,36 @@ const app = new Hono<App>()
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
.get('/eac/challenge', (c) => c.text(`"AA=="`))
// Cached logins for a platform id. No CachedLogins storage yet, so there's never
// a cached account — return []. The client then goes through a fresh login /
// create_account instead of auto-logging into a stub account.
.get('/cachedlogin/forplatformid/:platform/:id', (c) => {
// Cached logins for a platform id — the accounts linked to this platform-native
// id, so the client can offer them on the login screen (and post one back as a
// cached_login grant). No linked account → [], and the client falls back to a
// fresh login / create_account.
.get('/cachedlogin/forplatformid/:platform/:id', async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
// TODO: query CachedLogins once they're persisted.
return c.json([])
const platformInt = Number.parseInt(platform, 10)
const accounts = await getAccountsByPlatformId(c.env.DB, id)
return c.json(
accounts
.filter((a) => Number.isNaN(platformInt) || (a.platform ?? 0) === platformInt)
.map(toCachedLogin)
)
})
// Bulk cached-login lookup by platform id (friends resolution). The client
// POSTs repeated `id=` params on the auth host; no DB → no matches → [].
.post('/cachedlogin/forplatformids', (c) => c.json([]))
// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
// repeated `id=` params on the auth host; resolve each to its linked accounts.
.post('/cachedlogin/forplatformids', async (c) => {
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
}
return c.json(out)
})
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => {
@@ -149,6 +187,40 @@ const app = new Hono<App>()
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== 0) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' },
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
}
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
@@ -163,7 +235,16 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
lastLoginTime: new Date().toISOString(),
})
accountId = String(account.accountId)
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
@@ -184,6 +265,32 @@ const app = new Hono<App>()
accountId = String(refreshed.accountId)
platform = refreshed.platform
platformId = refreshed.platformId
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (
!account ||
!account.platformId ||
account.platformId !== platformId ||
account.platform !== platformInt
) {
return c.json(
{ error: 'invalid_grant', error_description: 'no linked account for this platform identity' },
400
)
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
@@ -214,6 +321,7 @@ const app = new Hono<App>()
)
}
accountId = String(resolvedId)
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
}
const accessToken = await generateToken(
+201
View File
@@ -0,0 +1,201 @@
/**
* Offline verification of a Steam `platform_auth` ticket, Worker-native.
*
* A Steam login posts `platform_auth = {"Ticket":"<hex>","AppId":"471710"}`. The
* ticket's ownership section is signed by Steam's "System" RSA key (RSA-SHA1), so
* we can verify it OFFLINE — no publisher Web API key, no network — and trust the
* SteamID64 it carries. The auth worker binds/authorizes accounts against THAT
* SteamID rather than the unauthenticated client-supplied `platform_id` field, so
* only the Steam user who owns the account can log into it.
*
* The byte layout and signed-region boundaries follow DoctorMcKay's steam-appticket
* (github.com/DoctorMcKay/node-steam-appticket). We reimplement the parse + verify
* here because that package reads its key via `fs` and verifies via `node:crypto`,
* neither of which is available in workerd — we use `crypto.subtle` instead.
*/
/**
* Steam "System" public RSA key (SPKI DER, base64), from @doctormckay/steam-crypto's
* `system.pem`. Steam signs every app-ownership ticket with the matching private key.
*/
const STEAM_SYSTEM_PUBLIC_KEY_SPKI =
'MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T' +
'2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY' +
'gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6' +
'6WOiu4gZKODnFMBCiQIBEQ=='
let cachedKey: Promise<CryptoKey> | null = null
function steamPublicKey(): Promise<CryptoKey> {
cachedKey ??= crypto.subtle.importKey(
'spki',
Uint8Array.from(atob(STEAM_SYSTEM_PUBLIC_KEY_SPKI), (ch) => ch.charCodeAt(0)),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
false,
['verify']
)
return cachedKey
}
/** Decode a hex string to bytes, or null when it isn't valid hex. */
function hexToBytes(hex: string): Uint8Array | null {
if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null
const out = new Uint8Array(hex.length / 2)
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return out
}
/** Little-endian cursor over a ticket buffer. */
class Reader {
private pos = 0
constructor(private readonly view: DataView) {}
get offset(): number {
return this.pos
}
skip(n: number): void {
this.pos += n
}
u16(): number {
const v = this.view.getUint16(this.pos, true)
this.pos += 2
return v
}
u32(): number {
const v = this.view.getUint32(this.pos, true)
this.pos += 4
return v
}
u64(): bigint {
const v = this.view.getBigUint64(this.pos, true)
this.pos += 8
return v
}
}
/** Parsed fields plus the byte range covered by the RSA signature. */
export interface SteamTicket {
steamId: string
appId: number
/** Ownership-ticket expiry, ms since epoch (0 when absent). */
expiresAt: number
/** Signed region `[start, end)` and the 128-byte signature over it. */
signedStart: number
signedEnd: number
signature: Uint8Array
}
/**
* Parse a Steam app/session ticket into its fields WITHOUT verifying the signature.
* Returns null when the buffer isn't a well-formed, signed ticket.
*/
export function parseSteamTicket(buf: Uint8Array): SteamTicket | null {
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
const r = new Reader(view)
const limit = buf.byteLength
try {
const initialLength = r.u32()
if (initialLength === 20) {
// Full ticket: GC token + session header precede the ownership ticket.
r.skip(8) // gcToken
r.skip(8) // steamID (read from the ownership section below instead)
r.u32() // tokenGenerated
if (r.u32() !== 24) return null // session header length
r.skip(8) // unknown1, unknown2
r.u32() // session external IP
r.skip(4) // filler
r.u32() // client connection time
r.u32() // client connection count
if (r.u32() + r.offset !== limit) return null // ownership-section length check
} else {
r.skip(-4) // bare ownership ticket — rewind the length we just read
}
const ownershipTicketOffset = r.offset
const ownershipTicketLength = r.u32() // includes itself
if (
ownershipTicketOffset + ownershipTicketLength !== limit &&
ownershipTicketOffset + ownershipTicketLength + 128 !== limit
) {
return null
}
r.u32() // version
const steamId = r.u64().toString()
const appId = r.u32()
r.u32() // ownership external IP
r.u32() // ownership internal IP
r.u32() // flags
r.u32() // generated
const expiresAt = r.u32() * 1000
const licenseCount = r.u16()
for (let i = 0; i < licenseCount; i++) r.u32()
const dlcCount = r.u16()
for (let i = 0; i < dlcCount; i++) {
r.u32() // dlc appID
const dlcLicenseCount = r.u16()
for (let j = 0; j < dlcLicenseCount; j++) r.u32()
}
r.u16() // reserved
if (r.offset + 128 !== limit) return null // require a signature
return {
steamId,
appId,
expiresAt,
signedStart: ownershipTicketOffset,
signedEnd: ownershipTicketOffset + ownershipTicketLength,
signature: buf.subarray(r.offset, r.offset + 128),
}
} catch {
return null // ran off the end / malformed
}
}
/** Verify a parsed ticket's ownership signature against Steam's System public key. */
export async function verifySteamTicketSignature(
buf: Uint8Array,
ticket: SteamTicket
): Promise<boolean> {
return crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
await steamPublicKey(),
ticket.signature,
buf.subarray(ticket.signedStart, ticket.signedEnd)
)
}
/** The trustworthy identity proven by a verified Steam ticket. */
export interface VerifiedSteamIdentity {
steamId: string
appId: number
}
/**
* Verify a Steam `platform_auth` payload and return the SteamID64 it proves, or
* null when the payload is missing/malformed, expired, or its signature doesn't
* verify. Only ever returns a SteamID that Steam itself signed. `now` (ms since
* epoch, defaulting to the current time) is the instant expiry is checked against;
* it's a parameter so tests can pin it to a captured ticket's validity window.
*/
export async function verifySteamTicket(
platformAuth: string,
now: number = Date.now()
): Promise<VerifiedSteamIdentity | null> {
let ticketHex: string
try {
const parsed = JSON.parse(platformAuth) as { Ticket?: unknown }
if (typeof parsed.Ticket !== 'string') return null
ticketHex = parsed.Ticket
} catch {
return null
}
const buf = hexToBytes(ticketHex)
if (!buf) return null
const ticket = parseSteamTicket(buf)
if (!ticket) return null
if (ticket.expiresAt !== 0 && ticket.expiresAt < now) return null // expired
if (!(await verifySteamTicketSignature(buf, ticket))) return null
return { steamId: ticket.steamId, appId: ticket.appId }
}
+76 -3
View File
@@ -36,7 +36,7 @@ beforeAll(async () => {
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
for (const id of [42, 77]) {
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
@@ -116,6 +116,79 @@ describe('auth worker routes', () => {
expect(await res.json()).toEqual([])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
`grant_type=create_account&platform=${platform}&platform_id=whoever`
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
`grant_type=cached_login&account_id=42&platform=${platform}&platform_id=whoever`
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
}
)
test('Steam create_account requires a valid platform_auth ticket', async () => {
// platform=0 (Steam) with no verifiable ticket must not bind the spoofable
// platform_id field — it's rejected outright.
const res = await postToken('grant_type=create_account&platform=0&platform_id=76561197962463211')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Steam cached_login requires a valid platform_auth ticket', async () => {
const res = await postToken(
'grant_type=cached_login&account_id=42&platform=0&platform_id=76561197962463211'
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
const steamId = '76561197962463299'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 31380,
username: 'SteamPlayer',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-07-09T21:20:31.419Z',
})
)
.run()
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 0,
platformId: steamId,
accountId: 31380,
lastLoginTime: '2026-07-09T21:20:31.419Z',
requirePassword: false,
},
])
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
@@ -179,7 +252,7 @@ describe('auth worker routes', () => {
test('POST /connect/token refuses login to an account with no password set', async () => {
// Account 999 exists but never set a password — it has no credential to verify,
// so login by id alone is refused (this is the closed takeover hole).
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'NoPass' }))
.run()
const res = await postToken('account_id=999&password=anything')
@@ -228,7 +301,7 @@ describe('auth worker routes', () => {
const sub = Number.parseInt(payload.sub as string, 10)
expect(sub).toBeGreaterThanOrEqual(2)
// The account exists in the DB with an auto-assigned (non-default) username.
const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = ?1')
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(sub)
.first<{ data: string }>()
expect(row).not.toBeNull()
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest'
import {
parseSteamTicket,
verifySteamTicket,
verifySteamTicketSignature,
} from '../../steam-ticket'
// A real Steam session ticket captured from a live login: ownership section signed
// by Steam, steamID64 76561197962463211, appID 471710 (Rec Room on Steam).
const TICKET_HEX =
'14000000D6BCAD355A77A1C1EB872100010010012522516A1800000001000000020000005FD1B15ADC400B3B069BB24D80010000B20000003200000004000000EB872100010010019E3207002239346C2101A8C00000000008CE4B6A887D676A0100DF97010000000000596381B1BB1AA2197EF13223E62CCE95AAEC0BB48EF50FF74AE88A4D50CF17BEF363A35307C917E3B4173B54B293D3BD8A270DF25C7713E4FB5AF170FBC531DBE76D86DF1BBE8F7EE91D2A357AA7AAEDBFA4A0E5BC6F1F541C98C5C682E685357722CB82C70BEB6F4152A2CD142541BF130CFD6601D75B1418BE58E5B3DA2CCE'
const hex = (s: string) => Uint8Array.from(s.match(/../g)!.map((b) => Number.parseInt(b, 16)))
describe('steam-ticket', () => {
test('parses the ticket fields', () => {
const t = parseSteamTicket(hex(TICKET_HEX))
expect(t).not.toBeNull()
expect(t!.steamId).toBe('76561197962463211')
expect(t!.appId).toBe(471710)
expect(t!.signature).toHaveLength(128)
})
test('verifies the Steam-signed ownership signature (real key, WebCrypto)', async () => {
const buf = hex(TICKET_HEX)
const t = parseSteamTicket(buf)!
expect(await verifySteamTicketSignature(buf, t)).toBe(true)
})
test('rejects a ticket whose signed bytes were tampered with', async () => {
const buf = hex(TICKET_HEX)
const t = parseSteamTicket(buf)!
buf[t.signedStart + 4] ^= 0xff // flip a byte inside the signed region
expect(await verifySteamTicketSignature(buf, t)).toBe(false)
})
test('verifySteamTicket returns the proven identity for a valid, unexpired ticket', async () => {
const { expiresAt } = parseSteamTicket(hex(TICKET_HEX))!
const payload = JSON.stringify({ Ticket: TICKET_HEX, AppId: '471710' })
// Pin `now` to just inside the ticket's validity window so the test is durable.
expect(await verifySteamTicket(payload, expiresAt - 1000)).toEqual({
steamId: '76561197962463211',
appId: 471710,
})
})
test('verifySteamTicket rejects an expired ticket', async () => {
const { expiresAt } = parseSteamTicket(hex(TICKET_HEX))!
const payload = JSON.stringify({ Ticket: TICKET_HEX, AppId: '471710' })
expect(await verifySteamTicket(payload, expiresAt + 1000)).toBeNull()
})
test('verifySteamTicket returns null for malformed payloads', async () => {
expect(await verifySteamTicket('not json')).toBeNull()
expect(await verifySteamTicket('{}')).toBeNull()
expect(await verifySteamTicket(JSON.stringify({ Ticket: 'zzzz' }))).toBeNull()
expect(await verifySteamTicket(JSON.stringify({ Ticket: '1400' }))).toBeNull()
})
})