Skip to content

fix: refuse a legacy account's password at the same cost as any other - #898

Merged
blaipr merged 1 commit into
mainfrom
fix/a-legacy-account-refuses-a-password-at-the-same-cost
Sep 2, 2026
Merged

fix: refuse a legacy account's password at the same cost as any other#898
blaipr merged 1 commit into
mainfrom
fix/a-legacy-account-refuses-a-password-at-the-same-cost

Conversation

@blaipr

@blaipr blaipr commented Sep 2, 2026

Copy link
Copy Markdown
Member

A wrong password for an account that has not been migrated refused in 0.3ms, where a wrong
password for a migrated account and a login naming nobody both cost 220ms — measured on this
installation by the test below, which was written before the fix and failed on it.

A row with isMigrate holds a sha1, md5 or crypt digest rather than a bcrypt hash.
checkMigrateUser() compares against all three — microseconds — and then hands that same digest to
Hash::checkHashKey(), where password_verify() rejects it on sight because it is not a bcrypt
hash at all. Falling through to the check below it did not spend the time either: that is the same
check against the same value, so it fast-fails identically.

This is the enumeration oracle authUser()'s catch block already deals with, reopened for a
subset. A fast refusal says this login exists and is one of the old ones — a real account
name confirmed to an unauthenticated caller who never guessed a password, and specifically the
accounts whose stored hashes are the weakest, which is the set an attacker most wants named.

The change

The migrate branch now answers for itself, and spends a verify against ABSENT_USER_HASH on its
way to the same refusal — the same fixed hash, and the same reasoning, as the missing-user path
directly below it.

Nothing else moves: checkMigrateUser() returning false already meant the fall-through check would
return false too, so short-circuiting there is behaviour-preserving. A migrated account with a
bcrypt hash and a wrong password paid for two verifies before and pays for two now.

Tests

testALegacyAccountRefusesAPasswordAtTheSameCostAsAMigratedOne compares the two paths against
each other rather than against a number of milliseconds, so it calibrates itself to whatever
machine it runs on — the same technique as
testALoginForAMissingUserCostsTheSameAsOneForAnExistingUser, which it sits beside.

Written first and watched fail: (migrated: 220.3ms, legacy: 0.3ms). The four existing migration
tests — SHA1, MD5, crypt and bcrypt — still pass, so a legacy account that supplies the right
password still authenticates and still gets migrated.

A row with `isMigrate` holds a sha1, md5 or crypt digest rather than a bcrypt hash, so
`checkMigrateUser()`'s three comparisons take microseconds and its fourth hands that
digest to password_verify(), which rejects it on sight. A wrong password for such an
account refused in 0.3ms where a migrated account and an unknown login both paid 220ms
for a real verify — measured by the test, which was written first and failed on it.

That is the enumeration oracle the catch block below already deals with, reopened for a
subset: a fast refusal said 'this login exists and is one of the old ones', naming both a
real account and the ones whose stored hashes are weakest, to a caller who never guessed
a password.

The migrate branch now answers for itself and spends a verify against ABSENT_USER_HASH on
the way to the same refusal. Falling through would not have spent it: that check is the
same one checkMigrateUser() just made against the same non-bcrypt value, so the answer was
false either way and short-circuiting is behaviour-preserving.
@blaipr
blaipr merged commit 2fd5f6f into main Sep 2, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-legacy-account-refuses-a-password-at-the-same-cost branch September 2, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant