🆕 Handshake on connect - ECDH key exchange (P-256 so it works in every browser's crypto.subtle)

This commit is contained in:
duckietm
2026-04-23 15:53:30 +02:00
parent dd06f2b15c
commit 030b5ec174
8 changed files with 322 additions and 0 deletions
@@ -10,4 +10,6 @@ public class GameServerAttributes {
public static final AttributeKey<HabboRC4> CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient");
public static final AttributeKey<HabboRC4> CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer");
public static final AttributeKey<String> WS_IP = AttributeKey.valueOf("WebSocketIP");
public static final AttributeKey<byte[]> WS_AES_KEY = AttributeKey.valueOf("WsAesKey");
}
@@ -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<SocketChanne
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
ch.pipeline().addLast(WsHandshakeHandler.HANDLER_NAME, new WsHandshakeHandler());
}
ch.pipeline().addLast(new GamePolicyDecoder());
ch.pipeline().addLast(new GameByteFrameDecoder());
ch.pipeline().addLast(new GameByteDecoder());
@@ -0,0 +1,46 @@
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.MessageToMessageDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WsAesDecoder extends MessageToMessageDecoder<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class);
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> 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();
}
}
}
@@ -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<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesEncoder.class);
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf in, List<Object> 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);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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