From c977e36368a91484a13386bcd29094029e60c69d Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Thu, 3 Mar 2022 18:52:26 -0500 Subject: [PATCH] Deprecate userAuths in favor of a saved token system --- .gitignore | 5 +- .../standalone/GeyserStandaloneBootstrap.java | 6 ++ core/pom.xml | 2 +- .../java/org/geysermc/geyser/Constants.java | 2 + .../org/geysermc/geyser/GeyserBootstrap.java | 7 ++ .../java/org/geysermc/geyser/GeyserImpl.java | 84 ++++++++++++++++++- .../configuration/GeyserConfiguration.java | 3 + .../GeyserJacksonConfiguration.java | 3 + .../geyser/network/UpstreamPacketHandler.java | 8 ++ .../geyser/session/GeyserSession.java | 64 ++++++++++++-- .../PendingMicrosoftAuthentication.java | 21 +++-- ...SetLocalPlayerAsInitializedTranslator.java | 12 ++- .../geyser/util/LoginEncryptionUtils.java | 42 ++++++++++ core/src/main/resources/config.yml | 23 ++--- core/src/main/resources/languages | 2 +- 15 files changed, 251 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 85f8a6e9e..401002e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -239,8 +239,9 @@ nbdist/ run/ config.yml logs/ -public-key.pem +key.pem locales/ /cache/ /packs/ -/dump.json \ No newline at end of file +/dump.json +/saved-refresh-tokens.json \ No newline at end of file diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java index 43ab4b3fe..7c49585d5 100644 --- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java +++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java @@ -275,6 +275,12 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap { return Paths.get(System.getProperty("user.dir")); } + @Override + public Path getSavedUserLoginsFolder() { + // Return the location of the config + return new File(configFilename).getAbsoluteFile().getParentFile().toPath(); + } + @Override public BootstrapDumpInfo getDumpInfo() { return new GeyserStandaloneDumpInfo(this); diff --git a/core/pom.xml b/core/pom.xml index 5b509e417..51fc149e3 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -149,7 +149,7 @@ com.github.GeyserMC MCAuthLib - 6c99331 + d9d773e compile diff --git a/core/src/main/java/org/geysermc/geyser/Constants.java b/core/src/main/java/org/geysermc/geyser/Constants.java index 49f8fa676..23fb76d16 100644 --- a/core/src/main/java/org/geysermc/geyser/Constants.java +++ b/core/src/main/java/org/geysermc/geyser/Constants.java @@ -37,6 +37,8 @@ public final class Constants { public static final String FLOODGATE_DOWNLOAD_LOCATION = "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/"; + static final String SAVED_REFRESH_TOKEN_FILE = "saved-refresh-tokens.json"; + static { URI wsUri = null; try { diff --git a/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java b/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java index 54ca36787..d40060310 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java @@ -97,6 +97,13 @@ public interface GeyserBootstrap { */ Path getConfigFolder(); + /** + * @return the folder where user tokens are saved. This should always point to the location of the config. + */ + default Path getSavedUserLoginsFolder() { + return getConfigFolder(); + } + /** * Information used for the bootstrap section of the debug dump * diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 2577e6af1..f3ebfa4a3 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -26,6 +26,7 @@ package org.geysermc.geyser; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.steveice10.packetlib.tcp.TcpSession; @@ -37,6 +38,7 @@ import io.netty.channel.kqueue.KQueue; import io.netty.util.NettyRuntime; import io.netty.util.concurrent.DefaultThreadFactory; import io.netty.util.internal.SystemPropertyUtil; +import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; import org.checkerframework.checker.nullness.qual.NonNull; @@ -72,6 +74,9 @@ import org.geysermc.geyser.util.*; import javax.naming.directory.Attribute; import javax.naming.directory.InitialDirContext; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; import java.io.InputStream; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -79,6 +84,7 @@ import java.net.UnknownHostException; import java.security.Key; import java.text.DecimalFormat; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.regex.Matcher; @@ -127,6 +133,8 @@ public class GeyserImpl implements GeyserApi { private Metrics metrics; private PendingMicrosoftAuthentication pendingMicrosoftAuthentication; + @Getter(AccessLevel.NONE) + private Map savedRefreshTokens; private static GeyserImpl instance; @@ -325,7 +333,7 @@ public class GeyserImpl implements GeyserApi { metrics = new Metrics(this, "GeyserMC", config.getMetrics().getUniqueId(), false, java.util.logging.Logger.getLogger("")); metrics.addCustomChart(new Metrics.SingleLineChart("players", sessionManager::size)); // Prevent unwanted words best we can - metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase())); + metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase(Locale.ROOT))); metrics.addCustomChart(new Metrics.SimplePie("platform", platformType::getPlatformName)); metrics.addCustomChart(new Metrics.SimplePie("defaultLocale", GeyserLocale::getDefaultLocale)); metrics.addCustomChart(new Metrics.SimplePie("version", () -> GeyserImpl.VERSION)); @@ -409,6 +417,47 @@ public class GeyserImpl implements GeyserApi { metrics = null; } + if (config.getRemote().getAuthType() == AuthType.ONLINE) { + if (config.getUserAuths() != null && !config.getUserAuths().isEmpty()) { + getLogger().warning("The 'userAuths' config section is now deprecated, and will be removed in the near future! " + + "Please migrate to the new 'saved-user-logins' config option: " + + "https://wiki.geysermc.org/geyser/understanding-the-config/"); + } + + // May be written/read to on multiple threads from each GeyserSession as well as writing the config + savedRefreshTokens = new ConcurrentHashMap<>(); + + File tokensFile = bootstrap.getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile(); + if (tokensFile.exists()) { + TypeReference> type = new TypeReference<>() { }; + + Map refreshTokenFile = null; + try { + refreshTokenFile = JSON_MAPPER.readValue(tokensFile, type); + } catch (IOException e) { + logger.error("Cannot load saved user tokens!", e); + } + if (refreshTokenFile != null) { + List validUsers = config.getSavedUserLogins(); + boolean doWrite = false; + for (Map.Entry entry : refreshTokenFile.entrySet()) { + String user = entry.getKey(); + if (!validUsers.contains(user)) { + // Perform a write to this file to purge the now-unused name + doWrite = true; + continue; + } + savedRefreshTokens.put(user, entry.getValue()); + } + if (doWrite) { + scheduleRefreshTokensWrite(); + } + } + } + } else { + savedRefreshTokens = null; + } + newsHandler.handleNews(null, NewsItemAction.ON_SERVER_STARTED); } @@ -516,6 +565,39 @@ public class GeyserImpl implements GeyserApi { return bootstrap.getWorldManager(); } + @Nullable + public String refreshTokenFor(@NonNull String bedrockName) { + return savedRefreshTokens.get(bedrockName); + } + + public void saveRefreshToken(@NonNull String bedrockName, @NonNull String refreshToken) { + if (!getConfig().getSavedUserLogins().contains(bedrockName)) { + // Do not save this login + return; + } + + // We can safely overwrite old instances because MsaAuthenticationService#getLoginResponseFromRefreshToken + // refreshes the token for us + if (!Objects.equals(refreshToken, savedRefreshTokens.put(bedrockName, refreshToken))) { + scheduleRefreshTokensWrite(); + } + } + + private void scheduleRefreshTokensWrite() { + scheduledThread.execute(() -> { + // Ensure all writes are handled on the same thread + File savedTokens = getBootstrap().getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile(); + TypeReference> type = new TypeReference<>() { }; + try (FileWriter writer = new FileWriter(savedTokens)) { + JSON_MAPPER.writerFor(type) + .withDefaultPrettyPrinter() + .writeValue(writer, savedRefreshTokens); + } catch (IOException e) { + getLogger().error("Unable to write saved refresh tokens!", e); + } + }); + } + public static GeyserImpl getInstance() { return instance; } diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java index e2163675c..7bb73a648 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java @@ -44,6 +44,9 @@ public interface GeyserConfiguration { IRemoteConfiguration getRemote(); + List getSavedUserLogins(); + + @Deprecated Map getUserAuths(); boolean isCommandSuggestions(); diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java index e8be96138..463350441 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java @@ -62,6 +62,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration private BedrockConfiguration bedrock = new BedrockConfiguration(); private RemoteConfiguration remote = new RemoteConfiguration(); + @JsonProperty("saved-user-logins") + private List savedUserLogins = Collections.emptyList(); + @JsonProperty("floodgate-key-file") private String floodgateKeyFile = "key.pem"; diff --git a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java index 23542719a..24ede03c1 100644 --- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java +++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java @@ -190,6 +190,14 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { } private boolean couldLoginUserByName(String bedrockUsername) { + if (geyser.getConfig().getSavedUserLogins().contains(bedrockUsername)) { + String refreshToken = geyser.refreshTokenFor(bedrockUsername); + if (refreshToken != null) { + geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.auth.stored_credentials", session.getAuthData().name())); + session.authenticateWithRefreshToken(refreshToken); + return true; + } + } if (geyser.getConfig().getUserAuths() != null) { GeyserConfiguration.IUserAuthenticationInfo info = geyser.getConfig().getUserAuths().get(bedrockUsername); diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index d45276240..437044a6d 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -637,7 +637,6 @@ public class GeyserSession implements GeyserConnection, CommandSender { loggingIn = true; // Use a future to prevent timeouts as all the authentication is handled sync - // This will be changed with the new protocol library. CompletableFuture.supplyAsync(() -> { try { if (password != null && !password.isEmpty()) { @@ -694,10 +693,58 @@ public class GeyserSession implements GeyserConnection, CommandSender { }); } + public void authenticateWithRefreshToken(String refreshToken) { + if (loggedIn) { + geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name())); + return; + } + + loggingIn = true; + + CompletableFuture.supplyAsync(() -> { + MsaAuthenticationService service = new MsaAuthenticationService(GeyserImpl.OAUTH_CLIENT_ID); + service.setRefreshToken(refreshToken); + try { + service.login(); + } catch (RequestException e) { + geyser.getLogger().error("Error while attempting to use refresh token for " + name() + "!", e); + return Boolean.FALSE; + } + + GameProfile profile = service.getSelectedProfile(); + if (profile == null) { + // Java account is offline + disconnect(GeyserLocale.getPlayerLocaleString("geyser.network.remote.invalid_account", clientData.getLanguageCode())); + return null; + } + + protocol = new MinecraftProtocol(profile, service.getAccessToken()); + geyser.saveRefreshToken(name(), service.getRefreshToken()); + return Boolean.TRUE; + }).whenComplete((successful, ex) -> { + if (this.closed) { + return; + } + if (successful == Boolean.FALSE) { + // The player is waiting for a spawn packet, so let's spawn them in now to show them forms + connect(); + // Will be cached for after login + LoginEncryptionUtils.buildAndShowTokenExpiredWindow(this); + return; + } + + connectDownstream(); + }); + } + + public void authenticateWithMicrosoftCode() { + authenticateWithMicrosoftCode(false); + } + /** * Present a form window to the user asking to log in with another web browser */ - public void authenticateWithMicrosoftCode() { + public void authenticateWithMicrosoftCode(boolean offlineAccess) { if (loggedIn) { geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name())); return; @@ -719,7 +766,7 @@ public class GeyserSession implements GeyserConnection, CommandSender { if (task.getAuthentication().isDone()) { onMicrosoftLoginComplete(task); } else { - task.getCode().whenComplete((response, ex) -> { + task.getCode(offlineAccess).whenComplete((response, ex) -> { boolean connected = !closed; if (ex != null) { if (connected) { @@ -735,6 +782,9 @@ public class GeyserSession implements GeyserConnection, CommandSender { } } + /** + * If successful, also begins connecting to the Java server. + */ public boolean onMicrosoftLoginComplete(PendingMicrosoftAuthentication.AuthenticationTask task) { if (closed) { return false; @@ -745,7 +795,8 @@ public class GeyserSession implements GeyserConnection, CommandSender { geyser.getLogger().error("Failed to log in with Microsoft code!", ex); disconnect(ex.toString()); } else { - GameProfile selectedProfile = task.getMsaAuthenticationService().getSelectedProfile(); + MsaAuthenticationService service = task.getMsaAuthenticationService(); + GameProfile selectedProfile = service.getSelectedProfile(); if (selectedProfile == null) { disconnect(GeyserLocale.getPlayerLocaleString( "geyser.network.remote.invalid_account", @@ -754,9 +805,12 @@ public class GeyserSession implements GeyserConnection, CommandSender { } else { this.protocol = new MinecraftProtocol( selectedProfile, - task.getMsaAuthenticationService().getAccessToken() + service.getAccessToken() ); connectDownstream(); + + // Save our refresh token for later use + geyser.saveRefreshToken(name(), service.getRefreshToken()); return true; } } diff --git a/core/src/main/java/org/geysermc/geyser/session/PendingMicrosoftAuthentication.java b/core/src/main/java/org/geysermc/geyser/session/PendingMicrosoftAuthentication.java index 696d6b088..93200dcb6 100644 --- a/core/src/main/java/org/geysermc/geyser/session/PendingMicrosoftAuthentication.java +++ b/core/src/main/java/org/geysermc/geyser/session/PendingMicrosoftAuthentication.java @@ -90,8 +90,6 @@ public class PendingMicrosoftAuthentication { @Setter private boolean online = true; - @Getter - private final CompletableFuture code; @Getter private final CompletableFuture authentication; @@ -103,11 +101,7 @@ public class PendingMicrosoftAuthentication { this.timeoutMs = timeoutMs; this.remainingTimeMs = timeoutMs; - // Request the code - this.code = CompletableFuture.supplyAsync(this::tryGetCode); this.authentication = new CompletableFuture<>(); - // Once the code is received, continuously try to request the access token, profile, etc - this.code.thenRun(() -> performLoginAttempt(System.currentTimeMillis())); this.authentication.whenComplete((r, ex) -> { this.loginException = ex; // avoid memory leak, in case player doesn't connect again @@ -127,9 +121,20 @@ public class PendingMicrosoftAuthentication { authentications.invalidate(userKey); } - private MsaAuthenticationService.MsCodeResponse tryGetCode() throws CompletionException { + public CompletableFuture getCode(boolean offlineAccess) { + // Request the code + CompletableFuture code = CompletableFuture.supplyAsync(() -> tryGetCode(offlineAccess)); + // Once the code is received, continuously try to request the access token, profile, etc + code.thenRun(() -> performLoginAttempt(System.currentTimeMillis())); + return code; + } + + /** + * @param offlineAccess whether we want a refresh token for later use. + */ + private MsaAuthenticationService.MsCodeResponse tryGetCode(boolean offlineAccess) throws CompletionException { try { - return msaAuthenticationService.getAuthCode(); + return msaAuthenticationService.getAuthCode(offlineAccess); } catch (RequestException e) { throw new CompletionException(e); } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java index e55b28602..8641a35ff 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java @@ -43,7 +43,17 @@ public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslat if (session.getRemoteAuthType() == AuthType.ONLINE) { if (!session.isLoggedIn()) { - LoginEncryptionUtils.buildAndShowLoginWindow(session); + if (session.getGeyser().getConfig().getSavedUserLogins().contains(session.name())) { + if (session.getGeyser().refreshTokenFor(session.name()) == null) { + LoginEncryptionUtils.buildAndShowConsentWindow(session); + } else { + // If the refresh token is not null and we're here, then the refresh token expired + // and the expiration form has been cached + session.getFormCache().resendAllForms(); + } + } else { + LoginEncryptionUtils.buildAndShowLoginWindow(session); + } } // else we were able to log the user in } diff --git a/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java b/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java index dec138a3c..2ed754963 100644 --- a/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java @@ -262,6 +262,48 @@ public class LoginEncryptionUtils { })); } + /** + * Build a window that explains the user's credentials will be saved to the system. + */ + public static void buildAndShowConsentWindow(GeyserSession session) { + String locale = session.getLocale(); + session.sendForm( + SimpleForm.builder() + .title("%gui.signIn") + .content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.warning", locale) + + "\n\n" + + GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale)) + .button("%gui.ok") + .button("%gui.decline") + .responseHandler((form, responseData) -> { + SimpleFormResponse response = form.parseResponse(responseData); + if (response.isCorrect() && response.getClickedButtonId() == 0) { + session.authenticateWithMicrosoftCode(true); + } else { + session.disconnect("%disconnect.quitting"); + } + })); + } + + public static void buildAndShowTokenExpiredWindow(GeyserSession session) { + String locale = session.getLocale(); + session.sendForm( + SimpleForm.builder() + .title(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.expired", locale)) + .content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.expired", locale) + + "\n\n" + + GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale)) + .button("%gui.ok") + .responseHandler((form, responseData) -> { + SimpleFormResponse response = form.parseResponse(responseData); + if (response.isCorrect()) { + session.authenticateWithMicrosoftCode(true); + } else { + session.disconnect("%disconnect.quitting"); + } + })); + } + public static void buildAndShowLoginDetailsWindow(GeyserSession session) { session.sendForm( CustomForm.builder() diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 9692adbf3..2582e4d4d 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -66,20 +66,15 @@ remote: # If you're using a plugin version of Floodgate on the same server, the key will automatically be picked up from Floodgate. floodgate-key-file: key.pem -# The Xbox/Minecraft Bedrock username is the key for the Java server auth-info. -# This allows automatic configuration/login to the remote Java server. -# If you are brave enough to put your Mojang account info into a config file. -# Uncomment the lines below to enable this feature. -#userAuths: -# BedrockAccountUsername: # Your Minecraft: Bedrock Edition username -# email: javaccountemail@example.com # Your Minecraft: Java Edition email -# password: javaccountpassword123 # Your Minecraft: Java Edition password -# microsoft-account: true # Whether the account is a Mojang or Microsoft account. -# -# bluerkelp2: -# email: not_really_my_email_address_mr_minecrafter53267@gmail.com -# password: "this isn't really my password" -# microsoft-account: false +# For online mode authentication type only. +# Stores a list of Bedrock players that should have their Java Edition account saved after login. +# This saves a token that can be reused to authenticate the player later. This does not save emails or passwords, +# but you should still be cautious when adding to this list and giving others access to this Geyser instance's files. +# Removing a name from this list will delete its cached login information on the next Geyser startup. +# The file for this is in the same folder as this config, named "saved-refresh-tokens.json". +saved-user-logins: + - ThisExampleUsernameShouldBeLongEnoughToNeverBeAnXboxUsername + - ThisOtherExampleUsernameShouldAlsoBeLongEnough # Specify how many seconds to wait while user authorizes Geyser to access their Microsoft account. # User is allowed to disconnect from the server during this period. diff --git a/core/src/main/resources/languages b/core/src/main/resources/languages index 5db9d29ec..c03eea033 160000 --- a/core/src/main/resources/languages +++ b/core/src/main/resources/languages @@ -1 +1 @@ -Subproject commit 5db9d29ece0b3d810ae42f6bdc9eeefd76e3d99d +Subproject commit c03eea033cb61ece135cd795ce04b34dd39a02f8