User entity and password storage (task 49) #26
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/user-password-storage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Userentity,IPasswordHasher/Pbkdf2PasswordHasher, and a read-onlyUserRepository.FindByEmailAsync. Recorded in ADR-0028.The stored format is self-describing —
$pbkdf2-sha256$i=600000$<b64 salt>$<b64 hash>— so verification reads the label, cost and salt from the stored value rather than from configuration. That is what allows the algorithm or its parameters to change later without invalidating existing passwords:VerifyreturnsSucceededWithOutdatedHashand sign-in rewrites the hash.Open to challenge:
IUserRepositoryinterface. Reasoning is in the ADR and inUserRepository's doc comment.SeedDatastill writesNULLforpassword_hash, left to task 51.Three things this hands to the tickets that follow, all recorded in the ADR:
SucceededWithOutdatedHash, or the upgrade path never runs and the format's whole purpose is unrealised.Verifyreturns immediately when there is no stored hash, so sign-in must equalise the timing — verifying against a throwaway hash — or the endpoint enumerates users. An OIDC-only account has no hash, so this is reachable, not theoretical.docs/adr/README.mdwill conflict with the PRs in flight — keep the rows in numeric order.Pbkdf2PasswordHasher.TryParsebounds the iteration count below but not above —i=100000000measured at 14 s of single-threaded CPU perVerifyon this machine,i=2147483647extrapolates to ~5.5 min; cap it at a small multiple ofIterationsand fail closed above that.VerifypassesexpectedHash.LengthtoPbkdf2as the derived-key length with no cap, so a 4 MiB hash segment allocates 3 MiB inTryDecodeBase64and then derives 131,072 blocks × 600,000 iterations (had not returned after 8 s) — bound the decoded length.Truncating a stored hash returns
SucceededWithOutdatedHashall the way down to one byte (measured at 16/8/4/2/1), because the comparison width comes from the stored value: any password matching an 8-bit prefix authenticates. Reject a decoded hash below a floor (16 bytes) rather than merely reporting it outdated, and the same for the salt. All three reach the parser only through the column, so they need database write or a partial-write injection first — but the class documents itself as failing closed on an unreadable stored value, and these are the three fields where it does not.ADR-0028 lines 91–94 claim the population converts itself when the algorithm changes; it will not — this class returns
Succeededfor a current-cost PBKDF2 hash, so a future Argon2id default has to re-map any non-preferred label toSucceededWithOutdatedHashitself. Say that in the ADR, since it is the one part of the upgrade story no test can hold.The two duties this hands to #51 — rewrite on
SucceededWithOutdatedHash, and equalise the timing of the no-stored-hash branch — live only in this PR body and the ADR; Vikunja #51's description mentions neither, and a merged PR body is not where the next agent looks.ADR-0028 justifies 600,000 (OWASP) and the 32-byte output (extra pass per block, no added strength) but only states the 128-bit salt — cite NIST SP 800-132 §5.1 so all three are justified rather than two.
Both open decisions are right: nothing consumes either type yet so the lifetime belongs with #50/#51, and
UserRepositoryhas no second implementation and is tested against PostgreSQL, so an interface would be a seam nothing pulls.Verdict: changes required
All three bounds are in at
b4bbf80, plus the general fix behind them: the parser re-renders what it parsed and requires it back character for character, so it accepts only what this hasher would have written — which also closes the padded iteration count, whitespace inside base64 and non-canonical trailing bits it used to honour. Thirteen new cases, each failing against0724dca.ADR-0028 carries the bounds and their reasons, NIST SP 800-132 §5.1 for the salt, and the correction that a successor preferring Argon2id must mark the old label outdated itself; the two duties for #51 are now a comment on that ticket.
Paused. No verdict on
b4bbf80— the re-review was stopped before it reported. Resume by reviewing0724dca..b4bbf80, checking in particular that the new canonical-form requirement cannot reject a value the hasher itself produces.ADR-0028's new DoS sentence understates the bound by 2×: the worst accepted stored value measured 8.02× a sign-in here (81 ms at
i=600000/32 B, 651 ms ati=2400000/64 B), because a 64-byte hash is two PBKDF2 blocks. The ceiling is the product ofMaximumIterationsandMaximumKeyBytes, not the iteration multiple alone — say eight, or state it as the product.Verifydroppedsalt.Length < SaltBytesfrom the outdated test, andMinimumSaltBytes's doc points the next editor at raising the floor withSaltBytes. Doing that refuses every stored row written with a 16-byte salt; not doing it means salts silently never upgrade, since a short-salt hash then reportsSucceeded. Restore the clause — it is correct under either edit — and reword the comment to say the floor must not move.docs/adr/README.mdconflicts withmainat75cf44e; rebase before merging.Verdict: changes required
All three actioned at
58fb23f: the salt clause is back in the outdated test (inert while both constants are 16, with a comment saying deleting it is how salts stop upgrading),MinimumSaltBytesnow says the floor must not move and why a floor differs from a ceiling, and ADR-0028 states the work ceiling as the product ofMaximumIterationsandMaximumKeyBytes— about eight sign-ins, 651 ms against 81 ms.mainis merged in ate984294; the only conflict was the ADR index, and both rows are kept in numeric order.The floor/ceiling rule has a gap in the one direction that locks people out:
MaximumIterationsis not independently settable, so loweringIterationslowers a ceiling below what existing rows already carry. Built withIterations = 100_000, a row written ati=600000verifies asFailed— every account locked out, permanently, from an edit the constant's own doc frames as free ("raising it is a one-line change"). Cutting the cost on a small box is the plausible edit, and it needs the same warning the salt floor now has:Iterationsmay be raised, but never lowered below a quarter of the highest count already written.Verdict: changes required
Actioned at
408cf09: the rule is now "a bound may only move in the direction that widens what verifies", with the derived case spelled out onIterations/MaximumIterations; the same defect inMaximumStoredHashLength(a literal 256 that would not have tracked a raisedMaximumKeyBytes) is fixed by deriving it — 205 — and a new test verifies a stored hash sitting on every ceiling at once, which fails if the cap stops keeping up.SaltBytes"may be raised freely" is false, and it is the interaction the directional rule still misses: both written lengths are bounded above byMaximumKeyBytesas well as below by their floors. Built withSaltBytes = 128, and separatelyHashBytes = 128, the hasher's own output verifies asFailed— 64 is the last value that works. Say "up toMaximumKeyBytes" in both doc comments.205 is not "exactly the longest value the bounds above permit" — that is 202, since
MaximumIterationsallows seven digits, notint's ten. The slack is right and load-bearing (it survives a raisedIterations), which is why calling it exact matters: it invites the tightening to 202 that would then refuse an eight-digit count. Drop "exactly" rather than the slack.Verify_AStoredHashAtEveryCeilingAtOnce_Succeedsdoes not catch a cap that has stopped keeping up, contrary to its comment: its2_400_000/64/64are literals, not the constants. WithMaximumKeyBytes = 128and the cap pinned back to a literal 205, that value stillSucceededwhile a value at the actual raised ceilingFailed. The derivation is what closes this; the test guards today's numbers only, so say that.Verdict: changes required
All three corrected at
71296d4, plus eight more the sweep found: the parser accepts a fixed spelling rather than "only what this hasher would have written" (twice), a higher-cost hash is only left alone belowMaximumIterations, the canonical form fixes more than the delimiter and segment count, the test class overclaimed that a self-consistent format change is the only way to break stored passwords, the written value is 93 characters not ~95, sixteen bytes is accepted-and-outdated rather than refused, a sign-in is 600,000 HMAC iterations rather than that many compressions, and raising the cost offers an upgrade rather than performing one. Prose only; no behaviour changed.Nothing to act on at
71296d4: every claim checks out, and the diff is comment-only — non-comment lines are byte-identical in all three source files.Verdict: mergeable