Merge duckie main into live merge branch

This commit is contained in:
Lorenzune
2026-04-25 13:52:04 +02:00
18 changed files with 1385 additions and 192 deletions
+97
View File
@@ -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`;
+8
View File
@@ -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`;
+3 -3
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.1.5</version>
<version>4.1.7</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -163,7 +163,7 @@
<version>2.13.0</version>
</dependency>
<!-- jBCrypt — used by the built-in /api/auth/* HTTP login handler
<!-- jBCrypt — used by the built-in /api/auth/* HTTP login handler
to verify Laravel-style $2y$ BCrypt hashes from users.password -->
<dependency>
<groupId>org.mindrot</groupId>
@@ -171,7 +171,7 @@
<version>0.4</version>
</dependency>
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
when smtp.* keys are configured in emulator_settings -->
<dependency>
<groupId>org.eclipse.angus</groupId>
@@ -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());
@@ -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;
}
}
@@ -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);
}
}
}
}
@@ -10,4 +10,6 @@ public class GameServerAttributes {
public static final AttributeKey<HabboRC4> CRYPTO_CLIENT = AttributeKey.valueOf("CryptoClient");
public static final AttributeKey<HabboRC4> CRYPTO_SERVER = AttributeKey.valueOf("CryptoServer");
public static final AttributeKey<String> WS_IP = AttributeKey.valueOf("WebSocketIP");
public static final AttributeKey<byte[]> WS_AES_KEY = AttributeKey.valueOf("WsAesKey");
}
@@ -1,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<SocketChanne
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
if (Emulator.getConfig().getBoolean("crypto.ws.enabled", false)) {
ch.pipeline().addLast(WsHandshakeHandler.HANDLER_NAME, new WsHandshakeHandler());
}
ch.pipeline().addLast(new GamePolicyDecoder());
ch.pipeline().addLast(new GameByteFrameDecoder());
ch.pipeline().addLast(new GameByteDecoder());
@@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.auth;
import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.netty.buffer.Unpooled;
@@ -16,7 +17,6 @@ import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.*;
import java.time.Instant;
@@ -30,13 +30,17 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
private static final String REGISTER_PATH = "/api/auth/register";
private static final String FORGOT_PATH = "/api/auth/forgot-password";
private static final String LOGOUT_PATH = "/api/auth/logout";
private static final String REMEMBER_PATH = "/api/auth/remember";
private static final String CHECK_EMAIL_PATH = "/api/auth/check-email";
private static final String CHECK_USERNAME_PATH = "/api/auth/check-username";
private static final String ROOM_TEMPLATES_PATH = "/api/auth/room-templates";
private static final String REMEMBER_PATH = "/api/auth/remember";
private static final String REFRESH_PATH = "/api/auth/refresh";
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
private static final String HEALTH_PATH = "/api/health";
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
private static final Pattern FIGURE_RE = Pattern.compile("^[A-Za-z0-9.\\-]{1,200}$");
private static final SecureRandom RNG = new SecureRandom();
private static final int MAX_BODY_BYTES = 8 * 1024;
@@ -51,8 +55,11 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH)
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)
&& !path.equals(REMEMBER_PATH)
&& !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH)
&& !path.equals(ROOM_TEMPLATES_PATH)
&& !path.equals(REMEMBER_PATH)
&& !path.equals(REFRESH_PATH)
&& !path.equals(SERVER_KEY_PATH)
&& !path.equals(HEALTH_PATH)) {
super.channelRead(ctx, msg);
return;
@@ -82,6 +89,24 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
return;
}
if (path.equals(ROOM_TEMPLATES_PATH)) {
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
return;
}
handleRoomTemplates(ctx, req);
return;
}
if (path.equals(SERVER_KEY_PATH)) {
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
return;
}
handleServerKey(ctx, req);
return;
}
if (req.method() != HttpMethod.POST) {
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
return;
@@ -114,10 +139,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
handleLogout(ctx, req, body);
return;
}
if (path.equals(REMEMBER_PATH)) {
handleRemember(ctx, req, body, ip);
return;
}
if (path.equals(CHECK_EMAIL_PATH)) {
handleCheckEmail(ctx, req, body, ip);
@@ -127,6 +148,14 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
handleCheckUsername(ctx, req, body, ip);
return;
}
if (path.equals(REMEMBER_PATH)) {
handleRemember(ctx, req, body, ip);
return;
}
if (path.equals(REFRESH_PATH)) {
handleRefresh(ctx, req, body, ip);
return;
}
String turnstileToken = readString(body, "turnstileToken");
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
@@ -142,8 +171,6 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
/* ─── Availability probes ─── */
private void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
if (!AuthRateLimiter.tryProbe(ip)) {
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
@@ -223,19 +250,12 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
sendJson(ctx, req, HttpResponseStatus.OK, res);
}
/* ─── Logout ─── */
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
String ssoTicket = readString(body, "ssoTicket");
String rememberToken = readString(body, "rememberToken");
String rememberToken = readString(body, "rememberToken").trim();
JsonObject ok = new JsonObject();
ok.addProperty("message", "Logged out.");
if ((ssoTicket == null || ssoTicket.isEmpty()) && rememberToken.isEmpty()) {
sendJson(ctx, req, HttpResponseStatus.OK, ok);
return;
}
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection()) {
int userId = 0;
@@ -247,26 +267,10 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
if (rs.next()) userId = rs.getInt("id");
}
}
}
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")) {
"UPDATE users SET auth_ticket = '', online = '0' WHERE id = ? LIMIT 1")) {
clear.setInt(1, userId);
clear.executeUpdate();
}
@@ -280,19 +284,82 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
}
}
}
}
if (!rememberToken.isEmpty()) {
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);
return obj.get(key).getAsInt();
} catch (Exception e) {
return defaultValue;
}
} catch (Exception ignored) {
}
private static boolean readBoolean(JsonObject obj, String key, boolean defaultValue) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsBoolean();
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) {
return false;
}
}
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) {
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();
} catch (Exception e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
return defaultValue;
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -0,0 +1,46 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WsAesDecoder extends MessageToMessageDecoder<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesDecoder.class);
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
if (key == null) {
LOGGER.warn("[ws-crypto] inbound frame with no session key, closing");
ctx.close();
return;
}
int readable = in.readableBytes();
if (readable < WsSessionCrypto.NONCE_LEN + 16) {
LOGGER.warn("[ws-crypto] inbound frame too short ({} bytes)", readable);
ctx.close();
return;
}
byte[] nonce = new byte[WsSessionCrypto.NONCE_LEN];
in.readBytes(nonce);
byte[] ct = new byte[in.readableBytes()];
in.readBytes(ct);
try {
byte[] plain = WsSessionCrypto.aesGcmDecrypt(key, nonce, ct);
out.add(Unpooled.wrappedBuffer(plain));
} catch (Exception e) {
LOGGER.warn("[ws-crypto] AES-GCM decrypt failed ({}), closing channel", e.getClass().getSimpleName());
ctx.close();
}
}
}
@@ -0,0 +1,35 @@
package com.eu.habbo.networking.gameserver.crypto;
import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WsAesEncoder extends MessageToMessageEncoder<ByteBuf> {
private static final Logger LOGGER = LoggerFactory.getLogger(WsAesEncoder.class);
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
byte[] key = ctx.channel().attr(GameServerAttributes.WS_AES_KEY).get();
if (key == null) {
LOGGER.warn("[ws-crypto] outbound frame with no session key, dropping");
return;
}
byte[] plain = new byte[in.readableBytes()];
in.readBytes(plain);
byte[] nonce = WsSessionCrypto.randomNonce();
byte[] ct = WsSessionCrypto.aesGcmEncrypt(key, nonce, plain);
ByteBuf framed = ctx.alloc().buffer(nonce.length + ct.length);
framed.writeBytes(nonce);
framed.writeBytes(ct);
out.add(framed);
}
}
@@ -0,0 +1,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();
}
}
@@ -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);
}
}
@@ -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<Object> 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));
}
}
+6 -1
View File
@@ -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=