mirror of
https://gitlab.bixilon.de/bixilon/minosoft.git
synced 2025-09-08 23:13:10 -04:00
basic update checking
No updates available yet...Lets see.
This commit is contained in:
parent
ec602af0ef
commit
482d337fcb
58
doc/Updater.md
Normal file
58
doc/Updater.md
Normal file
@ -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": "<Optional link to a release page>",
|
||||
"date": "Unix timestamp (in seconds) of the release date",
|
||||
"download": {
|
||||
// optional
|
||||
"url": "https:// where to download it",
|
||||
"size": <Update size in Bytes>,
|
||||
"sha512": "SHA512 hash of the binary",
|
||||
"signature": "Release signature"
|
||||
},
|
||||
"release_notes": "<Optional text for release notes to show in the client>"
|
||||
}
|
||||
```
|
||||
|
||||
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)
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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`() {
|
||||
|
||||
}
|
||||
}
|
@ -51,6 +51,6 @@ class YggdrasilUtilTest {
|
||||
val texture = "ewogICJ0aW1lc3RhbXAiIDogMTY5MDQwMTIzODM2NCwKICAicHJvZmlsZUlkIiA6ICJhODUxNzQ0MDNlNjg0MDgwYWNkODU3MzhlMjI5NGNhZiIsCiAgInByb2ZpbGVOYW1lIiA6ICJEYVJpdmVyc09uZSIsCiAgInNpZ25hdHVyZVJlcXVpcmVkIiA6IHRydWUsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS80YjJjMjAwYjA1ZWY4NDhiY2IyZGM2YTBlOGY2OTQ0MWE3YzE3Mzg4Y2FjY2UzNzAxMzg5YTU0OGM2NTdjMzZkIgogICAgfQogIH0KfQ=="
|
||||
val signature = ""
|
||||
|
||||
assertThrows { YggdrasilUtil.requireSignature(texture, signature) }
|
||||
assertThrows { YggdrasilUtil.require(texture, signature) }
|
||||
}
|
||||
}
|
||||
|
@ -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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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)
|
||||
}
|
@ -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<String, Any> = Jackson.MAPPER.readValue(Base64.getDecoder().decode(encoded), Jackson.JSON_MAP_TYPE)
|
||||
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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<InternalCommandClickEvent> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ enum class BootTasks {
|
||||
ASSETS_PROPERTIES,
|
||||
DEFAULT_REGISTRIES,
|
||||
LAN_SERVERS,
|
||||
YGGDRASIL,
|
||||
KEYS,
|
||||
STARTUP_PROGRESS,
|
||||
ASSETS_OVERRIDE,
|
||||
CLI,
|
||||
|
@ -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) })
|
||||
|
@ -31,8 +31,9 @@ object MinosoftPropertiesLoader {
|
||||
fun load() {
|
||||
val properties = IntegratedAssets.DEFAULT.getOrNull(minosoft("version.json"))?.readJson<MinosoftP>(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
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
@ -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) {
|
||||
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
}
|
23
src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt
Normal file
23
src/main/java/de/bixilon/minosoft/updater/DownloadLink.kt
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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,
|
||||
)
|
45
src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt
Normal file
45
src/main/java/de/bixilon/minosoft/updater/MinosoftUpdate.kt
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
}
|
90
src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt
Normal file
90
src/main/java/de/bixilon/minosoft/updater/MinosoftUpdater.kt
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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
|
||||
}
|
||||
}
|
20
src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt
Normal file
20
src/main/java/de/bixilon/minosoft/updater/UpdateKey.kt
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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")
|
37
src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt
Normal file
37
src/main/java/de/bixilon/minosoft/updater/UpdateProgress.kt
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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,
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* 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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
14
src/main/resources/assets/minosoft/updater/release.pub
Normal file
14
src/main/resources/assets/minosoft/updater/release.pub
Normal file
@ -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-----
|
Loading…
x
Reference in New Issue
Block a user