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