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
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.
Constructors
Instance methods
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.
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.
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.
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.
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.