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

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

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

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

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

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

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

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

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

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

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

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

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