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:
parent
23dc6649a0
commit
b2c59191bd
8 changed files with 180 additions and 7 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue