From dde2c4143c8b5053e0690582025aeddc5d8ffb71 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Thu, 23 Apr 2026 07:01:09 +0200 Subject: [PATCH] checkpoint: secure config gdm and api baseline --- Emulator/pom.xml | 2 +- .../WebSocketChannelInitializer.java | 4 + .../auth/NitroSecureApiHandler.java | 223 +++++++++++ .../auth/NitroSecureAssetHandler.java | 345 ++++++++++++++++++ Latest_Compiled_Version/config.ini.example | 8 +- 5 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureApiHandler.java create mode 100644 Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/NitroSecureAssetHandler.java 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