From e7f3ac8ecd92e84db8a189d188df5e994395236c Mon Sep 17 00:00:00 2001 From: Bixilon Date: Wed, 15 Dec 2021 10:51:28 +0100 Subject: [PATCH] assets: allow sha256 hashes, check if hash is hex asset (security), save player textures --- .../directory/DirectoryAssetsManager.kt | 2 +- .../assets/minecraft/JarAssetsManager.kt | 4 +- .../minecraft/index/IndexAssetsManager.kt | 8 ++-- .../minosoft/assets/util/FileAssetsUtil.kt | 45 +++++++++++++------ .../bixilon/minosoft/assets/util/FileUtil.kt | 6 +-- .../profiles/eros/server/entries/Server.kt | 3 +- .../properties/textures/PlayerTexture.kt | 24 ++++++++++ .../registries/registries/RegistriesLoader.kt | 2 +- .../main/play/server/card/FaviconManager.kt | 2 +- .../java/de/bixilon/minosoft/util/KUtil.kt | 10 +++++ .../java/de/bixilon/minosoft/util/Util.java | 38 ++++++++++++---- 11 files changed, 109 insertions(+), 35 deletions(-) diff --git a/src/main/java/de/bixilon/minosoft/assets/directory/DirectoryAssetsManager.kt b/src/main/java/de/bixilon/minosoft/assets/directory/DirectoryAssetsManager.kt index 97e1ffaab..10b9232ea 100644 --- a/src/main/java/de/bixilon/minosoft/assets/directory/DirectoryAssetsManager.kt +++ b/src/main/java/de/bixilon/minosoft/assets/directory/DirectoryAssetsManager.kt @@ -78,6 +78,6 @@ class DirectoryAssetsManager( if (path !in assets) { return null } - return FileUtil.saveReadFile(path.filePath, false) + return FileUtil.safeReadFile(path.filePath, false) } } diff --git a/src/main/java/de/bixilon/minosoft/assets/minecraft/JarAssetsManager.kt b/src/main/java/de/bixilon/minosoft/assets/minecraft/JarAssetsManager.kt index 7f6c875bb..f1d0c48e2 100644 --- a/src/main/java/de/bixilon/minosoft/assets/minecraft/JarAssetsManager.kt +++ b/src/main/java/de/bixilon/minosoft/assets/minecraft/JarAssetsManager.kt @@ -49,13 +49,13 @@ class JarAssetsManager( check(!loaded) { "Already loaded!" } val jarAssetFile = File(FileAssetsUtil.getPath(jarAssetsHash)) - if (FileAssetsUtil.verifyAsset(jarAssetsHash, jarAssetFile, profile.verify)) { + if (FileAssetsUtil.verifyAsset(jarAssetsHash, jarAssetFile, profile.verify, FileAssetsUtil.HashTypes.SHA1)) { val jarAssets = FileUtil.readFile(jarAssetFile).readArchive() for ((path, data) in jarAssets) { this.jarAssets[path.removePrefix("assets/" + ProtocolDefinition.DEFAULT_NAMESPACE + "/")] = data } } else { - var clientJar = FileUtil.saveReadFile(File(FileAssetsUtil.getPath(clientJarHash)), false)?.readZipArchive() + var clientJar = FileUtil.safeReadFile(File(FileAssetsUtil.getPath(clientJarHash)), false)?.readZipArchive() if (clientJar == null) { val downloaded = FileAssetsUtil.downloadAndGetAsset(Util.formatString(profile.source.launcherPackages, mapOf( "fullHash" to clientJarHash, diff --git a/src/main/java/de/bixilon/minosoft/assets/minecraft/index/IndexAssetsManager.kt b/src/main/java/de/bixilon/minosoft/assets/minecraft/index/IndexAssetsManager.kt index 3ce863671..3f97628c1 100644 --- a/src/main/java/de/bixilon/minosoft/assets/minecraft/index/IndexAssetsManager.kt +++ b/src/main/java/de/bixilon/minosoft/assets/minecraft/index/IndexAssetsManager.kt @@ -59,12 +59,12 @@ class IndexAssetsManager( mapOf( "fullHash" to indexHash, "filename" to "$assetsVersion.json", - ))).second, Jackson.JSON_MAP_TYPE) + )), hashType = FileAssetsUtil.HashTypes.SHA1).second, Jackson.JSON_MAP_TYPE) } fun verifyAsset(hash: String) { val file = File(FileAssetsUtil.getPath(hash)) - if (FileAssetsUtil.verifyAsset(hash, file, verify)) { + if (FileAssetsUtil.verifyAsset(hash, file, verify, hashType = FileAssetsUtil.HashTypes.SHA1)) { return } val url = Util.formatString(profile.source.minecraftResources, @@ -73,7 +73,7 @@ class IndexAssetsManager( "fullHash" to hash, )) Log.log(LogMessageType.ASSETS, LogLevels.VERBOSE) { "Downloading asset $url" } - val downloadedHash = FileAssetsUtil.downloadAsset(url) + val downloadedHash = FileAssetsUtil.downloadAsset(url, hashType = FileAssetsUtil.HashTypes.SHA1) if (downloadedHash != hash) { throw IOException("Verification of asset $hash failed!") } @@ -82,7 +82,7 @@ class IndexAssetsManager( override fun load(latch: CountUpAndDownLatch) { check(!loaded) { "Already loaded!" } - var assets = FileUtil.saveReadFile(FileAssetsUtil.getPath(indexHash))?.readJsonObject() ?: downloadAssetsIndex() + var assets = FileUtil.safeReadFile(FileAssetsUtil.getPath(indexHash))?.readJsonObject() ?: downloadAssetsIndex() assets["objects"].let { assets = it.asCompound() } val tasks = CountUpAndDownLatch(0) diff --git a/src/main/java/de/bixilon/minosoft/assets/util/FileAssetsUtil.kt b/src/main/java/de/bixilon/minosoft/assets/util/FileAssetsUtil.kt index 4f8efbfe2..b41e1db3f 100644 --- a/src/main/java/de/bixilon/minosoft/assets/util/FileAssetsUtil.kt +++ b/src/main/java/de/bixilon/minosoft/assets/util/FileAssetsUtil.kt @@ -18,6 +18,7 @@ import com.github.luben.zstd.ZstdOutputStream import de.bixilon.minosoft.data.registries.ResourceLocation import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition import de.bixilon.minosoft.terminal.RunConfiguration +import de.bixilon.minosoft.util.KUtil.isHexString import de.bixilon.minosoft.util.Util import java.io.* import java.net.URL @@ -29,18 +30,21 @@ object FileAssetsUtil { private val BASE_PATH = RunConfiguration.HOME_DIRECTORY + "assets/objects/" fun getPath(hash: String): String { + if (!hash.isHexString) { + throw IllegalArgumentException("String is not a hex string. Invalid data or manipulated?") + } return BASE_PATH + hash.substring(0, 2) + "/" + hash } - fun downloadAsset(url: String, compress: Boolean = true): String { - return saveAndGet(URL(url).openStream(), compress, false).first + fun downloadAsset(url: String, compress: Boolean = true, hashType: HashTypes = HashTypes.SHA256): String { + return saveAndGet(URL(url).openStream(), compress, false, hashType).first } - fun downloadAndGetAsset(url: String, compress: Boolean = true): Pair { - return saveAndGet(URL(url).openStream(), compress, true) + fun downloadAndGetAsset(url: String, compress: Boolean = true, hashType: HashTypes = HashTypes.SHA256): Pair { + return saveAndGet(URL(url).openStream(), compress, true, hashType) } - fun saveAndGet(stream: InputStream, compress: Boolean = true, get: Boolean = true): Pair { + fun saveAndGet(stream: InputStream, compress: Boolean = true, get: Boolean = true, hashType: HashTypes = HashTypes.SHA256): Pair { var tempFile: File do { tempFile = File(RunConfiguration.TEMPORARY_FOLDER + Util.generateRandomString(32)) @@ -56,7 +60,7 @@ object FileAssetsUtil { } else { ByteArrayOutputStream(0) } - val digest = MessageDigest.getInstance("SHA-1") + val digest = hashType.createDigest() var output: OutputStream = FileOutputStream(tempFile) if (compress) { output = ZstdOutputStream(output) @@ -97,16 +101,16 @@ object FileAssetsUtil { return Pair(hash, returnStream.toByteArray()) } - fun saveAsset(stream: InputStream, compress: Boolean = true): String { - return saveAndGet(stream, compress, false).first + fun saveAsset(stream: InputStream, compress: Boolean = true, hashType: HashTypes = HashTypes.SHA256): String { + return saveAndGet(stream, compress, false, hashType).first } - fun saveAsset(data: ByteArray, compress: Boolean = true): String { - return saveAndGet(ByteArrayInputStream(data), compress, false).first + fun saveAsset(data: ByteArray, compress: Boolean = true, hashType: HashTypes = HashTypes.SHA256): String { + return saveAndGet(ByteArrayInputStream(data), compress, false, hashType).first } - fun saveAndGetAsset(data: ByteArray, compress: Boolean = true): Pair { - return saveAndGet(ByteArrayInputStream(data), compress, false) + fun saveAndGetAsset(data: ByteArray, hashType: HashTypes = HashTypes.SHA256, compress: Boolean = true): Pair { + return saveAndGet(ByteArrayInputStream(data), compress, false, hashType) } fun String.toAssetName(verifyPrefix: Boolean = true): ResourceLocation? { @@ -120,7 +124,7 @@ object FileAssetsUtil { return ResourceLocation(split[0], split[1]) } - fun verifyAsset(hash: String, file: File = File(getPath(hash)), verify: Boolean, compress: Boolean = true): Boolean { + fun verifyAsset(hash: String, file: File = File(getPath(hash)), verify: Boolean, hashType: HashTypes = HashTypes.SHA256, compress: Boolean = true): Boolean { if (!file.exists()) { return false } @@ -137,7 +141,7 @@ object FileAssetsUtil { } try { - val digest = MessageDigest.getInstance("SHA-1") + val digest = hashType.createDigest() var input: InputStream = FileInputStream(file) if (compress) { @@ -163,4 +167,17 @@ object FileAssetsUtil { return false } } + + enum class HashTypes( + val digestName: String, + val length: Int, + ) { + SHA1("SHA-1", 40), + SHA256("SHA-256", 64), + ; + + fun createDigest(): MessageDigest { + return MessageDigest.getInstance(digestName) + } + } } diff --git a/src/main/java/de/bixilon/minosoft/assets/util/FileUtil.kt b/src/main/java/de/bixilon/minosoft/assets/util/FileUtil.kt index b478ce5ae..6612b9c03 100644 --- a/src/main/java/de/bixilon/minosoft/assets/util/FileUtil.kt +++ b/src/main/java/de/bixilon/minosoft/assets/util/FileUtil.kt @@ -29,11 +29,11 @@ import java.util.zip.ZipInputStream object FileUtil { - fun saveReadFile(path: String, compressed: Boolean = true): InputStream? { - return saveReadFile(File(path), compressed) + fun safeReadFile(path: String, compressed: Boolean = true): InputStream? { + return safeReadFile(File(path), compressed) } - fun saveReadFile(file: File, compressed: Boolean = true): InputStream? { + fun safeReadFile(file: File, compressed: Boolean = true): InputStream? { if (!file.exists()) { return null } diff --git a/src/main/java/de/bixilon/minosoft/config/profile/profiles/eros/server/entries/Server.kt b/src/main/java/de/bixilon/minosoft/config/profile/profiles/eros/server/entries/Server.kt index ebb76f2d8..a1222bf0f 100644 --- a/src/main/java/de/bixilon/minosoft/config/profile/profiles/eros/server/entries/Server.kt +++ b/src/main/java/de/bixilon/minosoft/config/profile/profiles/eros/server/entries/Server.kt @@ -16,6 +16,7 @@ package de.bixilon.minosoft.config.profile.profiles.eros.server.entries import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty +import de.bixilon.minosoft.assets.util.FileAssetsUtil import de.bixilon.minosoft.config.profile.profiles.eros.ErosProfileManager.backingDelegate import de.bixilon.minosoft.config.profile.profiles.eros.ErosProfileManager.delegate import de.bixilon.minosoft.config.profile.profiles.eros.ErosProfileManager.mapDelegate @@ -56,5 +57,5 @@ class Server( var forcedVersion by backingDelegate(getter = { Versions[_forcedVersion] }, setter = { _forcedVersion = it?.name }) @get:JsonInclude(JsonInclude.Include.NON_DEFAULT) - var faviconHash: String? by delegate(null) { if (it != null) check(it.length == 40) { "Not a valid sha1 hash!" } } + var faviconHash: String? by delegate(null) { if (it != null) check(it.length == FileAssetsUtil.HashTypes.SHA256.length) { "Not a valid sha256 hash!" } } } diff --git a/src/main/java/de/bixilon/minosoft/data/player/properties/textures/PlayerTexture.kt b/src/main/java/de/bixilon/minosoft/data/player/properties/textures/PlayerTexture.kt index 70b6bf95e..0439b5b5f 100644 --- a/src/main/java/de/bixilon/minosoft/data/player/properties/textures/PlayerTexture.kt +++ b/src/main/java/de/bixilon/minosoft/data/player/properties/textures/PlayerTexture.kt @@ -1,19 +1,43 @@ package de.bixilon.minosoft.data.player.properties.textures +import de.bixilon.minosoft.assets.util.FileAssetsUtil +import de.bixilon.minosoft.assets.util.FileUtil import de.bixilon.minosoft.util.KUtil.check import java.net.URL open class PlayerTexture( val url: URL, ) { + var data: ByteArray? = null + private set + init { url.check() check(urlMatches(url, ALLOWED_DOMAINS) && !urlMatches(url, BLOCKED_DOMAINS)) { "URL hostname is not allowed!" } } + fun read(): ByteArray { + val sha256 = when (url.host) { + "textures.minecraft.net" -> url.file.split("/").last() + else -> TODO("Can not get texture identifier!") + } + val file = FileUtil.safeReadFile(FileAssetsUtil.getPath(sha256), true)?.let { + val data = it.readAllBytes() + this.data = data + return data + } + + val input = url.openStream() + if (input.available() > MAX_TEXTURE_SIZE) { + throw IllegalStateException("Texture is too big!") + } + val data = FileAssetsUtil.saveAndGet(input) + return data.second + } companion object { + private const val MAX_TEXTURE_SIZE = 64 * 64 * 3 + 100 // width * height * rgb + some padding private val ALLOWED_DOMAINS = arrayOf(".minecraft.net", ".mojang.com") private val BLOCKED_DOMAINS = arrayOf("bugs.mojang.com", "education.minecraft.net", "feedback.minecraft.net") diff --git a/src/main/java/de/bixilon/minosoft/data/registries/registries/RegistriesLoader.kt b/src/main/java/de/bixilon/minosoft/data/registries/registries/RegistriesLoader.kt index 538c5dd79..f68bbfbec 100644 --- a/src/main/java/de/bixilon/minosoft/data/registries/registries/RegistriesLoader.kt +++ b/src/main/java/de/bixilon/minosoft/data/registries/registries/RegistriesLoader.kt @@ -39,7 +39,7 @@ object RegistriesLoader { "hashPrefix" to hash.substring(0, 2), "fullHash" to hash, ) - ), false) + ), false, hashType = FileAssetsUtil.HashTypes.SHA1) if (savedHash.first != hash) { throw IllegalStateException("Data mismatch, expected $hash, got ${savedHash.first}") } diff --git a/src/main/java/de/bixilon/minosoft/gui/eros/main/play/server/card/FaviconManager.kt b/src/main/java/de/bixilon/minosoft/gui/eros/main/play/server/card/FaviconManager.kt index 3ec47d4a4..e253cc521 100644 --- a/src/main/java/de/bixilon/minosoft/gui/eros/main/play/server/card/FaviconManager.kt +++ b/src/main/java/de/bixilon/minosoft/gui/eros/main/play/server/card/FaviconManager.kt @@ -22,7 +22,7 @@ object FaviconManager { return null } - fun Server.saveFavicon(favicon: ByteArray?, faviconHash: String = Util.sha1(favicon)) { + fun Server.saveFavicon(favicon: ByteArray?, faviconHash: String = Util.sha256(favicon)) { if (this.faviconHash == faviconHash) { return } diff --git a/src/main/java/de/bixilon/minosoft/util/KUtil.kt b/src/main/java/de/bixilon/minosoft/util/KUtil.kt index a3ab6c02a..04c979570 100644 --- a/src/main/java/de/bixilon/minosoft/util/KUtil.kt +++ b/src/main/java/de/bixilon/minosoft/util/KUtil.kt @@ -564,4 +564,14 @@ object KUtil { fun URL.check() { check(this.protocol == "http" || this.protocol == "https") { "Url is not a web address" } } + + val String.isHexString: Boolean + get() { + for (digit in toCharArray()) { + if (digit !in '0'..'9' && digit !in 'a'..'f') { + return false + } + } + return true + } } diff --git a/src/main/java/de/bixilon/minosoft/util/Util.java b/src/main/java/de/bixilon/minosoft/util/Util.java index f96860837..4d1b87b6a 100644 --- a/src/main/java/de/bixilon/minosoft/util/Util.java +++ b/src/main/java/de/bixilon/minosoft/util/Util.java @@ -139,6 +139,16 @@ public final class Util { return null; } + public static String sha256(byte[] data) { + ByteArrayInputStream inputStream = new ByteArrayInputStream(data); + try { + return sha256(inputStream); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + public static String sha1(File file) throws IOException { return sha1(new FileInputStream(file)); } @@ -149,15 +159,27 @@ public final class Util { public static String sha1(InputStream inputStream) throws IOException { try { - MessageDigest crypt = MessageDigest.getInstance("SHA-1"); - crypt.reset(); + return hash(MessageDigest.getInstance("SHA-1"), inputStream); + } catch (NoSuchAlgorithmException | FileNotFoundException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } - byte[] buffer = new byte[ProtocolDefinition.DEFAULT_BUFFER_SIZE]; - int length; - while ((length = inputStream.read(buffer, 0, buffer.length)) != -1) { - crypt.update(buffer, 0, length); - } - return byteArrayToHexString(crypt.digest()); + private static String hash(MessageDigest digest, InputStream inputStream) throws IOException { + digest.reset(); + + byte[] buffer = new byte[ProtocolDefinition.DEFAULT_BUFFER_SIZE]; + int length; + while ((length = inputStream.read(buffer, 0, buffer.length)) != -1) { + digest.update(buffer, 0, length); + } + return byteArrayToHexString(digest.digest()); + } + + public static String sha256(InputStream inputStream) throws IOException { + try { + return hash(MessageDigest.getInstance("SHA-256"), inputStream); } catch (NoSuchAlgorithmException | FileNotFoundException e) { e.printStackTrace(); throw new RuntimeException(e);