/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * * File: PasswordWorker.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 password thread. Strings and references it only carries: the worker reads no /// game state and writes none. /// /// Either half is optional, which is what lets one job type serve both callers. A login verifies /// and may rehash; an explicit password change only hashes. /// internal sealed class PasswordJob { public Account Account; /// Ties the job to a connection. Null when the work is not gated on one, such as a /// password change by an admin. public NetState State; /// Hash to verify against, with . public string StoredHash; /// Phrase to verify, or null to skip verification. public string VerifyPhrase; /// Phrase to hash, or null when nothing needs writing. public string HashPhrase; public PasswordProtectionAlgorithm TargetAlgorithm; /// Write slot claimed at dispatch, checked by /// . public int Sequence; /// Runs on the game loop with the result. Free to touch game state. public Action OnComplete; } internal readonly struct PasswordOutcome { /// True when no verification was asked for, or it succeeded. public readonly bool Verified; /// The derived hash, or null when nothing was hashed or verification failed. public readonly string Hash; public PasswordOutcome(bool verified, string hash) { Verified = verified; Hash = hash; } } /// /// Runs Argon2 off the game loop, where a verify costs ~8.9 ms of frozen world per login attempt. /// /// Exactly one worker. 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; a pool breaks /// that bound and is what would make the gain hardware-dependent. It also caps live Argon2 arenas /// at one, and does the least total harm to the loop's own cache footprint. /// /// ~110 verifies/sec, which is ample: login latency is not a concern, only loop time. /// internal sealed class PasswordWorker { private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker)); /// /// Backstop, not a flood defence. SentFirstPacket holds a connection to one pending /// verify and the engine caps connections at 4096 (NetState.Network.cs), so this matches /// that bound and can only trip if the one-per-connection invariant breaks. A cap low enough to /// blunt an attack would reject real players first -- during a mass reconnect they are the /// queue. Flood defence belongs at the connection layer. /// internal const int MaxPending = 4096; // 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 PasswordWorker _instance; // Needs a spare core to move work to, which a 1-2 core host does not have. Off in DEBUG: // dev boxes and test shards have few logins and are better served by the simpler path. 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: verification is static-backed and safe to share, hashing draws from a // per-instance RNG and is not. private readonly IPasswordProtection _argon2 = Argon2PasswordProtection.CreateIsolated(); private int _pending; private volatile bool _exit; private PasswordWorker() { _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 PasswordWorker Instance => _instance ??= new PasswordWorker(); 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(PasswordJob job) => Instance.TryEnqueueCore(job); private bool TryEnqueueCore(PasswordJob job) { if (Volatile.Read(ref _pending) >= MaxPending) { return false; } Interlocked.Increment(ref _pending); _queue.Enqueue(job); _work.Set(); return true; } /// /// Checked before each job, which bounds a save overlap to whichever hash was already running: /// the freeze holds the loop, so nothing new can be queued during it. PendingSave counts too -- /// the serialization threads are already awake and spinning on an empty queue by then. /// 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); // Gone while it waited: skip it rather than spend ~9 ms on a verdict nobody receives. // Running only goes true -> false, so a stale read wastes a hash but can never skip a // live connection. if (job.State?.Running != true) { continue; } PasswordOutcome outcome; try { outcome = Compute(job); } catch (Exception ex) { // A verdict must still come back, or the connection never gets a reply. logger.Error(ex, "Password verification failed for {Username}", job.Account?.Username); outcome = new PasswordOutcome(false, null); } Core.LoopContext.Post(() => Apply(job, outcome)); } } private PasswordOutcome Compute(PasswordJob job) { if (job.VerifyPhrase != null && !_argon2.ValidatePassword(job.StoredHash, job.VerifyPhrase)) { return new PasswordOutcome(false, null); } return new PasswordOutcome( true, job.HashPhrase == null ? null : _argon2.EncryptPassword(job.HashPhrase) ); } private static void Apply(PasswordJob job, PasswordOutcome outcome) { // Re-checked: a connection can drop while the result sits in the loop queue. Jobs with no // connection attached, such as an admin password change, are unaffected. if (job.State != null && !job.State.Running) { return; } if (outcome.Verified && outcome.Hash != null) { job.Account.ApplyPasswordWrite(job.Sequence, outcome.Hash, job.TargetAlgorithm); } job.OnComplete?.Invoke(job, outcome); } /// /// Sets a password, off the loop when that is available and inline otherwise, invoking /// on the loop either way. Both branches claim a write slot first, so /// the newest request wins however the work was routed. /// /// The confirmation belongs in , not at the call site: off-loop it has /// not happened yet when the call returns. /// internal static void SetPassword(Account account, string plainPassword, Action onDone) { if (!Enabled || AccountSecurity.CurrentAlgorithm != PasswordProtectionAlgorithm.Argon2) { account.SetPassword(plainPassword); onDone?.Invoke(true); return; } var job = new PasswordJob { Account = account, HashPhrase = account.GetRehashPhrase(plainPassword), TargetAlgorithm = AccountSecurity.CurrentAlgorithm, Sequence = account.BeginPasswordWrite(), OnComplete = (_, outcome) => onDone?.Invoke(outcome.Hash != null) }; if (!TryEnqueue(job)) { // Saturated. A password change is rare and must not be silently dropped, so this one // pays the hash on the loop rather than failing. account.SetPassword(plainPassword); onDone?.Invoke(true); } } /// Runs a job on the calling thread. The seam the tests drive. internal static PasswordOutcome ComputeInline(PasswordJob 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; } }