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

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

Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент
называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого
соединения, клиент тянет из пароля ключ через 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());
}