Verify authenticated Forge transfer on encrypted session

This commit is contained in:
layue13 committed 2026-09-26 00:48:42 +08:00
1 parent aa4d7e560d
commit c99d296cd4
2 files changed
+77 -8

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ StrataProxy 是面向 Minecraft 1.7.10 Forge 的玩家会话代理。项目正
配置必须显式选择 `authentication: OFFLINE` 或 `ONLINE_BUNGEE`。两种模式均解析协议 5 Handshake 与 Login Start,等待异步落点,预留容量,连接后端,确认 Login Success,然后转发普通字节流。在线模式执行加密握手、异步会话校验,并向可信的 Bungee 兼容后端转发已验证身份。状态查询由代理直接回答:在线数是本代理已登录的会话数,最大值按当前已注册后端的声明容量计算,且不低于在线数。登录阶段的后端拒绝消息会在前端写出后再清理会话;后端关闭或读失败时,已提交给客户端的写入也会先排空再关闭前端,这两种等待最多 5 秒。`Players.transfer` 会在旧后端继续服务时登录候选后端;普通候选后端收到 Join Game 后即可开始交接,Forge 候选后端收到 ServerHello 后先切换握手链路,待客户端完成握手并收到 Join Game 时再合成 Respawn。`NETWORK_READY` 等待这次协议握手和世界切换包写出,但不表示目标服的插件已完成空岛或副本加载。切换后的玩家实体 ID 会映射到原客户端 ID;候选登录失败时保留旧链路。Forge 重置包写出后若目标拒绝握手,代理结束会话并返回失败,不能恢复旧链路。DNS 与 Agent 是调用通用注册 API 的独立插件。
**验证边界**:跨后端切换已通过普通协议与 Forge 握手的合成 TCP 测试,包括切换前失败回退、候选服 Join Game 后立即断开时保留旧会话、切换后 Forge 握手拒绝、维度变化后再转服、Forge 到普通后端的切换、握手后 Join Game、维度覆盖、玩家实体 ID 映射和中途断线清理。从 Forge 切换到普通后端会发送一次 FML 重置以恢复客户端注册表;随后进入 Forge 时不会重复发送重置。转服固定发送中间维度和目标维度两次 Respawn,避免依赖已经过期的客户端维度记录;交接期间客户端与旧后端的帧分别进入有上限的缓冲区。成功时,客户端帧在世界切换包写出后转发,旧后端帧丢弃;切换前失败恢复旧链路后,两侧暂存的帧都会回放。这些时序已通过合成 TCP 测试。在线认证仅通过注入会话校验器的合成 TCP 测试。最小 Uranium 1.7.10 后端已通过代理完成协议探针登录和保持连接,两台 Uranium 间的 Forge 转服探针也已通过。Prism 中的真实 Minecraft 1.7.10 + Forge 10.13.4.1614 客户端已通过最小 Uranium 的首次登录与两台 Uranium 间的插件触发转服,见 [真实客户端联机记录](smoke/results/2026-09-25-prism-forge-client.md)。目标 1.7.10 整合包、Mojang 在线认证、跨主机部署和目标流量下的性能验收仍未验证;当前不能宣称生产可用或目标整合包实服兼容。
**验证边界**:跨后端切换已通过普通协议与 Forge 握手的合成 TCP 测试,包括切换前失败回退、候选服 Join Game 后立即断开时保留旧会话、切换后 Forge 握手拒绝、维度变化后再转服、Forge 到普通后端的切换、握手后 Join Game、维度覆盖、玩家实体 ID 映射和中途断线清理。从 Forge 切换到普通后端会发送一次 FML 重置以恢复客户端注册表;随后进入 Forge 时不会重复发送重置。转服固定发送中间维度和目标维度两次 Respawn,避免依赖已经过期的客户端维度记录;交接期间客户端与旧后端的帧分别进入有上限的缓冲区。成功时,客户端帧在世界切换包写出后转发,旧后端帧丢弃;切换前失败恢复旧链路后,两侧暂存的帧都会回放。这些时序已通过合成 TCP 测试。在线认证及其加密连接上的 Forge 转服已通过注入会话校验器的合成 TCP 测试,尚未调用真实 Mojang 会话服务。最小 Uranium 1.7.10 后端已通过代理完成协议探针登录和保持连接,两台 Uranium 间的 Forge 转服探针也已通过。Prism 中的真实 Minecraft 1.7.10 + Forge 10.13.4.1614 客户端已通过最小 Uranium 的首次登录与两台 Uranium 间的插件触发转服,见 [真实客户端联机记录](smoke/results/2026-09-25-prism-forge-client.md)。目标 1.7.10 整合包、Mojang 在线认证、跨主机部署和目标流量下的性能验收仍未验证;当前不能宣称生产可用或目标整合包实服兼容。
## 配置与运行
@@ -817,7 +817,7 @@ final class ProxySessionListenerTest {
}
@Test
void onlineTransferForwardsVerifiedIdentityToReplacementAndKeepsClientEncrypted() throws Exception {
void onlineTransferToForgeForwardsVerifiedIdentityAndKeepsClientEncrypted() throws Exception {
UUID uuid = UUID.fromString("12345678-1234-1234-1234-123456789abc");
var catalog = new InMemoryBackendCatalog();
try (ServerSocket oldServer = new ServerSocket(0, 8, InetAddress.getLoopbackAddress());
@@ -843,7 +843,21 @@ final class ProxySessionListenerTest {
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream());
acceptForwardedLogin(input, uuid);
sendOnlineBackendPlayStart(output, uuid, 200);
sendOnlineBackendLoginSuccess(output, uuid);
writeFrame(output, forgeRegistration(true));
writeFrame(output, forgeHello(7));
assertArrayEquals(forgeRegistration(false), readFrame(input));
assertArrayEquals(forgeMessage(false, new byte[]{1, 2}), readFrame(input));
assertArrayEquals(forgeMessage(false, new byte[]{2, 0}), readFrame(input));
writeFrame(output, forgeMessage(true, new byte[]{2, 0}));
assertArrayEquals(forgeMessage(false, new byte[]{(byte) 0xff, 2}), readFrame(input));
writeFrame(output, forgeMessage(true, new byte[]{3, 0, 0, 0}));
writeFrame(output, forgeMessage(true, new byte[]{(byte) 0xff, 2}));
assertArrayEquals(forgeMessage(false, new byte[]{(byte) 0xff, 3}), readFrame(input));
assertArrayEquals(forgeMessage(false, new byte[]{(byte) 0xff, 4}), readFrame(input));
writeFrame(output, forgeMessage(true, new byte[]{(byte) 0xff, 3}));
assertArrayEquals(forgeMessage(false, new byte[]{(byte) 0xff, 5}), readFrame(input));
sendOnlineBackendWorld(output, 200);
assertArrayEquals(new byte[]{0x01, 0x33}, readFrame(input));
writeFrame(output, new byte[]{0x03, 0x44});
replacementRelayed.complete(null);
@@ -898,6 +912,7 @@ final class ProxySessionListenerTest {
var encryptedInput = new DataInputStream(decryptingInput(client.getInputStream(),
aes(secret, Cipher.DECRYPT_MODE)));
Cipher encryptor = aes(secret, Cipher.ENCRYPT_MODE);
var success = new java.io.ByteArrayInputStream(readFrame(encryptedInput));
assertEquals(2, readVarInt(success));
assertEquals(uuid.toString(), readString(success, 36));
@@ -909,16 +924,30 @@ final class ProxySessionListenerTest {
while (listener.online().isEmpty() && System.nanoTime() < deadline) Thread.sleep(5);
assertEquals(1, listener.online().size());
var player = listener.online().get(0);
var transfer = listener.transfer(player.identity(), "new").toCompletableFuture()
.get(5, TimeUnit.SECONDS);
var pendingTransfer = listener.transfer(player.identity(), "new").toCompletableFuture();
byte[] reset = readFrame(encryptedInput);
assertEquals(0x3f, readVarInt(new java.io.ByteArrayInputStream(reset)));
assertEquals((byte) 0xfe, reset[reset.length - 1]);
assertArrayEquals(forgeRegistration(true), readFrame(encryptedInput));
assertArrayEquals(forgeHello(7), readFrame(encryptedInput));
writeEncryptedFrame(clearOutput, encryptor, forgeRegistration(false));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{1, 2}));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{2, 0}));
assertArrayEquals(forgeMessage(true, new byte[]{2, 0}), readFrame(encryptedInput));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{(byte) 0xff, 2}));
assertArrayEquals(forgeMessage(true, new byte[]{3, 0, 0, 0}), readFrame(encryptedInput));
assertArrayEquals(forgeMessage(true, new byte[]{(byte) 0xff, 2}), readFrame(encryptedInput));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{(byte) 0xff, 3}));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{(byte) 0xff, 4}));
assertArrayEquals(forgeMessage(true, new byte[]{(byte) 0xff, 3}), readFrame(encryptedInput));
writeEncryptedFrame(clearOutput, encryptor, forgeMessage(false, new byte[]{(byte) 0xff, 5}));
var transfer = pendingTransfer.get(5, TimeUnit.SECONDS);
assertEquals(dev.strataproxy.api.TransferStatus.NETWORK_READY, transfer.status(),
transfer.detail().orElse(""));
assertEquals(7, readVarInt(new java.io.ByteArrayInputStream(readFrame(encryptedInput))));
assertEquals(7, readVarInt(new java.io.ByteArrayInputStream(readFrame(encryptedInput))));
assertArrayEquals(new byte[]{0x08}, readFrame(encryptedInput));
byte[] encryptedPlay = aes(secret, Cipher.ENCRYPT_MODE).update(framed(new byte[]{0x01, 0x33}));
clearOutput.write(encryptedPlay);
clearOutput.flush();
writeEncryptedFrame(clearOutput, encryptor, new byte[]{0x01, 0x33});
replacementRelayed.get(5, TimeUnit.SECONDS);
assertArrayEquals(new byte[]{0x03, 0x44}, readFrame(encryptedInput));
assertEquals("new", listener.find(player.identity()).orElseThrow().currentServer().orElseThrow());
@@ -945,11 +974,19 @@ final class ProxySessionListenerTest {
}
private static void sendOnlineBackendPlayStart(DataOutputStream output, UUID uuid, int entityId) throws Exception {
sendOnlineBackendLoginSuccess(output, uuid);
sendOnlineBackendWorld(output, entityId);
}
private static void sendOnlineBackendLoginSuccess(DataOutputStream output, UUID uuid) throws Exception {
ByteArrayOutputStream success = new ByteArrayOutputStream();
writeVarInt(success, 2);
writeString(success, uuid.toString());
writeString(success, "Alice");
writeFrame(output, success.toByteArray());
}
private static void sendOnlineBackendWorld(DataOutputStream output, int entityId) throws Exception {
ByteArrayOutputStream join = new ByteArrayOutputStream();
writeVarInt(join, 1);
var data = new DataOutputStream(join);
@@ -963,6 +1000,38 @@ final class ProxySessionListenerTest {
writeFrame(output, new byte[]{0x08});
}
private static void writeEncryptedFrame(DataOutputStream output, Cipher cipher, byte[] payload) throws Exception {
output.write(cipher.update(framed(payload)));
output.flush();
}
private static byte[] forgeRegistration(boolean clientbound) throws Exception {
return forgePacket(clientbound, "REGISTER", "FML|HS\0FML".getBytes(StandardCharsets.UTF_8));
}
private static byte[] forgeHello(int dimensionOverride) throws Exception {
ByteArrayOutputStream hello = new ByteArrayOutputStream();
var output = new DataOutputStream(hello);
output.writeByte(0);
output.writeByte(2);
output.writeInt(dimensionOverride);
return forgeMessage(true, hello.toByteArray());
}
private static byte[] forgeMessage(boolean clientbound, byte[] handshake) throws Exception {
return forgePacket(clientbound, "FML|HS", handshake);
}
private static byte[] forgePacket(boolean clientbound, String channel, byte[] payload) throws Exception {
ByteArrayOutputStream packet = new ByteArrayOutputStream();
writeVarInt(packet, clientbound ? 0x3f : 0x17);
writeString(packet, channel);
var output = new DataOutputStream(packet);
output.writeShort(payload.length);
output.write(payload);
return packet.toByteArray();
}
@Test
void onlinePlacementRejectionIsEncryptedAndDelivered() throws Exception {
UUID uuid = UUID.fromString("12345678-1234-1234-1234-123456789abc");