using System; using System.Security.Cryptography; using System.Text; namespace StellaOps.Cryptography; /// /// PBKDF2-SHA256 password hasher for legacy credentials. /// public sealed class Pbkdf2PasswordHasher : IPasswordHasher { private const int SaltLengthBytes = 16; private const int HashLengthBytes = 32; private const string Prefix = "PBKDF2"; public string Hash(string password, PasswordHashOptions options) { ArgumentException.ThrowIfNullOrEmpty(password); ArgumentNullException.ThrowIfNull(options); if (options.Algorithm != PasswordHashAlgorithm.Pbkdf2) { throw new InvalidOperationException("Pbkdf2PasswordHasher only supports the PBKDF2 algorithm."); } if (options.Iterations <= 0) { throw new InvalidOperationException("PBKDF2 requires a positive iteration count."); } Span salt = stackalloc byte[SaltLengthBytes]; RandomNumberGenerator.Fill(salt); var hash = Derive(password, salt, options.Iterations); var payload = new byte[1 + SaltLengthBytes + HashLengthBytes]; payload[0] = 0x01; salt.CopyTo(payload.AsSpan(1)); hash.CopyTo(payload.AsSpan(1 + SaltLengthBytes)); return $"{Prefix}.{options.Iterations}.{Convert.ToBase64String(payload)}"; } public bool Verify(string password, string encodedHash) { ArgumentException.ThrowIfNullOrEmpty(password); ArgumentException.ThrowIfNullOrEmpty(encodedHash); if (!TryParse(encodedHash, out var parsed)) { return false; } var computed = Derive(password, parsed.Salt, parsed.Iterations); return CryptographicOperations.FixedTimeEquals(parsed.Hash, computed); } public bool NeedsRehash(string encodedHash, PasswordHashOptions desired) { ArgumentNullException.ThrowIfNull(desired); if (!TryParse(encodedHash, out var parsed)) { return true; } if (desired.Algorithm != PasswordHashAlgorithm.Pbkdf2) { return true; } return parsed.Iterations != desired.Iterations; } private static byte[] Derive(string password, ReadOnlySpan salt, int iterations) { return Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(password), salt.ToArray(), iterations, HashAlgorithmName.SHA256, HashLengthBytes); } private static bool TryParse(string encodedHash, out Pbkdf2Parameters parsed) { parsed = default; var parts = encodedHash.Split('.', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 3 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal)) { return false; } if (!int.TryParse(parts[1], out var iterations) || iterations <= 0) { return false; } byte[] payload; try { payload = Convert.FromBase64String(parts[2]); } catch (FormatException) { return false; } if (payload.Length != 1 + SaltLengthBytes + HashLengthBytes || payload[0] != 0x01) { return false; } var salt = new byte[SaltLengthBytes]; var hash = new byte[HashLengthBytes]; Array.Copy(payload, 1, salt, 0, SaltLengthBytes); Array.Copy(payload, 1 + SaltLengthBytes, hash, 0, HashLengthBytes); parsed = new Pbkdf2Parameters(iterations, salt, hash); return true; } private readonly struct Pbkdf2Parameters { public Pbkdf2Parameters(int iterations, byte[] salt, byte[] hash) { Iterations = iterations; Salt = salt; Hash = hash; } public int Iterations { get; } public byte[] Salt { get; } public byte[] Hash { get; } } }