class

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

new(current : Hasher, legacy : Enumerable(LegacyVerifier))
Source

Instance methods

current
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

Always the current hasher. A migration that can still write the old format is not one.

Source
legacy
Source
legacy_scheme_for(digest : String) : String | Nil

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.

Source
legacy_schemes

The legacy scheme names, for a migration-progress report.

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

The current hasher's answer, which is already true for anything it cannot parse — every legacy digest, by construction.

Source
scheme

The current scheme. What gets written to password_scheme on every rehash, so that the query counting what is left to migrate keeps working.

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