You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
checkpoint: secure config gdm and api baseline
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.eu.habbo</groupId>
|
||||
<artifactId>Habbo</artifactId>
|
||||
<version>4.1.3</version>
|
||||
<version>4.1.5</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
+4
@@ -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<SocketChanne
|
||||
ch.pipeline().addLast("httpCodec", new HttpServerCodec());
|
||||
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
|
||||
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
|
||||
ch.pipeline().addLast("nitroSecureAssetHandler", new NitroSecureAssetHandler());
|
||||
ch.pipeline().addLast("nitroSecureApiHandler", new NitroSecureApiHandler());
|
||||
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package com.eu.habbo.networking.gameserver.auth;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelPromise;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import io.netty.util.AttributeKey;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Objects;
|
||||
|
||||
public class NitroSecureApiHandler extends ChannelDuplexHandler {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(NitroSecureApiHandler.class);
|
||||
private static final String API_PREFIX = "/api/";
|
||||
private static final AttributeKey<Deque<SecureApiContext>> 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<SecureApiContext> 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<SecureApiContext> 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<SecureApiContext> 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<SecureApiContext> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
@@ -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<String, CacheEntry> 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) {}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user