fix(basicauth): add salted PBKDF2 password hashing, deprecate SHA-256 HashPassword - #508
Open
FumingPower3925 wants to merge 2 commits into
Open
fix(basicauth): add salted PBKDF2 password hashing, deprecate SHA-256 HashPassword#508FumingPower3925 wants to merge 2 commits into
FumingPower3925 wants to merge 2 commits into
Conversation
… HashPassword HashPassword produced an unsalted, fast SHA-256 digest — not a credential-storage hash (CodeQL go/weak-sensitive-data-hashing). Add HashPasswordPBKDF2 (stdlib crypto/pbkdf2, HMAC-SHA256, 16-byte crypto/rand salt, 600000 iterations, 32-byte key) encoded as pbkdf2-sha256$<iter>$<salt-b64>$<hash-b64>, and VerifyPassword, a ready-made HashedUsersFunc that detects the format of each stored hash, verifies pbkdf2-sha256 strings and legacy hex SHA-256 digests alike, and compares with crypto/subtle.ConstantTimeCompare (malformed input still burns a derivation so the error branch is not timing-distinguishable). HashedUsers stores made entirely of pbkdf2-sha256 hashes get VerifyPassword wired in by default; legacy or mixed stores without a HashedUsersFunc still panic at New, as before. HashPassword keeps its exact behaviour and gains a proper Deprecated: notice pointing at HashPasswordPBKDF2; the package doc gains a migration section. Fixes #503
…uniform Address two review findings on #508. Parameter window. VerifyPassword honours the parameters carried by a stored pbkdf2-sha256 hash only within 600,000 <= iterations <= 10,000,000 and a salt of at least 16 bytes: the values HashPasswordPBKDF2 has ever emitted, pinned as literals so a future raise of the default keeps old hashes valid, with a compile-time guard that the defaults sit inside the window. The parser previously accepted any 31-bit count, so a stored `pbkdf2-sha256$2147483647$...` cost ~10 minutes of CPU per verification (for unknown users too, since applyDefaults feeds a real stored hash to the verifier on a miss), and it accepted 1 iteration and 1-byte salts. Out-of-window values now take the existing burn-then-false path, and an auto-wired HashedUsers store that contains one panics at New naming the entry instead of answering 401 to that user on every request. Cost uniformity. Every VerifyPassword call performs exactly one PBKDF2 derivation: the legacy SHA-256 path (valid hex, malformed and "" alike) burns a default-cost derivation first, so in a mixed store the stored format is no longer recoverable from response time, as the HashedUsersFunc contract requires. The legacy candidate digest goes through the deprecated HashPassword rather than a second inline sha256.Sum256, so no new weak-hash sink is introduced (CodeQL go/weak-sensitive-data-hashing). applyDefaults picks the unknown-user dummy hash deterministically (pbkdf2-sha256 entry preferred, ties broken on username). Docs state the timing property precisely, including the residual signal of a non-default iteration count. Tests: parser window edges without paying for derivations, correct-key hashes one step outside the window refused, New panics on out-of-window entries, dummy-hash preference and tie-break, legacy verification as parallel subtests, and a serial cost-uniformity check (pbkdf2, legacy, empty and a hostile 2^31 count within 10x; measured within 2% under -race). Against the pre-fix sources the window, panic, dummy and edge tests fail and the cost check stalls on the hostile count until the test timeout.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
middleware/basicauth.HashPasswordproduces an unsalted, fast SHA-256 digest, which is not a credential-storage hash: identical passwords share a digest and the digest is brute-forceable at GPU speed (CodeQLgo/weak-sensitive-data-hashing, alert #9,config.go:151).This PR adds a proper slow, salted KDF with zero new dependencies (Go 1.27 stdlib
crypto/pbkdf2, already used bydriver/postgres/protocol/scram.go):HashPasswordPBKDF2(password) string— PBKDF2-HMAC-SHA256, 16-bytecrypto/randsalt,PBKDF2Iterations = 600_000, 32-byte key, encoded aspbkdf2-sha256$<iter>$<salt-b64>$<hash-b64>(standard padded base64).VerifyPassword(hash, password) bool— a ready-madeConfig.HashedUsersFunc. Dispatches on thepbkdf2-sha256$tag: parses iterations / salt / hash strictly (exactly 4 fields; a decimal iteration count within the bounded window600_000 <= iter <= 10_000_000; a salt of at least 16 bytes; a hash of exactly 32 bytes), re-derives and compares withcrypto/subtle.ConstantTimeCompare. Anything without the tag is treated as a legacy hex SHA-256 digest fromHashPasswordand verified in constant time too, so existing stores keep authenticating.VerifyPasswordis cost-uniform: every call performs exactly one derivation at the default iteration count (aburnPBKDF2on the legacy, malformed, out-of-window and empty paths), so the format taken is not timing-distinguishable. The only residual is that a non-default stored iteration count is observable, as with bcrypt cost. The legacy candidate digest is derived through the deprecatedHashPassword, so the PR adds no new SHA-256 sink for CodeQL.HashedUsersstores whose values are allpbkdf2-sha256getVerifyPasswordas the defaultHashedUsersFunc. Legacy or mixed stores without aHashedUsersFuncstill panic atNew, exactly as before (no fast-hash default is reinstated); the panic message now names the migration path.HashPassword— behaviour frozen (still the bare hex digest,TestHashPasswordDeterministic/TestHashPasswordMatchesSHA256untouched and green). The doc comment is now a properDeprecated:notice (the old one saidDEPRECATED:, which tooling does not recognise) pointing atHashPasswordPBKDF2/VerifyPassword.doc.gogains a "Migrating from HashPassword (SHA-256)" section with the three-step incremental migration (setHashedUsersFunc: VerifyPassword→ re-hash entries → drop the explicit func once no legacy digests remain), plus a PBKDF2 usage snippet.ExampleNew_hashedUsersnow usesHashPasswordPBKDF2; a newExampleVerifyPasswordshows a mixed store. There is nomiddleware/README.mdin this repo, so the package doc is the README-equivalent.Fail-first evidence
middleware/basicauth/pbkdf2_test.gowas written first and run against unmodifiedmain(9a8fc6a):After the fix:
One case surfaced during development and was removed from the wrong-password table on purpose:
"secret\x00"verifies against the hash of"secret". That is inherent to PBKDF2 (the password is the HMAC key; HMAC zero-pads keys shorter than the block size), not a verifier bug; it is documented onHashPasswordPBKDF2, and RFC 7617 restricts Basic passwords to TEXT anyway.Tests
$fields,pbkdf2-sha256tag, iterations== PBKDF2Iterations == 600000, 16-byte salt, 32-byte key; two hashes of the same password differ (fresh salt)2147483647,1,1000) / zero / negative / non-numeric iterations, salts of 1 and 15 bytes (16 and 17 accepted), flipped / truncated / invalid-base64 salt, flipped / truncated hash, missing / extra field, wrong algorithm tag, bare tag, empty stringHashPasswordhex digests still verify (incl. upper-case hex); truncated / overlong / non-hex rejected;HashPasswordoutput unchangedHashedUsersFunc(default wiring); mixed store withHashedUsersFunc: VerifyPassword; mixed store without a func still panicsTest cost note: each derivation is 600k iterations (~0.2 s native, several seconds under
-race), so the new tests share one pre-computed hash where the salt is irrelevant and run witht.Parallel(). Package-racetime goes from ~1.4 s to ~27 s on a 10-core arm64 laptop; that is the price of exercising the real iteration count rather than a test hook in production code.Review follow-up (fe621d5)
The adversarial review found two things the first commit got wrong, both fixed in
fe621d5:pbkdf2-sha256$2147483647$…entry was a per-request DoS and a$1$entry a downgrade. The window is now pinned as literals with compile-time guards (minPBKDF2Iterations = 600_000, deliberately not an alias of the default so a future raise never invalidates stored hashes;maxPBKDF2Iterations = 10_000_000;minPBKDF2SaltLen = 16). Out-of-window hashes take the burn-then-false path, and a store that is auto-wired toVerifyPassword(noHashedUsersFunc) now fails fast:New()panics naming the malformed entry instead of returning 401 for that user forever.burnPBKDF2, andpickDummyHashprefers a PBKDF2 entry with a deterministic username tiebreak so the unknown-user path is uniform as well. Cost samples (pbkdf2 / legacy / empty / hostile count) agree within 2% under-race.Fail-first: against the first commit the cost-uniformity test stalled for over 300 s because the parser honoured 2,147,483,647 iterations. New tests: parser-window table, window-edge round trips, serial 10x cost-uniformity, out-of-window
New()panic,TestPickDummyHash.gofmt,go vet,golangci-lintclean;go test -race ./middleware/basicauth/green.Checks
gofmtclean,go vet ./...clean,golangci-lint run ./middleware/basicauth/...0 issues,go build ./...ok,go test -race ./middleware/basicauth/green.Fixes #503