Usage
Hashing, verifying, rehashing and adding an algorithm.
Hashing
Hash generates a fresh salt every call, so hashing the same password twice gives two
different results.
using ApricotFramework.PasswordHasher;
var hash = hasher.Hash("correct horse battery staple");
hash.Algorithm; // "PBKDF2-SHA512"
hash.Iterations; // 210000
hash.Salt; // Base64
hash.Key; // Base64
var stored = hash.AsHash(); // this is the value to persistToString() returns the same string as AsHash(), and PasswordHash.TryParse reads one back.
Verifying, and rehashing when it is time
var result = hasher.Verify(stored, candidate);
if (!result.Verified)
{
return Unauthorized();
}
if (result.NeedsUpgrade)
{
// Verified means the plain password is correct and in hand right now — the only moment a
// rehash is possible without asking the user for anything.
stored = hasher.Hash(candidate).AsHash();
await this.users.UpdatePasswordHashAsync(userId, stored);
}NeedsUpgrade is true when the stored hash was written with a different algorithm or a
different iteration count from the ones currently configured. It is only ever true alongside
Verified, because an upgrade signal is meaningless for a password that did not match.
Failure behaviour
Verify returns "not verified" rather than throwing for anything wrong with the stored hash —
wrong field count, a non-numeric, non-positive or implausibly large iteration count, invalid
Base64, an unrecognised algorithm name.
Note
This is deliberate. A truncated or corrupted database value must fail the login, not
crash it. Verify throws only for a null argument, which is a programming error rather than
bad data.
Verification is constant-time, via CryptographicOperations.FixedTimeEquals, so a wrong guess
takes the same time regardless of how much of the derived key happened to match.
Note
Verification re-derives at the iteration count recorded in the stored hash, so a
corrupted or tampered row could otherwise cost unbounded CPU. Counts above
PasswordHash.MaximumIterations (10,000,000) are treated as malformed, which bounds what a
single verification can cost. That is roughly sixteen times the highest published
recommendation, so no realistic configuration comes near it.
Choosing parameters
using ApricotFramework.PasswordHasher.Impl;
using ApricotFramework.PasswordHasher.Options;
var hasher = new DefaultPasswordHasher(new PasswordHasherOptions
{
Algorithm = Pbkdf2Sha512Algorithm.AlgorithmName,
Iterations = 210_000,
});Both settings are optional: leave Algorithm unset for PBKDF2-SHA512, and Iterations unset
for that algorithm's own default.
Warning
The default of 210,000 iterations costs roughly 100–200ms per hash, by design — that cost is the security property. If you verify several candidate hashes in a loop, for instance one-time codes, that cost multiplies. Look up the single row you mean to check rather than scanning.
Adding an algorithm
An algorithm is a name, a default iteration count and a derivation function.
using ApricotFramework.PasswordHasher.Algorithms;
public sealed class MyAlgorithm : IPasswordHashAlgorithm
{
public string Name => "MY-ALG";
public int DefaultIterations => 100_000;
public byte[] DeriveKey(string password, byte[] salt, int iterations) => /* ... */;
}Pass it in as an addition. The built-in algorithms are always available, so you never restate them and registering your own cannot stop an existing hash from verifying:
var hasher = new DefaultPasswordHasher(
new PasswordHasherOptions { Algorithm = "MY-ALG" },
[new MyAlgorithm()]);
hasher.Verify(legacyRfcHash, password); // still worksVerification resolves the algorithm by the name recorded in each hash, so adding one only ever widens what can be read.
Note
An addition whose Name matches a built-in replaces it — that is the one way to
swap out a built-in implementation. Among the additions themselves, the last one wins. The
flip side is that a built-in cannot be removed, so RFC hashes are always accepted.
Warning
A name may not be blank or contain a ., because that character separates the
fields of an encoded hash — a name carrying one would shift every field, so the hash would
write successfully and then never parse back. Such a name is rejected when the hasher is
constructed, rather than being allowed to lose data silently.
The salt is generated by the hasher at a fixed 16 bytes, and the derived key is however many
bytes DeriveKey returns. Verification compares in constant time and treats a length mismatch
as a failure, so an algorithm that changes its key length simply stops matching its old hashes.