Серверная база со своим логином

Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него.

Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент
называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого
соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и
подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а
запись обрезать нельзя: сервер не знает исходного пароля.

Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе:
соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт
называется в Hello, а не в Auth. Иначе клиент получает соль не того
аккаунта и не может вывести ключ.

Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды
лежат рядом с записью, а не в исходниках: число, существующее только в
коде, однажды становится причиной того, что аккаунт невозможно проверить.

Имена уникальны независимо от регистра: Player и player — это один
аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его.

Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает
подбирать одно имя, другое не мешает перебирать много имён с разных
адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на
неверный пароль, и занимает то же время.

Токены живут только в памяти. Записанный на диск токен пережил бы процесс,
который его выдал, а украденный каталог сервера стал бы набором живых
сессий.

Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто
зашёл с одного IP, то есть всех игроков за одним NAT.

Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который
тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего
мы уходим, перенося симуляцию на свой сервер.

Регистрация — команда оператора, а не функция у клиента. Открытая
саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь
поддержки.

Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий
сокет: регистрация, вход, доказательство пароля, токен, его погашение и
появление игроком. Отдельно проверено, что неверный пароль отвергается, а
старое доказательство не принимается против нового вызова. Тик-цикл
замеряется числом тиков за секунду, а не наличием потока.

Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
sasheg dev
2026-10-19 01:45:56 +03:00
parent 7fbb26092f
commit 620f136361
17 changed files with 2576 additions and 14 deletions
@@ -34,6 +34,16 @@ public final class Packets {
/** Longest a handshake proof may be, in bytes. */
public static final int MAX_PROOF = 256;
/** A salt and a nonce, in the sizes the crypto actually produces. */
public static final int SALT_BYTES = 16;
public static final int CHALLENGE_BYTES = 32;
/** Rounds of PBKDF2 that both ends must use. */
public static final int KEY_ITERATIONS = 210000;
/** Derived key length in bytes, matching PBKDF2WithHmacSHA256. */
public static final int KEY_BYTES = 32;
/** Longest the handshake body may be, in bytes. */
public static final int MAX_HANDSHAKE_BODY = 8 * 1024;
@@ -45,14 +55,16 @@ public final class Packets {
public static final int LOGIN_ACK = 0x06;
public static final int DISCONNECT = 0x7F;
/** The client introducing itself. */
/** The client introducing itself, and naming the account it wants. */
public static final class Hello {
public final int protocolVersion;
public final String account;
public final String clientName;
public final int clientVersion;
public Hello(int protocolVersion, String clientName, int clientVersion) {
public Hello(int protocolVersion, String account, String clientName, int clientVersion) {
this.protocolVersion = protocolVersion;
this.account = account;
this.clientName = clientName;
this.clientVersion = clientVersion;
}
@@ -60,13 +72,15 @@ public final class Packets {
public Buf write() {
Buf buf = new Buf();
buf.writeVarInt(this.protocolVersion);
buf.writeString(this.account);
buf.writeString(this.clientName);
buf.writeVarInt(this.clientVersion);
return buf;
}
public static Hello read(Buf in) {
return new Hello(in.readVarInt(), in.readString(MAX_TEXT), in.readVarInt());
return new Hello(in.readVarInt(), in.readString(MAX_TEXT),
in.readString(MAX_TEXT), in.readVarInt());
}
}
@@ -78,13 +92,33 @@ public final class Packets {
public final int onlinePlayers;
public final int maxPlayers;
/**
* A value fresh for this connection.
*
* The proof is computed over it, so a recording of one login cannot be
* replayed against the next one.
*/
public final byte[] nonce;
/**
* The salt of the account that was named, when it exists.
*
* Sent before the client has proved anything, which does reveal whether
* an account exists. That is a deliberate trade for a design where the
* password never crosses the wire and the server stores no reversible
* form of it.
*/
public final byte[] salt;
public HelloAck(int protocolVersion, String serverName, String motd,
int onlinePlayers, int maxPlayers) {
int onlinePlayers, int maxPlayers, byte[] nonce, byte[] salt) {
this.protocolVersion = protocolVersion;
this.serverName = serverName;
this.motd = motd;
this.onlinePlayers = onlinePlayers;
this.maxPlayers = maxPlayers;
this.nonce = nonce;
this.salt = salt;
}
public Buf write() {
@@ -94,21 +128,31 @@ public final class Packets {
buf.writeString(this.motd);
buf.writeVarInt(this.onlinePlayers);
buf.writeVarInt(this.maxPlayers);
buf.writeBytes(this.nonce);
buf.writeBytes(this.salt);
return buf;
}
public static HelloAck read(Buf in) {
return new HelloAck(in.readVarInt(), in.readString(MAX_TEXT),
in.readString(MAX_TEXT), in.readVarInt(), in.readVarInt());
in.readString(MAX_TEXT), in.readVarInt(), in.readVarInt(),
in.readBytes(CHALLENGE_BYTES), in.readBytes(SALT_BYTES));
}
}
/** The client claiming an account. */
/**
* The client claiming an account, with proof rather than with the password.
*
* The proof is HMAC-SHA256 over the derived key, the nonce and the account
* name. The password itself is never sent, the server stores no reversible
* form of it, and a proof captured from one connection is useless against
* the next because the nonce differs.
*/
public static final class Auth {
public final String account;
public final String proof;
public final byte[] proof;
public Auth(String account, String proof) {
public Auth(String account, byte[] proof) {
this.account = account;
this.proof = proof;
}
@@ -116,12 +160,60 @@ public final class Packets {
public Buf write() {
Buf buf = new Buf();
buf.writeString(this.account);
buf.writeString(this.proof);
buf.writeBytes(this.proof);
return buf;
}
public static Auth read(Buf in) {
return new Auth(in.readString(MAX_TEXT), in.readString(MAX_PROOF));
return new Auth(in.readString(MAX_TEXT), in.readBytes(KEY_BYTES));
}
/** Derives the key from a password and salt the way the server does. */
public static byte[] deriveKey(char[] password, byte[] salt) {
try {
javax.crypto.SecretKeyFactory factory =
javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
javax.crypto.spec.PBEKeySpec spec = new javax.crypto.spec.PBEKeySpec(
password, salt, KEY_ITERATIONS, KEY_BYTES * 8);
try {
return factory.generateSecret(spec).getEncoded();
} finally {
// PBEKeySpec keeps its own copy of the password, and only
// clearPassword reaches it
spec.clearPassword();
}
} catch (java.security.NoSuchAlgorithmException
| java.security.spec.InvalidKeySpecException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is unavailable", e);
}
}
/** Builds the proof for a password, a salt and a nonce. */
public static byte[] proofFor(String account, char[] password, byte[] salt, byte[] nonce) {
byte[] key = deriveKey(password, salt);
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"));
mac.update(nonce);
mac.update(account.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return mac.doFinal();
} catch (java.security.NoSuchAlgorithmException | java.security.InvalidKeyException e) {
throw new IllegalStateException("HmacSHA256 is unavailable", e);
} finally {
java.util.Arrays.fill(key, (byte) 0);
}
}
/** Compares two proofs without leaking how much matched. */
public static boolean proofsEqual(byte[] a, byte[] b) {
if (a == null || b == null || a.length != b.length) {
return false;
}
int diff = 0;
for (int i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
@@ -159,8 +251,12 @@ public final class Packets {
}
public static AuthAck read(Buf in) {
if (!in.readBoolean()) {
return refused(in.readString(MAX_TEXT));
boolean accepted = in.readBoolean();
// the reason is written either way, so it has to be read either way:
// skipping it here shifts everything after it by one field
String reason = in.readString(MAX_TEXT);
if (!accepted) {
return refused(reason);
}
return ok(in.readString(MAX_TEXT), in.readLong());
}
@@ -222,8 +318,10 @@ public final class Packets {
}
public static LoginAck read(Buf in) {
if (!in.readBoolean()) {
return refused(in.readString(MAX_TEXT));
boolean accepted = in.readBoolean();
String reason = in.readString(MAX_TEXT);
if (!accepted) {
return refused(reason);
}
return ok(in.readVarInt(), in.readVarInt());
}
@@ -0,0 +1,193 @@
package net.rustnotmc.tools;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.List;
import net.rustnotmc.protocol.Buf;
import net.rustnotmc.protocol.Frame;
import net.rustnotmc.protocol.FrameDecoder;
import net.rustnotmc.protocol.Packets;
/**
* A console client that performs the whole login and prints what happened.
*
* It exists because "the protocol round trips in a unit test" and "somebody can
* actually log in" are different claims. This is the smallest thing that makes
* the second one, and it needs nothing but the protocol jar.
*
* The password is read without echo and turned into a char array that is wiped
* as soon as the proof is computed. It never goes into a String, because a
* String is immutable and would sit in the heap until the process ends.
*/
public final class LoginClient {
private final Socket socket;
private final FrameDecoder decoder = new FrameDecoder();
private final InputStream in;
private final OutputStream out;
private final int timeoutMillis;
public LoginClient(String host, int port, int timeoutMillis) throws IOException {
this.timeoutMillis = timeoutMillis;
this.socket = new Socket();
this.socket.connect(new InetSocketAddress(host, port), timeoutMillis);
this.socket.setTcpNoDelay(true);
this.socket.setSoTimeout(timeoutMillis);
this.in = new BufferedInputStream(this.socket.getInputStream(), 8192);
this.out = this.socket.getOutputStream();
}
/** Sends Hello and returns the server's answer. */
public Packets.HelloAck hello(String account, String clientName) throws IOException {
Packets.Hello hello = new Packets.Hello(Frame.VERSION, account, clientName, 1);
send(Packets.HELLO, hello.write());
Frame frame = read();
require(frame, Packets.HELLO_ACK, "HELLO_ACK");
return Packets.HelloAck.read(new Buf(frame.body()));
}
/** Proves knowledge of the password and returns the verdict. */
public Packets.AuthAck auth(String account, char[] password,
byte[] salt, byte[] nonce) throws IOException {
byte[] proof = Packets.Auth.proofFor(account, password, salt, nonce);
Packets.Auth auth = new Packets.Auth(account, proof);
send(Packets.AUTH, auth.write());
Frame frame = read();
require(frame, Packets.AUTH_ACK, "AUTH_ACK");
return Packets.AuthAck.read(new Buf(frame.body()));
}
/** Redeems the token and returns the verdict. */
public Packets.LoginAck login(String account, String token) throws IOException {
Packets.Login login = new Packets.Login(account, token);
send(Packets.LOGIN, login.write());
Frame frame = read();
require(frame, Packets.LOGIN_ACK, "LOGIN_ACK");
return Packets.LoginAck.read(new Buf(frame.body()));
}
private void send(int packetId, Buf body) throws IOException {
sendPublic(packetId, body);
}
/** Exposed so the console front end can drive the exchange directly. */
public void sendPublic(int packetId, Buf body) throws IOException {
Frame frame = new Frame(packetId, body.toByteArray());
this.out.write(frame.encode().toByteArray());
this.out.flush();
}
/** Exposed so the console front end can read one reply directly. */
public Frame readPublic() throws IOException {
return read();
}
private Frame read() throws IOException {
while (true) {
byte[] chunk = new byte[8192];
int read = this.in.read(chunk);
if (read < 0) {
throw new IOException("server closed the connection");
}
List<Frame> frames = this.decoder.offer(chunk, 0, read);
if (!frames.isEmpty()) {
return frames.get(0);
}
}
}
private static void require(Frame frame, int wanted, String name) throws IOException {
if (frame.packetId() != wanted) {
throw new IOException("expected " + name + " but got packet id "
+ frame.packetId());
}
}
public void close() {
try {
this.socket.close();
} catch (IOException ignored) {
// nothing useful to do
}
}
/**
* Reads a password, preferring the console so it is not echoed.
*
* The caller supplies the reader. Two readers over one stream is a trap:
* the first one buffers whatever the pipe gave it, usually both lines, and
* the second then sees nothing at all and returns an empty password that
* looks like a wrong one.
*/
private static char[] readPassword(String prompt, java.io.BufferedReader shared) {
java.io.Console console = System.console();
if (console != null) {
System.out.print(prompt);
System.out.flush();
return console.readPassword();
}
System.out.print(prompt);
System.out.flush();
try {
String line = shared.readLine();
return line == null ? new char[0] : line.toCharArray();
} catch (java.io.IOException e) {
return new char[0];
}
}
public static void main(String[] args) throws Exception {
String host = args.length > 0 ? args[0] : "127.0.0.1";
int port = args.length > 1 ? Integer.parseInt(args[1]) : 25565;
System.out.println("connecting to " + host + ":" + port);
LoginClient client = new LoginClient(host, port, 10_000);
try {
java.io.BufferedReader line = new java.io.BufferedReader(
new java.io.InputStreamReader(System.in));
System.out.print("account : ");
System.out.flush();
String account = line.readLine();
char[] password = readPassword("password : ", line);
// the account has to be named before the handshake, because the
// salt in the reply is that account's salt and the client needs it
// to derive the proof
Packets.HelloAck hello = client.hello(account, "cli");
System.out.println("server : " + hello.serverName);
System.out.println("motd : " + hello.motd);
System.out.println("players : " + hello.onlinePlayers + "/" + hello.maxPlayers);
System.out.println("version : " + hello.protocolVersion);
byte[] proof = Packets.Auth.proofFor(account, password, hello.salt, hello.nonce);
java.util.Arrays.fill(password, '\0');
Packets.Auth request = new Packets.Auth(account, proof);
client.sendPublic(Packets.AUTH, request.write());
net.rustnotmc.protocol.Frame authFrame = client.readPublic();
Packets.AuthAck auth = Packets.AuthAck.read(new Buf(authFrame.body()));
if (!auth.accepted) {
System.out.println("refused : " + auth.reason);
return;
}
System.out.println("accepted : token until " + auth.expiresAt);
Packets.LoginAck login = client.login(account, auth.token);
if (!login.accepted) {
System.out.println("refused : " + login.reason);
return;
}
System.out.println("player id : " + login.playerId);
System.out.println("entity id : " + login.entityId);
System.out.println("logged in as " + account);
} finally {
client.close();
}
}
}