class

KemalIdentity::Passwords::BcryptHasher

Inherits KemalIdentity::Passwords::Hasher < Reference < Object

bcrypt, via Crystal's standard library. The default hasher.

Argon2id is the better algorithm and ships as kemal_identity_argon2, separately, because it needs a C binding — the only real reason to split a shard (docs/00-scope.md). bcrypt is the default because it is in the standard library, so the default path has no native dependency at all.

Cost

DEFAULT_COST here is 12, above the standard library's 11. It is a starting point, not a recommendation: the right cost is the highest one that keeps p95 login latency inside budget on the deployment target, which is what bench/ measures. Crystal's own bcrypt documentation makes the same point, and adds the other half — rate-limit every endpoint that verifies a hash, because deliberately slow work is a denial-of-service lever (docs/04-kemal-integration.md).

Verification is CPU-bound for tens of milliseconds. Run on a request fiber it occupies a scheduler thread for that whole time, so a burst of logins queues unrelated requests behind it. Keeping this class synchronous is deliberate: the fix is to dispatch it to a dedicated execution context, and that belongs in a wrapper around this contract rather than in the contract itself, so introducing it later changes one call site instead of the Hasher API (docs/06-roadmap.md, step 10).

Constants

DEFAULT_COST = 12
MAX_SECRET_BYTESIZE = 71

bcrypt's limit is 72 bytes including the trailing NUL the algorithm appends, which leaves 71 for the secret. Crystal's Crypto::Bcrypt enforces PASSWORD_RANGE = 1..72 against bytesize + 1, so 71 is exactly where it starts raising.

SCHEME = "bcrypt"

Constructors

new(cost : Int32 = DEFAULT_COST, random : KemalIdentity::RandomSource = SecureRandomSource.new)
Source

Instance methods

cost
Source
dummy_digest

A digest that no input verifies against, costing what a real verification costs.

This closes the enumeration-timing oracle. If an unknown login returns before doing any hashing work, the response comes back a hundred milliseconds early and the attacker has a reliable account oracle no matter how identical the response body is:

account = accounts.find_by_login(normalized, tenant_id)
digest = account.try(&.password_digest) || hasher.dummy_digest
ok = hasher.verify(submitted, digest)
return Failed.new(FailureReason::InvalidCredential) if account.nil? || !ok

Computed once, when the hasher is built, so it costs nothing per request.

Source
hash_secret(secret : Secret) : String

Digests secret at the current parameters.

Raises ArgumentError if secret is empty or longer than #max_secret_bytesize. The message carries the length and never the secret.

Source
max_secret_bytesize

The largest secret this algorithm can represent, in bytes — not characters. A multi-byte character costs more than one byte of the budget, so a limit measured in characters would be wrong for exactly the users least likely to be testing it.

Policy reads this to reject an over-long secret with a useful message before #hash_secret raises on it.

Source
needs_rehash?(digest : String) : Bool

Whether digest was produced at parameters weaker than the current ones, or by another scheme entirely.

This is what makes lazy rehashing work: a successful login at an outdated cost silently rehashes at the current one, so old digests disappear as people sign in and nobody is forced through a password reset (docs/06-roadmap.md, migration step 2). A digest this hasher cannot parse counts as needing a rehash — that is precisely the legacy digest the migration is trying to retire.

Source
scheme

Identifies the algorithm, and is stored alongside the digest in auth_accounts.password_scheme so #needs_rehash? can tell a foreign digest from one of ours.

Source
verify(secret : Secret, digest : String) : Bool

Whether secret produced digest.

Returns false — never raises, never truncates — for a secret the algorithm cannot represent, and for a digest this hasher cannot parse.

Source