Files
git.stella-ops.org/src/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
master 607e72e2a1
Some checks failed
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-10-12 20:37:18 +03:00

138 lines
3.9 KiB
C#

using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Cryptography;
/// <summary>
/// PBKDF2-SHA256 password hasher for legacy credentials.
/// </summary>
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<byte> 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<byte> 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; }
}
}