Architecture Decision Record
ADR-133: Versioned Password Hash Work Factor
Status
Accepted (2026-10-23). Supersedes ADR-102.
The change is the MMCA.Common v1.252.0 entry of the framework changelog
(MMCA.Common/CHANGELOG.md:7, :16, :22). ADR-102's single-path decision (one interface, one
implementation, PBKDF2 only, constant-time compare, canonical-material guard) carries forward
unchanged; what this record replaces is its fixed, unstored work factor and its header-less format.
Context
ADR-102 recorded PBKDF2-HMAC-SHA512 with a 32-byte salt, a 64-byte digest and a compile-time
Iterations = 600_000, described as tracking OWASP guidance for that primitive
(102-pbkdf2-only-password-hashing.md:48-53). Its own Trade-offs named the cost of that shape:
verification recomputed with the same constant the write path used and no per-record count was
stored, so changing the constant in either direction would stop every stored credential from
verifying (102-pbkdf2-only-password-hashing.md:155-159), and the format carried no version marker
(:160-162).
Two facts made the constant worth changing. First, the figure was wrong for the primitive: 600,000 is the OWASP Password Storage Cheat Sheet figure for PBKDF2-HMAC-SHA256, while its figure for PBKDF2-HMAC-SHA512 is 210,000. The comment on the old constant presented 600,000 as the SHA-512 recommendation, so every hash cost roughly 2.9 times what the guidance asks for this primitive.
Second, the cost landed on availability. Password hashing was the measured CPU bottleneck at the
ADC 2026 venue, at about 0.3 to 0.5 core-seconds per hash. Anonymous callers pay that cost too: an
unknown address, or one with no stored credential, still burns one full derivation
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:170-172,
:648-656), and a known address with a wrong password runs the real verification (:178). Behind
the venue NAT many attendees share one address, so ADC's per-IP auth limit had to be raised from its
default of 30 per minute to 300 per minute in conference mode
(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:164-165), which raises in step how
much hashing one anonymous source can demand.
Under ADR-102's format the constant could not move at all without breaking every stored password, so the fix needed the hash to carry its own work factor first.
Decision
New hashes are written in a versioned format that records their own iteration count, at the SHA-512 figure; every existing hash still verifies and is upgraded on its owner's next successful sign-in.
- A 69-byte versioned format. A hash is
[0x01 version][4-byte big-endian iterations][64-byte PBKDF2 output](MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:14-15), built from the constantsFormatVersion = 0x01(:40),HeaderSize = 5(:43),HashSize = 64(:28) andVersionedHashSize = HeaderSize + HashSize(:46).HashPassword(:61) draws a 32-byte salt (SaltSize = 32,:25;:65), derives the digest (:66), writes the version byte (:69) and the big-endian count throughBinaryPrimitives.WriteInt32BigEndian(:70), and copies the digest after the header (:71). - New hashes use 210,000 iterations.
Iterations = 210_000(PasswordHasher.cs:34), documented as the OWASP figure for PBKDF2-HMAC-SHA512 with 600,000 named as its SHA-256 figure (:9-11,:31-32). The primitive is unchanged:Rfc2898DeriveBytes.Pbkdf2withHashAlgorithmName.SHA512and aHashSizeoutput (:141-147). - Legacy hashes still verify at 600,000. A bare 64-byte hash is the earlier format and is read as
LegacyIterations = 600_000(PasswordHasher.cs:37,:16-17, read at:124-128), so every account written under ADR-102 signs in unchanged. - The stored count is bounded, and anything else verifies nothing.
TryReadIterations(PasswordHasher.cs:122) accepts exactly the two shapes the hasher writes: the legacy length, or the versioned length with a0x01version byte (:130) and a count betweenMinIterations = 100_000(:52) andMaxIterations = 2_000_000(:58) inclusive (:133). The lower bound stops a tampered row from lowering the cost of guessing its password (:48-51); the upper bound caps the work one verification can be made to do, so a tampered row cannot turn a sign-in into a CPU denial of service (:54-57). Any other shape returnsfalse(:136-137). - Verification derives at the stored count, then compares in constant time.
VerifyPassword(PasswordHasher.cs:77) keeps ADR-102's canonical-material guard, now admitting two shapes: the salt must beSaltSizebytes and the hash must passTryReadIterations(:90), on the same stated ground that an empty hash-and-salt pair would otherwise verify any password (:83-89). It recomputes at the stored count (:96) and compares against the trailingHashSizebytes (:95) withCryptographicOperations.FixedTimeEquals(:100). NeedsRehashreports an outdated record.IPasswordHashergainsNeedsRehash(byte[] hash)as a default interface member answeringfalse(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/IPasswordHasher.cs:28), so a custom hasher compiles unchanged and never triggers a rehash (:18-22).PasswordHasheroverrides it (PasswordHasher.cs:109-115): true for a legacy 64-byte hash and for a versioned hash whose count differs from the current one, false for the current shape and for unrecognized material, which never verifies and so never reaches a rehash (:104-108).- The aggregate opts in through its own surface.
IPasswordRehashableUser(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordRehashableUser.cs:16) addsRehashPassword(byte[], byte[])(:25), kept separate fromChangePasswordon purpose: a rehash keeps the same password, so it must not raise the password-changed event or anything a real change triggers, such as revoking sessions or notifying the owner (:10-14). AUserthat does not implement it keeps its stored hash as it is (:8-9). - The upgrade runs after a successful sign-in, best effort, on its own save.
LoginAsynccallsRehashPasswordIfNeededAsync(AuthenticationServiceBase.cs:237) after the tokens are issued (:230); a wrong password returns at:178-183and never reaches it. The method returns unless issuance succeeded and the user implementsIPasswordRehashableUser(:677-680), then runs insideBestEffort.ExecuteAsyncunder the operation namepassword-rehash(:78,:682-683) withCancellationToken.None(:705). It checksNeedsRehash(:687), hashes the verified plaintext (:692), applies it throughRehashPassword(:693), logs and skips the save when the aggregate refuses it (:694-700), and otherwise saves through the unit of work on its own (:703). A failure there cannot fail a sign-in that already succeeded; it is logged and counted, and the record is upgraded on a later sign-in (:665-670). The logger is a virtual property defaulting toNullLogger.Instance(:137), and a failed rehash is counted on theMMCA.Common.BestEffortmeter whether or not it is overridden (:134-135). - The unknown-account burn costs the current work factor.
BurnPasswordVerificationCost(AuthenticationServiceBase.cs:648) still discards aHashPasswordof the submitted password (:655), so it costs what verifying a current-format record costs; its remarks state that a record on an older work factor costs that older amount until it is upgraded (:643-646). - Both consuming apps opt in, without a domain event. ADC's
UserimplementsIPasswordRehashableUser(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:36); itsRehashPassword(:442) validates the new hash and salt with the sameUserInvariantschecks (:444-446) and raises no event, whileChangePassword(:415) still raisesUserPasswordChanged(:429). Store'sUserdoes the same (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/User.cs:30,RehashPasswordat:205, invariants at:207-209;ChangePasswordat:178raisingUserPasswordChangedat:192). MMCA.Helpdesk holds no password material: no.csfile under itsSource/referencesPasswordHashorIPasswordHasher. - No schema migration. Both consumers map
PasswordHashwithout a maximum length, asvarbinary(max)(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:36-39,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:29-32), so a 69-byte value lands in the same column as a 64-byte one. - The format, the counts and the upgrade are pinned by tests.
PasswordHasherSecurityTestspins210_000,600_000, 32, 64, 69 and0x01(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/PasswordHasherSecurityTests.cs:23-28); accepts a legacy 64-byte digest computed at 600,000 (:34) and a versioned one (:53); checksHashPasswordagainst an independent known answer (:67); proves a stored count of 300,000 is honored and reported byNeedsRehash(:86-96); rejects counts 99,999, 2,000,001, 0 and -1 and version bytes0x00and0x02(:98-105) and a digest at 100,000 (:122,:129); reads the private constants by reflection (:141,:147,:153,:159); keeps ADR-102's legacy-HMAC rejection (:164); walks ten non-canonical length pairs across both shapes (:184-195); rejects a valid header over an odd salt (:209); and round-trips its own output (:220).PasswordHasherTestsasserts the 69-byte hash and 32-byte salt (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/PasswordHasherTests.cs:21-25) and theNeedsRehashcases (:31,:39,:43,:50).AuthenticationServicePasswordRehashTestscovers the sign-in path: a legacy hash on an opted-in user is rehashed and saved (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/AuthenticationServicePasswordRehashTests.cs:39), not rehashed a second time (:58), left untouched for a user that does not opt in (:73), and the sign-in still succeeds when the save throws (:89), when the hasher throws (:112) and when the aggregate refuses (:128); a wrong password never rehashes (:156). The IL-shape rules from ADR-102 still hold the type toRfc2898DeriveBytesandCryptographicOperations(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/PasswordHashingFitnessTests.cs:30,:43, non-vacuity at:24).
Rationale
- The hash is the right place for its own parameters. The count travels with the digest it produced, so a future change in either direction re-derives records on their next sign-in instead of locking every account out, which is the failure ADR-102's Trade-offs called out.
- The work factor should match the guidance for the primitive in use. 210,000 is the published figure for PBKDF2-HMAC-SHA512; spending about 2.9 times that per sign-in bought no protection the guidance asks for and cost CPU at exactly the moment a conference needed it.
- An explicit version marker, not another data convention. ADR-102 rejected selecting an algorithm
from a data property such as salt length and said a future format change was better served by an
explicit version marker (
102-pbkdf2-only-password-hashing.md:134-137). The0x01byte is that marker, and the iteration bounds stop the data from choosing a weaker or abusive cost. - Sign-in is the only moment the upgrade is possible. The plaintext exists only transiently at login, so the rehash runs there, after verification has proven it. Making it best effort keeps a failed write from turning a successful sign-in into an error.
- A rehash is not a password change.
ChangePasswordraisesUserPasswordChanged, which drives consequences a re-derivation of the same password must not trigger; a separate opt-in method keeps the two from being confused.
Trade-offs
- Legacy accounts are distinguishable by timing until they sign in once. A wrong password against
a legacy account verifies at 600,000 iterations (
PasswordHasher.cs:126,:96), while an unknown address burns a derivation at the current 210,000 (AuthenticationServiceBase.cs:655), so a caller timing failed logins can tell a legacy account from a missing one. Accepted because registration already reveals whether an address exists:RegisterAsyncreturnsAuthErrorCodes.EmailAlreadyExistsfor a taken address (AuthenticationServiceBase.cs:271-274,:713-715). The gap closes per account at its first sign-in; an account that never signs in keeps the legacy hash, and nothing forces the upgrade. - The upgrade is opt-in. An app whose
Userdoes not implementIPasswordRehashableUserkeeps its legacy hashes indefinitely (IPasswordRehashableUser.cs:8-9), as does any custom hasher that does not overrideNeedsRehash(IPasswordHasher.cs:28). - The first sign-in after the change costs two derivations. A legacy account verifies at 600,000
(
AuthenticationServiceBase.cs:178) and is then hashed at 210,000 (:692), plus one extra save (:703). Every later sign-in costs one derivation at 210,000. - New hashes resist offline guessing less than the old ones did. A leaked table of current-format hashes costs an attacker 210,000 iterations per guess instead of 600,000. Accepted because that is the published figure for this primitive.
- The work factor is still a compile-time constant (
PasswordHasher.cs:34), so changing it is a framework release rather than configuration; what changed is that the release no longer breaks stored credentials. - The format is now closed to unknown versions. A version byte other than
0x01verifies nothing (PasswordHasher.cs:130,:136-137), so a future format needs code that reads its marker before any hash carrying it is written.
Alternatives rejected
Weighed and rejected on 2026-10-23.
- A separate iterations column. It would carry the same information as the header at the price of a schema migration in every consumer. Revisit if a per-record parameter ever has to be queryable in the database rather than read at verification time.
- Changing the constant without a stored count. Under ADR-102's format every stored password would
stop verifying (
102-pbkdf2-only-password-hashing.md:155-159), leaving a reset for every account as the only remedy. - Argon2id. It is not in the .NET base class library, so adopting it would add a native or
third-party dependency to a framework that ships PBKDF2 only and pins that dependency in an IL-shape
rule (
PasswordHashingFitnessTests.cs:30). Revisit if the base class library ships it; the version byte is where such a change would be recorded.
Consequences
- Signing in is cheaper for every account on the current format, and so is every anonymous failed attempt that burns a derivation, which lowers the CPU cost of the per-IP limits a conference venue needs.
- The legacy population shrinks as accounts sign in; until it is gone, the 600,000 read path and the timing difference above both stay.
- A future work-factor change is a constant edit plus a release, with the upgrade riding the same sign-in path.
Related
ADR-102 (the superseded record: the single PBKDF2 path, the canonical-material guard and the tests this record keeps), ADR-032 (the original hasher and its call sites), ADR-029 (authentication brute-force protection: lockout and rate limiting wrap the same login path whose cost this record lowers), ADR-036 (external OAuth login: the credential-free account shape the guard still rejects on the password path).