fix(accounts): compare salt and digest lengths in NeedsRehash

HashMetadataValues carries SaltLength and HashLength alongside the type and
the cost parameters, and NeedsRehash ignored both. A hash with a 16-byte
digest or an 8-byte salt verified fine and was judged current forever --
exactly the drift this method exists to catch. Unreachable today, since
every hash ModernUO has written uses the library defaults of 32 and 16, but
that is a property of the current configuration rather than of the check.

The two lengths are the decoded sizes of the base64 segments rather than
entries in the m/t/p list, so they cannot be varied through the existing
theory template; they get their own rows with pinned literals. The
"current defaults" row of that theory is the negative control.

Also name the benchmark behind the 8.5 ms / 10.1 ms figures in the parameter
comment: ModernUO-Benchmarks/Benchmarks/Argon2Hashing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-07 17:30:54 -07:00
parent dce677b8c3
commit ccfbc1878a
2 changed files with 26 additions and 4 deletions

View file

@ -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")]

View file

@ -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;
}
}