diff --git a/components/entitlementStrategies.ts b/components/entitlementStrategies.ts index d3fd1ad2..9152dd03 100644 --- a/components/entitlementStrategies.ts +++ b/components/entitlementStrategies.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { AxiosError, AxiosResponse } from "axios" +import axios, { AxiosError, AxiosResponse } from "axios" import { log, LogLevel } from "./loggingInterop" import { userAuths } from "./officialServerAuth" import { @@ -27,7 +27,18 @@ import { STEAM_NAMESPACE_2016, } from "./platformEntitlements" import { GameVersion } from "./types/types" -import { getRemoteService } from "./utils" +import { + AppTicket, + getRemoteService, + parseAppTicket, + PEACOCKVERSTRING, +} from "./utils" +import { getFlag } from "./flags" + +// An in-memory cache of valid Steam ownership ticket hashes (they're valid for up to 21 days) +// For most users, this won't provide any benefit since they'll be restarting Peacock often, +// but this is more here for those running it 24/7 on a server somewhere. +const STEAM_TICKET_CACHE: Set = new Set() /** * The base class for an entitlement strategy. @@ -39,6 +50,12 @@ abstract class EntitlementStrategy { accessToken: string, userId: string, ): string[] | Promise + + abstract get( + clientToken: string, + identity: string, + steamId: string, + ): string[] | Promise } /** @@ -56,6 +73,281 @@ export class EpicH3Strategy extends EntitlementStrategy { } } +/** + * Provider for any Steam-based game using the ISteamUserAuth API. + * + * @internal + */ +type SteamAuthMethod = "OFFICIAL" | "BACKEND" | "STEAM" | "STEAM_STRICT" +type SteamAuthResult = + | { + success: true + steamId: string + entitlements: string[] + } + | { + success: false + code: number + error: string + } +type SteamAuthResponse = { + response: { + error?: { + errorcode: number + errordesc: string + } + params?: { + result: string + steamid: string + ownersteamid: string + vacbanned: boolean + publisherbanned: boolean + } + } +} +type SteamAuthBackendResponse = + | { + success: true + steam_id: string + entitlements: string[] + } + | { + success: false + error: string + } + +export class SteamStrategy extends EntitlementStrategy { + private readonly _apiKey: string = getFlag("steamApiKey") as SteamAuthMethod + public readonly isValid: boolean = false + + constructor() { + super() + + const method = getFlag("steamAuthenticationMethod") as SteamAuthMethod + + switch (method) { + case "BACKEND": { + const host = getFlag("leaderboardsHost") as string + + if (!host) { + log( + LogLevel.WARN, + "steamAuthenticationMethod is set to 'BACKEND' but 'leaderboardsHost' is null or empty - using official!", + "SteamStrategy", + ) + break + } + + this.isValid = true + break + } + case "STEAM": + case "STEAM_STRICT": { + if (!this._apiKey) { + log( + LogLevel.WARN, + `steamAuthenticationMethod is set to '${method}' but 'steamApiKey' is null or empty${method !== "STEAM_STRICT" ? " - using official" : ""}!`, + "SteamStrategy", + ) + break + } + + this.isValid = true + break + } + case "OFFICIAL": + break + } + } + + private async _getFromBackend( + clientToken: string, + identity: string, + ): Promise { + try { + const host = getFlag("leaderboardsHost") as string + const resp = await axios.post( + `${host}/peacock/steam/validate_ticket`, + { + ticket: clientToken, + identity, + }, + { + headers: { + "Peacock-Version": PEACOCKVERSTRING, + }, + validateStatus: (status) => + status === 400 || (status >= 200 && status < 300), + }, + ) + + if (resp.status !== 200 && resp.status !== 400) { + return { + success: false, + code: resp.status, + error: `${resp.statusText}`, + } + } + + const data = resp.data as SteamAuthBackendResponse + + if (!data.success) { + return { + success: false, + code: resp.status, + error: data.error, + } + } + + return { + success: true, + steamId: data.steam_id, + entitlements: data.entitlements, + } + } catch (error) { + if (error instanceof AxiosError) { + return { + success: false, + code: error.response?.status ?? 400, + error: `${error.response?.statusText}`, + } + } else { + return { + success: false, + code: 400, + error: `${error}`, + } + } + } + } + + private async _getFromSteam( + clientToken: string, + ticket: AppTicket, + identity: string, + ): Promise { + // We already check this before calling, but it's just for sanity. + if (!ticket?.valid) { + return { + success: false, + code: 400, + error: "Invalid app ticket.", + } + } + + try { + const resp = await axios( + "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1", + { + params: { + key: this._apiKey, + appid: ticket.appId, + ticket: clientToken, + identity, + }, + }, + ) + + if (resp.status !== 200) { + return { + success: false, + code: resp.status, + error: `${resp.statusText}`, + } + } + + const data = resp.data as SteamAuthResponse + + if (data.response.error) { + return { + success: false, + code: data.response.error.errorcode, + error: `${data.response.error.errordesc}`, + } + } + + if (data.response.params!.result !== "OK") { + return { + success: false, + code: 200, + error: `${data.response.params!.result}`, + } + } + + ticket.dlc.unshift(ticket.appId) + return { + success: true, + steamId: data.response.params!.steamid, + entitlements: ticket.dlc, + } + } catch (error) { + if (error instanceof AxiosError) { + return { + success: false, + code: error.response?.status ?? 400, + error: `${error.response?.statusText}`, + } + } else { + return { + success: false, + code: 400, + error: `${error}`, + } + } + } + } + + // @ts-expect-error There are two functions we can overload + override async get( + clientToken: string, + identity: string, + steamId: string, + ): Promise { + if (!this.isValid) return [] + + const ticket = parseAppTicket(Buffer.from(clientToken, "hex")) + + if (!ticket?.valid) { + log(LogLevel.WARN, "Invalid ticket.", "SteamStrategy") + return [] + } + + if (STEAM_TICKET_CACHE.has(ticket.hash)) { + ticket.dlc.unshift(ticket.appId) + return ticket.dlc + } + + const authMethod = getFlag( + "steamAuthenticationMethod", + ) as SteamAuthMethod + const res = await (authMethod === "BACKEND" + ? this._getFromBackend(clientToken, identity) + : this._getFromSteam(clientToken, ticket, identity)) + + if (!res.success) { + log( + LogLevel.WARN, + `Failed to get entitlements from ${authMethod.split("_")[0]}. Code: ${res.code}, Error: ${res.error} `, + "SteamStrategy", + ) + return [] + } + + if (res.steamId !== steamId) { + log( + LogLevel.WARN, + `Encountered mismatched SteamID when validating authentication token! Expected: ${steamId}, Got: ${res.steamId}`, + "SteamStrategy", + ) + return [] + } + + STEAM_TICKET_CACHE.add(ticket.hash) + + return res.entitlements + } +} + /** * Provider for any game using the official servers. * @@ -63,13 +355,11 @@ export class EpicH3Strategy extends EntitlementStrategy { */ export class IOIStrategy extends EntitlementStrategy { private readonly _remoteService: string + private readonly _issuerId: string - constructor( - gameVersion: GameVersion, - private readonly issuerId: string, - ) { + constructor(gameVersion: GameVersion, issuerId: string) { super() - this.issuerId = issuerId + this._issuerId = issuerId this._remoteService = getRemoteService(gameVersion)! } @@ -88,7 +378,7 @@ export class IOIStrategy extends EntitlementStrategy { `https://${this._remoteService}.hitman.io/authentication/api/userchannel/ProfileService/GetPlatformEntitlements`, false, { - issuerId: this.issuerId, + issuerId: this._issuerId, }, ) } catch (error) { diff --git a/components/flags.ts b/components/flags.ts index 4cab8e44..f58d7758 100644 --- a/components/flags.ts +++ b/components/flags.ts @@ -130,6 +130,26 @@ export const defaultFlags: Flags = { possibleValues: ["SAVEASREQUESTED", "ONLINE", "OFFLINE"], default: "SAVEASREQUESTED", }, + steamAuthenticationMethod: { + category: "Services", + title: "steamAuthenticationMethod", + desc: "How users connecting via Steam should be authenticated. OFFICIAL = Official Servers, BACKEND = Using a separate backend server (uses leaderboardsHost), STEAM = Issues requests to Steam directly from Peacock, requires 'steamApiKey' to be set, STEAM_STRICT = Same as Steam, but will never fallback to official. OFFICIAL is used as a fallback if other methods fail.", + possibleValues: [ + "OFFICIAL", + "BACKEND", + "STEAM", + "STEAM_STRICT", + ], + default: "OFFICIAL", // TODO: Change this to BACKEND when ready. + showIngame: false, + }, + steamApiKey: { + category: "Services", + title: "Steam API Key", + desc: "The Steam API key to use when 'steamAuthenticationMethod' is set to 'STEAM' or 'STEAM_STRICT'.", + default: "", + showIngame: false, + }, liveSplit: { category: "Splitter", title: "LiveSplit", diff --git a/components/oauthToken.ts b/components/oauthToken.ts index fe2f70ef..580baa2a 100644 --- a/components/oauthToken.ts +++ b/components/oauthToken.ts @@ -39,10 +39,12 @@ import { EpicH1Strategy, EpicH3Strategy, IOIStrategy, + SteamStrategy, SteamH1Strategy, SteamH2Strategy, SteamScpcStrategy, } from "./entitlementStrategies" +import { getFlag } from "./flags" export const JWT_SECRET = PEACOCK_DEV ? "secret" @@ -51,6 +53,8 @@ export const JWT_SECRET = PEACOCK_DEV export type OAuthTokenBody = { grant_type: "external_steam" | "external_epic" | "refresh_token" steam_userid?: string + steam_clienttoken?: string + steam_identity?: string epic_userid?: string access_token: string pId?: string @@ -222,17 +226,19 @@ export async function handleOAuthToken( /* Store user auth for all games except scpc */ - if (!isScpc) { - const authContainer = new OfficialServerAuth( - gameVersion, - req.body.access_token, - ) + const authUser = async () => { + if (!isScpc) { + const authContainer = new OfficialServerAuth( + gameVersion, + req.body.access_token, + ) - log(LogLevel.DEBUG, `Setting up container with ID ${req.body.pId}.`) + log(LogLevel.DEBUG, `Setting up container with ID ${req.body.pId}.`) - userAuths.set(req.body.pId, authContainer) + userAuths.set(req.body.pId!, authContainer) - await authContainer._initiallyAuthenticate(req) + await authContainer._initiallyAuthenticate(req) + } } let userData = getUserData(req.body.pId, gameVersion) @@ -266,6 +272,10 @@ export async function handleOAuthToken( return new SteamScpcStrategy().get() } + // Authenticate user with official if we're not on H3 Steam (otherwise the token will be invalidated) + if (gameVersion !== "h3" || external_platform !== "steam") + await authUser() + if (gameVersion === "h1") { if (external_platform === "steam") { return new SteamH1Strategy().get() @@ -288,10 +298,35 @@ export async function handleOAuthToken( req.body.epic_userid!, ) } else if (external_platform === "steam") { - return await new IOIStrategy( - gameVersion, - STEAM_NAMESPACE_2021, - ).get(req.body.pId!) + let ents = await new SteamStrategy().get( + req.body.steam_clienttoken!, + req.body.steam_identity!, + req.body.steam_userid!, + ) + await authUser() + + if (ents.length === 0) { + if ( + getFlag("steamAuthenticationMethod") === "STEAM_STRICT" + ) { + log( + LogLevel.WARN, + "No entitlements returned by SteamStrategy with strict mode enabled!", + ) + return [] + } + + log( + LogLevel.WARN, + "No entitlements returned by SteamStrategy - defaulting to official!", + ) + ents = await new IOIStrategy( + gameVersion, + external_appid, + ).get(req.body.pId!) + } + + return ents } else { log(LogLevel.ERROR, "Unsupported platform.") return [] diff --git a/components/utils.ts b/components/utils.ts index e90eb047..0ade2341 100644 --- a/components/utils.ts +++ b/components/utils.ts @@ -37,6 +37,7 @@ import { getConfig, getVersionedConfig } from "./configSwizzleManager" import { compare } from "semver" import assert from "assert" import { getUnlockableById } from "./inventory" +import { createHash, createVerify } from "crypto" /** * True if the server is being run by the launcher, false otherwise. @@ -860,3 +861,106 @@ export function parsePageNumber(page: unknown): number { page = Math.max(page as number, 0) return page as number } + +// Steam Utility Functions +const STEAM_PUBLIC_KEY: string = + "-----BEGIN PUBLIC KEY-----\n" + + "MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T\n" + + "2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY\n" + + "gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6\n" + + "6WOiu4gZKODnFMBCiQIBEQ==\n" + + "-----END PUBLIC KEY-----" + +// A subset of the full app ticket data +export interface AppTicket { + steamId: string + appId: string + expiry: Date + dlc: string[] + valid: boolean + hash: string +} + +export function parseAppTicket(ticket: Buffer): AppTicket | undefined { + try { + // Adapted from the structs found at https://github.com/SteamRE/SteamKit/blob/master/Resources/Structs/steam3_appticket.hsl + const appTicket: AppTicket = { + steamId: "", + appId: "", + expiry: new Date(), + dlc: [], + valid: false, + hash: "", + } + + let offset = 0 + + if (ticket.readUInt32LE() === 20) { + // This ticket includes a GCTOKEN, skip this (extra + 4 to skip the length from OWNERSHIPSECTIONWITHSIGNATURE) + offset += 52 + 4 + } + + // Invalid app ticket + if (ticket.length <= offset) return + + const ownershipTicket = ticket.subarray( + offset, + offset + ticket.readUInt32LE(offset), + ) + + const ticketVersion = ticket.readUInt32LE((offset += 4)) + + if (ticketVersion !== 4) { + log( + LogLevel.ERROR, + `Encountered unknown ownership ticket version! Expected: 4, Got: ${ticketVersion}`, + "parseAppTicket", + ) + return + } + + appTicket.steamId = ticket.readBigUInt64LE((offset += 4)).toString() + appTicket.appId = ticket.readUInt32LE((offset += 8)).toString() + appTicket.expiry = new Date(ticket.readUInt32LE((offset += 20)) * 1000) + appTicket.hash = createHash("sha256") + .update(ownershipTicket) + .digest("hex") + + // Skip past licenses + const licensesLength = ticket.readUInt16LE((offset += 4)) + offset += 2 + licensesLength * 4 + + const dlcLength = ticket.readUInt16LE(offset) + offset += 2 + + for (let i = 0; i < dlcLength; ++i) { + appTicket.dlc.push(ticket.readUInt32LE(offset).toString()) + + // DLC Licenses array, usually empty + const licensesLength = ticket.readUInt16LE((offset += 4)) + offset += 2 + licensesLength * 4 + } + + // Skip past reserved + offset += 2 + + // We require a valid signature + let validSignature = false + + if (offset + 128 === ticket.length) { + const signature = ticket.subarray(offset, offset + 128) + + const verify = createVerify("RSA-SHA1") + verify.update(ownershipTicket) + verify.end() + + validSignature = verify.verify(STEAM_PUBLIC_KEY, signature) + } + + appTicket.valid = new Date() < appTicket.expiry && validSignature + + return appTicket + } catch { + return + } +}