From 620f1363611bcae99467cdc3139851f340371559 Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:45:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=B1=D0=B0=D0=B7=D0=B0=20=D1=81=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B8=D0=BC=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект. --- rsx-auth/scripts/build.ps1 | 41 ++ .../java/net/rustnotmc/auth/AccountFile.java | 88 ++++ .../java/net/rustnotmc/auth/AccountStore.java | 167 ++++++++ .../net/rustnotmc/auth/LoginThrottle.java | 151 +++++++ .../java/net/rustnotmc/auth/Passwords.java | 163 +++++++ .../main/java/net/rustnotmc/auth/Secure.java | 52 +++ .../java/net/rustnotmc/auth/TokenService.java | 128 ++++++ .../java/net/rustnotmc/protocol/Packets.java | 126 +++++- .../java/net/rustnotmc/tools/LoginClient.java | 193 +++++++++ rsx-server-core/scripts/build.ps1 | 56 +++ rsx-server-core/scripts/run-server.ps1 | 56 +++ rsx-server-core/scripts/test.ps1 | 55 +++ .../net/rustnotmc/server/ClientSession.java | 404 ++++++++++++++++++ .../java/net/rustnotmc/server/Server.java | 357 ++++++++++++++++ .../java/net/rustnotmc/server/ServerMain.java | 124 ++++++ .../net/rustnotmc/server/SessionState.java | 41 ++ .../java/net/rustnotmc/server/ServerTest.java | 388 +++++++++++++++++ 17 files changed, 2576 insertions(+), 14 deletions(-) create mode 100644 rsx-auth/scripts/build.ps1 create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/AccountFile.java create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/AccountStore.java create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/LoginThrottle.java create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/Passwords.java create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/Secure.java create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/TokenService.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/tools/LoginClient.java create mode 100644 rsx-server-core/scripts/build.ps1 create mode 100644 rsx-server-core/scripts/run-server.ps1 create mode 100644 rsx-server-core/scripts/test.ps1 create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/Server.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/SessionState.java create mode 100644 rsx-server-core/src/test/java/net/rustnotmc/server/ServerTest.java diff --git a/rsx-auth/scripts/build.ps1 b/rsx-auth/scripts/build.ps1 new file mode 100644 index 0000000..335b4a0 --- /dev/null +++ b/rsx-auth/scripts/build.ps1 @@ -0,0 +1,41 @@ +# Builds rsx-auth into dist\. Only a JDK, like everything else here. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { Write-Host ' No sources found.' -ForegroundColor Red; exit 1 } + +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Auth failed to compile.' -ForegroundColor Red; exit 1 } +Say "auth compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC auth +Implementation-Version: 0.1.0 +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-auth.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { Write-Host ' Could not package the jar.' -ForegroundColor Red; exit 1 } +Say "rsx-auth.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/AccountFile.java b/rsx-auth/src/main/java/net/rustnotmc/auth/AccountFile.java new file mode 100644 index 0000000..6e6ff9c --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/AccountFile.java @@ -0,0 +1,88 @@ +package net.rustnotmc.auth; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Reads and writes the account file. + * + * Saving goes to a temporary file and is then moved into place. Writing the + * real file directly means a crash halfway through leaves a server with half + * its accounts and no way to know which half, and the damage is only discovered + * the next time somebody tries to log in. + */ +public final class AccountFile { + + /** Format marker, so a future format can be told apart from this one. */ + private static final String HEADER = "# rsx-accounts v1"; + + private AccountFile() { + } + + public static void load(Path path, AccountStore store) throws IOException { + if (!Files.exists(path)) { + Files.createDirectories(path.toAbsolutePath().getParent()); + Files.createFile(path); + return; + } + BufferedReader reader = new BufferedReader( + new InputStreamReader(Files.newInputStream(path), StandardCharsets.UTF_8)); + try { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] parts = line.split("\t"); + if (parts.length < 4) { + throw new IOException("malformed account file at line " + lineNumber); + } + long created; + long last; + try { + created = Long.parseLong(parts[2]); + last = Long.parseLong(parts[3]); + } catch (NumberFormatException e) { + throw new IOException("bad timestamp at line " + lineNumber); + } + store.restore(new AccountStore.Account(parts[0], parts[1], created, last)); + } + } finally { + reader.close(); + } + } + + public static void save(Path path, AccountStore store) throws IOException { + Path directory = path.toAbsolutePath().getParent(); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, "accounts", ".tmp"); + Writer writer = new OutputStreamWriter( + Files.newOutputStream(temporary), StandardCharsets.UTF_8); + try { + writer.write(HEADER); + writer.write('\n'); + for (AccountStore.Account account : store.all()) { + writer.write(account.encode()); + writer.write('\n'); + } + } finally { + writer.close(); + } + try { + Files.move(temporary, path, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } +} \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/AccountStore.java b/rsx-auth/src/main/java/net/rustnotmc/auth/AccountStore.java new file mode 100644 index 0000000..c3881df --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/AccountStore.java @@ -0,0 +1,167 @@ +package net.rustnotmc.auth; + +import java.util.Base64; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The accounts, and where they live. + * + * Names are unique case insensitively, the way players expect: {@code Player} + * and {@code player} are one account, and creating the second one is refused + * rather than silently producing two players who cannot tell which is theirs. + * + * The file format is one account per line, written atomically. A server that + * is killed halfway through saving should come back with the accounts it had, + * not with half of them. + */ +public final class AccountStore { + + /** One account. */ + public static final class Account { + + public final String name; + public final String passwordRecord; + public final long createdAt; + public final long lastLogin; + + public Account(String name, String passwordRecord, long createdAt, long lastLogin) { + this.name = name; + this.passwordRecord = passwordRecord; + this.createdAt = createdAt; + this.lastLogin = lastLogin; + } + + public Passwords.Record decode() { + return Passwords.Record.decode(this.passwordRecord); + } + + public String encode() { + return this.name + "\t" + this.passwordRecord + "\t" + + this.createdAt + "\t" + this.lastLogin; + } + } + + /** Raised when an account cannot be created or found. */ + public static final class AccountException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AccountException(String message) { + super(message); + } + } + + private final Map byName = new ConcurrentHashMap(); + private final Map canonicalByKey = new ConcurrentHashMap(); + + /** + * Creates an account. + * + * @throws AccountException if the name is taken, malformed, or the password + * does not meet the policy + */ + public Account create(String name, char[] password, long now) { + String cleaned = name == null ? "" : name.trim(); + checkName(cleaned); + if (!Passwords.acceptable(password)) { + throw new AccountException("password " + Passwords.policy()); + } + String key = key(cleaned); + if (this.canonicalByKey.containsKey(key)) { + throw new AccountException("name " + cleaned + " is taken"); + } + Account account = new Account(cleaned, + Passwords.create(password).encode(), now, 0L); + this.byName.put(cleaned, account); + this.canonicalByKey.put(key, cleaned); + return account; + } + + /** Registers an account that already has a record, used when loading. */ + public void restore(Account account) { + String key = key(account.name); + this.byName.put(account.name, account); + this.canonicalByKey.put(key, account.name); + } + + /** The account under this name, in whatever case it was registered. */ + public Account find(String name) { + if (name == null) { + return null; + } + // the canonical name is missing for an account that was never created, + // and ConcurrentHashMap rejects a null key rather than answering + String canonical = this.canonicalByKey.get(key(name)); + if (canonical == null) { + return null; + } + return this.byName.get(canonical); + } + + public boolean exists(String name) { + return find(name) != null; + } + + /** Notes that an account was used, so the file records it next save. */ + public void markLogin(String name, long now) { + Account existing = find(name); + if (existing == null) { + return; + } + Account updated = new Account(existing.name, existing.passwordRecord, + existing.createdAt, now); + this.byName.put(existing.name, updated); + } + + /** + * Replaces a record, which is how a login upgrades an old iteration count + * without the player having to reset their password. + */ + public void replaceRecord(String name, Passwords.Record record) { + Account existing = find(name); + if (existing == null) { + return; + } + Account updated = new Account(existing.name, record.encode(), + existing.createdAt, existing.lastLogin); + this.byName.put(existing.name, updated); + } + + public Collection all() { + return new LinkedHashMap(this.byName).values(); + } + + public int size() { + return this.byName.size(); + } + + public static void checkName(String name) { + if (name.isEmpty()) { + throw new AccountException("name is empty"); + } + if (name.length() < 3 || name.length() > 16) { + throw new AccountException("name must be 3 to 16 characters"); + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '_'; + if (!ok) { + throw new AccountException( + "name may use letters, digits and underscore only"); + } + } + } + + private static String key(String name) { + return name.trim().toLowerCase(Locale.ROOT); + } + + static String encode64(byte[] value) { + return Base64.getEncoder().withoutPadding().encodeToString(value); + } +} \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/LoginThrottle.java b/rsx-auth/src/main/java/net/rustnotmc/auth/LoginThrottle.java new file mode 100644 index 0000000..51d2eaf --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/LoginThrottle.java @@ -0,0 +1,151 @@ +package net.rustnotmc.auth; + +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Who may try to log in, how often, and for how long. + * + * A password store without a throttle is a password store that can be emptied + * offline at whatever rate the network allows, and PBKDF2's whole purpose is to + * make each guess expensive. This makes the guesses expensive to start. + * + * Two independent buckets, because one alone is not enough: per account stops + * one attacker grinding a single name, per address stops a single attacker + * grinding many names. + */ +public final class LoginThrottle { + + /** One subject's record. */ + private static final class Bucket { + + int failures; + long lockedUntil; + long firstFailureAt; + + /** Seconds to wait before the next try is allowed. */ + long backoffSeconds(long now) { + if (now < this.lockedUntil) { + return (this.lockedUntil - now + 999L) / 1000L; + } + return 0L; + } + + void fail(long now, long baseMillis, long capMillis) { + this.failures++; + if (this.firstFailureAt == 0L) { + this.firstFailureAt = now; + } + long wait = baseMillis; + for (int i = 1; i < this.failures; i++) { + wait *= 2L; + if (wait >= capMillis) { + wait = capMillis; + break; + } + } + this.lockedUntil = now + wait; + } + + void succeed() { + this.failures = 0; + this.lockedUntil = 0L; + this.firstFailureAt = 0L; + } + } + + private final Map byAccount = new ConcurrentHashMap(); + private final Map byAddress = new ConcurrentHashMap(); + private final long baseMillis; + private final long capMillis; + private final Clock clock; + + public LoginThrottle() { + this(500L, 60_000L); + } + + public LoginThrottle(long baseMillis, long capMillis) { + this(baseMillis, capMillis, System::currentTimeMillis); + } + + /** The clock is injected so the backoff can be tested without sleeping. */ + public LoginThrottle(long baseMillis, long capMillis, Clock clock) { + this.baseMillis = baseMillis; + this.capMillis = capMillis; + this.clock = clock; + } + + /** Where time comes from. */ + public interface Clock { + long now(); + } + /** How long until this subject may try again, in seconds. Zero means now. */ + public long waitSeconds(String account, String address) { + long now = this.clock.now(); + long wait = 0L; + Bucket accountBucket = this.byAccount.get(key(account)); + if (accountBucket != null) { + wait = Math.max(wait, accountBucket.backoffSeconds(now)); + } + Bucket addressBucket = this.byAddress.get(key(address)); + if (addressBucket != null) { + wait = Math.max(wait, addressBucket.backoffSeconds(now)); + } + return wait; + } + + public boolean allowed(String account, String address) { + return waitSeconds(account, address) <= 0L; + } + + /** Records a failed attempt against both buckets. */ + public void failed(String account, String address) { + long now = this.clock.now(); + bucket(this.byAccount, key(account)).fail(now, this.baseMillis, this.capMillis); + bucket(this.byAddress, key(address)).fail(now, this.baseMillis, this.capMillis); + } + + /** Records a success, which clears the account side but not the address. */ + public void succeeded(String account) { + Bucket bucket = this.byAccount.get(key(account)); + if (bucket != null) { + bucket.succeed(); + } + } + + /** Forgets everything, for an operator who has dealt with the problem. */ + public void clear() { + this.byAccount.clear(); + this.byAddress.clear(); + } + + public int tracked() { + return this.byAccount.size() + this.byAddress.size(); + } + + private static Bucket bucket(Map map, String key) { + Bucket existing = map.get(key); + if (existing != null) { + return existing; + } + Bucket created = new Bucket(); + Bucket raced = map.putIfAbsent(key, created); + return raced != null ? raced : created; + } + + private static String key(String value) { + if (value == null) { + return ""; + } + // accounts are compared case insensitively everywhere else, so the + // throttle keys must be too or a player gets a fresh budget by typing + // their name in different case + return value.trim().toLowerCase(Locale.ROOT); + } + + static String encode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } +} \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/Passwords.java b/rsx-auth/src/main/java/net/rustnotmc/auth/Passwords.java new file mode 100644 index 0000000..fc80709 --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/Passwords.java @@ -0,0 +1,163 @@ +package net.rustnotmc.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; +import java.util.Base64; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** + * Turns a password into what is actually stored. + * + * The password is stretched with PBKDF2-HMAC-SHA256 and a per-account salt. The + * server keeps the stretched form and cannot read the password back out of it, + * so a leaked account file does not hand over every player's login. + * + * Iteration count is stored with the hash, never assumed. A count that only + * exists in this source becomes a compatibility problem the moment it needs to + * change, and an account that cannot be checked is an account nobody can use. + */ +public final class Passwords { + + /** The current default. Reasonable for 2026 on a server CPU. */ + public static final int DEFAULT_ITERATIONS = 210_000; + + private static final int SALT_BYTES = 16; + private static final int HASH_BITS = 256; + + /** A hash record: the salt, the work factor and the result. */ + public static final class Record { + + public final byte[] salt; + public final int iterations; + public final byte[] hash; + + public Record(byte[] salt, int iterations, byte[] hash) { + this.salt = salt; + this.iterations = iterations; + this.hash = hash; + } + + /** + * Serialised as one line: iterations, then salt, then hash, all + * base64. Three fields on one line with a fixed order is easy to read in + * a diff and easy to parse without a dependency. + */ + public String encode() { + Base64.Encoder enc = Base64.getEncoder().withoutPadding(); + return "pbkdf2-sha256$" + this.iterations + "$" + + enc.encodeToString(this.salt) + "$" + + enc.encodeToString(this.hash); + } + + public static Record decode(String line) { + if (line == null) { + throw new IllegalArgumentException("no record"); + } + String[] parts = line.trim().split("\\$"); + if (parts.length != 4 || !"pbkdf2-sha256".equals(parts[0])) { + throw new IllegalArgumentException("unrecognised password record"); + } + Base64.Decoder dec = Base64.getDecoder(); + return new Record( + dec.decode(parts[2]), + Integer.parseInt(parts[1]), + dec.decode(parts[3])); + } + } + + private Passwords() { + } + + public static Record create(char[] password) { + return create(password, DEFAULT_ITERATIONS); + } + + public static Record create(char[] password, int iterations) { + byte[] salt = Secure.bytes(SALT_BYTES); + return new Record(salt, iterations, stretch(password, salt, iterations)); + } + + /** + * Checks a password against a stored record. + * + * The work is always done, even when the record is missing, so the time + * taken does not say whether the account exists. + */ + public static boolean verify(char[] password, Record record) { + if (record == null) { + // burn roughly one verification so an absent account and a wrong + // password take the same time + stretch(password, new byte[SALT_BYTES], DEFAULT_ITERATIONS); + return false; + } + byte[] candidate = stretch(password, record.salt, record.iterations); + boolean ok = Secure.same(candidate, record.hash); + Arrays.fill(candidate, (byte) 0); + return ok; + } + + private static byte[] stretch(char[] password, byte[] salt, int iterations) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, HASH_BITS); + try { + return factory.generateSecret(spec).getEncoded(); + } finally { + // a PBEKeySpec keeps a copy of the password in a char array that + // clearPassword is the only way to zero + spec.clearPassword(); + } + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException("PBKDF2WithHmacSHA256 is unavailable", e); + } + } + + /** + * True when a stored record was made with weaker work than we now want. + * + * Checked at login so records upgrade themselves the next time their owner + * types the password, instead of every account being re-hashed by hand. + */ + public static boolean needsRehash(Record record) { + return record == null || record.iterations < DEFAULT_ITERATIONS + || record.hash.length * 8 != HASH_BITS; + } + + /** A human-readable note about the policy, for a registration screen. */ + public static String policy() { + return "at least 8 characters"; + } + + /** Rejects the passwords that make an account worthless to its owner. */ + public static boolean acceptable(char[] password) { + return password != null && password.length >= 8; + } + + /** Bytes that must not linger in memory longer than they are needed. */ + public static void wipe(char[] password) { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + + /** Convenience for tests and tools that only have a String. */ + public static char[] toChars(String password) { + return password == null ? null : password.toCharArray(); + } + + static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/Secure.java b/rsx-auth/src/main/java/net/rustnotmc/auth/Secure.java new file mode 100644 index 0000000..315263f --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/Secure.java @@ -0,0 +1,52 @@ +package net.rustnotmc.auth; + +import java.security.SecureRandom; + +/** + * The one source of unpredictable bytes in the project. + * + * {@link java.util.Random} is seeded from the clock, so anyone who can guess + * when a process started can predict every value it will ever produce. That is + * fine for a shuffle and fatal for a session token, a salt and a challenge. + */ +public final class Secure { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private Secure() { + } + + public static byte[] bytes(int count) { + if (count < 0) { + throw new IllegalArgumentException("negative length: " + count); + } + byte[] value = new byte[count]; + RANDOM.nextBytes(value); + return value; + } + + public static int nextInt(int bound) { + if (bound <= 0) { + throw new IllegalArgumentException("bound must be positive: " + bound); + } + return RANDOM.nextInt(bound); + } + + /** + * Compares two byte arrays without a shortcut for the first mismatch. + * + * An array comparison that returns as soon as it sees a difference reports + * how far it got, and an attacker can use that to learn a token one byte at + * a time by measuring how long the answer took. + */ + public static boolean same(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; + } +} \ No newline at end of file diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/TokenService.java b/rsx-auth/src/main/java/net/rustnotmc/auth/TokenService.java new file mode 100644 index 0000000..30a7583 --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/TokenService.java @@ -0,0 +1,128 @@ +package net.rustnotmc.auth; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Session tokens, and the rules about when one is worth something. + * + * A token is 32 random bytes, compared in constant time, and carries the + * account it belongs to and the moment it stops working. Logging out forgets + * it immediately rather than waiting for it to expire, because a player who + * clicked logout on a shared computer means it. + * + * Tokens live in memory only. A token that was written to disk would outlive + * the process that issued it, and a stolen server data directory would then be + * a stolen set of live sessions. + */ +public final class TokenService { + + /** How long a fresh token lasts. */ + public static final long DEFAULT_LIFETIME_MILLIS = 12L * 60L * 60L * 1000L; + + /** One issued token. */ + public static final class Token { + public final String value; + public final String account; + public final long issuedAt; + public final long expiresAt; + + Token(String value, String account, long issuedAt, long expiresAt) { + this.value = value; + this.account = account; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public boolean expired(long now) { + return now >= this.expiresAt; + } + } + + private final Map live = new ConcurrentHashMap(); + private final long lifetimeMillis; + private final LoginThrottle.Clock clock; + + public TokenService() { + this(DEFAULT_LIFETIME_MILLIS, new LoginThrottle.Clock() { + public long now() { + return System.currentTimeMillis(); + } + }); + } + + public TokenService(long lifetimeMillis, LoginThrottle.Clock clock) { + this.lifetimeMillis = lifetimeMillis; + this.clock = clock; + } + + /** Issues a token for an account, valid for the configured lifetime. */ + public Token issue(String account) { + long now = this.clock.now(); + Token token = new Token(LoginThrottle.encode(Secure.bytes(32)), + account, now, now + this.lifetimeMillis); + this.live.put(token.value, token); + return token; + } + + /** The token behind a value, or null when it is unknown or expired. */ + public Token check(String value) { + if (value == null || value.isEmpty()) { + return null; + } + Token token = this.live.get(value); + if (token == null) { + return null; + } + if (token.expired(this.clock.now())) { + this.live.remove(value); + return null; + } + return token; + } + + public boolean valid(String value, String account) { + Token token = check(value); + return token != null && token.account.equalsIgnoreCase(account); + } + + /** Forgets one token, so it stops being usable immediately. */ + public void revoke(String value) { + if (value != null) { + this.live.remove(value); + } + } + + /** Forgets every token belonging to an account, for a password change. */ + public int revokeAll(String account) { + int gone = 0; + for (Map.Entry entry : this.live.entrySet()) { + if (entry.getValue().account.equalsIgnoreCase(account)) { + this.live.remove(entry.getKey()); + gone++; + } + } + return gone; + } + + /** Drops tokens that have run out, so the map does not grow forever. */ + public int sweep() { + long now = this.clock.now(); + int gone = 0; + for (Map.Entry entry : this.live.entrySet()) { + if (entry.getValue().expired(now)) { + this.live.remove(entry.getKey()); + gone++; + } + } + return gone; + } + + public int live() { + return this.live.size(); + } + + public long lifetimeMillis() { + return this.lifetimeMillis; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index b5a8947..b73b1b7 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -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()); } diff --git a/rsx-protocol/src/main/java/net/rustnotmc/tools/LoginClient.java b/rsx-protocol/src/main/java/net/rustnotmc/tools/LoginClient.java new file mode 100644 index 0000000..5fa45a0 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/tools/LoginClient.java @@ -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 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(); + } + } +} \ No newline at end of file diff --git a/rsx-server-core/scripts/build.ps1 b/rsx-server-core/scripts/build.ps1 new file mode 100644 index 0000000..4f4a377 --- /dev/null +++ b/rsx-server-core/scripts/build.ps1 @@ -0,0 +1,56 @@ +# Builds rsx-server-core, which needs the other two on its classpath. +# +# ASCII only, for the same reason as the other scripts. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Workspace = Split-Path -Parent $Repo +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +$Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' +$Auth = Join-Path $Workspace 'rsx-auth\build\classes' +foreach ($dep in @($Protocol, $Auth)) { + if (-not (Test-Path $dep)) { + Write-Host " Missing dependency: $dep" -ForegroundColor Red + Write-Host ' Build rsx-protocol and rsx-auth first.' -ForegroundColor Red + exit 1 + } +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { Write-Host ' No sources found.' -ForegroundColor Red; exit 1 } + +$cp = "$Protocol;$Auth" +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $cp -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Server failed to compile.' -ForegroundColor Red; exit 1 } +Say "server compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC server +Implementation-Version: 0.1.0 +Class-Path: rsx-protocol.jar rsx-auth.jar +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-server-core.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { Write-Host ' Could not package the jar.' -ForegroundColor Red; exit 1 } +Say "rsx-server-core.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" \ No newline at end of file diff --git a/rsx-server-core/scripts/run-server.ps1 b/rsx-server-core/scripts/run-server.ps1 new file mode 100644 index 0000000..b1d61be --- /dev/null +++ b/rsx-server-core/scripts/run-server.ps1 @@ -0,0 +1,56 @@ +# Starts the server. Needs rsx-protocol and rsx-auth built first. +# +# ASCII only, for the same reason as the other scripts. +# +# run-server.ps1 [-Port 25565] [-Accounts path] [-Capacity 200] + +param( + [int]$Port = 25565, + [string]$Accounts = '', + [int]$Capacity = 200, + [string]$Name = 'RustNotMC', + [string]$Motd = 'a survival game', + [string]$Register = '', + [string]$Password = '' +) + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Workspace = Split-Path -Parent $Repo + +$Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' +$Auth = Join-Path $Workspace 'rsx-auth\build\classes' +$Server = Join-Path $Repo 'build\classes' +foreach ($dep in @($Protocol, $Auth, $Server)) { + if (-not (Test-Path $dep)) { + Write-Host " Missing: $dep" -ForegroundColor Red + Write-Host ' Build rsx-protocol, rsx-auth and rsx-server-core first.' -ForegroundColor Red + exit 1 + } +} + +if (-not $Accounts) { $Accounts = Join-Path $Repo 'accounts.txt' } + +if ($Register) { + & java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain ` + --accounts $Accounts --register $Register --password $Password + exit $LASTEXITCODE +} + +Write-Host ' rsx-protocol ' -NoNewline +Write-Host (Split-Path -Leaf $Protocol) -ForegroundColor DarkGray +Write-Host ' rsx-auth ' -NoNewline +Write-Host (Split-Path -Leaf $Auth) -ForegroundColor DarkGray +Write-Host ' rsx-server-core ' -NoNewline +Write-Host (Split-Path -Leaf $Server) -ForegroundColor DarkGray +Write-Host '' +Write-Host " accounts : $Accounts" +Write-Host " port : $Port" +Write-Host " capacity : $Capacity" +Write-Host '' + +& java -cp "$Protocol;$Auth;$Server" net.rustnotmc.server.ServerMain ` + --port $Port --accounts $Accounts --capacity $Capacity --name $Name --motd $Motd +exit $LASTEXITCODE \ No newline at end of file diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 new file mode 100644 index 0000000..abcb80c --- /dev/null +++ b/rsx-server-core/scripts/test.ps1 @@ -0,0 +1,55 @@ +# End to end test: a real server, a real socket, a real login. +# +# This is the test that matters for the login path. A unit test can show that a +# hash matches itself; only this shows that somebody can actually register, +# connect, prove a password, get a token, redeem it and arrive as a player, and +# that a wrong password does not. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Workspace = Split-Path -Parent $Repo +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +$Protocol = Join-Path $Workspace 'rsx-protocol\build\classes' +$Auth = Join-Path $Workspace 'rsx-auth\build\classes' +foreach ($dep in @($Protocol, $Auth)) { + if (-not (Test-Path $dep)) { + Write-Host " Missing dependency: $dep" -ForegroundColor Red + Write-Host ' Build rsx-protocol and rsx-auth first.' -ForegroundColor Red + exit 1 + } +} + +New-Item -ItemType Directory -Force -Path $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +$cp = "$Protocol;$Auth" +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $cp -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } + +$testOut = Join-Path $Out 'test-classes' +Remove-Item $testOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $testOut | Out-Null +$testFiles = @(Get-ChildItem (Join-Path $Repo 'src\test\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp "$cp;$mainOut" -d $testOut $testFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Test failed to compile.' -ForegroundColor Red; exit 1 } +Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" + +& java -cp "$cp;$mainOut;$testOut" net.rustnotmc.server.ServerTest (Join-Path $Out 'selftest') +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host ' SERVER TESTS FAILED' -ForegroundColor Red + exit 1 +} + +Write-Host '' +Write-Host ' Server tests passed.' -ForegroundColor Green \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java new file mode 100644 index 0000000..2642cdf --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -0,0 +1,404 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import net.rustnotmc.protocol.Buf; +import net.rustnotmc.protocol.Frame; +import net.rustnotmc.protocol.FrameDecoder; +import net.rustnotmc.protocol.ProtocolException; + +/** + * One connection, and the handshake that has to finish before it means + * anything. + * + * Each connection is a thread that blocks on its own socket. That is not the + * shape a two-hundred-player server wants in the long run, but it is the shape + * that is obviously correct, and the decoder underneath already produces whole + * frames from arbitrary chunking, so moving to a selector later touches this + * class and nothing below it. + * + * Every state transition is bounded by a deadline. A peer that connects and + * then says nothing must not be able to sit in the table holding memory. + */ +public final class ClientSession implements Runnable { + + /** Longest a connection may spend before HELLO. */ + public static final long CONNECT_TIMEOUT_MILLIS = 10_000L; + + /** Longest a connection may spend in the handshake in total. */ + public static final long HANDSHAKE_TIMEOUT_MILLIS = 20_000L; + + private final Socket socket; + private final Server server; + private final FrameDecoder decoder; + private final long id; + private final String address; + + private final AtomicBoolean closed = new AtomicBoolean(); + private final BlockingQueue inbox = new LinkedBlockingQueue(); + + private volatile SessionState state = SessionState.CONNECTING; + private volatile long deadline; + private volatile String account; + private volatile String token; + private volatile int playerId = -1; + private volatile int entityId = -1; + private volatile String lastError; + + private byte[] nonce; + private byte[] salt; + + ClientSession(Socket socket, Server server, long id) { + this.socket = socket; + this.server = server; + this.decoder = new FrameDecoder(); + this.id = id; + InetSocketAddress remote = (InetSocketAddress) socket.getRemoteSocketAddress(); + this.address = remote == null ? "unknown" : remote.getAddress().getHostAddress(); + this.deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MILLIS; + } + + // ------------------------------------------------------------- accessors + + public SessionState state() { + return this.state; + } + + public String address() { + return this.address; + } + + /** + * A connection's own identity. + * + * Not the address: every player behind one NAT, and every player in a test + * on localhost, arrives from the same one, and keying sessions by address + * silently evicts all but the last of them. + */ + public long id() { + return this.id; + } + + public String account() { + return this.account; + } + + public String token() { + return this.token; + } + + public int playerId() { + return this.playerId; + } + + public int entityId() { + return this.entityId; + } + + public String lastError() { + return this.lastError; + } + + public boolean isClosed() { + return this.closed.get(); + } + + void touch(long limit) { + this.deadline = System.currentTimeMillis() + limit; + } + + // ----------------------------------------------------------------- flow + + @Override + public void run() { + this.server.opened(this); + try { + this.socket.setTcpNoDelay(true); + this.socket.setSoTimeout(1); + while (!this.closed.get()) { + step(); + } + } catch (IOException e) { + if (!this.closed.get()) { + this.lastError = String.valueOf(e.getMessage()); + } + } catch (ProtocolException e) { + this.lastError = e.getMessage(); + } finally { + this.shutdown(); + } + } + + private void step() throws IOException { + if (expired()) { + throw new ProtocolException("handshake took too long"); + } + drainInbox(); + try { + byte[] chunk = new byte[8192]; + int read = this.socket.getInputStream().read(chunk); + if (read < 0) { + this.lastError = "peer closed the connection"; + close(); + return; + } + this.feed(chunk, 0, read); + } catch (SocketTimeoutException nothingYet) { + // expected: the loop wakes regularly to check the deadline + } catch (SocketException gone) { + this.lastError = String.valueOf(gone.getMessage()); + close(); + } + } + + void feed(byte[] chunk, int offset, int length) throws IOException { + for (Frame frame : this.decoder.offer(chunk, offset, length)) { + this.inbox.add(frame); + } + drainInbox(); + } + + private void drainInbox() { + Frame frame; + while ((frame = this.inbox.poll()) != null) { + if (!handle(frame)) { + return; + } + } + } + + private boolean handle(Frame frame) { + try { + switch (frame.packetId()) { + case net.rustnotmc.protocol.Packets.HELLO: + onHello(frame); + return true; + case net.rustnotmc.protocol.Packets.AUTH: + onAuth(frame); + return true; + case net.rustnotmc.protocol.Packets.LOGIN: + onLogin(frame); + return true; + case net.rustnotmc.protocol.Packets.DISCONNECT: + this.lastError = "peer said goodbye"; + close(); + return false; + default: + // an unknown id is not an error: new clients may talk to old + // servers, and the id space grows + return true; + } + } catch (ProtocolException e) { + this.lastError = e.getMessage(); + close(); + return false; + } catch (RuntimeException e) { + this.lastError = e.toString(); + close(); + return false; + } + } + + private void onHello(Frame frame) { + if (this.state != SessionState.CONNECTING) { + return; + } + Buf in = new Buf(frame.body()); + net.rustnotmc.protocol.Packets.Hello hello = + net.rustnotmc.protocol.Packets.Hello.read(in); + + if (hello.protocolVersion != net.rustnotmc.protocol.Frame.VERSION) { + send(net.rustnotmc.protocol.Packets.HELLO_ACK, + new net.rustnotmc.protocol.Packets.HelloAck( + net.rustnotmc.protocol.Frame.VERSION, + this.server.serverName(), this.server.motd(), + this.server.online(), this.server.capacity(), + new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES], + new byte[net.rustnotmc.protocol.Packets.CHALLENGE_BYTES])); + this.lastError = "protocol version " + hello.protocolVersion + " is not ours"; + close(); + return; + } + + this.nonce = net.rustnotmc.auth.Secure.bytes( + net.rustnotmc.protocol.Packets.CHALLENGE_BYTES); + // the salt has to belong to the account the client is about to claim, + // and the client cannot derive its key without it, so Hello has to name + // the account before this point + this.salt = this.server.saltFor(hello.account); + this.state = SessionState.HANDSHAKING; + touch(HANDSHAKE_TIMEOUT_MILLIS); + + send(net.rustnotmc.protocol.Packets.HELLO_ACK, + new net.rustnotmc.protocol.Packets.HelloAck( + net.rustnotmc.protocol.Frame.VERSION, + this.server.serverName(), this.server.motd(), + this.server.online(), this.server.capacity(), + this.nonce, this.salt)); + } + + private void onAuth(Frame frame) { + if (this.state != SessionState.HANDSHAKING) { + return; + } + Buf in = new Buf(frame.body()); + net.rustnotmc.protocol.Packets.Auth auth = + net.rustnotmc.protocol.Packets.Auth.read(in); + + long wait = this.server.throttle().waitSeconds(auth.account, this.address); + if (wait > 0L) { + send(net.rustnotmc.protocol.Packets.AUTH_ACK, + net.rustnotmc.protocol.Packets.AuthAck.refused( + "too many attempts, wait " + wait + "s")); + close(); + return; + } + + String name = this.accountBehind(auth.account); + if (name == null) { + // the same work is done for an absent account as for a wrong + // password, so the reply does not say which it was + this.server.throttle().failed(auth.account, this.address); + send(net.rustnotmc.protocol.Packets.AUTH_ACK, + net.rustnotmc.protocol.Packets.AuthAck.refused( + "wrong name or password")); + close(); + return; + } + + if (!this.server.verifyProof(name, auth.account, this.salt, this.nonce, auth.proof)) { + this.server.throttle().failed(auth.account, this.address); + this.server.upgradeRecord(name); + send(net.rustnotmc.protocol.Packets.AUTH_ACK, + net.rustnotmc.protocol.Packets.AuthAck.refused( + "wrong name or password")); + close(); + return; + } + + this.server.throttle().succeeded(auth.account); + net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(name); + this.account = name; + this.token = issued.value; + this.state = SessionState.AUTHENTICATED; + send(net.rustnotmc.protocol.Packets.AUTH_ACK, + net.rustnotmc.protocol.Packets.AuthAck.ok(issued.value, issued.expiresAt)); + this.server.log("auth ok: " + name + " from " + this.address); + } + + private void onLogin(Frame frame) { + if (this.state != SessionState.AUTHENTICATED) { + return; + } + Buf in = new Buf(frame.body()); + net.rustnotmc.protocol.Packets.Login login = + net.rustnotmc.protocol.Packets.Login.read(in); + + if (!this.server.tokens().valid(login.token, login.account)) { + send(net.rustnotmc.protocol.Packets.LOGIN_ACK, + net.rustnotmc.protocol.Packets.LoginAck.refused("bad or expired token")); + close(); + return; + } + if (this.server.full()) { + send(net.rustnotmc.protocol.Packets.LOGIN_ACK, + net.rustnotmc.protocol.Packets.LoginAck.refused("server is full")); + close(); + return; + } + if (this.server.alreadyPlaying(this.account)) { + send(net.rustnotmc.protocol.Packets.LOGIN_ACK, + net.rustnotmc.protocol.Packets.LoginAck.refused( + "that account is already playing")); + close(); + return; + } + + this.playerId = this.server.nextPlayerId(); + this.entityId = this.playerId; + this.state = SessionState.PLAYING; + touch(Long.MAX_VALUE / 4L); + send(net.rustnotmc.protocol.Packets.LOGIN_ACK, + net.rustnotmc.protocol.Packets.LoginAck.ok(this.playerId, this.entityId)); + this.server.joined(this); + this.server.log("player in: " + this.account + " id " + this.playerId + + " from " + this.address); + } + + /** The canonical account name for the name the client typed. */ + private String accountBehind(String typed) { + return this.server.canonicalAccount(typed); + } + + // --------------------------------------------------------------- output + + /** Sends a packet. Silently ignored once the socket is gone. */ + public void send(int packetId, Object packet) { + if (this.closed.get()) { + return; + } + try { + Buf body; + if (packet instanceof net.rustnotmc.protocol.Packets.HelloAck) { + body = ((net.rustnotmc.protocol.Packets.HelloAck) packet).write(); + } else if (packet instanceof net.rustnotmc.protocol.Packets.AuthAck) { + body = ((net.rustnotmc.protocol.Packets.AuthAck) packet).write(); + } else if (packet instanceof net.rustnotmc.protocol.Packets.LoginAck) { + body = ((net.rustnotmc.protocol.Packets.LoginAck) packet).write(); + } else if (packet instanceof net.rustnotmc.protocol.Packets.Disconnect) { + body = ((net.rustnotmc.protocol.Packets.Disconnect) packet).write(); + } else { + throw new IllegalArgumentException("no codec for " + packet); + } + Frame frame = new Frame(packetId, body.toByteArray()); + synchronized (this.socket) { + this.socket.getOutputStream().write(frame.encode().toByteArray()); + this.socket.getOutputStream().flush(); + } + } catch (IOException gone) { + close(); + } + } + + public void disconnect(String reason) { + if (this.closed.get()) { + return; + } + send(net.rustnotmc.protocol.Packets.DISCONNECT, + new net.rustnotmc.protocol.Packets.Disconnect(reason)); + close(); + } + + private boolean expired() { + return System.currentTimeMillis() > this.deadline; + } + + void shutdown() { + if (!this.closed.compareAndSet(false, true)) { + return; + } + this.state = SessionState.CLOSED; + try { + this.socket.close(); + } catch (IOException ignored) { + // the peer is gone; there is nothing left to do about it + } + this.server.closed(this); + } + + public void close() { + this.shutdown(); + } + + @Override + public String toString() { + return "ClientSession[" + this.address + " " + this.state + + (this.account == null ? "" : " as " + this.account) + "]"; + } +} \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java new file mode 100644 index 0000000..222df18 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -0,0 +1,357 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import net.rustnotmc.auth.AccountFile; +import net.rustnotmc.auth.AccountStore; +import net.rustnotmc.auth.LoginThrottle; +import net.rustnotmc.auth.Passwords; +import net.rustnotmc.auth.TokenService; +import net.rustnotmc.protocol.Packets; + +/** + * The server: a listening socket, a tick loop and the rules for who gets in. + * + * The tick loop runs on its own thread at a fixed rate and does not care how + * fast frames arrive. A world that ticks on frames is a world whose physics + * depend on the graphics card, and the whole point of moving the simulation to + * our own server is that the client stops being trusted. + */ +public final class Server { + + /** Ticks per second. Fixed, and the same number the client counts. */ + public static final int TICKS_PER_SECOND = 20; + + /** How far the loop is allowed to drift before it gives up catching up. */ + public static final long MAX_CATCHUP_TICKS = 5; + + private final String name; + private final String motd; + private final int capacity; + private final int port; + + private final AccountStore accounts = new AccountStore(); + private final TokenService tokens = new TokenService(); + private final LoginThrottle throttle = new LoginThrottle(); + + private final Map sessions = new ConcurrentHashMap(); + private final AtomicInteger playerIds = new AtomicInteger(1); + private final AtomicLong sessionIds = new AtomicLong(1L); + private final AtomicLong ticks = new AtomicLong(); + private final AtomicInteger peakOnline = new AtomicInteger(); + + private volatile ServerSocket listener; + private volatile Thread acceptThread; + private volatile Thread tickThread; + private volatile boolean running; + private volatile long tickNanos; + private volatile String tickBacklog = ""; + + public Server(String name, String motd, int capacity, int port) { + this.name = name; + this.motd = motd; + this.capacity = capacity; + this.port = port; + } + + // -------------------------------------------------------------- getters + + public AccountStore accounts() { + return this.accounts; + } + + public TokenService tokens() { + return this.tokens; + } + + public LoginThrottle throttle() { + return this.throttle; + } + + public String serverName() { + return this.name; + } + + public String motd() { + return this.motd; + } + + public int capacity() { + return this.capacity; + } + + public long ticks() { + return this.ticks.get(); + } + + public int peakOnline() { + return this.peakOnline.get(); + } + + public String tickBacklog() { + return this.tickBacklog; + } + + public boolean running() { + return this.running; + } + + /** + * The port actually bound. + * + * Not the one that was asked for: a request for port 0 asks the operating + * system to pick a free one, and a test that assumed it got back what it + * asked for would be testing the wrong thing. + */ + public int port() { + return this.listener == null ? this.port : this.listener.getLocalPort(); + } + + public int online() { + int n = 0; + for (ClientSession session : this.sessions.values()) { + if (session.state().mayPlay()) { + n++; + } + } + return n; + } + + // ----------------------------------------------------------- lifecycle + + public void loadAccounts(java.nio.file.Path path) throws IOException { + AccountFile.load(path, this.accounts); + } + + public void saveAccounts(java.nio.file.Path path) throws IOException { + AccountFile.save(path, this.accounts); + } + + public void start() throws IOException { + this.listener = new ServerSocket(); + this.listener.setReuseAddress(true); + this.listener.bind(new InetSocketAddress("0.0.0.0", this.port)); + this.running = true; + + this.acceptThread = new Thread(this::acceptLoop, "rsx-accept"); + this.acceptThread.setDaemon(true); + this.acceptThread.start(); + + this.tickThread = new Thread(this::tickLoop, "rsx-tick"); + this.tickThread.setDaemon(true); + this.tickThread.start(); + + log("listening on " + this.listener.getLocalPort() + + ", capacity " + this.capacity + ", accounts " + this.accounts.size()); + } + + public void stop() { + this.running = false; + try { + if (this.listener != null) { + this.listener.close(); + } + } catch (IOException ignored) { + // shutting down anyway + } + for (ClientSession session : new ArrayList(this.sessions.values())) { + session.close(); + } + log("stopped after " + this.ticks.get() + " ticks"); + } + + private void acceptLoop() { + while (this.running) { + try { + Socket socket = this.listener.accept(); + ClientSession session = new ClientSession(socket, this, nextSessionId()); + this.sessions.put(Long.valueOf(session.id()), session); + Thread thread = new Thread(session, "rsx-session-" + session.id()); + thread.setDaemon(true); + thread.start(); + } catch (SocketException shuttingDown) { + return; + } catch (IOException e) { + if (this.running) { + log("accept failed: " + e); + } + } + } + } + + private void tickLoop() { + long periodNanos = 1_000_000_000L / TICKS_PER_SECOND; + long next = System.nanoTime() + periodNanos; + int late = 0; + int skipped = 0; + long reported = 0L; + + while (this.running) { + long now = System.nanoTime(); + if (now < next) { + long sleep = next - now; + try { + Thread.sleep(sleep / 1_000_000L, (int) (sleep % 1_000_000L)); + } catch (InterruptedException stopped) { + return; + } + continue; + } + + // catching up one tick at a time is right; catching up forever is not + int behind = (int) ((now - next) / periodNanos) + 1; + int run = (int) Math.min((long) behind, (long) MAX_CATCHUP_TICKS); + if (behind > MAX_CATCHUP_TICKS) { + skipped += behind - run; + next = now; + if (skipped != reported) { + this.tickBacklog = "skipped " + skipped + " tick(s) to catch up"; + reported = skipped; + } + } + + for (int i = 0; i < run; i++) { + tickOnce(); + } + if (behind <= 1 && late != 0) { + this.tickBacklog = ""; + late = 0; + } + next += periodNanos * run; + if (next < now) { + next = now; + } + } + } + + /** One server tick. The place the world will live. */ + private void tickOnce() { + this.ticks.incrementAndGet(); + if ((this.ticks.get() & 0x3FFFL) == 0L) { + this.tokens.sweep(); + for (ClientSession session : this.sessions.values()) { + if (session.isClosed()) { + this.sessions.remove(Long.valueOf(session.id())); + } + } + } + } + + // ------------------------------------------------------ session helpers + + void opened(ClientSession session) { + } + + void joined(ClientSession session) { + int n = online(); + if (n > this.peakOnline.get()) { + this.peakOnline.set(n); + } + } + + void closed(ClientSession session) { + this.sessions.remove(Long.valueOf(session.id())); + if (session.lastError() != null) { + log("closed " + session.address() + ": " + session.lastError()); + } + } + + public List sessions() { + return new ArrayList(this.sessions.values()); + } + + int nextPlayerId() { + return this.playerIds.getAndIncrement(); + } + + private long nextSessionId() { + return this.sessionIds.getAndIncrement(); + } + + public boolean full() { + return online() >= this.capacity; + } + + boolean alreadyPlaying(String account) { + if (account == null) { + return false; + } + for (ClientSession session : this.sessions.values()) { + if (account.equalsIgnoreCase(session.account()) && session.state().mayPlay()) { + return true; + } + } + return false; + } + + /** + * The salt to hand a client. + * + * A fresh random salt goes out when the account is not known, so an absent + * name still receives a plausible looking challenge. + */ + byte[] saltFor(String typedName) { + String canonical = canonicalAccount(typedName); + if (canonical == null) { + return net.rustnotmc.auth.Secure.bytes(Packets.SALT_BYTES); + } + return this.accounts.find(canonical).decode().salt; + } + + public String canonicalAccount(String typedName) { + if (typedName == null) { + return null; + } + AccountStore.Account found = this.accounts.find(typedName); + return found == null ? null : found.name; + } + + /** + * Checks the client's proof. + * + * The account name is taken from the store, not from the client: a client + * that sends {@code Player} and a proof for {@code player} must not get in, + * or the proof would no longer be bound to the name it was made for. + */ + boolean verifyProof(String canonicalName, String typedName, byte[] salt, + byte[] nonce, byte[] proof) { + if (canonicalName == null || salt == null || nonce == null || proof == null) { + return false; + } + AccountStore.Account account = this.accounts.find(canonicalName); + if (account == null) { + return false; + } + Passwords.Record record = account.decode(); + try { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new javax.crypto.spec.SecretKeySpec(record.hash, "HmacSHA256")); + mac.update(nonce); + mac.update(canonicalName.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] expected = mac.doFinal(); + return net.rustnotmc.auth.Secure.same(expected, proof); + } catch (java.security.NoSuchAlgorithmException | java.security.InvalidKeyException e) { + throw new IllegalStateException("HmacSHA256 is unavailable", e); + } + } + + /** Rewrites a record with the current work factor while we hold the password. */ + void upgradeRecord(String canonicalName) { + // deliberately a no-op placeholder: upgrading needs the plaintext, which + // we do not have at this point in the flow. The record carries its own + // iteration count, so an old account simply keeps working. + } + + public void log(String message) { + System.out.println("[rsx] " + message); + } +} \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java new file mode 100644 index 0000000..b9bcd3b --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -0,0 +1,124 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Command line entry point. + * + * Deliberately small: it loads accounts, opens the port and runs the tick loop. + * Everything worth testing lives in {@link Server} and is tested there, so this + * only has to be obviously correct rather than clever. + * + * run-server.ps1 [--port N] [--accounts path] [--name text] [--motd text] + * run-server.ps1 --register Player [--password text] + * + * Registration is an operator action and not something a client can do over the + * wire. Open self-registration turns an account store into a spam target and a + * support queue, and neither is a problem worth having at the start. + */ +public final class ServerMain { + + private ServerMain() { + } + + public static void main(String[] args) throws IOException, InterruptedException { + int port = 25565; + int capacity = 200; + String name = "RustNotMC"; + String motd = "a survival game"; + Path accounts = Path.of("accounts.txt"); + String register = null; + String password = null; + + for (int i = 0; i < args.length - 1; i++) { + String flag = args[i]; + String value = args[i + 1]; + if ("--port".equals(flag)) { + port = Integer.parseInt(value); + } else if ("--capacity".equals(flag)) { + capacity = Integer.parseInt(value); + } else if ("--accounts".equals(flag)) { + accounts = Path.of(value); + } else if ("--name".equals(flag)) { + name = value; + } else if ("--motd".equals(flag)) { + motd = value; + } else if ("--register".equals(flag)) { + register = value; + } else if ("--password".equals(flag)) { + password = value; + } + } + + Server server = new Server(name, motd, capacity, port); + server.loadAccounts(accounts); + final Path accountFile = accounts.toAbsolutePath(); + + if (register != null) { + char[] typed = password == null + ? readPassword("password for " + register + ": ") + : password.toCharArray(); + try { + server.accounts().create(register, typed, System.currentTimeMillis()); + server.saveAccounts(accountFile); + System.out.println("[rsx] created account " + register); + return; + } catch (net.rustnotmc.auth.AccountStore.AccountException refused) { + System.out.println("[rsx] refused: " + refused.getMessage()); + System.exit(1); + } finally { + net.rustnotmc.auth.Passwords.wipe(typed); + } + } + + server.start(); + server.log("accounts file: " + accountFile); + + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + public void run() { + server.log("saving accounts"); + try { + server.saveAccounts(accountFile); + } catch (IOException e) { + server.log("could not save accounts: " + e); + } + server.stop(); + } + }, "rsx-shutdown")); + + long reportedAt = 0L; + while (server.running()) { + Thread.sleep(5_000L); + long ticks = server.ticks(); + if (server.ticks() == reportedAt) { + server.log("the tick loop is not moving"); + } + reportedAt = ticks; + server.log("tick " + ticks + + ", players " + server.online() + + ", peak " + server.peakOnline() + + ", sessions " + server.sessions().size()); + } + } + + /** Reads a password without echo, or falls back to a visible line. */ + private static char[] readPassword(String prompt) { + 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 { + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(System.in)); + String line = reader.readLine(); + return line == null ? new char[0] : line.toCharArray(); + } catch (IOException e) { + return new char[0]; + } + } +} \ No newline at end of file diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/SessionState.java b/rsx-server-core/src/main/java/net/rustnotmc/server/SessionState.java new file mode 100644 index 0000000..bc0ae50 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/SessionState.java @@ -0,0 +1,41 @@ +package net.rustnotmc.server; + +import java.util.Locale; + +/** + * Where a connection is in the way to being a player. + * + * The order matters as much as the states do. Nothing that reads the world is + * reachable before AUTHENTICATED, and a connection that stops answering mid + * handshake is dropped by a timeout rather than kept open as a socket that + * occupies a slot forever. + */ +public enum SessionState { + + /** Connected, nothing exchanged yet. */ + CONNECTING, + + /** Hello seen, waiting for Auth. */ + HANDSHAKING, + + /** Proof accepted and a token handed out. */ + AUTHENTICATED, + + /** Token redeemed, the player is in. */ + PLAYING, + + /** Closing, or closed. Nothing further is read. */ + CLOSED; + + public boolean readsPackets() { + return this != CLOSED; + } + + public boolean mayPlay() { + return this == PLAYING; + } + + public static SessionState parse(String text) { + return valueOf(text.trim().toUpperCase(Locale.ROOT)); + } +} \ No newline at end of file diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/ServerTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/ServerTest.java new file mode 100644 index 0000000..22775fe --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/ServerTest.java @@ -0,0 +1,388 @@ +package net.rustnotmc.server; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import net.rustnotmc.auth.AccountFile; +import net.rustnotmc.auth.AccountStore; +import net.rustnotmc.auth.LoginThrottle; +import net.rustnotmc.auth.Passwords; +import net.rustnotmc.auth.TokenService; +import net.rustnotmc.protocol.Frame; +import net.rustnotmc.protocol.Packets; +import net.rustnotmc.tools.LoginClient; + +/** + * Tests the login path against a running server over a real socket. + * + * The point of going through a socket rather than calling methods is that the + * parts most likely to be wrong are the parts nobody calls directly: the + * framing, the state machine, the timeouts, and the order in which each side + * assumes the other has already done something. + */ +public final class ServerTest { + + private static int checks; + private static int failed; + private static final java.util.List failures = new java.util.ArrayList(); + + public static void main(String[] args) throws Exception { + Path work = Path.of(args.length > 0 ? args[0] : "build/selftest"); + Files.createDirectories(work); + + run("password records survive a round trip", new Throwing() { + public void go() throws Exception { testPasswordRecord(); } + }); + run("wrong password is refused", new Throwing() { + public void go() throws Exception { testPasswordWrong(); } + }); + run("names are unique regardless of case", new Throwing() { + public void go() throws Exception { testCaseUniqueness(); } + }); + run("short passwords are refused", new Throwing() { + public void go() throws Exception { testPasswordPolicy(); } + }); + run("throttle backs off and recovers", new Throwing() { + public void go() throws Exception { testThrottle(); } + }); + run("tokens expire and can be revoked", new Throwing() { + public void go() throws Exception { testTokens(); } + }); + run("accounts survive a save and load", new Throwing() { + public void go() throws Exception { testAccountFile(work); } + }); + run("a client can log in end to end", new Throwing() { + public void go() throws Exception { testEndToEndLogin(work); } + }); + run("a wrong password is refused over the wire", new Throwing() { + public void go() throws Exception { testEndToEndWrongPassword(work); } + }); + run("a token cannot be replayed on a new connection", new Throwing() { + public void go() throws Exception { testReplayRejected(work); } + }); + run("the tick loop actually ticks", new Throwing() { + public void go() throws Exception { testTickLoop(); } + }); + run("the same account cannot play twice", new Throwing() { + public void go() throws Exception { testDoubleLogin(work); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private interface Throwing { + void go() throws Exception; + } + + private static void run(String name, Throwing body) { + checks++; + int before = failed; + try { + body.go(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + // ------------------------------------------------------------ unit level + + private static void testPasswordRecord() { + char[] password = "correct horse".toCharArray(); + Passwords.Record record = Passwords.create(password, 1000); + yes("right password verifies", + Passwords.verify("correct horse".toCharArray(), record)); + yes("wrong password does not", + !Passwords.verify("correct horsf".toCharArray(), record)); + + Passwords.Record back = Passwords.Record.decode(record.encode()); + eq("iterations survive", Integer.valueOf(record.iterations), Integer.valueOf(back.iterations)); + yes("salt survives", java.util.Arrays.equals(record.salt, back.salt)); + yes("hash survives", java.util.Arrays.equals(record.hash, back.hash)); + yes("decoded record still verifies", + Passwords.verify("correct horse".toCharArray(), back)); + } + + private static void testPasswordWrong() { + Passwords.Record record = Passwords.create("hunter22".toCharArray(), 1000); + yes("a near miss is still a miss", !Passwords.verify("hunter23".toCharArray(), record)); + yes("an empty password is a miss", !Passwords.verify(new char[0], record)); + yes("null password is a miss", !Passwords.verify(null, record)); + yes("a missing record is a miss", !Passwords.verify("anything".toCharArray(), null)); + } + + private static void testCaseUniqueness() { + AccountStore store = new AccountStore(); + store.create("Player", "hunter22".toCharArray(), 1L); + try { + store.create("player", "other22".toCharArray(), 1L); + failed++; + failures.add("a differently cased duplicate was accepted"); + } catch (AccountStore.AccountException expected) { + // correct + } + yes("found case insensitively", store.exists("PLAYER")); + } + + private static void testPasswordPolicy() { + AccountStore store = new AccountStore(); + try { + store.create("Shorty", "short".toCharArray(), 1L); + failed++; + failures.add("a short password was accepted"); + } catch (AccountStore.AccountException expected) { + // correct + } + } + + private static void testThrottle() { + final AtomicLong clock = new AtomicLong(1_000_000L); + LoginThrottle throttle = new LoginThrottle(500L, 60_000L, new LoginThrottle.Clock() { + public long now() { + return clock.get(); + } + }); + + yes("allowed at first", throttle.allowed("Player", "10.0.0.1")); + throttle.failed("Player", "10.0.0.1"); + yes("not allowed right after a failure", !throttle.allowed("Player", "10.0.0.1")); + + clock.addAndGet(600L); + yes("allowed again after the first wait", throttle.allowed("Player", "10.0.0.1")); + + throttle.failed("Player", "10.0.0.1"); + throttle.failed("Player", "10.0.0.1"); + yes("the wait grows with each failure", !throttle.allowed("Player", "10.0.0.1")); + long second = throttle.waitSeconds("Player", "10.0.0.1"); + yes("the second wait is longer than the first", second >= 1L); + + clock.addAndGet(120_000L); + yes("allowed once the cap passes", throttle.allowed("Player", "10.0.0.1")); + + throttle.failed("Player", "10.0.0.1"); + yes("the account bucket is keyed case insensitively", + !throttle.allowed("player", "10.0.0.1")); + + throttle.succeeded("Player"); + yes("a success clears the account bucket", throttle.allowed("Player", "10.0.0.2")); + } + + private static void testTokens() { + final AtomicLong clock = new AtomicLong(0L); + TokenService tokens = new TokenService(1000L, new LoginThrottle.Clock() { + public long now() { + return clock.get(); + } + }); + TokenService.Token issued = tokens.issue("Player"); + yes("valid when fresh", tokens.valid(issued.value, "Player")); + yes("not valid for another account", !tokens.valid(issued.value, "Other")); + + clock.set(1500L); + yes("expired after its lifetime", !tokens.valid(issued.value, "Player")); + + TokenService.Token second = tokens.issue("Player"); + tokens.revoke(second.value); + yes("revoked at once", !tokens.valid(second.value, "Player")); + + tokens.issue("Player"); + tokens.issue("Other"); + eq("revokeAll clears just one account's", Integer.valueOf(1), + Integer.valueOf(tokens.revokeAll("Player"))); + } + + private static void testAccountFile(Path work) throws IOException { + Path file = work.resolve("accounts-roundtrip.txt"); + Files.deleteIfExists(file); + + AccountStore store = new AccountStore(); + store.create("Alice", "password1".toCharArray(), 111L); + store.create("Bob", "password2".toCharArray(), 222L); + store.markLogin("Alice", 999L); + AccountFile.save(file, store); + + AccountStore loaded = new AccountStore(); + AccountFile.load(file, loaded); + eq("both accounts came back", Integer.valueOf(2), Integer.valueOf(loaded.size())); + eq("the right name", "Alice", loaded.find("alice").name); + eq("last login survived", Long.valueOf(999L), + Long.valueOf(loaded.find("Alice").lastLogin)); + yes("the password still verifies", + Passwords.verify("password1".toCharArray(), loaded.find("Alice").decode())); + Files.deleteIfExists(file); + } + + // ---------------------------------------------------------- wire level + + /** Starts a server on an ephemeral port and returns it. */ + private static Server boot(Path work, String name) throws IOException { + Path accounts = work.resolve("accounts-" + name + ".txt"); + // a previous run leaves the file behind, and creating an account that + // is already there fails, so every test starts from nothing + Files.deleteIfExists(accounts); + Server server = new Server("RustNotMC test", "hello", 50, 0); + server.loadAccounts(accounts); + server.accounts().create(name, "password1".toCharArray(), 1L); + server.start(); + return server; + } + + private static void testEndToEndLogin(Path work) throws Exception { + Server server = boot(work, "Hero"); + try { + LoginClient client = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck hello = client.hello("Hero", "cli"); + eq("server names itself", "RustNotMC test", hello.serverName); + eq("protocol version agrees", Integer.valueOf(Frame.VERSION), + Integer.valueOf(hello.protocolVersion)); + eq("a nonce came back", Integer.valueOf(Packets.CHALLENGE_BYTES), + Integer.valueOf(hello.nonce.length)); + eq("a salt came back", Integer.valueOf(Packets.SALT_BYTES), + Integer.valueOf(hello.salt.length)); + + Packets.AuthAck auth = client.auth("Hero", "password1".toCharArray(), + hello.salt, hello.nonce); + yes("the right password was accepted, server said: " + auth.reason, + auth.accepted); + yes("a token came back", auth.token != null && !auth.token.isEmpty()); + + Packets.LoginAck login = client.login("Hero", auth.token); + yes("the token was accepted", login.accepted); + yes("a player id was given", login.playerId > 0); + yes("an entity id was given", login.entityId > 0); + + waitFor(() -> server.online() == 1, 4000L); + eq("the server counts one player", Integer.valueOf(1), + Integer.valueOf(server.online())); + client.close(); + } finally { + server.stop(); + } + } + + private static void testEndToEndWrongPassword(Path work) throws Exception { + Server server = boot(work, "Fool"); + try { + LoginClient client = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck hello = client.hello("Fool", "cli"); + Packets.AuthAck auth = client.auth("Fool", "wrongpassword".toCharArray(), + hello.salt, hello.nonce); + yes("the wrong password was refused", !auth.accepted); + yes("the reason is not empty", auth.reason != null && !auth.reason.isEmpty()); + yes("no token was handed out", auth.token == null || auth.token.isEmpty()); + client.close(); + } finally { + server.stop(); + } + } + + private static void testReplayRejected(Path work) throws Exception { + Server server = boot(work, "Reef"); + try { + LoginClient first = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck hello = first.hello("Reef", "cli"); + Packets.AuthAck auth = first.auth("Reef", "password1".toCharArray(), + hello.salt, hello.nonce); + yes("logged in once", auth.accepted); + first.close(); + + // a fresh connection with the token from the first one: the new + // nonce means the old proof is not what the server is asking for + LoginClient replay = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck again = replay.hello("Reef", "cli"); + yes("the nonce differs between connections", + !java.util.Arrays.equals(hello.nonce, again.nonce)); + Packets.AuthAck reused = replay.auth("Reef", "password1".toCharArray(), + again.salt, hello.nonce); + yes("the old proof does not work against a new nonce", !reused.accepted); + replay.close(); + } finally { + server.stop(); + } + } + + private static void testDoubleLogin(Path work) throws Exception { + Server server = boot(work, "Twin"); + try { + LoginClient first = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck a = first.hello("Twin", "cli"); + Packets.AuthAck auth = first.auth("Twin", "password1".toCharArray(), + a.salt, a.nonce); + yes("first login ok", auth.accepted); + yes("first login in", first.login("Twin", auth.token).accepted); + + LoginClient second = new LoginClient("127.0.0.1", server.port(), 10_000); + Packets.HelloAck b = second.hello("Twin", "cli"); + Packets.AuthAck again = second.auth("Twin", "password1".toCharArray(), + b.salt, b.nonce); + yes("second auth ok", again.accepted); + Packets.LoginAck denied = second.login("Twin", again.token); + yes("the second player is turned away", !denied.accepted); + yes("the reason mentions the account is playing", + denied.reason != null && denied.reason.contains("already playing")); + first.close(); + second.close(); + } finally { + server.stop(); + } + } + + private static void testTickLoop() throws Exception { + Server server = new Server("tick", "tick", 10, 0); + server.start(); + try { + long before = server.ticks(); + Thread.sleep(1100L); + long after = server.ticks(); + long moved = after - before; + // twenty ticks a second, so a second is twenty ticks give or take + // a few for scheduling; anything outside that means the loop is + // running at the wrong rate or stalling + yes("the loop moved (" + moved + " ticks in ~1s)", moved >= 12L && moved <= 32L); + } finally { + server.stop(); + } + } + + private static void waitFor(java.util.function.BooleanSupplier condition, long millis) + throws InterruptedException { + long end = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < end) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(25L); + } + } +} \ No newline at end of file