Skip to content

Commit c6eed0b

Browse files
author
Vadim Belov
committed
Add unit tests for Pbkdf2PasswordHashService
Introduce `CryptographyTests` to validate password hashing functionality, covering performance, correctness, and error handling. Update `Pbkdf2PasswordHashService` to enforce input validation, set default iterations to 210,000, and improve documentation on security practices.
1 parent 8e70c3c commit c6eed0b

File tree

2 files changed

+379
-0
lines changed

2 files changed

+379
-0
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using System;
2+
using System.Text;
3+
using EasyExtensions.Services;
4+
using System.Security.Cryptography;
5+
using System.Diagnostics;
6+
using NUnit.Framework.Internal;
7+
8+
namespace EasyExtensions.Tests
9+
{
10+
[TestFixture]
11+
internal class CryptographyTests
12+
{
13+
private const string ValidPepper = "this-is-a-valid-test-pepper-12345"; // >16 bytes
14+
15+
[Test]
16+
public void HashIterationDuration_IsAtLeast200ms()
17+
{
18+
const int expectedMinDurationMs = 200; // 100 ms
19+
Stopwatch stopwatch = Stopwatch.StartNew();
20+
var svc = new Pbkdf2PasswordHashService(ValidPepper);
21+
var password = "SomePassword!";
22+
svc.Hash(password);
23+
Assert.That(stopwatch.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(expectedMinDurationMs),
24+
$"Hashing should take at least {expectedMinDurationMs} ms to be secure enough. " +
25+
$"If your hardware is very fast, consider increasing the default iteration count in the Pbkdf2PasswordHashService constructor.");
26+
}
27+
28+
[Test]
29+
public void Hash_And_Verify_Success()
30+
{
31+
var svc = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: 5_000);
32+
var password = "SuperSecret!";
33+
34+
var phc = svc.Hash(password);
35+
var ok = svc.Verify(password, phc, out var needsRehash);
36+
37+
using (Assert.EnterMultipleScope())
38+
{
39+
Assert.That(ok, Is.True);
40+
Assert.That(needsRehash, Is.False);
41+
}
42+
}
43+
44+
[Test]
45+
public void Verify_WrongPassword_Fails()
46+
{
47+
var svc = new Pbkdf2PasswordHashService(ValidPepper, iterations: 3_000);
48+
var phc = svc.Hash("CorrectHorseBatteryStaple");
49+
50+
var ok = svc.Verify("wrong-password", phc, out var needsRehash);
51+
52+
using (Assert.EnterMultipleScope())
53+
{
54+
Assert.That(ok, Is.False);
55+
Assert.That(needsRehash, Is.False);
56+
}
57+
}
58+
59+
[Test]
60+
public void Verify_WrongPepper_Fails()
61+
{
62+
var svc1 = new Pbkdf2PasswordHashService(ValidPepper, iterations: 2_000);
63+
var svc2 = new Pbkdf2PasswordHashService(ValidPepper + "-different", iterations: 2_000);
64+
var password = "Pa$$w0rd";
65+
66+
var phc = svc1.Hash(password);
67+
var ok = svc2.Verify(password, phc, out var needsRehash);
68+
69+
using (Assert.EnterMultipleScope())
70+
{
71+
Assert.That(ok, Is.False);
72+
Assert.That(needsRehash, Is.False);
73+
}
74+
}
75+
76+
[Test]
77+
public void Hash_ProducesDifferentHashes_DueToRandomSalt()
78+
{
79+
var svc = new Pbkdf2PasswordHashService(ValidPepper, iterations: 2_000);
80+
var password = "same-password";
81+
82+
var phc1 = svc.Hash(password);
83+
var phc2 = svc.Hash(password);
84+
85+
Assert.That(phc1, Is.Not.EqualTo(phc2));
86+
}
87+
88+
[Test]
89+
public void Hash_Format_IsValidPHC()
90+
{
91+
var iterations = 1_500;
92+
var svc = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: iterations);
93+
var phc = svc.Hash("abc");
94+
95+
// $pbkdf2-sha256$v=1$i=ITER$SALT$HASH
96+
Assert.That(phc, Does.StartWith("$pbkdf2-sha256$v=1$i=" + iterations + "$"));
97+
98+
var parts = phc.Split('$', StringSplitOptions.RemoveEmptyEntries);
99+
Assert.That(parts, Has.Length.EqualTo(5));
100+
using (Assert.EnterMultipleScope())
101+
{
102+
Assert.That(parts[0], Is.EqualTo("pbkdf2-sha256"));
103+
Assert.That(parts[1], Does.StartWith("v="));
104+
Assert.That(parts[2], Does.StartWith("i="));
105+
// Salt and hash should be valid base64
106+
Assert.That(() => Convert.FromBase64String(parts[3]), Throws.Nothing);
107+
Assert.That(() => Convert.FromBase64String(parts[4]), Throws.Nothing);
108+
}
109+
}
110+
111+
[Test]
112+
public void Verify_NeedsRehash_WhenVersionIncreased()
113+
{
114+
var svcV1 = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: 2_000);
115+
var phc = svcV1.Hash("pwd");
116+
117+
var svcV2 = new Pbkdf2PasswordHashService(ValidPepper, version: 2, iterations: 2_000);
118+
var ok = svcV2.Verify("pwd", phc, out var needsRehash);
119+
120+
using (Assert.EnterMultipleScope())
121+
{
122+
Assert.That(ok, Is.True);
123+
Assert.That(needsRehash, Is.True);
124+
}
125+
}
126+
127+
[Test]
128+
public void Verify_NeedsRehash_WhenIterationsIncreased()
129+
{
130+
var svcLow = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: 1_000);
131+
var phc = svcLow.Hash("pwd");
132+
133+
var svcHigh = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: 2_000);
134+
var ok = svcHigh.Verify("pwd", phc, out var needsRehash);
135+
136+
using (Assert.EnterMultipleScope())
137+
{
138+
Assert.That(ok, Is.True);
139+
Assert.That(needsRehash, Is.True);
140+
}
141+
}
142+
143+
[Test]
144+
public void Verify_NeedsRehash_WhenHashSizeDifferent()
145+
{
146+
// Arrange
147+
var password = "pwd";
148+
var iterations = 1_000;
149+
var svc = new Pbkdf2PasswordHashService(ValidPepper, version: 1, iterations: iterations);
150+
var phc = svc.Hash(password);
151+
152+
var parts = phc.Split('$', StringSplitOptions.RemoveEmptyEntries);
153+
var salt = Convert.FromBase64String(parts[3]);
154+
155+
// Re-derive the input the same way as service does
156+
var key = Encoding.UTF8.GetBytes(ValidPepper);
157+
var pwdBytes = Encoding.UTF8.GetBytes(password);
158+
using var h = new HMACSHA256(key);
159+
var input = h.ComputeHash(pwdBytes);
160+
161+
using var pbkdf2 = new Rfc2898DeriveBytes(input, salt, iterations, HashAlgorithmName.SHA256);
162+
var shortHash = pbkdf2.GetBytes(16); // smaller than default 32
163+
var phcShort = "$pbkdf2-sha256$" + parts[1] + "$" + parts[2] + "$" + parts[3] + "$" + Convert.ToBase64String(shortHash);
164+
165+
// Act
166+
var ok = svc.Verify(password, phcShort, out var needsRehash);
167+
168+
using (Assert.EnterMultipleScope())
169+
{
170+
// Assert
171+
Assert.That(ok, Is.True);
172+
Assert.That(needsRehash, Is.True);
173+
}
174+
}
175+
176+
[Test]
177+
public void Constructor_InvalidPepper_Throws()
178+
{
179+
Assert.Throws<ArgumentException>(() => new Pbkdf2PasswordHashService(" "));
180+
Assert.Throws<ArgumentException>(() => new Pbkdf2PasswordHashService("short-pepper")); // < 16 bytes
181+
}
182+
183+
[Test]
184+
public void Constructor_InvalidParameters_Throw()
185+
{
186+
Assert.Throws<ArgumentOutOfRangeException>(() => new Pbkdf2PasswordHashService(ValidPepper, version: 0));
187+
Assert.Throws<ArgumentOutOfRangeException>(() => new Pbkdf2PasswordHashService(ValidPepper, iterations: 0));
188+
}
189+
190+
[Test]
191+
public void Hash_NullOrWhitespacePassword_Throws()
192+
{
193+
var svc = new Pbkdf2PasswordHashService(ValidPepper);
194+
Assert.Throws<ArgumentNullException>(() => svc.Hash(null!));
195+
Assert.Throws<ArgumentNullException>(() => svc.Hash(" "));
196+
}
197+
198+
[Test]
199+
public void Verify_NullOrWhitespacePassword_Throws()
200+
{
201+
var svc = new Pbkdf2PasswordHashService(ValidPepper, iterations: 1_000);
202+
var phc = svc.Hash("pwd");
203+
204+
Assert.Throws<ArgumentNullException>(() => svc.Verify(null!, phc, out _));
205+
Assert.Throws<ArgumentNullException>(() => svc.Verify(" ", phc, out _));
206+
}
207+
208+
[Test]
209+
public void Verify_InvalidPhc_ReturnsFalse()
210+
{
211+
var svc = new Pbkdf2PasswordHashService(ValidPepper, iterations: 1_000);
212+
213+
using (Assert.EnterMultipleScope())
214+
{
215+
Assert.That(svc.Verify("pwd", null!, out _), Is.False);
216+
Assert.That(svc.Verify("pwd", "", out _), Is.False);
217+
Assert.That(svc.Verify("pwd", "$wrongprefix$v=1$i=1$AAAA$BBBB", out _), Is.False);
218+
Assert.That(svc.Verify("pwd", "$pbkdf2-sha256$v=x$i=1$AAAA$BBBB", out _), Is.False);
219+
Assert.That(svc.Verify("pwd", "$pbkdf2-sha256$v=1$i=x$AAAA$BBBB", out _), Is.False);
220+
Assert.That(svc.Verify("pwd", "$pbkdf2-sha256$v=1$i=1$not-base64$still-not", out _), Is.False);
221+
Assert.That(svc.Verify("pwd", "$pbkdf2-sha256$v=1$i=1$AAAA", out _), Is.False); // not enough parts
222+
}
223+
}
224+
}
225+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using System;
2+
using System.Text;
3+
using System.Security.Cryptography;
4+
5+
namespace EasyExtensions.Services
6+
{
7+
/// <summary>
8+
/// PBKDF2 password hashing service using HMAC-SHA256.
9+
/// </summary>
10+
public class Pbkdf2PasswordHashService
11+
{
12+
private readonly int _version;
13+
private readonly string _pepper;
14+
private readonly int _iterations;
15+
16+
private const int SaltSize = 16;
17+
private const int HashSize = 32;
18+
private const string Prefix = "pbkdf2-sha256";
19+
private readonly HashAlgorithmName _hashAlgorithm = HashAlgorithmName.SHA256;
20+
21+
/// <summary>
22+
/// Creates a new instance of the Pbkdf2PasswordHashService.
23+
/// </summary>
24+
/// <param name="pepper">A secret value that is used in addition to the password. Must be at least 16 bytes (UTF-8).</param>
25+
/// <param name="version">The version of the hashing algorithm. Must be greater than 0. Default is 1.</param>
26+
/// <param name="iterations">The number of iterations for the PBKDF2 algorithm. Must be greater than 0. Default is 210,000.</param>
27+
/// <exception cref="ArgumentException">Thrown when the pepper is null, whitespace, or less than 16 bytes (UTF-8).</exception>
28+
/// <exception cref="ArgumentOutOfRangeException">Thrown when the version or iterations are less than 1.</exception>
29+
/// <remarks>
30+
/// The pepper should be a long, random string that is kept secret and not stored in the database.
31+
/// It is recommended to use a unique pepper for each application.
32+
/// The default number of iterations is set to 210,000 as of 2025, which is a good balance between security and performance.
33+
/// Consider increasing this value as hardware capabilities improve over time.
34+
/// </remarks>
35+
public Pbkdf2PasswordHashService(string pepper, int version = 1, int iterations = 210_000)
36+
{
37+
if (string.IsNullOrWhiteSpace(pepper))
38+
{
39+
throw new ArgumentException("Pepper cannot be null or whitespace.", nameof(pepper));
40+
}
41+
42+
if (Encoding.UTF8.GetByteCount(pepper) < 16)
43+
{
44+
throw new ArgumentException("Pepper must be at least 16 bytes (UTF-8).", nameof(pepper));
45+
}
46+
47+
if (version < 1)
48+
{
49+
throw new ArgumentOutOfRangeException(nameof(version), "Version must be greater than 0.");
50+
}
51+
52+
if (iterations < 1)
53+
{
54+
throw new ArgumentOutOfRangeException(nameof(iterations), "Iterations must be greater than 0.");
55+
}
56+
57+
_pepper = pepper;
58+
_version = version;
59+
_iterations = iterations;
60+
}
61+
62+
/// <summary>
63+
/// Creates a password hash in PHC format like:
64+
/// $pbkdf2-sha256$v=1$i=210000$[saltB64]$[hashB64]
65+
/// </summary>
66+
public string Hash(string password)
67+
{
68+
if (string.IsNullOrWhiteSpace(password))
69+
{
70+
throw new ArgumentNullException(nameof(password));
71+
}
72+
73+
byte[] saltBytes = new byte[SaltSize];
74+
RandomNumberGenerator.Create().GetBytes(saltBytes);
75+
var input = DeriveInput(password, _pepper);
76+
using var pbkdf2 = new Rfc2898DeriveBytes(input, saltBytes, _iterations, _hashAlgorithm);
77+
var hash = pbkdf2.GetBytes(HashSize);
78+
string hashB64 = Convert.ToBase64String(hash);
79+
string saltB64 = Convert.ToBase64String(saltBytes);
80+
return $"${Prefix}$v={_version}$i={_iterations}${saltB64}${hashB64}";
81+
}
82+
83+
/// <summary>
84+
/// Checks the password against the given PHC hash.
85+
/// </summary>
86+
public bool Verify(string password, string phc, out bool needsRehash)
87+
{
88+
needsRehash = false;
89+
90+
if (string.IsNullOrWhiteSpace(phc))
91+
{
92+
return false;
93+
}
94+
if (string.IsNullOrWhiteSpace(password))
95+
{
96+
throw new ArgumentNullException(nameof(password));
97+
}
98+
99+
var parts = phc.Split('$', StringSplitOptions.RemoveEmptyEntries);
100+
if (parts.Length != 5 || parts[0] != Prefix)
101+
{
102+
return false;
103+
}
104+
105+
// v=...
106+
if (!parts[1].StartsWith("v=") || !int.TryParse(parts[1].AsSpan(2), out var ver))
107+
{
108+
return false;
109+
}
110+
111+
// i=...
112+
if (!parts[2].StartsWith("i=") || !int.TryParse(parts[2].AsSpan(2), out var iter))
113+
{
114+
return false;
115+
}
116+
117+
byte[] salt, expected;
118+
try
119+
{
120+
salt = Convert.FromBase64String(parts[3]);
121+
expected = Convert.FromBase64String(parts[4]);
122+
}
123+
catch
124+
{
125+
return false;
126+
}
127+
128+
var input = DeriveInput(password, _pepper);
129+
using var pbkdf2 = new Rfc2898DeriveBytes(input, salt, iter, _hashAlgorithm);
130+
var actual = pbkdf2.GetBytes(expected.Length);
131+
132+
var ok = CryptographicOperations.FixedTimeEquals(actual, expected);
133+
134+
if (ok)
135+
{
136+
// Check if we need to rehash (parameters changed or hash size changed)
137+
if (ver < _version || iter < _iterations || expected.Length != HashSize)
138+
{
139+
needsRehash = true;
140+
}
141+
}
142+
143+
return ok;
144+
}
145+
146+
private static byte[] DeriveInput(string password, string pepper)
147+
{
148+
var pwdBytes = Encoding.UTF8.GetBytes(password);
149+
var pepperBytes = Encoding.UTF8.GetBytes(pepper);
150+
using var h = new HMACSHA256(pepperBytes);
151+
return h.ComputeHash(pwdBytes);
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)