diff --git a/changelog.d/3155-password-hasher.added.md b/changelog.d/3155-password-hasher.added.md new file mode 100644 index 0000000000..1532aef5e8 --- /dev/null +++ b/changelog.d/3155-password-hasher.added.md @@ -0,0 +1 @@ +- Added `wheels.auth.PasswordHasher`, a cross-engine password hashing service using PBKDF2-HMAC-SHA256 (600,000 iterations by default per OWASP 2023+, 16-byte SecureRandom salt, 256-bit derived key) with a self-describing modular-crypt storage format (`$pbkdf2-sha256$i=$$`). `verify()` compares digests in constant time and returns `false` (never throws) on malformed input; `needsRehash()` enables transparent work-factor upgrades. Hashes are byte-identical across Lucee, Adobe CF, and BoxLang, so they survive engine migrations. Groundwork for `wheels generate auth` (#3155, #2962). diff --git a/vendor/wheels/auth/PasswordHasher.cfc b/vendor/wheels/auth/PasswordHasher.cfc new file mode 100644 index 0000000000..c4e2ceb9ac --- /dev/null +++ b/vendor/wheels/auth/PasswordHasher.cfc @@ -0,0 +1,263 @@ +/** + * Cross-engine password hashing service using PBKDF2-HMAC-SHA256. + * + * Produces and verifies self-describing, modular-crypt-style hashes: + * + * $pbkdf2-sha256$i=$$ + * + * One algorithm, one storage format: derivation goes through the JVM's + * javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), so the same + * (password, salt, iterations) always yields the same bytes on Lucee, + * Adobe CF, and BoxLang alike. Hashes are portable across engines and + * engine migrations, and the embedded iteration count lets deployments + * raise the work factor over time (see needsRehash()). + * + * Defaults: 600000 iterations (OWASP 2023+ recommendation for + * PBKDF2-HMAC-SHA256), 16-byte SecureRandom salt, 256-bit derived key. + * + * Passwords are UTF-8 encoded before derivation, so unicode passwords + * round-trip. Empty passwords hash and verify successfully by design — + * minimum-length policy belongs in application-level validations + * (e.g. validatesLengthOf() on the User model), not in the hasher. + * + * Usage: + * // Register during app init (config/services.cfm) + * injector().map("passwordHasher").to("wheels.auth.PasswordHasher").asSingleton(); + * + * // Hashing on signup / password change: + * user.passwordHash = service("passwordHasher").hash(params.password); + * + * // Verifying on login: + * if (service("passwordHasher").verify(params.password, user.passwordHash)) { ... } + * + * // Transparent work-factor upgrades after a successful verify: + * if (service("passwordHasher").needsRehash(user.passwordHash)) { + * user.passwordHash = service("passwordHasher").hash(params.password); + * } + * + * [section: Authentication] + * [category: Core] + */ +component output="false" { + + /** + * Creates a new PasswordHasher. + * + * @iterations PBKDF2 iteration count used by hash() and as the needsRehash() threshold. Must be a positive integer; construction throws Wheels.PasswordHasher.InvalidConfiguration otherwise. Default 600000 (OWASP 2023+). + */ + public PasswordHasher function init(numeric iterations = 600000) { + if (arguments.iterations <= 0 || arguments.iterations != Int(arguments.iterations)) { + throw( + type = "Wheels.PasswordHasher.InvalidConfiguration", + message = "PasswordHasher iterations must be a positive integer.", + extendedInfo = "Received `#arguments.iterations#`. Use the default (600000, the OWASP 2023+ recommendation for PBKDF2-HMAC-SHA256) unless you have measured a different work factor for your hardware." + ); + } + + variables.iterations = arguments.iterations; + variables.algorithmTag = "pbkdf2-sha256"; + variables.saltLengthBytes = 16; + variables.keyLengthBits = 256; + + // Cached Java handles. SecureRandom is documented thread-safe; + // MessageDigest is only used for its static isEqual(). SecretKeyFactory + // is NOT documented thread-safe, so $deriveKey() creates one per call — + // getInstance() cost is noise next to a 600k-iteration derivation. + variables.secureRandom = CreateObject("java", "java.security.SecureRandom").init(); + variables.messageDigest = CreateObject("java", "java.security.MessageDigest"); + + return this; + } + + /** + * Hash a password with a fresh random salt. + * + * Every call generates a new 16-byte SecureRandom salt, so hashing the + * same password twice yields different strings. The empty password is + * accepted by design; enforce minimum-length policy in your model + * validations instead. + * + * @password The plaintext password to hash (UTF-8 encoded before derivation). + * @return Self-describing hash string: $pbkdf2-sha256$i=$$. + */ + public string function hash(required string password) { + local.salt = $randomBytes(variables.saltLengthBytes); + local.derivedKey = $deriveKey( + password = arguments.password, + salt = local.salt, + iterations = variables.iterations, + keyLengthBits = variables.keyLengthBits + ); + + return "$" & variables.algorithmTag + & "$i=" & variables.iterations + & "$" & BinaryEncode(local.salt, "base64") + & "$" & BinaryEncode(local.derivedKey, "base64"); + } + + /** + * Verify a password against a stored hash. + * + * Re-derives the key using the salt and iteration count embedded in the + * stored hash (so hashes created under a different configured iteration + * count still verify) and compares the raw digest bytes in constant time + * via java.security.MessageDigest.isEqual(). + * + * Never throws: malformed, empty, truncated, or unknown-format hashes + * return false. + * + * @password The plaintext password to check. + * @hash The stored hash string produced by hash(). + * @return True if the password matches the stored hash. + */ + public boolean function verify(required string password, required string hash) { + try { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return false; + } + + local.candidate = $deriveKey( + password = arguments.password, + salt = local.parsed.salt, + iterations = local.parsed.iterations, + keyLengthBits = Len(local.parsed.derivedKey) * 8 + ); + + // Constant-time comparison of the raw digest bytes — never + // compare password hashes with string operators (timing leaks). + return variables.messageDigest.isEqual(local.candidate, local.parsed.derivedKey); + } catch (any e) { + // verify() is a boolean predicate on untrusted input: any parse or + // derivation error means "does not match", never an exception. + return false; + } + } + + /** + * Check whether a stored hash should be re-hashed under the current + * configuration. + * + * Returns true when the stored iteration count is below the configured + * one, or when the hash format/algorithm tag is unrecognized (including + * malformed hashes). Call after a successful verify() and re-hash the + * plaintext to transparently upgrade the work factor. + * + * @hash The stored hash string to inspect. + * @return True if the hash should be regenerated with hash(). + */ + public boolean function needsRehash(required string hash) { + local.parsed = $parseHash(arguments.hash); + if (!local.parsed.valid) { + return true; + } + return local.parsed.iterations < variables.iterations; + } + + /** + * Return the configured iteration count. + */ + public numeric function getIterations() { + return variables.iterations; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Parse a modular-crypt-style hash string into its components. + * + * Returns {valid, iterations, salt, derivedKey} where salt/derivedKey are + * byte arrays. Never throws: any structural problem (wrong segment count, + * unknown algorithm tag, non-numeric or non-positive iterations, invalid + * base64, empty salt/key) yields valid=false. + */ + private struct function $parseHash(required string hash) { + local.parsed = {valid = false, iterations = 0, salt = "", derivedKey = ""}; + + if (!Len(arguments.hash) || Left(arguments.hash, 1) != "$") { + return local.parsed; + } + + // Base64 never contains "$", so a well-formed hash splits into exactly + // four segments (ListToArray drops the leading empty element). + local.segments = ListToArray(arguments.hash, "$"); + if (ArrayLen(local.segments) != 4) { + return local.parsed; + } + + // Algorithm tag is lowercase by modular-crypt convention — compare + // case-sensitively (CFML == is case-insensitive, hence Compare()). + if (Compare(local.segments[1], variables.algorithmTag) != 0) { + return local.parsed; + } + + if (!REFind("^i=[1-9][0-9]*$", local.segments[2])) { + return local.parsed; + } + local.parsed.iterations = Val(ListLast(local.segments[2], "=")); + + try { + local.parsed.salt = BinaryDecode(local.segments[3], "base64"); + local.parsed.derivedKey = BinaryDecode(local.segments[4], "base64"); + } catch (any e) { + return local.parsed; + } + + if (Len(local.parsed.salt) == 0 || Len(local.parsed.derivedKey) == 0) { + return local.parsed; + } + + local.parsed.valid = true; + return local.parsed; + } + + /** + * Derive a PBKDF2-HMAC-SHA256 key for the given password and salt. + * + * Uses javax.crypto.SecretKeyFactory ("PBKDF2WithHmacSHA256"), which the + * JVM converts password characters to UTF-8 bytes for — byte-identical on + * every engine by construction. A fresh factory per call keeps this safe + * under the DI container's singleton scope (SecretKeyFactory instances + * are not documented thread-safe). + */ + private any function $deriveKey( + required string password, + required any salt, + required numeric iterations, + required numeric keyLengthBits + ) { + // Route through java.lang.String explicitly so toCharArray() resolves + // on every engine regardless of how CFML strings are wrapped. + local.passwordChars = CreateObject("java", "java.lang.String").init(arguments.password).toCharArray(); + + local.keySpec = CreateObject("java", "javax.crypto.spec.PBEKeySpec").init( + local.passwordChars, + arguments.salt, + JavaCast("int", arguments.iterations), + JavaCast("int", arguments.keyLengthBits) + ); + + try { + local.factory = CreateObject("java", "javax.crypto.SecretKeyFactory").getInstance("PBKDF2WithHmacSHA256"); + local.derivedKey = local.factory.generateSecret(local.keySpec).getEncoded(); + } finally { + // Zero the internal password copy held by the spec. + local.keySpec.clearPassword(); + } + + return local.derivedKey; + } + + /** + * Generate cryptographically secure random bytes. + */ + private any function $randomBytes(required numeric byteCount) { + // Allocate a zeroed byte[] of the right length, then fill it in place. + local.randomBytes = BinaryDecode(RepeatString("00", arguments.byteCount), "hex"); + variables.secureRandom.nextBytes(local.randomBytes); + return local.randomBytes; + } + +} diff --git a/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc new file mode 100644 index 0000000000..c2e18091b2 --- /dev/null +++ b/vendor/wheels/tests/specs/auth/PasswordHasherSpec.cfc @@ -0,0 +1,181 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("PasswordHasher", function() { + + beforeEach(function() { + // Low iteration count keeps the suite fast; the algorithm is the + // same regardless of count. Default-count behavior is asserted + // in its own spec below. + hasher = new wheels.auth.PasswordHasher(iterations = 1000); + }); + + describe("init() validation", function() { + + it("throws InvalidConfiguration for zero iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 0); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for negative iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = -1); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("throws InvalidConfiguration for non-integer iterations", function() { + expect(function() { + var svc = new wheels.auth.PasswordHasher(iterations = 1000.5); + }).toThrow("Wheels.PasswordHasher.InvalidConfiguration"); + }); + + it("defaults to 600000 iterations (OWASP 2023+)", function() { + var svc = new wheels.auth.PasswordHasher(); + var h = svc.hash("secret"); + expect(ListGetAt(h, 2, "$")).toBe("i=600000"); + }); + + }); + + describe("hash()", function() { + + it("produces the self-describing modular-crypt format", function() { + var h = hasher.hash("correct horse battery staple"); + // $pbkdf2-sha256$i=$$ + expect(Left(h, 1)).toBe("$"); + var parts = ListToArray(h, "$"); + expect(ArrayLen(parts)).toBe(4); + expect(parts[1]).toBe("pbkdf2-sha256"); + expect(parts[2]).toBe("i=1000"); + // Salt decodes to 16 random bytes, derived key to 32 bytes (256 bits) + expect(Len(BinaryDecode(parts[3], "base64"))).toBe(16); + expect(Len(BinaryDecode(parts[4], "base64"))).toBe(32); + }); + + it("produces different hashes for the same password (random salt)", function() { + var first = hasher.hash("same-password"); + var second = hasher.hash("same-password"); + expect(Compare(first, second)).notToBe(0); + // And both still verify + expect(hasher.verify("same-password", first)).toBeTrue(); + expect(hasher.verify("same-password", second)).toBeTrue(); + }); + + it("hashes an empty password (minimum-length policy lives in app validations)", function() { + var h = hasher.hash(""); + expect(hasher.verify("", h)).toBeTrue(); + expect(hasher.verify("not-empty", h)).toBeFalse(); + }); + + }); + + describe("verify()", function() { + + it("returns true for the correct password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("s3cret!", h)).toBeTrue(); + }); + + it("returns false for the wrong password", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.verify("wrong-password", h)).toBeFalse(); + }); + + it("is case-sensitive on the password", function() { + var h = hasher.hash("Secret"); + expect(hasher.verify("secret", h)).toBeFalse(); + }); + + it("round-trips unicode passwords via UTF-8 bytes", function() { + var unicodePassword = "pässwörd-契約-κωδικός"; + var h = hasher.hash(unicodePassword); + expect(hasher.verify(unicodePassword, h)).toBeTrue(); + expect(hasher.verify("passwoerd", h)).toBeFalse(); + }); + + it("verifies hashes produced under a different iteration count (stored count wins)", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + // A hasher configured with more iterations still verifies the stored hash + expect(hasher.verify("migrate-me", h)).toBeTrue(); + }); + + it("returns false (never throws) for an empty hash", function() { + expect(hasher.verify("anything", "")).toBeFalse(); + }); + + it("returns false (never throws) for a non-hash string", function() { + expect(hasher.verify("anything", "not-a-hash-at-all")).toBeFalse(); + }); + + it("returns false (never throws) for a truncated hash", function() { + var h = hasher.hash("s3cret!"); + // Drop the derived-key segment entirely + var truncated = "$" & ListGetAt(h, 1, "$") & "$" & ListGetAt(h, 2, "$") & "$" & ListGetAt(h, 3, "$"); + expect(hasher.verify("s3cret!", truncated)).toBeFalse(); + }); + + it("returns false (never throws) for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.verify("s3cret!", foreign)).toBeFalse(); + }); + + it("returns false (never throws) for invalid base64 in the hash", function() { + expect(hasher.verify("anything", "$pbkdf2-sha256$i=1000$!!!not-base64!!!$%%%also-bad%%%")).toBeFalse(); + }); + + it("returns false (never throws) for a zero-iterations hash", function() { + var h = hasher.hash("s3cret!"); + var doctored = Replace(h, "i=1000", "i=0"); + expect(hasher.verify("s3cret!", doctored)).toBeFalse(); + }); + + it("returns false when the format lacks the leading dollar sign", function() { + var h = hasher.hash("s3cret!"); + var noPrefix = Right(h, Len(h) - 1); + expect(hasher.verify("s3cret!", noPrefix)).toBeFalse(); + }); + + }); + + describe("needsRehash()", function() { + + it("returns false for a hash produced at the configured iteration count", function() { + var h = hasher.hash("s3cret!"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true when the stored iteration count is below the configured one", function() { + var older = new wheels.auth.PasswordHasher(iterations = 500); + var h = older.hash("migrate-me"); + expect(hasher.needsRehash(h)).toBeTrue(); + }); + + it("returns false when the stored iteration count exceeds the configured one", function() { + var stronger = new wheels.auth.PasswordHasher(iterations = 2000); + var h = stronger.hash("already-strong"); + expect(hasher.needsRehash(h)).toBeFalse(); + }); + + it("returns true for an unknown algorithm tag", function() { + var h = hasher.hash("s3cret!"); + var foreign = Replace(h, "pbkdf2-sha256", "argon2id"); + expect(hasher.needsRehash(foreign)).toBeTrue(); + }); + + it("returns true for a malformed hash", function() { + expect(hasher.needsRehash("")).toBeTrue(); + expect(hasher.needsRehash("not-a-hash")).toBeTrue(); + expect(hasher.needsRehash("$pbkdf2-sha256$i=1000$only-three-parts")).toBeTrue(); + }); + + }); + + }); + + } + +}