diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java index 5de9d67f..a8ec86b8 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/GameServerAttributes.java @@ -10,4 +10,6 @@ public class GameServerAttributes { public static final AttributeKey CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient"); public static final AttributeKey CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer"); public static final AttributeKey WS_IP = AttributeKey.valueOf("WebSocketIP"); + public static final AttributeKey WS_AES_KEY = AttributeKey.valueOf("WsAesKey"); } + diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java index 99a7ebbe..dfba3f75 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java @@ -1,8 +1,10 @@ package com.eu.habbo.networking.gameserver; +import com.eu.habbo.Emulator; import com.eu.habbo.messages.PacketManager; import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler; import com.eu.habbo.networking.gameserver.codec.WebSocketCodec; +import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler; import com.eu.habbo.networking.gameserver.decoders.*; import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder; import com.eu.habbo.networking.gameserver.encoders.GameServerMessageLogger; @@ -53,6 +55,11 @@ public class WebSocketChannelInitializer extends ChannelInitializer { + private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class); + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get(); + if (key == null) { + LOGGER.warn("[ws-crypto] inbound frame with no session key, closing"); + ctx.close(); + return; + } + + int readable = in.readableBytes(); + if (readable < WsSessionCrypto.NONCE_LEN + 16) { + LOGGER.warn("[ws-crypto] inbound frame too short ({} bytes)", readable); + ctx.close(); + return; + } + + byte[] nonce = new byte[WsSessionCrypto.NONCE_LEN]; + in.readBytes(nonce); + + byte[] ct = new byte[in.readableBytes()]; + in.readBytes(ct); + + try { + byte[] plain = WsSessionCrypto.aesGcmDecrypt(key, nonce, ct); + out.add(Unpooled.wrappedBuffer(plain)); + } catch (Exception e) { + LOGGER.warn("[ws-crypto] AES-GCM decrypt failed", e); + ctx.close(); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesEncoder.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesEncoder.java new file mode 100644 index 00000000..2a14f453 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesEncoder.java @@ -0,0 +1,35 @@ +package com.eu.habbo.networking.gameserver.crypto; + +import com.eu.habbo.networking.gameserver.GameServerAttributes; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToMessageEncoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class WsAesEncoder extends MessageToMessageEncoder { + private static final Logger LOGGER = LoggerFactory.getLogger(WsAesEncoder.class); + + @Override + protected void encode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get(); + if (key == null) { + LOGGER.warn("[ws-crypto] outbound frame with no session key, dropping"); + return; + } + + byte[] plain = new byte[in.readableBytes()]; + in.readBytes(plain); + + byte[] nonce = WsSessionCrypto.randomNonce(); + byte[] ct = WsSessionCrypto.aesGcmEncrypt(key, nonce, plain); + + ByteBuf framed = ctx.alloc().buffer(nonce.length + ct.length); + framed.writeBytes(nonce); + framed.writeBytes(ct); + out.add(framed); + } +} 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 new file mode 100644 index 00000000..b4cd222f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java @@ -0,0 +1,130 @@ +package com.eu.habbo.networking.gameserver.crypto; + +import com.eu.habbo.networking.gameserver.GameServerAttributes; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.KeyPair; +import java.security.PrivateKey; +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 KeyPair serverKeyPair; + private boolean helloSent = false; + private boolean handshakeComplete = false; + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) { + sendServerHello(ctx); + } + super.userEventTriggered(ctx, evt); + } + + private void sendServerHello(ChannelHandlerContext ctx) { + if (helloSent) return; + try { + this.serverKeyPair = WsSessionCrypto.generateEphemeralKeyPair(); + byte[] spki = WsSessionCrypto.encodePublicKeySpki(serverKeyPair.getPublic()); + + ByteBuf buf = ctx.alloc().buffer(4 + 1 + 2 + spki.length); + buf.writeInt(WsSessionCrypto.HANDSHAKE_MAGIC); + buf.writeByte(WsSessionCrypto.TYPE_SERVER_HELLO); + buf.writeShort(spki.length); + buf.writeBytes(spki); + + ctx.writeAndFlush(buf); + helloSent = true; + } catch (Exception e) { + LOGGER.error("[ws-crypto] failed to send server_hello", e); + ctx.close(); + } + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (handshakeComplete) { + ctx.fireChannelRead(msg); + return; + } + + if (!(msg instanceof ByteBuf)) { + ctx.fireChannelRead(msg); + return; + } + + ByteBuf in = (ByteBuf) msg; + try { + if (in.readableBytes() < 7) { + LOGGER.warn("[ws-crypto] handshake frame too short ({} bytes) from {}", in.readableBytes(), clientAddress(ctx)); + ctx.close(); + return; + } + + int magic = in.readInt(); + if (magic != WsSessionCrypto.HANDSHAKE_MAGIC) { + LOGGER.warn("[ws-crypto] handshake magic mismatch: 0x{} from {}", Integer.toHexString(magic), clientAddress(ctx)); + ctx.close(); + return; + } + + byte type = in.readByte(); + if (type != WsSessionCrypto.TYPE_CLIENT_HELLO) { + LOGGER.warn("[ws-crypto] expected client_hello, got type=0x{} from {}", Integer.toHexString(type & 0xff), clientAddress(ctx)); + ctx.close(); + return; + } + + int keyLen = in.readUnsignedShort(); + if (keyLen <= 0 || keyLen > in.readableBytes() || keyLen > 2048) { + LOGGER.warn("[ws-crypto] invalid client key length {} from {}", keyLen, clientAddress(ctx)); + ctx.close(); + return; + } + + byte[] clientSpki = new byte[keyLen]; + in.readBytes(clientSpki); + + PublicKey clientPub = WsSessionCrypto.decodePublicKeySpki(clientSpki); + 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()); + handshakeComplete = true; + p.remove(this); + + LOGGER.debug("[ws-crypto] handshake complete for {}", clientAddress(ctx)); + } catch (Exception e) { + LOGGER.error("[ws-crypto] handshake failed from " + clientAddress(ctx), e); + ctx.close(); + } finally { + in.release(); + } + } + + private static String clientAddress(ChannelHandlerContext ctx) { + String wsIp = ctx.channel().attr(GameServerAttributes.WS_IP).get(); + if (wsIp != null && !wsIp.isEmpty()) return wsIp; + return String.valueOf(ctx.channel().remoteAddress()); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + LOGGER.error("[ws-crypto] handshake handler error", cause); + ctx.close(); + } +} 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 new file mode 100644 index 00000000..305e4f00 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java @@ -0,0 +1,99 @@ +package com.eu.habbo.networking.gameserver.crypto; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +public final class WsSessionCrypto { + + public static final int HANDSHAKE_MAGIC = 0xC0DEC0DE; + public static final byte TYPE_SERVER_HELLO = 0x01; + public static final byte TYPE_CLIENT_HELLO = 0x02; + + public static final String HKDF_INFO = "nitro-ws-v1"; + public static final int AES_KEY_LEN = 32; + public static final int NONCE_LEN = 12; + public static final int GCM_TAG_BITS = 128; + + private static final SecureRandom RNG = new SecureRandom(); + + private WsSessionCrypto() {} + + public static KeyPair generateEphemeralKeyPair() throws GeneralSecurityException { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new java.security.spec.ECGenParameterSpec("secp256r1"), RNG); + return kpg.generateKeyPair(); + } + + public static byte[] encodePublicKeySpki(PublicKey publicKey) { + return publicKey.getEncoded(); + } + + public static PublicKey decodePublicKeySpki(byte[] spki) throws GeneralSecurityException { + KeyFactory kf = KeyFactory.getInstance("EC"); + return kf.generatePublic(new X509EncodedKeySpec(spki)); + } + + public static byte[] deriveSharedSecret(PrivateKey ourPrivate, PublicKey theirPublic) throws GeneralSecurityException { + KeyAgreement ka = KeyAgreement.getInstance("ECDH"); + ka.init(ourPrivate); + ka.doPhase(theirPublic, true); + return ka.generateSecret(); + } + + public static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int outLen) throws GeneralSecurityException { + if (salt == null || salt.length == 0) salt = new byte[32]; + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = mac.doFinal(ikm); + + int hashLen = 32; + int n = (outLen + hashLen - 1) / hashLen; + if (n > 255) throw new GeneralSecurityException("HKDF output too long"); + + ByteArrayOutputStream okm = new ByteArrayOutputStream(); + byte[] t = new byte[0]; + + for (int i = 1; i <= n; i++) { + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(t); + if (info != null) mac.update(info); + mac.update((byte) i); + t = mac.doFinal(); + okm.write(t, 0, t.length); + } + + byte[] result = okm.toByteArray(); + return (result.length == outLen) ? result : Arrays.copyOf(result, outLen); + } + + public static byte[] deriveAesKey(byte[] sharedSecret) throws GeneralSecurityException { + return hkdfSha256(sharedSecret, null, HKDF_INFO.getBytes(StandardCharsets.UTF_8), AES_KEY_LEN); + } + + public static byte[] aesGcmEncrypt(byte[] key, byte[] nonce, byte[] plaintext) throws GeneralSecurityException { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce)); + return c.doFinal(plaintext); + } + + public static byte[] aesGcmDecrypt(byte[] key, byte[] nonce, byte[] ciphertextWithTag) throws GeneralSecurityException { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, nonce)); + return c.doFinal(ciphertextWithTag); + } + + public static byte[] randomNonce() { + byte[] n = new byte[NONCE_LEN]; + RNG.nextBytes(n); + return n; + } +} 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 086403d0..70b49e34 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 diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example index e1eee315..96571801 100644 --- a/Latest_Compiled_Version/config.ini.example +++ b/Latest_Compiled_Version/config.ini.example @@ -8,6 +8,9 @@ db.params= db.pool.minsize=25 db.pool.maxsize=100 +# Encrypt your traffic +crypto.ws.enabled=0 + #Game Configuration. #Host IP. Most likely just 0.0.0.0 Use 127.0.0.1 if you want to play on LAN. game.host=0.0.0.0