🆙 CryptoV2 - please red the how_things_work on DC !!!

This commit is contained in:
duckietm
2026-04-24 15:54:37 +02:00
parent da2307f3b5
commit b18d65bd79
8 changed files with 212 additions and 7 deletions
+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`;
@@ -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);
}
}
}
}
@@ -35,6 +35,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
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}$");
@@ -58,6 +59,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
&& !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;
@@ -96,6 +98,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
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;
@@ -651,6 +662,18 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
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);
@@ -14,6 +14,7 @@ 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;
@@ -182,7 +183,7 @@ public final class RememberJwtService {
return new RotationResult(newJwt, parsed.userId, username, newExpiresAt);
}
public static void revokeFromToken(Connection conn, String jwt) {
public static void revokeFromToken(Connection conn, String jwt) {
try {
ParsedJwt p = verifyAndParse(jwt);
revokeFamilyById(conn, p.familyId);
@@ -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);
}
}
}
@@ -1,5 +1,6 @@
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.channel.ChannelHandlerContext;
@@ -15,9 +16,8 @@ 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;
@@ -35,12 +35,23 @@ public class WsHandshakeHandler extends ChannelInboundHandlerAdapter {
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);
}
ByteBuf buf = ctx.alloc().buffer(4 + 1 + 2 + spki.length);
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;
@@ -98,9 +109,7 @@ public class WsHandshakeHandler extends ChannelInboundHandlerAdapter {
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());
@@ -8,6 +8,7 @@ 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;
@@ -96,4 +97,67 @@ public final class WsSessionCrypto {
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);
}
}