fix: Fixes Argon2 verify correctness and the password upgrade lockout (#2562)

> ⚠️ **Rollback hazard — one-way door once logins are taken.** Serialization is unchanged, so a save
> written by this build still *loads* on the previous one. Its contents do not survive the trip: on
> its first successful login each account is rehashed to `$argon2id$`, and the previous build ships
> Argon2.Bindings 1.19.0, whose `Verify` is gated by the verifier's own configured type and answers
> `false` for an `$argon2id$` hash. **After a shard running this build has accepted logins, do not
> roll back past this commit** — every account that logged in is locked out on the older binary, and
> the only recovery is rolling forward again or resetting passwords by hand. Roll back only from a
> save taken before the first post-deploy login.

Requires [Argon2.Bindings 1.20.0](https://github.com/modernuo/Argon2.Bindings/pull/14), now published.

## What

- Consume `Argon2.Bindings` 1.20.0, which resolves the Argon2 type from the stored PHC string rather than from the verifier's own configuration.
- Default to **Argon2id, m=16384, t=1, p=1** — 8.51 ms against the old Argon2i 8 MiB t=3 at 10.11 ms. Cheaper *and* stronger.
- Rehash on a successful login whenever the stored parameters are stale, not only when the algorithm changes.
- Fix `SetPassword`, which derived the password phrase from the outgoing algorithm while storing it under the incoming one.

## Why

**Verification was gated by the verifier's configured type.** `Verify` passed the instance's own `ArgonType` to native `argon2_verify`, whose `decode_string` rejects a disagreeing `$argon2i$`/`$argon2id$` prefix and returns `DECODING_FAIL` — folded into `false`, the same answer as a wrong password. Switching the default type would have locked out every existing account, and `VerifyAndUpdate` could not have migrated them either: it delegates to the same type-fixed `Verify` and never compared `ArgonType`. Fixed upstream in 1.20.0. The pinned legacy-`$argon2i$` test here fails on 1.19.0 for exactly that reason, which is what makes the package bump load-bearing rather than incidental.

**Changing the defaults would otherwise have reached nobody.** Argon2's PHC string embeds `m`, `t` and `p`, so verification uses the parameters stored with each account, not the configured ones — and verification is the hot path. `CheckPassword` only rehashed when the *algorithm* changed, never when its cost parameters did, so on an established shard the new defaults would have applied to new accounts only. `IPasswordProtection.NeedsRehash` closes that: it defaults to `false`, so PBKDF2 and the `HashAlgorithm` protections are untouched — only Argon2 carries its cost inside the stored value.

**`SetPassword` picked the phrase rule from the wrong algorithm.** SHA1 and SHA2 salt the phrase with the username; Argon2 and PBKDF2 do not. It chose the rule from the *outgoing* algorithm while storing under the *incoming* one, so any algorithm change wrote a credential its own next verify could not reproduce. It now assigns `PasswordAlgorithm` first and derives the phrase from that. Note this ordering is load-bearing and invisible — `UpgradingAlgorithm_DoesNotLockTheAccountOut` is what pins it.

## Cost

Verification is re-derivation, so these are login numbers. A full login calls `CheckPassword` twice — `AccountLogin` (0x80) then `GameLogin` (0x91): **~20 ms before, ~17 ms after**, plus a one-time ~8.5 ms rehash on each account's migrating login.

That cost is still paid on the game loop. Moving hashing off-loop is deliberately **not** in this PR — it needs a pending-auth state in the login handlers, bounding of in-flight hashes, and login rate limiting.
This commit is contained in:
Kamron Batman 2026-08-08 00:24:59 -07:00 committed by GitHub
parent 23dc6649a0
commit b2c59191bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 180 additions and 7 deletions

View file

@ -100,6 +100,9 @@ internal static class TestServerInitializer
} }
World.Configure(); 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(); RaceDefinitions.Configure();
MovementImpl.Configure(); MovementImpl.Configure();
PathFollower.Configure(); PathFollower.Configure();

View file

@ -0,0 +1,74 @@
using System;
using Server.Accounting;
using Server.Accounting.Security;
using Xunit;
namespace Server.Tests.Accounting;
[Collection("Sequential UOContent Tests")]
public class AccountPasswordTests : IDisposable
{
private const string Password = "hunter2";
// CurrentAlgorithm is process-wide state shared with the rest of the collection.
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
[Theory]
[InlineData(PasswordProtectionAlgorithm.SHA1)]
[InlineData(PasswordProtectionAlgorithm.SHA2)]
[InlineData(PasswordProtectionAlgorithm.PBKDF2)]
[InlineData(PasswordProtectionAlgorithm.Argon2)]
public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm)
{
AccountSecurity.CurrentAlgorithm = algorithm;
var account = new Account($"new-{algorithm}-user", Password);
Assert.Equal(algorithm, account.PasswordAlgorithm);
Assert.True(account.CheckPassword(Password));
Assert.False(account.CheckPassword("wrong-password"));
}
// SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversing those two
// lines salts the hash by the outgoing algorithm's rule and stores it under the incoming one,
// which verifies once and then never again.
[Theory]
[InlineData(PasswordProtectionAlgorithm.SHA1)]
[InlineData(PasswordProtectionAlgorithm.SHA2)]
[InlineData(PasswordProtectionAlgorithm.PBKDF2)]
public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from)
{
AccountSecurity.CurrentAlgorithm = from;
var account = new Account($"upgrade-{from}-user", Password);
Assert.True(account.CheckPassword(Password));
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
Assert.True(account.CheckPassword(Password));
Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm);
// Must verify against what the rehash wrote.
Assert.True(account.CheckPassword(Password));
Assert.False(account.CheckPassword("wrong-password"));
}
[Fact]
public void StaleArgon2Parameters_AreRehashedOnLogin()
{
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
var account = new Account("stale-params-user", Password);
// The shipping default before this change: Argon2i, m=8192, t=3, p=1.
account.Password =
"$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
Assert.True(account.CheckPassword(Password));
Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password);
// Already current: verifying again must not rewrite the hash.
var afterFirst = account.Password;
Assert.True(account.CheckPassword(Password));
Assert.Equal(afterFirst, account.Password);
}
}

View file

@ -74,4 +74,64 @@ public class PasswordProtectionTest
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
} }
// The shipping default before this change. 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";
[Fact]
public void Argon2_ValidatesLegacyArgon2iHash()
{
Assert.True(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "hunter2"));
Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong"));
}
[Theory]
// type, memory, time, parallelism -> expected NeedsRehash
[InlineData("argon2id", 16384, 1, 1, false)] // current defaults
[InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default
[InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory
[InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations
[InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism
[InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type
public void Argon2_NeedsRehash_ComparesTypeAndCost(
string type, int memory, int time, int parallelism, bool expected
)
{
var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" +
"LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
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.
[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")]
// 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to.
[InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")]
public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash)
{
Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash));
}
[Theory]
[InlineData("")]
[InlineData("not-a-hash")]
public void Argon2_NeedsRehash_IsTrueForUnparseableHashes(string hash)
{
Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash));
}
[Fact]
public void NonArgon2Protections_NeverNeedRehash()
{
Assert.False(PBKDF2PasswordProtection.Instance.NeedsRehash("anything"));
Assert.False(HashAlgorithmPasswordProtection.SHA2Instance.NeedsRehash("anything"));
Assert.False(HashAlgorithmPasswordProtection.SHA1Instance.NeedsRehash("anything"));
Assert.False(HashAlgorithmPasswordProtection.MD5Instance.NeedsRehash("anything"));
}
} }

View file

@ -378,12 +378,12 @@ public partial class Account : IAccount, IComparable<Account>
public void SetPassword(string plainPassword) public void SetPassword(string plainPassword)
{ {
var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
var phrase = PasswordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
? $"{_username}{plainPassword}" ? $"{_username}{plainPassword}"
: plainPassword; : plainPassword;
Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase);
PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
} }
public bool CheckPassword(string plainPassword) public bool CheckPassword(string plainPassword)
@ -399,7 +399,8 @@ public partial class Account : IAccount, IComparable<Account>
} }
// Upgrade the password protection in case we change the algorithm // Upgrade the password protection in case we change the algorithm
if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm) if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm ||
AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password))
{ {
SetPassword(plainPassword); SetPassword(plainPassword);
} }

View file

@ -4,5 +4,12 @@ namespace Server.Accounting
{ {
string EncryptPassword(string plainPassword); string EncryptPassword(string plainPassword);
bool ValidatePassword(string encryptedPassword, string plainPassword); bool ValidatePassword(string encryptedPassword, string plainPassword);
/// <summary>
/// True when <paramref name="encryptedPassword"/> was produced with parameters that differ
/// from the ones this protection currently uses, so a successful login should rewrite it.
/// Algorithms whose cost is not embedded in the stored value never need this.
/// </summary>
bool NeedsRehash(string encryptedPassword) => false;
} }
} }

View file

@ -21,11 +21,38 @@ public class Argon2PasswordProtection : IPasswordProtection
{ {
public static IPasswordProtection Instance = new Argon2PasswordProtection(); public static IPasswordProtection Instance = new Argon2PasswordProtection();
private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create()); // 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(
time: 1,
memory: 16384,
parallel: 1,
type: Argon2Type.Argon2id,
rng: RandomNumberGenerator.Create()
);
public string EncryptPassword(string plainPassword) => public string EncryptPassword(string plainPassword) =>
m_PasswordHasher.Hash(plainPassword); _passwordHasher.Hash(plainPassword);
public bool ValidatePassword(string encryptedPassword, string plainPassword) => public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
m_PasswordHasher.Verify(encryptedPassword, plainPassword); _passwordHasher.Verify(encryptedPassword, plainPassword);
// The PHC string carries the parameters it was hashed with, so verification uses those rather
// than the configured ones. Comparing them is what lets a parameter change reach existing
// accounts.
public bool NeedsRehash(string encryptedPassword)
{
// Unparseable but verified: a format this build does not understand, so rewrite it.
if (!Argon2PasswordHasher.TryExtractMetadataValues(encryptedPassword, out var values))
{
return true;
}
return values.ArgonType != _passwordHasher.ArgonType
|| values.MemoryCost != _passwordHasher.MemoryCost
|| values.TimeCost != _passwordHasher.TimeCost
|| values.Parallelism != _passwordHasher.Parallelism
|| values.HashLength != (int)_passwordHasher.HashLength
|| values.SaltLength != (int)_passwordHasher.SaltLength;
}
} }

View file

@ -45,7 +45,7 @@
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.10" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="Argon2.Bindings" Version="1.19.0" /> <PackageReference Include="Argon2.Bindings" Version="1.20.0" />
<PackageReference Include="ModernUO.CodeGeneratedEvents.Annotations" Version="1.0.0" /> <PackageReference Include="ModernUO.CodeGeneratedEvents.Annotations" Version="1.0.0" />
<PackageReference Include="ModernUO.CodeGeneratedEvents.Generator" Version="1.0.3.2" PrivateAssets="all" /> <PackageReference Include="ModernUO.CodeGeneratedEvents.Generator" Version="1.0.3.2" PrivateAssets="all" />
<PackageReference Include="ZstdNet" Version="1.5.7" /> <PackageReference Include="ZstdNet" Version="1.5.7" />

View file

@ -98,6 +98,7 @@ Examples from the codebase:
accountHandler.enableAutoAccountCreation accountHandler.enableAutoAccountCreation
accountHandler.enablePlayerPasswordCommand accountHandler.enablePlayerPasswordCommand
accountHandler.maxAccountsPerIP accountHandler.maxAccountsPerIP
accountSecurity.encryptionAlgorithm
autosave.enabled autosave.enabled
autosave.saveDelay autosave.saveDelay
world.savePath world.savePath