From 44db1dfb4062013411585ca28fc218506c6b1947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Jaramago=20Fern=C3=A1ndez?= Date: Thu, 21 Mar 2024 12:21:33 +0100 Subject: [PATCH] Add test for Admin SQLite3 password extensions --- test/tap/tests/test_sqlite3_pass_exts-t.cpp | 408 ++++++++++++++++++++ test/tap/tests/test_sqlite3_pass_exts-t.env | 3 + 2 files changed, 411 insertions(+) create mode 100644 test/tap/tests/test_sqlite3_pass_exts-t.cpp create mode 100644 test/tap/tests/test_sqlite3_pass_exts-t.env diff --git a/test/tap/tests/test_sqlite3_pass_exts-t.cpp b/test/tap/tests/test_sqlite3_pass_exts-t.cpp new file mode 100644 index 000000000..9d5ccb427 --- /dev/null +++ b/test/tap/tests/test_sqlite3_pass_exts-t.cpp @@ -0,0 +1,408 @@ +/** + * @file test_sqlite3_pass_exts-t.cpp + * @brief Tests the SQLite3 extensions in the Admin interface and it's MySQL compatibility. + * @details The test perform the following operations: + * 1. Create MySQL users with random pass and check pass reproduction for: + * - 'mysql_native_password' + * - 'caching_sha2_password' + * 2. Stress password creation, ensure that start and length matches expected. + */ + +#include +#include +#include +#include + +#include "mysql.h" + +#include "command_line.h" +#include "tap.h" +#include "utils.h" + +using std::string; +using std::vector; +using std::pair; + +struct user_def_t { + string name; + string auth; + string pass; + string hash; + string salt; +}; + +#define MYSQL_QUERY_T_(mysql, query) \ + do { \ + const std::string time { get_formatted_time() }; \ + fprintf(stderr, "# %s: Issuing query `%s` to ('%s':%d)\n", time.c_str(), query, mysql->host, mysql->port); \ + if (mysql_query(mysql, query)) { \ + fprintf(stderr, "File %s, line %d, Error: %s\n", __FILE__, __LINE__, mysql_error(mysql)); \ + return { EXIT_FAILURE, user_def_t {} }; \ + } \ + } while(0) + +pair create_mysql_user_rnd_creds(MYSQL* mysql, const string& name, const string& auth) { + diag("Creating user with random pass user:'%s'", name.c_str()); + + const string CREATE_USER { + "CREATE USER '" + name + "'@'%' IDENTIFIED WITH '" + auth + "' BY RANDOM PASSWORD" + }; + const string EXT_NATIVE_AUTH { + "SELECT authentication_string FROM mysql.user WHERE user='" + name + "'" + }; + const string EXT_SHA2_AUTH { + "SELECT HEX(authentication_string), HEX(SUBSTR(authentication_string, 8, 20)) AS salt" + " FROM mysql.user WHERE user='" + name + "'" + }; + const string DROP_USER { "DROP USER IF EXISTS '" + name + "'"}; + string pass {}; + string hash {}; + string salt {}; + + // DROP/CREATE and extract new password + { + MYSQL_QUERY_T_(mysql, DROP_USER.c_str()); + MYSQL_QUERY_T_(mysql, CREATE_USER.c_str()); + + MYSQL_RES* myres = mysql_store_result(mysql); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (myrow && myrow[2]) { + pass = string { myrow[2] }; + } + + mysql_free_result(myres); + } + + // Extract 'authentication_string' and 'salt' + if (auth == "mysql_native_password") { + MYSQL_QUERY_T_(mysql, EXT_NATIVE_AUTH.c_str()); + MYSQL_RES* myres = mysql_store_result(mysql); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (myrow && myrow[0]) { + hash = string { myrow[0] }; + } else { + assert(!"Received malformed result"); + } + + mysql_free_result(myres); + } else if (auth == "caching_sha2_password") { + MYSQL_QUERY_T_(mysql, EXT_SHA2_AUTH.c_str()); + + MYSQL_RES* myres = mysql_store_result(mysql); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (myrow && myrow[0] && myrow[1]) { + hash = string { myrow[0] }; + salt = string { myrow[1] }; + } else { + assert(!"Received malformed result"); + } + + mysql_free_result(myres); + } else { + assert(!"Invalid auth method"); + } + + diag( + "Created user user:'%s', pass:'%s', hash: '%s', salt:'%s'", + name.c_str(), pass.c_str(), hash.c_str(), salt.c_str() + ); + + return { EXIT_SUCCESS, user_def_t { name, auth, pass, hash, salt } }; +} + +int test_pass_match(MYSQL* admin, const user_def_t& def) { + diag( + "Test MySQL/Admin pass match user:'%s', auth:'%s', pass:'%s', hash: '%s', salt:'%s'", + def.name.c_str(), def.auth.c_str(), def.pass.c_str(), def.hash.c_str(), def.salt.c_str() + ); + + if (def.auth == "mysql_native_password") { + const string GEN_NATIVE_PASS { + "SELECT MYSQL_NATIVE_PASSWORD('" + def.pass + "')" + }; + MYSQL_QUERY_T(admin, GEN_NATIVE_PASS.c_str()); + + MYSQL_RES* myres = mysql_store_result(admin); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + string admin_hash { myrow[0] }; + + ok( + def.hash == admin_hash, + "MySQL hash should match ProxySQL generated mysql:'%s', admin:'%s'", + def.hash.c_str(), admin_hash.c_str() + ); + + mysql_free_result(myres); + } else if (def.auth == "caching_sha2_password") { + const string GEN_SHA2_PASS { + "SELECT HEX(CACHING_SHA2_PASSWORD('" + def.pass + "', UNHEX('" + def.salt + "')))" + }; + MYSQL_QUERY_T(admin, GEN_SHA2_PASS.c_str()); + + MYSQL_RES* myres = mysql_store_result(admin); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + string admin_hash { myrow[0] }; + + ok( + def.hash == admin_hash, + "MySQL hash should match ProxySQL generated mysql:'%s', admin:'%s'", + def.hash.c_str(), admin_hash.c_str() + ); + + mysql_free_result(myres); + } + + return EXIT_SUCCESS; +} + +int test_pass_gen(MYSQL* admin, const string& auth, const string& pass, const string& salt) { + diag("Test Admin pass hash gen auth:'%s',pass:'%s'", auth.c_str(), pass.c_str()); + + if (auth == "mysql_native_password") { + const string GEN_NATIVE_PASS { "SELECT MYSQL_NATIVE_PASSWORD('" + pass + "')" }; + MYSQL_QUERY_T(admin, GEN_NATIVE_PASS.c_str()); + + MYSQL_RES* myres = mysql_store_result(admin); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (pass.size() > 0) { + const string admin_hash { myrow[0] }; + bool valid_hash = admin_hash.size() == 41 && admin_hash[0] == '*'; + + ok( + valid_hash, + "Gen hash should be wellformed size:'%lu', hash:'%s'", + admin_hash.size(), admin_hash.c_str() + ); + } else { + const string act_msg { myrow[0] }; + const string exp_msg { "Invalid argument size" }; + + ok( + exp_msg == act_msg, + "Args verf should have failed exp_msg:'%s', act_msg:'%s'", + exp_msg.c_str(), act_msg.c_str() + ); + } + + mysql_free_result(myres); + } else if (auth == "caching_sha2_password") { + const string GEN_SHA2_PASS { "SELECT CACHING_SHA2_PASSWORD('" + pass + "', '" + salt + "')" }; + MYSQL_QUERY_T(admin, GEN_SHA2_PASS.c_str()); + + MYSQL_RES* myres = mysql_store_result(admin); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (pass.size() > 0 && salt.size() > 0 && salt.size() <= 20) { + const string admin_hash { myrow[0] }; + const string hash_start { "$A$005$" + salt }; + + bool valid_hash = + (admin_hash.size() == 50 + salt.size()) && + admin_hash.rfind(hash_start, 0) == 0; + + ok( + valid_hash, + "Gen hash should be wellformed size:'%lu', salt_size:'%lu', hash:'%s'", + admin_hash.size(), salt.size(), admin_hash.c_str() + ); + } else { + const string act_msg { myrow[0] }; + const string exp_msg { "Invalid argument size" }; + + ok( + exp_msg == act_msg, + "Args verf should have failed exp_msg:'%s', act_msg:'%s'", + exp_msg.c_str(), act_msg.c_str() + ); + } + + mysql_free_result(myres); + } + + return EXIT_SUCCESS; +} + +pair get_query_res(MYSQL* mysql, const string& query) { + string res {}; + + int rc = mysql_query_t(mysql, query.c_str()); + + if (rc) { + return { rc, mysql_error(mysql) }; + } else { + MYSQL_RES* myres = mysql_store_result(mysql); + MYSQL_ROW myrow = mysql_fetch_row(myres); + + if (myrow && myrow[0]) { + res = myrow[0]; + } else { + rc = 1; + res = "Invalid resultset received"; + } + + mysql_free_result(myres); + } + + return { rc, res }; +} + +struct inv_input_t { + string query; + int err; + string msg; +}; + +// NOTE: If modified, set even numbers +const uint32_t USER_GEN_COUNT = 100; +const uint32_t PASS_GEN_COUNT = 1000; + +const vector INV_INPUTS { + { + "SELECT MYSQL_NATIVE_PASSWORD()", 1, + "ProxySQL Admin Error: wrong number of arguments to function MYSQL_NATIVE_PASSWORD()" + }, + { + "SELECT MYSQL_NATIVE_PASSWORD('00', '00')", 1, + "ProxySQL Admin Error: wrong number of arguments to function MYSQL_NATIVE_PASSWORD()" + }, + { "SELECT MYSQL_NATIVE_PASSWORD('')", 0, "Invalid argument size" }, + { "SELECT MYSQL_NATIVE_PASSWORD(2)", 0, "Invalid argument type" }, + + { + "SELECT CACHING_SHA2_PASSWORD()", 1, + "ProxySQL Admin Error: wrong number of arguments to function CACHING_SHA2_PASSWORD()" + }, + { + "SELECT CACHING_SHA2_PASSWORD('00', '00', '00')", 1, + "ProxySQL Admin Error: wrong number of arguments to function CACHING_SHA2_PASSWORD()" + }, + { "SELECT CACHING_SHA2_PASSWORD('', '')", 0, "Invalid argument size" }, + { "SELECT CACHING_SHA2_PASSWORD('', '000000000000000000000')", 0, "Invalid argument size" }, + { "SELECT CACHING_SHA2_PASSWORD(2, '00')", 0, "Invalid argument type" }, + { "SELECT CACHING_SHA2_PASSWORD('00', 2)", 0, "Invalid argument type" }, +}; + + +int main(int argc, char** argv) { + CommandLine cl; + + plan( + INV_INPUTS.size() + + USER_GEN_COUNT + + PASS_GEN_COUNT * 2 + + 2 // EXTRA: Two extra correctness tests; forcing randomness + ); + + if (cl.getEnv()) { + diag("Failed to get the required environmental variables."); + return EXIT_FAILURE; + } + + MYSQL* mysql = mysql_init(NULL); + + if (!mysql_real_connect(mysql, cl.host, cl.mysql_username, cl.mysql_password, NULL, cl.mysql_port, NULL, 0)) { + fprintf(stderr, "File %s, line %d, Error: %s\n", __FILE__, __LINE__, mysql_error(mysql)); + return EXIT_FAILURE; + } + + + MYSQL* admin = mysql_init(NULL); + + if (!mysql_real_connect(admin, cl.host, cl.admin_username, cl.admin_password, NULL, cl.admin_port, NULL, 0)) { + fprintf(stderr, "File %s, line %d, Error: %s\n", __FILE__, __LINE__, mysql_error(admin)); + return EXIT_FAILURE; + } + + // Tests functions input verification + { + for (const inv_input_t& inv_input : INV_INPUTS) { + const pair res { get_query_res(admin, inv_input.query) }; + + if (res.first && inv_input.err == 0) { + diag("Query on Admin unexpectedly failed rc:'%d', err:'%s'", res.first, res.second.c_str()); + goto cleanup; + } else { + ok( + inv_input.msg == res.second, + "Expected failure should match actual exp:'%s', act:'%s'", + inv_input.msg.c_str(), res.second.c_str() + ); + } + } + } + + // Tests MySQL/Admin hashes compatibility + { + vector users {}; + + for (size_t i = 0; i < USER_GEN_COUNT/2; i++) { + const string name { "rndextuser" + std::to_string(i) }; + pair user_def { + create_mysql_user_rnd_creds(mysql, name, "mysql_native_password") + }; + + if (user_def.first) { + diag("User creation failed user:'%s'", name.c_str()); + goto cleanup; + } else { + users.push_back(user_def.second); + } + } + + for (size_t i = 50; i < USER_GEN_COUNT; i++) { + const string name { "rndextuser" + std::to_string(i) }; + pair user_def { + create_mysql_user_rnd_creds(mysql, name, "caching_sha2_password") + }; + + if (user_def.first) { + diag("User creation failed user:'%s'", name.c_str()); + goto cleanup; + } else { + users.push_back(user_def.second); + } + } + + for (const user_def_t& def : users) { + test_pass_match(admin, def); + } + } + + // Tests correctness of randomly generated hashes + { + std::srand(static_cast(std::time(nullptr))); + + // EXTRA: Two extra correctness tests; forcing randomness + test_pass_gen(admin, "mysql_native_password", "randpass0", ""); + test_pass_gen(admin, "caching_sha2_password", "randpass0", "00000000000000000000"); + + for (size_t i = 0; i < PASS_GEN_COUNT; i++) { + const uint32_t pass_len = rand() % 150; + const string pass { random_string(pass_len) }; + + test_pass_gen(admin, "mysql_native_password", pass, ""); + } + + for (size_t i = 0; i < PASS_GEN_COUNT; i++) { + const uint32_t pass_len = rand() % 150; + const uint32_t salt_len = rand() % 20; + const string pass { random_string(pass_len) }; + const string salt { random_string(pass_len) }; + + test_pass_gen(admin, "caching_sha2_password", pass, salt); + } + } + +cleanup: + + mysql_close(mysql); + mysql_close(admin); + + return exit_status(); +} diff --git a/test/tap/tests/test_sqlite3_pass_exts-t.env b/test/tap/tests/test_sqlite3_pass_exts-t.env new file mode 100644 index 000000000..ae176fef5 --- /dev/null +++ b/test/tap/tests/test_sqlite3_pass_exts-t.env @@ -0,0 +1,3 @@ +TAP_MYSQLUSERNAME=root +TAP_MYSQLPASSWORD=root +TAP_MYSQLPORT=14806