diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 640c05837..1325700d5 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -100,6 +100,9 @@ internal static class TestServerInitializer } World.Configure(); + // Registers the Accounts entity persistence, without which Accounts.NewAccount cannot + // resolve and no test can construct an Account. + Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs new file mode 100644 index 000000000..fc66c6277 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Net; +using Server.Accounting; +using Server.Accounting.Security; +using Server.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 DoesNotVouchForADifferentAccount() + { + var issued = CreateAccount("authid-owner-user"); + var other = CreateAccount("authid-other-user"); + var authId = Register(issued, AddressX); + + // AccountMismatch, not Rejected: the caller must still be able to accept this login by + // verifying the password, exactly as it did before pre-authentication existed. + Assert.Equal( + IncomingAccountPackets.AuthIdResult.AccountMismatch, + 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 _) + ); + } + + [Fact] + public void IsSpentEvenWhenTheAccountDoesNotMatch() + { + var account = CreateAccount("authid-spent-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.AccountMismatch, + IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _) + ); + + // Spent regardless, so a guessed id cannot be reused to enumerate usernames. + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + [Fact] + public void GeneratesDistinctAuthIds() + { + var account = CreateAccount("authid-distinct-user"); + + Assert.NotEqual(Register(account, AddressX), Register(account, AddressX)); + } +} diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 07a444d03..8d34bb06a 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -17,6 +17,9 @@ using System; using System.Buffers; using System.Collections.Generic; using System.IO; +using System.Net; +using System.Security.Cryptography; +using Server.Accounting; using Server.Engines.CharacterCreation; using Server.Misc; using Server.Mobiles; @@ -26,6 +29,11 @@ namespace Server.Network; public static class IncomingAccountPackets { private const int _authIDWindowSize = 128; + + // The gap between PlayServerAck and the client's game login is seconds. Two minutes is generous, + // and bounds how long a stolen id stays usable. + private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0); + private static readonly Dictionary _authIDWindow = new(_authIDWindowSize); @@ -34,13 +42,33 @@ public static class IncomingAccountPackets public DateTime Age; public readonly ClientVersion Version; - public AuthIDPersistence(ClientVersion v) + // The account and address that earned this id on the account login packet. GameLogin skips + // its own password verify when both match, so the id is a bearer token and must be bound. + public readonly IAccount Account; + public readonly IPAddress Address; + + public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address) { Age = Core.Now; Version = v; + Account = account; + Address = Utility.Intern(address); } } + internal enum AuthIdResult + { + // No such id, or it expired, or it came from another address. Indistinguishable from forged. + Rejected, + + // Live id from the right address, but for a different account than the one being logged + // into. Proves nothing, so the caller falls back to verifying the password. + AccountMismatch, + + // Issued to this account, from this address. Stands in for the password verify. + Vouched + } + public static unsafe void Configure() { IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true); @@ -312,9 +340,12 @@ public static class IncomingAccountPackets } } - private static int GenerateAuthID(this NetState state) + private static int GenerateAuthID(this NetState state) => + RegisterAuthId(state.Account, state.Address, state.Version); + + internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version) { - if (_authIDWindow.Count == _authIDWindowSize) + if (_authIDWindow.Count >= _authIDWindowSize) { var oldestID = 0; var oldest = DateTime.MaxValue; @@ -333,21 +364,46 @@ public static class IncomingAccountPackets int authID; + // A cryptographic draw across the whole int range: 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 { - authID = Utility.Random(1, int.MaxValue - 1); + authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue); + } while (authID == 0 || _authIDWindow.ContainsKey(authID)); - if (Utility.RandomBool()) - { - authID |= 1 << 31; - } - } while (_authIDWindow.ContainsKey(authID)); - - _authIDWindow[authID] = new AuthIDPersistence(state.Version); + _authIDWindow[authID] = new AuthIDPersistence(version, account, address); return authID; } + /// + /// Looks up and spends an auth id. The id is removed whether or not it vouches, so a guessed id + /// cannot be reused to enumerate usernames. An address mismatch is + /// rather than a fallback: network switching mid-login is + /// not supported. + /// + internal static AuthIdResult ConsumeAuthId( + int authId, string username, IPAddress address, out AuthIDPersistence entry + ) + { + if (!_authIDWindow.Remove(authId, out entry)) + { + return AuthIdResult.Rejected; + } + + if (Core.Now - entry.Age > _authIDLifetime || !Utility.Intern(address).Equals(entry.Address)) + { + return AuthIdResult.Rejected; + } + + return entry.Account != null && username.InsensitiveEquals(entry.Account.Username) + ? AuthIdResult.Vouched + : AuthIdResult.AccountMismatch; + } + + internal static void ClearAuthIdWindow() => _authIDWindow.Clear(); + public static void GameLogin(NetState state, SpanReader reader) { if (state.SentFirstPacket)