Skip to content

fix(auth): run one bcrypt check per login whether or not the name exists - #847

Open
LeeroyHannigan wants to merge 1 commit into
mainfrom
fix/login-time-unknown-principal
Open

LeeroyHannigan wants to merge 1 commit into
mainfrom
fix/login-time-unknown-principal

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

A password login now runs one bcrypt check whether or not the name exists. This covers the management API (HTTP Basic auth), /metrics and the console login.

Login bcrypt checks before after
A name without / that an admin has, right or wrong password 1 1
A name without / that no admin has 0 1
account_id/user_name, the user has a console password, right or wrong password 1 1
account_id/user_name, the user has no console password 0 1
account_id/user_name, no such user or account 0 1
An admin whose name contains /, right or wrong password 1 1
  • The new extenddb_storage::password::verify_stored_password takes the stored hash, or None when there is none. With None, it checks the password against a fixed bcrypt hash at cost 12 through verify_password. So the check waits for one of the four bcrypt slots, as a check against a stored hash does. It refuses the login whatever bcrypt returns.
  • Each backend's verify_iam_user_password (SQLite, PostgreSQL, MongoDB and Cassandra) passes the stored hash to the helper. It passes None for an unknown account, an unknown user and a user without a console password.
  • verify_admin_password does not change. For an unknown admin it returns None without a check. The login then goes to the IAM user step, which runs one check for any name. For a name without /, that step checks the fixed hash.
  • Status codes, messages and the records of the rate limiter do not change. A login that the rate limiter refuses, and a name longer than 256 bytes, run no check, as before.
  • Docs: the security model (Login Rate Limiting) and the guide for backend authors (docs/manuals/12-extending-extenddb-storage.md) describe the new rule.

Why

Closes #688.

A wrong password for an existing admin or IAM user ran one bcrypt check, about 100 ms at cost 12. A name that did not exist ran no check. So the time of a login told the caller whether the name existed. admin is the default admin name in the compose files.

Testing done

  • The test-only count_verifications records the bcrypt cost of each check that bcrypt runs. A hash that bcrypt refuses records nothing. So a test sees both the number of checks and their cost.

  • Two server tests run against an in-memory SQLite catalog. every_login_path_runs_one_bcrypt_check goes through authenticate, and every_credential_check_runs_one_bcrypt_check goes through the console's check_credentials. Each covers 9 cases:

    • an admin with the right password, and with a wrong password
    • an unknown name without /
    • an IAM user with the right password, and with a wrong password
    • an IAM user without a console password, an unknown user and an unknown account
    • an admin named team/root with a wrong password

    Each case runs exactly 1 check and ends with the expected outcome. A refused case records 1 failure, and an accepted case records none. The 4 cases with no stored hash run their check at cost 12. The other 5 run at the cost of the test hashes, 4.

  • Storage tests: the fixed hash has cost 12 and bcrypt decodes it. A check without a stored hash runs 1 check at cost 12 and refuses. With all four slots held, it waits and does not run. A hash that bcrypt refuses records no check.

  • IAM store tests cover a wrong password, a user without a console password, an unknown user and an unknown account:

    • SQLite, in memory: passed.
    • PostgreSQL, new crates/storage-postgres/tests/iam_user_password.rs: passed against a local pgvector/pgvector:pg16 server, the image that CI uses. CI runs it in the PostgreSQL storage test step.
    • Cassandra, a new test in crates/storage-cassandra/tests/direct/users.rs: it compiles. It has not run yet, so its first run is in the CI direct_integration step.
    • MongoDB: no test. CI runs no MongoDB store test that this change could join.
  • Unit tests: server 296, storage 218, SQLite 236, PostgreSQL 75, MongoDB 165 and Cassandra 125 passed. cargo fmt --check and cargo clippy --all-targets -- -D warnings on the six changed crates pass.

  • Negative controls, each one made a test fail:

    • main's early return in the SQLite or the PostgreSQL IAM store
    • no fixed-hash check in try_iam_user_auth, or in try_iam_login
    • an empty hash in place of the fixed hash in either of those functions, which bcrypt refuses with no work
    • the fixed hash at cost 4 in place of cost 12

Behaviour under load

A login for a name that does not exist now waits for a bcrypt slot. When the server is overloaded and no slot frees up in 10 seconds, the answer is HTTP 500, as it is for an existing name. Before this change, the answer for such a name was 401 at once.

Merging with other PRs

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: n/a. The trait signatures do not change. The change adds one bcrypt check on the login paths that ran none.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

A login to the management API, the /metrics endpoint or the console ran
one bcrypt verification, about 100 ms at the default cost of 12, when
the name belonged to an admin or to an IAM user with a console password,
and none for any other name. The time a login took therefore told the
caller whether the name existed.

Every login now runs one bcrypt check. The admin stores are unchanged:
an existing admin's hash is checked as before, and an unknown name
returns not found without a check, after which the login runs the IAM
user step.

In that step, every backend's verify_iam_user_password passes the
user's hash, or None for an unknown account, an unknown user or a user
without a console password, to the new function
extenddb_storage::password::verify_stored_password. A name without a
slash names no IAM user, and the management API and the console pass
None for it themselves. With None, verify_stored_password checks the
password against a fixed hash at the default cost through
verify_password, so it waits for one of the four bcrypt slots as a
check against a stored hash does, and it refuses the login whatever
bcrypt returns.

bcrypt checks per login, on main and with this change:

                                                           main  now
  name without a slash, an admin has the name                 1    1
  name without a slash, no admin has the name                 0    1
  account_id/user_name, user with a console password          1    1
  account_id/user_name, user without a console password       0    1
  account_id/user_name, unknown user or unknown account       0    1
  admin whose name contains a slash                           1    1

A login the rate limiter refuses runs no check, on main and now.

Status codes, messages and what the rate limiter records are as before.
A check for a name that does not exist now waits for a bcrypt slot, so
after 10 seconds without one it is answered 500, as a check for an
existing name is.

Under the test-util feature of extenddb-storage, verify_password records
the bcrypt cost of each hash it checks, and
password::count_verifications returns those costs. The server tests run
nine logins through the management API's authenticate and through the
console's credential check against an in-memory SQLite catalog. They
assert one check per login, at the default cost for the four logins that
name no principal with a password and at the test hashes' cost for the
other five. The same assertions cover verify_iam_user_password in the
SQLite unit tests, in a new PostgreSQL integration test and in the
Cassandra direct integration tests. The server crate gains three
dev-dependencies: extenddb-storage with test-util,
extenddb-storage-sqlite, and sqlx with the sqlite feature.

The security model manual states the rule under Login Rate Limiting. The
storage extension guide states what each backend's verify_admin_password
and verify_iam_user_password must do.
let (right, right_costs) = count_verifications(catalog_store.verify_iam_user_password(
&account_id,
"alice",
"correct horse",
let (verified, costs) = count_verifications(catalog_store.verify_iam_user_password(
account,
user_name,
"battery staple",
store.create_user(ACCOUNT, "bob", None).await.unwrap();

let (right, right_costs) =
count_verifications(store.verify_iam_user_password(ACCOUNT, "alice", "correct horse"))
let (verified, costs) = count_verifications(store.verify_iam_user_password(
account_id,
user_name,
"battery staple",
s.create_user(ACCOUNT, "bob", None).await.unwrap();

let (right, right_costs) =
count_verifications(s.verify_iam_user_password(ACCOUNT, "alice", "correct horse"))
let (verified, costs) = count_verifications(s.verify_iam_user_password(
account_id,
user_name,
"battery staple",
let _counters = COUNTERS.lock().await;
let hash = bcrypt::hash("correct horse", COST).unwrap();
let (wrong, wrong_costs) = count_verifications(verify_stored_password(
"battery staple".to_owned(),
assert!(!wrong.unwrap(), "a wrong password verified");
assert_eq!(wrong_costs, [COST]);
let (absent, absent_costs) =
count_verifications(verify_stored_password("battery staple".to_owned(), None)).await;
let slots = u32::try_from(MAX_CONCURRENT_BCRYPT).unwrap();
let held = BCRYPT_SEMAPHORE.acquire_many(slots).await.unwrap();
let check = tokio::spawn(count_verifications(verify_stored_password(
"pw".to_owned(),
for hash in ["not a bcrypt hash".to_owned(), unreadable_salt] {
assert!(bcrypt::verify("x", &hash).is_err(), "{hash}");
let (verified, costs) =
count_verifications(verify_password("x".to_owned(), hash)).await;

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console and management API login: response time shows whether a username exists

2 participants