You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
🆕 Handshake on connect - ECDH key exchange (P-256 so it works in every browser's crypto.subtle)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
+130
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user