diff --git a/Projects/Server/Events/AccountLoginEvent.cs b/Projects/Server/Events/AccountLoginEvent.cs
index 5c81af000..b631ac68a 100644
--- a/Projects/Server/Events/AccountLoginEvent.cs
+++ b/Projects/Server/Events/AccountLoginEvent.cs
@@ -37,6 +37,13 @@ public class AccountLoginEventArgs
public bool Accepted { get; set; }
public ALRReason RejectReason { get; set; }
+
+ ///
+ /// No verdict yet: a subscriber moved the password check off the game loop and will reply
+ /// itself once it lands. The packet handler must not send an accept or a reject when this is
+ /// set, or the client receives two answers to one login.
+ ///
+ public bool Deferred { get; set; }
}
public static partial class EventSink
diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordVerificationTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordVerificationTests.cs
new file mode 100644
index 000000000..efd506bb3
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordVerificationTests.cs
@@ -0,0 +1,154 @@
+using System;
+using Server.Accounting;
+using Server.Accounting.Security;
+using Xunit;
+
+namespace Server.Tests.Accounting;
+
+[Collection("Sequential UOContent Tests")]
+public class PasswordVerificationTests : IDisposable
+{
+ private const string Password = "hunter2";
+
+ private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
+
+ public PasswordVerificationTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
+
+ public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
+
+ private static Account CreateAccount(string username) =>
+ Accounts.GetAccount(username) as Account ?? new Account(username, Password);
+
+ private static PasswordVerificationJob JobFor(Account account, string submitted) =>
+ new()
+ {
+ Account = account,
+ StoredHash = account.Password,
+ VerifyPhrase = account.GetVerifyPhrase(submitted),
+ RehashPhrase = account.NeedsPasswordUpgrade() ? account.GetRehashPhrase(submitted) : null,
+ TargetAlgorithm = AccountSecurity.CurrentAlgorithm
+ };
+
+ [Fact]
+ public void VerifiesTheCorrectPassword()
+ {
+ var account = CreateAccount("offloop-correct-user");
+
+ var outcome = PasswordVerificationWorker.ComputeInline(JobFor(account, Password));
+
+ Assert.True(outcome.Verified);
+ }
+
+ [Fact]
+ public void RejectsTheWrongPassword()
+ {
+ var account = CreateAccount("offloop-wrong-user");
+
+ var outcome = PasswordVerificationWorker.ComputeInline(JobFor(account, "not-the-password"));
+
+ Assert.False(outcome.Verified);
+ Assert.Null(outcome.UpgradedPassword);
+ }
+
+ [Fact]
+ public void ProducesNoUpgradeWhenParametersAreCurrent()
+ {
+ var account = CreateAccount("offloop-current-user");
+
+ var outcome = PasswordVerificationWorker.ComputeInline(JobFor(account, Password));
+
+ Assert.True(outcome.Verified);
+ Assert.Null(outcome.UpgradedPassword);
+ }
+
+ [Fact]
+ public void ProducesAnUpgradeWhenParametersAreStale()
+ {
+ var account = CreateAccount("offloop-stale-user");
+
+ // The shipping default before #2562: Argon2i, m=8192, t=3, p=1.
+ account.Password =
+ "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
+
+ var outcome = PasswordVerificationWorker.ComputeInline(JobFor(account, Password));
+
+ Assert.True(outcome.Verified);
+ Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.UpgradedPassword);
+ }
+
+ [Fact]
+ public void ProducesNoUpgradeWhenThePasswordIsWrong()
+ {
+ var account = CreateAccount("offloop-wrong-stale-user");
+ account.Password =
+ "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
+
+ var outcome = PasswordVerificationWorker.ComputeInline(JobFor(account, "not-the-password"));
+
+ Assert.False(outcome.Verified);
+ Assert.Null(outcome.UpgradedPassword);
+ }
+
+ [Fact]
+ public void AppliesAnUpgradeWhenThePasswordIsUnchanged()
+ {
+ var account = CreateAccount("offloop-apply-user");
+ var stored = account.Password;
+
+ var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password);
+ account.ApplyPasswordUpgrade(stored, upgraded, PasswordProtectionAlgorithm.Argon2);
+
+ Assert.Equal(upgraded, account.Password);
+ Assert.True(account.CheckPassword(Password));
+ }
+
+ ///
+ /// The verify runs off-loop for ~9 ms. A password changed in that window was already written
+ /// with current parameters; applying the stale upgrade would replace it with a hash of the
+ /// previous password and lock the account out.
+ ///
+ [Fact]
+ public void DropsAnUpgradeWhenThePasswordChangedMeanwhile()
+ {
+ var account = CreateAccount("offloop-stale-apply-user");
+ var storedAtDispatch = account.Password;
+
+ // Derived from the old password, as the worker would have.
+ var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password);
+
+ // ...and the password changes before the verdict lands.
+ account.SetPassword("a-brand-new-password");
+ var afterChange = account.Password;
+
+ account.ApplyPasswordUpgrade(storedAtDispatch, upgraded, PasswordProtectionAlgorithm.Argon2);
+
+ Assert.Equal(afterChange, account.Password);
+ Assert.True(account.CheckPassword("a-brand-new-password"));
+ Assert.False(account.CheckPassword(Password));
+ }
+
+ [Theory]
+ [InlineData(PasswordProtectionAlgorithm.SHA1)]
+ [InlineData(PasswordProtectionAlgorithm.SHA2)]
+ public void UsesTheUsernameSaltedPhraseForShaAccounts(PasswordProtectionAlgorithm algorithm)
+ {
+ AccountSecurity.CurrentAlgorithm = algorithm;
+ var account = CreateAccount($"offloop-phrase-{algorithm}-user");
+
+ // Verification must use the algorithm the hash was stored under...
+ Assert.Equal($"{account.Username}{Password}", account.GetVerifyPhrase(Password));
+
+ // ...and a rehash the one it is moving to. Swapping these is the #2562 lockout.
+ AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
+ Assert.Equal(Password, account.GetRehashPhrase(Password));
+ }
+
+ [Fact]
+ public void UsesTheBarePasswordForArgon2Accounts()
+ {
+ var account = CreateAccount("offloop-phrase-argon2-user");
+
+ Assert.Equal(Password, account.GetVerifyPhrase(Password));
+ Assert.Equal(Password, account.GetRehashPhrase(Password));
+ }
+}
diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs
index 45ede29bb..4a99cfaee 100644
--- a/Projects/UOContent/Accounting/Account.cs
+++ b/Projects/UOContent/Accounting/Account.cs
@@ -379,28 +379,63 @@ public partial class Account : IAccount, IComparable
public void SetPassword(string plainPassword)
{
PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
- var phrase = PasswordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
- ? $"{_username}{plainPassword}"
- : plainPassword;
+ Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(
+ AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword)
+ );
+ }
- Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase);
+ /// The phrase that verifies against the currently stored hash.
+ internal string GetVerifyPhrase(string plainPassword) =>
+ AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword);
+
+ /// The phrase a rehash to the configured algorithm would be derived from.
+ internal string GetRehashPhrase(string plainPassword) =>
+ AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword);
+
+ ///
+ /// Whether a successful login should rewrite the stored hash, because the algorithm changed or
+ /// its cost parameters moved.
+ ///
+ internal bool NeedsPasswordUpgrade() =>
+ _passwordAlgorithm != AccountSecurity.CurrentAlgorithm ||
+ AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password);
+
+ ///
+ /// Applies a rehash derived off the game loop. Not to be confused with the private
+ /// UpgradePassword below, which adopts a legacy hash wholesale during RunUO/ServUO
+ /// import.
+ ///
+ ///
+ /// The hash the verification started from. Milliseconds passed while it ran, and the password
+ /// may have been changed in that window by an admin or by the player. The newer value was
+ /// already written with current parameters and needs no upgrade, so this drops the stale one
+ /// rather than replacing a live credential with a hash of the previous password.
+ ///
+ internal void ApplyPasswordUpgrade(
+ string expectedCurrent, string newEncrypted, PasswordProtectionAlgorithm algorithm
+ )
+ {
+ if (!string.Equals(Password, expectedCurrent, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ PasswordAlgorithm = algorithm;
+ Password = newEncrypted;
}
public bool CheckPassword(string plainPassword)
{
- var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
- ? $"{_username}{plainPassword}"
- : plainPassword;
+ var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm)
+ .ValidatePassword(Password, GetVerifyPhrase(plainPassword));
- var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase);
if (!ok)
{
return false;
}
// Upgrade the password protection in case we change the algorithm
- if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm ||
- AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password))
+ if (NeedsPasswordUpgrade())
{
SetPassword(plainPassword);
}
diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs
index 542e16a40..8aea6cbe2 100644
--- a/Projects/UOContent/Accounting/AccountHandler.cs
+++ b/Projects/UOContent/Accounting/AccountHandler.cs
@@ -5,6 +5,7 @@ using System.Net;
using System.Runtime.CompilerServices;
using ModernUO.CodeGeneratedEvents;
using Server.Accounting;
+using Server.Accounting.Security;
using Server.Engines.CharacterCreation;
using Server.Engines.Help;
using Server.Logging;
@@ -307,25 +308,134 @@ public static class AccountHandler
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass;
}
- else if (!acct.CheckPassword(pw))
+ else
{
- logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
- e.RejectReason = ALRReason.BadPass;
+ HandlePasswordCheck(e, acct, pw);
}
- else if (acct.Banned)
+ }
+
+ ///
+ /// Decides where the password check runs, and produces the verdict when it runs here. An
+ /// else-if chain cannot express this: the off-loop path yields no verdict at all, and the
+ /// saturated path is a rejection rather than a reason to fall through and verify.
+ ///
+ private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw)
+ {
+ switch (DispatchPasswordCheck(e, acct, pw))
{
- logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un);
+ case PasswordCheckDispatch.Deferred:
+ {
+ e.Deferred = true;
+ return;
+ }
+ case PasswordCheckDispatch.Saturated:
+ {
+ // Reject rather than verify inline: steering the work back onto the loop is
+ // what a flood would be trying to achieve.
+ logger.Warning(
+ "Login: {NetState} Password verification queue full, rejecting '{Username}'",
+ e.State,
+ acct.Username
+ );
+
+ e.RejectReason = ALRReason.BadComm;
+ return;
+ }
+ }
+
+ if (!acct.CheckPassword(pw))
+ {
+ logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, acct.Username);
+ e.RejectReason = ALRReason.BadPass;
+ return;
+ }
+
+ ApplyVerifiedLogin(e, acct);
+ }
+
+ ///
+ /// Everything after the password is known good. Shared so an off-loop verdict lands in exactly
+ /// the same state as an inline one.
+ ///
+ private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct)
+ {
+ if (acct.Banned)
+ {
+ logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username);
e.RejectReason = ALRReason.Blocked;
+ return;
+ }
+
+ logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, acct.Username);
+ e.State.Account = acct;
+ e.Accepted = true;
+
+ acct.LogAccess(e.State);
+ LoginAllowlist.RecordLogin(e.State?.Address);
+ }
+
+ private enum PasswordCheckDispatch
+ {
+ /// Verify on the loop, as before.
+ Inline,
+
+ /// Handed to the verification thread; no verdict yet.
+ Deferred,
+
+ /// The queue is full.
+ Saturated
+ }
+
+ ///
+ /// Hands an Argon2 verify to the verification thread.
+ ///
+ /// Argon2-stored accounts only. A SHA/MD5 hash is verified in microseconds and its protection
+ /// holds a shared HashAlgorithm whose ComputeHash is not thread safe, so those
+ /// stay here. Their one-time rehash into Argon2 stays here too: it costs a migrating account a
+ /// single 8.9 ms login, exactly as it does today, and moving it would mean either making the
+ /// player wait on an upgrade that does not gate their verdict, or applying account state from a
+ /// callback with no login left to attach it to.
+ ///
+ private static PasswordCheckDispatch DispatchPasswordCheck(AccountLoginEventArgs e, Account acct, string pw)
+ {
+ if (!PasswordVerificationWorker.Enabled ||
+ AccountSecurity.CurrentAlgorithm != PasswordProtectionAlgorithm.Argon2 ||
+ acct.PasswordAlgorithm != PasswordProtectionAlgorithm.Argon2)
+ {
+ return PasswordCheckDispatch.Inline;
+ }
+
+ var job = new PasswordVerificationJob
+ {
+ Account = acct,
+ State = e.State,
+ StoredHash = acct.Password,
+ VerifyPhrase = acct.GetVerifyPhrase(pw),
+ RehashPhrase = acct.NeedsPasswordUpgrade() ? acct.GetRehashPhrase(pw) : null,
+ TargetAlgorithm = AccountSecurity.CurrentAlgorithm
+ };
+
+ return PasswordVerificationWorker.TryEnqueue(job)
+ ? PasswordCheckDispatch.Deferred
+ : PasswordCheckDispatch.Saturated;
+ }
+
+ /// Resumes a login whose password check ran on the verification thread.
+ internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified)
+ {
+ var e = new AccountLoginEventArgs(state, acct.Username, null);
+
+ if (verified)
+ {
+ ApplyVerifiedLogin(e, acct);
}
else
{
- logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un);
- e.State.Account = acct;
- e.Accepted = true;
-
- acct.LogAccess(e.State);
- LoginAllowlist.RecordLogin(e.State?.Address);
+ logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username);
+ e.RejectReason = ALRReason.BadPass;
}
+
+ IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason);
}
[OnEvent(nameof(GameServer.GameServerLoginEvent))]
diff --git a/Projects/UOContent/Accounting/Security/AccountSecurity.cs b/Projects/UOContent/Accounting/Security/AccountSecurity.cs
index 9f7faeafc..2a4385043 100644
--- a/Projects/UOContent/Accounting/Security/AccountSecurity.cs
+++ b/Projects/UOContent/Accounting/Security/AccountSecurity.cs
@@ -51,6 +51,19 @@ public static class AccountSecurity
}
}
+ ///
+ /// The string actually fed to the KDF. SHA1 and SHA2 salt by username; everything else hashes
+ /// the password alone. Verification must derive with the algorithm the stored hash was made
+ /// with, and a rehash with the one it is moving to -- deriving with the wrong one produces a
+ /// hash that verifies once and never again.
+ ///
+ public static string DerivePhrase(
+ PasswordProtectionAlgorithm algorithm, string username, string plainPassword
+ ) =>
+ algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
+ ? $"{username}{plainPassword}"
+ : plainPassword;
+
public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm)
{
var passwordProtection = algorithm switch
diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs
index 0a952117d..3676dba7d 100644
--- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs
+++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs
@@ -21,6 +21,14 @@ public class Argon2PasswordProtection : IPasswordProtection
{
public static IPasswordProtection Instance = new Argon2PasswordProtection();
+ ///
+ /// An instance sharing no state with . Verification is static-backed and
+ /// safe to call from anywhere, but hashing draws its salt from a per-instance
+ /// , so a thread that hashes off the game loop takes its own
+ /// rather than racing the loop for that one field.
+ ///
+ public static IPasswordProtection CreateIsolated() => new Argon2PasswordProtection();
+
// 16 MiB at t=1 is cheaper than 8 MiB at t=3 (8.5 ms vs 10.1 ms) and twice as memory-hard, which
// is what resists GPU and ASIC cracking. p=1: native argon2 spawns a thread per lane.
private readonly Argon2PasswordHasher _passwordHasher = new(
diff --git a/Projects/UOContent/Accounting/Security/PasswordVerificationWorker.cs b/Projects/UOContent/Accounting/Security/PasswordVerificationWorker.cs
new file mode 100644
index 000000000..ea08cc3dc
--- /dev/null
+++ b/Projects/UOContent/Accounting/Security/PasswordVerificationWorker.cs
@@ -0,0 +1,259 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: PasswordVerificationWorker.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using Server.Logging;
+using Server.Misc;
+using Server.Network;
+
+namespace Server.Accounting.Security;
+
+///
+/// Work handed to the verification thread. Everything here is either an immutable string or a
+/// reference the worker only carries -- the worker reads no game state and writes none.
+///
+internal sealed class PasswordVerificationJob
+{
+ public Account Account;
+ public NetState State;
+
+ /// The stored hash at dispatch, used to verify and to guard the upgrade against a
+ /// password change that lands while this runs.
+ public string StoredHash;
+
+ /// Phrase to verify against .
+ public string VerifyPhrase;
+
+ /// Phrase to rehash from, or null when no upgrade is due.
+ public string RehashPhrase;
+
+ public PasswordProtectionAlgorithm TargetAlgorithm;
+}
+
+internal readonly struct PasswordVerificationOutcome
+{
+ public readonly bool Verified;
+
+ /// The new hash, or null when the password did not verify or needed no upgrade.
+ public readonly string UpgradedPassword;
+
+ public PasswordVerificationOutcome(bool verified, string upgradedPassword)
+ {
+ Verified = verified;
+ UpgradedPassword = upgradedPassword;
+ }
+}
+
+///
+/// Runs Argon2 off the game loop.
+///
+/// An Argon2 verify is ~8.9 ms, which is more than half a frame of frozen world for every login
+/// attempt, successful or not. Measurement (docs/handoffs/2026-08-07-off-loop-argon2-hashing.md)
+/// puts the on-loop saving at 3.5-8.9 ms per login: the hand-off costs ~220 ns, and the only real
+/// residue is the loop's own work slowing while a memory-hard KDF evicts shared L3.
+///
+/// One worker, deliberately, for three reasons that agree:
+/// - the per-login contention tax falls with concurrency but total loop damage rises, so one
+/// hasher does the least harm to the loop;
+/// - a single background hasher cannot cost the loop more than the inline verify under any
+/// scheduling regime, because at worst it takes an equal share of one core -- which is what
+/// makes the measurement extrapolate to hardware we cannot inspect. A pool breaks that bound;
+/// - exactly one 16 MiB Argon2 arena is live at a time whatever the login volume, which answers
+/// memory-exhaustion without a separate cap.
+///
+/// Throughput is ~110 verifies/sec. Wall-clock login latency is explicitly not a concern, so
+/// head-of-line blocking during a rush costs nothing.
+///
+internal sealed class PasswordVerificationWorker
+{
+ private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordVerificationWorker));
+
+ ///
+ /// Pending cap. Overflow rejects the login rather than verifying it inline: falling back to the
+ /// loop would let anyone who fills the queue steer the work back onto the thread this exists to
+ /// protect.
+ ///
+ internal const int MaxPending = 128;
+
+ // Nothing signals the worker when a save freeze ends, so it re-checks on this interval -- but
+ // only while a save is in progress, never in steady state.
+ private const int SaveGatePollMs = 50;
+
+ private static PasswordVerificationWorker _instance;
+
+ ///
+ /// Off-loop verification needs a spare core to move work to, which a 1-2 core host does not
+ /// have, and is pointless on a dev box or test shard where logins are rare and the simpler
+ /// path is easier to reason about.
+ ///
+ internal static bool Enabled { get; } =
+#if DEBUG
+ false;
+#else
+ Environment.ProcessorCount >= 4;
+#endif
+
+ private readonly Thread _thread;
+ private readonly AutoResetEvent _work = new(false);
+ private readonly ConcurrentQueue _queue = new();
+
+ // Its own Argon2, sharing no RNG with the loop's. Verification is static-backed and would be
+ // safe either way; hashing is not.
+ private readonly IPasswordProtection _argon2 = Argon2PasswordProtection.CreateIsolated();
+
+ private int _pending;
+ private volatile bool _exit;
+
+ private PasswordVerificationWorker()
+ {
+ _thread = new Thread(Execute)
+ {
+ IsBackground = true,
+ Name = "Password Verification"
+ };
+
+ _thread.Start();
+ }
+
+ // Created on first use, so a shard that never takes the off-loop path never allocates a thread.
+ private static PasswordVerificationWorker Instance => _instance ??= new PasswordVerificationWorker();
+
+ internal static int Pending => _instance?._pending ?? 0;
+
+ ///
+ /// Queues a job. False when the queue is full, in which case the caller must reject the login
+ /// without verifying.
+ ///
+ internal static bool TryEnqueue(PasswordVerificationJob job) => Instance.TryEnqueueCore(job);
+
+ private bool TryEnqueueCore(PasswordVerificationJob job)
+ {
+ if (Volatile.Read(ref _pending) >= MaxPending)
+ {
+ return false;
+ }
+
+ Interlocked.Increment(ref _pending);
+ _queue.Enqueue(job);
+ _work.Set();
+
+ return true;
+ }
+
+ ///
+ /// Argon2 only, and only outside the save freeze. The freeze runs on the loop, so nothing new
+ /// can be queued while it holds; checking before each job bounds the overlap to whichever hash
+ /// was already in flight. PendingSave counts too -- the serialization threads are awake and
+ /// spinning on an empty queue by then, which is the worst moment to add a competitor.
+ ///
+ private static bool CanRunNow() =>
+ World.WorldState is WorldState.Running or WorldState.WritingSave;
+
+ private void Execute()
+ {
+ while (!_exit)
+ {
+ if (_queue.IsEmpty)
+ {
+ // A kernel block at zero CPU. Set() during a hash leaves the event signalled, so a
+ // wake arriving mid-job is not lost.
+ _work.WaitOne();
+ continue;
+ }
+
+ if (!CanRunNow())
+ {
+ _work.WaitOne(SaveGatePollMs);
+ continue;
+ }
+
+ if (!_queue.TryDequeue(out var job))
+ {
+ continue;
+ }
+
+ Interlocked.Decrement(ref _pending);
+
+ PasswordVerificationOutcome outcome;
+
+ try
+ {
+ outcome = Compute(job);
+ }
+ catch (Exception ex)
+ {
+ // A verdict must still come back, or the connection waits forever for a reply.
+ logger.Error(ex, "Password verification failed for {Username}", job.Account?.Username);
+ outcome = new PasswordVerificationOutcome(false, null);
+ }
+
+ Core.LoopContext.Post(() => Apply(job, outcome));
+ }
+ }
+
+ private PasswordVerificationOutcome Compute(PasswordVerificationJob job)
+ {
+ if (!_argon2.ValidatePassword(job.StoredHash, job.VerifyPhrase))
+ {
+ return new PasswordVerificationOutcome(false, null);
+ }
+
+ return new PasswordVerificationOutcome(
+ true,
+ job.RehashPhrase == null ? null : _argon2.EncryptPassword(job.RehashPhrase)
+ );
+ }
+
+ private static void Apply(PasswordVerificationJob job, PasswordVerificationOutcome outcome)
+ {
+ var state = job.State;
+
+ // The connection may have gone while the hash ran. A dead NetState must not be revived, and
+ // nothing may be written on its behalf.
+ if (state?.Running != true)
+ {
+ return;
+ }
+
+ if (outcome.Verified && outcome.UpgradedPassword != null)
+ {
+ job.Account.ApplyPasswordUpgrade(job.StoredHash, outcome.UpgradedPassword, job.TargetAlgorithm);
+ }
+
+ AccountHandler.CompleteDeferredAccountLogin(state, job.Account, outcome.Verified);
+ }
+
+ /// Runs a job on the calling thread. The seam the tests drive, and the path taken when
+ /// off-loop verification is gated off.
+ internal static PasswordVerificationOutcome ComputeInline(PasswordVerificationJob job) =>
+ Instance.Compute(job);
+
+ internal static void Exit()
+ {
+ var instance = _instance;
+
+ if (instance == null)
+ {
+ return;
+ }
+
+ instance._exit = true;
+ instance._work.Set();
+ instance._thread.Join(TimeSpan.FromSeconds(5));
+ _instance = null;
+ }
+}
diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs
index 888eeb6d7..792ed880b 100644
--- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs
+++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs
@@ -564,7 +564,22 @@ public static class IncomingAccountPackets
EventSink.InvokeAccountLogin(accountLoginEventArgs);
- if (accountLoginEventArgs.Accepted)
+ // The password check moved off the loop; whoever took it replies when the verdict lands.
+ if (accountLoginEventArgs.Deferred)
+ {
+ return;
+ }
+
+ CompleteAccountLogin(state, accountLoginEventArgs.Accepted, accountLoginEventArgs.RejectReason);
+ }
+
+ ///
+ /// Replies to an account login. Split out so a verdict produced off the loop reaches the client
+ /// through exactly the same path as one produced inline.
+ ///
+ internal static void CompleteAccountLogin(NetState state, bool accepted, ALRReason rejectReason)
+ {
+ if (accepted)
{
var serverListEventArgs = new GatewayServer.ServerListEventArgs(state, state.Account);
@@ -584,7 +599,7 @@ public static class IncomingAccountPackets
else
{
state.Account = null;
- AccountLogin_ReplyRej(state, accountLoginEventArgs.RejectReason);
+ AccountLogin_ReplyRej(state, rejectReason);
}
}