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
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
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.
Instance methods
Whether this instance actually dispatches, or runs on the calling fiber.
False only where execution contexts are unavailable and allow_inline was passed.
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.