1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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));
}
}
|