Apricot Framework

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 persist

ToString() 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 works

Verification 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.

On this page