/* * Copyright (c) 2016-2017, Adam * Copyright (c) 2018, Tomas Slusny * Copyright (c) 2019 Abex * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package net.runelite.client.rs; import com.google.common.base.Strings; import com.google.common.hash.Hashing; import com.google.common.io.ByteStreams; import com.openosrs.client.OpenOSRS; import java.applet.Applet; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.util.Arrays; import java.util.Collection; import java.util.Enumeration; import java.util.Map; import java.util.function.Supplier; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.JarInputStream; import javax.annotation.Nonnull; import javax.swing.SwingUtilities; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.client.RuneLite; import net.runelite.client.RuneLiteProperties; import static net.runelite.client.rs.ClientUpdateCheckMode.AUTO; import static net.runelite.client.rs.ClientUpdateCheckMode.NONE; import static net.runelite.client.rs.ClientUpdateCheckMode.VANILLA; import net.runelite.client.ui.FatalErrorDialog; import net.runelite.client.ui.SplashScreen; import net.runelite.client.util.CountingInputStream; import net.runelite.client.util.VerificationException; import net.runelite.http.api.worlds.World; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; @Slf4j @SuppressWarnings("deprecation") public class ClientLoader implements Supplier { private static final String INJECTED_CLIENT_NAME = "/injected-client.oprs"; private static final int NUM_ATTEMPTS = 6; private static File LOCK_FILE = new File(RuneLite.CACHE_DIR, "cache.lock"); private static File VANILLA_CACHE = new File(RuneLite.CACHE_DIR, "vanilla.cache"); private static File PATCHED_CACHE = new File(RuneLite.CACHE_DIR, "patched.cache"); private final OkHttpClient okHttpClient; private final ClientConfigLoader clientConfigLoader; private ClientUpdateCheckMode updateCheckMode; private final WorldSupplier worldSupplier; private final String javConfigUrl; private Object client; public ClientLoader(OkHttpClient okHttpClient, ClientUpdateCheckMode updateCheckMode, String javConfigUrl) { this.okHttpClient = okHttpClient; this.clientConfigLoader = new ClientConfigLoader(okHttpClient); this.updateCheckMode = updateCheckMode; this.worldSupplier = new WorldSupplier(okHttpClient); this.javConfigUrl = javConfigUrl; } @Override public synchronized Applet get() { if (client == null) { client = doLoad(); } if (client instanceof Throwable) { throw new RuntimeException((Throwable) client); } return (Applet) client; } private Object doLoad() { if (updateCheckMode == NONE) { return null; } try { SplashScreen.stage(0, null, "Fetching applet viewer config"); RSConfig config = downloadConfig(); SplashScreen.stage(.05, null, "Waiting for other clients to start"); LOCK_FILE.getParentFile().mkdirs(); ClassLoader classLoader; try (FileChannel lockfile = FileChannel.open(LOCK_FILE.toPath(), StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE); FileLock flock = lockfile.lock()) { SplashScreen.stage(.15, null, "Downloading Old School RuneScape"); try { updateVanilla(config); } catch (IOException ex) { // try again with the fallback config and gamepack if (javConfigUrl.equals(RuneLiteProperties.getJavConfig()) && !config.isFallback()) { log.warn("Unable to download game client, attempting to use fallback config", ex); config = downloadFallbackConfig(); updateVanilla(config); } else { throw ex; } } if (!checkVanillaHash()) { log.error("Injected client vanilla hash doesn't match, loading vanilla client."); updateCheckMode = VANILLA; } SplashScreen.stage(.40, null, "Loading client"); File oprsInjected = new File(System.getProperty("user.home") + "/.openosrs/cache/injected-client.jar"); if (updateCheckMode == AUTO) { writeInjectedClient(oprsInjected); } File jarFile = updateCheckMode == AUTO ? oprsInjected : VANILLA_CACHE; // create the classloader for the jar while we hold the lock, and eagerly load and link all classes // in the jar. Otherwise the jar can change on disk and can break future classloads. classLoader = createJarClassLoader(jarFile); } SplashScreen.stage(.465, "Starting", "Starting Old School RuneScape"); Applet rs = loadClient(config, classLoader); SplashScreen.stage(.5, null, "Starting core classes"); return rs; } catch (IOException | ClassNotFoundException | InstantiationException | IllegalAccessException | VerificationException | SecurityException e) { log.error("Error loading RS!", e); SwingUtilities.invokeLater(() -> FatalErrorDialog.showNetErrorWindow("loading the client", e)); return e; } } private RSConfig downloadConfig() throws IOException { HttpUrl url = HttpUrl.parse(javConfigUrl); IOException err = null; for (int attempt = 0; attempt < NUM_ATTEMPTS; attempt++) { try { RSConfig config = clientConfigLoader.fetch(url); if (Strings.isNullOrEmpty(config.getCodeBase()) || Strings.isNullOrEmpty(config.getInitialJar()) || Strings.isNullOrEmpty(config.getInitialClass())) { throw new IOException("Invalid or missing jav_config"); } return config; } catch (IOException e) { log.info("Failed to get jav_config from host \"{}\" ({})", url.host(), e.getMessage()); if (!javConfigUrl.equals(RuneLiteProperties.getJavConfig())) { throw e; } String host = worldSupplier.get().getAddress(); url = url.newBuilder().host(host).build(); err = e; } } log.info("Falling back to backup client config"); try { return downloadFallbackConfig(); } catch (IOException ex) { log.debug("error downloading backup config", ex); throw err; // use error from Jagex's servers } } @Nonnull private RSConfig downloadFallbackConfig() throws IOException { RSConfig backupConfig = clientConfigLoader.fetch(HttpUrl.parse(RuneLiteProperties.getJavConfigBackup())); if (Strings.isNullOrEmpty(backupConfig.getCodeBase()) || Strings.isNullOrEmpty(backupConfig.getInitialJar()) || Strings.isNullOrEmpty(backupConfig.getInitialClass())) { throw new IOException("Invalid or missing jav_config"); } if (Strings.isNullOrEmpty(backupConfig.getRuneLiteGamepack()) || Strings.isNullOrEmpty(backupConfig.getRuneLiteWorldParam())) { throw new IOException("Backup config does not have RuneLite gamepack url"); } // Randomize the codebase World world = worldSupplier.get(); backupConfig.setCodebase("http://" + world.getAddress() + "/"); // Update the world applet parameter Map appletProperties = backupConfig.getAppletProperties(); appletProperties.put(backupConfig.getRuneLiteWorldParam(), Integer.toString(world.getId())); return backupConfig; } private void updateVanilla(RSConfig config) throws IOException, VerificationException { Certificate[] jagexCertificateChain = getJagexCertificateChain(); // Get the mtime of the first thing in the vanilla cache // we check this against what the server gives us to let us skip downloading and patching the whole thing try (FileChannel vanilla = FileChannel.open(VANILLA_CACHE.toPath(), StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE)) { long vanillaCacheMTime = -1; boolean vanillaCacheIsInvalid = false; try { JarInputStream vanillaCacheTest = new JarInputStream(Channels.newInputStream(vanilla)); vanillaCacheTest.skip(Long.MAX_VALUE); JarEntry je = vanillaCacheTest.getNextJarEntry(); if (je != null) { verifyJarEntry(je, jagexCertificateChain); vanillaCacheMTime = je.getLastModifiedTime().toMillis(); } else { vanillaCacheIsInvalid = true; } } catch (Exception e) { log.info("Failed to read the vanilla cache: {}", e.toString()); vanillaCacheIsInvalid = true; } vanilla.position(0); // Start downloading the vanilla client HttpUrl url; if (config.isFallback()) { // If we are using the backup config, use our own gamepack and ignore the codebase url = HttpUrl.parse(config.getRuneLiteGamepack()); } else { String codebase = config.getCodeBase(); String initialJar = config.getInitialJar(); url = HttpUrl.parse(codebase + initialJar); } for (int attempt = 0; ; attempt++) { Request request = new Request.Builder() .url(url) .build(); try (Response response = okHttpClient.newCall(request).execute()) { // Its important to not close the response manually - this should be the only close or // try-with-resources on this stream or it's children if (!response.isSuccessful()) { throw new IOException("unsuccessful response fetching gamepack: " + response.message()); } int length = (int) response.body().contentLength(); if (length < 0) { length = 3 * 1024 * 1024; } else { if (!vanillaCacheIsInvalid && vanilla.size() != length) { // The zip trailer filetab can be missing and the ZipInputStream will not notice log.info("Vanilla cache is the wrong size"); vanillaCacheIsInvalid = true; } } final int flength = length; TeeInputStream copyStream = new TeeInputStream(new CountingInputStream(response.body().byteStream(), read -> SplashScreen.stage(.05, .35, null, "Downloading Old School RuneScape", read, flength, true))); // Save the bytes from the mtime test so we can write it to disk // if the test fails, or the cache doesn't verify ByteArrayOutputStream preRead = new ByteArrayOutputStream(); copyStream.setOut(preRead); JarInputStream networkJIS = new JarInputStream(copyStream); // Get the mtime from the first entry so check it against the cache { JarEntry je = networkJIS.getNextJarEntry(); if (je == null) { throw new IOException("unable to peek first jar entry"); } networkJIS.skip(Long.MAX_VALUE); verifyJarEntry(je, jagexCertificateChain); long vanillaClientMTime = je.getLastModifiedTime().toMillis(); if (!vanillaCacheIsInvalid && vanillaClientMTime != vanillaCacheMTime) { log.info("Vanilla cache is out of date: {} != {}", vanillaClientMTime, vanillaCacheMTime); vanillaCacheIsInvalid = true; } } // the mtime matches so the cache is probably up to date, but just make sure its fully // intact before closing the server connection if (!vanillaCacheIsInvalid) { try { // as with the request stream, its important to not early close vanilla too JarInputStream vanillaCacheTest = new JarInputStream(Channels.newInputStream(vanilla)); verifyWholeJar(vanillaCacheTest, jagexCertificateChain); } catch (Exception e) { log.warn("Failed to verify the vanilla cache", e); vanillaCacheIsInvalid = true; } } if (vanillaCacheIsInvalid) { // the cache is not up to date, commit our peek to the file and write the rest of it, while verifying vanilla.position(0); OutputStream out = Channels.newOutputStream(vanilla); out.write(preRead.toByteArray()); copyStream.setOut(out); verifyWholeJar(networkJIS, jagexCertificateChain); copyStream.skip(Long.MAX_VALUE); // write the trailer to the file too out.flush(); vanilla.truncate(vanilla.position()); } else { log.info("Using cached vanilla client"); } return; } catch (IOException e) { log.warn("Failed to download gamepack from \"{}\"", url, e); // With fallback config do 1 attempt (there are no additional urls to try) if (!javConfigUrl.equals(RuneLiteProperties.getJavConfig()) || config.isFallback() || attempt >= NUM_ATTEMPTS) { throw e; } url = url.newBuilder().host(worldSupplier.get().getAddress()).build(); } } } } private void applyPatch() throws IOException { /* byte[] vanillaHash = new byte[64]; byte[] appliedPatchHash = new byte[64]; try (InputStream is = ClientLoader.class.getResourceAsStream("/client.serial")) { if (is == null) { SwingUtilities.invokeLater(() -> new FatalErrorDialog("The client-patch is missing from the classpath. If you are building " + "the client you need to re-run maven") .addBuildingGuide() .open()); throw new NullPointerException(); } DataInputStream dis = new DataInputStream(is); dis.readFully(vanillaHash); dis.readFully(appliedPatchHash); } byte[] vanillaCacheHash = Files.asByteSource(VANILLA_CACHE).hash(Hashing.sha512()).asBytes(); if (!Arrays.equals(vanillaHash, vanillaCacheHash)) { log.info("Client is outdated!"); updateCheckMode = VANILLA; return; } if (PATCHED_CACHE.exists()) { byte[] diskBytes = Files.asByteSource(PATCHED_CACHE).hash(Hashing.sha512()).asBytes(); if (!Arrays.equals(diskBytes, appliedPatchHash)) { log.warn("Cached patch hash mismatches, regenerating patch"); } else { log.info("Using cached patched client"); return; } } try (HashingOutputStream hos = new HashingOutputStream(Hashing.sha512(), new FileOutputStream(PATCHED_CACHE)); InputStream patch = ClientLoader.class.getResourceAsStream("/client.patch")) { new FileByFileV1DeltaApplier().applyDelta(VANILLA_CACHE, patch, hos); if (!Arrays.equals(hos.hash().asBytes(), appliedPatchHash)) { log.error("Patched client hash mismatch"); updateCheckMode = VANILLA; return; } } catch (IOException e) { log.error("Unable to apply patch despite hash matching", e); updateCheckMode = VANILLA; return; } */ } private boolean checkVanillaHash() { try (InputStream is = ClientLoader.class.getResourceAsStream("/client.hash")) { String storedHash = new String(is.readAllBytes(), StandardCharsets.UTF_8); String vanillaHash = Hashing.sha256().hashBytes(Files.readAllBytes(VANILLA_CACHE.toPath())).toString(); log.debug("Stored vanilla hash: {}", storedHash); log.debug("Actual vanilla hash: {}", vanillaHash); return vanillaHash.equals(storedHash); } catch (IOException ex) { log.error("Failed to compare vanilla hashes, loading vanilla", ex); } return false; } private void writeInjectedClient(File cachedInjected) throws IOException { String cachedHash = ""; try { cachedHash = com.google.common.io.Files.asByteSource(cachedInjected).hash(Hashing.sha256()).toString(); } catch (IOException ex) { if (!(ex instanceof FileNotFoundException)) { log.error("Failed to calculate hash for cached file, falling back to vanilla", ex); updateCheckMode = VANILLA; return; } } byte[] currentInjected = ByteStreams.toByteArray(ClientLoader.class.getResourceAsStream(INJECTED_CLIENT_NAME)); String currentHash = Hashing.sha256().hashBytes(currentInjected).toString(); if (!cachedInjected.exists() || !currentHash.equals(cachedHash)) { cachedInjected.getParentFile().mkdirs(); Files.write(cachedInjected.toPath(), currentInjected); } } private ClassLoader createJarClassLoader(File jar) throws IOException, ClassNotFoundException { try (JarFile jarFile = new JarFile(jar)) { ClassLoader classLoader = new ClassLoader(ClientLoader.class.getClassLoader()) { @Override protected Class findClass(String name) throws ClassNotFoundException { String entryName = name.replace('.', '/').concat(".class"); JarEntry jarEntry; try { jarEntry = jarFile.getJarEntry(entryName); } catch (IllegalStateException ex) { throw new ClassNotFoundException(name, ex); } if (jarEntry == null) { throw new ClassNotFoundException(name); } try { InputStream inputStream = jarFile.getInputStream(jarEntry); if (inputStream == null) { throw new ClassNotFoundException(name); } byte[] bytes = ByteStreams.toByteArray(inputStream); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(null, e); } } }; // load all of the classes in this jar; after the jar is closed the classloader // will no longer be able to look up classes Enumeration entries = jarFile.entries(); while (entries.hasMoreElements()) { JarEntry jarEntry = entries.nextElement(); String name = jarEntry.getName(); if (name.endsWith(".class")) { name = name.substring(0, name.length() - 6); classLoader.loadClass(name); } } return classLoader; } } private Applet loadClient(RSConfig config, ClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException, InstantiationException { String initialClass = config.getInitialClass(); Class clientClass = classLoader.loadClass(initialClass); Applet rs = (Applet) clientClass.newInstance(); rs.setStub(new RSAppletStub(config)); if (rs instanceof Client) { log.info("injected-client {}", OpenOSRS.SYSTEM_VERSION); } return rs; } private static Certificate[] getJagexCertificateChain() { try { CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); Collection certificates = certificateFactory.generateCertificates(ClientLoader.class.getResourceAsStream("jagex.crt")); return certificates.toArray(new Certificate[0]); } catch (CertificateException e) { throw new RuntimeException("Unable to parse pinned certificates", e); } } private void verifyJarEntry(JarEntry je, Certificate[] certs) throws VerificationException { switch (je.getName()) { case "META-INF/JAGEXLTD.SF": case "META-INF/JAGEXLTD.RSA": // You can't sign the signing files return; default: if (!Arrays.equals(je.getCertificates(), certs)) { throw new VerificationException("Unable to verify jar entry: " + je.getName()); } } } private void verifyWholeJar(JarInputStream jis, Certificate[] certs) throws IOException, VerificationException { for (JarEntry je; (je = jis.getNextJarEntry()) != null; ) { jis.skip(Long.MAX_VALUE); verifyJarEntry(je, certs); } } }