diff --git a/Database Updates/011_HotelLogin.sql b/Database Updates/011_HotelLogin.sql index 11210bc8..666a9b5f 100644 --- a/Database Updates/011_HotelLogin.sql +++ b/Database Updates/011_HotelLogin.sql @@ -36,3 +36,100 @@ INSERT INTO `emulator_settings` (`key`, `value`) VALUES ('smtp.use_tls', '1'), ('smtp.use_ssl', '0') ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +INSERT INTO emulator_settings (`key`, `value`, `comment`) VALUES + ('new_user_credits', '0' , 'This is the default setting for habbo credits when creating an account for the NitroV3 Login'), + ('new_user_duckets', '0' , 'This is the default setting for habbo duckets when creating an account for the NitroV3 Login'), + ('new_user_diamonds', '0' , 'This is the default setting for habbo diamonds when creating an account for the NitroV3 Login') +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +-- Grant to rank 7 only (adjust rank_7 if your rank id differs) +INSERT INTO `permission_definitions` (`permission_key`, `rank_7`, `comment`) VALUES + ('cmd_setroom_template', '1', 'Use the setroom_template to copy the room into the template') +ON DUPLICATE KEY UPDATE `rank_7` = VALUES(`rank_7`); + +INSERT INTO `emulator_texts` (`key`, `value`) VALUES + ('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'), + ('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'), + ('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'), + ('commands.error.cmd_setroom_template', 'Could not save room as template. Check the server log for details.'), + ('commands.error.cmd_setroom_template.no_room', 'You must be inside a room to use this command.') +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +CREATE TABLE IF NOT EXISTS `room_templates` ( + `template_id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(128) NOT NULL DEFAULT '', + `description` varchar(256) NOT NULL DEFAULT '', + `thumbnail` varchar(512) NOT NULL DEFAULT '', + `sort_order` int(11) NOT NULL DEFAULT 0, + `enabled` enum('0','1') NOT NULL DEFAULT '1', + `name` varchar(50) NOT NULL DEFAULT '', + `room_description` varchar(250) NOT NULL DEFAULT '', + `model` varchar(100) NOT NULL, + `password` varchar(50) NOT NULL DEFAULT '', + `state` enum('open','locked','password','invisible') NOT NULL DEFAULT 'open', + `users_max` int(11) NOT NULL DEFAULT 25, + `category` int(11) NOT NULL DEFAULT 0, + `paper_floor` varchar(50) NOT NULL DEFAULT '0.0', + `paper_wall` varchar(50) NOT NULL DEFAULT '0.0', + `paper_landscape` varchar(50) NOT NULL DEFAULT '0.0', + `thickness_wall` int(11) NOT NULL DEFAULT 0, + `thickness_floor` int(11) NOT NULL DEFAULT 0, + `moodlight_data` varchar(2048) NOT NULL DEFAULT '', + `override_model` enum('0','1') NOT NULL DEFAULT '0', + `trade_mode` int(2) NOT NULL DEFAULT 2, + `heightmap` mediumtext NOT NULL DEFAULT '', + `door_x` int(11) NOT NULL DEFAULT 0, + `door_y` int(11) NOT NULL DEFAULT 0, + `door_dir` int(4) NOT NULL DEFAULT 2, + PRIMARY KEY (`template_id`), + KEY `enabled_sort` (`enabled`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- -------------------------------------------------------- +-- Items belonging to a template. Clone target is `items`. +-- `template_id` replaces `room_id`; `user_id` is absent because items +-- are re-owned by the new user at clone time. +-- -------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `room_templates_items` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `template_id` int(11) NOT NULL, + `item_id` int(11) unsigned NOT NULL, + `wall_pos` varchar(20) NOT NULL DEFAULT '', + `x` int(11) NOT NULL DEFAULT 0, + `y` int(11) NOT NULL DEFAULT 0, + `z` double(10,6) NOT NULL DEFAULT 0.000000, + `rot` int(11) NOT NULL DEFAULT 0, + `extra_data` varchar(2096) NOT NULL DEFAULT '', + `wired_data` varchar(4096) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `template_id` (`template_id`), + CONSTRAINT `fk_rt_items_template` + FOREIGN KEY (`template_id`) REFERENCES `room_templates` (`template_id`) ON DELETE CASCADE, + CONSTRAINT `fk_rt_items_item_base` + 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_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_family_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +DROP TABLE IF EXISTS `users_remember_tokens`; + +INSERT INTO `emulator_settings` (`key`, `value`) VALUES + ('login.remember.duration.days', '30'), + ('login.remember.rotate.interval.minutes', '15'), + ('login.remember.jwt.secret', '') +ON DUPLICATE KEY UPDATE `value` = `value`; + 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/pom.xml b/Emulator/pom.xml index ac1ffcb3..01b2888d 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.1.5 + 4.1.7 UTF-8 @@ -163,7 +163,7 @@ 2.13.0 - org.mindrot @@ -171,7 +171,7 @@ 0.4 - org.eclipse.angus diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java index 3f26738b..f85d83f2 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/CommandHandler.java @@ -190,7 +190,7 @@ public class CommandHandler { addCommand(new ControlCommand()); addCommand(new CoordsCommand()); addCommand(new CreditsCommand()); - addCommand(new DanceCommand()); + addCommand(new DanceCommand()); addCommand(new DiagonalCommand()); addCommand(new DisconnectCommand()); addCommand(new EjectAllCommand()); @@ -230,7 +230,7 @@ public class CommandHandler { addCommand(new MutePetsCommand()); addCommand(new PetInfoCommand()); addCommand(new PickallCommand()); - addCommand(new PingCommand()); + addCommand(new PingCommand()); addCommand(new PixelCommand()); addCommand(new PluginsCommand()); addCommand(new PointsCommand()); @@ -253,6 +253,7 @@ public class CommandHandler { addCommand(new SayCommand()); addCommand(new SetMaxCommand()); addCommand(new SetPollCommand()); + addCommand(new SetRoomTemplateCommand()); addCommand(new SetSpeedCommand()); addCommand(new ShoutAllCommand()); addCommand(new ShoutCommand()); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java new file mode 100644 index 00000000..f35dd443 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/SetRoomTemplateCommand.java @@ -0,0 +1,116 @@ +package com.eu.habbo.habbohotel.commands; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.gameclients.GameClient; +import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.*; + +public class SetRoomTemplateCommand extends Command { + private static final Logger LOGGER = LoggerFactory.getLogger(SetRoomTemplateCommand.class); + + public SetRoomTemplateCommand() { + super("cmd_setroom_template", Emulator.getTexts().getValue("commands.keys.cmd_setroom_template").split(";")); + } + + @Override + public boolean handle(GameClient gameClient, String[] params) throws Exception { + Room room = gameClient.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null) { + gameClient.getHabbo().whisper( + Emulator.getTexts().getValue("commands.error.cmd_setroom_template.no_room"), + RoomChatMessageBubbles.ALERT); + return true; + } + + String yes = Emulator.getTexts().getValue("generic.yes"); + + if (params.length < 2 || !params[1].equalsIgnoreCase(yes)) { + gameClient.getHabbo().alert( + Emulator.getTexts().getValue("commands.succes.cmd_setroom_template.verify") + .replace("%generic.yes%", yes) + .replace("%roomname%", room.getName())); + return true; + } + + int newTemplateId = 0; + int itemsCopied = 0; + int itemsSkipped = 0; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + try (PreparedStatement insTemplate = connection.prepareStatement( + "INSERT INTO room_templates (title, description, thumbnail, sort_order, enabled, " + + "name, room_description, model, password, state, users_max, category, " + + "paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " + + "moodlight_data, override_model, trade_mode) " + + "(SELECT name, description, '', 0, '1', " + + "name, description, model, password, state, users_max, category, " + + "paper_floor, paper_wall, paper_landscape, thickness_wall, thickness_floor, " + + "moodlight_data, override_model, trade_mode " + + "FROM rooms WHERE id = ?)", + Statement.RETURN_GENERATED_KEYS)) { + insTemplate.setInt(1, room.getId()); + insTemplate.executeUpdate(); + try (ResultSet keys = insTemplate.getGeneratedKeys()) { + if (keys.next()) newTemplateId = keys.getInt(1); + } + } + + if (newTemplateId <= 0) { + gameClient.getHabbo().whisper( + Emulator.getTexts().getValue("commands.error.cmd_setroom_template"), + RoomChatMessageBubbles.ALERT); + return true; + } + + if (room.hasCustomLayout()) { + try (PreparedStatement updLayout = connection.prepareStatement( + "UPDATE room_templates t " + + "JOIN room_models_custom c ON c.id = ? " + + "SET t.heightmap = c.heightmap, t.door_x = c.door_x, " + + " t.door_y = c.door_y, t.door_dir = c.door_dir " + + "WHERE t.template_id = ?")) { + updLayout.setInt(1, room.getId()); + updLayout.setInt(2, newTemplateId); + updLayout.executeUpdate(); + } + } + + try (PreparedStatement insItems = connection.prepareStatement( + "INSERT INTO room_templates_items (template_id, item_id, wall_pos, x, y, z, rot, extra_data, wired_data) " + + "SELECT ?, i.item_id, i.wall_pos, i.x, i.y, i.z, i.rot, i.extra_data, i.wired_data " + + "FROM items i JOIN items_base ib ON ib.id = i.item_id " + + "WHERE i.room_id = ?")) { + insItems.setInt(1, newTemplateId); + insItems.setInt(2, room.getId()); + itemsCopied = insItems.executeUpdate(); + } + + try (PreparedStatement countTotal = connection.prepareStatement( + "SELECT COUNT(*) FROM items WHERE room_id = ?")) { + countTotal.setInt(1, room.getId()); + try (ResultSet rs = countTotal.executeQuery()) { + if (rs.next()) itemsSkipped = Math.max(0, rs.getInt(1) - itemsCopied); + } + } + } catch (SQLException e) { + LOGGER.error("cmd_setroom_template failed for roomId=" + room.getId(), e); + gameClient.getHabbo().whisper( + Emulator.getTexts().getValue("commands.error.cmd_setroom_template"), + RoomChatMessageBubbles.ALERT); + return true; + } + + gameClient.getHabbo().whisper( + Emulator.getTexts().getValue("commands.succes.cmd_setroom_template") + .replace("%id%", Integer.toString(newTemplateId)) + .replace("%items%", Integer.toString(itemsCopied)) + .replace("%skipped%", Integer.toString(itemsSkipped)), + RoomChatMessageBubbles.ALERT); + + return true; + } +} 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/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 64c7b291..045b8cbd 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,10 +1,12 @@ 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.auth.NitroSecureApiHandler; import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler; 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; @@ -57,6 +59,11 @@ public class WebSocketChannelInitializer extends ChannelInitializer 0) { + try (PreparedStatement clear = conn.prepareStatement( + "UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) { + clear.setInt(1, userId); + clear.executeUpdate(); + } + + if (Emulator.getGameServer() != null + && Emulator.getGameServer().getGameClientManager() != null) { + com.eu.habbo.habbohotel.users.Habbo habbo = + Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (habbo != null && habbo.getClient() != null) { + Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); + } + } + } } if (!rememberToken.isEmpty()) { - String rememberHash = sha256Hex(rememberToken); - if (userId == 0) { - try (PreparedStatement lookupRemember = conn.prepareStatement( - "SELECT id FROM users WHERE remember_token_hash = ? LIMIT 1")) { - lookupRemember.setString(1, rememberHash); - try (ResultSet rs = lookupRemember.executeQuery()) { - if (rs.next()) userId = rs.getInt("id"); - } - } - } else { - clearRememberToken(conn, rememberHash); - } - } - - if (userId > 0) { - try (PreparedStatement clear = conn.prepareStatement( - "UPDATE users SET auth_ticket = '', online = '0', remember_token_hash = '', remember_token_expires_at = 0 WHERE id = ? LIMIT 1")) { - clear.setInt(1, userId); - clear.executeUpdate(); - } - - if (Emulator.getGameServer() != null - && Emulator.getGameServer().getGameClientManager() != null) { - com.eu.habbo.habbohotel.users.Habbo habbo = - Emulator.getGameServer().getGameClientManager().getHabbo(userId); - if (habbo != null && habbo.getClient() != null) { - Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); - } - } + RememberJwtService.revokeFromToken(conn, rememberToken); } } catch (Exception e) { - LOGGER.error("Logout cleanup failed for ticket", e); + LOGGER.error("Logout cleanup failed", e); } sendJson(ctx, req, HttpResponseStatus.OK, ok); } - /* โ”€โ”€โ”€ Login โ”€โ”€โ”€ */ + private void handleRemember(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; + } + + 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; + } + + String ssoTicket = mintSsoTicket(); + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { + upd.setString(1, ssoTicket); + upd.setString(2, ip == null ? "" : ip); + upd.setInt(3, rot.userId); + upd.executeUpdate(); + } + + JsonObject ok = new JsonObject(); + ok.addProperty("ssoTicket", ssoTicket); + ok.addProperty("username", rot.username); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); + ok.addProperty("rememberExpiresAt", rot.expiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } catch (Exception e) { + LOGGER.error("Remember login failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + } + } + + 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; + } + + 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; + } + JsonObject ok = new JsonObject(); + ok.addProperty("rememberToken", rot.jwt); + ok.addProperty("expiresAt", rot.expiresAt); + ok.addProperty("rememberExpiresAt", rot.expiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } catch (Exception e) { + LOGGER.error("Refresh failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + } + } private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String username = readString(body, "username").trim(); String password = readString(body, "password"); - boolean remember = readBoolean(body, "remember") || readBoolean(body, "rememberMe"); + boolean rememberMe = readBoolean(body, "remember", false) || readBoolean(body, "rememberMe", false); if (username.isEmpty() || password.isEmpty()) { sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing credentials.")); @@ -328,33 +395,32 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } String ssoTicket = mintSsoTicket(); - String rememberToken = ""; - int rememberExpiresAt = 0; - - if (remember && rememberEnabled()) { - rememberToken = mintRememberToken(); - rememberExpiresAt = rememberExpiresAt(); - } try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) { + "UPDATE users SET auth_ticket = ?, ip_current = ? WHERE id = ? LIMIT 1")) { upd.setString(1, ssoTicket); upd.setString(2, ip == null ? "" : ip); - upd.setString(3, rememberToken.isEmpty() ? "" : sha256Hex(rememberToken)); - upd.setInt(4, rememberExpiresAt); - upd.setInt(5, userId); + upd.setInt(3, userId); upd.executeUpdate(); } + 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); JsonObject ok = new JsonObject(); ok.addProperty("ssoTicket", ssoTicket); ok.addProperty("username", rs.getString("username")); - if (!rememberToken.isEmpty()) { - ok.addProperty("rememberToken", rememberToken); - ok.addProperty("rememberExpiresAt", rememberExpiresAt); - } + if (rememberToken != null) ok.addProperty("rememberToken", rememberToken); sendJson(ctx, req, HttpResponseStatus.OK, ok); } } catch (Exception e) { @@ -363,71 +429,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } -/* รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ Remember login รขโ€โ‚ฌรขโ€โ‚ฌรขโ€โ‚ฌ */ - - private void handleRemember(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { - if (!rememberEnabled()) { - sendJson(ctx, req, HttpResponseStatus.FORBIDDEN, errorPayload("Remember login is disabled.")); - return; - } - - String rememberToken = readString(body, "rememberToken").trim(); - - if (rememberToken.isEmpty()) { - sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing remember token.")); - return; - } - - int now = Emulator.getIntUnixTimestamp(); - String rememberHash = sha256Hex(rememberToken); - - try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); - PreparedStatement stmt = conn.prepareStatement( - "SELECT id, username FROM users WHERE remember_token_hash = ? AND remember_token_expires_at > ? LIMIT 1")) { - stmt.setString(1, rememberHash); - stmt.setInt(2, now); - - try (ResultSet rs = stmt.executeQuery()) { - if (!rs.next()) { - clearRememberToken(conn, rememberHash); - AuthRateLimiter.recordFailure(ip); - sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("Remember login expired.")); - return; - } - - int userId = rs.getInt("id"); - String username = rs.getString("username"); - String ssoTicket = mintSsoTicket(); - String nextRememberToken = mintRememberToken(); - int rememberExpiresAt = rememberExpiresAt(); - - try (PreparedStatement upd = conn.prepareStatement( - "UPDATE users SET auth_ticket = ?, ip_current = ?, remember_token_hash = ?, remember_token_expires_at = ? WHERE id = ? LIMIT 1")) { - upd.setString(1, ssoTicket); - upd.setString(2, ip == null ? "" : ip); - upd.setString(3, sha256Hex(nextRememberToken)); - upd.setInt(4, rememberExpiresAt); - upd.setInt(5, userId); - upd.executeUpdate(); - } - - AuthRateLimiter.recordSuccess(ip); - - JsonObject ok = new JsonObject(); - ok.addProperty("ssoTicket", ssoTicket); - ok.addProperty("username", username); - ok.addProperty("rememberToken", nextRememberToken); - ok.addProperty("rememberExpiresAt", rememberExpiresAt); - sendJson(ctx, req, HttpResponseStatus.OK, ok); - } - } catch (Exception e) { - LOGGER.error("Remember-login failed", e); - sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); - } - } - - /* โ”€โ”€โ”€ 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.")); @@ -437,6 +438,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { String username = readString(body, "username").trim(); String email = readString(body, "email").trim(); String password = readString(body, "password"); + String figure = readString(body, "figure").trim(); + String gender = readString(body, "gender").trim().toUpperCase(); + int templateId = readInt(body, "templateId", 0); if (!USERNAME_RE.matcher(username).matches()) { sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, @@ -496,11 +500,19 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { String defaultMotto = Emulator.getConfig().getValue("register.default.motto", "I love Habbo!"); int now = Emulator.getIntUnixTimestamp(); + String finalLook = (figure.isEmpty() || !FIGURE_RE.matcher(figure).matches()) ? defaultLook : figure; + String finalGender = (gender.equals("M") || gender.equals("F")) ? gender : "M"; + + int startingCredits = Math.max(0, Emulator.getConfig().getInt("new_user_credits", 0)); + int startingDuckets = Math.max(0, Emulator.getConfig().getInt("new_user_duckets", 0)); + int startingDiamonds = Math.max(0, Emulator.getConfig().getInt("new_user_diamonds", 0)); + + int newUserId = 0; try (PreparedStatement ins = conn.prepareStatement( "INSERT INTO users (username, password, mail, account_created, " + "ip_register, ip_current, last_online, last_login, motto, look, gender, " + "credits, `rank`, home_room, machine_id, auth_ticket, online) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'M', 0, 1, 0, '', '', '0')", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, '', '', '0')", Statement.RETURN_GENERATED_KEYS)) { ins.setString(1, username); ins.setString(2, hashed); @@ -511,8 +523,26 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { ins.setInt(7, now); ins.setInt(8, now); ins.setString(9, defaultMotto); - ins.setString(10, defaultLook); + ins.setString(10, finalLook); + ins.setString(11, finalGender); + ins.setInt(12, startingCredits); ins.executeUpdate(); + try (ResultSet keys = ins.getGeneratedKeys()) { + if (keys.next()) newUserId = keys.getInt(1); + } + } + + if (newUserId > 0 && (startingDuckets > 0 || startingDiamonds > 0)) { + seedUserCurrencies(conn, newUserId, startingDuckets, startingDiamonds); + } + + LOGGER.info("[auth/register] user created id={} username='{}' templateId={} credits={} duckets={} diamonds={}", + newUserId, username, templateId, startingCredits, startingDuckets, startingDiamonds); + + if (newUserId > 0 && templateId > 0) { + cloneTemplateForUser(conn, templateId, newUserId, username); + } else if (templateId > 0) { + LOGGER.warn("[auth/register] skipping template clone: user insert did not return an id (username='{}')", username); } AvailabilityCache.invalidateEmail(email); @@ -527,7 +557,201 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - /* โ”€โ”€โ”€ Forgot password โ”€โ”€โ”€ */ + private static void materializeCustomLayout(Connection conn, int templateId, int newRoomId) { + String overrideModel = "0"; + String heightmap = ""; + int doorX = 0, doorY = 0, doorDir = 2; + try (PreparedStatement sel = conn.prepareStatement( + "SELECT override_model, heightmap, door_x, door_y, door_dir " + + "FROM room_templates WHERE template_id = ? LIMIT 1")) { + sel.setInt(1, templateId); + try (ResultSet rs = sel.executeQuery()) { + if (rs.next()) { + overrideModel = rs.getString("override_model"); + heightmap = rs.getString("heightmap"); + doorX = rs.getInt("door_x"); + doorY = rs.getInt("door_y"); + doorDir = rs.getInt("door_dir"); + } + } + } catch (SQLException e) { + LOGGER.error("[auth/register] reading template layout failed templateId=" + templateId, e); + return; + } + + if (!"1".equals(overrideModel) || heightmap == null || heightmap.isEmpty()) { + return; + } + + String customName = "custom_" + newRoomId; + + try (PreparedStatement ins = conn.prepareStatement( + "INSERT INTO room_models_custom (id, name, door_x, door_y, door_dir, heightmap) " + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE name = VALUES(name), door_x = VALUES(door_x), " + + "door_y = VALUES(door_y), door_dir = VALUES(door_dir), heightmap = VALUES(heightmap)")) { + ins.setInt(1, newRoomId); + ins.setString(2, customName); + ins.setInt(3, doorX); + ins.setInt(4, doorY); + ins.setInt(5, doorDir); + ins.setString(6, heightmap); + ins.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("[auth/register] room_models_custom insert failed roomId=" + newRoomId, e); + return; + } + + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE rooms SET model = ? WHERE id = ? LIMIT 1")) { + upd.setString(1, customName); + upd.setInt(2, newRoomId); + upd.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("[auth/register] rooms.model rename failed roomId=" + newRoomId, e); + } + + LOGGER.info("[auth/register] materialized custom layout '{}' for roomId={}", customName, newRoomId); + } + + 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 (?, ?, ?) " + + "ON DUPLICATE KEY UPDATE amount = VALUES(amount)")) { + if (duckets > 0) { + ins.setInt(1, userId); + ins.setInt(2, 0); + ins.setInt(3, duckets); + ins.addBatch(); + } + if (diamonds > 0) { + ins.setInt(1, userId); + ins.setInt(2, 5); + ins.setInt(3, diamonds); + ins.addBatch(); + } + ins.executeBatch(); + } catch (SQLException e) { + LOGGER.error("[auth/register] seeding users_currency failed userId=" + userId + + " duckets=" + duckets + " diamonds=" + diamonds, e); + } + } + + private void handleRoomTemplates(ChannelHandlerContext ctx, FullHttpRequest req) { + JsonArray templates = new JsonArray(); + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement( + "SELECT template_id, title, description, thumbnail " + + "FROM room_templates WHERE enabled = '1' " + + "ORDER BY sort_order ASC, template_id ASC")) { + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + JsonObject t = new JsonObject(); + t.addProperty("templateId", rs.getInt("template_id")); + t.addProperty("title", rs.getString("title")); + t.addProperty("description", rs.getString("description")); + t.addProperty("thumbnail", rs.getString("thumbnail")); + templates.add(t); + } + } + } catch (Exception e) { + LOGGER.error("room-templates list failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + return; + } + JsonObject res = new JsonObject(); + res.add("templates", templates); + 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); + + try (PreparedStatement check = conn.prepareStatement( + "SELECT 1 FROM room_templates WHERE template_id = ? AND enabled = '1' LIMIT 1")) { + check.setInt(1, templateId); + try (ResultSet rs = check.executeQuery()) { + if (!rs.next()) { + LOGGER.warn("[auth/register] unknown/disabled room template id={} for user id={}", templateId, userId); + return; + } + } + } catch (SQLException e) { + LOGGER.error("[auth/register] template lookup failed for templateId=" + templateId, e); + return; + } + + int newRoomId = 0; + int roomsInserted = 0; + try (PreparedStatement ins = conn.prepareStatement( + "INSERT INTO rooms (owner_id, owner_name, name, description, model, password, state, " + + "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " + + "thickness_floor, moodlight_data, override_model, trade_mode) " + + "(SELECT ?, ?, name, room_description, model, password, state, " + + "users_max, category, paper_floor, paper_wall, paper_landscape, thickness_wall, " + + "thickness_floor, moodlight_data, override_model, trade_mode " + + "FROM room_templates WHERE template_id = ?)", + Statement.RETURN_GENERATED_KEYS)) { + ins.setInt(1, userId); + ins.setString(2, userName); + ins.setInt(3, templateId); + roomsInserted = ins.executeUpdate(); + try (ResultSet keys = ins.getGeneratedKeys()) { + if (keys.next()) newRoomId = keys.getInt(1); + } + } catch (SQLException e) { + LOGGER.error("[auth/register] clone rooms failed templateId=" + templateId + " userId=" + userId, e); + return; + } + + LOGGER.info("[auth/register] rooms insert: rowsAffected={} newRoomId={}", roomsInserted, newRoomId); + + if (newRoomId <= 0) { + LOGGER.warn("[auth/register] clone aborted - no roomId returned (templateId={}, userId={})", templateId, userId); + return; + } + + materializeCustomLayout(conn, templateId, newRoomId); + + int itemsInserted = 0; + try (PreparedStatement ins = conn.prepareStatement( + "INSERT INTO items (user_id, room_id, item_id, wall_pos, x, y, z, rot, " + + "extra_data, wired_data, limited_data, guild_id) " + + "(SELECT ?, ?, item_id, wall_pos, x, y, z, rot, extra_data, wired_data, '0:0', 0 " + + "FROM room_templates_items WHERE template_id = ?)")) { + ins.setInt(1, userId); + ins.setInt(2, newRoomId); + ins.setInt(3, templateId); + itemsInserted = ins.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("[auth/register] clone items failed templateId=" + templateId + + " roomId=" + newRoomId + " userId=" + userId, e); + } + + LOGGER.info("[auth/register] items insert: rowsAffected={} roomId={}", itemsInserted, newRoomId); + + try (PreparedStatement upd = conn.prepareStatement( + "UPDATE users SET home_room = ? WHERE id = ? LIMIT 1")) { + upd.setInt(1, newRoomId); + upd.setInt(2, userId); + int rows = upd.executeUpdate(); + LOGGER.info("[auth/register] home_room update: rowsAffected={} userId={} roomId={}", rows, userId, newRoomId); + } catch (SQLException e) { + LOGGER.error("[auth/register] setting home_room failed userId=" + userId + " roomId=" + newRoomId, e); + } + } private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String email = readString(body, "email").trim(); @@ -582,8 +806,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 { @@ -605,12 +827,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } - private static String mintRememberToken() { - byte[] buf = new byte[48]; - RNG.nextBytes(buf); - return "remember-" + Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - private static String readString(JsonObject obj, String key) { if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return ""; try { @@ -620,56 +836,24 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { } } - private static boolean readBoolean(JsonObject obj, String key) { - if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return false; + private static int readInt(JsonObject obj, String key, int defaultValue) { + if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; try { - if (obj.get(key).isJsonPrimitive()) { - String value = obj.get(key).getAsString(); - return "true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value); - } - } catch (Exception ignored) { - } - try { - return obj.get(key).getAsBoolean(); + return obj.get(key).getAsInt(); } catch (Exception e) { - return false; + return defaultValue; } } - private static boolean rememberEnabled() { - return Emulator.getConfig().getBoolean("login.remember.enabled", true); - } - - private static int rememberExpiresAt() { - int days = Math.max(1, Emulator.getConfig().getInt("login.remember.days", 30)); - long expiresAt = (long) Emulator.getIntUnixTimestamp() + (days * 86400L); - return (int) Math.min(Integer.MAX_VALUE, expiresAt); - } - - private static void clearRememberToken(Connection conn, String rememberHash) { - if (rememberHash == null || rememberHash.isEmpty()) return; - try (PreparedStatement clear = conn.prepareStatement( - "UPDATE users SET remember_token_hash = '', remember_token_expires_at = 0 WHERE remember_token_hash = ? LIMIT 1")) { - clear.setString(1, rememberHash); - clear.executeUpdate(); - } catch (Exception e) { - LOGGER.debug("Unable to clear remember token", e); - } - } - - private static String sha256Hex(String value) { + private static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) { + if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue; try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(hash.length * 2); - - for (byte b : hash) { - builder.append(String.format("%02x", b)); - } - - return builder.toString(); + com.google.gson.JsonElement el = obj.get(key); + if (el.getAsJsonPrimitive().isBoolean()) return el.getAsBoolean(); + String s = el.getAsString(); + return "1".equals(s) || "true".equalsIgnoreCase(s); } catch (Exception e) { - throw new IllegalStateException("SHA-256 is unavailable", e); + return defaultValue; } } 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..93763902 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/RememberJwtService.java @@ -0,0 +1,279 @@ +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.sql.Statement; +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() { + int configured = Emulator.getConfig().getInt("login.remember.duration.days", 0); + if (configured <= 0) configured = Emulator.getConfig().getInt("login.remember.days", 30); + return Math.max(1, configured); + } + + 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/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/WsAesDecoder.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsAesDecoder.java new file mode 100644 index 00000000..0cc947da --- /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 ({}), closing channel", e.getClass().getSimpleName()); + 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..0947dc03 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsHandshakeHandler.java @@ -0,0 +1,152 @@ +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.buffer.Unpooled; +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 static final boolean SIGN_ENABLED = Emulator.getConfig().getBoolean("crypto.ws.signing.enabled", false); + 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()); + byte[] sigIeee = null; + if (SIGN_ENABLED) { + KeyPair signingKp = CryptoSigningKeyManager.get(); + byte[] sigDer = WsSessionCrypto.signEcdsaSha256(signingKp.getPrivate(), spki); + sigIeee = WsSessionCrypto.derToIeee1363(sigDer); + } + + 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; + } 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.warn("[ws-crypto] handshake failed from {} : {}", clientAddress(ctx), friendlyReason(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()); + } + + private static String friendlyReason(Throwable t) { + if (t == null) return "unknown"; + String name = t.getClass().getSimpleName(); + String msg = t.getMessage(); + return (msg == null || msg.isEmpty()) ? name : name + ": " + msg; + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + if (cause instanceof java.io.IOException) { + LOGGER.debug("[ws-crypto] client disconnected during handshake ({}): {}", + clientAddress(ctx), friendlyReason(cause)); + } else { + LOGGER.error("[ws-crypto] handshake handler error from " + clientAddress(ctx), 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..4aa6eea3 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/crypto/WsSessionCrypto.java @@ -0,0 +1,163 @@ +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.PKCS8EncodedKeySpec; +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; + } + + 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/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecoder.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecoder.java index e905eeea..f0212c4b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecoder.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecoder.java @@ -2,7 +2,6 @@ package com.eu.habbo.networking.gameserver.decoders; import com.eu.habbo.messages.ClientMessage; import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; @@ -12,8 +11,7 @@ public class GameByteDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { short header = in.readShort(); - ByteBuf body = Unpooled.copiedBuffer(in.readBytes(in.readableBytes())); - + ByteBuf body = in.readBytes(in.readableBytes()); out.add(new ClientMessage(header, body)); } } \ No newline at end of file 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 3cd4c116..fa1e80db 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 0c4f1e5d..4292b785 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 @@ -53,4 +56,6 @@ nitro.secure.master_key=change-me-to-a-long-random-secret # Remember-me login tokens. login.remember.enabled=true -login.remember.days=30 +login.remember.duration.days=30 +# Optional: set a persistent remember-me JWT secret here, otherwise one is generated and stored in emulator_settings. +login.remember.jwt.secret=