/* * Copyright (C) 2016 ScyllaDB */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "ent/encryption/azure_host.hh" #include "ent/encryption/encryption.hh" #include "ent/encryption/symmetric_key.hh" #include "ent/encryption/local_file_provider.hh" #include "ent/encryption/encryption_exceptions.hh" #include "test/lib/tmpdir.hh" #include "test/lib/test_utils.hh" #include "test/lib/random_utils.hh" #include "test/lib/cql_test_env.hh" #include "test/lib/cql_assertions.hh" #include "test/lib/log.hh" #include "test/lib/proc_utils.hh" #include "test/lib/aws_kms_fixture.hh" #include "test/lib/azure_kms_fixture.hh" #include "db/config.hh" #include "db/extensions.hh" #include "db/commitlog/commitlog.hh" #include "db/commitlog/commitlog_replayer.hh" #include "init.hh" #include "sstables/sstables.hh" #include "cql3/untyped_result_set.hh" #include "utils/rjson.hh" #include "utils/http.hh" #include "utils/azure/identity/exceptions.hh" #include "utils/azure/identity/managed_identity_credentials.hh" #include "utils/azure/identity/service_principal_credentials.hh" #include "replica/database.hh" #include "service/client_state.hh" using namespace encryption; namespace fs = std::filesystem; using test_hook = std::function; struct test_provider_args { const tmpdir& tmp; std::string options; std::string extra_yaml = {}; unsigned n_tables = 1; unsigned n_restarts = 1; std::string explicit_provider = {}; test_hook before_create_table; test_hook after_create_table; test_hook after_insert; test_hook on_insert_exception; test_hook before_verify; std::optional timeout; }; static void do_create_and_insert(cql_test_env& env, const test_provider_args& args, const std::string& pk, const std::string& v) { for (auto i = 0u; i < args.n_tables; ++i) { if (args.before_create_table) { testlog.debug("Calling before create table"); args.before_create_table(env); } if (args.options.empty()) { env.execute_cql(fmt::format("create table t{} (pk text primary key, v text)", i)).get(); } else { env.execute_cql(fmt::format("create table t{} (pk text primary key, v text) WITH scylla_encryption_options={{{}}}", i, args.options)).get(); } if (args.after_create_table) { testlog.debug("Calling after create table"); args.after_create_table(env); } try { env.execute_cql(fmt::format("insert into ks.t{} (pk, v) values ('{}', '{}')", i, pk, v)).get(); } catch (...) { testlog.info("Insert error {}. Notifying.", std::current_exception()); args.on_insert_exception(env); throw; } if (args.after_insert) { testlog.debug("Calling after insert"); args.after_insert(env); } } } static future<> test_provider(const test_provider_args& args) { auto make_config = [&] { auto ext = std::make_shared(); auto cfg = seastar::make_shared(ext); cfg->data_file_directories({args.tmp.path().string()}); // Currently the test fails with consistent_cluster_management = true. See #2995. cfg->consistent_cluster_management(false); { boost::program_options::options_description desc; boost::program_options::options_description_easy_init init(&desc); configurable::append_all(*cfg, init); } if (!args.extra_yaml.empty()) { cfg->read_from_yaml(args.extra_yaml); } return std::make_tuple(cfg, ext); }; std::string pk = "apa"; std::string v = "ko"; { auto [cfg, ext] = make_config(); co_await do_with_cql_env_thread([&] (cql_test_env& env) { do_create_and_insert(env, args, pk, v); }, cfg, {}, cql_test_init_configurables{ *ext }); } for (auto rs = 0u; rs < args.n_restarts; ++rs) { auto [cfg, ext] = make_config(); co_await do_with_cql_env_thread([&] (cql_test_env& env) { if (args.before_verify) { testlog.debug("Calling after second start"); args.before_verify(env); } for (auto i = 0u; i < args.n_tables; ++i) { require_rows(env, fmt::format("select * from ks.t{}", i), {{utf8_type->decompose(pk), utf8_type->decompose(v)}}); auto provider = args.explicit_provider; // check that all sstables have the defined provider class (i.e. are encrypted using correct optons) if (provider.empty() && args.options.find("'key_provider'") != std::string::npos) { static std::regex ex(R"foo('key_provider'\s*:\s*'(\w+)')foo"); std::smatch m; BOOST_REQUIRE(std::regex_search(args.options.begin(), args.options.end(), m, ex)); provider = m[1].str(); BOOST_REQUIRE(!provider.empty()); } if (!provider.empty()) { env.db().invoke_on_all([&](replica::database& db) { auto& cf = db.find_column_family("ks", "t" + std::to_string(i)); auto sstables = cf.get_sstables_including_compacted_undeleted(); if (sstables) { for (auto& t : *sstables) { auto sst_provider = encryption::encryption_provider(*t); BOOST_REQUIRE_EQUAL(provider, sst_provider); } } }).get(); } } }, cfg, {}, cql_test_init_configurables{ *ext }); } } static future<> test_provider(const std::string& options, const tmpdir& tmp, const std::string& extra_yaml = {}, unsigned n_tables = 1, unsigned n_restarts = 1, const std::string& explicit_provider = {}) { test_provider_args args{ .tmp = tmp, .options = options, .extra_yaml = extra_yaml, .n_tables = n_tables, .n_restarts = n_restarts, .explicit_provider = explicit_provider }; co_await test_provider(args); } static auto make_commitlog_config(const test_provider_args& args, const std::unordered_map& scopts) { auto ext = std::make_shared(); auto cfg = seastar::make_shared(ext); cfg->data_file_directories({args.tmp.path().string()}); cfg->commitlog_sync("batch"); // just to make sure files are written // Currently the test fails with consistent_cluster_management = true. See #2995. cfg->consistent_cluster_management(false); boost::program_options::options_description desc; boost::program_options::options_description_easy_init init(&desc); configurable::append_all(*cfg, init); std::ostringstream ss; ss << "system_info_encryption:" << std::endl << " enabled: true" << std::endl << " cipher_algorithm: AES/CBC/PKCS5Padding" << std::endl << " secret_key_strength: 128" << std::endl ; for (auto& [k, v] : scopts) { ss << " " << k << ": " << v << std::endl; } auto str = ss.str(); cfg->read_from_yaml(str); if (!args.extra_yaml.empty()) { cfg->read_from_yaml(args.extra_yaml); } return std::make_tuple(cfg, ext); } static future<> test_encrypted_commitlog(const test_provider_args& args, std::unordered_map scopts = {}) { fs::path clback = args.tmp.path() / "commitlog_back"; std::string pk = "apa"; std::string v = "ko"; { auto [cfg, ext] = make_commitlog_config(args, scopts); cql_test_config cqlcfg(cfg); if (args.timeout) { cqlcfg.query_timeout = args.timeout; } co_await do_with_cql_env_thread([&] (cql_test_env& env) { do_create_and_insert(env, args, pk, v); fs::copy(fs::path(cfg->commitlog_directory()), clback); }, cqlcfg, {}, cql_test_init_configurables{ *ext }); } { auto [cfg, ext] = make_commitlog_config(args, scopts); cql_test_config cqlcfg(cfg); if (args.timeout) { cqlcfg.query_timeout = args.timeout; } co_await do_with_cql_env_thread([&] (cql_test_env& env) { // Fake commitlog replay using the files copied. std::vector paths; for (auto const& dir_entry : fs::directory_iterator{clback}) { auto p = dir_entry.path(); try { db::commitlog::descriptor d(p); paths.emplace_back(std::move(p)); } catch (...) { } } BOOST_REQUIRE(!paths.empty()); auto rp = db::commitlog_replayer::create_replayer(env.db(), env.get_system_keyspace()).get(); rp.recover(paths, db::commitlog::descriptor::FILENAME_PREFIX).get(); // not really checking anything, but make sure we did not break anything. for (auto i = 0u; i < args.n_tables; ++i) { require_rows(env, fmt::format("select * from ks.t{}", i), {{utf8_type->decompose(pk), utf8_type->decompose(v)}}); } }, cqlcfg, {}, cql_test_init_configurables{ *ext }); } } static future<> test_encrypted_commitlog(const tmpdir& tmp, std::unordered_map scopts = {}, const std::string& extra_yaml = {}, unsigned n_tables = 1) { test_provider_args args{ .tmp = tmp, .extra_yaml = extra_yaml, .n_tables = n_tables, }; co_await test_encrypted_commitlog(args, std::move(scopts)); } using scopts_map = std::unordered_map; static future<> test_broken_encrypted_commitlog(const test_provider_args& args, scopts_map scopts = {}) { std::string pk = "apa"; std::string v = "ko"; { auto [cfg, ext] = make_commitlog_config(args, scopts); cql_test_config cqlcfg(cfg); if (args.timeout) { cqlcfg.query_timeout = args.timeout; } co_await do_with_cql_env_thread([&] (cql_test_env& env) { do_create_and_insert(env, args, pk, v); }, cqlcfg, {}, cql_test_init_configurables{ *ext }); } } class fake_proxy { seastar::server_socket _socket; socket_address _address; bool _go_on = true; bool _do_proxy = true; future<> _f; future<> run(std::string dst_addr) { uint16_t port = 443u; auto i = dst_addr.find_last_of(':'); if (i != std::string::npos && i > 0 && dst_addr[i - 1] != ':') { // just check against ipv6... port = std::stoul(dst_addr.substr(i + 1)); dst_addr = dst_addr.substr(0, i); } auto addr = co_await seastar::net::dns::resolve_name(dst_addr); std::vector> work; while (_go_on) { try { auto client = co_await _socket.accept(); auto dst = co_await seastar::connect(socket_address(addr, port)); testlog.debug("Got proxy connection: {}->{}:{} ({})", client.remote_address, dst_addr, port, _do_proxy); auto f = [&]() -> future<> { auto& s = client.connection; auto& ldst = dst; auto addr = client.remote_address; auto do_io = [this, &addr, &dst_addr, port](connected_socket& src, connected_socket& dst) noexcept -> future<> { try { auto sin = src.input(); auto dout = output_stream(dst.output().detach(), 1024); // note: have to have differing conditions for proxying // and shutdown, and need to check inside look, because // kmip connector caches connection -> not new socket. std::exception_ptr p; try { while (_go_on && _do_proxy && !sin.eof()) { auto buf = co_await sin.read(); auto n = buf.size(); testlog.trace("Read {} bytes: {}->{}:{}", n, addr, dst_addr, port); if (_do_proxy) { co_await dout.write(std::move(buf)); co_await dout.flush(); testlog.trace("Wrote {} bytes: {}->{}:{}", n, addr, dst_addr, port); } } } catch (...) { p = std::current_exception(); } co_await dout.flush(); co_await dout.close(); co_await sin.close(); if (p) { std::rethrow_exception(p); } } catch (...) { testlog.warn("Exception running proxy {}:{}->{}: {}", dst_addr, port, _address, std::current_exception()); } }; co_await when_all(do_io(s, ldst), do_io(ldst, s)); }(); work.emplace_back(std::move(f)); } catch (...) { testlog.warn("Exception running proxy {}: {}", _address, std::current_exception()); } } for (auto&& f : work) { try { co_await std::move(f); } catch (...) { } } } public: fake_proxy(std::string dst) : _socket(seastar::listen(socket_address(0x7f000001, 0))) , _address(_socket.local_address()) , _f(run(std::move(dst))) {} const socket_address& address() const { return _address; } void enable(bool b) { _do_proxy = b; testlog.info("Set proxy {} enabled = {}", _address, b); } future<> stop() { if (std::exchange(_go_on, false)) { testlog.info("Stopping proxy {}", _address); _socket.abort_accept(); co_await std::move(_f); } } }; /** * Tests that a network error in key resolution (in commitlog in this case) results in a non-fatal, non-isolating * exception, i.e. an eventual write error. */ static future<> network_error_test_helper(const tmpdir& tmp, const std::string& host, std::function(const fake_proxy&)> make_opts) { fake_proxy proxy(host); std::exception_ptr p; try { auto [scopts, yaml] = make_opts(proxy); test_provider_args args{ .tmp = tmp, .extra_yaml = yaml, .n_tables = 10, .before_create_table = [&](auto& env) { // turn off proxy. all key resolution after this should fail proxy.enable(false); // wait for key cache expiry. seastar::sleep(10ms).get(); // ensure commitlog will create a new segment on write -> eventual write failure env.db().invoke_on_all([](replica::database& db) { return db.commitlog()->force_new_active_segment(); }).get(); }, .on_insert_exception = [&](auto&&) { // once we get the exception we have to enable key resolution again, // otherwise we can't shut down cql test env. proxy.enable(true); }, .timeout = timeout_config{ // set really low write timeouts so we get a failure (timeout) // when we fail to write to commitlog 100ms, 100ms, 100ms, 100ms, 100ms, 100ms, 100ms }, }; BOOST_REQUIRE_THROW( co_await test_broken_encrypted_commitlog(args, scopts); , exceptions::mutation_write_timeout_exception ); } catch (...) { p = std::current_exception(); } co_await proxy.stop(); if (p) { std::rethrow_exception(p); } } SEASTAR_TEST_CASE(test_local_file_provider) { tmpdir tmp; auto keyfile = tmp.path() / "secret_key"; co_await test_provider(fmt::format("'key_provider': 'LocalFileSystemKeyProviderFactory', 'secret_key_file': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", keyfile.string()), tmp); } static future<> create_key_file(const fs::path& path, const std::vector& key_types) { std::ostringstream ss; for (auto& info : key_types) { symmetric_key k(info); ss << info.alg << ":" << info.len << ":" << base64_encode(k.key()) << std::endl; } auto s = ss.str(); co_await seastar::recursive_touch_directory(fs::path(path).remove_filename().string()); co_await write_text_file_fully(path.string(), s); } static future<> do_test_replicated_provider(unsigned n_tables, unsigned n_restarts, const std::string& extra = {}, test_hook hook = {}) { tmpdir tmp; auto keyfile = tmp.path() / "secret_key"; auto sysdir = tmp.path() / "system_keys"; auto syskey = sysdir / "system_key"; auto yaml = fmt::format("system_key_directory: {}", sysdir.string()); co_await create_key_file(syskey, { { "AES/CBC/PKCSPadding", 256 }}); BOOST_REQUIRE(fs::exists(syskey));; test_provider_args args{ .tmp = tmp, .options = fmt::format("'key_provider': 'ReplicatedKeyProviderFactory', 'system_key_file': 'system_key', 'secret_key_file': '{}','cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128{}", keyfile.string(), extra), .extra_yaml = yaml, .n_tables = n_tables, .n_restarts = n_restarts, .explicit_provider = {}, .after_create_table = hook }; co_await test_provider(args); BOOST_REQUIRE(fs::exists(tmp.path())); } SEASTAR_TEST_CASE(test_replicated_provider) { co_await do_test_replicated_provider(1, 1); } SEASTAR_TEST_CASE(test_replicated_provider_many_tables) { co_await do_test_replicated_provider(100, 5); } using namespace std::chrono_literals; static const timeout_config rkp_db_timeout_config { 5s, 5s, 5s, 5s, 5s, 5s, 5s, }; static service::query_state& rkp_db_query_state() { static thread_local service::client_state cs(service::client_state::internal_tag{}, rkp_db_timeout_config); static thread_local service::query_state qs(cs, empty_service_permit()); return qs; } SEASTAR_TEST_CASE(test_replicated_provider_shutdown_failure) { co_await do_test_replicated_provider(1, 1, ", 'DEBUG': 'nocache,novalidate'", [](cql_test_env& env) { /** * Try to remove all keys in replicated table. Note: we can't use truncate because we * are not running any proper remotes. */ auto res = env.local_qp().execute_internal("select * from system_replicated_keys.encrypted_keys", db::consistency_level::ONE, rkp_db_query_state(), {}, cql3::query_processor::cache_internal::no ).get(); for (auto& row : (*res)) { auto key_file = row.get_as("key_file"); auto cipher = row.get_as("cipher"); auto strength = row.get_as("strength"); auto uuid = row.get_as("key_id"); env.local_qp().execute_internal("delete from system_replicated_keys.encrypted_keys where key_file=? AND cipher=? AND strength=? AND key_id=?", db::consistency_level::ONE, rkp_db_query_state(), { key_file, cipher, strength, uuid }, cql3::query_processor::cache_internal::no ).get(); } }); } static std::string get_var_or_default(const char* var, std::string_view def, bool* set) { const char* val = std::getenv(var); if (val == nullptr) { *set = false; return std::string(def); } *set = true; return val; } static std::string get_var_or_default(const char* var, std::string_view def) { bool dummy; return get_var_or_default(var, def, &dummy); } static bool check_run_test(const char* var, bool defval = false) { auto do_test = get_var_or_default(var, std::to_string(defval)); if (!strcasecmp(do_test.data(), "0") || !strcasecmp(do_test.data(), "false")) { BOOST_TEST_MESSAGE(fmt::format("Skipping test. Set {}=1 to run", var)); return false; } return true; } static auto check_run_test_decorator(const char* var, bool def = false) { return boost::unit_test::precondition(std::bind(&check_run_test, var, def)); } #ifdef HAVE_KMIP struct kmip_test_info { std::string host; std::string cert; std::string key; std::string ca; std::string prio; }; static future<> kmip_test_helper(const std::function(const kmip_test_info&, const tmpdir&)>& f) { tmpdir tmp; bool host_set = false; // Preserve tmpdir on exception. // The directory contains PyKMIP server logs that may help diagnose test failures. // Allow the user to disable this with an env var (e.g., for local debugging). auto preserve_tmpdir = get_var_or_default("SCYLLA_TEST_PRESERVE_TMP_ON_EXCEPTION", "true"); if (preserve_tmpdir == "1" || strcasecmp(preserve_tmpdir.data(), "true") == 0) { tmp.preserve_on_exception(true); } static const char* def_resourcedir = "./test/resource/certs"; const char* resourcedir = std::getenv("KMIP_RESOURCE_DIR"); if (resourcedir == nullptr) { resourcedir = def_resourcedir; } kmip_test_info info { .host = get_var_or_default("KMIP_HOST", "127.0.0.1", &host_set), .cert = get_var_or_default("KMIP_CERT", fmt::format("{}/scylla.pem", resourcedir)), .key = get_var_or_default("KMIP_KEY", fmt::format("{}/scylla.pem", resourcedir)), .ca = get_var_or_default("KMIP_CA", fmt::format("{}/cacert.pem", resourcedir)), .prio = get_var_or_default("KMIP_PRIO", "SECURE128:+RSA:-VERS-TLS1.0:-ECDHE-ECDSA") }; // note: default kmip port = 5696; if (!host_set) { // Note: we set `enable_tls_client_auth=False` - client cert is still validated, // but we have note generated certs with "extended usage client OID", which // pykmip will check for if this is true. auto cfg = fmt::format(R"foo( [server] hostname=127.0.0.1 port=1 certificate_path={} key_path={} ca_path={} auth_suite=TLS1.2 policy_path={} enable_tls_client_auth=False logging_level=DEBUG database_path=:memory: )foo", info.cert, info.key, info.ca, tmp.path().string()); auto cfgfile = fmt::format("{}/pykmip.conf", tmp.path().string()); auto log = fmt::format("{}/pykmip.log", tmp.path().string()); { std::ofstream of(cfgfile); of << cfg; } auto pyexec = tests::proc::find_file_in_path("python"); promise port_promise; auto port_future = port_promise.get_future(); auto python = co_await tests::proc::process_fixture::create(pyexec, { // args pyexec.string(), "test/pylib/kmip_wrapper.py", "-l", log, "-f", cfgfile, "-v", "DEBUG", }, { // env fmt::format("TMPDIR={}", tmp.path().string()) }, // stdout handler [port_promise = std::move(port_promise), b = false](std::string_view line) mutable -> future> { static std::regex port_ex("Listening on (\\d+)"); std::cout << line << std::endl; std::match_results m; if (!b && std::regex_match(line.begin(), line.end(), m, port_ex)) { port_promise.set_value(std::stoi(m[1].str())); BOOST_TEST_MESSAGE("Matched PyKMIP port: " + m[1].str()); b = true; } co_return continue_consuming{}; }, // stderr handler tests::proc::process_fixture::create_copy_handler(std::cerr) ); std::exception_ptr ep; try { // arbitrary timeout of 20s for the server to make some output. Very generous. auto port = co_await with_timeout(std::chrono::steady_clock::now() + 20s, std::move(port_future)); if (port <= 0) { throw std::runtime_error("Invalid port"); } tls::credentials_builder b; co_await b.set_x509_trust_file(info.ca, seastar::tls::x509_crt_format::PEM); co_await b.set_x509_key_file(info.cert, info.key, seastar::tls::x509_crt_format::PEM); auto certs = b.build_certificate_credentials(); // wait for port. for (;;) { try { // TODO: seastar does not have a connect with timeout. That would be helpful here. But alas... auto c = co_await seastar::tls::connect(certs, socket_address(net::inet_address("127.0.0.1"), port)); BOOST_TEST_MESSAGE("PyKMIP server up and available"); // debug print. Why not. co_await tls::check_session_is_resumed(c); // forces handshake. Make python ssl happy. c.shutdown_output(); break; } catch (...) { } co_await sleep(100ms); } info.host = fmt::format("127.0.0.1:{}", port); co_await f(info, tmp); } catch (timed_out_error&) { ep = std::make_exception_ptr(std::runtime_error("Could not start pykmip")); } catch (...) { ep = std::current_exception(); } BOOST_TEST_MESSAGE("Stopping PyKMIP server"); // debug print. Why not. python.terminate(); co_await python.wait(); if (ep) { std::rethrow_exception(ep); } } else { co_await f(info, tmp); } } SEASTAR_TEST_CASE(test_kmip_provider, *check_run_test_decorator("ENABLE_KMIP_TEST", true)) { co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} )foo" , info.host, info.cert, info.key, info.ca, info.prio ); co_await test_provider("'key_provider': 'KmipKeyProviderFactory', 'kmip_host': 'kmip_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); }); } SEASTAR_TEST_CASE(test_kmip_provider_multiple_hosts, *check_run_test_decorator("ENABLE_KMIP_TEST", true)) { /** * Tests for #3251. KMIP connector ends up in endless loop if using more than one * fallover host. This is only in initial connection (in real life only in initial connection verification). * * We don't have access to more than one KMIP server for testing (at a time). * Pretend to have failover by using a local proxy. */ co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { fake_proxy proxy(info.host); auto host2 = boost::lexical_cast(proxy.address()); auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0}, {5} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} )foo" , info.host, info.cert, info.key, info.ca, info.prio, host2 ); std::exception_ptr ex; try { co_await test_provider("'key_provider': 'KmipKeyProviderFactory', 'kmip_host': 'kmip_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } catch (...) { ex = std::current_exception(); } co_await proxy.stop(); if (ex) { std::rethrow_exception(ex); } }); } SEASTAR_TEST_CASE(test_commitlog_kmip_encryption_with_slow_key_resolve, *check_run_test_decorator("ENABLE_KMIP_TEST")) { co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} )foo" , info.host, info.cert, info.key, info.ca, info.prio ); co_await test_encrypted_commitlog(tmp, { { "key_provider", "KmipKeyProviderFactory" }, { "kmip_host", "kmip_test" } }, yaml); }); } SEASTAR_TEST_CASE(test_kmip_network_error, *check_run_test_decorator("ENABLE_KMIP_TEST")) { co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { co_await network_error_test_helper(tmp, info.host, [&](const auto& proxy) { auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} key_cache_expiry: 1ms )foo" , proxy.address(), info.cert, info.key, info.ca, info.prio ); return std::make_tuple(scopts_map({ { "key_provider", "KmipKeyProviderFactory" }, { "kmip_host", "kmip_test" } }), yaml); }); }); } SEASTAR_TEST_CASE(test_kmip_provider_broken_config_on_restart, *check_run_test_decorator("ENABLE_KMIP_TEST", true)) { co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} )foo" , info.host, info.cert, info.key, info.ca, info.prio ); bool past_create = false; test_provider_args args{ .tmp = tmp, .options = "'key_provider': 'KmipKeyProviderFactory', 'kmip_host': 'kmip_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", .extra_yaml = yaml, .n_tables = 1, .n_restarts = 1, .explicit_provider = {}, }; // After tables are created, and data inserted, remove EAR config // for the restart. This should cause us to fail creating the // tables from schema tables, since the extension will throw. args.after_insert = [&](cql_test_env&) { past_create = true; args.extra_yaml = {}; }; BOOST_REQUIRE_THROW( co_await test_provider(args); , std::exception ); BOOST_REQUIRE(past_create); }); } SEASTAR_TEST_CASE(test_kmip_provider_broken_sstables_on_restart, *check_run_test_decorator("ENABLE_KMIP_TEST", true)) { co_await kmip_test_helper([](const kmip_test_info& info, const tmpdir& tmp) -> future<> { auto yaml = fmt::format(R"foo( kmip_hosts: kmip_test: hosts: {0} certificate: {1} keyfile: {2} truststore: {3} priority_string: {4} )foo" , info.host, info.cert, info.key, info.ca, info.prio ); bool past_create = false; bool past_second_start = false; test_provider_args args{ .tmp = tmp, .options = "'key_provider': 'KmipKeyProviderFactory', 'kmip_host': 'kmip_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", .extra_yaml = yaml, .n_tables = 1, .n_restarts = 1, .explicit_provider = {}, }; // After data is inserted, flush all shards and alter the table // to no longer use EAR, then remove EAR config. This will result // in a schema that loads fine, but accessing the sstables will // throw. args.after_insert = [&](cql_test_env& env) { try { env.db().invoke_on_all([](replica::database& db) { auto& cf = db.find_column_family("ks", "t0"); return cf.flush(); }).get(); env.execute_cql(fmt::format("alter table ks.t0 WITH scylla_encryption_options={{'key_provider': 'none'}}")).get(); } catch (...) { testlog.error("Unexpected exception {}", std::current_exception()); throw; } past_create = true; args.extra_yaml = {}; args.options = {}; }; // If we get here, startup of second run was successful. args.before_verify = [&](cql_test_env& env) { past_second_start = true; }; BOOST_REQUIRE_THROW( co_await test_provider(args); , std::exception ); BOOST_REQUIRE(past_create); // We'd really want to be past this here, since "only" the sstables // on disk should mention unresolvable EAR stuff here. But scylla will // scan sstables on startup, and thus fail already there. // TODO: move and upload sstables? BOOST_REQUIRE(!past_second_start); }); } #endif // HAVE_KMIP std::string make_aws_host(std::string_view aws_region, std::string_view service); BOOST_AUTO_TEST_SUITE(aws_kms, *seastar::testing::async_fixture()) SEASTAR_FIXTURE_TEST_CASE(test_kms_provider, local_aws_kms_wrapper, *check_run_test_decorator("ENABLE_KMS_TEST", true)) { tmpdir tmp; /** * Note: NOT including any auth stuff here. The provider will pick up AWS credentials * from ~/.aws/credentials */ auto yaml = fmt::format(R"foo( kms_hosts: kms_test: master_key: {0} aws_region: {1} aws_profile: {2} endpoint: {3} )foo" , kms_key_alias, kms_aws_region, kms_aws_profile, endpoint ); co_await test_provider("'key_provider': 'KmsKeyProviderFactory', 'kms_host': 'kms_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } SEASTAR_FIXTURE_TEST_CASE(test_kms_provider_with_master_key_in_cf, local_aws_kms_wrapper, *check_run_test_decorator("ENABLE_KMS_TEST", true)) { tmpdir tmp; /** * Note: NOT including any auth stuff here. The provider will pick up AWS credentials * from ~/.aws/credentials */ auto yaml = fmt::format(R"foo( kms_hosts: kms_test: aws_region: {1} aws_profile: {2} endpoint: {3} )foo" , kms_key_alias, kms_aws_region, kms_aws_profile, endpoint ); // should fail try { try { co_await test_provider("'key_provider': 'KmsKeyProviderFactory', 'kms_host': 'kms_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', " "'secret_key_strength': 128", tmp, yaml); } catch (std::nested_exception& ex) { std::rethrow_if_nested(ex); } BOOST_FAIL("Required an exception to be re-thrown"); } catch (encryption::configuration_error&) { // EXPECTED } catch (...) { BOOST_FAIL(format("Unexpected exception: {}", std::current_exception())); } // should be ok co_await test_provider(fmt::format("'key_provider': 'KmsKeyProviderFactory', 'kms_host': 'kms_test', 'master_key': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", kms_key_alias) , tmp, yaml ); } SEASTAR_FIXTURE_TEST_CASE(test_kms_provider_with_broken_algo, local_aws_kms_wrapper, *check_run_test_decorator("ENABLE_KMS_TEST", true)) { tmpdir tmp; /** * Note: NOT including any auth stuff here. The provider will pick up AWS credentials * from ~/.aws/credentials */ auto yaml = fmt::format(R"foo( kms_hosts: kms_test: master_key: {0} aws_region: {1} aws_profile: {2} endpoint: {3} )foo" , kms_key_alias, kms_aws_region, kms_aws_profile, endpoint ); try { co_await test_provider("'key_provider': 'KmsKeyProviderFactory', 'kms_host': 'kms_test', 'cipher_algorithm':'', 'secret_key_strength': 128", tmp, yaml); BOOST_FAIL("should not reach"); } catch (exceptions::configuration_exception&) { // ok } } SEASTAR_FIXTURE_TEST_CASE(test_commitlog_kms_encryption_with_slow_key_resolve, local_aws_kms_wrapper, *check_run_test_decorator("ENABLE_KMS_TEST", true)) { tmpdir tmp; /** * Note: NOT including any auth stuff here. The provider will pick up AWS credentials * from ~/.aws/credentials */ auto yaml = fmt::format(R"foo( kms_hosts: kms_test: master_key: {0} aws_region: {1} aws_profile: {2} endpoint: {3} )foo" , kms_key_alias, kms_aws_region, kms_aws_profile, endpoint ); co_await test_encrypted_commitlog(tmp, { { "key_provider", "KmsKeyProviderFactory" }, { "kms_host", "kms_test" } }, yaml); } SEASTAR_FIXTURE_TEST_CASE(test_kms_network_error, local_aws_kms_wrapper, *check_run_test_decorator("ENABLE_KMS_TEST", true)) { tmpdir tmp; std::string host, scheme; if (endpoint.empty()) { host = make_aws_host(kms_aws_region, "kms"); scheme = "https://"; } else { auto info = utils::http::parse_simple_url(endpoint); host = info.host + ":" + std::to_string(info.port); scheme = info.scheme; } co_await network_error_test_helper(tmp, host, [&](const auto& proxy) { auto yaml = fmt::format(R"foo( kms_hosts: kms_test: master_key: {0} aws_region: {1} aws_profile: {2} endpoint: {3}://{4} key_cache_expiry: 1ms )foo" , kms_key_alias, kms_aws_region, kms_aws_profile, scheme, proxy.address() ); return std::make_tuple(scopts_map({ { "key_provider", "KmsKeyProviderFactory" }, { "kms_host", "kms_test" } }), yaml); }); } BOOST_AUTO_TEST_SUITE_END() SEASTAR_TEST_CASE(test_user_info_encryption) { tmpdir tmp; auto keyfile = tmp.path() / "secret_key"; auto yaml = fmt::format(R"foo( user_info_encryption: enabled: True key_provider: LocalFileSystemKeyProviderFactory secret_key_file: {} cipher_algorithm: AES/CBC/PKCS5Padding secret_key_strength: 128 )foo" , keyfile.string()); co_await test_provider({}, tmp, yaml, 4, 1, "LocalFileSystemKeyProviderFactory" /* verify encrypted even though no kp in options*/); } SEASTAR_TEST_CASE(test_user_info_encryption_dont_allow_per_table_encryption) { tmpdir tmp; auto keyfile = tmp.path() / "secret_key"; auto yaml = fmt::format(R"foo( allow_per_table_encryption: false user_info_encryption: enabled: True key_provider: LocalFileSystemKeyProviderFactory secret_key_file: {} cipher_algorithm: AES/CBC/PKCS5Padding secret_key_strength: 128 )foo" , keyfile.string()); try { co_await test_provider( fmt::format("'key_provider': 'LocalFileSystemKeyProviderFactory', 'secret_key_file': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", keyfile.string()) , tmp, yaml, 4, 1 ); BOOST_FAIL("Should not reach"); } catch (std::invalid_argument&) { // Ok. } } /* Simple test of GCP cloudkms provider. Uses scylladb GCP project "scylla-kms-test" and keys therein. Note: the above text blobs are service account credentials, including private keys. _Never_ give any real priviledges to these accounts, as we are obviously exposing them here. User1 is assumed to have permissions to encrypt/decrypt using the given key User2 is assumed to _not_ have permissions to encrypt/decrypt using the given key, but permission to impersonate User1. This test is parameterized with env vars: * ENABLE_GCP_TEST - set to non-zero (1/true) to run * GCP_USER_1_CREDENTIALS - set to credentials file for user1 * GCP_USER_2_CREDENTIALS - set to credentials file for user2 * GCP_KEY_NAME - set to / to override. * GCP_PROJECT_ID - set to test project * GCP_LOCATION - set to test location */ struct gcp_test_env { std::string key_name; std::string location; std::string project_id; std::string user_1_creds; std::string user_2_creds; }; static future<> gcp_test_helper(std::function(const tmpdir&, const gcp_test_env&)> f) { gcp_test_env env { .key_name = get_var_or_default("GCP_KEY_NAME", "test_ring/test_key"), .location = get_var_or_default("GCP_LOCATION", "global"), .project_id = get_var_or_default("GCP_PROJECT_ID", "scylla-kms-test"), .user_1_creds = get_var_or_default("GCP_USER_1_CREDENTIALS", ""), .user_2_creds = get_var_or_default("GCP_USER_2_CREDENTIALS", ""), }; tmpdir tmp; if (env.user_1_creds.empty()) { BOOST_ERROR("No 'GCP_USER_1_CREDENTIALS' provided"); } if (env.user_2_creds.empty()) { BOOST_ERROR("No 'GCP_USER_2_CREDENTIALS' provided"); } co_await f(tmp, env); } SEASTAR_TEST_CASE(test_gcp_provider, *check_run_test_decorator("ENABLE_GCP_TEST")) { co_await gcp_test_helper([](const tmpdir& tmp, const gcp_test_env& gcp) -> future<> { auto yaml = fmt::format(R"foo( gcp_hosts: gcp_test: master_key: {0} gcp_project_id: {1} gcp_location: {2} gcp_credentials_file: {3} )foo" , gcp.key_name, gcp.project_id, gcp.location, gcp.user_1_creds ); co_await test_provider("'key_provider': 'GcpKeyProviderFactory', 'gcp_host': 'gcp_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); }); } SEASTAR_TEST_CASE(test_gcp_provider_with_master_key_in_cf, *check_run_test_decorator("ENABLE_GCP_TEST")) { co_await gcp_test_helper([](const tmpdir& tmp, const gcp_test_env& gcp) -> future<> { auto yaml = fmt::format(R"foo( gcp_hosts: gcp_test: gcp_project_id: {1} gcp_location: {2} gcp_credentials_file: {3} )foo" , gcp.key_name, gcp.project_id, gcp.location, gcp.user_1_creds ); // should fail try { try { co_await test_provider( "'key_provider': 'GcpKeyProviderFactory', 'gcp_host': 'gcp_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } catch (std::nested_exception& ex) { std::rethrow_if_nested(ex); } BOOST_FAIL("Required an exception to be re-thrown"); } catch (encryption::configuration_error&) { // EXPECTED } catch (...) { BOOST_FAIL(format("Unexpected exception: {}", std::current_exception())); } // should be ok co_await test_provider(fmt::format("'key_provider': 'GcpKeyProviderFactory', 'gcp_host': 'gcp_test', 'master_key': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", gcp.key_name) , tmp, yaml ); }); } /** * Verify that trying to access key materials with a user w/o permissions to encrypt/decrypt using cloudkms * fails. */ SEASTAR_TEST_CASE(test_gcp_provider_with_invalid_user, *check_run_test_decorator("ENABLE_GCP_TEST")) { co_await gcp_test_helper([](const tmpdir& tmp, const gcp_test_env& gcp) -> future<> { auto yaml = fmt::format(R"foo( gcp_hosts: gcp_test: master_key: {0} gcp_project_id: {1} gcp_location: {2} gcp_credentials_file: {3} )foo" , gcp.key_name, gcp.project_id, gcp.location, gcp.user_2_creds ); // should fail BOOST_REQUIRE_THROW( co_await test_provider("'key_provider': 'GcpKeyProviderFactory', 'gcp_host': 'gcp_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , std::exception ); }); } /** * Verify that impersonation of an allowed service account works. User1 can encrypt, but we run * as User2. However, impersonating user1 will allow us do it ourselves. */ SEASTAR_TEST_CASE(test_gcp_provider_with_impersonated_user, *check_run_test_decorator("ENABLE_GCP_TEST")) { co_await gcp_test_helper([](const tmpdir& tmp, const gcp_test_env& gcp) -> future<> { auto buf = co_await read_text_file_fully(sstring(gcp.user_1_creds)); auto json = rjson::parse(std::string_view(buf.begin(), buf.end())); auto user1 = rjson::get(json, "client_email"); auto yaml = fmt::format(R"foo( gcp_hosts: gcp_test: master_key: {0} gcp_project_id: {1} gcp_location: {2} gcp_credentials_file: {3} gcp_impersonate_service_account: {4} )foo" , gcp.key_name, gcp.project_id, gcp.location, gcp.user_2_creds, user1 ); co_await test_provider("'key_provider': 'GcpKeyProviderFactory', 'gcp_host': 'gcp_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); }); } // Note: cannot do the above test for gcp, because we can't use false endpoints there. Could mess with address resolution, // but there is no infrastructure for that atm. /* Simple test of Azure Key Provider. User1 is assumed to have permissions to wrap/unwrap using the given key. User2 is assumed to _not_ have permissions to wrap/unwrap using the given key. This test is parameterized with env vars: * ENABLE_AZURE_TEST - set to non-zero (1/true) to run Azure tests (enabled by default) * ENABLE_AZURE_TEST_REAL - set to non-zero (1/true) to run tests against real Azure services (disabled by default, same tests run against a local mock server regardless) * AZURE_TENANT_ID - the tenant where the principals live * AZURE_USER_1_CLIENT_ID - the client ID of user1 * AZURE_USER_1_CLIENT_SECRET - the secret of user1 * AZURE_USER_1_CLIENT_CERTIFICATE - the PEM-encoded certificate and private key of user1 * AZURE_USER_2_CLIENT_ID - the client ID of user2 * AZURE_USER_2_CLIENT_SECRET - the secret of user2 * AZURE_USER_2_CLIENT_CERTIFICATE - the PEM-encoded certificate and private key of user2 * AZURE_KEY_NAME - set to / */ // TODO: this separation into two test runs, one mock, one maybe real is both // good and bad. Good, we ensure CI test both, but bad because we run tests // twice for maybe not great payout. Consider moving this to default fake, with // option for CI to run real. BOOST_AUTO_TEST_SUITE(azure_kms, *seastar::testing::async_fixture(azure_mode::local)) static auto check_azure_mock_test_decorator() { return check_run_test_decorator("ENABLE_AZURE_TEST", true); } static auto check_azure_real_test_decorator() { return boost::unit_test::precondition([](boost::unit_test::test_unit_id){ return check_run_test("ENABLE_AZURE_TEST", true) && check_run_test("ENABLE_AZURE_TEST_REAL", false); }); } template class mode_local_azure_kms_wrapper : public local_azure_kms_wrapper { public: mode_local_azure_kms_wrapper() : local_azure_kms_wrapper(M) {} }; using real_azure = mode_local_azure_kms_wrapper; using fake_azure = mode_local_azure_kms_wrapper; SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_imds, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} imds_endpoint: {1} )foo" , key_name, imds_endpoint ); co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } static future<> do_test_azure_provider_with_secret(const azure_test_env& azure) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} azure_tenant_id: {1} azure_client_id: {2} azure_client_secret: {3} azure_authority_host: {5} )foo" , azure.key_name, azure.tenant_id, azure.user_1_client_id, azure.user_1_client_secret, azure.user_1_client_certificate, azure.authority_host ); co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_secret, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { co_await do_test_azure_provider_with_secret(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_secret_real, real_azure, *check_azure_real_test_decorator()) { co_await do_test_azure_provider_with_secret(*this); } static future<> do_test_azure_provider_with_certificate(const azure_test_env& azure) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} azure_tenant_id: {1} azure_client_id: {2} azure_client_certificate_path: {4} azure_authority_host: {5} )foo" , azure.key_name, azure.tenant_id, azure.user_1_client_id, azure.user_1_client_secret, azure.user_1_client_certificate, azure.authority_host ); co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_certificate, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { co_await do_test_azure_provider_with_certificate(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_certificate_real, real_azure, *check_azure_real_test_decorator()) { co_await do_test_azure_provider_with_certificate(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_master_key_in_cf, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: azure_tenant_id: {1} azure_client_id: {2} azure_client_secret: {3} azure_authority_host: {5} )foo" , key_name, tenant_id, user_1_client_id, user_1_client_secret, user_1_client_certificate, authority_host ); // should fail BOOST_REQUIRE_EXCEPTION( co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , exceptions::configuration_exception, [](const exceptions::configuration_exception& e) { try { std::rethrow_if_nested(e); } catch (const encryption::configuration_error& inner) { return sstring(inner.what()).find("No master key set") != sstring::npos; } catch (...) { return false; // Unexpected nested exception type } return false; // No nested exception } ); // should be ok co_await test_provider(fmt::format("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'master_key': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", key_name) , tmp, yaml ); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_no_host, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { tmpdir tmp; auto yaml = R"foo( azure_hosts: )foo"; // should fail BOOST_REQUIRE_EXCEPTION( co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , std::invalid_argument , [](const std::invalid_argument& e) { return sstring(e.what()).find("No such host") != sstring::npos; } ); } /** * Verify that the Azure key provider throws if the provided Service Principal * credentials are incomplete. The provider will first fall back to the default * credentials source to detect credentials from the system (env vars, Azure CLI, * IMDS), and only after all these attempts fail will it throw. * * Note: Just in case we ever run these tests on Azure VMs, use a non-routable * IP address for the IMDS endpoint to ensure the connection will fail. */ SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_incomplete_creds, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} azure_tenant_id: {1} azure_client_id: {2} imds_endpoint: http://127.0.0.255:1 )foo" , key_name, tenant_id, user_1_client_id, user_1_client_secret, user_1_client_certificate ); // should fail BOOST_REQUIRE_EXCEPTION( co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , permission_error, [](const encryption::permission_error& e) { try { std::rethrow_if_nested(e); } catch (const azure::auth_error& inner) { return sstring(inner.what()).find("No credentials found in any source.") != sstring::npos; } catch (...) { return false; // Unexpected nested exception type } return false; // No nested exception } ); } static future<> do_test_azure_provider_with_invalid_key(const azure_test_env& azure) { tmpdir tmp; auto vault = azure.key_name.substr(0, azure.key_name.find_last_of('/')); auto master_key = fmt::format("{}/nonexistentkey", vault); auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} azure_tenant_id: {1} azure_client_id: {2} azure_client_secret: {3} azure_authority_host: {5} )foo" , master_key, azure.tenant_id, azure.user_1_client_id, azure.user_1_client_secret, azure.user_1_client_certificate, azure.authority_host ); // should fail BOOST_REQUIRE_EXCEPTION( co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , service_error, [](const service_error& e) { try { std::rethrow_if_nested(e); } catch (const encryption::vault_error& inner) { // Both error codes are valid depending on the scope of the role assignment: // - "Forbidden": key-scoped permissions // - "KeyNotFound": vault-scoped permissions return inner.code() == "Forbidden" || inner.code() == "KeyNotFound"; } catch (...) { return false; // Unexpected nested exception type } return false; // No nested exception } ); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_invalid_key, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { co_await do_test_azure_provider_with_invalid_key(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_invalid_key_real, real_azure, *check_azure_real_test_decorator()) { co_await do_test_azure_provider_with_invalid_key(*this); } /** * Verify that trying to access key materials with a user w/o permissions to wrap/unwrap using vault * fails. */ static future<> do_test_azure_provider_with_invalid_user(const azure_test_env& azure) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: {0} azure_tenant_id: {1} azure_client_id: {2} azure_client_secret: {3} azure_authority_host: {5} )foo" , azure.key_name, azure.tenant_id, azure.user_2_client_id, azure.user_2_client_secret, azure.user_2_client_certificate, azure.authority_host ); // should fail BOOST_REQUIRE_EXCEPTION( co_await test_provider("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", tmp, yaml) , service_error, [](const service_error& e) { try { std::rethrow_if_nested(e); } catch (const encryption::vault_error& inner) { return inner.code() == "Forbidden"; } catch (...) { return false; // Unexpected nested exception type } return false; // No nested exception } ); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_invalid_user, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { co_await do_test_azure_provider_with_invalid_user(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_invalid_user_real, real_azure, *check_azure_real_test_decorator()) { co_await do_test_azure_provider_with_invalid_user(*this); } /** * Verify that the secret has higher precedence that the certificate. * Use the wrong user's certificate to make sure it causes the test to fail. */ static future<> do_test_azure_provider_with_both_secret_and_cert(const azure_test_env& azure) { tmpdir tmp; auto yaml = fmt::format(R"foo( azure_hosts: azure_test: azure_tenant_id: {1} azure_client_id: {2} azure_client_secret: {3} azure_client_certificate_path: {4} azure_authority_host: {5} )foo" , azure.key_name, azure.tenant_id, azure.user_1_client_id, azure.user_1_client_secret, azure.user_2_client_certificate, azure.authority_host ); // should be ok co_await test_provider(fmt::format("'key_provider': 'AzureKeyProviderFactory', 'azure_host': 'azure_test', 'master_key': '{}', 'cipher_algorithm':'AES/CBC/PKCS5Padding', 'secret_key_strength': 128", azure.key_name) , tmp, yaml ); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_both_secret_and_cert, local_azure_kms_wrapper, *check_azure_mock_test_decorator()) { co_await do_test_azure_provider_with_both_secret_and_cert(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_provider_with_both_secret_and_cert_real, real_azure, *check_azure_real_test_decorator()) { co_await do_test_azure_provider_with_both_secret_and_cert(*this); } SEASTAR_FIXTURE_TEST_CASE(test_azure_network_error, fake_azure, *check_azure_mock_test_decorator()) { tmpdir tmp; auto info = utils::http::parse_simple_url(imds_endpoint); auto host_endpoint = fmt::format("{}:{}", info.host, info.port); auto key = key_name.substr(key_name.find_last_of('/') + 1); co_await network_error_test_helper(tmp, host_endpoint, [&](const auto& proxy) { auto yaml = fmt::format(R"foo( azure_hosts: azure_test: master_key: http://{0}/{1} azure_tenant_id: {2} azure_client_id: {3} azure_client_secret: {4} azure_authority_host: {5} key_cache_expiry: 1ms )foo" , proxy.address(), key, tenant_id, user_1_client_id, user_1_client_secret, authority_host ); return std::make_tuple(scopts_map({ { "key_provider", "AzureKeyProviderFactory" }, { "azure_host", "azure_test" } }), yaml); }); } static future<> configure_azure_mock_server(const std::string& host, const unsigned int port, const std::string& service, const std::string& error_type, int repeat) { auto cln = http::experimental::client(socket_address(net::inet_address(host), uint16_t(port))); auto close_client = deferred_close(cln); auto req = http::request::make("POST", host, "/config/error"); req._headers["Content-Length"] = "0"; req.set_query_param("service", service); req.set_query_param("error_type", error_type); req.set_query_param("repeat", std::to_string(repeat)); co_await cln.make_request(std::move(req), [](const http::reply&, input_stream&&) -> future<> { return seastar::make_ready_future(); }); } SEASTAR_FIXTURE_TEST_CASE(test_imds, fake_azure, *check_azure_mock_test_decorator()) { auto info = utils::http::parse_simple_url(imds_endpoint); auto host = info.host; auto port = info.port; // Create new credential object for each test case because it caches the token. { testlog.info("Testing IMDS success path"); azure::managed_identity_credentials creds { fmt::format("{}:{}", host, port) }; co_await creds.get_access_token("https://vault.azure.net/.default"); } { testlog.info("Testing IMDS transient errors"); azure::managed_identity_credentials creds { fmt::format("{}:{}", host, port) }; co_await configure_azure_mock_server(host, port, "imds", "InternalError", 1); // expected to not throw co_await creds.get_access_token("https://vault.azure.net/.default"); } { testlog.info("Testing IMDS non-transient errors"); azure::managed_identity_credentials creds { fmt::format("{}:{}", host, port) }; co_await configure_azure_mock_server(host, port, "imds", "NoIdentity", 1); BOOST_REQUIRE_THROW( co_await creds.get_access_token("https://vault.azure.net/.default"), azure::creds_auth_error ); } } SEASTAR_FIXTURE_TEST_CASE(test_entra_sts, fake_azure, *check_azure_mock_test_decorator()) { auto info = utils::http::parse_simple_url(imds_endpoint); auto host = info.host; auto port = info.port; auto make_entra_creds = [&] { return azure::service_principal_credentials { "00000000-1111-2222-3333-444444444444", "mock-client-id", "mock-client-secret", "", fmt::format("http://{}:{}", host, port), }; }; // Create new credential object for each test case because it caches the token. { testlog.info("Testing Entra STS success path"); auto creds = make_entra_creds(); co_await creds.get_access_token("https://vault.azure.net/.default"); } { testlog.info("Testing Entra STS transient errors"); auto creds = make_entra_creds(); co_await configure_azure_mock_server(host, port, "entra", "TemporarilyUnavailable", 1); // expected to not throw co_await creds.get_access_token("https://vault.azure.net/.default"); } { testlog.info("Testing Entra STS non-transient errors"); auto creds = make_entra_creds(); co_await configure_azure_mock_server(host, port, "entra", "InvalidSecret", 1); BOOST_REQUIRE_THROW( co_await creds.get_access_token("https://vault.azure.net/.default"), azure::creds_auth_error ); } } SEASTAR_FIXTURE_TEST_CASE(test_azure_host, fake_azure, *check_azure_mock_test_decorator()) { auto info = utils::http::parse_simple_url(imds_endpoint); auto host = info.host; auto port = info.port; key_info kinfo { .alg = "AES/CBC/PKCS5Padding", .len = 128}; azure_host::host_options options { .imds_endpoint = fmt::format("http://{}:{}", host, port), .master_key = fmt::format("http://{}:{}/test-key", host, port), }; { testlog.info("Testing Key Vault success path"); azure_host azhost {"azure_test", options}; co_await azhost.get_or_create_key(kinfo, nullptr); } { testlog.info("Testing Key Vault transient errors"); azure_host azhost {"azure_test", options}; co_await configure_azure_mock_server(host, port, "vault", "Throttled", 1); co_await azhost.get_or_create_key(kinfo, nullptr); } { testlog.info("Testing Key Vault non-transient errors"); azure_host azhost {"azure_test", options}; co_await configure_azure_mock_server(host, port, "vault", "Forbidden", 1); BOOST_REQUIRE_THROW( co_await azhost.get_or_create_key(kinfo, nullptr), encryption::service_error ); } } BOOST_AUTO_TEST_SUITE_END()