class

KemalIdentity::Passwords::HashingExecutor

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

Runs a Hasher's expensive operations on a dedicated execution context.

The problem this solves

Bcrypt verification at cost 12 is tens of milliseconds of pure CPU, by design, and Crystal's scheduler is cooperative — a verification never yields. Run on the request fiber it occupies a scheduler thread for its whole duration, and enough concurrent logins queue every unrelated request behind them. The naive implementation is therefore a latency problem for the entire application, not just for logins.

Dispatching to a small, separately sized context bounds the damage: a burst of logins degrades login latency, which is the thing that should degrade, while the main context keeps serving everything else. Measured at 50 concurrent logins, unrelated-request p99 is 1.17 ms with this and 2,176 ms without.

Why it is a wrapper and not a change to Hasher

Hasher stays synchronous and knows nothing about scheduling. Dispatch is a decorator over the contract, so introducing it changed one line of wiring rather than the Hasher API. It satisfies the Hasher contract itself, so it drops in anywhere a hasher goes, and it runs the same contract spec.

On a Crystal without execution contexts

The class still exists and the API is identical, but there is nowhere to dispatch to: before execution contexts a program cannot create a second scheduler, and running the hash on another fiber of the same one moves nothing, because the work never yields.

So it refuses to be built, rather than quietly becoming a pass-through:

KemalIdentity::ConfigurationError: HashingExecutor needs execution contexts, which this
Crystal (1.20.0) does not provide. Upgrade to 1.21, build with -Dexecution_context, or
pass allow_inline: true to hash on the request fiber and accept the latency cost.

allow_inline: true is the deliberate opt-out. It is named for what it does, it is one grep away in review, and it is the only way to end up without the protection — because a security property that silently disappears on an older compiler is worse than one that is absent loudly. See blueprints/0013-execution-contexts-are-optional.md.

KemalIdentity.configure(
  accounts: accounts,
  sessions: sessions,
  hasher: KemalIdentity::Passwords::HashingExecutor.new(
    KemalIdentity::Passwords::BcryptHasher.new(cost: 12), size: 2
  ),
)

What is dispatched, and what is not

Only hash_secret and verify — the two that actually burn CPU. scheme, max_secret_bytesize, needs_rehash? and dummy_digest are a field read, a comparison, and a string parse; hopping contexts for those would cost more than doing them.

Constants

DEFAULT_SIZE = 2

Small on purpose. This is a ceiling on how much of the machine logins may take, not a throughput target: the whole point is that a login burst cannot starve everything else, and a pool sized like the main context would defeat that. Two is a starting point, and bench/hashing_latency.cr is how a deployment picks its own.

Constructors

new(inner : Hasher, size : Int32 = DEFAULT_SIZE, name : String = "kemal_identity-hashing", allow_inline : Bool = false)
Source
new(inner : Hasher, context : Fiber::ExecutionContext)

Shares an existing context, for an application that already runs one for CPU-bound work — and for specs, which would otherwise build a thread pool per example.

Source

Instance methods

dispatching?

Whether this instance actually dispatches, or runs on the calling fiber.

False only where execution contexts are unavailable and allow_inline was passed.

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
inner
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