From 482d337fcb716ab08b8df13558ae1df26d5d2099 Mon Sep 17 00:00:00 2001 From: Moritz Zwerger Date: Wed, 20 Dec 2023 18:32:26 +0100 Subject: [PATCH] basic update checking No updates available yet...Lets see. --- doc/Updater.md | 58 ++++++++++ .../minosoft/updater/MinosoftUpdateTest.kt | 33 ++++++ .../util/yggdrasil/YggdrasilUtilTest.kt | 2 +- src/main/java/de/bixilon/minosoft/Minosoft.kt | 12 +++ .../profile/profiles/other/OtherProfile.kt | 2 + .../profiles/other/updater/UpdaterC.kt | 55 ++++++++++ .../properties/textures/PlayerTextures.kt | 4 +- .../events/click/InternalCommandClickEvent.kt | 51 +++++++++ .../de/bixilon/minosoft/main/BootTasks.kt | 2 +- .../de/bixilon/minosoft/main/MinosoftBoot.kt | 3 +- .../properties/MinosoftPropertiesLoader.kt | 5 +- .../minosoft/properties/general/GeneralP.kt | 3 +- .../minosoft/protocol/PlayerPublicKey.kt | 6 +- .../terminal/{commands => }/Commands.kt | 5 +- .../de/bixilon/minosoft/terminal/cli/CLI.kt | 6 +- .../terminal/commands/UpdateCommand.kt | 101 ++++++++++++++++++ .../bixilon/minosoft/updater/DownloadLink.kt | 23 ++++ .../minosoft/updater/MinosoftUpdate.kt | 45 ++++++++ .../minosoft/updater/MinosoftUpdater.kt | 90 ++++++++++++++++ .../de/bixilon/minosoft/updater/UpdateKey.kt | 20 ++++ .../minosoft/updater/UpdateProgress.kt | 37 +++++++ .../account/minecraft/MinecraftPrivateKey.kt | 2 +- .../account/minecraft/key/MinecraftKeyPair.kt | 4 +- .../SignatureException.kt} | 6 +- .../util/signature/SignatureSigner.kt | 64 +++++++++++ .../minosoft/util/yggdrasil/YggdrasilUtil.kt | 50 ++------- .../assets/minosoft/updater/release.pub | 14 +++ 27 files changed, 639 insertions(+), 64 deletions(-) create mode 100644 doc/Updater.md create mode 100644 src/integration-test/kotlin/de/bixilon/minosoft/updater/MinosoftUpdateTest.kt create mode 100644 src/main/java/de/bixilon/minosoft/config/profile/profiles/other/updater/UpdaterC.kt create mode 100644 src/main/java/de/bixilon/minosoft/data/text/events/click/InternalCommandClickEvent.kt rename src/main/java/de/bixilon/minosoft/terminal/{commands => }/Commands.kt (91%) create mode 100644 src/main/java/de/bixilon/minosoft/terminal/commands/UpdateCommand.kt create mode 100644 src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt create mode 100644 src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt create mode 100644 src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt create mode 100644 src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt create mode 100644 src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt rename src/main/java/de/bixilon/minosoft/util/{yggdrasil/YggdrasilException.kt => signature/SignatureException.kt} (83%) create mode 100644 src/main/java/de/bixilon/minosoft/util/signature/SignatureSigner.kt create mode 100644 src/main/resources/assets/minosoft/updater/release.pub diff --git a/doc/Updater.md b/doc/Updater.md new file mode 100644 index 000000000..9e6a1c0a8 --- /dev/null +++ b/doc/Updater.md @@ -0,0 +1,58 @@ +# Updater + +Minosoft can (and will by default; it asks first) check for updates at startup. + +## Update check + +Minosoft fetches a configured url with the following parameters: + +- current commit (if available) +- current version +- configured update channel (`stable` by default) +- os: current operating system (binaries are platform specific) +- arch: current cpu architecture (e.g. `x64` or `x86`) + +### Response (v1) + +The server internally checks if the user can receive updates for the version or platform. If no update is available, it responds with `204 No content`. +Otherwise it responds with `200 OK` and returns a json object: + +```json +{ + "id": "Version id", + "name": "Display text of the version", + "stable": "Build from a tag or not", + "page": "", + "date": "Unix timestamp (in seconds) of the release date", + "download": { + // optional + "url": "https:// where to download it", + "size": , + "sha512": "SHA512 hash of the binary", + "signature": "Release signature" + }, + "release_notes": "" +} +``` + +All urls in the response **must** start with `https://` or be a localhost link. + +## Update process + +When an update is available, it prompts the user the install it. It tries to store the file in the current directory (if possible) or asks the user where to store it. +Once it is downloaded, minosoft asks the user to restart. It will then quit and starts the new jar. + +The client will refuse to update, if the release date of the next version is lower than the version currently running (i.e. no downgrades) + +## Signature + +The signature is created by base64 appending the following strings (in order) together and then using RSA (`SHA512withRSA`) to sign it: + +1. Version id +2. release date (number to string) +3. Release page (optional) +4. sha512 hash of the binary + +## Future + +- Maybe split the fat jar and download all dependencies individual (reduces size of the binary; lowers traffic) diff --git a/src/integration-test/kotlin/de/bixilon/minosoft/updater/MinosoftUpdateTest.kt b/src/integration-test/kotlin/de/bixilon/minosoft/updater/MinosoftUpdateTest.kt new file mode 100644 index 000000000..224a2186d --- /dev/null +++ b/src/integration-test/kotlin/de/bixilon/minosoft/updater/MinosoftUpdateTest.kt @@ -0,0 +1,33 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import org.testng.annotations.Test + + +@Test +class MinosoftUpdateTest { + + fun `invalid signature`() { + TODO() + } + + fun `older signature`() { + TODO() + } + + fun `correct signature`() { + + } +} diff --git a/src/integration-test/kotlin/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtilTest.kt b/src/integration-test/kotlin/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtilTest.kt index c344e1f9a..def6d8692 100644 --- a/src/integration-test/kotlin/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtilTest.kt +++ b/src/integration-test/kotlin/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtilTest.kt @@ -51,6 +51,6 @@ class YggdrasilUtilTest { val texture = "ewogICJ0aW1lc3RhbXAiIDogMTY5MDQwMTIzODM2NCwKICAicHJvZmlsZUlkIiA6ICJhODUxNzQ0MDNlNjg0MDgwYWNkODU3MzhlMjI5NGNhZiIsCiAgInByb2ZpbGVOYW1lIiA6ICJEYVJpdmVyc09uZSIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS80YjJjMjAwYjA1ZWY4NDhiY2IyZGM2YTBlOGY2OTQ0MWE3YzE3Mzg4Y2FjY2UzNzAxMzg5YTU0OGM2NTdjMzZkIgogICAgfQogIH0KfQ==" val signature = "" - assertThrows { YggdrasilUtil.requireSignature(texture, signature) } + assertThrows { YggdrasilUtil.require(texture, signature) } } } diff --git a/src/main/java/de/bixilon/minosoft/Minosoft.kt b/src/main/java/de/bixilon/minosoft/Minosoft.kt index 449706536..6c8cb9009 100644 --- a/src/main/java/de/bixilon/minosoft/Minosoft.kt +++ b/src/main/java/de/bixilon/minosoft/Minosoft.kt @@ -32,6 +32,7 @@ import de.bixilon.kutil.unit.UnitFormatter.formatNanos import de.bixilon.minosoft.assets.IntegratedAssets import de.bixilon.minosoft.config.StaticConfiguration import de.bixilon.minosoft.config.profile.profiles.eros.ErosProfileManager +import de.bixilon.minosoft.config.profile.profiles.other.OtherProfileManager import de.bixilon.minosoft.data.language.IntegratedLanguage import de.bixilon.minosoft.data.text.formatting.FormattingCodes import de.bixilon.minosoft.data.text.formatting.color.ChatColors @@ -49,6 +50,7 @@ import de.bixilon.minosoft.properties.MinosoftPropertiesLoader import de.bixilon.minosoft.terminal.AutoConnect import de.bixilon.minosoft.terminal.CommandLineArguments import de.bixilon.minosoft.terminal.RunConfiguration +import de.bixilon.minosoft.updater.MinosoftUpdater import de.bixilon.minosoft.util.KUtil import de.bixilon.minosoft.util.json.Jackson import de.bixilon.minosoft.util.logging.Log @@ -110,6 +112,8 @@ object Minosoft { KUtil.initPlayClasses() GlobalEventMaster.fire(FinishBootEvent()) DefaultThreadPool += { DefaultModPhases.POST.load(); Log.log(LogMessageType.MOD_LOADING, LogLevels.INFO) { "Mod loading completed!" } } + checkForUpdates() + if (RunConfiguration.DISABLE_EROS) { Log.log(LogMessageType.GENERAL, LogLevels.WARN) { "Eros is disabled, no gui will show up! Use the cli to connect to servers!" } } @@ -163,4 +167,12 @@ object Minosoft { Log.log(LogMessageType.GENERAL, LogLevels.WARN) { "You are using macOS. To use rendering you must not set the jvm argument §9-XstartOnFirstThread§r. Please remove it!" } ShutdownManager.shutdown(reason = AbstractShutdownReason.CRASH) } + + fun checkForUpdates() { + if (!OtherProfileManager.selected.updater.check) return + DefaultThreadPool += ForcePooledRunnable(priority = ThreadPool.LOW) { + val update = MinosoftUpdater.check() ?: return@ForcePooledRunnable + Log.log(LogMessageType.OTHER, LogLevels.INFO) { "A new update is available: ${update.name} (${update.id}). Type \"update\" or click in the gui to update." } + } + } } diff --git a/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/OtherProfile.kt b/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/OtherProfile.kt index 1eca4f7d4..6f72ea16c 100644 --- a/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/OtherProfile.kt +++ b/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/OtherProfile.kt @@ -18,6 +18,7 @@ import de.bixilon.minosoft.config.profile.ProfileType import de.bixilon.minosoft.config.profile.delegate.primitive.BooleanDelegate import de.bixilon.minosoft.config.profile.profiles.Profile import de.bixilon.minosoft.config.profile.profiles.other.log.LogC +import de.bixilon.minosoft.config.profile.profiles.other.updater.UpdaterC import de.bixilon.minosoft.config.profile.storage.ProfileStorage import de.bixilon.minosoft.data.registries.identified.Namespaces.minosoft import org.kordamp.ikonli.Ikon @@ -42,6 +43,7 @@ class OtherProfile( var listenLAN by BooleanDelegate(this, true) val log = LogC(this) + val updater = UpdaterC(this) override fun toString(): String { diff --git a/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/updater/UpdaterC.kt b/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/updater/UpdaterC.kt new file mode 100644 index 000000000..4306f1840 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/config/profile/profiles/other/updater/UpdaterC.kt @@ -0,0 +1,55 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.config.profile.profiles.other.updater + +import de.bixilon.minosoft.config.profile.delegate.primitive.BooleanDelegate +import de.bixilon.minosoft.config.profile.delegate.types.NullableStringDelegate +import de.bixilon.minosoft.config.profile.delegate.types.StringDelegate +import de.bixilon.minosoft.config.profile.profiles.other.OtherProfile + +class UpdaterC(profile: OtherProfile) { + + + /** + * The user did not decide whether to check for updates + */ + var ask by BooleanDelegate(profile, true) + + /** + * Check for updates + */ + var check by BooleanDelegate(profile, true) + + /** + * Update channel + * If automatic, it chooses either stable or beta matching the current executed version + */ + var channel by StringDelegate(profile, "auto") + + /** + * URL to check for updates + * Possible variables: + * - VERSION: current version string + * - CHANNEL: target channel + * - COMMIT: optional: the current commit + * - OS: Operating system (windows, linux, mac, ...) + * - ARCH: architecture of the current build (x86, x64, arm64) + */ + var url by StringDelegate(profile, "https://minosoft.bixilon.de/v1/updates?version=\${VERSION}&channel=\${CHANNEL}&commit=\${COMMIT}&os=\${OS}&arch=\${ARCH}") + + /** + * If the newest version matches this field, it won't be shown + */ + var dismiss by NullableStringDelegate(profile, null) +} diff --git a/src/main/java/de/bixilon/minosoft/data/entities/entities/player/properties/textures/PlayerTextures.kt b/src/main/java/de/bixilon/minosoft/data/entities/entities/player/properties/textures/PlayerTextures.kt index bd817bcb1..d8656a0ca 100644 --- a/src/main/java/de/bixilon/minosoft/data/entities/entities/player/properties/textures/PlayerTextures.kt +++ b/src/main/java/de/bixilon/minosoft/data/entities/entities/player/properties/textures/PlayerTextures.kt @@ -19,7 +19,7 @@ import de.bixilon.kutil.json.JsonUtil.toJsonObject import de.bixilon.kutil.primitive.LongUtil.toLong import de.bixilon.kutil.uuid.UUIDUtil.toUUID import de.bixilon.minosoft.util.json.Jackson -import de.bixilon.minosoft.util.yggdrasil.YggdrasilException +import de.bixilon.minosoft.util.signature.SignatureException import de.bixilon.minosoft.util.yggdrasil.YggdrasilUtil import java.util.* @@ -36,7 +36,7 @@ data class PlayerTextures( fun of(encoded: String, signature: String): PlayerTextures { if (!YggdrasilUtil.verify(encoded, signature)) { - throw YggdrasilException("Texture signature is invalid!") + throw SignatureException("Texture signature is invalid!") } val json: Map = Jackson.MAPPER.readValue(Base64.getDecoder().decode(encoded), Jackson.JSON_MAP_TYPE) diff --git a/src/main/java/de/bixilon/minosoft/data/text/events/click/InternalCommandClickEvent.kt b/src/main/java/de/bixilon/minosoft/data/text/events/click/InternalCommandClickEvent.kt new file mode 100644 index 000000000..51b52e516 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/data/text/events/click/InternalCommandClickEvent.kt @@ -0,0 +1,51 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.data.text.events.click + +import de.bixilon.kotlinglm.vec2.Vec2 +import de.bixilon.kutil.json.JsonObject +import de.bixilon.minosoft.gui.rendering.gui.GUIRenderer +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseActions +import de.bixilon.minosoft.gui.rendering.gui.input.mouse.MouseButtons +import de.bixilon.minosoft.terminal.cli.CLI + +class InternalCommandClickEvent( + val command: String, +) : ClickEvent { + + override fun onClick(guiRenderer: GUIRenderer, position: Vec2, button: MouseButtons, action: MouseActions) { + if (button != MouseButtons.LEFT || action != MouseActions.PRESS) { + return + } + CLI.execute(command) + } + + override fun hashCode(): Int { + return command.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is InternalCommandClickEvent) return false + return other.command == command + } + + companion object : ClickEventFactory { + override val name: String = "internal_command" + + override fun build(json: JsonObject, restricted: Boolean): InternalCommandClickEvent { + if (restricted) throw IllegalStateException("Its restricted") + return InternalCommandClickEvent(json.data.toString()) + } + } +} diff --git a/src/main/java/de/bixilon/minosoft/main/BootTasks.kt b/src/main/java/de/bixilon/minosoft/main/BootTasks.kt index c67275d8b..1b3859529 100644 --- a/src/main/java/de/bixilon/minosoft/main/BootTasks.kt +++ b/src/main/java/de/bixilon/minosoft/main/BootTasks.kt @@ -20,7 +20,7 @@ enum class BootTasks { ASSETS_PROPERTIES, DEFAULT_REGISTRIES, LAN_SERVERS, - YGGDRASIL, + KEYS, STARTUP_PROGRESS, ASSETS_OVERRIDE, CLI, diff --git a/src/main/java/de/bixilon/minosoft/main/MinosoftBoot.kt b/src/main/java/de/bixilon/minosoft/main/MinosoftBoot.kt index 0800c5779..83ba92141 100644 --- a/src/main/java/de/bixilon/minosoft/main/MinosoftBoot.kt +++ b/src/main/java/de/bixilon/minosoft/main/MinosoftBoot.kt @@ -29,6 +29,7 @@ import de.bixilon.minosoft.modding.loader.phase.DefaultModPhases import de.bixilon.minosoft.protocol.protocol.LANServerListener import de.bixilon.minosoft.protocol.versions.VersionLoader import de.bixilon.minosoft.terminal.cli.CLI +import de.bixilon.minosoft.updater.UpdateKey import de.bixilon.minosoft.util.yggdrasil.YggdrasilUtil object MinosoftBoot { @@ -44,7 +45,7 @@ object MinosoftBoot { worker += WorkerTask(identifier = BootTasks.LAN_SERVERS, dependencies = arrayOf(BootTasks.PROFILES), executor = LANServerListener::listen) - worker += WorkerTask(identifier = BootTasks.YGGDRASIL, executor = { YggdrasilUtil.load() }) + worker += WorkerTask(identifier = BootTasks.KEYS, executor = { YggdrasilUtil.load(); UpdateKey.load() }) worker += WorkerTask(identifier = BootTasks.ASSETS_OVERRIDE, executor = { IntegratedAssets.OVERRIDE.load(it) }) worker += WorkerTask(identifier = BootTasks.MODS, executor = { DefaultModPhases.BOOT.load(it) }) diff --git a/src/main/java/de/bixilon/minosoft/properties/MinosoftPropertiesLoader.kt b/src/main/java/de/bixilon/minosoft/properties/MinosoftPropertiesLoader.kt index b002dd45f..8fb8d6f9b 100644 --- a/src/main/java/de/bixilon/minosoft/properties/MinosoftPropertiesLoader.kt +++ b/src/main/java/de/bixilon/minosoft/properties/MinosoftPropertiesLoader.kt @@ -31,8 +31,9 @@ object MinosoftPropertiesLoader { fun load() { val properties = IntegratedAssets.DEFAULT.getOrNull(minosoft("version.json"))?.readJson(reader = reader) MinosoftProperties = if (properties == null) { - Log.log(LogMessageType.OTHER, LogLevels.FATAL) { "Can not load version.json! Did you compile with gradle?" } - MinosoftP(GeneralP("unknown", false), null) + // it should be fatal, but then users report it as error if they use an ide or ... + Log.log(LogMessageType.OTHER, LogLevels.WARN) { "Can not load build info! Do you have git installed and compiled with gradle? Updater will not work." } + MinosoftP(GeneralP("unknown", 0L, false), null) } else { properties } diff --git a/src/main/java/de/bixilon/minosoft/properties/general/GeneralP.kt b/src/main/java/de/bixilon/minosoft/properties/general/GeneralP.kt index a5d3ea878..983912b1b 100644 --- a/src/main/java/de/bixilon/minosoft/properties/general/GeneralP.kt +++ b/src/main/java/de/bixilon/minosoft/properties/general/GeneralP.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2022 Moritz Zwerger + * Copyright (C) 2020-2023 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -15,5 +15,6 @@ package de.bixilon.minosoft.properties.general class GeneralP( val name: String, + val date: Long, val stable: Boolean, ) diff --git a/src/main/java/de/bixilon/minosoft/protocol/PlayerPublicKey.kt b/src/main/java/de/bixilon/minosoft/protocol/PlayerPublicKey.kt index 1025f9642..f1bc055b3 100644 --- a/src/main/java/de/bixilon/minosoft/protocol/PlayerPublicKey.kt +++ b/src/main/java/de/bixilon/minosoft/protocol/PlayerPublicKey.kt @@ -21,7 +21,7 @@ import de.bixilon.minosoft.protocol.protocol.ProtocolVersions import de.bixilon.minosoft.protocol.protocol.encryption.CryptManager import de.bixilon.minosoft.protocol.protocol.encryption.CryptManager.encodeNetwork import de.bixilon.minosoft.util.account.minecraft.key.MinecraftKeyPair -import de.bixilon.minosoft.util.yggdrasil.YggdrasilException +import de.bixilon.minosoft.util.signature.SignatureException import de.bixilon.minosoft.util.yggdrasil.YggdrasilUtil import java.nio.charset.StandardCharsets import java.security.PublicKey @@ -60,9 +60,9 @@ class PlayerPublicKey( fun requireSignature(versionId: Int, uuid: UUID) { if (versionId < ProtocolVersions.V_1_19_1_PRE4) { - if (!isLegacySignatureCorrect()) throw YggdrasilException() + if (!isLegacySignatureCorrect()) throw SignatureException() } else { - if (!isSignatureCorrect(uuid)) throw YggdrasilException() + if (!isSignatureCorrect(uuid)) throw SignatureException() } } diff --git a/src/main/java/de/bixilon/minosoft/terminal/commands/Commands.kt b/src/main/java/de/bixilon/minosoft/terminal/Commands.kt similarity index 91% rename from src/main/java/de/bixilon/minosoft/terminal/commands/Commands.kt rename to src/main/java/de/bixilon/minosoft/terminal/Commands.kt index 4f7213d2c..d5d27c01d 100644 --- a/src/main/java/de/bixilon/minosoft/terminal/commands/Commands.kt +++ b/src/main/java/de/bixilon/minosoft/terminal/Commands.kt @@ -11,8 +11,9 @@ * This software is not affiliated with Mojang AB, the original developer of Minecraft. */ -package de.bixilon.minosoft.terminal.commands +package de.bixilon.minosoft.terminal +import de.bixilon.minosoft.terminal.commands.* import de.bixilon.minosoft.terminal.commands.connection.* import de.bixilon.minosoft.terminal.commands.rendering.ReloadCommand @@ -27,7 +28,7 @@ object Commands { CrashCommand, DumpCommand, - AboutCommand, + AboutCommand, UpdateCommand, SayCommand, diff --git a/src/main/java/de/bixilon/minosoft/terminal/cli/CLI.kt b/src/main/java/de/bixilon/minosoft/terminal/cli/CLI.kt index 026db7f48..4e69b1b38 100644 --- a/src/main/java/de/bixilon/minosoft/terminal/cli/CLI.kt +++ b/src/main/java/de/bixilon/minosoft/terminal/cli/CLI.kt @@ -24,7 +24,7 @@ import de.bixilon.minosoft.commands.errors.ReaderError import de.bixilon.minosoft.commands.nodes.RootNode import de.bixilon.minosoft.main.MinosoftBoot import de.bixilon.minosoft.protocol.network.connection.play.PlayConnection -import de.bixilon.minosoft.terminal.commands.Commands +import de.bixilon.minosoft.terminal.Commands import de.bixilon.minosoft.terminal.commands.connection.ConnectionCommand import de.bixilon.minosoft.util.logging.Log import de.bixilon.minosoft.util.logging.LogLevels @@ -95,7 +95,7 @@ object CLI { } if (line.isBlank()) continue - processLine(line) + execute(line) } } @@ -104,7 +104,7 @@ object CLI { Log.log(LogMessageType.GENERAL, LogLevels.WARN) { "End of file error in cli thread. Disabling cli." } } - private fun processLine(line: String) { + fun execute(line: String) { try { commands.execute(line, connection) } catch (error: ReaderError) { diff --git a/src/main/java/de/bixilon/minosoft/terminal/commands/UpdateCommand.kt b/src/main/java/de/bixilon/minosoft/terminal/commands/UpdateCommand.kt new file mode 100644 index 000000000..d86695359 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/terminal/commands/UpdateCommand.kt @@ -0,0 +1,101 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.terminal.commands + +import de.bixilon.kutil.concurrent.pool.DefaultThreadPool +import de.bixilon.minosoft.commands.nodes.LiteralNode +import de.bixilon.minosoft.commands.stack.print.PrintTarget +import de.bixilon.minosoft.config.profile.profiles.other.OtherProfileManager +import de.bixilon.minosoft.data.text.BaseComponent +import de.bixilon.minosoft.data.text.TextComponent +import de.bixilon.minosoft.data.text.events.click.InternalCommandClickEvent +import de.bixilon.minosoft.updater.MinosoftUpdate +import de.bixilon.minosoft.updater.MinosoftUpdater +import de.bixilon.minosoft.updater.UpdateProgress + +object UpdateCommand : Command { + override var node: LiteralNode = LiteralNode("update", executor = { it.print.check() }).addChild( + LiteralNode("notes", executor = { + val update = MinosoftUpdater.update + if (update != null) { + update.printNotes(it.print) + return@LiteralNode + } + it.print.print("Fetching update release notes...") + DefaultThreadPool += { + val update = MinosoftUpdater.check() + if (update == null) { + print("No update available!") + } else { + update.printNotes(it.print) + } + } + }), + LiteralNode("update", executor = { + val update = MinosoftUpdater.update + if (update != null) { + DefaultThreadPool += { + val progress = UpdateProgress(log = it.print) + MinosoftUpdater.update(update, progress) + } + return@LiteralNode + } + it.print.print("Fetching update details...") + DefaultThreadPool += { + val update = MinosoftUpdater.check() + if (update == null) { + print("No update available!") + } else { + DefaultThreadPool += { + val progress = UpdateProgress(log = it.print) + MinosoftUpdater.update(update, progress) + } + } + } + }), + LiteralNode("dismiss", executor = { + val update = MinosoftUpdater.update + if (update == null) { + it.print.print("§cNot checked for updates!") + return@LiteralNode + } + it.print.print("Dismissed update ${update.name} (${update.id})") + OtherProfileManager.selected.updater.dismiss = update.id + }), + ) + + private fun PrintTarget.check() { + print("Checking for updates...") + DefaultThreadPool += { + val update = MinosoftUpdater.check() + if (update == null) { + print("No update available!") + } else { + print("There is a new update available:") + print("Version: ${update.name} (${update.id})") + print(BaseComponent("Run ", TextComponent("\"update notes\"").clickEvent(InternalCommandClickEvent("update notes")), " to see the release notes.")) + print(BaseComponent("Run ", TextComponent("\"update update\"").clickEvent(InternalCommandClickEvent("update update")), " to download and update.")) + } + } + } + + private fun MinosoftUpdate.printNotes(target: PrintTarget) { + if (releaseNotes == null) { + target.print("No release notes for $name ($id) available.") + return + } + target.print("Release notes for update $name ($id):") + target.print(releaseNotes) + } +} diff --git a/src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt b/src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt new file mode 100644 index 000000000..963633d77 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt @@ -0,0 +1,23 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import java.net.URL + +data class DownloadLink( + val url: URL, + val size: Int, + val sha512: String, + val signature: String, +) diff --git a/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt b/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt new file mode 100644 index 000000000..862e26515 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt @@ -0,0 +1,45 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import de.bixilon.minosoft.data.text.ChatComponent +import de.bixilon.minosoft.properties.MinosoftProperties +import java.net.URL + +data class MinosoftUpdate( + val id: String, + val name: String, + val date: Long, + val stable: Boolean, + val page: URL? = null, + val download: DownloadLink? = null, + val releaseNotes: ChatComponent? = null, +) { + + init { + verify() + } + + private fun verify() { + if (MinosoftProperties.general.date >= date) throw Exception("Update is older than the current version!") + if (this.download == null) return + val builder = StringBuilder() + builder.append(id) + builder.append(date) + page?.let { builder.append(it) } + builder.append(download.sha512) + + UpdateKey.require(builder.toString(), download.signature) + } +} diff --git a/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt b/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt new file mode 100644 index 000000000..345433dd4 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt @@ -0,0 +1,90 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import de.bixilon.kutil.observer.DataObserver.Companion.observed +import de.bixilon.kutil.os.PlatformInfo +import de.bixilon.kutil.string.StringUtil.formatPlaceholder +import de.bixilon.kutil.url.URLUtil.toURL +import de.bixilon.minosoft.config.profile.profiles.other.OtherProfileManager +import de.bixilon.minosoft.properties.MinosoftProperties +import de.bixilon.minosoft.util.http.HTTP2.getJson +import de.bixilon.minosoft.util.http.exceptions.HTTPException +import de.bixilon.minosoft.util.json.Jackson +import java.net.URL + +object MinosoftUpdater { + var update: MinosoftUpdate? by observed(null) + private set + + private fun validateURL(url: URL) { + if (url.protocol == "https") return + if (url.protocol == "http") { + if (url.host == "localhost" || url.host == "127.0.0.1") return + throw IllegalArgumentException("Using non secure hosts on http is not allowed: $url!") + } + + throw IllegalStateException("Illegal protocol: $url") + } + + fun check(): MinosoftUpdate? { + val profile = OtherProfileManager.selected.updater + return check(profile.url.toURL(), profile.channel) + } + + + fun check(url: URL, channel: String): MinosoftUpdate? { + validateURL(url) + + val commit = MinosoftProperties.git?.commit ?: "" + val version = MinosoftProperties.general.name + val stable = MinosoftProperties.general.stable + val os = PlatformInfo.OS + val arch = PlatformInfo.ARCHITECTURE + + val request = url.toString().formatPlaceholder( + "\${COMMIT}" to commit, + "\${VERSION}" to version, + "\${STABLE}" to stable, + "\${OS}" to os, + "\${ARCH}" to arch, + "\${CHANNEL}" to channel, + ) + val update = request(request) + this.update = update + return update + } + + private fun request(url: String): MinosoftUpdate? { + val response = url.getJson() + + return when (response.statusCode) { + 204 -> null + 200 -> Jackson.MAPPER.convertValue(response.body, MinosoftUpdate::class.java) + else -> throw HTTPException(response.statusCode, response.body.toString()) + } + } + + fun update(update: MinosoftUpdate, progress: UpdateProgress) { + val download = update.download + if (download == null) { + progress.log?.print("Update is unavailable for download. Please download it manually!") + return + } + progress.log?.print("Downloading update...") + + progress.log?.print("TODO :)") + progress.stage = UpdateProgress.UpdateStage.FAILED + } +} diff --git a/src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt b/src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt new file mode 100644 index 000000000..b755d721f --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt @@ -0,0 +1,20 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import de.bixilon.minosoft.data.registries.identified.Namespaces.minosoft +import de.bixilon.minosoft.util.signature.SignatureSigner + + +object UpdateKey : SignatureSigner(minosoft("updater/release.pub"), "SHA512withRSA") diff --git a/src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt b/src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt new file mode 100644 index 000000000..c4949779e --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt @@ -0,0 +1,37 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.updater + +import de.bixilon.kutil.latch.AbstractLatch +import de.bixilon.kutil.latch.SimpleLatch +import de.bixilon.kutil.observer.DataObserver.Companion.observed +import de.bixilon.minosoft.commands.stack.print.PrintTarget + +class UpdateProgress( + val progress: AbstractLatch = SimpleLatch(0), + var log: PrintTarget? = null, +) { + var stage by observed(UpdateStage.WAITING) + var error: Throwable? = null + + enum class UpdateStage { + WAITING, + DOWNLOADING, + VERIFYING, + STORING, + + DONE, + FAILED, + } +} diff --git a/src/main/java/de/bixilon/minosoft/util/account/minecraft/MinecraftPrivateKey.kt b/src/main/java/de/bixilon/minosoft/util/account/minecraft/MinecraftPrivateKey.kt index b8630c10b..40eafe9f4 100644 --- a/src/main/java/de/bixilon/minosoft/util/account/minecraft/MinecraftPrivateKey.kt +++ b/src/main/java/de/bixilon/minosoft/util/account/minecraft/MinecraftPrivateKey.kt @@ -57,7 +57,7 @@ data class MinecraftPrivateKey( @JsonIgnore fun requireSignature(uuid: UUID) { MinecraftKeyPair.requireSignature(uuid, expiresAt, pair.public, signatureBytesV2) - signatureBytes?.let { YggdrasilUtil.requireSignature((expiresAt.toEpochMilli().toString() + pair.publicString).toByteArray(StandardCharsets.US_ASCII), it) } + signatureBytes?.let { YggdrasilUtil.require((expiresAt.toEpochMilli().toString() + pair.publicString).toByteArray(StandardCharsets.US_ASCII), it) } } fun getSignature(versionId: Int): ByteArray { diff --git a/src/main/java/de/bixilon/minosoft/util/account/minecraft/key/MinecraftKeyPair.kt b/src/main/java/de/bixilon/minosoft/util/account/minecraft/key/MinecraftKeyPair.kt index 6ccff933a..4bf9dfec0 100644 --- a/src/main/java/de/bixilon/minosoft/util/account/minecraft/key/MinecraftKeyPair.kt +++ b/src/main/java/de/bixilon/minosoft/util/account/minecraft/key/MinecraftKeyPair.kt @@ -17,7 +17,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import de.bixilon.minosoft.protocol.protocol.buffers.OutByteBuffer import de.bixilon.minosoft.protocol.protocol.encryption.CryptManager -import de.bixilon.minosoft.util.yggdrasil.YggdrasilException +import de.bixilon.minosoft.util.signature.SignatureException import de.bixilon.minosoft.util.yggdrasil.YggdrasilUtil import java.security.PublicKey import java.time.Instant @@ -42,7 +42,7 @@ data class MinecraftKeyPair( fun requireSignature(uuid: UUID, expiresAt: Instant, publicKey: PublicKey, signature: ByteArray) { if (!isSignatureCorrect(uuid, expiresAt, publicKey, signature)) { - throw YggdrasilException() + throw SignatureException() } } } diff --git a/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilException.kt b/src/main/java/de/bixilon/minosoft/util/signature/SignatureException.kt similarity index 83% rename from src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilException.kt rename to src/main/java/de/bixilon/minosoft/util/signature/SignatureException.kt index 45216d472..4d9b7c5c6 100644 --- a/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilException.kt +++ b/src/main/java/de/bixilon/minosoft/util/signature/SignatureException.kt @@ -1,6 +1,6 @@ /* * Minosoft - * Copyright (C) 2020-2022 Moritz Zwerger + * Copyright (C) 2020-2023 Moritz Zwerger * * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * @@ -11,6 +11,6 @@ * This software is not affiliated with Mojang AB, the original developer of Minecraft. */ -package de.bixilon.minosoft.util.yggdrasil +package de.bixilon.minosoft.util.signature -class YggdrasilException(message: String? = null) : Exception(message) +class SignatureException(message: String? = null) : Exception(message) diff --git a/src/main/java/de/bixilon/minosoft/util/signature/SignatureSigner.kt b/src/main/java/de/bixilon/minosoft/util/signature/SignatureSigner.kt new file mode 100644 index 000000000..7cbf9fca3 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/util/signature/SignatureSigner.kt @@ -0,0 +1,64 @@ +/* + * Minosoft + * Copyright (C) 2020-2023 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.util.signature + +import de.bixilon.minosoft.assets.IntegratedAssets +import de.bixilon.minosoft.data.registries.identified.ResourceLocation +import java.security.KeyFactory +import java.security.PublicKey +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.* + + +abstract class SignatureSigner( + val keyPath: ResourceLocation, + val algorithm: String, +) { + private var key: PublicKey? = null + + open fun load() { + if (key != null) throw IllegalStateException("Already loaded!") + val spec = X509EncodedKeySpec(IntegratedAssets.DEFAULT[keyPath].readAllBytes()) + val keyFactory: KeyFactory = KeyFactory.getInstance("RSA") + key = keyFactory.generatePublic(spec) + } + + protected fun createInstance(): Signature { + val instance = Signature.getInstance(algorithm) + instance.initVerify(key) + + return instance + } + + open fun verify(data: ByteArray, signature: ByteArray): Boolean { + val instance = createInstance() + instance.update(data) + return instance.verify(signature) + } + + fun require(data: ByteArray, signature: ByteArray) { + if (verify(data, signature)) return + throw SignatureException() + } + + fun verify(data: String, signature: String): Boolean { + return verify(data.toByteArray(), Base64.getDecoder().decode(signature)) + } + + fun require(data: String, signature: String) { + if (verify(data, signature)) return + throw SignatureException() + } +} diff --git a/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtil.kt b/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtil.kt index 9c9fe8756..18f52b55f 100644 --- a/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtil.kt +++ b/src/main/java/de/bixilon/minosoft/util/yggdrasil/YggdrasilUtil.kt @@ -13,60 +13,26 @@ package de.bixilon.minosoft.util.yggdrasil -import de.bixilon.minosoft.assets.IntegratedAssets import de.bixilon.minosoft.data.registries.identified.Namespaces.mojang import de.bixilon.minosoft.terminal.RunConfiguration import de.bixilon.minosoft.util.logging.Log import de.bixilon.minosoft.util.logging.LogLevels import de.bixilon.minosoft.util.logging.LogMessageType -import java.security.KeyFactory -import java.security.PublicKey -import java.security.Signature -import java.security.spec.X509EncodedKeySpec -import java.util.* +import de.bixilon.minosoft.util.signature.SignatureSigner -object YggdrasilUtil { - lateinit var PUBLIC_KEY: PublicKey - private set +object YggdrasilUtil : SignatureSigner(mojang("yggdrasil/pubkey.der"), "SHA1withRSA") { - fun load() { + override fun load() { if (RunConfiguration.IGNORE_YGGDRASIL) { - Log.log(LogMessageType.OTHER, LogLevels.WARN) { "Yggdrasil signature checking is disabled. Servers can pretend that they have valid data from mojang!" } + Log.log(LogMessageType.LOADING, LogLevels.WARN) { "Yggdrasil signature checking is disabled. Servers can pretend that they have valid data from mojang!" } return } - check(!this::PUBLIC_KEY.isInitialized) { "Already loaded!" } - val spec = X509EncodedKeySpec(IntegratedAssets.DEFAULT[mojang("yggdrasil/pubkey.der")].readAllBytes()) - val keyFactory: KeyFactory = KeyFactory.getInstance("RSA") - PUBLIC_KEY = keyFactory.generatePublic(spec) + super.load() } - fun verify(data: ByteArray, signature: ByteArray): Boolean { - if (RunConfiguration.IGNORE_YGGDRASIL) { - return true - } - val signatureInstance = Signature.getInstance("SHA1withRSA") - signatureInstance.initVerify(PUBLIC_KEY) - signatureInstance.update(data) - return signatureInstance.verify(signature) - } - - fun requireSignature(data: ByteArray, signature: ByteArray) { - if (!verify(data, signature)) { - throw YggdrasilException() - } - } - - fun verify(data: String, signature: String): Boolean { - if (RunConfiguration.IGNORE_YGGDRASIL) { - return true - } - return verify(data.toByteArray(), Base64.getDecoder().decode(signature)) - } - - fun requireSignature(data: String, signature: String) { - if (!verify(data, signature)) { - throw YggdrasilException() - } + override fun verify(data: ByteArray, signature: ByteArray): Boolean { + if (RunConfiguration.IGNORE_YGGDRASIL) return true + return super.verify(data, signature) } } diff --git a/src/main/resources/assets/minosoft/updater/release.pub b/src/main/resources/assets/minosoft/updater/release.pub new file mode 100644 index 000000000..c8b55f6ea --- /dev/null +++ b/src/main/resources/assets/minosoft/updater/release.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApoG/cXO1wyfitUwf55fs +8ch8JxOmb7RW7YQWGVJ13StZZdsaBPmYNsHkQWwL9G45U4QIEwnlDLpDWPe90pB2 +7sf41LHq81PygtxdnByR04fgnIwXYQA2G/A1e7+4CrlyenUCr14F9zjoyVqbUovz +Tf3ibHkJ3DLDSk612tEadJ6LJvc77W4di1GO6V0Kes1WvB1XIM2QhKV1Vyy5QsIf +wpY9RQ4eN3c93YdIhHXElkkdeSAT8csXYiD8ncnt0sDIdrlA5rqDn0wJbkM/tGNP +f7Zbr91RoWZjNCRDCgCDY1Nbs2ZgTL/z2syHoA5AXMzisRGzqCJt8iHSZ/pRlqP6 +CWN6wQcSbTNUWpF43I0CkWakM9a1zUgcBkuLEG8yYL/zKFmYo2x3rdjaTyWCQyOO +SWkc4NBVgfwtlgNBrsB7zO+IdIEJwW+Jp5a7yR72AsFjcU5xcz/p9QmNhvWqAFCD +fXrTNHFtr11T91sKdgG2B6E2TbYlyEsKR4BmQfJn7qemlJrIFkX/H3k8wygbnHWg +cHPGcTVAMVxb9rk6M+BAkglG4nS8ldz/ZC/ScaWczwvNPsn0tbkgzql6BOi7jPc0 +WO33JN/QxqO00xEg/O5NRXpzSIh3ehHnAQBV1KmxnP1U0bY277F+pwaYXRz5s/iR +KGX3gNbcTlQEfFJe3zKeEuUCAwEAAQ== +-----END PUBLIC KEY-----