↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-102: PBKDF2-Only Password Hashing

Status

Accepted (2026-08-31). Supersedes ADR-032. Revised 2026-09-11: verification now carries a canonical-material length guard (it rejects any stored hash or salt that is not exactly HashSize/SaltSize bytes) and derives to HashSize rather than to the stored hash length. The record's decision is unchanged (one interface, one implementation, PBKDF2 only); the Decision and Trade-offs passages describing the read path are rewritten to match. Revised 2026-10-01: anchor refresh only. Revised 2026-10-06: the app-side forwarding passage now names the seeder service-locate, and the legacy-removal search cite is narrowed to the one matching test line.

Context

ADR-032 recorded a hasher with two verification paths: PBKDF2-HMAC-SHA512 for new credentials, and an HMAC-SHA512 recompute for rows written under an older scheme, selected at verify time by reading the stored salt length (128 bytes routed to the legacy path). That dual path existed for one reason only: an existing corpus of stored credentials that could not be re-hashed in place, because the plaintext needed to recompute a hash exists only transiently at the owner's next login.

Two things make that design a liability rather than an asset once the corpus is gone. The legacy branch verifies a single-round, effectively unsalted HMAC digest, so any row still in that shape is authenticated by a primitive that offers none of the offline-cracking resistance the record itself argues for. And algorithm selection keyed on a data property (salt length) means the credential table decides which primitive runs, with no stored version marker and no way to assert from configuration which path a given login took.

The production credential stores of both consuming applications were checked for legacy-format rows (a salt of the legacy length) before the branch was removed, and contained none: every stored credential was already in the current 32-byte-salt PBKDF2 format. With no row left that the branch could serve, keeping it meant carrying a weaker verification path for a population of zero. The branch, its LegacyHmacSaltSize constant, its ComputeLegacyHash helper and the two regression tests that pinned it were deleted in the collapse-dual-paths wave (MMCA.Common v1.173/v1.174).

Decision

Hashing and verification are PBKDF2-only. There is one framework IPasswordHasher and one code path through it, in both directions.

  • One interface, one implementation, one registration. IPasswordHasher (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:6) has the single implementation PasswordHasher (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:12), registered with TryAddSingleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:314) inside the AddServices helper (:294) that AddInfrastructure (:57) calls unconditionally (:234). TryAdd semantics keep a host's own prior registration, so the framework supplies the default rather than forcing it, and the type is stateless (three private const fields and no instance state, PasswordHasher.cs:15, :18, :24), which is what makes the singleton lifetime safe.
  • PBKDF2-HMAC-SHA512, 32-byte salt, 64-byte digest, 600,000 iterations. HashPassword (PasswordHasher.cs:27) draws a fresh salt from RandomNumberGenerator.GetBytes(SaltSize) (:31) and derives the key with Rfc2898DeriveBytes.Pbkdf2 (:32-37), passing HashAlgorithmName.SHA512 (:36) and HashSize (:37). The parameters are the named constants SaltSize = 32 (:15), HashSize = 64 (:18) and Iterations = 600_000 (:24), the iteration count tracking OWASP 2023 guidance for this primitive.
  • Verification runs one algorithm, gated by a length check. VerifyPassword (PasswordHasher.cs:43) validates its arguments, then rejects any stored material that is not exactly the shape this hasher produces: if (hash.Length != HashSize || salt.Length != SaltSize) returns false (:55-58). That is a canonical-material guard, not an algorithm branch. No second primitive exists to route to and the salt length selects nothing: on the one remaining path the recompute is ComputePbkdf2Hash(password, salt, HashSize) (:60), deriving to the constant rather than to the stored hash length, and the private helper (:68-74) is the only recompute in the type, using the same Iterations (:72) and HashAlgorithmName.SHA512 (:73) the write path uses. The guard's stated ground (:49-54) is that a row holding an empty hash and salt, the shape an external-OAuth account carries under ADR-036, otherwise derived an empty output and compared two empty spans, which a fixed-time comparison answers true for.
  • The comparison stays constant time. The recomputed bytes are compared with CryptographicOperations.FixedTimeEquals (PasswordHasher.cs:64), which always reads the full length so verify time does not leak how many leading bytes matched.
  • The legacy path is gone, not merely unreachable. LegacyHmacSaltSize, ComputeLegacyHash and every HMACSHA512 usage are absent from all Source/ code in the four repos: a search of each repo for those three identifiers across *.cs matches only the new HMACSHA512() in the test that proves the removal (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/PasswordHasherSecurityTests.cs:108). PasswordHasher.cs is 75 lines end to end.
  • A test asserts the rejection rather than the acceptance. VerifyPassword_RejectsALegacyHmacDigest (PasswordHasherSecurityTests.cs:105-115) builds a 128-byte HMAC key as the salt and the matching single-round HMACSHA512 digest (:108-110) and requires VerifyPassword to answer false for the correct password (:112), on the stated ground that a credential still in the legacy shape must fail rather than authenticate through an unsalted, single-round digest (:113-114). Re-introducing the branch fails this test.
  • The parameters are pinned two ways, structurally and by value. PasswordHasherSecurityTests recomputes the digest independently with the pinned settings (:28-43, :46-66), proves the work factor participates in verification by rejecting a digest derived at 100,000 iterations (:69-84), and reads the three private constants by reflection so a lowered or renamed one fails the build (:88-102, helper at :164-172). The same file pins the read-path guard: the empty hash-and-salt pair is rejected (:117-123), a Theory walks six non-canonical length combinations (:125-142), and a round trip proves the guard does not break the real path (:144-151). At the architecture tier, PasswordHashingFitnessTests asserts against compiled IL that the type depends on Rfc2898DeriveBytes (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/PasswordHashingFitnessTests.cs:30-40) and on CryptographicOperations (:43-54), with a non-vacuity check that the scan actually reaches the type (:24-27).
  • PasswordHasherTests covers the current format and argument validation only. The 88-line file holds the PBKDF2 round trips (PasswordHasherTests.cs:21-27 for the 64-byte digest and 32-byte salt, :50-54 for the correct password, :57-61 for the wrong one), per-call salt uniqueness (:30-36) and null/empty argument guards; no legacy-format test remains in it.
  • Every framework call site runs through the one path. Login verification (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:161) and registration hashing (:255) live in the shared base (:64, hasher parameter at :66), which also equalizes timing for an unknown account by burning a verification against canonical-shaped dummy material, VerifyPassword(password, new byte[64], new byte[32]) (:624, in BurnPasswordVerificationCost at :615): that call depends on the guard admitting exactly the 64/32 shape, so the burn still costs a full derivation rather than short-circuiting; change-password verifies then hashes in ChangePasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:42, hasher parameter at :44, verify at :95, hash at :104); reset-password hashes in ResetPasswordHandlerBase<TUser, TCommand> (.../UseCases/ResetPassword/ResetPasswordHandlerBase.cs:41, hasher parameter at :43, hashing at :94); and seeding hashes in IdentityModuleDbSeederBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:39, :104). No file under either app's Source/ invokes the hasher: ADC and Store only obtain it and forward it to a Common base, either as a constructor parameter (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:52,61, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:28,35) or, for seeding, by resolving it from the service provider before handing it to the seeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:34, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/IdentityModuleSeeder.cs:33).
  • The security model summary states the same rule. MMCA.Common/SECURITY.md:29-35 documents PBKDF2-SHA512 with a high iteration count and constant-time comparison as build-failing invariants and says outright that PBKDF2 is the only verification path with no legacy HMAC fallback (:34-35).

Rationale

  • A verification path that serves no rows is pure risk. With the production stores confirmed free of legacy-format credentials, the branch could only ever be reached by a corrupt, hostile or restored-from-an-archive row, and reaching it would authenticate through the weakest primitive in the system. Removing it converts "an old-format row logs in through a fast hash" into "an old-format row does not log in".
  • The strongest form of the invariant is a single path. ADR-032's own Trade-offs called the legacy branch the most refactor-fragile line in the hasher, mitigated by a record and two tests. One path needs no mitigation: there is no selection logic to get wrong, and the known-answer, reflection and IL-shape tests now pin the one algorithm that exists rather than guarding a second one.
  • Data-driven algorithm selection is the wrong control point. Keying on salt length puts the choice of primitive in the credential table, where it is invisible to configuration, to review and to an operator. A future format change is better served by an explicit version marker and a planned migration than by a second implicit length convention.
  • Deletion is cheaper than indefinite carriage. The alternative to removing the branch was keeping it forever (nothing was going to retire it on its own, since ADR-032 deliberately declined rehash-on-login), or building an opportunistic upgrade path for a population already measured at zero.

Trade-offs

  • A legacy-format row that reaches production now fails verification. Restoring an old backup, importing a credential from an external system in the legacy shape, or missing a row in the pre-deletion check produces a login that fails with the correct password. The failure is indistinguishable from a wrong password at the call site (a 128-byte legacy salt short-circuits at the length guard, PasswordHasher.cs:55-58, returning a bare false at :57), so there is no signal that names the cause; the only remedy is a password reset. Nothing in the code detects or reports such a row.
  • The work factor is a fixed compile-time constant. Iterations = 600_000 (PasswordHasher.cs:24) is not bound to configuration, so raising it is a framework change and a release rather than an appsetting. That keeps the security floor uniform across consumers at the cost of per-deployment tuning.
  • Raising the work factor invalidates every stored credential. Verification recomputes with the same constant the write path used (PasswordHasher.cs:72), and no per-record iteration count is stored, so an increase makes existing hashes stop matching. A future hardening therefore needs a stored parameter set or a migration strategy, which is exactly the versioning problem this record declines to solve by salt-length convention.
  • The stored format carries no version marker. The format is implicit in the code, so introducing a third scheme later requires adding that marker (or an out-of-band migration) rather than reading another data property.
  • The read path admits exactly one material shape. The guard (PasswordHasher.cs:55-58) makes the 64-byte digest and 32-byte salt an invariant of verification as well as of the write path (:37, pinned by PasswordHasherSecurityTests.cs:100-102), so a truncated, empty or foreign-format row is rejected as malformed instead of being compared against an equally shaped recompute. The cost is the same one the work factor carries: changing SaltSize or HashSize stops every stored credential from verifying, and any account legitimately holding no credential material (an external-OAuth row under ADR-036) must be routed away from password login by the caller, since the hasher answers it with an unexplained false.

Revision (2026-10-01)

Anchor refresh only; no decision or rationale changed. The Decision's citations are re-anchored to current source: the TryAddSingleton registration (DependencyInjection.cs:304), AddServices (:284), AddInfrastructure (:56) and its AddServices() call (:224); the timing burn in AuthenticationServiceBase.cs (BurnPasswordVerificationCost at :923, dummy verify at :932); ChangePasswordHandlerBase.cs (:42, :44, :95, :104); ResetPasswordHandlerBase.cs (:41, :43); and the ADC and Store AuthenticationService.cs parameter and forward lines (:51,62 and :28,38), which still only forward the hasher to the Common base.

Revision (2026-10-06)

  • The legacy-removal search cite now names only the matching line (PasswordHasherSecurityTests.cs:108, the new HMACSHA512()); :105 is the test method name and matches none of the three identifiers. The search is stated as per repo, since each repo has to be searched on its own.
  • The app-side forwarding passage now also names the IdentityModuleSeeder service-locate in ADC and Store (GetRequiredService<IPasswordHasher>), which still forwards the hasher without invoking it.
  • Anchors re-verified against current source: DependencyInjection.cs (:314, :294, :57, :234), AuthenticationServiceBase.cs (:64, :66, :161, :255, :615, :624), and the ADC and Store AuthenticationService.cs parameter and forward lines (:52,61 and :28,35).

ADR-032 (the superseded record: the same hasher, its parameters and its call-site hoisting, plus the legacy-compatibility design this reverses), ADR-004 (cross-service JWT / JWKS authentication: the hasher gates the credential verification that issues the tokens ADR-004 validates across services), ADR-005 (soft-delete vs erasure: 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), ADR-036 (external OAuth login: the credential-free account shape the canonical-material guard rejects on the password path). The security model summary in MMCA.Common/SECURITY.md:29-35 stays the reader-facing pointer to this record.