Summary
AuthService.login (server/backend/src/cq_server/services/auth.py) short-circuits password verification when the username is unknown, so an unknown-username request returns measurably faster than a known-username/wrong-password request. This is a timing oracle for username enumeration.
Where
server/backend/src/cq_server/services/auth.py:
user = await self._users.get(username)
if user is None or not verify_password(password, user["password_hash"]):
raise InvalidCredentialsError()
verify_password is bcrypt.checkpw (deliberately slow). When user is None, or short-circuits and bcrypt never runs, so the response is fast; when the user exists, bcrypt runs and the response is slow.
Impact
Low severity, but real: the uniform 401 and "Invalid username or password" message correctly closes the content channel (there is no message difference between unknown-user and wrong-password), but the timing channel remains open. An unauthenticated attacker can distinguish valid usernames by latency.
Suggested fix
Equalize the work regardless of whether the user exists: when user is None, still run a bcrypt verify against a fixed dummy hash of the same cost, then fail. This keeps both paths on the same code cost. A test asserting both branches call verify_password would lock it in.
Context
Surfaced while reviewing #525 (a frontend-only error-message change, which correctly did not touch this). The message-channel enumeration protection lives in the backend here; this issue is only about the timing channel.
Summary
AuthService.login(server/backend/src/cq_server/services/auth.py) short-circuits password verification when the username is unknown, so an unknown-username request returns measurably faster than a known-username/wrong-password request. This is a timing oracle for username enumeration.Where
server/backend/src/cq_server/services/auth.py:verify_passwordisbcrypt.checkpw(deliberately slow). Whenuser is None,orshort-circuits and bcrypt never runs, so the response is fast; when the user exists, bcrypt runs and the response is slow.Impact
Low severity, but real: the uniform
401and "Invalid username or password" message correctly closes the content channel (there is no message difference between unknown-user and wrong-password), but the timing channel remains open. An unauthenticated attacker can distinguish valid usernames by latency.Suggested fix
Equalize the work regardless of whether the user exists: when
user is None, still run a bcrypt verify against a fixed dummy hash of the same cost, then fail. This keeps both paths on the same code cost. A test asserting both branches callverify_passwordwould lock it in.Context
Surfaced while reviewing #525 (a frontend-only error-message change, which correctly did not touch this). The message-channel enumeration protection lives in the backend here; this issue is only about the timing channel.