diff --git a/Database Updates/012_crypto.sql b/Database Updates/012_crypto.sql new file mode 100644 index 00000000..334c1fd6 --- /dev/null +++ b/Database Updates/012_crypto.sql @@ -0,0 +1,8 @@ +INSERT INTO `emulator_settings` (`key`, `value`) VALUES + ('crypto.ws.enabled', '0'), + ('crypto.ws.signing.enabled', '0'), + ('crypto.ws.signing.public_key', ''), + ('crypto.ws.signing.private_key', '') +ON DUPLICATE KEY UPDATE `value` = `value`; + + diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java index eebc86b5..b3f81c91 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServer.java @@ -96,6 +96,16 @@ public class GameServer extends Server { LOGGER.error("Failed to start WebSocket server on {}:{}", wsHost, wsPort); } else { LOGGER.info("WebSocket server started on {}:{} (SSL: {})", wsHost, wsPort, wsInitializer.isSslEnabled()); + + if (com.eu.habbo.Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false)) { + try { + com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.get(); + LOGGER.info("[ws-crypto] signing public key ready: {}", + com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64()); + } catch (Exception e) { + LOGGER.error("[ws-crypto] failed to warm signing keypair", e); + } + } } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java index 174bcbb7..6ed177c4 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpHandler.java @@ -35,6 +35,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { private static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates"; private static final String REMEMBER_PATH = "/api/auth/remember"; private static final String REFRESH_PATH = "/api/auth/refresh"; + private static final String SERVER_KEY_PATH = "/api/auth/server-key"; private static final String HEALTH_PATH = "/api/health"; private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$"); @@ -58,6 +59,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { && !path.equals(ROOM_TEMPLATES_PATH) && !path.equals(REMEMBER_PATH) && !path.equals(REFRESH_PATH) + && !path.equals(SERVER_KEY_PATH) && !path.equals(HEALTH_PATH)) { super.channelRead(ctx, msg); return; @@ -96,6 +98,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return; } + if (path.equals(SERVER_KEY_PATH)) { + if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET.")); + return; + } + handleServerKey(ctx, req); + return; + } + if (req.method() != HttpMethod.POST) { sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST.")); return; @@ -651,6 +662,18 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, res); } + private void handleServerKey(ChannelHandlerContext ctx, FullHttpRequest req) { + try { + JsonObject ok = new JsonObject(); + ok.addProperty("publicKey", com.eu.habbo.networking.gameserver.crypto.CryptoSigningKeyManager.publicKeyBase64()); + ok.addProperty("algorithm", "ECDSA-P256-SHA256"); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } catch (Exception e) { + LOGGER.error("server-key fetch failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + } + } + private static void cloneTemplateForUser(Connection conn, int templateId, int userId, String userName) { LOGGER.info("[auth/register] cloning template id={} for user id={} name='{}'", templateId, userId, userName); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java index bba9dbce..9e2a4891 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java @@ -14,6 +14,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.util.Base64; import java.util.UUID; @@ -181,8 +182,8 @@ public final class RememberJwtService { String newJwt = buildJwt(parsed.userId, parsed.familyId, newVersion, now, newExpiresAt); return new RotationResult(newJwt, parsed.userId, username, newExpiresAt); } - - public static void revokeFromToken(Connection conn, String jwt) { + + public static void revokeFromToken(Connection conn, String jwt) { try { ParsedJwt p = verifyAndParse(jwt); revokeFamilyById(conn, p.familyId); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java new file mode 100644 index 00000000..0e265f80 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/CryptoSigningKeyManager.java @@ -0,0 +1,90 @@ +package com.eu.habbo.networking.gameserver.crypto; + +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.Base64; + +public final class CryptoSigningKeyManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(CryptoSigningKeyManager.class); + private static final String KEY_PUBLIC = "crypto.ws.signing.public_key"; + private static final String KEY_PRIVATE = "crypto.ws.signing.private_key"; + private static volatile KeyPair cached; + private static volatile String cachedPublicB64; + private CryptoSigningKeyManager() {} + + public static KeyPair get() { + KeyPair kp = cached; + if (kp != null) return kp; + + synchronized (CryptoSigningKeyManager.class) { + if (cached != null) return cached; + + String pubB64 = Emulator.getConfig().getValue(KEY_PUBLIC, ""); + String privB64 = Emulator.getConfig().getValue(KEY_PRIVATE, ""); + + if (pubB64 != null && !pubB64.isEmpty() && privB64 != null && !privB64.isEmpty()) { + try { + byte[] pubDer = Base64.getDecoder().decode(pubB64); + byte[] privDer = Base64.getDecoder().decode(privB64); + KeyFactory kf = KeyFactory.getInstance("EC"); + PublicKey pub = kf.generatePublic(new X509EncodedKeySpec(pubDer)); + PrivateKey priv = kf.generatePrivate(new PKCS8EncodedKeySpec(privDer)); + cached = new KeyPair(pub, priv); + cachedPublicB64 = pubB64; + return cached; + } catch (Exception e) { + LOGGER.error("[ws-crypto] persisted signing key is corrupt, generating a new pair", e); + } + } + + try { + KeyPair generated = WsSessionCrypto.generateSigningKeyPair(); + byte[] pubDer = WsSessionCrypto.encodePublicKeySpki(generated.getPublic()); + byte[] privDer = WsSessionCrypto.encodePrivateKeyPkcs8(generated.getPrivate()); + String newPubB64 = Base64.getEncoder().withoutPadding().encodeToString(pubDer); + String newPrivB64 = Base64.getEncoder().withoutPadding().encodeToString(privDer); + + persist(KEY_PUBLIC, newPubB64); + persist(KEY_PRIVATE, newPrivB64); + Emulator.getConfig().update(KEY_PUBLIC, newPubB64); + Emulator.getConfig().update(KEY_PRIVATE, newPrivB64); + + cached = generated; + cachedPublicB64 = newPubB64; + LOGGER.info("[ws-crypto] generated a new ECDSA P-256 signing keypair (persisted to emulator_settings)"); + return cached; + } catch (Exception e) { + throw new IllegalStateException("Cannot generate signing keypair", e); + } + } + } + + public static String publicKeyBase64() { + if (cachedPublicB64 == null) get(); + return cachedPublicB64; + } + + private static void persist(String key, String value) { + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO emulator_settings (`key`, `value`) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) { + stmt.setString(1, key); + stmt.setString(2, value); + stmt.executeUpdate(); + } catch (Exception e) { + LOGGER.error("[ws-crypto] failed to persist " + key + " to emulator_settings (key stays in-memory only)", e); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java index b4cd222f..864498dc 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java @@ -1,5 +1,6 @@ package com.eu.habbo.networking.gameserver.crypto; +import com.eu.habbo.Emulator; import com.eu.habbo.networking.gameserver.GameServerAttributes; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; @@ -15,9 +16,8 @@ import java.security.PublicKey; public class WsHandshakeHandler extends ChannelInboundHandlerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(WsHandshakeHandler.class); - public static final String HANDLER_NAME = "wsCryptoHandshake"; - + private static final boolean SIGN_ENABLED = Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false); private KeyPair serverKeyPair; private boolean helloSent = false; private boolean handshakeComplete = false; @@ -35,12 +35,23 @@ public class WsHandshakeHandler extends ChannelInboundHandlerAdapter { try { this.serverKeyPair = WsSessionCrypto.generateEphemeralKeyPair(); byte[] spki = WsSessionCrypto.encodePublicKeySpki(serverKeyPair.getPublic()); + byte[] sigIeee = null; + if (SIGN_ENABLED) { + KeyPair signingKp = CryptoSigningKeyManager.get(); + byte[] sigDer = WsSessionCrypto.signEcdsaSha256(signingKp.getPrivate(), spki); + sigIeee = WsSessionCrypto.derToIeee1363(sigDer); + } - ByteBuf buf = ctx.alloc().buffer(4 + 1 + 2 + spki.length); + int frameLen = 4 + 1 + 2 + spki.length + (sigIeee != null ? 2 + sigIeee.length : 0); + ByteBuf buf = ctx.alloc().buffer(frameLen); buf.writeInt(WsSessionCrypto.HANDSHAKE_MAGIC); buf.writeByte(WsSessionCrypto.TYPE_SERVER_HELLO); buf.writeShort(spki.length); buf.writeBytes(spki); + if (sigIeee != null) { + buf.writeShort(sigIeee.length); + buf.writeBytes(sigIeee); + } ctx.writeAndFlush(buf); helloSent = true; @@ -98,9 +109,7 @@ public class WsHandshakeHandler extends ChannelInboundHandlerAdapter { PrivateKey ourPriv = serverKeyPair.getPrivate(); byte[] shared = WsSessionCrypto.deriveSharedSecret(ourPriv, clientPub); byte[] aesKey = WsSessionCrypto.deriveAesKey(shared); - ctx.channel().attr(GameServerAttributes.WS_AES_KEY).set(aesKey); - ChannelPipeline p = ctx.pipeline(); p.addAfter(HANDLER_NAME, "wsAesDecoder", new WsAesDecoder()); p.addAfter(HANDLER_NAME, "wsAesEncoder", new WsAesEncoder()); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java index 305e4f00..4aa6eea3 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java @@ -8,6 +8,7 @@ import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.security.*; +import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Arrays; @@ -96,4 +97,67 @@ public final class WsSessionCrypto { RNG.nextBytes(n); return n; } + + public static KeyPair generateSigningKeyPair() throws GeneralSecurityException { + return generateEphemeralKeyPair(); + } + + public static PrivateKey decodePrivateKeyPkcs8(byte[] pkcs8) throws GeneralSecurityException { + KeyFactory kf = KeyFactory.getInstance("EC"); + return kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8)); + } + + public static byte[] encodePrivateKeyPkcs8(PrivateKey privateKey) { + return privateKey.getEncoded(); + } + + public static byte[] signEcdsaSha256(PrivateKey signingKey, byte[] message) throws GeneralSecurityException { + Signature sig = Signature.getInstance("SHA256withECDSA"); + sig.initSign(signingKey); + sig.update(message); + return sig.sign(); + } + + public static byte[] derToIeee1363(byte[] der) throws GeneralSecurityException { + if (der == null || der.length < 8 || der[0] != 0x30) { + throw new GeneralSecurityException("Malformed DER signature"); + } + + int seqLen; + int idx; + if ((der[1] & 0x80) == 0) { + seqLen = der[1] & 0xff; + idx = 2; + } else { + int lenBytes = der[1] & 0x7f; + if (lenBytes > 2) throw new GeneralSecurityException("DER length too big"); + seqLen = 0; + for (int i = 0; i < lenBytes; i++) seqLen = (seqLen << 8) | (der[2 + i] & 0xff); + idx = 2 + lenBytes; + } + if (idx + seqLen > der.length) throw new GeneralSecurityException("DER truncated"); + + if (der[idx] != 0x02) throw new GeneralSecurityException("Expected INTEGER r"); + int rLen = der[idx + 1] & 0xff; + int rStart = idx + 2; + + int sHeader = rStart + rLen; + if (der[sHeader] != 0x02) throw new GeneralSecurityException("Expected INTEGER s"); + int sLen = der[sHeader + 1] & 0xff; + int sStart = sHeader + 2; + + byte[] r = stripLeadingZero(Arrays.copyOfRange(der, rStart, rStart + rLen)); + byte[] s = stripLeadingZero(Arrays.copyOfRange(der, sStart, sStart + sLen)); + + byte[] out = new byte[64]; + System.arraycopy(r, 0, out, 32 - r.length, r.length); + System.arraycopy(s, 0, out, 64 - s.length, s.length); + return out; + } + + private static byte[] stripLeadingZero(byte[] v) { + int i = 0; + while (i < v.length - 1 && v[i] == 0x00) i++; + return Arrays.copyOfRange(v, i, v.length); + } } diff --git a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar index 470be101..e6311f33 100644 Binary files a/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.2-jar-with-dependencies.jar differ