Серверная база со своим логином
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user