Architecture Decision Record
ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility
Status
Superseded by ADR-102 (2026-08-31). Originally Accepted (2026-06-29, adoption note revised 2026-07-06, registration note revised 2026-08-01, call-site hoist recorded 2026-08-23). The legacy HMAC-SHA512 verification branch this record calls load-bearing has since been deleted: hashing and verification are PBKDF2-only, and ADR-102 carries the current decision. The body below is retained unchanged as the historical record of the migration-era design.
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 as a singleton via
TryAddSingleton (Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:475, in the
AddServices helper that AddInfrastructure calls at DependencyInjection.cs:167). The call site
always runs, but TryAdd semantics mean a host that has already registered its own IPasswordHasher
keeps it: the framework supplies the default hasher rather than forcing it. 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 shared bases. 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:159) 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). The other two hasher call sites are hoisted the same way, each into its own shared base. The change-password workflow lives inChangePasswordHandlerBase<TUser, TCommand>(MMCA.Common.Application.Users.UseCases.ChangePassword,ChangePasswordHandlerBase.cs:24), which verifies the current password (ChangePasswordHandlerBase.cs:55) before hashing the new one (ChangePasswordHandlerBase.cs:61); each app'sChangePasswordHandleris a sealed subclass that only declares theIPasswordHasherparameter and forwards it (MMCA.ADC.../UseCases/ChangePassword/ChangePasswordHandler.cs:19,MMCA.Store.../UseCases/ChangePassword/ChangePasswordHandler.cs:18). Seeding lives inIdentityModuleDbSeederBase<TUser>(MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding,IdentityModuleDbSeederBase.cs:38), which hashes each seeded account's password (IdentityModuleDbSeederBase.cs:103); each app'sIdentityModuleDbSeedersupplies only the account list and forwardsIPasswordHasherto the base (MMCA.ADC.../Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:29,MMCA.Store.../Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:24). Every hasher invocation both apps depend on therefore sits in framework code; the app code only injects the dependency and passes it down.
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:29 with a governance record;
the security model summary there stays as the reader-facing pointer.
Revision (2026-08-23)
The decision is unchanged: one framework-owned IPasswordHasher, PBKDF2-HMAC-SHA512 for new passwords,
salt-length dispatch on verification. What changed is where the last two call sites live. The two use cases
this record used to list as exceptions to the base-hoisting pattern (ChangePasswordHandler and
IdentityModuleDbSeeder) have since been hoisted the same way AuthenticationService was, into their own
shared Common bases.
- Change-password. The verify-then-hash workflow lives once in
ChangePasswordHandlerBase<TUser, TCommand>(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:24):VerifyPasswordagainst the stored hash and salt at:55,HashPasswordfor the new credential at:61, and the aggregate's ownChangePasswordinvariant at:62. The per-app classes remain as sealed subclasses with no body of their own (ADCChangePasswordHandler.cs:17-23, StoreChangePasswordHandler.cs:16-20), deliberately keeping their names:HandlerNamedefaults to the runtime type name (ChangePasswordHandlerBase.cs:39), so thesourcereported on every error stays theChangePasswordHandlerstring clients already match on. - Seeding. The seed hash call lives once in
IdentityModuleDbSeederBase<TUser>(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:38, hashing at:103). The per-app seeders now supply data rather than behavior: anAccountslist plus aCreateUserfactory (abstract atIdentityModuleDbSeederBase.cs:90), forwardingIPasswordHasherto the base constructor (ADCIdentityModuleDbSeeder.cs:27-30, StoreIdentityModuleDbSeeder.cs:22-25).
The effect on this ADR's guarantees: the "one framework-owned primitive" rationale now holds for the whole
Identity credential surface, not just login and registration. No file under either app's Source/ invokes
IPasswordHasher at all; both apps only inject it and pass it to a Common base, so a future hardening (a
higher work factor, a third format) reaches every credential path in both apps through the framework alone.