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();
|
||||
// Registers the Accounts entity persistence, without which Accounts.NewAccount cannot
|
||||
// resolve and no test can construct an Account.
|
||||
Server.Accounting.Accounts.Configure();
|
||||
RaceDefinitions.Configure();
|
||||
MovementImpl.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"));
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue