KemalIdentity::Passwords::MigratingHasher
Inherits KemalIdentity::Passwords::Hasher < Reference < Object
The current hasher, plus the ability to verify digests from the system being migrated off.
docs/06-roadmap.md's migration step 2, made expressible:
login
├─ current hasher verifies → done
└─ legacy verifier succeeds → rehash with the current hasher, immediately
Nobody is forced through a password reset, and old digests disappear as people sign in. The
rehash is not this class's doing — Passwords::Authenticator already rehashes whenever
#needs_rehash? says so, and Hasher#needs_rehash? is documented to return true for a
digest it cannot parse, which is exactly the legacy one. All that was missing was a way to
verify it, and this is that.
KemalIdentity.configure(
accounts: accounts,
sessions: sessions,
hasher: KemalIdentity::Passwords::MigratingHasher.new(
KemalIdentity::Passwords::BcryptHasher.new,
[DeviseVerifier.new.as(KemalIdentity::Passwords::LegacyVerifier)]
),
)
It never writes a legacy digest
#hash_secret and #scheme are the current hasher's, always. Verification is the only
thing the legacy side is allowed to do, so the count of old digests can only go down.
One verifier runs, not all of them
Digests are routed by LegacyVerifier#handles?, which looks at shape and never at the
secret. Trying every verifier in turn would make a login cost the sum of every legacy
scheme, and would make that cost depend on which scheme the account uses.
The timing hole this closes, which is not the usual one
The enumeration oracle — unknown logins answering faster than real ones — is already closed
by Hasher#dummy_digest. This introduces a different one. Legacy schemes are usually
fast, because being fast is why they are being retired; bcrypt is deliberately slow. So a
failed login against an un-migrated account would return in microseconds while a failed
login against a migrated one takes tens of milliseconds, and an attacker learns which
accounts have not been migrated yet — precisely the accounts whose digests are cheapest
to attack if the database ever leaks.
So a failed legacy verification is followed by a throwaway verification against the current
hasher's dummy digest, and the two paths cost the same. A successful one is not, because
the caller rehashes immediately and that rehash is the same cost — see
blueprints/0019-migrating-an-existing-application.md.
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.
Always the current hasher. A migration that can still write the old format is not one.
Which verifier would handle digest, or nil for one the current hasher owns. For a
script that wants to count what is left without a password in hand.
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.
The current hasher's answer, which is already true for anything it cannot parse — every
legacy digest, by construction.
The current scheme. What gets written to password_scheme on every rehash, so that
the query counting what is left to migrate keeps working.