diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index 1a6e08f6f..a84557f7c 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -105,6 +105,21 @@ public class PasswordProtectionTest Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); } + // The digest and salt lengths are not in the parameter list -- they are the decoded sizes of the + // two base64 segments -- so they cannot be varied through the theory template above. Both hashes + // here carry the current type and cost; only a segment length differs from the library defaults + // (32-byte digest, 16-byte salt). The "current defaults" row of the theory above is the negative + // control: it uses those default lengths and must stay false. + [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")] diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs index f89150aa5..9f0221e43 100644 --- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs @@ -23,9 +23,10 @@ public class Argon2PasswordProtection : IPasswordProtection // Argon2id over Argon2i: RFC 9106 recommends Argon2i only where side-channel resistance is // required and memory is scarce. 16 MiB at t=1 measures cheaper than the old 8 MiB at t=3 - // (8.5 ms vs 10.1 ms) while doubling memory-hardness, which is the property that resists GPU - // and ASIC cracking; iterations mostly buy wall-clock. p=1 because native argon2 spawns a - // thread per lane, which is oversubscription on the 1-2 core hosts this path exists to serve. + // (8.5 ms vs 10.1 ms, measured by ModernUO-Benchmarks/Benchmarks/Argon2Hashing) while doubling + // memory-hardness, which is the property that resists GPU and ASIC cracking; iterations mostly + // buy wall-clock. p=1 because native argon2 spawns a thread per lane, which is oversubscription + // on the 1-2 core hosts this path exists to serve. private readonly Argon2PasswordHasher _passwordHasher = new( time: 1, memory: 16384, @@ -52,9 +53,15 @@ public class Argon2PasswordProtection : IPasswordProtection return true; } + // The digest and salt lengths live in the base64 segments rather than the parameter list, + // but they are just as much a part of "was this produced with the parameters we configure + // today". Casting the hasher's uint properties keeps the comparison signed-vs-signed; both + // are small byte counts, so the narrowing cannot lose anything. return values.ArgonType != _passwordHasher.ArgonType || values.MemoryCost != _passwordHasher.MemoryCost || values.TimeCost != _passwordHasher.TimeCost - || values.Parallelism != _passwordHasher.Parallelism; + || values.Parallelism != _passwordHasher.Parallelism + || values.HashLength != (int)_passwordHasher.HashLength + || values.SaltLength != (int)_passwordHasher.SaltLength; } }