diff --git a/Emulator/pom.xml b/Emulator/pom.xml
index 46fef640..ac1ffcb3 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.1.3
+ 4.1.5
UTF-8
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java
index 99a7ebbe..64c7b291 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
@@ -2,6 +2,8 @@ package com.eu.habbo.networking.gameserver;
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.decoders.*;
import com.eu.habbo.networking.gameserver.encoders.GameServerMessageEncoder;
@@ -50,6 +52,8 @@ public class WebSocketChannelInitializer extends ChannelInitializer> SECURE_CONTEXTS =
+ AttributeKey.valueOf("nitroSecureApiContexts");
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ String path = new QueryStringDecoder(req.uri()).path();
+
+ if (!path.startsWith(API_PREFIX)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ if (req.method() == HttpMethod.OPTIONS) {
+ sendCors(ctx, req);
+ ReferenceCountUtil.release(req);
+ return;
+ }
+
+ if (!isSecureRequest(req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ try {
+ String clientKey = req.headers().get("X-Nitro-Key");
+ if (clientKey == null || clientKey.isBlank()) {
+ sendText(ctx, req, HttpResponseStatus.UNAUTHORIZED, "Missing key.");
+ return;
+ }
+
+ SecretKey sessionKey = NitroSecureAssetHandler.deriveSessionKey(java.util.Base64.getDecoder().decode(clientKey));
+ SecureApiContext secureContext = new SecureApiContext(
+ NitroSecureAssetHandler.getServerKeyFingerprint(),
+ NitroSecureAssetHandler.fingerprint(sessionKey.getEncoded()),
+ sessionKey
+ );
+
+ if (!req.content().isReadable()) {
+ enqueueContext(ctx, secureContext);
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ byte[] encrypted = new byte[req.content().readableBytes()];
+ req.content().getBytes(req.content().readerIndex(), encrypted);
+ byte[] clear = NitroSecureAssetHandler.decrypt(sessionKey, NitroSecureAssetHandler.fromHex(new String(encrypted, StandardCharsets.UTF_8)));
+
+ FullHttpRequest decryptedReq = new DefaultFullHttpRequest(
+ req.protocolVersion(),
+ req.method(),
+ req.uri(),
+ Unpooled.wrappedBuffer(clear)
+ );
+
+ decryptedReq.headers().setAll(req.headers());
+ decryptedReq.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
+ decryptedReq.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, clear.length);
+
+ enqueueContext(ctx, secureContext);
+ ReferenceCountUtil.release(req);
+ ctx.fireChannelRead(decryptedReq);
+ } catch (IllegalArgumentException e) {
+ LOGGER.warn("Nitro secure API rejected invalid encrypted payload", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, e.getMessage());
+ ReferenceCountUtil.release(req);
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure API failed to decrypt request", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid secure payload.");
+ ReferenceCountUtil.release(req);
+ }
+ }
+
+ @Override
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
+ if (!(msg instanceof FullHttpResponse response)) {
+ super.write(ctx, msg, promise);
+ return;
+ }
+
+ SecureApiContext secureContext = pollContext(ctx);
+ if (secureContext == null) {
+ super.write(ctx, msg, promise);
+ return;
+ }
+
+ try {
+ byte[] clear = readBytes(response.content());
+ byte[] encrypted = NitroSecureAssetHandler.encrypt(secureContext.sessionKey(), clear);
+ byte[] hex = NitroSecureAssetHandler.toHex(encrypted).getBytes(StandardCharsets.UTF_8);
+
+ FullHttpResponse encryptedResponse = new DefaultFullHttpResponse(
+ response.protocolVersion(),
+ response.status(),
+ Unpooled.wrappedBuffer(hex)
+ );
+
+ encryptedResponse.headers().setAll(response.headers());
+ encryptedResponse.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
+ encryptedResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, hex.length);
+ encryptedResponse.headers().set("X-Nitro-Sec", "1");
+ encryptedResponse.headers().set("X-Nitro-Key-Fp", secureContext.serverKeyFingerprint());
+ encryptedResponse.headers().set("X-Nitro-Derive-Fp", secureContext.derivedFingerprint());
+ encryptedResponse.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+
+ ReferenceCountUtil.release(response);
+ super.write(ctx, encryptedResponse, promise);
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure API failed to encrypt response", e);
+ super.write(ctx, msg, promise);
+ }
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ Deque contexts = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (contexts != null) contexts.clear();
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
+ Deque contexts = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (contexts != null) contexts.clear();
+ super.exceptionCaught(ctx, cause);
+ }
+
+ private static boolean isSecureRequest(FullHttpRequest req) {
+ return "1".equals(req.headers().get("X-Nitro-Api"));
+ }
+
+ private static void enqueueContext(ChannelHandlerContext ctx, SecureApiContext context) {
+ Deque queue = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (queue == null) {
+ queue = new ArrayDeque<>();
+ ctx.channel().attr(SECURE_CONTEXTS).set(queue);
+ }
+
+ queue.addLast(context);
+ }
+
+ private static SecureApiContext pollContext(ChannelHandlerContext ctx) {
+ Deque queue = ctx.channel().attr(SECURE_CONTEXTS).get();
+ if (queue == null || queue.isEmpty()) return null;
+ return queue.pollFirst();
+ }
+
+ private static byte[] readBytes(ByteBuf content) {
+ byte[] bytes = new byte[content.readableBytes()];
+ content.getBytes(content.readerIndex(), bytes);
+ return bytes;
+ }
+
+ private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
+ applyCors(req, response);
+ ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text) {
+ byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
+ response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
+ response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
+ applyCors(req, response);
+ boolean keepAlive = isKeepAlive(req);
+ if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ var future = ctx.writeAndFlush(response);
+ if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
+ String origin = req.headers().get(HttpHeaderNames.ORIGIN);
+ if (origin != null && !origin.isEmpty()) {
+ response.headers().set("Access-Control-Allow-Origin", origin);
+ response.headers().set("Vary", "Origin");
+ response.headers().set("Access-Control-Allow-Credentials", "true");
+ }
+ response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
+ response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api");
+ response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+ }
+
+ private static boolean isKeepAlive(FullHttpRequest req) {
+ String connection = req.headers().get(HttpHeaderNames.CONNECTION);
+ return connection == null || !"close".equalsIgnoreCase(connection);
+ }
+
+ private record SecureApiContext(String serverKeyFingerprint, String derivedFingerprint, SecretKey sessionKey) {
+ private SecureApiContext {
+ Objects.requireNonNull(serverKeyFingerprint);
+ Objects.requireNonNull(derivedFingerprint);
+ Objects.requireNonNull(sessionKey);
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java
new file mode 100644
index 00000000..297a9311
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java
@@ -0,0 +1,345 @@
+package com.eu.habbo.networking.gameserver.auth;
+
+import com.eu.habbo.Emulator;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.*;
+import io.netty.util.ReferenceCountUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyAgreement;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.*;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class NitroSecureAssetHandler extends ChannelInboundHandlerAdapter {
+ private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureAssetHandler.class);
+ private static final String MASTER_KEY_CONFIG = "nitro.secure.master_key";
+ private static final String BOOTSTRAP_PATH = "/nitro-sec/bootstrap";
+ private static final String FILE_PATH = "/nitro-sec/file";
+ private static final int MAX_BOOTSTRAP_BODY_BYTES = 4096;
+ private static final SecureRandom RNG = new SecureRandom();
+ private static final KeyPair SERVER_KEYPAIR = createServerKeyPair();
+ private static final String SERVER_KEY_FINGERPRINT = fingerprint(SERVER_KEYPAIR.getPublic().getEncoded());
+ private static final Map CACHE = new ConcurrentHashMap<>();
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest req)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ String path = new QueryStringDecoder(req.uri()).path();
+
+ if (!path.equals(BOOTSTRAP_PATH) && !path.equals(FILE_PATH)) {
+ super.channelRead(ctx, msg);
+ return;
+ }
+
+ try {
+ if (req.method() == HttpMethod.OPTIONS) {
+ sendCors(ctx, req);
+ return;
+ }
+
+ if (path.equals(BOOTSTRAP_PATH)) handleBootstrap(ctx, req);
+ else handleFile(ctx, req);
+ } finally {
+ ReferenceCountUtil.release(req);
+ }
+ }
+
+ private void handleBootstrap(ChannelHandlerContext ctx, FullHttpRequest req) {
+ if (req.method() != HttpMethod.POST) {
+ sendText(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, "Use POST.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ if (req.content().readableBytes() > MAX_BOOTSTRAP_BODY_BYTES) {
+ sendText(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, "Payload too large.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ try {
+ JsonObject body = JsonParser.parseString(req.content().toString(StandardCharsets.UTF_8)).getAsJsonObject();
+ String clientKey = body.has("key") ? body.get("key").getAsString() : "";
+ if (clientKey.isEmpty()) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Missing key.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ JsonObject response = new JsonObject();
+ response.addProperty("key", Base64.getEncoder().encodeToString(SERVER_KEYPAIR.getPublic().getEncoded()));
+ sendText(ctx, req, HttpResponseStatus.OK, response.toString(), "application/json; charset=utf-8");
+ } catch (Exception e) {
+ LOGGER.warn("Nitro secure bootstrap failed", e);
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid bootstrap.", "text/plain; charset=utf-8");
+ }
+ }
+
+ private void handleFile(ChannelHandlerContext ctx, FullHttpRequest req) {
+ if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
+ sendText(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, "Use GET.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ QueryStringDecoder query = new QueryStringDecoder(req.uri());
+ String clientKey = headerOrQuery(req, query, "X-Nitro-Key", "key");
+ if (clientKey == null || clientKey.isEmpty()) {
+ sendText(ctx, req, HttpResponseStatus.UNAUTHORIZED, "Missing key.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ String kind = queryParam(query, "kind");
+ String file = queryParam(query, "file");
+ if (!kind.equals("config") && !kind.equals("gamedata")) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, "Invalid kind.", "text/plain; charset=utf-8");
+ return;
+ }
+
+ try {
+ SecretKey sessionKey = deriveSessionKey(Base64.getDecoder().decode(clientKey));
+ byte[] clear = readAsset(kind, file);
+ byte[] encrypted = encrypt(sessionKey, clear);
+ sendText(ctx, req, HttpResponseStatus.OK, toHex(encrypted), "text/plain; charset=utf-8", true, fingerprint(sessionKey.getEncoded()));
+ } catch (IllegalArgumentException e) {
+ sendText(ctx, req, HttpResponseStatus.BAD_REQUEST, e.getMessage(), "text/plain; charset=utf-8");
+ } catch (IOException e) {
+ sendText(ctx, req, HttpResponseStatus.NOT_FOUND, "Not found.", "text/plain; charset=utf-8");
+ } catch (Exception e) {
+ LOGGER.error("Nitro secure asset failed kind=" + kind + " file=" + file, e);
+ sendText(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, "Server error.", "text/plain; charset=utf-8");
+ }
+ }
+
+ private static byte[] readAsset(String kind, String file) throws IOException {
+ String normalized = normalizeFile(file);
+ String rootConfigKey = kind.equals("config") ? "nitro.secure.config.root" : "nitro.secure.gamedata.root";
+ String fallback = kind.equals("config") ? "Nitro-V3/public" : "Nitro-V3/public/nitro/gamedata";
+ Path root = resolveRoot(rootConfigKey, fallback, kind.equals("config")
+ ? new String[] { "../Nitro-V3/public", "../../Nitro-V3/public", "Nitro-V3/public" }
+ : new String[] { "../Nitro-V3/public/nitro/gamedata", "../../Nitro-V3/public/nitro/gamedata", "Nitro-V3/public/nitro/gamedata" });
+ Path target = root.resolve(normalized).normalize();
+
+ if (!target.startsWith(root)) throw new IllegalArgumentException("Invalid file.");
+ if (!Files.isRegularFile(target)) throw new IOException("Not found");
+
+ String cacheKey = kind + ":" + target;
+ long modified = Files.getLastModifiedTime(target).toMillis();
+ CacheEntry cached = CACHE.get(cacheKey);
+ if (cached != null && cached.modified == modified) return cached.bytes;
+
+ byte[] bytes = Files.readAllBytes(target);
+ if (normalized.toLowerCase().endsWith(".json")) bytes = minifyJson(bytes);
+ CACHE.put(cacheKey, new CacheEntry(modified, bytes));
+ return bytes;
+ }
+
+ private static String normalizeFile(String file) {
+ if (file == null) throw new IllegalArgumentException("Missing file.");
+ String value = URLDecoder.decode(file, StandardCharsets.UTF_8).replace('\\', '/');
+ int queryIndex = value.indexOf('?');
+ if (queryIndex >= 0) value = value.substring(0, queryIndex);
+ int fragmentIndex = value.indexOf('#');
+ if (fragmentIndex >= 0) value = value.substring(0, fragmentIndex);
+ while (value.startsWith("/")) value = value.substring(1);
+ if (value.isEmpty() || value.contains("..") || value.contains(":")) throw new IllegalArgumentException("Invalid file.");
+ return value;
+ }
+
+ private static byte[] minifyJson(byte[] bytes) {
+ try {
+ return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).toString().getBytes(StandardCharsets.UTF_8);
+ } catch (Exception ignored) {
+ return bytes;
+ }
+ }
+
+ private static Path resolveRoot(String configKey, String fallback, String[] alternatives) {
+ String configured = Emulator.getConfig().getValue(configKey, "");
+ if (configured != null && !configured.isEmpty()) return Path.of(configured).toAbsolutePath().normalize();
+
+ for (String alternative : alternatives) {
+ Path path = Path.of(alternative).toAbsolutePath().normalize();
+ if (Files.isDirectory(path)) return path;
+ }
+
+ return Path.of(fallback).toAbsolutePath().normalize();
+ }
+
+ static SecretKey deriveSessionKey(byte[] clientPublicEncoded) throws Exception {
+ KeyFactory factory = KeyFactory.getInstance("EC");
+ PublicKey clientPublic = factory.generatePublic(new X509EncodedKeySpec(clientPublicEncoded));
+ KeyAgreement agreement = KeyAgreement.getInstance("ECDH");
+ agreement.init(SERVER_KEYPAIR.getPrivate());
+ agreement.doPhase(clientPublic, true);
+ byte[] secret = agreement.generateSecret();
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ digest.update(secret);
+ digest.update("nitro-secure-assets-v1".getBytes(StandardCharsets.UTF_8));
+ return new SecretKeySpec(digest.digest(), "AES");
+ }
+
+ static byte[] encrypt(SecretKey key, byte[] clear) throws Exception {
+ byte[] iv = new byte[12];
+ RNG.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
+ byte[] encrypted = cipher.doFinal(clear);
+ byte[] out = new byte[iv.length + encrypted.length];
+ System.arraycopy(iv, 0, out, 0, iv.length);
+ System.arraycopy(encrypted, 0, out, iv.length, encrypted.length);
+ return out;
+ }
+
+ static byte[] decrypt(SecretKey key, byte[] encryptedPayload) throws Exception {
+ if (encryptedPayload.length < 13) throw new IllegalArgumentException("Encrypted payload is too short.");
+ byte[] iv = new byte[12];
+ byte[] payload = new byte[encryptedPayload.length - iv.length];
+ System.arraycopy(encryptedPayload, 0, iv, 0, iv.length);
+ System.arraycopy(encryptedPayload, iv.length, payload, 0, payload.length);
+
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
+ return cipher.doFinal(payload);
+ }
+
+ private static KeyPair createServerKeyPair() {
+ try {
+ String configuredSecret = Emulator.getConfig().getValue(MASTER_KEY_CONFIG, "");
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ if (configuredSecret != null && !configuredSecret.isBlank()) {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] seed = digest.digest(configuredSecret.getBytes(StandardCharsets.UTF_8));
+ SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG");
+ deterministic.setSeed(seed);
+ generator.initialize(256, deterministic);
+ LOGGER.info("Nitro secure assets using persistent server key from config {}", MASTER_KEY_CONFIG);
+ } else {
+ generator.initialize(256, RNG);
+ LOGGER.warn("Nitro secure assets using ephemeral server key because {} is empty", MASTER_KEY_CONFIG);
+ }
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to create Nitro secure server key", e);
+ }
+ }
+
+ private static String headerOrQuery(FullHttpRequest req, QueryStringDecoder query, String header, String param) {
+ String value = req.headers().get(header);
+ return (value == null || value.isEmpty()) ? queryParam(query, param) : value;
+ }
+
+ private static String queryParam(QueryStringDecoder query, String key) {
+ if (!query.parameters().containsKey(key) || query.parameters().get(key).isEmpty()) return "";
+ return query.parameters().get(key).get(0);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text, String contentType) {
+ sendBytes(ctx, req, status, text.getBytes(StandardCharsets.UTF_8), contentType, false, null);
+ }
+
+ private static void sendText(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, String text, String contentType, boolean encrypted, String deriveFingerprint) {
+ sendBytes(ctx, req, status, text.getBytes(StandardCharsets.UTF_8), contentType, encrypted, deriveFingerprint);
+ }
+
+ private static void sendBytes(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, byte[] bytes, String contentType, boolean encrypted) {
+ sendBytes(ctx, req, status, bytes, contentType, encrypted, null);
+ }
+
+ private static void sendBytes(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, byte[] bytes, String contentType, boolean encrypted, String deriveFingerprint) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes));
+ response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType);
+ response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
+ response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store, no-cache, must-revalidate");
+ if (encrypted) response.headers().set("X-Nitro-Sec", "1");
+ response.headers().set("X-Nitro-Key-Fp", SERVER_KEY_FINGERPRINT);
+ if (deriveFingerprint != null && !deriveFingerprint.isEmpty()) response.headers().set("X-Nitro-Derive-Fp", deriveFingerprint);
+ applyCors(req, response);
+ boolean keepAlive = isKeepAlive(req);
+ if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ var future = ctx.writeAndFlush(response);
+ if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) {
+ FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
+ applyCors(req, response);
+ ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+ }
+
+ private static void applyCors(FullHttpRequest req, FullHttpResponse response) {
+ String origin = req.headers().get(HttpHeaderNames.ORIGIN);
+ if (origin != null && !origin.isEmpty()) {
+ response.headers().set("Access-Control-Allow-Origin", origin);
+ response.headers().set("Vary", "Origin");
+ }
+ response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
+ response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Nitro-Key");
+ response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp");
+ }
+
+ private static boolean isKeepAlive(FullHttpRequest req) {
+ String connection = req.headers().get(HttpHeaderNames.CONNECTION);
+ return connection == null || !"close".equalsIgnoreCase(connection);
+ }
+
+ static String fingerprint(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(bytes);
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < 8 && i < hash.length; i++) {
+ builder.append(String.format("%02x", hash[i]));
+ }
+ return builder.toString();
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ static String getServerKeyFingerprint() {
+ return SERVER_KEY_FINGERPRINT;
+ }
+
+ static String toHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte value : bytes) {
+ builder.append(String.format("%02x", value & 0xff));
+ }
+ return builder.toString();
+ }
+
+ static byte[] fromHex(String hex) {
+ String normalized = hex == null ? "" : hex.trim();
+ if ((normalized.length() % 2) != 0) throw new IllegalArgumentException("Invalid encrypted hex payload.");
+
+ byte[] out = new byte[normalized.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ int high = Character.digit(normalized.charAt(i * 2), 16);
+ int low = Character.digit(normalized.charAt((i * 2) + 1), 16);
+ if (high < 0 || low < 0) throw new IllegalArgumentException("Invalid encrypted hex payload.");
+ out[i] = (byte) ((high << 4) | low);
+ }
+ return out;
+ }
+
+ private record CacheEntry(long modified, byte[] bytes) {}
+}
diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example
index e1eee315..98500a03 100644
--- a/Latest_Compiled_Version/config.ini.example
+++ b/Latest_Compiled_Version/config.ini.example
@@ -40,4 +40,10 @@ db.pool.leak_detection_ms = 20000 set to 0 to disable
enc.enabled=false
enc.e=3
enc.n=86851dd364d5c5cece3c883171cc6ddc5760779b992482bd1e20dd296888df91b33b936a7b93f06d29e8870f703a216257dec7c81de0058fea4cc5116f75e6efc4e9113513e45357dc3fd43d4efab5963ef178b78bd61e81a14c603b24c8bcce0a12230b320045498edc29282ff0603bc7b7dae8fc1b05b52b2f301a9dc783b7
-enc.d=59ae13e243392e89ded305764bdd9e92e4eafa67bb6dac7e1415e8c645b0950bccd26246fd0d4af37145af5fa026c0ec3a94853013eaae5ff1888360f4f9449ee023762ec195dff3f30ca0b08b8c947e3859877b5d7dced5c8715c58b53740b84e11fbc71349a27c31745fcefeeea57cff291099205e230e0c7c27e8e1c0512b
\ No newline at end of file
+enc.d=59ae13e243392e89ded305764bdd9e92e4eafa67bb6dac7e1415e8c645b0950bccd26246fd0d4af37145af5fa026c0ec3a94853013eaae5ff1888360f4f9449ee023762ec195dff3f30ca0b08b8c947e3859877b5d7dced5c8715c58b53740b84e11fbc71349a27c31745fcefeeea57cff291099205e230e0c7c27e8e1c0512b
+
+# Nitro secure runtime assets. JSON files are read live from disk.
+nitro.secure.config.root=
+nitro.secure.gamedata.root=
+# Set a persistent secret when using Cloudflare / multiple backend requests.
+nitro.secure.master_key=change-me-to-a-long-random-secret