fix: Bind the login auth id to its account and drop the redundant verify (#2564)

## What

- Bind the login auth id to the account **and** origin address that earned it, make it a CSPRNG draw, expire it after two minutes, and spend it only once its owner presents it.
- Skip the password verify on `GameLogin` (0x91) when the presented id vouches for the submitted username and address.

## Why

A full client login hashes the password twice — `AccountLogin` (0x80) and then `GameLogin` (0x91). At the current Argon2 parameters that is **most of a 16 ms frame each, on the single-threaded game loop**, for every login attempt.

The second verify is redundant. `GameLogin` already requires an id from `_authIDWindow`, and that window is only populated by `GenerateAuthID`, called from `PlayServer` — reachable only after 0x80 has already authenticated the account **in this same process**. ModernUO Gateway has its own auth-id passing mechanism and is out of scope here.

## Why the id needed hardening first

Skipping the verify promotes the id from a correlation token to a bearer token, and it was not one:

- drawn from `Utility.Random` → `BuiltInRng`, a non-cryptographic PRNG
- bound to nothing — `AuthIDPersistence` carried only `Age` and `Version`
- never expiring; `Age` was only read to pick an eviction victim

A guessed id got you nothing while the password was still checked. Without that check it would have been an account takeover, so the id is now a CSPRNG draw, single-use, two-minute TTL, and bound to both the account and the origin address.

What remains is observing a live id on the client's network or machine — which the server cannot defend against under any design, and which already yields the password itself, since the client transmits it in the same handshake.

Network switching mid-login is deliberately unsupported.

## Behaviour

A full verify was always required before this change, and ids never expired, so every "before" is a password check.

| Case | Before | After |
|---|---|---|
| Id absent | Disconnect | Disconnect |
| Address mismatch | Verify | **Disconnect** |
| Account mismatch | Verify | **Disconnect** |
| Expired | Verify | **Verify** |
| Id vouches | Verify | **Skip** |

No case grants access the previous code would have denied. Expiry deliberately falls back to the verify rather than disconnecting — a player can idle, and turning that into a lockout would be a regression for no gain.

## Look, then take

An id is not consumed until the presenter has shown it is theirs. Removing it first would let anyone who lands on a live id burn it, and its owner would arrive to `"Unable to find auth id."` and have to log in again over a packet they had no part in.

The **address is compared before the account**, so a guesser from anywhere else is rejected before a username is ever looked at. That is what makes it safe to leave the id in place on a mismatch: there is no username-enumeration risk to trade against, and the only presenter who could enumerate is already on the victim's own address.

## The window is not a cap

It was 128 entries with the oldest evicted to make room. That is a cap on *concurrent logins*, not a resource bound: 800 people picking a server at once would have live ids discarded and those clients would arrive to `"Unable to find auth id."` — a failed login caused by nothing except other people logging in.

Issuing now sweeps expired entries and lets the window grow if everything in it is still live. Unbounded is safe here: an entry costs a **successful** password verify to create and dies after two minutes, so its size tracks logins genuinely in flight.

Removing an id when its connection drops is not an option, and this was checked rather than assumed — `NetState.cs:787` disconnects the login connection *deliberately*, immediately after the id is issued, and that disconnect is never cancelled. Surviving it is the whole purpose of the id. Expiry is the only correct reclamation.

## Handshake hardening

Choosing a server queues a disconnect, but the queue drains on the *next* slice, so a client pipelining into the same recv buffer can reach the handshake handlers again. Two had no do-once guard:

- `LoginServerSeed` (0xEF) now rejects when `state.Seeded` is already set.
- `PlayServer` (0xA0) now rejects when `state.AuthId != 0` — otherwise a connection that had already spent its id would be handed the spent one back.

Issuing is also idempotent (`EnsureAuthId`), so a connection holds exactly one id by construction and an orphan is impossible rather than something to clean up. The login state machine itself is untouched.

Also fixes a fall-through: the "Unable to find auth id" branch disconnected without returning, then continued with a default entry and nulled `state.Version`.

## Testing

`ConsumeAuthId` is a seam with no `NetState` dependency, so the auth decision is tested directly: vouching, account mismatch, address mismatch, case-insensitive usernames, IPv4-mapped-IPv6, unknown ids, single-use by the owner, **a rejected attempt leaving the id redeemable**, expiry-into-verify, and an 800-id login rush that must evict nobody. Expiry is driven by moving `Core._now`, not by waiting. Every new clause was verified to discriminate by removing it and confirming only its own tests fail.

## Cost

Halves the per-login game-loop cost. This does not make hashing cheaper or move it off the loop — that is gated on a measurement described in `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md`.
This commit is contained in:
Kamron Batman 2026-08-08 09:25:42 -07:00 committed by GitHub
parent 64e6fe5da8
commit f33bcd6006
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 545 additions and 50 deletions

View file

@ -100,8 +100,7 @@ internal static class TestServerInitializer
} }
World.Configure(); World.Configure();
// Registers the Accounts entity persistence, without which Accounts.NewAccount cannot // Registers the Accounts entity persistence; without it no test can construct an Account.
// resolve and no test can construct an Account.
Server.Accounting.Accounts.Configure(); Server.Accounting.Accounts.Configure();
RaceDefinitions.Configure(); RaceDefinitions.Configure();
MovementImpl.Configure(); MovementImpl.Configure();

View file

@ -30,9 +30,9 @@ public class AccountPasswordTests : IDisposable
Assert.False(account.CheckPassword("wrong-password")); Assert.False(account.CheckPassword("wrong-password"));
} }
// SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversing those two // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversed, the hash
// lines salts the hash by the outgoing algorithm's rule and stores it under the incoming one, // is salted by the outgoing algorithm's rule but stored under the incoming one, which verifies
// which verifies once and then never again. // once and then never again.
[Theory] [Theory]
[InlineData(PasswordProtectionAlgorithm.SHA1)] [InlineData(PasswordProtectionAlgorithm.SHA1)]
[InlineData(PasswordProtectionAlgorithm.SHA2)] [InlineData(PasswordProtectionAlgorithm.SHA2)]

View file

@ -75,7 +75,7 @@ public class PasswordProtectionTest
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
} }
// The shipping default before this change. A literal, so it cannot drift with the configured // The shipping default before this change, as a literal so it cannot drift with the configured
// defaults. Password: "hunter2". // defaults. Password: "hunter2".
private const string LegacyArgon2iHash = private const string LegacyArgon2iHash =
"$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
@ -105,9 +105,8 @@ public class PasswordProtectionTest
Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash));
} }
// Digest and salt lengths are the decoded sizes of the base64 segments, not parameter-list // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they
// entries, so they need their own literals. Current type and cost throughout; only a length // need their own literals. Current type and cost throughout; only a length differs.
// differs from the defaults. The theory above is the negative control at default lengths.
[Theory] [Theory]
// 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to.
[InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")]

View file

@ -0,0 +1,382 @@
using System;
using System.Net;
using Server.Accounting;
using Server.Accounting.Security;
using Server.Network;
using Server.Tests.Network;
using Xunit;
namespace Server.Tests.Network.Packets;
[Collection("Sequential UOContent Tests")]
public class AuthIdTests : IDisposable
{
private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10");
private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11");
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
public AuthIdTests()
{
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
IncomingAccountPackets.ClearAuthIdWindow();
}
public void Dispose()
{
IncomingAccountPackets.ClearAuthIdWindow();
AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
}
private static IAccount CreateAccount(string username) =>
Accounts.GetAccount(username) ?? new Account(username, "hunter2");
private static int Register(IAccount account, IPAddress address) =>
IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0));
[Fact]
public void VouchesForTheAccountAndAddressItWasIssuedTo()
{
var account = CreateAccount("authid-match-user");
var authId = Register(account, AddressX);
var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry);
Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result);
Assert.Same(account, entry.Account);
}
[Fact]
public void RejectsADifferentAccount()
{
var issued = CreateAccount("authid-owner-user");
var other = CreateAccount("authid-other-user");
var authId = Register(issued, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _)
);
}
[Fact]
public void RejectsADifferentAddress()
{
var account = CreateAccount("authid-switch-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
);
}
[Fact]
public void MatchesTheUsernameCaseInsensitively()
{
var account = CreateAccount("AuthId-Case-User");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _)
);
}
[Fact]
public void MatchesAnIPv4MappedIPv6Address()
{
var account = CreateAccount("authid-mapped-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _)
);
}
[Fact]
public void RejectsAnUnknownAuthId()
{
var account = CreateAccount("authid-unknown-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _)
);
}
[Fact]
public void IsSingleUseAfterASuccess()
{
var account = CreateAccount("authid-once-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
);
}
// A rejected attempt must not consume the id, or anyone landing on a live one could burn it and
// force its owner to log in again.
[Fact]
public void SurvivesAnAttemptFromTheWrongAddress()
{
var account = CreateAccount("authid-not-burned-address-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
);
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
);
}
[Fact]
public void SurvivesAnAttemptForTheWrongAccount()
{
var account = CreateAccount("authid-not-burned-account-user");
var authId = Register(account, AddressX);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _)
);
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
);
}
[Fact]
public void ARejectedAttemptYieldsNoEntry()
{
var account = CreateAccount("authid-no-leak-user");
var authId = Register(account, AddressX);
IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out var entry);
Assert.Null(entry.Account);
}
[Fact]
public void AnExpiredIdIsSpentByItsOwner()
{
var account = CreateAccount("authid-expired-spent-user");
var authId = Register(account, AddressX);
var now = Core._now;
try
{
Core._now = now + TimeSpan.FromMinutes(30.0);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Expired,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
);
Assert.Equal(0, IncomingAccountPackets.AuthIdWindowCount);
}
finally
{
Core._now = now;
}
}
// Expiry is not a lockout. The game login always verified the password before any of this
// existed, so falling back to that verify is the behaviour we started from.
[Fact]
public void ExpiresIntoAPasswordVerifyRatherThanARejection()
{
var account = CreateAccount("authid-expired-user");
var authId = Register(account, AddressX);
var now = Core._now;
try
{
Core._now = now + TimeSpan.FromMinutes(30.0);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Expired,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry)
);
// Still carries the client version the game login needs.
Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version);
}
finally
{
Core._now = now;
}
}
[Fact]
public void AnExpiredIdFromAnotherAddressIsStillRejected()
{
var account = CreateAccount("authid-expired-elsewhere-user");
var authId = Register(account, AddressX);
var now = Core._now;
try
{
Core._now = now + TimeSpan.FromMinutes(30.0);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Rejected,
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
);
}
finally
{
Core._now = now;
}
}
private static int Ensure(int existingAuthId, IAccount account, IPAddress address) =>
IncomingAccountPackets.EnsureAuthId(
existingAuthId,
account,
address,
new ClientVersion(7, 0, 0, 0)
);
[Fact]
public void IssuesAnIdWhenTheConnectionHasNone()
{
var account = CreateAccount("authid-first-select-user");
var authId = Ensure(0, account, AddressX);
Assert.NotEqual(0, authId);
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
}
// Handing the same id back rather than minting another is what makes an orphan impossible,
// instead of something to clean up afterwards.
[Fact]
public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow()
{
var account = CreateAccount("authid-reselect-user");
var first = Ensure(0, account, AddressX);
for (var i = 0; i < 10; i++)
{
Assert.Equal(first, Ensure(first, account, AddressX));
}
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _)
);
}
[Fact]
public void AbandonedIdsAreSweptWhenNewOnesAreIssued()
{
var abandoned = CreateAccount("authid-abandoned-user");
var live = CreateAccount("authid-live-user");
var now = Core._now;
try
{
for (var i = 0; i < 128; i++)
{
Register(abandoned, AddressX);
}
Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount);
Core._now = now + TimeSpan.FromMinutes(30.0);
var liveId = Register(live, AddressX);
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _)
);
}
finally
{
Core._now = now;
}
}
// A login rush is not a backlog. Every id belongs to a client on its way to redeem it, so none
// may be discarded to hold the window at some arbitrary size.
[Fact]
public void ALoginRushDoesNotEvictAnyonesAuthId()
{
var account = CreateAccount("authid-rush-user");
var ids = new int[800];
for (var i = 0; i < ids.Length; i++)
{
ids[i] = Register(account, AddressX);
}
Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount);
// Every id issued during the rush is still redeemable, including the first one.
for (var i = 0; i < ids.Length; i++)
{
Assert.Equal(
IncomingAccountPackets.AuthIdResult.Vouched,
IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _)
);
}
}
[Fact]
public void PreAuthenticatedGameLogin_SkipsThePasswordCheck()
{
var account = CreateAccount("authid-preauth-user");
using var ns = PacketTestUtilities.CreateTestNetState();
// A wrong password is accepted only because the auth id already vouched for the account.
var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true);
GameServer.GameServerLoginEvent(e);
Assert.True(e.Accepted);
}
[Fact]
public void GameLoginWithoutPreAuthentication_StillChecksThePassword()
{
var account = CreateAccount("authid-nopreauth-user");
using var ns = PacketTestUtilities.CreateTestNetState();
var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false);
GameServer.GameServerLoginEvent(wrong);
Assert.False(wrong.Accepted);
var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false);
GameServer.GameServerLoginEvent(right);
Assert.True(right.Accepted);
}
[Fact]
public void GeneratesDistinctAuthIds()
{
var account = CreateAccount("authid-distinct-user");
Assert.NotEqual(Register(account, AddressX), Register(account, AddressX));
}
}

View file

@ -343,7 +343,9 @@ public static class AccountHandler
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
e.Accepted = false; e.Accepted = false;
} }
else if (!acct.CheckPassword(pw)) // The auth id was only issued after the account login packet verified this password, so
// re-deriving the hash costs a second Argon2 verify to answer the same question.
else if (!e.PreAuthenticated && !acct.CheckPassword(pw))
{ {
logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
e.Accepted = false; e.Accepted = false;

View file

@ -37,9 +37,8 @@ public class Argon2PasswordProtection : IPasswordProtection
public bool ValidatePassword(string encryptedPassword, string plainPassword) => public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
_passwordHasher.Verify(encryptedPassword, plainPassword); _passwordHasher.Verify(encryptedPassword, plainPassword);
// The PHC string carries the parameters it was hashed with, so verification uses those rather // Verification uses the parameters embedded in the PHC string, not the configured ones, so
// than the configured ones. Comparing them is what lets a parameter change reach existing // comparing them is what lets a parameter change reach existing accounts.
// accounts.
public bool NeedsRehash(string encryptedPassword) public bool NeedsRehash(string encryptedPassword)
{ {
// Unparseable but verified: a format this build does not understand, so rewrite it. // Unparseable but verified: a format this build does not understand, so rewrite it.

View file

@ -6,13 +6,21 @@ public static partial class GameServer
{ {
public class GameLoginEventArgs public class GameLoginEventArgs
{ {
public GameLoginEventArgs(NetState state, string un, string pw) public GameLoginEventArgs(NetState state, string un, string pw, bool preAuthenticated)
{ {
State = state; State = state;
Username = un; Username = un;
Password = pw; Password = pw;
PreAuthenticated = preAuthenticated;
} }
/// <summary>
/// The auth id presented on this game login was issued to this account, from this address,
/// after the account login packet verified the password. Read-only so a subscriber cannot
/// grant itself the skip.
/// </summary>
public bool PreAuthenticated { get; }
public NetState State { get; } public NetState State { get; }
public string Username { get; } public string Username { get; }

View file

@ -17,6 +17,9 @@ using System;
using System.Buffers; using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net;
using System.Security.Cryptography;
using Server.Accounting;
using Server.Engines.CharacterCreation; using Server.Engines.CharacterCreation;
using Server.Misc; using Server.Misc;
using Server.Mobiles; using Server.Mobiles;
@ -25,7 +28,16 @@ namespace Server.Network;
public static class IncomingAccountPackets public static class IncomingAccountPackets
{ {
// Initial capacity and the point at which issuing sweeps expired ids. Not a cap; the window
// grows rather than evicting a live id.
private const int _authIDWindowSize = 128; private const int _authIDWindowSize = 128;
private static int _authIdPurgeThreshold = _authIDWindowSize;
// The gap between PlayServerAck and the game login is seconds. Bounds how long a stolen id
// stays usable.
private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0);
private static readonly Dictionary<int, AuthIDPersistence> _authIDWindow = private static readonly Dictionary<int, AuthIDPersistence> _authIDWindow =
new(_authIDWindowSize); new(_authIDWindowSize);
@ -34,13 +46,33 @@ public static class IncomingAccountPackets
public DateTime Age; public DateTime Age;
public readonly ClientVersion Version; public readonly ClientVersion Version;
public AuthIDPersistence(ClientVersion v) // GameLogin skips its password verify when both match, so the id is a bearer token and has
// to be bound to whatever earned it.
public readonly IAccount Account;
public readonly IPAddress Address;
public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address)
{ {
Age = Core.Now; Age = Core.Now;
Version = v; Version = v;
Account = account;
Address = Utility.Intern(address);
} }
} }
internal enum AuthIdResult
{
// No such id, or it was issued for a different account or address.
Rejected,
// Right account and address, too old to stand in for the verify. Idling on the server list
// is normal, so this falls back to the password check rather than becoming a lockout.
Expired,
// Issued to this account, from this address, recently. Stands in for the password verify.
Vouched
}
public static unsafe void Configure() public static unsafe void Configure()
{ {
IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true); IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true);
@ -312,42 +344,92 @@ public static class IncomingAccountPackets
} }
} }
private static int GenerateAuthID(this NetState state) private static int GenerateAuthID(this NetState state) =>
EnsureAuthId(state.AuthId, state.Account, state.Address, state.Version);
/// <summary>
/// One id per connection, by construction. Choosing a server queues a disconnect that is not
/// drained until the next slice, so a client pipelining another select into the same buffer
/// arrives here again; handing back the id it already holds cannot orphan one.
/// </summary>
internal static int EnsureAuthId(int existingAuthId, IAccount account, IPAddress address, ClientVersion version)
=> existingAuthId != 0 ? existingAuthId : RegisterAuthId(account, address, version);
internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version)
{ {
if (_authIDWindow.Count == _authIDWindowSize) // Sweep the ids left behind by clients that picked a server and never arrived, but never
// evict a live one to make room -- the client holding it is on its way to redeem it. If all
// are live the window grows, which is a login rush, not a backlog. Each entry costs a
// successful password verify, so the size is self-limiting.
if (_authIDWindow.Count >= _authIdPurgeThreshold)
{ {
var oldestID = 0; PurgeExpiredAuthIds();
var oldest = DateTime.MaxValue; _authIdPurgeThreshold = Math.Max(_authIDWindowSize, _authIDWindow.Count * 2);
foreach (var (key, authId) in _authIDWindow)
{
if (authId.Age < oldest)
{
oldestID = key;
oldest = authId.Age;
}
}
_authIDWindow.Remove(oldestID);
} }
int authID; int authID;
// The id stands in for a password verify, so it has to be unguessable. Zero is reserved:
// GameLogin reads state.AuthId == 0 as "no auth id was issued".
do do
{ {
authID = Utility.Random(1, int.MaxValue - 1); authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue);
} while (authID == 0 || _authIDWindow.ContainsKey(authID));
if (Utility.RandomBool()) _authIDWindow[authID] = new AuthIDPersistence(version, account, address);
{
authID |= 1 << 31;
}
} while (_authIDWindow.ContainsKey(authID));
_authIDWindow[authID] = new AuthIDPersistence(state.Version);
return authID; return authID;
} }
/// <summary>
/// Spends an auth id, but only for the account and address it was issued to. An address
/// mismatch is <see cref="AuthIdResult.Rejected"/> rather than a fallback: network switching
/// mid-login is not supported.
/// </summary>
internal static AuthIdResult ConsumeAuthId(int authId, string username, IPAddress address, out AuthIDPersistence entry)
{
if (!_authIDWindow.TryGetValue(authId, out entry))
{
return AuthIdResult.Rejected;
}
// Look, then take: removing before ownership is proven would let anyone landing on a live id
// burn it, leaving its owner to log in again. Address before username, so a remote guesser
// never learns whether a username matched.
if (!Utility.Intern(address).Equals(entry.Address)
|| entry.Account == null || !username.InsensitiveEquals(entry.Account.Username))
{
entry = default;
return AuthIdResult.Rejected;
}
// Theirs, so spend it. Expired counts as spent; it has done all it is ever going to do.
_authIDWindow.Remove(authId);
return Core.Now - entry.Age > _authIDLifetime ? AuthIdResult.Expired : AuthIdResult.Vouched;
}
private static void PurgeExpiredAuthIds()
{
var now = Core.Now;
foreach (var (key, entry) in _authIDWindow)
{
if (now - entry.Age > _authIDLifetime)
{
_authIDWindow.Remove(key);
}
}
}
internal static void ClearAuthIdWindow()
{
_authIDWindow.Clear();
_authIdPurgeThreshold = _authIDWindowSize;
}
internal static int AuthIdWindowCount => _authIDWindow.Count;
public static void GameLogin(NetState state, SpanReader reader) public static void GameLogin(NetState state, SpanReader reader)
{ {
if (state.SentFirstPacket) if (state.SentFirstPacket)
@ -360,12 +442,6 @@ public static class IncomingAccountPackets
var authId = reader.ReadInt32(); var authId = reader.ReadInt32();
if (!_authIDWindow.TryGetValue(authId, out var ap))
{
state.LogInfo("Invalid client detected, disconnecting...");
state.Disconnect("Unable to find auth id.");
}
if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed)
{ {
state.LogInfo("Invalid client detected, disconnecting..."); state.LogInfo("Invalid client detected, disconnecting...");
@ -373,14 +449,28 @@ public static class IncomingAccountPackets
return; return;
} }
_authIDWindow.Remove(authId);
state.Version = ap.Version;
state.Seeded = true;
var username = reader.ReadLatin1Safe(30); var username = reader.ReadLatin1Safe(30);
var password = reader.ReadLatin1Safe(30); var password = reader.ReadLatin1Safe(30);
var e = new GameServer.GameLoginEventArgs(state, username, password); var authResult = ConsumeAuthId(authId, username, state.Address, out var ap);
if (authResult == AuthIdResult.Rejected)
{
state.LogInfo("Invalid client detected, disconnecting...");
state.Disconnect("Unable to find auth id.");
return;
}
state.Version = ap.Version;
state.Seeded = true;
// Expired carries a usable entry; only the password verify skip is withheld.
var e = new GameServer.GameLoginEventArgs(
state,
username,
password,
authResult == AuthIdResult.Vouched
);
GameServer.GameServerLoginEvent(e); GameServer.GameServerLoginEvent(e);
@ -402,6 +492,14 @@ public static class IncomingAccountPackets
public static void PlayServer(NetState state, SpanReader reader) public static void PlayServer(NetState state, SpanReader reader)
{ {
// A server is picked once per connection. Picking again hands back an id this connection may
// already have spent on a game login, which the client could never redeem.
if (state.AuthId != 0)
{
state.Disconnect("Duplicate play server packet sent.");
return;
}
int index = reader.ReadInt16(); int index = reader.ReadInt16();
var info = state.ServerInfo; var info = state.ServerInfo;
var a = state.Account; var a = state.Account;
@ -414,7 +512,7 @@ public static class IncomingAccountPackets
{ {
var si = info[index]; var si = info[index];
state.AuthId = GenerateAuthID(state); state.AuthId = state.GenerateAuthID();
state.SentFirstPacket = false; state.SentFirstPacket = false;
state.SendPlayServerAck(si, state.AuthId); state.SendPlayServerAck(si, state.AuthId);
@ -423,6 +521,14 @@ public static class IncomingAccountPackets
public static void LoginServerSeed(NetState state, SpanReader reader) public static void LoginServerSeed(NetState state, SpanReader reader)
{ {
// Seeding happens once per connection. A second one restarts a handshake this connection
// already completed, which no real client does.
if (state.Seeded)
{
state.Disconnect("Duplicate login server seed packet sent.");
return;
}
state.Seed = reader.ReadInt32(); state.Seed = reader.ReadInt32();
state.Seeded = true; state.Seeded = true;