feat(auth): PBKDF2 password hashing service (PasswordHasher) - #3288
Conversation
Adds wheels.auth.PasswordHasher, the cross-engine password hashing service that unblocks the wheels generate auth scaffold (#3155, child of #2962). - PBKDF2-HMAC-SHA256 via javax.crypto.SecretKeyFactory (PBKDF2WithHmacSHA256) — byte-identical on Lucee, Adobe CF, and BoxLang by construction, so hashes survive engine migrations. - Defaults: 600000 iterations (OWASP 2023+), 16-byte SecureRandom salt, 256-bit derived key. - Self-describing modular-crypt storage format: $pbkdf2-sha256$i=<iterations>$<base64(salt)>$<base64(derivedKey)> - verify() re-derives with the stored salt/iterations and compares raw digest bytes in constant time (MessageDigest.isEqual); returns false, never throws, on malformed/empty/unknown-format hashes. - needsRehash() flags hashes below the configured iteration count or with an unrecognized format for transparent work-factor upgrades. - init() validates iterations as a positive integer and throws Wheels.PasswordHasher.InvalidConfiguration otherwise. - Unicode passwords round-trip (UTF-8); empty password hashing is allowed by design — minimum-length policy lives in app validations. - Not auto-registered in DI (matches Authenticator): the generator will wire it in config/services.cfm. TDD: 24-spec PasswordHasherSpec written first and confirmed failing for the right reason before implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR adds wheels.auth.PasswordHasher, a PBKDF2-HMAC-SHA256 password hashing service (600k iterations default, 16-byte SecureRandom salt, 256-bit key) in a self-describing modular-crypt format, plus a 24-spec TDD suite and a changelog fragment. The implementation is careful and idiomatic: constant-time digest comparison, never-throw verify()/needsRehash() on untrusted input, and a work-factor-upgrade path. I found no blocking issues — no correctness, cross-engine, or security defect survived review. Verdict: comment (one non-blocking recommendation + minor notes; nothing here blocks merge).
Correctness
No issues. $parseHash() is genuinely total (the only throw source, BinaryDecode, is wrapped in try/catch; ListToArray/Compare/REFind/Val/Len don't throw on these inputs), so needsRehash() calling it without its own try/catch is safe. The ^i=[1-9][0-9]*$ guard correctly rejects the doctored i=0 hash, and ListToArray dropping empty segments correctly rejects empty salt/key. verify() re-deriving keyLengthBits from the stored key length is fine — a truncated key can only match an equally-derived candidate, and cost stays linear in input size (no PBKDF2 amplification).
Cross-engine
Clean, and the risky patterns are all avoided:
- The constant-time compare via
CreateObject("java", "java.security.MessageDigest")thenvariables.messageDigest.isEqual(...)matches existing shipping prior art invendor/wheels/auth/JwtService.cfc:54,135andvendor/wheels/auth/TokenStrategy.cfc:60,260, so the static-method-via-class-proxy call is confirmed engine-portable. - The
finallyblock in$deriveKey()contains only a bare call (local.keySpec.clearPassword();), not a loop — safe under Cross-Engine Invariant 12. - Derivation is pure
SecretKeyFactory/PBEKeySpecJVM crypto — no engine-native crypto, no closures, noarguments-as-attributeCollection, no bare tag-in-script statements.
Recommendation (non-blocking): the PR body notes only Lucee 7 + SQLite was run locally and explicitly flags the engine matrix to confirm the hash() method-name shadowing. Worth running tools/test-matrix.sh adobe2023 sqlite and tools/test-matrix.sh boxlang sqlite to close that open question before the generator (issue 3155) builds on it — particularly the Len(local.parsed.derivedKey) call on a binary byte array in verify(), which the spec exercises but only on Lucee so far. Prior-art risk is low (sibling auth CFCs ship the same CreateObject("java", ...) patterns across engines), so this is confirmation, not a suspected break.
Tests
Strong. vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc extends wheels.WheelsTest (BDD, correct base — not legacy RocketUnit), was written first and confirmed red for the right reason, and covers happy path, wrong/case-sensitive/unicode/empty passwords, random-salt divergence, cross-iteration verify, needsRehash in all four directions, and every malformed-input branch (empty, non-hash, truncated, unknown tag, bad base64, i=0, missing leading $). Closures are passed positionally to expect(...), not as constructor named args, so Cross-Engine Invariant 5 does not apply.
Docs
Changelog fragment changelog.d/3155-password-hasher.added.md uses the correct <slug>.<type>.md fragment form (not a direct CHANGELOG.md [Unreleased] edit) — good. No user-facing guide is required yet since the service isn't surfaced until the generator lands; the inline doc-comment ([section: Authentication] / [category: Core]) matches the sibling Authenticator.cfc convention.
Commits
Single commit feat(auth): PBKDF2 password hashing service (PasswordHasher) conforms to commitlint.config.js — valid type, optional scope, subject well under 100 chars, not ALL-CAPS.
|
Adobe 2023 verification run (the optional confirmation the bot review asked for): |
Part of #3155 / #2962 (do not auto-close — the
wheels generate authgenerator PR closes #3155).Adds
wheels.auth.PasswordHasher, the cross-engine password hashing service the auth scaffold builds on. The maintainer signed off on the design decisions below (PBKDF2-everywhere, 2026-07-06) — see #3155.Approved design
vendor/wheels/auth/PasswordHasher.cfc(sibling toAuthenticator.cfc/SessionStrategy.cfc, same component/doc-comment conventions).$pbkdf2-sha256$i=<iterations>$<base64(salt)>$<base64(derivedKey)>.init(numeric iterations = 600000)— throwsWheels.PasswordHasher.InvalidConfigurationunless a positive integer.hash(required string password)— fresh random salt per call.verify(required string password, required string hash)— re-derives with the stored salt/iteration count, compares raw digest bytes in constant time viajava.security.MessageDigest.isEqual(); returnsfalse(never throws) on malformed/empty/truncated/unknown-format hashes.needsRehash(required string hash)—truewhen stored iterations are below the configured count or the format/algorithm tag is unrecognized; enables transparent work-factor upgrades after a successful login.javax.crypto.SecretKeyFactory"PBKDF2WithHmacSHA256"only — byte-identical on every JVM engine by construction. Per the approved design, the engine-nativeGeneratePBKDFKey()fast path was not taken (it would require a proven byte-identical equivalence spec; determinism beats cleverness).hash("")works and verifies; minimum-length policy belongs in generator/model-level validations, not the hasher.service("authenticator"): the framework does not auto-register built-in auth services — apps wire them inconfig/services.cfm(onapplicationstart.cfconly includes the app's services file).PasswordHasherfollows the same pattern; the feature: wheels generate auth — session auth scaffold on the existing wheels.auth primitives #3155 generator will emitinjector().map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton();.vendor/wheels/mixesauth/CFCs into models/controllers ($integrateComponentscovers onlywheels.controller,wheels.view,wheels.mapper,wheels.model), so internal helpers are normalprivate$-prefixed methods, matching the sibling auth components.TDD / test evidence
vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc(24 specs) was written first and confirmed failing for the right reason (could not find component or class with name [wheels.auth.PasswordHasher], 24 errors) before the implementation existed.Coverage: round-trip verify; wrong password; two hashes of the same password differ (random salt); malformed/empty/truncated/no-leading-
$/bad-base64/zero-iteration hashes returnfalsewithout throwing;needsRehashtrue for lower iterations + unknown algorithm tag + malformed, false for current and higher counts; cross-instance verify (stored iteration count wins); unicode password; empty password; exact format-shape assertion (segment count, tag, salt/key byte lengths);initthrows on0,-1, and non-integer iteration counts; default of 600000 asserted via the emitted format.Local runs (Lucee 7 + SQLite against this branch's worktree,
db=sqlite&format=json):directory=wheels.tests.specs.auth: 199 pass / 0 fail / 0 error (8 bundles, includes all pre-existing auth specs).Cross-engine notes
SecretKeyFactory/PBEKeySpecviaCreateObject("java", ...)) — no engine-native crypto functions, no closures, noarguments-as-attributeCollection, no bare tag-in-script statements.finallyblock contains only a bare function call (clearPassword()), not a loop — safe under the Lucee 7 finally-loop miscompile (Cross-Engine Invariant 12).SecretKeyFactoryis not documented thread-safe, so a fresh factory is created per derivation — the service is safe under.asSingleton().SecureRandom(cached) is documented thread-safe;MessageDigestis only used for its staticisEqual().hash()method name shadows the CFML built-inHash(). CFC methods may shadow built-ins on all supported engines (prior art:update()/delete()on the Wheels model layer,insert()/find()in qb on Adobe CF); the component never calls the built-in internally, and all external call sites are object-scoped (hasher.hash(...)). Flagging for the engine matrix to confirm.🤖 Generated with Claude Code