Architecture Decision Record
ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility
Status
Accepted (2026-06-29, adoption note revised 2026-07-06).
Context
Identity stores a credential as a (salt, hash) pair, never plaintext. The framework needs one canonical hasher that every consuming Identity flow shares, so the key-derivation algorithm, work factor, and comparison are decided once and not re-implemented per app. Two forces shape the choice:
- A modern, deliberately slow KDF. A fast hash (or a raw HMAC) makes offline brute force cheap if a credential table leaks, so the new format must use a stretched, salted derivation tuned to current guidance, with a timing-safe comparison.
- An existing corpus of already-stored passwords. Earlier records were written with an older HMAC-SHA512 scheme. Those rows cannot be force-reset, and they cannot be re-hashed in place: the plaintext needed to recompute a hash only exists transiently at the user's next login. Verification therefore has to accept the old format and the new one from the same stored bytes, with no schema flag distinguishing them.
Decision
Provide a single IPasswordHasher (MMCA.Common.Application.Interfaces.Infrastructure,
IPasswordHasher.cs:6) with one implementation PasswordHasher
(Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:12), registered unconditionally as
a singleton (Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:187, in the AddServices
helper that AddInfrastructure calls at DependencyInjection.cs:139). The type is stateless (only
const parameters), so
the singleton lifetime is safe.
- New passwords use PBKDF2-HMAC-SHA512.
HashPassworddraws a fresh 32-byte (256-bit) salt fromRandomNumberGenerator.GetBytesand derives a 64-byte (512-bit) key viaRfc2898DeriveBytes.Pbkdf2withHashAlgorithmName.SHA512and600_000iterations (PasswordHasher.cs:34,PasswordHasher.cs:35). The constants are namedSaltSize = 32,HashSize = 64, andIterations = 600_000(PasswordHasher.cs:15,PasswordHasher.cs:18,PasswordHasher.cs:24), the iteration count tracking OWASP 2023 guidance for this primitive. - Verification picks the algorithm by salt length, not by a stored flag.
VerifyPasswordbranches onsalt.Length == LegacyHmacSaltSize(PasswordHasher.cs:52), whereLegacyHmacSaltSize = 128(PasswordHasher.cs:27): a 128-byte salt routes to the legacy HMAC-SHA512 recompute (ComputeLegacyHash,PasswordHasher.cs:71, which keysHMACSHA512with the stored salt), and any other length (the 32-byte current format) routes to PBKDF2 (ComputePbkdf2Hash,PasswordHasher.cs:62, using the stored hash length as the output length). The decision is encoded in the data, so the same call site verifies both eras transparently. - The legacy branch is load-bearing, not dead code. It is the only path by which a pre-existing
stored password still verifies. A naive refactor that "simplifies"
VerifyPassworddown to the PBKDF2 call would compile cleanly, pass the new-format tests, deploy without error, and then silently reject every password written under the old scheme: a 128-byte salt fed to PBKDF2 produces a hash that can never equal the stored HMAC output. There is no exception and no startup failure to catch it; the only symptom is that legacy users cannot log in. That risk is exactly why this branch is recorded here. - Comparison is constant time. Both paths compare the recomputed bytes to the stored hash with
CryptographicOperations.FixedTimeEquals(PasswordHasher.cs:58), which always reads the full length so the verify time does not leak how many leading bytes matched. - Adopted by both apps' Identity flow, through a shared base. Each app's
AuthenticationServiceis now a sealed subclass ofAuthenticationServiceBase<TUser>(Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34) that passesIPasswordHasherinto the base constructor rather than calling the hasher itself. The login-timeVerifyPassword(AuthenticationServiceBase.cs:112) and the registration-timeHashPassword(AuthenticationServiceBase.cs:160) both live once in that base, so a single hoisted call site verifies and hashes for both apps. ADC's subclass declares theIPasswordHasherparameter and forwards it to the base (MMCA.ADC/.../Identity.Application/Users/AuthenticationService.cs:38, forwarded atAuthenticationService.cs:43); Store's subclass does the same (MMCA.Store/.../Identity.Application/Users/AuthenticationService.cs:23, forwarded atAuthenticationService.cs:30). A handful of use cases still injectIPasswordHasherdirectly rather than through the base: both apps'ChangePasswordHandler(MMCA.ADCandMMCA.Store.../UseCases/ChangePassword/ChangePasswordHandler.cs:16) verify the current password before hashing the new one, and both apps'IdentityModuleDbSeeder(.../Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:18) hash the seeded accounts' passwords.
Rationale
- One framework-owned primitive, not per-app crypto. Putting the algorithm, work factor, salt size, and comparison in a single shared type means a future hardening (raising iterations, changing the KDF) is one edit that every consumer inherits, rather than a hunt across Identity modules.
- Migrate on the user's own login, with no flag day. Because the stored bytes themselves select the
algorithm, legacy records keep working untouched, and there is no bulk migration job, no reset email
blast, and no
IsLegacycolumn to maintain. A record naturally moves to the current format the next time its owner sets or changes a password (which writes a 32-byte salt). - Slow-by-design KDF over a fast hash. PBKDF2-HMAC-SHA512 at a high iteration count makes offline cracking of a leaked table expensive, which a single-pass HMAC (the legacy scheme) does not, so the new format is a genuine security upgrade rather than a cosmetic change.
Trade-offs
- The legacy branch is a permanent correctness dependency that looks deletable. Its load-bearing role
is invisible from the method body alone, so it is the single most refactor-fragile line in the hasher.
The mitigation is this record plus the two focused regression tests in
Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PasswordHasherTests.cs(PasswordHasherTests.cs:66asserts a 128-byte-salt HMAC hash verifies true,PasswordHasherTests.cs:78asserts a wrong password against the legacy format verifies false), which fail loudly if the branch is dropped, alongside the current-format coverage atPasswordHasherTests.cs:22andPasswordHasherTests.cs:51. - Algorithm selection is heuristic, keyed on salt length. It relies on the legacy and current salt sizes (128 vs 32 bytes) being distinct. They are, and a hostile or corrupt 128-byte salt at worst fails to verify rather than misauthenticates, but a future third format would have to pick a third salt length (or add an explicit version marker) rather than colliding on 128 or 32.
- No automatic rehash on a successful legacy verify. A legacy user who only ever logs in (never
changes a password) stays on the old format indefinitely, because
VerifyPassworddoes not re-emit a current-format hash on success. Opportunistic upgrade-on-login is possible but deliberately out of scope here; the current design accepts a long tail of legacy rows. - The work factor is a fixed compile-time constant.
Iterations = 600_000is not configurable per host, so raising it is a framework change and a release, not an appsetting. That keeps the security floor uniform across consumers at the cost of per-deployment tuning.
Related
ADR-004 (cross-service JWT / JWKS authentication: the hasher gates credential verification that issues the
tokens that ADR-004 then validates across services),
ADR-005 (soft-delete vs erasure: the same Infrastructure layer's EncryptedStringConverter protects other
sensitive columns, the at-rest counterpart to hashing credentials),
ADR-029 (authentication brute-force protection: lockout and throttling wrap the same login path whose
final credential check is this hasher).
This ADR supersedes the one-line "Password hashing" note in SECURITY.md:25 with a governance record;
the security model summary there stays as the reader-facing pointer.