From b6041c190854303a7dd227c0dccd290bb57deb2f Mon Sep 17 00:00:00 2001 From: sanasol Date: Mon, 19 Jan 2026 16:03:12 +0100 Subject: [PATCH] Add client and server patching functionality to support custom authentication domain and token system --- backend/core/config.js | 36 ++- backend/managers/gameLauncher.js | 146 ++++++++- backend/utils/clientPatcher.js | 511 +++++++++++++++++++++++++++++++ 3 files changed, 686 insertions(+), 7 deletions(-) create mode 100644 backend/utils/clientPatcher.js diff --git a/backend/core/config.js b/backend/core/config.js index 9857b70..aa050f5 100644 --- a/backend/core/config.js +++ b/backend/core/config.js @@ -2,6 +2,35 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +// Default auth domain - can be overridden by env var or config +const DEFAULT_AUTH_DOMAIN = 'sanasol.ws'; + +// Get auth domain from env, config, or default +function getAuthDomain() { + // First check environment variable + if (process.env.HYTALE_AUTH_DOMAIN) { + return process.env.HYTALE_AUTH_DOMAIN; + } + // Then check config file + const config = loadConfig(); + if (config.authDomain) { + return config.authDomain; + } + // Fall back to default + return DEFAULT_AUTH_DOMAIN; +} + +// Get full auth server URL +function getAuthServerUrl() { + const domain = getAuthDomain(); + return `https://sessions.${domain}`; +} + +// Save auth domain to config +function saveAuthDomain(domain) { + saveConfig({ authDomain: domain || DEFAULT_AUTH_DOMAIN }); +} + function getAppDir() { const home = os.homedir(); if (process.platform === 'win32') { @@ -159,5 +188,10 @@ module.exports = { loadModsFromConfig, isFirstLaunch, markAsLaunched, - CONFIG_FILE + CONFIG_FILE, + // Auth domain config + DEFAULT_AUTH_DOMAIN, + getAuthDomain, + getAuthServerUrl, + saveAuthDomain }; diff --git a/backend/managers/gameLauncher.js b/backend/managers/gameLauncher.js index 60c32ba..311f62f 100644 --- a/backend/managers/gameLauncher.js +++ b/backend/managers/gameLauncher.js @@ -1,17 +1,105 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { exec } = require('child_process'); const { promisify } = require('util'); const { spawn } = require('child_process'); +const { v4: uuidv4 } = require('uuid'); const { getResolvedAppDir, findClientPath } = require('../core/paths'); const { setupWaylandEnvironment } = require('../utils/platformUtils'); -const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser } = require('../core/config'); +const { saveUsername, saveInstallPath, loadJavaPath, getUuidForUser, getAuthServerUrl, getAuthDomain } = require('../core/config'); const { resolveJavaPath, getJavaExec, getBundledJavaPath, detectSystemJava, JAVA_EXECUTABLE } = require('./javaManager'); const { getInstalledClientVersion, getLatestClientVersion } = require('../services/versionManager'); const { updateGameFiles } = require('./gameManager'); +// Client patcher for custom auth server (sanasol.ws) +let clientPatcher = null; +try { + clientPatcher = require('../utils/clientPatcher'); +} catch (err) { + console.log('[Launcher] Client patcher not available:', err.message); +} + const execAsync = promisify(exec); +// Fetch tokens from the auth server (properly signed with server's Ed25519 key) +async function fetchAuthTokens(uuid, name) { + const authServerUrl = getAuthServerUrl(); + try { + console.log(`Fetching auth tokens from ${authServerUrl}/game-session/child`); + + const response = await fetch(`${authServerUrl}/game-session/child`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + uuid: uuid, + name: name, + scopes: ['hytale:server', 'hytale:client'] + }) + }); + + if (!response.ok) { + throw new Error(`Auth server returned ${response.status}`); + } + + const data = await response.json(); + console.log('Auth tokens received from server'); + + return { + identityToken: data.IdentityToken || data.identityToken, + sessionToken: data.SessionToken || data.sessionToken + }; + } catch (error) { + console.error('Failed to fetch auth tokens:', error.message); + // Fallback to local generation if server unavailable + return generateLocalTokens(uuid, name); + } +} + +// Fallback: Generate tokens locally (won't pass signature validation but allows offline testing) +function generateLocalTokens(uuid, name) { + console.log('Using locally generated tokens (fallback mode)'); + const authServerUrl = getAuthServerUrl(); + const now = Math.floor(Date.now() / 1000); + const exp = now + 36000; + + const header = Buffer.from(JSON.stringify({ + alg: 'EdDSA', + kid: '2025-10-01', + typ: 'JWT' + })).toString('base64url'); + + const identityPayload = Buffer.from(JSON.stringify({ + sub: uuid, + name: name, + username: name, + entitlements: ['game.base'], + scope: 'hytale:server hytale:client', + iat: now, + exp: exp, + iss: authServerUrl, + jti: uuidv4() + })).toString('base64url'); + + const sessionPayload = Buffer.from(JSON.stringify({ + sub: uuid, + scope: 'hytale:server', + iat: now, + exp: exp, + iss: authServerUrl, + jti: uuidv4() + })).toString('base64url'); + + const signature = crypto.randomBytes(64).toString('base64url'); + + return { + identityToken: `${header}.${identityPayload}.${signature}`, + sessionToken: `${header}.${sessionPayload}.${signature}` + }; +} + async function launchGame(playerName = 'Player', progressCallback, javaPathOverride, installPathOverride) { const customAppDir = getResolvedAppDir(installPathOverride); const customGameDir = path.join(customAppDir, 'release', 'package', 'game', 'latest'); @@ -53,6 +141,51 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr } } + const uuid = getUuidForUser(playerName); + + // Fetch tokens from auth server + if (progressCallback) { + progressCallback('Fetching authentication tokens...', null, null, null, null); + } + const { identityToken, sessionToken } = await fetchAuthTokens(uuid, playerName); + + // Patch client and server binaries to use custom auth server (BEFORE signing on macOS) + const authDomain = getAuthDomain(); + if (clientPatcher) { + try { + if (progressCallback) { + progressCallback('Patching game for custom server...', null, null, null, null); + } + console.log(`Patching game binaries for ${authDomain}...`); + + const patchResult = await clientPatcher.ensureClientPatched(gameLatest, (msg, percent) => { + console.log(`[Patcher] ${msg}`); + if (progressCallback && msg) { + progressCallback(msg, percent, null, null, null); + } + }); + + if (patchResult.success) { + if (patchResult.alreadyPatched) { + console.log(`Game already patched for ${authDomain}`); + } else { + console.log(`Game patched successfully (${patchResult.patchCount} total occurrences)`); + if (patchResult.client) { + console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`); + } + if (patchResult.server) { + console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`); + } + } + } else { + console.warn('Game patching failed:', patchResult.error); + } + } catch (patchError) { + console.warn('Game patching failed (game may not connect to custom server):', patchError.message); + } + } + + // macOS: Sign binaries AFTER patching so the patched binaries have valid signatures if (process.platform === 'darwin') { try { const appBundle = path.join(gameLatest, 'Client', 'Hytale.app'); @@ -66,10 +199,10 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr if (fs.existsSync(appBundle)) { await signPath(appBundle, true); - console.log('Signed macOS app bundle'); + console.log('Signed macOS app bundle (after patching)'); } else { await signPath(path.dirname(clientPath), true); - console.log('Signed macOS client binary'); + console.log('Signed macOS client binary (after patching)'); } if (javaBin && fs.existsSync(javaBin)) { @@ -85,7 +218,7 @@ async function launchGame(playerName = 'Player', progressCallback, javaPathOverr if (fs.existsSync(serverDir)) { await execAsync(`xattr -cr "${serverDir}"`).catch(() => {}); await execAsync(`find "${serverDir}" -type f -perm +111 -exec codesign --force --sign - {} \\;`).catch(() => {}); - console.log('Signed server binaries'); + console.log('Signed server binaries (after patching)'); } if (javaBin && fs.existsSync(javaBin)) { @@ -113,13 +246,14 @@ exec "$REAL_JAVA" "\${ARGS[@]}" } } - const uuid = getUuidForUser(playerName); const args = [ '--app-dir', gameLatest, '--java-exec', javaBin, - '--auth-mode', 'offline', + '--auth-mode', 'authenticated', '--uuid', uuid, '--name', playerName, + '--identity-token', identityToken, + '--session-token', sessionToken, '--user-dir', userDataDir ]; diff --git a/backend/utils/clientPatcher.js b/backend/utils/clientPatcher.js new file mode 100644 index 0000000..0380d71 --- /dev/null +++ b/backend/utils/clientPatcher.js @@ -0,0 +1,511 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const AdmZip = require('adm-zip'); + +// Domain configuration +const ORIGINAL_DOMAIN = 'hytale.com'; + +// Get target domain from config or environment +function getTargetDomain() { + // Check environment variable first + if (process.env.HYTALE_AUTH_DOMAIN) { + return process.env.HYTALE_AUTH_DOMAIN; + } + // Try to load from config + try { + const { getAuthDomain } = require('../core/config'); + return getAuthDomain(); + } catch (e) { + // Config not available, use default + return 'sanasol.ws'; + } +} + +// Default domain - must be exactly 10 characters (same as hytale.com) +const DEFAULT_NEW_DOMAIN = 'sanasol.ws'; + +/** + * Patches HytaleClient and HytaleServer binaries to replace hytale.com with custom domain + * This allows the game to connect to a custom authentication server + */ +class ClientPatcher { + constructor() { + this.patchedFlag = '.patched_custom'; + } + + /** + * Get the target domain for patching + */ + getNewDomain() { + const domain = getTargetDomain(); + // Validate domain length matches original + if (domain.length !== ORIGINAL_DOMAIN.length) { + console.warn(`Warning: Domain "${domain}" length (${domain.length}) doesn't match original "${ORIGINAL_DOMAIN}" (${ORIGINAL_DOMAIN.length})`); + console.warn(`Using default domain: ${DEFAULT_NEW_DOMAIN}`); + return DEFAULT_NEW_DOMAIN; + } + return domain; + } + + /** + * Convert a string to UTF-16LE bytes (how .NET stores strings) + */ + stringToUtf16LE(str) { + const buf = Buffer.alloc(str.length * 2); + for (let i = 0; i < str.length; i++) { + buf.writeUInt16LE(str.charCodeAt(i), i * 2); + } + return buf; + } + + /** + * Convert a string to UTF-8 bytes (how Java stores strings) + */ + stringToUtf8(str) { + return Buffer.from(str, 'utf8'); + } + + /** + * Find all occurrences of a pattern in a buffer + */ + findAllOccurrences(buffer, pattern) { + const positions = []; + let pos = 0; + while (pos < buffer.length) { + const index = buffer.indexOf(pattern, pos); + if (index === -1) break; + positions.push(index); + pos = index + 1; + } + return positions; + } + + /** + * UTF-8 domain replacement for Java JAR files. + * Java stores strings in UTF-8 format in the constant pool. + */ + findAndReplaceDomainUtf8(data, oldDomain, newDomain) { + let count = 0; + const result = Buffer.from(data); + + const oldUtf8 = this.stringToUtf8(oldDomain); + const newUtf8 = this.stringToUtf8(newDomain); + + // Find all occurrences of the domain + const positions = this.findAllOccurrences(result, oldUtf8); + + for (const pos of positions) { + // Replace the domain + newUtf8.copy(result, pos); + count++; + console.log(` Patched UTF-8 occurrence at offset 0x${pos.toString(16)}`); + } + + return { buffer: result, count }; + } + + /** + * Smart domain replacement that handles both null-terminated and non-null-terminated strings. + * .NET AOT stores some strings in various formats: + * - Standard UTF-16LE (each char is 2 bytes with \x00 high byte) + * - Length-prefixed where last char may have metadata byte instead of \x00 + */ + findAndReplaceDomainSmart(data, oldDomain, newDomain) { + let count = 0; + const result = Buffer.from(data); + + // Get UTF-16LE bytes without the last character + const oldUtf16NoLast = this.stringToUtf16LE(oldDomain.slice(0, -1)); + const newUtf16NoLast = this.stringToUtf16LE(newDomain.slice(0, -1)); + const oldLastChar = this.stringToUtf16LE(oldDomain.slice(-1)); + const newLastChar = this.stringToUtf16LE(newDomain.slice(-1)); + + // ASCII code of last characters + const oldLastCharByte = oldDomain.charCodeAt(oldDomain.length - 1); + const newLastCharByte = newDomain.charCodeAt(newDomain.length - 1); + + // Find all occurrences of the domain without the last character + const positions = this.findAllOccurrences(result, oldUtf16NoLast); + + for (const pos of positions) { + // Check if we have the last character following + const lastCharPos = pos + oldUtf16NoLast.length; + if (lastCharPos + 1 > result.length) continue; + + // Read the byte at last char position + const lastCharFirstByte = result[lastCharPos]; + + // Check if first byte matches the last character of old domain + if (lastCharFirstByte === oldLastCharByte) { + // Replace all but last character + newUtf16NoLast.copy(result, pos); + + // Replace just the first byte of the last character (preserve metadata byte if any) + result[lastCharPos] = newLastCharByte; + + // If there's a proper null byte (standard UTF-16LE), also check/preserve it + if (lastCharPos + 1 < result.length) { + const secondByte = result[lastCharPos + 1]; + // Log what type of occurrence this is + if (secondByte === 0x00) { + console.log(` Patched UTF-16LE occurrence at offset 0x${pos.toString(16)}`); + } else { + console.log(` Patched length-prefixed occurrence at offset 0x${pos.toString(16)} (metadata: 0x${secondByte.toString(16)})`); + } + } + count++; + } + } + + return { buffer: result, count }; + } + + /** + * Check if the client binary has already been patched + */ + isPatchedAlready(clientPath) { + const newDomain = this.getNewDomain(); + const patchFlagFile = clientPath + this.patchedFlag; + if (fs.existsSync(patchFlagFile)) { + try { + const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8')); + // Check if patched with same target domain + if (flagData.targetDomain === newDomain) { + return true; + } + } catch (e) { + // Flag file corrupted, will re-patch + } + } + return false; + } + + /** + * Mark the client as patched + */ + markAsPatched(clientPath) { + const newDomain = this.getNewDomain(); + const patchFlagFile = clientPath + this.patchedFlag; + const flagData = { + patchedAt: new Date().toISOString(), + originalDomain: ORIGINAL_DOMAIN, + targetDomain: newDomain, + patcherVersion: '1.0.0' + }; + fs.writeFileSync(patchFlagFile, JSON.stringify(flagData, null, 2)); + } + + /** + * Create a backup of the original client binary + */ + backupClient(clientPath) { + const backupPath = clientPath + '.original'; + if (!fs.existsSync(backupPath)) { + console.log(` Creating backup at ${path.basename(backupPath)}`); + fs.copyFileSync(clientPath, backupPath); + return backupPath; + } + console.log(' Backup already exists'); + return backupPath; + } + + /** + * Restore the original client binary from backup + */ + restoreClient(clientPath) { + const backupPath = clientPath + '.original'; + if (fs.existsSync(backupPath)) { + fs.copyFileSync(backupPath, clientPath); + const patchFlagFile = clientPath + this.patchedFlag; + if (fs.existsSync(patchFlagFile)) { + fs.unlinkSync(patchFlagFile); + } + console.log('Client restored from backup'); + return true; + } + console.log('No backup found to restore'); + return false; + } + + /** + * Patch the client binary to use the custom domain + * @param {string} clientPath - Path to the HytaleClient binary + * @param {function} progressCallback - Optional callback for progress updates + * @returns {object} Result object with success status and details + */ + async patchClient(clientPath, progressCallback) { + const newDomain = this.getNewDomain(); + console.log('=== Client Patcher ==='); + console.log(`Target: ${clientPath}`); + console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`); + + // Check if file exists + if (!fs.existsSync(clientPath)) { + const error = `Client binary not found: ${clientPath}`; + console.error(error); + return { success: false, error }; + } + + // Check if already patched + if (this.isPatchedAlready(clientPath)) { + console.log(`Client already patched for ${newDomain}, skipping`); + if (progressCallback) { + progressCallback('Client already patched', 100); + } + return { success: true, alreadyPatched: true, patchCount: 0 }; + } + + if (progressCallback) { + progressCallback('Preparing to patch client...', 10); + } + + // Create backup + console.log('Creating backup...'); + this.backupClient(clientPath); + + if (progressCallback) { + progressCallback('Reading client binary...', 20); + } + + // Read the binary + console.log('Reading client binary...'); + const data = fs.readFileSync(clientPath); + console.log(`Binary size: ${(data.length / 1024 / 1024).toFixed(2)} MB`); + + if (progressCallback) { + progressCallback('Patching domain references...', 50); + } + + // Perform the domain replacement + console.log('Patching domain references...'); + const { buffer: patchedData, count } = this.findAndReplaceDomainSmart(data, ORIGINAL_DOMAIN, newDomain); + + if (count === 0) { + console.log('No occurrences of hytale.com found - binary may already be modified or has different format'); + return { success: true, patchCount: 0, warning: 'No domain occurrences found' }; + } + + if (progressCallback) { + progressCallback('Writing patched binary...', 80); + } + + // Write the patched binary + console.log('Writing patched binary...'); + fs.writeFileSync(clientPath, patchedData); + + // Mark as patched + this.markAsPatched(clientPath); + + if (progressCallback) { + progressCallback('Patching complete', 100); + } + + console.log(`Successfully patched ${count} occurrences`); + console.log('=== Patching Complete ==='); + + return { success: true, patchCount: count }; + } + + /** + * Patch the server JAR to use the custom domain + * JAR files are ZIP archives, so we need to extract, patch class files, and repackage + * @param {string} serverPath - Path to the HytaleServer.jar + * @param {function} progressCallback - Optional callback for progress updates + * @returns {object} Result object with success status and details + */ + async patchServer(serverPath, progressCallback) { + const newDomain = this.getNewDomain(); + console.log('=== Server Patcher ==='); + console.log(`Target: ${serverPath}`); + console.log(`Replacing: ${ORIGINAL_DOMAIN} -> ${newDomain}`); + + // Check if file exists + if (!fs.existsSync(serverPath)) { + const error = `Server JAR not found: ${serverPath}`; + console.error(error); + return { success: false, error }; + } + + // Check if already patched + if (this.isPatchedAlready(serverPath)) { + console.log(`Server already patched for ${newDomain}, skipping`); + if (progressCallback) { + progressCallback('Server already patched', 100); + } + return { success: true, alreadyPatched: true, patchCount: 0 }; + } + + if (progressCallback) { + progressCallback('Preparing to patch server...', 10); + } + + // Create backup + console.log('Creating backup...'); + this.backupClient(serverPath); + + if (progressCallback) { + progressCallback('Extracting server JAR...', 20); + } + + // Open the JAR file as a ZIP + console.log('Opening server JAR...'); + const zip = new AdmZip(serverPath); + const entries = zip.getEntries(); + console.log(`JAR contains ${entries.length} entries`); + + if (progressCallback) { + progressCallback('Patching class files...', 40); + } + + // Patch each entry that might contain domain strings + let totalCount = 0; + const oldUtf8 = this.stringToUtf8(ORIGINAL_DOMAIN); + const newUtf8 = this.stringToUtf8(newDomain); + + for (const entry of entries) { + // Only patch class files and certain resource files + const name = entry.entryName; + if (name.endsWith('.class') || name.endsWith('.properties') || + name.endsWith('.json') || name.endsWith('.xml') || name.endsWith('.yml')) { + + const data = entry.getData(); + + // Check if this entry contains the domain + if (data.includes(oldUtf8)) { + const { buffer: patchedData, count } = this.findAndReplaceDomainUtf8(data, ORIGINAL_DOMAIN, newDomain); + if (count > 0) { + zip.updateFile(entry.entryName, patchedData); + console.log(` Patched ${count} occurrences in ${name}`); + totalCount += count; + } + } + } + } + + if (totalCount === 0) { + console.log('No occurrences of hytale.com found in server JAR entries'); + return { success: true, patchCount: 0, warning: 'No domain occurrences found in JAR' }; + } + + if (progressCallback) { + progressCallback('Writing patched JAR...', 80); + } + + // Write the patched JAR + console.log('Writing patched JAR...'); + zip.writeZip(serverPath); + + // Mark as patched + this.markAsPatched(serverPath); + + if (progressCallback) { + progressCallback('Server patching complete', 100); + } + + console.log(`Successfully patched ${totalCount} occurrences in server`); + console.log('=== Server Patching Complete ==='); + + return { success: true, patchCount: totalCount }; + } + + /** + * Find the client binary path based on platform + */ + findClientPath(gameDir) { + const candidates = []; + + if (process.platform === 'darwin') { + // macOS: Check both app bundle and direct binary + candidates.push(path.join(gameDir, 'Client', 'Hytale.app', 'Contents', 'MacOS', 'HytaleClient')); + candidates.push(path.join(gameDir, 'Client', 'HytaleClient')); + } else if (process.platform === 'win32') { + candidates.push(path.join(gameDir, 'Client', 'HytaleClient.exe')); + } else { + candidates.push(path.join(gameDir, 'Client', 'HytaleClient')); + } + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; + } + + /** + * Find the server JAR path + */ + findServerPath(gameDir) { + const candidates = [ + path.join(gameDir, 'Server', 'HytaleServer.jar'), + path.join(gameDir, 'Server', 'server.jar') + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; + } + + /** + * Ensure both client and server are patched before launching + * @param {string} gameDir - Path to the game directory + * @param {function} progressCallback - Optional callback for progress updates + */ + async ensureClientPatched(gameDir, progressCallback) { + const results = { + client: null, + server: null, + success: true + }; + + // Patch client + const clientPath = this.findClientPath(gameDir); + if (clientPath) { + if (progressCallback) { + progressCallback('Patching client binary...', 10); + } + results.client = await this.patchClient(clientPath, (msg, pct) => { + if (progressCallback) { + progressCallback(`Client: ${msg}`, pct ? pct / 2 : null); + } + }); + } else { + console.warn('Could not find HytaleClient binary'); + results.client = { success: false, error: 'Client binary not found' }; + } + + // Patch server + const serverPath = this.findServerPath(gameDir); + if (serverPath) { + if (progressCallback) { + progressCallback('Patching server JAR...', 50); + } + results.server = await this.patchServer(serverPath, (msg, pct) => { + if (progressCallback) { + progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null); + } + }); + } else { + console.warn('Could not find HytaleServer.jar'); + results.server = { success: false, error: 'Server JAR not found' }; + } + + // Calculate overall success + results.success = (results.client && results.client.success) || (results.server && results.server.success); + results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched); + results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0); + + if (progressCallback) { + progressCallback('Patching complete', 100); + } + + return results; + } +} + +// Export singleton instance +module.exports = new ClientPatcher();