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