diff --git a/Database Updates/011_HotelLogin.sql b/Database Updates/011_HotelLogin.sql index 76585c8d..666a9b5f 100644 --- a/Database Updates/011_HotelLogin.sql +++ b/Database Updates/011_HotelLogin.sql @@ -110,22 +110,26 @@ CREATE TABLE IF NOT EXISTS `room_templates_items` ( FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; -CREATE TABLE IF NOT EXISTS `users_remember_tokens` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `user_id` int(11) NOT NULL, - `token_hash` char(64) NOT NULL, - `created_at` int(11) NOT NULL, - `expires_at` int(11) NOT NULL, - `ip_address` varchar(45) NOT NULL DEFAULT '', - PRIMARY KEY (`id`), - UNIQUE KEY `token_hash` (`token_hash`), - KEY `user_id` (`user_id`), +CREATE TABLE IF NOT EXISTS `users_remember_families` ( + `family_id` char(36) NOT NULL, + `user_id` int(11) NOT NULL, + `current_version` int(11) NOT NULL DEFAULT 1, + `created_at` int(11) NOT NULL, + `expires_at` int(11) NOT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT 0, + `last_ip` varchar(45) NOT NULL DEFAULT '', + PRIMARY KEY (`family_id`), + KEY `user_id` (`user_id`), KEY `expires_at` (`expires_at`), - CONSTRAINT `fk_remember_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE + CONSTRAINT `fk_remember_family_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; --- Optional: configure how long a remember token is valid (default 30 days). +DROP TABLE IF EXISTS `users_remember_tokens`; + INSERT INTO `emulator_settings` (`key`, `value`) VALUES - ('login.remember.duration.days', '30') + ('login.remember.duration.days', '30'), + ('login.remember.rotate.interval.minutes', '15'), + ('login.remember.jwt.secret', '') ON DUPLICATE KEY UPDATE `value` = `value`; 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 now) { - userId = rs.getInt("user_id"); - tokenRowId = rs.getInt("id"); - } else { - tokenRowId = rs.getInt("id"); // expired - still purge below - } - } - } - } - - if (userId <= 0) { - if (tokenRowId > 0) { - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } - } - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); - return; - } - - String username = null; - try (PreparedStatement usr = conn.prepareStatement( - "SELECT username FROM users WHERE id = ? LIMIT 1")) { - usr.setInt(1, userId); - try (ResultSet rs = usr.executeQuery()) { - if (rs.next()) username = rs.getString("username"); - } - } - - if (username == null) { - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } + RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); + if (rot == null) { sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); return; } @@ -364,23 +304,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { upd.setString(1, ssoTicket); upd.setString(2, ip == null ? "" : ip); - upd.setInt(3, userId); + upd.setInt(3, rot.userId); upd.executeUpdate(); } - // Rotate: drop the consumed token and issue a new one. - try (PreparedStatement del = conn.prepareStatement( - "DELETE FROM users_remember_tokens WHERE id = ?")) { - del.setInt(1, tokenRowId); - del.executeUpdate(); - } - - String newToken = issueRememberToken(conn, userId, ip); - JsonObject ok = new JsonObject(); ok.addProperty("ssoTicket", ssoTicket); - ok.addProperty("username", username); - if (newToken != null) ok.addProperty("rememberToken", newToken); + ok.addProperty("username", rot.username); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); sendJson(ctx, req, HttpResponseStatus.OK, ok); } catch (Exception e) { LOGGER.error("Remember login failed", e); @@ -388,57 +320,29 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /** - * Generates a fresh remember-me token for a user, stores the hash, - * and returns the raw base64url string to embed in the response. - * Returns null on failure (the login still succeeds). - */ - private static String issueRememberToken(Connection conn, int userId, String ip) { - byte[] buf = new byte[32]; - RNG.nextBytes(buf); - String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - String hash = sha256Hex(raw); - if (hash == null) return null; - - int now = Emulator.getIntUnixTimestamp(); - int days = Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30)); - int expiresAt = now + (days * 24 * 60 * 60); - - try (PreparedStatement ins = conn.prepareStatement( - "INSERT INTO users_remember_tokens (user_id, token_hash, created_at, expires_at, ip_address) VALUES (?, ?, ?, ?, ?)")) { - ins.setInt(1, userId); - ins.setString(2, hash); - ins.setInt(3, now); - ins.setInt(4, expiresAt); - ins.setString(5, ip == null ? "" : ip); - ins.executeUpdate(); - } catch (SQLException e) { - LOGGER.error("Failed to persist remember token for userId=" + userId, e); - return null; + private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { + String jwt = readString(body, "rememberToken").trim(); + if (jwt.isEmpty()) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing rememberToken.")); + return; } - return raw; - } - - private static String sha256Hex(String input) { - try { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(digest.length * 2); - for (byte b : digest) { - String h = Integer.toHexString(b & 0xff); - if (h.length() == 1) sb.append('0'); - sb.append(h); + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) { + RememberJwtService.RotationResult rot = RememberJwtService.rotate(conn, jwt, ip); + if (rot == null) { + sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember token invalid or expired.")); + return; } - return sb.toString(); + JsonObject ok = new JsonObject(); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); } catch (Exception e) { - LOGGER.error("sha256Hex failed", e); - return null; + LOGGER.error("Refresh failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); } } - /* ─── Login ─── */ - private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String username = readString(body, "username").trim(); String password = readString(body, "password"); @@ -487,7 +391,16 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { upd.executeUpdate(); } - String rememberToken = rememberMe ? issueRememberToken(conn, userId, ip) : null; + String rememberToken = null; + if (rememberMe) { + try { + RememberJwtService.RotationResult issued = RememberJwtService.issueForNewFamily( + conn, userId, rs.getString("username"), ip); + rememberToken = issued.jwt; + } catch (SQLException e) { + LOGGER.error("Failed to issue remember-me JWT for userId=" + userId, e); + } + } AuthRateLimiter.recordSuccess(ip); @@ -503,8 +416,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Register ─── */ - private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) { sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Registration is closed.")); @@ -633,13 +544,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /** - * If the template carries a custom heightmap (override_model='1' and a - * non-empty heightmap), creates the matching room_models_custom row keyed - * by the new room id and renames the room's model to custom_<newRoomId>. - * Without this, cloned rooms reference a layout that doesn't exist - * (the source room's id) and load as a black screen. - */ private static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) { String overrideModel = "0"; String heightmap = ""; @@ -697,11 +601,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId); } - /** - * Seeds starting balances into users_currency for duckets (type=0) and - * diamonds (type=5). Only inserts when the amount is > 0. Credits live - * in users.credits and are set directly during the register INSERT. - */ private static void seedUserCurrencies(Connection conn, int userId, int duckets, int diamonds) { try (PreparedStatement ins = conn.prepareStatement( "INSERT INTO users_currency (user_id, type, amount) VALUES (?, ?, ?) " + @@ -725,8 +624,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Room templates (registration step 3) ─── */ - private void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) { JsonArray templates = new JsonArray(); try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); @@ -754,11 +651,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, res); } - /** - * Clones a room_templates entry + its room_templates_items into the new - * user's rooms/items rows, then points their home_room at the new room. - * Failures here do not abort registration; the account is still created. - */ 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); @@ -836,8 +728,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* ─── Forgot password ─── */ - private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String email = readString(body, "email").trim(); @@ -891,8 +781,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { sendJson(ctx, req, HttpResponseStatus.OK, ok); } - /* ─── Helpers ─── */ - private static boolean checkPassword(String plain, String stored) { String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; try { 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 new file mode 100644 index 00000000..bba9dbce --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java @@ -0,0 +1,276 @@ +package com.eu.habbo.networking.gameserver.auth; + +import com.eu.habbo.Emulator; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Base64; +import java.util.UUID; + +public final class RememberJwtService { + + private static final Logger LOGGER = LoggerFactory.getLogger(RememberJwtService.class); + private static final SecureRandom RNG = new SecureRandom(); + private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder(); + + private static volatile String cachedSecret = null; + + private RememberJwtService() {} + + public static final class RotationResult { + public final String jwt; + public final int userId; + public final String username; + public final long expiresAt; + + RotationResult(String jwt, int userId, String username, long expiresAt) { + this.jwt = jwt; + this.userId = userId; + this.username = username; + this.expiresAt = expiresAt; + } + } + + private static int familyTtlDays() { + return Math.max(1, Emulator.getConfig().getInt("login.remember.duration.days", 30)); + } + + private static long familyTtlSeconds() { + return familyTtlDays() * 86400L; + } + + private static String secret() { + String s = cachedSecret; + if (s != null && !s.isEmpty()) return s; + + synchronized (RememberJwtService.class) { + if (cachedSecret != null && !cachedSecret.isEmpty()) return cachedSecret; + + String configured = Emulator.getConfig().getValue("login.remember.jwt.secret", ""); + if (configured != null && !configured.isEmpty()) { + cachedSecret = configured; + return configured; + } + + byte[] buf = new byte[48]; + RNG.nextBytes(buf); + String generated = Base64.getEncoder().withoutPadding().encodeToString(buf); + + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO emulator_settings (`key`, `value`) VALUES ('login.remember.jwt.secret', ?) " + + "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) { + stmt.setString(1, generated); + stmt.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Could not persist generated login.remember.jwt.secret; using in-memory only", e); + } + + Emulator.getConfig().update("login.remember.jwt.secret", generated); + cachedSecret = generated; + LOGGER.info("[auth/remember] generated new JWT signing secret (persisted to emulator_settings)"); + return generated; + } + } + + public static RotationResult issueForNewFamily(Connection conn, int userId, String username, String ip) throws SQLException { + String familyId = UUID.randomUUID().toString(); + long now = Emulator.getIntUnixTimestamp(); + long expiresAt = now + familyTtlSeconds(); + + try (PreparedStatement ins = conn.prepareStatement( + "INSERT INTO users_remember_families (family_id, user_id, current_version, created_at, expires_at, revoked, last_ip) " + + "VALUES (?, ?, 1, ?, ?, 0, ?)")) { + ins.setString(1, familyId); + ins.setInt(2, userId); + ins.setLong(3, now); + ins.setLong(4, expiresAt); + ins.setString(5, ip == null ? "" : ip); + ins.executeUpdate(); + } + + String jwt = buildJwt(userId, familyId, 1, now, expiresAt); + return new RotationResult(jwt, userId, username, expiresAt); + } + + public static RotationResult rotate(Connection conn, String jwt, String ip) { + ParsedJwt parsed; + try { + parsed = verifyAndParse(jwt); + } catch (Exception e) { + LOGGER.debug("[auth/remember] invalid JWT: {}", e.getMessage()); + return null; + } + + long now = Emulator.getIntUnixTimestamp(); + if (parsed.exp <= now) return null; + + int familyVersion = 0; + boolean revoked = false; + long familyExpiresAt = 0; + try (PreparedStatement sel = conn.prepareStatement( + "SELECT current_version, revoked, expires_at FROM users_remember_families WHERE family_id = ? AND user_id = ? LIMIT 1")) { + sel.setString(1, parsed.familyId); + sel.setInt(2, parsed.userId); + try (ResultSet rs = sel.executeQuery()) { + if (!rs.next()) return null; + familyVersion = rs.getInt("current_version"); + revoked = rs.getInt("revoked") != 0; + familyExpiresAt = rs.getLong("expires_at"); + } + } catch (SQLException e) { + LOGGER.error("[auth/remember] family lookup failed", e); + return null; + } + + if (revoked || familyExpiresAt <= now) return null; + + if (parsed.version < familyVersion) { + LOGGER.warn("[auth/remember] replay detected: familyId={} presented v={} but current is v={}, revoking family", + parsed.familyId, parsed.version, familyVersion); + revokeFamilyById(conn, parsed.familyId); + return null; + } + if (parsed.version > familyVersion) { + LOGGER.warn("[auth/remember] future version: familyId={} presented v={} but current is v={}", + parsed.familyId, parsed.version, familyVersion); + return null; + } + + int newVersion = familyVersion + 1; + long newExpiresAt = now + familyTtlSeconds(); + + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users_remember_families SET current_version = ?, expires_at = ?, last_ip = ? " + + "WHERE family_id = ? AND current_version = ? AND revoked = 0")) { + upd.setInt(1, newVersion); + upd.setLong(2, newExpiresAt); + upd.setString(3, ip == null ? "" : ip); + upd.setString(4, parsed.familyId); + upd.setInt(5, familyVersion); + int rows = upd.executeUpdate(); + if (rows == 0) return null; + } catch (SQLException e) { + LOGGER.error("[auth/remember] rotation update failed", e); + return null; + } + + String username = null; + try (PreparedStatement usr = conn.prepareStatement("SELECT username FROM users WHERE id = ? LIMIT 1")) { + usr.setInt(1, parsed.userId); + try (ResultSet rs = usr.executeQuery()) { + if (rs.next()) username = rs.getString("username"); + } + } catch (SQLException e) { + LOGGER.error("[auth/remember] username lookup failed", e); + } + + if (username == null) return null; + + 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) { + try { + ParsedJwt p = verifyAndParse(jwt); + revokeFamilyById(conn, p.familyId); + } catch (Exception ignored) { } + } + + private static void revokeFamilyById(Connection conn, String familyId) { + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users_remember_families SET revoked = 1 WHERE family_id = ?")) { + upd.setString(1, familyId); + upd.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("[auth/remember] revoke failed for familyId=" + familyId, e); + } + } + + private static String buildJwt(int userId, String familyId, int version, long iat, long exp) { + JsonObject header = new JsonObject(); + header.addProperty("alg", "HS256"); + header.addProperty("typ", "JWT"); + + JsonObject payload = new JsonObject(); + payload.addProperty("sub", userId); + payload.addProperty("fid", familyId); + payload.addProperty("v", version); + payload.addProperty("iat", iat); + payload.addProperty("exp", exp); + payload.addProperty("typ", "refresh"); + + String h = URL_ENC.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8)); + String p = URL_ENC.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8)); + String signingInput = h + "." + p; + String sig = URL_ENC.encodeToString(hmacSha256(secret().getBytes(StandardCharsets.UTF_8), + signingInput.getBytes(StandardCharsets.UTF_8))); + return signingInput + "." + sig; + } + + private static final class ParsedJwt { + final int userId; + final String familyId; + final int version; + final long exp; + + ParsedJwt(int userId, String familyId, int version, long exp) { + this.userId = userId; + this.familyId = familyId; + this.version = version; + this.exp = exp; + } + } + + private static ParsedJwt verifyAndParse(String jwt) throws Exception { + if (jwt == null || jwt.isEmpty()) throw new IllegalArgumentException("empty"); + + String[] parts = jwt.split("\\."); + if (parts.length != 3) throw new IllegalArgumentException("not 3 segments"); + + String signingInput = parts[0] + "." + parts[1]; + byte[] expected = hmacSha256(secret().getBytes(StandardCharsets.UTF_8), signingInput.getBytes(StandardCharsets.UTF_8)); + byte[] provided = URL_DEC.decode(parts[2]); + if (!constantTimeEquals(expected, provided)) throw new SecurityException("bad signature"); + + byte[] payloadBytes = URL_DEC.decode(parts[1]); + JsonObject payload = JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8)).getAsJsonObject(); + + if (!payload.has("typ") || !"refresh".equals(payload.get("typ").getAsString())) throw new IllegalArgumentException("wrong typ"); + int userId = payload.get("sub").getAsInt(); + String fid = payload.get("fid").getAsString(); + int version = payload.get("v").getAsInt(); + long exp = payload.get("exp").getAsLong(); + + return new ParsedJwt(userId, fid, version, exp); + } + + private static byte[] hmacSha256(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (Exception e) { + throw new IllegalStateException("HmacSHA256 unavailable", e); + } + } + + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a == null || b == null || a.length != b.length) return false; + int r = 0; + for (int i = 0; i < a.length; i++) r |= a[i] ^ b[i]; + return r == 0; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java new file mode 100644 index 00000000..fade61ce --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java @@ -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 { + 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..470be101 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