Accounts corrupted by the pre-fix SetPassword hold argon2(username + password) tagged with an algorithm whose phrase rule omits the username. Nothing in the hash distinguishes that from a forgotten password, so the only detection is to retry with the other phrase on a failed verify -- which doubles the cost of every failed login, and failed logins are the credential-stuffing surface. Hence accountSecurity.repairMigratedPasswords, default false: an operator turns it on for a migration window and off again. Repaired accounts are force-rehashed because their stored parameters may already be current, in which case NeedsRehash would decline and the account would verify once and stay broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
5.3 KiB
C#
138 lines
5.3 KiB
C#
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";
|
|
|
|
// AccountSecurity.CurrentAlgorithm is process-wide static state, shared with every other
|
|
// class in the "Sequential UOContent Tests" collection. xUnit constructs/disposes this class
|
|
// once per test case, so capturing and restoring it here means every case -- current and any
|
|
// added later to this file -- starts from and leaves behind the ambient value, instead of
|
|
// bleeding whatever algorithm it last set into 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"));
|
|
}
|
|
|
|
[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;
|
|
|
|
// First login verifies under the old algorithm and rehashes under the new one.
|
|
Assert.True(account.CheckPassword(Password));
|
|
Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm);
|
|
|
|
// Second login must verify against what the first one 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);
|
|
|
|
// Simulate an account stored under the pre-1.20.0 default: 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("hunter2"));
|
|
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("hunter2"));
|
|
Assert.Equal(afterFirst, account.Password);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an account whose stored hash is what the pre-fix SetPassword produced when a SHA2
|
|
/// account was upgraded to Argon2: argon2(username + password), tagged Argon2, whose phrase
|
|
/// rule omits the username. Unrecoverable without the plaintext, hence the repair path.
|
|
/// </summary>
|
|
private static Account CreateMisMigratedAccount(string username)
|
|
{
|
|
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
|
|
var account = new Account(username, Password);
|
|
account.Password = AccountSecurity.CurrentPasswordProtection
|
|
.EncryptPassword($"{username}{Password}");
|
|
|
|
return account;
|
|
}
|
|
|
|
[Fact]
|
|
public void MisMigratedAccount_IsRejected_WhenRepairIsDisabled()
|
|
{
|
|
var account = CreateMisMigratedAccount("repair-off-user");
|
|
AccountSecurity.RepairMigratedPasswords = false;
|
|
|
|
Assert.False(account.CheckPassword(Password));
|
|
}
|
|
|
|
[Fact]
|
|
public void MisMigratedAccount_IsRepaired_WhenRepairIsEnabled()
|
|
{
|
|
var account = CreateMisMigratedAccount("repair-on-user");
|
|
AccountSecurity.RepairMigratedPasswords = true;
|
|
|
|
try
|
|
{
|
|
Assert.True(account.CheckPassword(Password));
|
|
}
|
|
finally
|
|
{
|
|
AccountSecurity.RepairMigratedPasswords = false;
|
|
}
|
|
|
|
// Repaired in place: it must now verify with the flag back off.
|
|
Assert.True(account.CheckPassword(Password));
|
|
Assert.False(account.CheckPassword("wrong-password"));
|
|
}
|
|
|
|
[Fact]
|
|
public void WrongPassword_IsStillRejected_WhenRepairIsEnabled()
|
|
{
|
|
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
|
|
var account = new Account("repair-wrong-pass-user", Password);
|
|
AccountSecurity.RepairMigratedPasswords = true;
|
|
|
|
try
|
|
{
|
|
Assert.False(account.CheckPassword("wrong-password"));
|
|
Assert.True(account.CheckPassword(Password));
|
|
}
|
|
finally
|
|
{
|
|
AccountSecurity.RepairMigratedPasswords = false;
|
|
}
|
|
}
|
|
}
|