/* * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * @author GeyserMC * @link https://github.com/GeyserMC/Geyser */ package org.geysermc.geyser.util; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.steveice10.mc.auth.service.MsaAuthenticationService; import org.cloudburstmc.protocol.bedrock.packet.LoginPacket; import org.cloudburstmc.protocol.bedrock.packet.ServerToClientHandshakePacket; import org.cloudburstmc.protocol.bedrock.util.ChainValidationResult; import org.cloudburstmc.protocol.bedrock.util.ChainValidationResult.IdentityData; import org.cloudburstmc.protocol.bedrock.util.EncryptionUtils; import org.geysermc.cumulus.form.CustomForm; import org.geysermc.cumulus.form.ModalForm; import org.geysermc.cumulus.form.SimpleForm; import org.geysermc.cumulus.response.SimpleFormResponse; import org.geysermc.cumulus.response.result.FormResponseResult; import org.geysermc.cumulus.response.result.ValidFormResponseResult; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.configuration.GeyserConfiguration; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.auth.AuthData; import org.geysermc.geyser.session.auth.BedrockClientData; import org.geysermc.geyser.text.ChatColor; import org.geysermc.geyser.text.GeyserLocale; import javax.crypto.SecretKey; import java.security.KeyPair; import java.security.PublicKey; import java.util.List; import java.util.function.BiConsumer; public class LoginEncryptionUtils { private static final ObjectMapper JSON_MAPPER = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); private static boolean HAS_SENT_ENCRYPTION_MESSAGE = false; public static void encryptPlayerConnection(GeyserSession session, LoginPacket loginPacket) { encryptConnectionWithCert(session, loginPacket.getExtra(), loginPacket.getChain()); } private static void encryptConnectionWithCert(GeyserSession session, String clientData, List certChainData) { try { GeyserImpl geyser = session.getGeyser(); ChainValidationResult result = EncryptionUtils.validateChain(certChainData); geyser.getLogger().debug(String.format("Is player data signed? %s", result.signed())); if (!result.signed() && !session.getGeyser().getConfig().isEnableProxyConnections()) { session.disconnect(GeyserLocale.getLocaleStringLog("geyser.network.remote.invalid_xbox_account")); return; } IdentityData extraData = result.identityClaims().extraData; session.setAuthenticationData(new AuthData(extraData.displayName, extraData.identity, extraData.xuid)); session.setCertChainData(certChainData); PublicKey identityPublicKey = result.identityClaims().parsedIdentityPublicKey(); byte[] clientDataPayload = EncryptionUtils.verifyClientData(clientData, identityPublicKey); if (clientDataPayload == null) { throw new IllegalStateException("Client data isn't signed by the given chain data"); } JsonNode clientDataJson = JSON_MAPPER.readTree(clientDataPayload); BedrockClientData data = JSON_MAPPER.convertValue(clientDataJson, BedrockClientData.class); data.setOriginalString(clientData); session.setClientData(data); try { startEncryptionHandshake(session, identityPublicKey); } catch (Throwable e) { // An error can be thrown on older Java 8 versions about an invalid key if (geyser.getConfig().isDebugMode()) { e.printStackTrace(); } sendEncryptionFailedMessage(geyser); } } catch (Exception ex) { session.disconnect("disconnectionScreen.internalError.cantConnect"); throw new RuntimeException("Unable to complete login", ex); } } private static void startEncryptionHandshake(GeyserSession session, PublicKey key) throws Exception { KeyPair serverKeyPair = EncryptionUtils.createKeyPair(); byte[] token = EncryptionUtils.generateRandomToken(); ServerToClientHandshakePacket packet = new ServerToClientHandshakePacket(); packet.setJwt(EncryptionUtils.createHandshakeJwt(serverKeyPair, token)); session.sendUpstreamPacketImmediately(packet); SecretKey encryptionKey = EncryptionUtils.getSecretKey(serverKeyPair.getPrivate(), key, token); session.getUpstream().getSession().enableEncryption(encryptionKey); } private static void sendEncryptionFailedMessage(GeyserImpl geyser) { if (!HAS_SENT_ENCRYPTION_MESSAGE) { geyser.getLogger().warning(GeyserLocale.getLocaleStringLog("geyser.network.encryption.line_1")); geyser.getLogger().warning(GeyserLocale.getLocaleStringLog("geyser.network.encryption.line_2", "https://geysermc.org/supported_java")); HAS_SENT_ENCRYPTION_MESSAGE = true; } } public static void buildAndShowLoginWindow(GeyserSession session) { if (session.isLoggedIn()) { // Can happen if a window is cancelled during dimension switch return; } // Set DoDaylightCycle to false so the time doesn't accelerate while we're here session.setDaylightCycle(false); GeyserConfiguration config = session.getGeyser().getConfig(); boolean isPasswordAuthEnabled = config.getRemote().isPasswordAuthentication(); session.sendForm( SimpleForm.builder() .translator(GeyserLocale::getPlayerLocaleString, session.locale()) .title("geyser.auth.login.form.notice.title") .content("geyser.auth.login.form.notice.desc") .optionalButton("geyser.auth.login.form.notice.btn_login.mojang", isPasswordAuthEnabled) .button("geyser.auth.login.form.notice.btn_login.microsoft") .button("geyser.auth.login.form.notice.btn_disconnect") .closedOrInvalidResultHandler(() -> buildAndShowLoginWindow(session)) .validResultHandler((response) -> { if (response.clickedButtonId() == 0) { session.setMicrosoftAccount(false); buildAndShowLoginDetailsWindow(session); return; } if (response.clickedButtonId() == 1) { session.authenticateWithMicrosoftCode(); return; } session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.locale())); })); } /** * Build a window that explains the user's credentials will be saved to the system. */ public static void buildAndShowConsentWindow(GeyserSession session) { String locale = session.locale(); session.sendForm( SimpleForm.builder() .translator(LoginEncryptionUtils::translate, locale) .title("%gui.signIn") .content(""" geyser.auth.login.save_token.warning geyser.auth.login.save_token.proceed""") .button("%gui.ok") .button("%gui.decline") .resultHandler(authenticateOrKickHandler(session)) ); } public static void buildAndShowTokenExpiredWindow(GeyserSession session) { String locale = session.locale(); session.sendForm( SimpleForm.builder() .translator(LoginEncryptionUtils::translate, locale) .title("geyser.auth.login.form.expired") .content(""" geyser.auth.login.save_token.expired geyser.auth.login.save_token.proceed""") .button("%gui.ok") .resultHandler(authenticateOrKickHandler(session)) ); } private static BiConsumer> authenticateOrKickHandler(GeyserSession session) { return (form, genericResult) -> { if (genericResult instanceof ValidFormResponseResult result && result.response().clickedButtonId() == 0) { session.authenticateWithMicrosoftCode(true); } else { session.disconnect("%disconnect.quitting"); } }; } public static void buildAndShowLoginDetailsWindow(GeyserSession session) { session.sendForm( CustomForm.builder() .translator(GeyserLocale::getPlayerLocaleString, session.locale()) .title("geyser.auth.login.form.details.title") .label("geyser.auth.login.form.details.desc") .input("geyser.auth.login.form.details.email", "account@geysermc.org", "") .input("geyser.auth.login.form.details.pass", "123456", "") .invalidResultHandler(() -> buildAndShowLoginDetailsWindow(session)) .closedResultHandler(() -> buildAndShowLoginWindow(session)) .validResultHandler((response) -> session.authenticate(response.next(), response.next()))); } /** * Shows the code that a user must input into their browser */ public static void buildAndShowMicrosoftCodeWindow(GeyserSession session, MsaAuthenticationService.MsCodeResponse msCode) { String locale = session.locale(); StringBuilder message = new StringBuilder("%xbox.signin.website\n") .append(ChatColor.AQUA) .append("%xbox.signin.url") .append(ChatColor.RESET) .append("\n%xbox.signin.enterCode\n") .append(ChatColor.GREEN) .append(msCode.user_code); int timeout = session.getGeyser().getConfig().getPendingAuthenticationTimeout(); if (timeout != 0) { message.append("\n\n") .append(ChatColor.RESET) .append(GeyserLocale.getPlayerLocaleString("geyser.auth.login.timeout", session.locale(), String.valueOf(timeout))); } session.sendForm( ModalForm.builder() .title("%xbox.signin") .content(message.toString()) .button1("%gui.done") .button2("%menu.disconnect") .closedOrInvalidResultHandler(() -> buildAndShowLoginWindow(session)) .validResultHandler((response) -> { if (response.clickedButtonId() == 1) { session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", locale)); } }) ); } /* This checks per line if there is something to be translated, and it skips Bedrock translation keys (%) */ private static String translate(String key, String locale) { StringBuilder newValue = new StringBuilder(); int previousIndex = 0; while (previousIndex < key.length()) { int nextIndex = key.indexOf('\n', previousIndex); int endIndex = nextIndex == -1 ? key.length() : nextIndex; // if there is more to this line than just a new line char if (endIndex - previousIndex > 1) { String substring = key.substring(previousIndex, endIndex); if (key.charAt(previousIndex) != '%') { newValue.append(GeyserLocale.getPlayerLocaleString(substring, locale)); } else { newValue.append(substring); } } newValue.append('\n'); previousIndex = endIndex + 1; } return newValue.toString(); } }