assets: allow sha256 hashes, check if hash is hex asset (security), save player textures

This commit is contained in:
Bixilon 2021-12-15 10:51:28 +01:00
parent f8f45818cd
commit e7f3ac8ecd
No known key found for this signature in database
GPG Key ID: 5CAD791931B09AC4
11 changed files with 109 additions and 35 deletions

View File

@ -78,6 +78,6 @@ class DirectoryAssetsManager(
if (path !in assets) {
return null
}
return FileUtil.saveReadFile(path.filePath, false)
return FileUtil.safeReadFile(path.filePath, false)
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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<String, ByteArray> {
return saveAndGet(URL(url).openStream(), compress, true)
fun downloadAndGetAsset(url: String, compress: Boolean = true, hashType: HashTypes = HashTypes.SHA256): Pair<String, ByteArray> {
return saveAndGet(URL(url).openStream(), compress, true, hashType)
}
fun saveAndGet(stream: InputStream, compress: Boolean = true, get: Boolean = true): Pair<String, ByteArray> {
fun saveAndGet(stream: InputStream, compress: Boolean = true, get: Boolean = true, hashType: HashTypes = HashTypes.SHA256): Pair<String, ByteArray> {
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<String, ByteArray> {
return saveAndGet(ByteArrayInputStream(data), compress, false)
fun saveAndGetAsset(data: ByteArray, hashType: HashTypes = HashTypes.SHA256, compress: Boolean = true): Pair<String, ByteArray> {
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)
}
}
}

View File

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

View File

@ -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!" } }
}

View File

@ -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")

View File

@ -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}")
}

View File

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

View File

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

View File

@ -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);