diff --git a/src/v/config/configuration.cc b/src/v/config/configuration.cc index 78e74d8cc64ae..5371643d3eefe 100644 --- a/src/v/config/configuration.cc +++ b/src/v/config/configuration.cc @@ -4178,6 +4178,16 @@ configuration::configuration() }, std::vector{"BASIC"}, validate_http_authn_mechanisms) + , scram_credential_cache_enabled( + *this, + "scram_credential_cache_enabled", + "Whether to cache SCRAM password validation results for authentication " + "paths that receive a plaintext password on every request (HTTP Basic " + "authentication and SASL/PLAIN). When enabled, repeat authentications " + "skip the salted password derivation, which costs thousands of HMAC " + "operations per validation.", + {.needs_restart = needs_restart::no, .visibility = visibility::tunable}, + false) , enable_mpx_extensions( *this, "enable_mpx_extensions", diff --git a/src/v/config/configuration.h b/src/v/config/configuration.h index a0be803c52df7..cce76e627e762 100644 --- a/src/v/config/configuration.h +++ b/src/v/config/configuration.h @@ -712,6 +712,7 @@ struct configuration final : public config_store { // HTTP Authentication enterprise>> http_authentication; + property scram_credential_cache_enabled; // MPX property enable_mpx_extensions; diff --git a/src/v/security/BUILD b/src/v/security/BUILD index 87ba2093dc9d3..074166c02aa80 100644 --- a/src/v/security/BUILD +++ b/src/v/security/BUILD @@ -111,6 +111,7 @@ redpanda_cc_library( "role.cc", "scram_algorithm.cc", "scram_authenticator.cc", + "scram_credential_cache.cc", ], hdrs = [ "acl.h", @@ -142,6 +143,7 @@ redpanda_cc_library( "scram_algorithm.h", "scram_authenticator.h", "scram_credential.h", + "scram_credential_cache.h", "types.h", ], implementation_deps = [ @@ -186,6 +188,7 @@ redpanda_cc_library( "//src/v/strings:utf8", "//src/v/utils:absl_sstring_hash", "//src/v/utils:base64", + "//src/v/utils:chunked_kv_cache", "//src/v/utils:log_hist", "//src/v/utils:named_type", "//src/v/utils:to_string", diff --git a/src/v/security/scram_algorithm.h b/src/v/security/scram_algorithm.h index ca5dab003d933..4ca19e922e032 100644 --- a/src/v/security/scram_algorithm.h +++ b/src/v/security/scram_algorithm.h @@ -338,6 +338,18 @@ class scram_algorithm { return bytes(result.begin(), result.end()); } + /** + * Computes the stored key for a plaintext password, as persisted in a + * scram_credential. This is the expensive part of non-SCRAM password + * validation: the salted password derivation runs `iterations` HMAC + * rounds. + */ + static bytes derive_stored_key( + const ss::sstring& password, bytes_view salt, int iterations) { + auto salted_password = salt_password(password, salt, iterations); + return stored_key(client_key(salted_password)); + } + /** * For doing non-SCRAM authentication, such as HTTP basic auth over TLS, * using stored SCRAM credentials. @@ -347,10 +359,8 @@ class scram_algorithm { bytes_view reference_stored_key, bytes_view salt, int iterations) { - auto salted_password = salt_password(password, salt, iterations); - auto clientkey = client_key(salted_password); - auto storedkey = stored_key(clientkey); - return storedkey == reference_stored_key; + return derive_stored_key(password, salt, iterations) + == reference_stored_key; } private: diff --git a/src/v/security/scram_authenticator.cc b/src/v/security/scram_authenticator.cc index a83aba7fe31ce..c79b3c3224ae7 100644 --- a/src/v/security/scram_authenticator.cc +++ b/src/v/security/scram_authenticator.cc @@ -11,10 +11,12 @@ #include "security/scram_authenticator.h" #include "base/vlog.h" +#include "config/configuration.h" #include "random/secure_generators.h" #include "security/credential_store.h" #include "security/errc.h" #include "security/logger.h" +#include "security/scram_credential_cache.h" namespace security { @@ -148,22 +150,68 @@ scram_authenticator::authenticate(bytes auth_bytes) { template class scram_authenticator; template class scram_authenticator; +namespace { + +// Matches a password against a stored credential by rederiving its stored +// key. When a cache is given, the derivation is memoized; a null cache +// derives every time (the plain, un-memoized path). +template +bool match_password( + scram_credential_cache* cache, + const scram_credential& cred, + const credential_password& password) { + constexpr auto mech = scram_mechanism_traits::algorithm; + if (cache != nullptr) { + auto cached = cache->get( + mech, password, cred.salt(), cred.iterations()); + if (cached.has_value()) { + return *cached == cred.stored_key(); + } + } + auto stored_key = scram::derive_stored_key( + password(), cred.salt(), cred.iterations()); + auto valid = stored_key == cred.stored_key(); + if (cache != nullptr) { + cache->put( + mech, + password, + cred.salt(), + cred.iterations(), + std::move(stored_key)); + } + return valid; +} +} // namespace + +namespace detail { + std::optional validate_scram_credential( - const scram_credential& cred, const credential_password& password) { + const scram_credential& cred, + const credential_password& password, + scram_credential_cache* cache) { std::optional sasl_mechanism; if ( - cred.stored_key().size() == security::scram_sha256::key_size - && security::scram_sha256::validate_password( - password, cred.stored_key(), cred.salt(), cred.iterations())) { - sasl_mechanism = security::scram_sha256_authenticator::name; + cred.stored_key().size() == scram_sha256::key_size + && match_password(cache, cred, password)) { + sasl_mechanism = scram_sha256_authenticator::name; } else if ( - cred.stored_key().size() == security::scram_sha512::key_size - && security::scram_sha512::validate_password( - password, cred.stored_key(), cred.salt(), cred.iterations())) { - sasl_mechanism = security::scram_sha512_authenticator::name; + cred.stored_key().size() == scram_sha512::key_size + && match_password(cache, cred, password)) { + sasl_mechanism = scram_sha512_authenticator::name; } return sasl_mechanism; } +} // namespace detail + +std::optional validate_scram_credential( + const scram_credential& cred, const credential_password& password) { + if (config::shard_local_cfg().scram_credential_cache_enabled()) { + static thread_local scram_credential_cache cache{ + scram_credential_cache::default_capacity}; + return detail::validate_scram_credential(cred, password, &cache); + } + return detail::validate_scram_credential(cred, password, nullptr); +} } // namespace security diff --git a/src/v/security/scram_authenticator.h b/src/v/security/scram_authenticator.h index d03dd976c0c48..a3cb6ba67eb23 100644 --- a/src/v/security/scram_authenticator.h +++ b/src/v/security/scram_authenticator.h @@ -79,11 +79,13 @@ class scram_authenticator final : public sasl_mechanism { template<> struct scram_mechanism_traits { static constexpr const char* name = "SCRAM-SHA-256"; + static constexpr scram_algorithm_t algorithm = scram_algorithm_t::sha256; }; template<> struct scram_mechanism_traits { static constexpr const char* name = "SCRAM-SHA-512"; + static constexpr scram_algorithm_t algorithm = scram_algorithm_t::sha512; }; struct scram_sha256_authenticator { @@ -98,7 +100,23 @@ struct scram_sha512_authenticator { = scram_mechanism_traits::name; }; +class scram_credential_cache; + +/// Validates a plaintext password against a stored SCRAM credential, for +/// non-SCRAM authentication such as HTTP Basic auth and SASL/PLAIN. Returns +/// the credential's SASL mechanism name on success. +/// +/// Consults the shard's scram_credential_cache to skip the expensive salted +/// password derivation for recently validated (password, credential) pairs, +/// unless disabled via the scram_credential_cache_enabled config. std::optional validate_scram_credential( const scram_credential& cred, const credential_password& password); +namespace detail { +/// As above, against a caller-provided cache. +std::optional validate_scram_credential( + const scram_credential& cred, + const credential_password& password, + scram_credential_cache* cache); +} // namespace detail } // namespace security diff --git a/src/v/security/scram_credential_cache.cc b/src/v/security/scram_credential_cache.cc new file mode 100644 index 0000000000000..337e4239c8ee4 --- /dev/null +++ b/src/v/security/scram_credential_cache.cc @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file licenses/BSL.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ +#include "security/scram_credential_cache.h" + +#include "bytes/random.h" +#include "hashing/secure.h" + +#include + +#include +#include + +namespace security { + +namespace { +// The S3-FIFO probationary queue is conventionally ~10% of the cache. +constexpr size_t small_queue_ratio = 10; +} // namespace + +scram_credential_cache::scram_credential_cache(size_t capacity) + : _digest_key(random_generators::get_crypto_bytes(digest_size)) + , _cache([capacity]() -> decltype(_cache)::config { + auto small_size = std::max(1, capacity / small_queue_ratio); + // s3_fifo::cache requires cache_size > small_size; derive cache_size + // from small_size so the invariant holds even for tiny capacities. + return { + .cache_size = std::max(capacity, small_size + 1), + .small_size = small_size}; + }()) {} + +scram_credential_cache::key_t scram_credential_cache::make_key( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations) const { + hmac_sha256 mac(_digest_key); + // Unambiguous serialization: fixed-size fields and the password length + // come first, so no two distinct inputs produce the same byte stream. + mac.update(std::array{static_cast(mech)}); + mac.update( + std::bit_cast>( + static_cast(iterations))); + mac.update( + std::bit_cast>( + static_cast(password().size()))); + mac.update(std::string_view{password()}); + mac.update(salt); + return {.digest = mac.reset()}; +} + +std::optional scram_credential_cache::get( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations) { + auto val = _cache.get_value(make_key(mech, password, salt, iterations)); + if (!val) { + return std::nullopt; + } + return **val; +} + +void scram_credential_cache::put( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations, + bytes stored_key) { + _cache.try_insert( + make_key(mech, password, salt, iterations), + ss::make_shared(std::move(stored_key))); +} + +scram_credential_cache::stats_t scram_credential_cache::stats() const { + auto s = _cache.stat(); + return { + .hits = s.hit_count, + .misses = s.access_count - s.hit_count, + .size = s.index_size}; +} + +} // namespace security diff --git a/src/v/security/scram_credential_cache.h b/src/v/security/scram_credential_cache.h new file mode 100644 index 0000000000000..dd178cd98abfd --- /dev/null +++ b/src/v/security/scram_credential_cache.h @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file licenses/BSL.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ +#pragma once + +#include "bytes/bytes.h" +#include "security/scram_credential.h" +#include "security/types.h" +#include "utils/chunked_kv_cache.h" + +#include +#include +#include + +namespace security { + +/// \brief Per-shard memoization of SCRAM password derivations. +/// +/// Validating a plaintext password against a stored SCRAM credential +/// rederives the credential's stored key (RFC 5802 `Hi`, at least 4096 HMAC +/// rounds). HTTP Basic auth and SASL/PLAIN pay that cost on every +/// authentication, which is expensive enough to saturate a core at a few +/// thousand authentications per second. This cache memoizes the derivation: +/// (algorithm, password, salt, iterations) -> stored key. +/// +/// Entries are keyed by an HMAC (with a random per-instance key) of the +/// inputs so plaintext passwords are not retained. The value is the stored +/// key derived from the presented password: for a correct password this is +/// the credential's stored key, already persisted in the credential store; +/// an incorrect password is cached too (so repeated bad credentials stay +/// cheap), and its derived value reveals nothing about the stored credential. +/// Either way a memory disclosure yields no credential secret beyond what the +/// store already holds. The memoized mapping is purely functional, so entries +/// never go stale: updating a credential generates a fresh salt, which +/// changes the cache key. +class scram_credential_cache { +public: + static constexpr size_t default_capacity = 1024; + + struct stats_t { + size_t hits; + size_t misses; + size_t size; + }; + + explicit scram_credential_cache(size_t capacity); + + /// Returns the memoized stored key for the derivation inputs, if any. + std::optional get( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations); + + /// Memoizes the stored key derived from the given inputs. + void put( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations, + bytes stored_key); + + stats_t stats() const; + +private: + static constexpr size_t digest_size = 32; + + struct key_t { + std::array digest; + + bool operator==(const key_t&) const = default; + + template + friend H AbslHashValue(H h, const key_t& k) { + return H::combine(std::move(h), k.digest); + } + }; + + key_t make_key( + scram_algorithm_t mech, + const credential_password& password, + bytes_view salt, + int iterations) const; + + bytes _digest_key; + utils::chunked_kv_cache _cache; +}; + +} // namespace security diff --git a/src/v/security/tests/BUILD b/src/v/security/tests/BUILD index e5c5b8992dd92..7b0503794649e 100644 --- a/src/v/security/tests/BUILD +++ b/src/v/security/tests/BUILD @@ -34,6 +34,22 @@ redpanda_cc_btest( ], ) +redpanda_cc_btest( + name = "scram_credential_cache_test", + timeout = "short", + srcs = [ + "scram_credential_cache_test.cc", + ], + deps = [ + "//src/v/bytes", + "//src/v/security", + "//src/v/test_utils:random_bytes", + "@boost//:test", + "@fmt", + "@seastar//:testing", + ], +) + redpanda_cc_gtest( name = "plain_authenticator_test", timeout = "short", @@ -251,6 +267,19 @@ redpanda_cc_bench( ], ) +redpanda_cc_bench( + name = "scram_credential_bench_rpbench", + srcs = [ + "scram_credential_bench.cc", + ], + deps = [ + "//src/v/security", + "//src/v/ssx:sformat", + "@seastar", + "@seastar//:benchmark", + ], +) + redpanda_cc_bench( name = "oidc_authenticator_bench_rpbench", srcs = [ diff --git a/src/v/security/tests/scram_algorithm_test.cc b/src/v/security/tests/scram_algorithm_test.cc index b3eb66568d4c0..9a58cd6519f24 100644 --- a/src/v/security/tests/scram_algorithm_test.cc +++ b/src/v/security/tests/scram_algorithm_test.cc @@ -353,4 +353,26 @@ BOOST_AUTO_TEST_CASE(make_credentials_sets_timestamp) { } } +BOOST_AUTO_TEST_CASE(derive_stored_key_matches_credential) { + const ss::sstring password = "letmein-derive"; + + auto creds256 = scram_sha256::make_credentials( + password, scram_sha256::min_iterations); + BOOST_REQUIRE( + scram_sha256::derive_stored_key( + password, creds256.salt(), creds256.iterations()) + == creds256.stored_key()); + BOOST_REQUIRE( + scram_sha256::derive_stored_key( + "wrong-password", creds256.salt(), creds256.iterations()) + != creds256.stored_key()); + + auto creds512 = scram_sha512::make_credentials( + password, scram_sha512::min_iterations); + BOOST_REQUIRE( + scram_sha512::derive_stored_key( + password, creds512.salt(), creds512.iterations()) + == creds512.stored_key()); +} + } // namespace security diff --git a/src/v/security/tests/scram_credential_bench.cc b/src/v/security/tests/scram_credential_bench.cc new file mode 100644 index 0000000000000..9090314e2b5ef --- /dev/null +++ b/src/v/security/tests/scram_credential_bench.cc @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Redpanda Data, Inc. + * + * Licensed as a Redpanda Enterprise file under the Redpanda Community + * License (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://github.com/redpanda-data/redpanda/blob/master/licenses/rcl.md + */ + +#include "security/scram_algorithm.h" +#include "security/scram_authenticator.h" +#include "security/types.h" +#include "ssx/sformat.h" + +#include + +// Measures security::validate_scram_credential, the password check that +// request_authenticator::do_authenticate runs on every HTTP Basic auth +// request (pandaproxy, schema registry, admin API). The PBKDF2-style +// salted-password derivation (scram_algorithm::hi, min_iterations = 4096 +// HMAC rounds) is what pegged pandaproxy CPU in INC-2882. A wrong password +// costs the same as a correct one: the mismatch is only detected after the +// full derivation. +// +// The correct/wrong cases reuse one credential per case, so they benefit +// from any per-credential memoization of the derivation; the +// unique-password case uses a fresh password each call and always pays the +// full derivation. + +namespace { + +const security::credential_password& password() { + static const security::credential_password p{"benchpassword"}; + return p; +} + +const security::credential_password& wrong_password() { + static const security::credential_password p{"wrongpassword"}; + return p; +} + +const security::scram_credential& sha256_credential() { + static const auto cred = security::scram_sha256::make_credentials( + password()(), security::scram_sha256::min_iterations); + return cred; +} + +const security::scram_credential& sha512_credential() { + static const auto cred = security::scram_sha512::make_credentials( + password()(), security::scram_sha512::min_iterations); + return cred; +} + +} // namespace + +PERF_TEST(validate_scram_credential, sha256_correct_password) { + auto mechanism = security::validate_scram_credential( + sha256_credential(), password()); + perf_tests::do_not_optimize(mechanism); +} + +PERF_TEST(validate_scram_credential, sha256_wrong_password) { + auto mechanism = security::validate_scram_credential( + sha256_credential(), wrong_password()); + perf_tests::do_not_optimize(mechanism); +} + +PERF_TEST(validate_scram_credential, sha512_correct_password) { + auto mechanism = security::validate_scram_credential( + sha512_credential(), password()); + perf_tests::do_not_optimize(mechanism); +} + +// Never-repeating passwords: every call pays the full salted-password +// derivation, with no opportunity for memoization. +PERF_TEST(validate_scram_credential, sha256_unique_passwords) { + static size_t counter = 0; + security::credential_password unique{ + ssx::sformat("benchpassword-{}", counter++)}; + auto mechanism = security::validate_scram_credential( + sha256_credential(), unique); + perf_tests::do_not_optimize(mechanism); +} diff --git a/src/v/security/tests/scram_credential_cache_test.cc b/src/v/security/tests/scram_credential_cache_test.cc new file mode 100644 index 0000000000000..c875344a4511c --- /dev/null +++ b/src/v/security/tests/scram_credential_cache_test.cc @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file licenses/BSL.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ +#define BOOST_TEST_MODULE security + +#include "bytes/bytes.h" +#include "security/scram_algorithm.h" +#include "security/scram_authenticator.h" +#include "security/scram_credential_cache.h" +#include "security/types.h" +#include "test_utils/random_bytes.h" + +#include +#include + +namespace security { + +namespace { + +const credential_password& password() { + static const credential_password p{"cache-test-password"}; + return p; +} + +constexpr int iterations = 4096; + +} // namespace + +BOOST_AUTO_TEST_CASE(cache_miss_then_hit) { + scram_credential_cache cache(16); + auto salt = tests::random_bytes(16); + auto stored_key = tests::random_bytes(32); + + BOOST_REQUIRE( + !cache.get(scram_algorithm_t::sha256, password(), salt, iterations) + .has_value()); + + cache.put( + scram_algorithm_t::sha256, password(), salt, iterations, stored_key); + + auto hit = cache.get( + scram_algorithm_t::sha256, password(), salt, iterations); + BOOST_REQUIRE(hit.has_value()); + BOOST_REQUIRE(*hit == stored_key); + + auto stats = cache.stats(); + BOOST_REQUIRE_EQUAL(stats.hits, 1); + BOOST_REQUIRE_EQUAL(stats.misses, 1); +} + +BOOST_AUTO_TEST_CASE(cache_key_includes_all_inputs) { + scram_credential_cache cache(16); + auto salt = tests::random_bytes(16); + auto stored_key = tests::random_bytes(32); + + cache.put( + scram_algorithm_t::sha256, password(), salt, iterations, stored_key); + + // Same inputs: hit. + BOOST_REQUIRE( + cache.get(scram_algorithm_t::sha256, password(), salt, iterations) + .has_value()); + // Different password: miss. + BOOST_REQUIRE(!cache + .get( + scram_algorithm_t::sha256, + credential_password{"other-password"}, + salt, + iterations) + .has_value()); + // Different salt (i.e. the credential was updated): miss. + auto other_salt = tests::random_bytes(16); + BOOST_REQUIRE( + !cache.get(scram_algorithm_t::sha256, password(), other_salt, iterations) + .has_value()); + // Different iteration count: miss. + BOOST_REQUIRE( + !cache.get(scram_algorithm_t::sha256, password(), salt, iterations * 2) + .has_value()); + // Different mechanism: miss. + BOOST_REQUIRE( + !cache.get(scram_algorithm_t::sha512, password(), salt, iterations) + .has_value()); +} + +BOOST_AUTO_TEST_CASE(cache_is_bounded) { + constexpr size_t capacity = 16; + scram_credential_cache cache(capacity); + auto salt = tests::random_bytes(16); + + for (size_t i = 0; i < capacity * 10; ++i) { + cache.put( + scram_algorithm_t::sha256, + credential_password{fmt::format("password-{}", i)}, + salt, + iterations, + tests::random_bytes(32)); + } + + // A one-hit-wonder inserted early must have been evicted. + BOOST_REQUIRE(!cache + .get( + scram_algorithm_t::sha256, + credential_password{"password-0"}, + salt, + iterations) + .has_value()); + // The index may retain evicted (ghost) entries, but remains bounded well + // below the number of insertions. + BOOST_REQUIRE_LT(cache.stats().size, capacity * 5); +} + +BOOST_AUTO_TEST_CASE(cache_tiny_capacity_does_not_crash) { + // The S3-FIFO backing asserts cache_size > small_size; capacities below + // that minimum must be floored rather than trip the assertion. + for (size_t capacity : {size_t{0}, size_t{1}, size_t{2}}) { + scram_credential_cache cache(capacity); + auto salt = tests::random_bytes(16); + auto stored_key = tests::random_bytes(32); + cache.put( + scram_algorithm_t::sha256, password(), salt, iterations, stored_key); + BOOST_REQUIRE( + cache.get(scram_algorithm_t::sha256, password(), salt, iterations) + .has_value()); + } +} + +BOOST_AUTO_TEST_CASE(cached_validate_scram_credential) { + scram_credential_cache cache(16); + auto cred = scram_sha256::make_credentials( + password()(), scram_sha256::min_iterations); + + auto mech = detail::validate_scram_credential(cred, password(), &cache); + BOOST_REQUIRE(mech.has_value()); + BOOST_REQUIRE_EQUAL(*mech, scram_sha256_authenticator::name); + BOOST_REQUIRE_EQUAL(cache.stats().hits, 0); + + // Repeat validation is served from the cache with the same result. + mech = detail::validate_scram_credential(cred, password(), &cache); + BOOST_REQUIRE(mech.has_value()); + BOOST_REQUIRE_EQUAL(*mech, scram_sha256_authenticator::name); + BOOST_REQUIRE_EQUAL(cache.stats().hits, 1); + + // A wrong password fails, and keeps failing once its derivation is + // cached. + const credential_password wrong{"wrong-password"}; + BOOST_REQUIRE( + !detail::validate_scram_credential(cred, wrong, &cache).has_value()); + BOOST_REQUIRE( + !detail::validate_scram_credential(cred, wrong, &cache).has_value()); +} + +BOOST_AUTO_TEST_CASE(cached_validate_scram_credential_sha512) { + scram_credential_cache cache(16); + auto cred = scram_sha512::make_credentials( + password()(), scram_sha512::min_iterations); + + for (int i = 0; i < 2; ++i) { + auto mech = detail::validate_scram_credential(cred, password(), &cache); + BOOST_REQUIRE(mech.has_value()); + BOOST_REQUIRE_EQUAL(*mech, scram_sha512_authenticator::name); + } + BOOST_REQUIRE_EQUAL(cache.stats().hits, 1); +} + +BOOST_AUTO_TEST_CASE(cached_validate_scram_credential_password_change) { + scram_credential_cache cache(16); + auto cred = scram_sha256::make_credentials( + password()(), scram_sha256::min_iterations); + + // Warm the cache with the old credential. + BOOST_REQUIRE(detail::validate_scram_credential(cred, password(), &cache)); + BOOST_REQUIRE(detail::validate_scram_credential(cred, password(), &cache)); + + // The user's password is changed: the new credential gets a fresh salt. + const credential_password new_password{"brand-new-password"}; + auto new_cred = scram_sha256::make_credentials( + new_password(), scram_sha256::min_iterations); + + // The stale cache entries do not interfere in either direction. + BOOST_REQUIRE( + detail::validate_scram_credential(new_cred, new_password, &cache) + .has_value()); + BOOST_REQUIRE( + !detail::validate_scram_credential(new_cred, password(), &cache) + .has_value()); +} + +} // namespace security