aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/de/hysky/skyblocker/utils
diff options
context:
space:
mode:
authorAaron <51387595+AzureAaron@users.noreply.github.com>2024-06-22 02:47:39 -0400
committerGitHub <noreply@github.com>2024-06-22 02:47:39 -0400
commitd12504ef9992a72de9eb3c3a930c9f3c855c0acc (patch)
tree840ac5bc666da72b26f104b088eb95da5306065e /src/main/java/de/hysky/skyblocker/utils
parentd6b220a8e42a1fc2dbc955779e86d199851b4674 (diff)
parent7e3ed3e5ca248434b61e876df227ec4ea72b46a2 (diff)
downloadSkyblocker-d12504ef9992a72de9eb3c3a930c9f3c855c0acc.tar.gz
Skyblocker-d12504ef9992a72de9eb3c3a930c9f3c855c0acc.tar.bz2
Skyblocker-d12504ef9992a72de9eb3c3a930c9f3c855c0acc.zip
Merge pull request #783 from SkyblockerMod/api-changes
Api changes the Aaron of the Azure color made
Diffstat (limited to 'src/main/java/de/hysky/skyblocker/utils')
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java169
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Http.java36
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ProfileUtils.java4
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Utils.java6
4 files changed, 204 insertions, 11 deletions
diff --git a/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java
new file mode 100644
index 00000000..fbf814ee
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java
@@ -0,0 +1,169 @@
+package de.hysky.skyblocker.utils;
+
+import java.nio.ByteBuffer;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.UUID;
+
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+
+import com.google.gson.JsonParser;
+import com.mojang.logging.LogUtils;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.JsonOps;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import de.hysky.skyblocker.SkyblockerMod;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
+import net.minecraft.SharedConstants;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.network.encryption.PlayerKeyPair;
+import net.minecraft.text.Text;
+import net.minecraft.util.Uuids;
+import net.minecraft.util.dynamic.Codecs;
+
+/**
+ * This class is responsible for communicating with the API to retrieve a fully custom token used to gain access to more privileged APIs
+ * such as the Hypixel API Proxy. The main point of this is to verify that a person is most likely playing Minecraft, and thus is likely to be
+ * using the mod, and not somebody who is attempting to freeload off of our services.
+ */
+public class ApiAuthentication {
+ private static final Logger LOGGER = LogUtils.getLogger();
+ private static final MinecraftClient CLIENT = MinecraftClient.getInstance();
+ private static final String MINECRAFT_VERSION = SharedConstants.getGameVersion().getName();
+ private static final String AUTH_URL = "https://hysky.de/api/aaron/authenticate";
+ private static final String CONTENT_TYPE = "application/json";
+ private static final String ALGORITHM = "SHA256withRSA";
+
+ private static TokenInfo tokenInfo = null;
+
+ public static void init() {
+ //Update token after the profileKeys instance is initialized
+ ClientLifecycleEvents.CLIENT_STARTED.register(_client -> updateToken());
+ }
+
+ /**
+ * Refreshes the token by fetching the player's key pair from the Minecraft Services API.
+ *
+ * We use the player's uuid, public key, public key signature, public key expiry date, and randomly signed data by the private key to verify the identity/legitimacy
+ * of the player without the server needing to handle any privileged information.
+ *
+ * Mojang provides a signature for each key pair which is comprised of the player's uuid, public key, and expiry time so we can easily validate if the key pair
+ * was generated by Mojang and is tied to said player. For information about what the randomly signed data is used for and why see {@link #getRandomSignedData(PrivateKey)}
+ */
+ private static void updateToken() {
+ //The fetching runs async in ProfileKeysImpl#getKeyPair
+ CLIENT.getProfileKeys().fetchKeyPair().thenAcceptAsync(playerKeypairOpt -> {
+ if (playerKeypairOpt.isPresent()) {
+ PlayerKeyPair playerKeyPair = playerKeypairOpt.get();
+
+ //The key header and footer can be sent but that doesn't matter to the server
+ String publicKey = Base64.getMimeEncoder().encodeToString(playerKeyPair.publicKey().data().key().getEncoded());
+ byte[] publicKeySignature = playerKeyPair.publicKey().data().keySignature();
+ long expiresAt = playerKeyPair.publicKey().data().expiresAt().toEpochMilli();
+
+ TokenRequest.KeyPairInfo keyPairInfo = new TokenRequest.KeyPairInfo(Objects.requireNonNull(CLIENT.getSession().getUuidOrNull()), publicKey, publicKeySignature, expiresAt);
+ TokenRequest.SignedData signedData = Objects.requireNonNull(getRandomSignedData(playerKeyPair.privateKey()));
+ TokenRequest tokenRequest = new TokenRequest(keyPairInfo, signedData, SkyblockerMod.SKYBLOCKER_MOD.getMetadata().getId(), MINECRAFT_VERSION, SkyblockerMod.VERSION);
+
+ String request = SkyblockerMod.GSON.toJson(TokenRequest.CODEC.encodeStart(JsonOps.INSTANCE, tokenRequest).getOrThrow());
+
+ try {
+ tokenInfo = TokenInfo.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseString(Http.sendPostRequest(AUTH_URL, request, CONTENT_TYPE))).getOrThrow();
+ int refreshAtTicks = (int) (((tokenInfo.expiresAt() - tokenInfo.issuedAt()) / 1000L) - 300L) * 20; //Refresh 5 minutes before expiry date
+
+ Scheduler.INSTANCE.schedule(ApiAuthentication::updateToken, refreshAtTicks, true);
+ } catch (Exception e) {
+ //Try again in 1 minute
+ logErrorAndScheduleRetry(Text.translatable("skyblocker.api.token.authFailure"), 60 * 20, "[Skyblocker Api Auth] Failed to refresh the api token! Some features might not work :(", e);
+ }
+ } else {
+ //The Minecraft Services API is probably down so we will retry in 5 minutes, either that or your access token has expired (game open for 24h) and you need to restart.
+ logErrorAndScheduleRetry(Text.translatable("skyblocker.api.token.noProfileKeys"), 300 * 20, "[Skyblocker Api Auth] Failed to fetch profile keys! Some features may not work temporarily :( (Has your game been open for more than 24 hours? If so restart.)");
+ }
+ }).exceptionally(throwable -> {
+ //Try again in 1 minute
+ logErrorAndScheduleRetry(Text.translatable("skyblocker.api.token.authFailure"), 60 * 20, "[Skyblocker Api Auth] Encountered an unexpected exception while refreshing the api token!", throwable);
+
+ return null;
+ });
+ }
+
+ /**
+ * Signs a string of random data with the key pair's private key. This is required to know if you are the real holder of the key pair or not. Why? Because your public key,
+ * public key signature, and expiry time are forwarded by the server to all players on the same server as you. This means that a malicious
+ * individual could scrape key pairs and pretend to be someone else when requesting a token from our API. So by signing data with the private key,
+ * the server can use the public key to verify its integrity which proves that the requester is the true holder of the complete key pair and not someone trying to pose as them for malicious purposes.
+ */
+ private static TokenRequest.SignedData getRandomSignedData(PrivateKey privateKey) {
+ try {
+ Signature signature = Signature.getInstance(ALGORITHM);
+ UUID uuid = UUID.randomUUID();
+ ByteBuffer buf = ByteBuffer.allocate(16)
+ .putLong(uuid.getMostSignificantBits())
+ .putLong(uuid.getLeastSignificantBits());
+
+ signature.initSign(privateKey);
+ signature.update(buf.array());
+
+ byte[] signedData = signature.sign();
+
+ return new TokenRequest.SignedData(buf.array(), signedData);
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker Api Auth] Failed to sign random data!", e);
+ }
+
+ //This should never ever be the case, since we are signing data that is not invalid in any case
+ return null;
+ }
+
+ private static void logErrorAndScheduleRetry(Text warningMessage, int retryAfter, String logMessage, Object... logArgs) {
+ LOGGER.error(logMessage, logArgs);
+ Scheduler.INSTANCE.schedule(ApiAuthentication::updateToken, retryAfter, true);
+
+ if (CLIENT.player != null) CLIENT.player.sendMessage(Constants.PREFIX.get().append(warningMessage));
+ }
+
+ @Nullable
+ public static String getToken() {
+ return tokenInfo != null ? tokenInfo.token() : null;
+ }
+
+ private record TokenRequest(KeyPairInfo keyPairInfo, SignedData signedData, String mod, String minecraftVersion, String modVersion) {
+ private static final Codec<TokenRequest> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ KeyPairInfo.CODEC.fieldOf("keyPair").forGetter(TokenRequest::keyPairInfo),
+ SignedData.CODEC.fieldOf("signedData").forGetter(TokenRequest::signedData),
+ Codec.STRING.fieldOf("mod").forGetter(TokenRequest::mod),
+ Codec.STRING.fieldOf("minecraftVersion").forGetter(TokenRequest::minecraftVersion),
+ Codec.STRING.fieldOf("modVersion").forGetter(TokenRequest::modVersion))
+ .apply(instance, TokenRequest::new));
+
+ private record KeyPairInfo(UUID uuid, String publicKey, byte[] publicKeySignature, long expiresAt) {
+ private static final Codec<KeyPairInfo> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Uuids.STRING_CODEC.fieldOf("uuid").forGetter(KeyPairInfo::uuid),
+ Codec.STRING.fieldOf("publicKey").forGetter(KeyPairInfo::publicKey),
+ Codecs.BASE_64.fieldOf("publicKeySignature").forGetter(KeyPairInfo::publicKeySignature),
+ Codec.LONG.fieldOf("expiresAt").forGetter(KeyPairInfo::expiresAt))
+ .apply(instance, KeyPairInfo::new));
+ }
+
+ private record SignedData(byte[] original, byte[] signed) {
+ private static final Codec<SignedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Codecs.BASE_64.fieldOf("original").forGetter(SignedData::original),
+ Codecs.BASE_64.fieldOf("signed").forGetter(SignedData::signed))
+ .apply(instance, SignedData::new));
+ }
+ }
+
+ private record TokenInfo(String token, long issuedAt, long expiresAt) {
+ private static final Codec<TokenInfo> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Codec.STRING.fieldOf("token").forGetter(TokenInfo::token),
+ Codec.LONG.fieldOf("issuedAt").forGetter(TokenInfo::issuedAt),
+ Codec.LONG.fieldOf("expiresAt").forGetter(TokenInfo::expiresAt))
+ .apply(instance, TokenInfo::new));
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/Http.java b/src/main/java/de/hysky/skyblocker/utils/Http.java
index 871eac78..99db0316 100644
--- a/src/main/java/de/hysky/skyblocker/utils/Http.java
+++ b/src/main/java/de/hysky/skyblocker/utils/Http.java
@@ -17,6 +17,7 @@ import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import de.hysky.skyblocker.SkyblockerMod;
import net.minecraft.SharedConstants;
@@ -33,15 +34,18 @@ public class Http {
.followRedirects(Redirect.NORMAL)
.build();
- private static ApiResponse sendCacheableGetRequest(String url) throws IOException, InterruptedException {
- HttpRequest request = HttpRequest.newBuilder()
+ private static ApiResponse sendCacheableGetRequest(String url, @Nullable String token) throws IOException, InterruptedException {
+ HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.GET()
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip, deflate")
.header("User-Agent", USER_AGENT)
.version(Version.HTTP_2)
- .uri(URI.create(url))
- .build();
+ .uri(URI.create(url));
+
+ if (token != null) requestBuilder.header("Token", token);
+
+ HttpRequest request = requestBuilder.build();
HttpResponse<InputStream> response = HTTP_CLIENT.send(request, BodyHandlers.ofInputStream());
InputStream decodedInputStream = getDecodedInputStream(response);
@@ -69,7 +73,7 @@ public class Http {
}
public static String sendGetRequest(String url) throws IOException, InterruptedException {
- return sendCacheableGetRequest(url).content();
+ return sendCacheableGetRequest(url, null).content();
}
public static HttpHeaders sendHeadRequest(String url) throws IOException, InterruptedException {
@@ -84,8 +88,26 @@ public class Http {
return response.headers();
}
+ public static String sendPostRequest(String url, String requestBody, String contentType) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .POST(BodyPublishers.ofString(requestBody))
+ .header("Accept-Encoding", "gzip, deflate")
+ .header("Content-Type", contentType)
+ .header("User-Agent", USER_AGENT)
+ .version(Version.HTTP_2)
+ .uri(URI.create(url))
+ .build();
+
+ HttpResponse<InputStream> response = HTTP_CLIENT.send(request, BodyHandlers.ofInputStream());
+ InputStream decodedInputStream = getDecodedInputStream(response);
+
+ String responseBody = new String(decodedInputStream.readAllBytes());
+
+ return responseBody;
+ }
+
public static ApiResponse sendName2UuidRequest(String name) throws IOException, InterruptedException {
- return sendCacheableGetRequest(NAME_2_UUID + name);
+ return sendCacheableGetRequest(NAME_2_UUID + name, null);
}
/**
@@ -96,7 +118,7 @@ public class Http {
* @implNote the {@code v2} prefix is automatically added
*/
public static ApiResponse sendHypixelRequest(String endpoint, @NotNull String query) throws IOException, InterruptedException {
- return sendCacheableGetRequest(HYPIXEL_PROXY + endpoint + query);
+ return sendCacheableGetRequest(HYPIXEL_PROXY + endpoint + query, ApiAuthentication.getToken());
}
private static InputStream getDecodedInputStream(HttpResponse<InputStream> response) {
diff --git a/src/main/java/de/hysky/skyblocker/utils/ProfileUtils.java b/src/main/java/de/hysky/skyblocker/utils/ProfileUtils.java
index 0a1f238a..a786e79f 100644
--- a/src/main/java/de/hysky/skyblocker/utils/ProfileUtils.java
+++ b/src/main/java/de/hysky/skyblocker/utils/ProfileUtils.java
@@ -18,10 +18,6 @@ public class ProfileUtils {
public static Map<String, ObjectLongPair<JsonObject>> players = new HashMap<>();
- public static void init() {
- updateProfile();
- }
-
public static CompletableFuture<JsonObject> updateProfile() {
return updateProfile(MinecraftClient.getInstance().getSession().getUsername());
}
diff --git a/src/main/java/de/hysky/skyblocker/utils/Utils.java b/src/main/java/de/hysky/skyblocker/utils/Utils.java
index 925879b8..84b3cb9e 100644
--- a/src/main/java/de/hysky/skyblocker/utils/Utils.java
+++ b/src/main/java/de/hysky/skyblocker/utils/Utils.java
@@ -2,6 +2,8 @@ package de.hysky.skyblocker.utils;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
+import com.mojang.util.UndashedUuid;
+
import de.hysky.skyblocker.events.SkyblockEvents;
import de.hysky.skyblocker.mixins.accessors.MessageHandlerAccessor;
import de.hysky.skyblocker.skyblock.item.MuseumItemCache;
@@ -507,4 +509,8 @@ public class Utils {
((MessageHandlerAccessor) client.getMessageHandler()).invokeAddToChatLog(message, Instant.now());
client.getNarratorManager().narrateSystemMessage(message);
}
+
+ public static String getUndashedUuid() {
+ return UndashedUuid.toString(MinecraftClient.getInstance().getSession().getUuidOrNull());
+ }
}