Skip to content

feat(auth): PBKDF2 password hashing service (PasswordHasher) - #3288

Merged
bpamiri merged 1 commit into
developfrom
peter/issue-3155-password-hasher
Jul 6, 2026
Merged

feat(auth): PBKDF2 password hashing service (PasswordHasher)#3288
bpamiri merged 1 commit into
developfrom
peter/issue-3155-password-hasher

Conversation

@bpamiri

@bpamiri bpamiri commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Part of #3155 / #2962 (do not auto-close — the wheels generate auth generator 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

  • New file vendor/wheels/auth/PasswordHasher.cfc (sibling to Authenticator.cfc / SessionStrategy.cfc, same component/doc-comment conventions).
  • One algorithm, one storage format: PBKDF2-HMAC-SHA256, default 600,000 iterations (OWASP 2023+), 16-byte SecureRandom salt, 256-bit derived key. Hashes are portable across engines and engine migrations.
  • Self-describing modular-crypt format: $pbkdf2-sha256$i=<iterations>$<base64(salt)>$<base64(derivedKey)>.
  • Public API:
    • init(numeric iterations = 600000) — throws Wheels.PasswordHasher.InvalidConfiguration unless 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 via java.security.MessageDigest.isEqual(); returns false (never throws) on malformed/empty/truncated/unknown-format hashes.
    • needsRehash(required string hash)true when stored iterations are below the configured count or the format/algorithm tag is unrecognized; enables transparent work-factor upgrades after a successful login.
  • Implementation path: javax.crypto.SecretKeyFactory "PBKDF2WithHmacSHA256" only — byte-identical on every JVM engine by construction. Per the approved design, the engine-native GeneratePBKDFKey() fast path was not taken (it would require a proven byte-identical equivalence spec; determinism beats cleverness).
  • Unicode passwords round-trip (the JVM PBKDF2 implementation encodes password chars as UTF-8) — covered by spec.
  • Empty-password behavior (documented decision): hash("") works and verifies; minimum-length policy belongs in generator/model-level validations, not the hasher.
  • DI registration: intentionally not auto-registered. Investigated service("authenticator"): the framework does not auto-register built-in auth services — apps wire them in config/services.cfm (onapplicationstart.cfc only includes the app's services file). PasswordHasher follows the same pattern; the feature: wheels generate auth — session auth scaffold on the existing wheels.auth primitives #3155 generator will emit injector().map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton();.
  • Not a mixin: nothing under vendor/wheels/ mixes auth/ CFCs into models/controllers ($integrateComponents covers only wheels.controller, wheels.view, wheels.mapper, wheels.model), so internal helpers are normal private $-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 return false without throwing; needsRehash true 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); init throws on 0, -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).
  • Full core suite: 4642 pass / 0 fail / 0 error (341 bundles).

Cross-engine notes

  • Derivation is pure JVM crypto (SecretKeyFactory/PBEKeySpec via CreateObject("java", ...)) — no engine-native crypto functions, no closures, no arguments-as-attributeCollection, no bare tag-in-script statements.
  • The finally block contains only a bare function call (clearPassword()), not a loop — safe under the Lucee 7 finally-loop miscompile (Cross-Engine Invariant 12).
  • SecretKeyFactory is not documented thread-safe, so a fresh factory is created per derivation — the service is safe under .asSingleton(). SecureRandom (cached) is documented thread-safe; MessageDigest is only used for its static isEqual().
  • The hash() method name shadows the CFML built-in Hash(). 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

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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") then variables.messageDigest.isEqual(...) matches existing shipping prior art in vendor/wheels/auth/JwtService.cfc:54,135 and vendor/wheels/auth/TokenStrategy.cfc:60,260, so the static-method-via-class-proxy call is confirmed engine-portable.
  • The finally block in $deriveKey() contains only a bare call (local.keySpec.clearPassword();), not a loop — safe under Cross-Engine Invariant 12.
  • Derivation is pure SecretKeyFactory/PBEKeySpec JVM crypto — no engine-native crypto, no closures, no arguments-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.

@bpamiri

bpamiri commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Adobe 2023 verification run (the optional confirmation the bot review asked for): PasswordHasherSpec executed against this branch's head (ebd29ce21) on the local wheels-test-adobe2023:v1.0.1 image with db=sqlite24 pass / 0 fail / 0 error (HTTP 200). That settles the three flagged unknowns on Adobe: the hash() method name shadowing the BIF, Len() on byte arrays in verify()/$parseHash(), and SecureRandom.nextBytes() mutating a CFML binary in place. BoxLang remains covered by the weekly compat-matrix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: wheels generate auth — session auth scaffold on the existing wheels.auth primitives

1 participant