From 163905a63d840a4dd17b29bb53c6e7be0bf55c03 Mon Sep 17 00:00:00 2001 From: Aaron <51387595+AzureAaron@users.noreply.github.com> Date: Sun, 16 Jun 2024 11:34:58 -0400 Subject: API Authentication --- .../hysky/skyblocker/utils/ApiAuthentication.java | 149 +++++++++++++++++++++ src/main/java/de/hysky/skyblocker/utils/Http.java | 36 ++++- .../de/hysky/skyblocker/utils/ProfileUtils.java | 4 - 3 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java (limited to 'src/main/java/de/hysky/skyblocker/utils') 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..16d07ac5 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java @@ -0,0 +1,149 @@ +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; + +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://api.azureaaron.net/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()); + } + + 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; + }); + } + + 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 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 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 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 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 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 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 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> players = new HashMap<>(); - public static void init() { - updateProfile(); - } - public static CompletableFuture updateProfile() { return updateProfile(MinecraftClient.getInstance().getSession().getUsername()); } -- cgit From 685b2da9745e9f9e0d23e1fce39e0c6bcb6ca699 Mon Sep 17 00:00:00 2001 From: Aaron <51387595+AzureAaron@users.noreply.github.com> Date: Sun, 16 Jun 2024 17:56:52 -0400 Subject: Pet Cache --- src/main/java/de/hysky/skyblocker/utils/Utils.java | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'src/main/java/de/hysky/skyblocker/utils') 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()); + } } -- cgit From 8fa68edd93117b21d0a6d1c6987fc6a1c68aafca Mon Sep 17 00:00:00 2001 From: Aaron <51387595+AzureAaron@users.noreply.github.com> Date: Sun, 16 Jun 2024 20:22:58 -0400 Subject: Use hysky redirect --- src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/main/java/de/hysky/skyblocker/utils') diff --git a/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java index 16d07ac5..6a6f388f 100644 --- a/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java +++ b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java @@ -30,7 +30,7 @@ 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://api.azureaaron.net/authenticate"; + 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"; -- cgit From 7e3ed3e5ca248434b61e876df227ec4ea72b46a2 Mon Sep 17 00:00:00 2001 From: Aaron <51387595+AzureAaron@users.noreply.github.com> Date: Sat, 22 Jun 2024 00:37:58 -0400 Subject: Add documentation --- .../de/hysky/skyblocker/utils/ApiAuthentication.java | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) (limited to 'src/main/java/de/hysky/skyblocker/utils') diff --git a/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java index 6a6f388f..fbf814ee 100644 --- a/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java +++ b/src/main/java/de/hysky/skyblocker/utils/ApiAuthentication.java @@ -26,6 +26,11 @@ 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(); @@ -41,6 +46,15 @@ public class ApiAuthentication { 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 -> { @@ -79,6 +93,12 @@ public class ApiAuthentication { }); } + /** + * 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); -- cgit