ModernUO/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs
Kamron Batman b2c59191bd
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.
2026-08-08 00:24:59 -07:00

137 lines
5.7 KiB
C#

using System;
using Server.Accounting;
using Server.Accounting.Security;
using Xunit;
namespace Server.Tests.Accounting.Security;
public class PasswordProtectionTest
{
private const string plainPassword = "hello-good-sir";
[Theory]
[InlineData(typeof(Argon2PasswordProtection), null)]
[InlineData(typeof(PBKDF2PasswordProtection), null)]
[InlineData(typeof(HashAlgorithmPasswordProtection), "MD5")]
[InlineData(typeof(HashAlgorithmPasswordProtection), "SHA1")]
[InlineData(typeof(HashAlgorithmPasswordProtection), "SHA2")]
public void TestValidates(Type protectionType, string algorithmType)
{
IPasswordProtection passwordProtection;
if (protectionType == typeof(HashAlgorithmPasswordProtection))
{
passwordProtection = algorithmType switch
{
"SHA1" => HashAlgorithmPasswordProtection.SHA1Instance,
"SHA2" => HashAlgorithmPasswordProtection.SHA2Instance,
_ => HashAlgorithmPasswordProtection.MD5Instance,
};
}
else
{
passwordProtection = Activator.CreateInstance(protectionType) as IPasswordProtection;
}
if (passwordProtection == null)
{
Assert.Fail($"{protectionType.Name} is not an IPasswordProtection.");
}
var encryptedPassword = passwordProtection.EncryptPassword(plainPassword);
Assert.True(passwordProtection.ValidatePassword(encryptedPassword, plainPassword));
}
[Theory]
[InlineData(typeof(Argon2PasswordProtection), null)]
[InlineData(typeof(PBKDF2PasswordProtection), null)]
[InlineData(typeof(HashAlgorithmPasswordProtection), "MD5")]
[InlineData(typeof(HashAlgorithmPasswordProtection), "SHA1")]
[InlineData(typeof(HashAlgorithmPasswordProtection), "SHA2")]
public void TestPasswordDoesNotValidate(Type protectionType, string algorithmType)
{
IPasswordProtection passwordProtection;
if (protectionType == typeof(HashAlgorithmPasswordProtection))
{
passwordProtection = algorithmType switch
{
"SHA1" => HashAlgorithmPasswordProtection.SHA1Instance,
"SHA2" => HashAlgorithmPasswordProtection.SHA2Instance,
_ => HashAlgorithmPasswordProtection.MD5Instance,
};
}
else
{
passwordProtection = Activator.CreateInstance(protectionType) as IPasswordProtection;
}
if (passwordProtection == null)
{
Assert.Fail($"{protectionType.Name} is not an IPasswordProtection.");
}
var encryptedPassword = passwordProtection.EncryptPassword(plainPassword);
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"));
}
}