feat(network): bind the login auth id to its account and address
The auth id is about to stand in for the game login's password verify, so it has to be a real bearer token. It was none of those things: drawn from Utility.Random (a non-cryptographic PRNG), tied to nothing, and expiring only when the 128-slot window filled, which on a quiet shard is never. It is now a CSPRNG draw across the whole int range, bound to the account and origin address that earned it, spent on presentation whether or not it vouches, and dead after two minutes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
23dc6649a0
commit
d59da47220
3 changed files with 222 additions and 11 deletions
|
|
@ -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();
|
||||
|
|
|
|||
152
Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs
Normal file
152
Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, AuthIDPersistence> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <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.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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue