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:
parent
64e6fe5da8
commit
f33bcd6006
8 changed files with 545 additions and 50 deletions
|
|
@ -75,7 +75,7 @@ public class PasswordProtectionTest
|
|||
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".
|
||||
private const string LegacyArgon2iHash =
|
||||
"$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));
|
||||
}
|
||||
|
||||
// Digest and salt lengths are the decoded sizes of the base64 segments, not parameter-list
|
||||
// entries, so they need their own literals. Current type and cost throughout; only a length
|
||||
// differs from the defaults. The theory above is the negative control at default lengths.
|
||||
// Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they
|
||||
// need their own literals. Current type and cost throughout; only a length differs.
|
||||
[Theory]
|
||||
// 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")]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue