620f136361
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект.
364 lines
13 KiB
Java
364 lines
13 KiB
Java
package net.rustnotmc.protocol;
|
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* The packets that start a connection, and the table that maps them to ids.
|
|
*
|
|
* The ids are written down here rather than generated, because they end up in
|
|
* every capture and every log line forever. Renumbering one is a protocol
|
|
* change and should look like a deliberate act, not a side effect of moving a
|
|
* class around.
|
|
*
|
|
* The order of a connection is fixed and short:
|
|
*
|
|
* <pre>
|
|
* client -> Hello protocol version, what the client is
|
|
* server -> HelloAck accepted version, server name, motd, online count
|
|
* client -> Auth account and proof
|
|
* server -> AuthAck session token, or a reason it was refused
|
|
* client -> Login the session token
|
|
* server -> LoginAck player id and entity id, or a reason
|
|
* either -> Disconnect a human readable reason
|
|
* </pre>
|
|
*/
|
|
public final class Packets {
|
|
|
|
private Packets() {
|
|
}
|
|
|
|
/** Longest a name, motd or reason may be, in bytes. */
|
|
public static final int MAX_TEXT = 512;
|
|
|
|
/** 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;
|
|
|
|
public static final int HELLO = 0x01;
|
|
public static final int HELLO_ACK = 0x02;
|
|
public static final int AUTH = 0x03;
|
|
public static final int AUTH_ACK = 0x04;
|
|
public static final int LOGIN = 0x05;
|
|
public static final int LOGIN_ACK = 0x06;
|
|
public static final int DISCONNECT = 0x7F;
|
|
|
|
/** 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 account, String clientName, int clientVersion) {
|
|
this.protocolVersion = protocolVersion;
|
|
this.account = account;
|
|
this.clientName = clientName;
|
|
this.clientVersion = clientVersion;
|
|
}
|
|
|
|
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.readString(MAX_TEXT), in.readVarInt());
|
|
}
|
|
}
|
|
|
|
/** The server accepting, and describing itself. */
|
|
public static final class HelloAck {
|
|
public final int protocolVersion;
|
|
public final String serverName;
|
|
public final String motd;
|
|
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, 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() {
|
|
Buf buf = new Buf();
|
|
buf.writeVarInt(this.protocolVersion);
|
|
buf.writeString(this.serverName);
|
|
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.readBytes(CHALLENGE_BYTES), in.readBytes(SALT_BYTES));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 byte[] proof;
|
|
|
|
public Auth(String account, byte[] proof) {
|
|
this.account = account;
|
|
this.proof = proof;
|
|
}
|
|
|
|
public Buf write() {
|
|
Buf buf = new Buf();
|
|
buf.writeString(this.account);
|
|
buf.writeBytes(this.proof);
|
|
return buf;
|
|
}
|
|
|
|
public static Auth read(Buf in) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** Whether the account was accepted, and on what terms. */
|
|
public static final class AuthAck {
|
|
public final boolean accepted;
|
|
public final String reason;
|
|
public final String token;
|
|
public final long expiresAt;
|
|
|
|
private AuthAck(boolean accepted, String reason, String token, long expiresAt) {
|
|
this.accepted = accepted;
|
|
this.reason = reason;
|
|
this.token = token;
|
|
this.expiresAt = expiresAt;
|
|
}
|
|
|
|
public static AuthAck ok(String token, long expiresAt) {
|
|
return new AuthAck(true, "", token, expiresAt);
|
|
}
|
|
|
|
public static AuthAck refused(String reason) {
|
|
return new AuthAck(false, reason, "", 0L);
|
|
}
|
|
|
|
public Buf write() {
|
|
Buf buf = new Buf();
|
|
buf.writeBoolean(this.accepted);
|
|
buf.writeString(this.reason);
|
|
if (this.accepted) {
|
|
buf.writeString(this.token);
|
|
buf.writeLong(this.expiresAt);
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
public static AuthAck read(Buf in) {
|
|
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());
|
|
}
|
|
}
|
|
|
|
/** The client redeeming the token it was just handed. */
|
|
public static final class Login {
|
|
public final String account;
|
|
public final String token;
|
|
|
|
public Login(String account, String token) {
|
|
this.account = account;
|
|
this.token = token;
|
|
}
|
|
|
|
public Buf write() {
|
|
Buf buf = new Buf();
|
|
buf.writeString(this.account);
|
|
buf.writeString(this.token);
|
|
return buf;
|
|
}
|
|
|
|
public static Login read(Buf in) {
|
|
return new Login(in.readString(MAX_TEXT), in.readString(MAX_TEXT));
|
|
}
|
|
}
|
|
|
|
/** Whether the player is in. */
|
|
public static final class LoginAck {
|
|
public final boolean accepted;
|
|
public final String reason;
|
|
public final int playerId;
|
|
public final int entityId;
|
|
|
|
private LoginAck(boolean accepted, String reason, int playerId, int entityId) {
|
|
this.accepted = accepted;
|
|
this.reason = reason;
|
|
this.playerId = playerId;
|
|
this.entityId = entityId;
|
|
}
|
|
|
|
public static LoginAck ok(int playerId, int entityId) {
|
|
return new LoginAck(true, "", playerId, entityId);
|
|
}
|
|
|
|
public static LoginAck refused(String reason) {
|
|
return new LoginAck(false, reason, -1, -1);
|
|
}
|
|
|
|
public Buf write() {
|
|
Buf buf = new Buf();
|
|
buf.writeBoolean(this.accepted);
|
|
buf.writeString(this.reason);
|
|
if (this.accepted) {
|
|
buf.writeVarInt(this.playerId);
|
|
buf.writeVarInt(this.entityId);
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
public static LoginAck read(Buf in) {
|
|
boolean accepted = in.readBoolean();
|
|
String reason = in.readString(MAX_TEXT);
|
|
if (!accepted) {
|
|
return refused(reason);
|
|
}
|
|
return ok(in.readVarInt(), in.readVarInt());
|
|
}
|
|
}
|
|
|
|
/** A reason, said out loud, before the connection goes away. */
|
|
public static final class Disconnect {
|
|
public final String reason;
|
|
|
|
public Disconnect(String reason) {
|
|
this.reason = reason;
|
|
}
|
|
|
|
public Buf write() {
|
|
return new Buf().writeString(this.reason);
|
|
}
|
|
|
|
public static Disconnect read(Buf in) {
|
|
return new Disconnect(in.readString(MAX_TEXT));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every packet id this build knows, and what it decodes into.
|
|
*
|
|
* Written as plain data so that adding a packet is one line and an unknown
|
|
* id is a loud error instead of a silent skip.
|
|
*/
|
|
public static Map<Integer, String> names() {
|
|
Map<Integer, String> map = new LinkedHashMap<Integer, String>();
|
|
map.put(Integer.valueOf(HELLO), "Hello");
|
|
map.put(Integer.valueOf(HELLO_ACK), "HelloAck");
|
|
map.put(Integer.valueOf(AUTH), "Auth");
|
|
map.put(Integer.valueOf(AUTH_ACK), "AuthAck");
|
|
map.put(Integer.valueOf(LOGIN), "Login");
|
|
map.put(Integer.valueOf(LOGIN_ACK), "LoginAck");
|
|
map.put(Integer.valueOf(DISCONNECT), "Disconnect");
|
|
return map;
|
|
}
|
|
} |