initial commit

This commit is contained in:
iceBear67
2026-07-15 14:28:58 +08:00
commit 6e0d7ec33f
47 changed files with 4022 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
java
application
id("com.gradleup.shadow") version "9.5.1"
id("io.freefair.lombok") version "9.5.0"
}
group = "io.icybear.redapricot"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
implementation("io.vertx:vertx-core:5.1.5")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainClass = "io.icybear.redapricot.Main"
}
tasks.test {
useJUnitPlatform()
}
// Fat "shadow" jar: build/libs/redapricot-server-<version>-all.jar
// java -jar build/libs/redapricot-server-0.1.0-all.jar <config.json>
// mergeServiceFiles() is required so Vert.x/Netty SPI (META-INF/services/*)
// survives the relocation into a single jar.
tasks.named<ShadowJar>("shadowJar") {
archiveClassifier.set("all")
mergeServiceFiles()
manifest {
attributes["Main-Class"] = "io.icybear.redapricot.Main"
}
}
// Build the shadow jar as part of the default `build`/`assemble` lifecycle.
tasks.named("assemble") {
dependsOn("shadowJar")
}
// Make `installDist` output predictable for the e2e harness: it produces
// build/install/redapricot-server/lib/*.jar + a start script.
+6
View File
@@ -0,0 +1,6 @@
{
"listen": "0.0.0.0:25565",
"psk": "change-me-to-a-long-random-passphrase",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = "redapricot-server"
@@ -0,0 +1,48 @@
package io.icybear.redapricot;
import io.icybear.redapricot.util.Json;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** Hub configuration, loaded from a JSON file. See PROTOCOL.md §9.1. */
public final class Config {
public final String host;
public final int port;
public final String psk;
public final long timestampWindowMs;
public final long pendingTimeoutMs;
public Config(String host, int port, String psk, long timestampWindowMs, long pendingTimeoutMs) {
this.host = host;
this.port = port;
this.psk = psk;
this.timestampWindowMs = timestampWindowMs;
this.pendingTimeoutMs = pendingTimeoutMs;
}
public static Config load(Path file) throws Exception {
Map<String, Object> m = Json.parseObject(Files.readString(file));
String listen = str(m, "listen", "0.0.0.0:25565");
int idx = listen.lastIndexOf(':');
if (idx < 0) throw new IllegalArgumentException("listen must be host:port");
String host = listen.substring(0, idx);
int port = Integer.parseInt(listen.substring(idx + 1));
String psk = str(m, "psk", null);
if (psk == null || psk.isEmpty()) throw new IllegalArgumentException("psk is required");
long tsWin = num(m, "timestampWindowMs", 30000);
long pending = num(m, "pendingTimeoutMs", 10000);
return new Config(host, port, psk, tsWin, pending);
}
private static String str(Map<String, Object> m, String k, String def) {
Object v = m.get(k);
return v == null ? def : v.toString();
}
private static long num(Map<String, Object> m, String k, long def) {
Object v = m.get(k);
return v == null ? def : (long) ((Number) v).doubleValue();
}
}
@@ -0,0 +1,79 @@
package io.icybear.redapricot;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
/**
* An authenticated control session (Magic 0x01). Carries pattern registrations
* and control requests; never tunnels game data.
*/
public final class ControlSession {
private static final System.Logger LOG = System.getLogger("redapricot.control");
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
public ControlSession(Hub hub, EncryptedFrames frames, String id) {
this.hub = hub;
this.frames = frames;
this.id = id;
}
public String id() { return id; }
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int type = r.readUByte();
switch (type) {
case Protocol.CTL_REGISTER -> {
String pattern = r.readString();
hub.register(pattern, this);
sendRegisterAck(pattern, 0);
}
case Protocol.CTL_UNREGISTER -> {
String pattern = r.readString();
hub.unregister(pattern, this);
}
case Protocol.CTL_PING -> {
long nonce = r.readI64();
sendPong(nonce);
}
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
"control " + id + " error: " + r.readString());
default -> LOG.log(System.Logger.Level.WARNING,
"control " + id + " unknown message type " + type);
}
}
public void sendControlRequest(byte[] cid, String pattern, String playerIp, int playerPort) {
byte[] msg = new ProtoWriter()
.u8(Protocol.CTL_CONTROL_REQUEST)
.bytes(cid)
.string(pattern)
.string(playerIp)
.u16(playerPort)
.toBytes();
frames.send(msg);
LOG.log(System.Logger.Level.INFO,
"control-request pattern=" + pattern + " player=" + playerIp + ":" + playerPort);
}
private void sendRegisterAck(String pattern, int status) {
frames.send(new ProtoWriter()
.u8(Protocol.CTL_REGISTER_ACK)
.string(pattern)
.u8(status)
.toBytes());
}
private void sendPong(long nonce) {
frames.send(new ProtoWriter().u8(Protocol.CTL_PONG).i64(nonce).toBytes());
}
public void onClose() {
hub.removeSession(this);
LOG.log(System.Logger.Level.INFO, "control session " + id + " closed");
}
}
@@ -0,0 +1,100 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.util.Hex;
import io.vertx.core.Vertx;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
/**
* Shared hub state: the pattern registry and the pending-player table. A single
* verticle instance owns this, so access is confined to one event loop; the
* concurrent maps are defensive.
*/
public final class Hub {
private static final System.Logger LOG = System.getLogger("redapricot.hub");
public final Vertx vertx;
public final Config config;
public final byte[] pskBytes;
public final String pskAddress;
private final Map<String, ControlSession> patterns = new ConcurrentHashMap<>();
private final Map<String, PendingPlayer> pending = new ConcurrentHashMap<>();
public Hub(Vertx vertx, Config config) {
this.vertx = vertx;
this.config = config;
this.pskBytes = config.psk.getBytes(StandardCharsets.UTF_8);
this.pskAddress = Crypto.pskAddress(config.psk);
}
// ---- pattern registry ----
public void register(String pattern, ControlSession session) {
String key = normalizeAddress(pattern);
patterns.put(key, session);
LOG.log(System.Logger.Level.INFO, "registered pattern '" + key + "' -> " + session.id());
}
public void unregister(String pattern, ControlSession session) {
String key = normalizeAddress(pattern);
patterns.remove(key, session);
}
public ControlSession match(String address) {
return patterns.get(normalizeAddress(address));
}
/** Drop every pattern currently owned by a (closing) session. */
public void removeSession(ControlSession session) {
patterns.entrySet().removeIf(e -> e.getValue() == session);
}
// ---- pending players ----
public byte[] newCid() {
byte[] cid = new byte[Protocol.CID_LEN];
ThreadLocalRandom.current().nextBytes(cid);
return cid;
}
public void addPending(PendingPlayer p) {
pending.put(p.cidHex, p);
p.timerId = vertx.setTimer(config.pendingTimeoutMs, id -> {
PendingPlayer removed = pending.remove(p.cidHex);
if (removed != null) {
LOG.log(System.Logger.Level.WARNING, "pending player " + p.cidHex + " timed out");
removed.socket.close();
}
});
}
public PendingPlayer takePending(byte[] cid) {
String hex = Hex.encode(cid);
PendingPlayer p = pending.remove(hex);
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
return p;
}
public void removePending(String cidHex) {
PendingPlayer p = pending.remove(cidHex);
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
}
// ---- helpers ----
/** Lower-cased, FML-suffix-stripped, trailing-dot-stripped hostname. */
public static String normalizeAddress(String addr) {
int nul = addr.indexOf('\0');
if (nul >= 0) addr = addr.substring(0, nul);
addr = addr.toLowerCase(Locale.ROOT);
while (addr.endsWith(".")) addr = addr.substring(0, addr.length() - 1);
return addr;
}
}
@@ -0,0 +1,199 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.Hex;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-socket state machine: reads the initial Minecraft Handshake, then either
* establishes an encrypted redapricot session (control / worker) or routes the
* connection as a player. See PROTOCOL.md §2 and §4.
*/
public final class HubConnection {
private static final System.Logger LOG = System.getLogger("redapricot.conn");
private static final AtomicLong SEQ = new AtomicLong();
private static final int MAX_HANDSHAKE = 8192;
private final Hub hub;
private final NetSocket socket;
private final String id;
private Buffer hs = Buffer.buffer();
private boolean dispatched = false;
private Runnable closeCleanup = () -> {};
// encryption / session state
private EncryptedFrames frames;
public HubConnection(Hub hub, NetSocket socket) {
this.hub = hub;
this.socket = socket;
this.id = "#" + SEQ.incrementAndGet();
}
public void start() {
socket.handler(this::onRaw);
socket.closeHandler(v -> closeCleanup.run());
socket.exceptionHandler(t -> socket.close());
}
private void onRaw(Buffer b) {
if (dispatched) return;
hs.appendBuffer(b);
if (hs.length() > MAX_HANDSHAKE) {
LOG.log(System.Logger.Level.WARNING, id + " handshake too large; closing");
socket.close();
return;
}
tryParseHandshake();
}
private void tryParseHandshake() {
VarInt.Read lr;
try {
lr = VarInt.tryRead(hs, 0);
} catch (RuntimeException e) {
socket.close();
return;
}
if (lr == null) return;
int pktLen = lr.value();
int hdr = lr.size();
if (pktLen < 0 || pktLen > Protocol.MAX_FRAME) {
socket.close();
return;
}
if (hs.length() < hdr + pktLen) return; // wait for the full packet
byte[] packet = hs.getBytes(hdr, hdr + pktLen);
Buffer afterHandshake = hs.getBuffer(hdr + pktLen, hs.length());
dispatched = true;
try {
dispatch(packet, afterHandshake);
} catch (RuntimeException e) {
LOG.log(System.Logger.Level.WARNING, id + " handshake error: " + e);
socket.close();
}
}
private void dispatch(byte[] packet, Buffer afterHandshake) {
ProtoReader r = new ProtoReader(packet);
int packetId = r.readVarInt();
if (packetId != 0x00) {
socket.close();
return;
}
r.readVarInt(); // protocol version (ignored)
String address = r.readString();
int port = r.readU16(); // server port (ignored)
int intent = r.readVarInt();
if (intent == Protocol.INTENT_REDAPRICOT) {
beginRedapricot(address, afterHandshake);
} else if (intent == Protocol.INTENT_RESERVED) {
LOG.log(System.Logger.Level.INFO, id + " reserved intent 18; closing");
socket.close();
} else {
handlePlayer(address);
}
}
// ---- redapricot session (Intent 17) ----
private void beginRedapricot(String address, Buffer afterHandshake) {
if (!address.equalsIgnoreCase(hub.pskAddress)) {
LOG.log(System.Logger.Level.WARNING, id + " bad PSK address; closing");
socket.close();
return;
}
// Phase A ciphers derived from the configured PSK.
frames = new EncryptedFrames(
socket,
Crypto.decryptCipher(hub.pskBytes, Crypto.DIR_C2S),
Crypto.encryptCipher(hub.pskBytes, Crypto.DIR_S2C),
this::onRekeyFrame);
socket.handler(frames::feed);
frames.feed(afterHandshake);
}
private void onRekeyFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int magic = r.readUByte();
int randLen = r.readVarInt();
if (randLen < 8 || randLen > 64) {
LOG.log(System.Logger.Level.WARNING, id + " bad rekey randLen; closing");
frames.close();
return;
}
byte[] rand = r.readBytes(randLen);
long ts = r.readI64();
long now = System.currentTimeMillis();
if (Math.abs(now - ts) > hub.config.timestampWindowMs) {
LOG.log(System.Logger.Level.WARNING, id + " rekey timestamp outside window; closing");
frames.close();
return;
}
// REKEY = Rand || Timestamp(I64 big-endian). Magic is excluded.
byte[] rekey = new byte[randLen + 8];
System.arraycopy(rand, 0, rekey, 0, randLen);
long t = ts;
for (int i = 7; i >= 0; i--) {
rekey[randLen + i] = (byte) (t & 0xFF);
t >>>= 8;
}
frames.switchCiphers(
Crypto.decryptCipher(rekey, Crypto.DIR_C2S),
Crypto.encryptCipher(rekey, Crypto.DIR_S2C));
frames.send(new byte[]{(byte) Protocol.CTL_SESSION_READY});
if (magic == Protocol.MAGIC_CONTROL) {
ControlSession session = new ControlSession(hub, frames, id);
frames.setHandler(session::onFrame);
closeCleanup = session::onClose;
LOG.log(System.Logger.Level.INFO, id + " control session established");
} else if (magic == Protocol.MAGIC_WORKER) {
WorkerConn worker = new WorkerConn(hub, frames, id);
frames.setHandler(worker::onFrame);
closeCleanup = worker::onClose;
LOG.log(System.Logger.Level.INFO, id + " worker conn established");
} else {
LOG.log(System.Logger.Level.WARNING, id + " bad magic " + magic + "; closing");
frames.close();
}
}
// ---- player connection ----
private void handlePlayer(String address) {
String pattern = Hub.normalizeAddress(address);
ControlSession session = hub.match(address);
if (session == null) {
LOG.log(System.Logger.Level.INFO, id + " no route for '" + pattern + "'; closing");
socket.close();
return;
}
byte[] cid = hub.newCid();
String cidHex = Hex.encode(cid);
String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0";
int port = socket.remoteAddress() != null ? socket.remoteAddress().port() : 0;
socket.pause();
Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim
PendingPlayer p = new PendingPlayer(cid, cidHex, socket, buffered, pattern, ip, port);
hub.addPending(p);
closeCleanup = () -> hub.removePending(cidHex);
session.sendControlRequest(cid, pattern, ip, port);
LOG.log(System.Logger.Level.INFO,
id + " player " + ip + ":" + port + " matched '" + pattern + "' cid=" + cidHex);
}
}
@@ -0,0 +1,42 @@
package io.icybear.redapricot;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.net.NetServer;
import io.vertx.core.net.NetServerOptions;
/** Vert.x verticle that accepts every inbound connection on the hub port. */
public final class HubServer extends AbstractVerticle {
private static final System.Logger LOG = System.getLogger("redapricot.server");
private final Config config;
private Hub hub;
public HubServer(Config config) {
this.config = config;
}
@Override
public void start(Promise<Void> startPromise) {
hub = new Hub(vertx, config);
NetServerOptions opts = new NetServerOptions()
.setHost(config.host)
.setPort(config.port)
.setTcpNoDelay(true)
.setReuseAddress(true);
NetServer server = vertx.createNetServer(opts);
server.connectHandler(socket -> new HubConnection(hub, socket).start());
server.listen().onComplete(ar -> {
if (ar.succeeded()) {
LOG.log(System.Logger.Level.INFO,
"redapricot hub listening on " + config.host + ":" + ar.result().actualPort());
LOG.log(System.Logger.Level.INFO, "PSK handshake address: " + hub.pskAddress);
startPromise.complete();
} else {
startPromise.fail(ar.cause());
}
});
}
}
@@ -0,0 +1,32 @@
package io.icybear.redapricot;
import io.vertx.core.Vertx;
import io.vertx.core.VertxOptions;
import java.nio.file.Path;
/** Entry point: {@code java -jar redapricot-server.jar <config.json>}. */
public final class Main {
private static final System.Logger LOG = System.getLogger("redapricot.main");
public static void main(String[] args) throws Exception {
if (args.length < 1) {
System.err.println("usage: redapricot-server <config.json>");
System.exit(2);
}
Config config = Config.load(Path.of(args[0]));
// A single verticle instance keeps all connection handling on one event
// loop, so the shared hub state needs no locking.
Vertx vertx = Vertx.vertx(new VertxOptions());
vertx.deployVerticle(new HubServer(config)).onComplete(ar -> {
if (ar.failed()) {
LOG.log(System.Logger.Level.ERROR, "failed to start hub", ar.cause());
vertx.close();
System.exit(1);
}
});
Runtime.getRuntime().addShutdownHook(new Thread(vertx::close));
}
}
@@ -0,0 +1,27 @@
package io.icybear.redapricot;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
/** A player connection awaiting a worker-conn takeover, keyed by CID. */
public final class PendingPlayer {
public final byte[] cid;
public final String cidHex;
public final NetSocket socket;
public final Buffer buffered; // raw bytes already read from the player (handshake + pipelined)
public final String pattern;
public final String playerIp;
public final int playerPort;
public long timerId = -1;
public PendingPlayer(byte[] cid, String cidHex, NetSocket socket, Buffer buffered,
String pattern, String playerIp, int playerPort) {
this.cid = cid;
this.cidHex = cidHex;
this.socket = socket;
this.buffered = buffered;
this.pattern = pattern;
this.playerIp = playerIp;
this.playerPort = playerPort;
}
}
@@ -0,0 +1,33 @@
package io.icybear.redapricot;
/** Shared redapricot protocol constants. See PROTOCOL.md. */
public final class Protocol {
private Protocol() {}
public static final int INTENT_REDAPRICOT = 17;
public static final int INTENT_RESERVED = 18;
public static final int MAGIC_CONTROL = 0x01;
public static final int MAGIC_WORKER = 0x02;
public static final int CID_LEN = 16;
public static final int MAX_FRAME = 1 << 20; // 1 MiB payload cap
// Control-session message types
public static final int CTL_SESSION_READY = 0x00;
public static final int CTL_REGISTER = 0x01;
public static final int CTL_UNREGISTER = 0x02;
public static final int CTL_REGISTER_ACK = 0x03;
public static final int CTL_CONTROL_REQUEST = 0x04;
public static final int CTL_PING = 0x05;
public static final int CTL_PONG = 0x06;
// Worker-conn mux frame types
public static final int MUX_SYN = 0x00;
public static final int MUX_DATA = 0x01;
public static final int MUX_FIN = 0x02;
public static final int MUX_RST = 0x03;
// Any redapricot connection
public static final int FRAME_ERROR = 0x7F;
}
@@ -0,0 +1,108 @@
package io.icybear.redapricot;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import java.util.HashMap;
import java.util.Map;
/**
* An authenticated worker connection (Magic 0x02). Multiplexes many player
* streams; the client opens streams via SYN(CID) to take over pending players.
*/
public final class WorkerConn {
private static final System.Logger LOG = System.getLogger("redapricot.worker");
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
private final Map<Integer, NetSocket> streams = new HashMap<>();
public WorkerConn(Hub hub, EncryptedFrames frames, String id) {
this.hub = hub;
this.frames = frames;
this.id = id;
}
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int type = r.readUByte();
int sid = r.readVarInt();
switch (type) {
case Protocol.MUX_SYN -> handleSyn(sid, r.readBytes(Protocol.CID_LEN));
case Protocol.MUX_DATA -> handleData(sid, r.readBytes(r.remaining()));
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeStream(sid);
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
"worker " + id + " error frame");
default -> LOG.log(System.Logger.Level.WARNING, "worker " + id + " unknown mux type " + type);
}
}
private void handleSyn(int sid, byte[] cid) {
PendingPlayer p = hub.takePending(cid);
if (p == null) {
LOG.log(System.Logger.Level.WARNING, "worker " + id + " SYN for unknown CID");
sendRst(sid);
return;
}
NetSocket player = p.socket;
streams.put(sid, player);
// From now on the player socket belongs to this stream.
player.handler(buf -> {
sendData(sid, buf.getBytes());
if (frames.writeQueueFull()) {
player.pause();
frames.socket().drainHandler(v -> player.resume());
}
});
player.closeHandler(v -> {
if (streams.remove(sid) != null) sendFin(sid);
});
player.exceptionHandler(t -> {
if (streams.remove(sid) != null) sendFin(sid);
});
// Forward the buffered handshake (and any pipelined bytes), then resume.
sendData(sid, p.buffered.getBytes());
player.resume();
LOG.log(System.Logger.Level.INFO,
"worker " + id + " stream " + sid + " bound to " + p.pattern);
}
private void handleData(int sid, byte[] data) {
NetSocket player = streams.get(sid);
if (player == null) return;
player.write(Buffer.buffer(data));
if (player.writeQueueFull()) {
frames.socket().pause();
player.drainHandler(v -> frames.socket().resume());
}
}
private void closeStream(int sid) {
NetSocket player = streams.remove(sid);
if (player != null) player.close();
}
private void sendData(int sid, byte[] data) {
frames.send(new ProtoWriter().u8(Protocol.MUX_DATA).varInt(sid).bytes(data).toBytes());
}
private void sendFin(int sid) {
frames.send(new ProtoWriter().u8(Protocol.MUX_FIN).varInt(sid).toBytes());
}
private void sendRst(int sid) {
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).varInt(sid).toBytes());
}
public void onClose() {
for (NetSocket player : streams.values()) player.close();
streams.clear();
LOG.log(System.Logger.Level.INFO, "worker " + id + " closed");
}
}
@@ -0,0 +1,75 @@
package io.icybear.redapricot.crypto;
import io.icybear.redapricot.util.Hex;
import javax.crypto.Cipher;
import javax.crypto.spec.ChaCha20ParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
/**
* Crypto primitives for redapricot: SHA3 hashing, per-direction key derivation,
* and ChaCha20 stream ciphers. See PROTOCOL.md §3.
*/
public final class Crypto {
private Crypto() {}
public static final int DIR_C2S = 0x01;
public static final int DIR_S2C = 0x02;
public static byte[] sha3_224(byte[] in) {
return digest("SHA3-224", in);
}
/** Handshake address for Intent 17: lowercase hex of SHA3-224(PSK). */
public static String pskAddress(String psk) {
return Hex.encode(sha3_224(psk.getBytes(StandardCharsets.UTF_8)));
}
/** keyDir = SHA3-256(phaseKey || dirByte). */
public static byte[] deriveKey(byte[] phaseKey, int dir) {
try {
MessageDigest md = MessageDigest.getInstance("SHA3-256");
md.update(phaseKey);
md.update((byte) dir);
return md.digest();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
/**
* Create a ChaCha20 stream cipher (RFC 8439) for a 32-byte key, 12-byte zero
* nonce, counter 0. Encryption and decryption are the identical XOR operation,
* so {@code opmode} is cosmetic; the keystream position advances across
* {@code update()} calls, giving a continuous stream.
*/
public static Cipher chacha20(byte[] key, int opmode) {
try {
Cipher c = Cipher.getInstance("ChaCha20");
c.init(opmode, new SecretKeySpec(key, "ChaCha20"),
new ChaCha20ParameterSpec(new byte[12], 0));
return c;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("ChaCha20 unavailable", e);
}
}
public static Cipher encryptCipher(byte[] phaseKey, int dir) {
return chacha20(deriveKey(phaseKey, dir), Cipher.ENCRYPT_MODE);
}
public static Cipher decryptCipher(byte[] phaseKey, int dir) {
return chacha20(deriveKey(phaseKey, dir), Cipher.DECRYPT_MODE);
}
private static byte[] digest(String alg, byte[] in) {
try {
return MessageDigest.getInstance(alg).digest(in);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,104 @@
package io.icybear.redapricot.net;
import io.icybear.redapricot.Protocol;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import javax.crypto.Cipher;
/**
* Length-prefixed encrypted frame transport over a NetSocket (PROTOCOL.md §3.1).
* The VarInt length prefix is plaintext; the payload is ChaCha20-encrypted. The
* cipher instances maintain a continuous per-direction keystream across frames.
*/
public final class EncryptedFrames {
public interface FrameHandler {
void handle(byte[] payload);
}
private final NetSocket socket;
private Cipher in;
private Cipher out;
private FrameHandler handler;
private Buffer buf = Buffer.buffer();
private boolean closed = false;
public EncryptedFrames(NetSocket socket, Cipher in, Cipher out, FrameHandler handler) {
this.socket = socket;
this.in = in;
this.out = out;
this.handler = handler;
}
public void setHandler(FrameHandler h) { this.handler = h; }
/** Swap both ciphers at a frame boundary (Phase A → Phase B rekey). */
public void switchCiphers(Cipher in, Cipher out) {
this.in = in;
this.out = out;
}
public NetSocket socket() { return socket; }
public boolean isClosed() { return closed; }
/** Feed raw incoming ciphertext (plaintext length prefixes + encrypted payloads). */
public void feed(Buffer incoming) {
if (closed) return;
if (incoming != null && incoming.length() > 0) buf.appendBuffer(incoming);
pump();
}
private void pump() {
while (!closed) {
VarInt.Read r;
try {
r = VarInt.tryRead(buf, 0);
} catch (RuntimeException e) {
close();
return;
}
if (r == null) return;
int payloadLen = r.value();
int hdr = r.size();
if (payloadLen < 0 || payloadLen > Protocol.MAX_FRAME) {
close();
return;
}
if (buf.length() < hdr + payloadLen) return;
byte[] ct = buf.getBytes(hdr, hdr + payloadLen);
byte[] pt = in.update(ct);
if (pt == null) pt = new byte[0];
buf = buf.getBuffer(hdr + payloadLen, buf.length());
FrameHandler h = handler;
if (h != null) {
try {
h.handle(pt);
} catch (RuntimeException e) {
close();
return;
}
}
}
}
/** Encrypt and send one frame payload. */
public void send(byte[] payload) {
if (closed) return;
byte[] ct = out.update(payload);
if (ct == null) ct = new byte[0];
Buffer f = Buffer.buffer(ct.length + VarInt.MAX_BYTES);
VarInt.write(f, ct.length);
f.appendBytes(ct);
socket.write(f);
}
public boolean writeQueueFull() { return socket.writeQueueFull(); }
public void close() {
if (closed) return;
closed = true;
socket.close();
}
}
@@ -0,0 +1,16 @@
package io.icybear.redapricot.util;
public final class Hex {
private Hex() {}
private static final char[] HEX = "0123456789abcdef".toCharArray();
public static String encode(byte[] in) {
char[] out = new char[in.length * 2];
for (int i = 0; i < in.length; i++) {
int v = in[i] & 0xFF;
out[i * 2] = HEX[v >>> 4];
out[i * 2 + 1] = HEX[v & 0x0F];
}
return new String(out);
}
}
@@ -0,0 +1,151 @@
package io.icybear.redapricot.util;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Minimal, dependency-free JSON parser sufficient for redapricot config files.
* Produces Map&lt;String,Object&gt;, List&lt;Object&gt;, String, Double, Boolean, null.
*/
public final class Json {
private final String s;
private int i;
private Json(String s) { this.s = s; }
public static Object parse(String text) {
Json p = new Json(text);
p.ws();
Object v = p.value();
p.ws();
if (p.i != p.s.length()) throw new IllegalArgumentException("trailing JSON at " + p.i);
return v;
}
@SuppressWarnings("unchecked")
public static Map<String, Object> parseObject(String text) {
Object v = parse(text);
if (!(v instanceof Map)) throw new IllegalArgumentException("expected JSON object");
return (Map<String, Object>) v;
}
private Object value() {
char c = peek();
return switch (c) {
case '{' -> object();
case '[' -> array();
case '"' -> string();
case 't', 'f' -> bool();
case 'n' -> nul();
default -> number();
};
}
private Map<String, Object> object() {
expect('{');
Map<String, Object> m = new LinkedHashMap<>();
ws();
if (peek() == '}') { i++; return m; }
while (true) {
ws();
String key = string();
ws();
expect(':');
ws();
m.put(key, value());
ws();
char c = next();
if (c == '}') return m;
if (c != ',') throw err("expected , or }");
}
}
private List<Object> array() {
expect('[');
List<Object> a = new ArrayList<>();
ws();
if (peek() == ']') { i++; return a; }
while (true) {
ws();
a.add(value());
ws();
char c = next();
if (c == ']') return a;
if (c != ',') throw err("expected , or ]");
}
}
private String string() {
expect('"');
StringBuilder sb = new StringBuilder();
while (true) {
char c = next();
if (c == '"') return sb.toString();
if (c == '\\') {
char e = next();
switch (e) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case '/' -> sb.append('/');
case 'b' -> sb.append('\b');
case 'f' -> sb.append('\f');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case 'u' -> {
int cp = Integer.parseInt(s.substring(i, i + 4), 16);
i += 4;
sb.append((char) cp);
}
default -> throw err("bad escape");
}
} else {
sb.append(c);
}
}
}
private Object number() {
int start = i;
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++;
String num = s.substring(start, i);
if (num.isEmpty()) throw err("bad value");
if (num.contains(".") || num.contains("e") || num.contains("E")) return Double.parseDouble(num);
return Double.parseDouble(num); // keep numbers as Double uniformly
}
private Boolean bool() {
if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; }
if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; }
throw err("bad literal");
}
private Object nul() {
if (s.startsWith("null", i)) { i += 4; return null; }
throw err("bad literal");
}
private void ws() {
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
}
private char peek() {
if (i >= s.length()) throw err("unexpected end");
return s.charAt(i);
}
private char next() {
if (i >= s.length()) throw err("unexpected end");
return s.charAt(i++);
}
private void expect(char c) {
if (next() != c) throw err("expected " + c);
}
private IllegalArgumentException err(String msg) {
return new IllegalArgumentException("JSON: " + msg + " at index " + i);
}
}
@@ -0,0 +1,62 @@
package io.icybear.redapricot.util;
import java.nio.charset.StandardCharsets;
/** Cursor-based reader for redapricot/Minecraft primitive types over a byte array. */
public final class ProtoReader {
private final byte[] buf;
private int pos;
private final int end;
public ProtoReader(byte[] buf) { this(buf, 0, buf.length); }
public ProtoReader(byte[] buf, int off, int len) {
this.buf = buf;
this.pos = off;
this.end = off + len;
}
public int remaining() { return end - pos; }
public int readUByte() {
if (pos >= end) throw new IllegalStateException("underflow");
return buf[pos++] & 0xFF;
}
public int readVarInt() {
int value = 0;
int shift = 0;
while (true) {
int b = readUByte();
value |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
shift += 7;
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
}
}
public int readU16() {
int hi = readUByte();
int lo = readUByte();
return (hi << 8) | lo;
}
public long readI64() {
long v = 0;
for (int i = 0; i < 8; i++) v = (v << 8) | readUByte();
return v;
}
public byte[] readBytes(int n) {
if (n < 0 || n > remaining()) throw new IllegalStateException("bad length " + n);
byte[] out = new byte[n];
System.arraycopy(buf, pos, out, 0, n);
pos += n;
return out;
}
public String readString() {
int len = readVarInt();
return new String(readBytes(len), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,35 @@
package io.icybear.redapricot.util;
import io.vertx.core.buffer.Buffer;
import java.nio.charset.StandardCharsets;
/** Builder for redapricot/Minecraft primitive types, backed by a Vert.x Buffer. */
public final class ProtoWriter {
private final Buffer b = Buffer.buffer();
public ProtoWriter u8(int v) { b.appendByte((byte) v); return this; }
public ProtoWriter varInt(int v) { VarInt.write(b, v); return this; }
public ProtoWriter u16(int v) {
b.appendByte((byte) (v >>> 8));
b.appendByte((byte) v);
return this;
}
public ProtoWriter i64(long v) { b.appendLong(v); return this; }
public ProtoWriter bytes(byte[] x) { b.appendBytes(x); return this; }
public ProtoWriter string(String s) {
byte[] u = s.getBytes(StandardCharsets.UTF_8);
varInt(u.length);
b.appendBytes(u);
return this;
}
public byte[] toBytes() { return b.getBytes(); }
public Buffer buffer() { return b; }
}
@@ -0,0 +1,51 @@
package io.icybear.redapricot.util;
import io.vertx.core.buffer.Buffer;
/** Minecraft-style VarInt (LEB128, 7 data bits/byte, max 5 bytes). */
public final class VarInt {
private VarInt() {}
public static final int MAX_BYTES = 5;
/** Result of a partial read: either complete (value/size) or null when more bytes are needed. */
public record Read(int value, int size) {}
/** Write {@code value} as a VarInt to the buffer. */
public static void write(Buffer buf, int value) {
while ((value & ~0x7F) != 0) {
buf.appendByte((byte) ((value & 0x7F) | 0x80));
value >>>= 7;
}
buf.appendByte((byte) (value & 0x7F));
}
/** Encoded size in bytes of {@code value}. */
public static int size(int value) {
int n = 1;
while ((value & ~0x7F) != 0) { value >>>= 7; n++; }
return n;
}
/**
* Try to read a VarInt from {@code buf} starting at {@code off}, without consuming.
* Returns null if the buffer does not yet hold the full VarInt.
* Throws IllegalArgumentException if it exceeds 5 bytes.
*/
public static Read tryRead(Buffer buf, int off) {
int value = 0;
int shift = 0;
int i = off;
while (true) {
if (i >= buf.length()) return null; // need more bytes
int b = buf.getByte(i) & 0xFF;
value |= (b & 0x7F) << shift;
i++;
if ((b & 0x80) == 0) {
return new Read(value, i - off);
}
shift += 7;
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
}
}
}
@@ -0,0 +1,74 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
class CryptoCodecTest {
@Test
void varIntRoundTrip() {
int[] cases = {0, 1, 127, 128, 255, 300, 16384, 2097151, 1 << 30};
for (int v : cases) {
Buffer b = Buffer.buffer();
VarInt.write(b, v);
assertEquals(VarInt.size(v), b.length(), "size for " + v);
VarInt.Read r = VarInt.tryRead(b, 0);
assertEquals(v, r.value());
assertEquals(b.length(), r.size());
}
}
@Test
void varIntTryReadNeedsMoreBytes() {
Buffer partial = Buffer.buffer();
partial.appendByte((byte) 0x80); // continuation set, but no following byte
assertNull(VarInt.tryRead(partial, 0));
}
@Test
void protoStringAndTypesRoundTrip() {
byte[] enc = new ProtoWriter()
.u8(0x04).string("mc.EXAMPLE.com").string("127.0.0.1").u16(45123).i64(1234567890123L)
.toBytes();
ProtoReader r = new ProtoReader(enc);
assertEquals(0x04, r.readUByte());
assertEquals("mc.EXAMPLE.com", r.readString());
assertEquals("127.0.0.1", r.readString());
assertEquals(45123, r.readU16());
assertEquals(1234567890123L, r.readI64());
}
/** Locked against the identical Go client assertion (SHA3-224 of "test-psk"). */
@Test
void pskAddressMatchesReference() {
assertEquals("90188f2d84e273e4d6fb27194b4a88ad10bcc20de00c493beae6d18f",
Crypto.pskAddress("test-psk"));
}
@Test
void perDirectionKeysDifferAndAreStable() {
byte[] pk = "phase-key".getBytes();
byte[] c2s = Crypto.deriveKey(pk, Crypto.DIR_C2S);
byte[] s2c = Crypto.deriveKey(pk, Crypto.DIR_S2C);
assertEquals(32, c2s.length);
assertEquals(32, s2c.length);
assertArrayEquals(c2s, Crypto.deriveKey(pk, Crypto.DIR_C2S)); // deterministic
assertFalse(java.util.Arrays.equals(c2s, s2c)); // directions differ
}
@Test
void normalizeAddressStripsFmlAndCase() {
assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com"));
assertEquals("mc.example.com", Hub.normalizeAddress("mc.example.com."));
assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com\u0000FML\u00000"));
}
}