sqlite3: CACHING_SHA2_PASSWORD() accepts optional rounds arg (closes #5640)

The ProxySQL admin SQLite3 extension CACHING_SHA2_PASSWORD() previously
always emitted hashes with 5000 rounds (hardcoded "\$A\$005\$" prefix and
"\$5\$rounds=5000\$" crypt salt). MySQL's caching_sha2_password_digest_rounds
is configurable from 5000 to 4095000 in steps of 1000, so a backend that
uses any non-default value produces hashes the extension cannot reproduce —
breaking the byte-for-byte hash equivalence test in
test_sqlite3_pass_exts-t on e.g. the mysql95 backend where rounds=10000.

Extend the SQLite function to accept an optional third integer argument
carrying the desired rounds value:

  CACHING_SHA2_PASSWORD(pass)                 -> random salt, 5000 rounds
  CACHING_SHA2_PASSWORD(pass, salt)           -> given salt,  5000 rounds
  CACHING_SHA2_PASSWORD(pass, salt, rounds)   -> given salt,  given rounds

rounds is validated: integer, multiple of 1000, in [5000, 4095000] —
matching MySQL's caching_sha2_password_digest_rounds bounds. The
"\$5\$rounds=<N>\$" salt prefix and "\$A\$<RRR>\$" hash prefix are now
built at runtime from the resolved value, with <RRR> emitted as 3-char
zero-padded uppercase hex of (rounds/1000) per MySQL's
sql/auth/sha2_password.cc::digest_round_separator().

Update test_sqlite3_pass_exts-t to parse the rounds field from the
MySQL-generated hash (chars 3..5 of \$A\$<RRR>\$..., base-16, ×1000)
and pass it to CACHING_SHA2_PASSWORD() — so the hash-equivalence
assertion matches any backend rounds. Update the INV_INPUTS fixture:
3-arg is now valid, so reject 4-arg ("wrong number"), reject 3-arg
with non-integer rounds ("Invalid argument type"), reject out-of-range
or non-multiple-of-1000 rounds ("Invalid rounds: ...").

Verified against mysql95 backend (rounds=10000): test_sqlite3_pass_exts-t
passes 2117/2117, test_auth_methods-t passes 10774/10774 (previously
failing with 50+122 not-ok respectively before the base-16 parse fix in
MySQL_Protocol.cpp).
v3.0-slim-dbdeployer-images
Rene Cannao 1 month ago
parent 4e0efd7536
commit 6322d58376

@ -1,6 +1,6 @@
--- sqlite3.c 2024-03-22 19:22:47.046093173 +0100
+++ sqlite3-pass-exts.c 2024-03-22 19:24:09.557303716 +0100
@@ -26313,6 +26313,183 @@
@@ -26275,6 +26275,207 @@
sqlite3ResultStrAccum(context, &sRes);
}
@ -27,30 +27,35 @@
+int check_args_types(int argc, sqlite3_value** argv) {
+ int inv_type = sqlite3_value_type(argv[0]) != SQLITE_TEXT;
+
+ if (inv_type == 0 && argc == 2) {
+ return
+ if (inv_type == 0 && argc >= 2) {
+ inv_type =
+ sqlite3_value_type(argv[1]) != SQLITE_TEXT &&
+ sqlite3_value_type(argv[1]) != SQLITE_BLOB;
+ } else {
+ return inv_type;
+ }
+
+ if (inv_type == 0 && argc >= 3) {
+ inv_type = sqlite3_value_type(argv[2]) != SQLITE_INTEGER;
+ }
+
+ return inv_type;
+}
+
+int check_args_lengths(int argc, sqlite3_value** argv) {
+ int inv_size = 1;
+
+ int pass_size = sqlite3_value_bytes(argv[0]);
+ if (pass_size > 0) {
+ inv_size = 0;
+ if (pass_size <= 0) {
+ return 1;
+ }
+
+ if (inv_size == 0 && argc == 2) {
+ if (argc >= 2) {
+ int salt_size = sqlite3_value_bytes(argv[1]);
+
+ return salt_size <= 0 || salt_size > DEF_SALT_SIZE;
+ } else {
+ return inv_size;
+ if (salt_size <= 0 || salt_size > DEF_SALT_SIZE) {
+ return 1;
+ }
+ }
+
+ /* argc == 3: rounds is an integer; range is checked in caching_sha2_passwordFunc(). */
+
+ return 0;
+}
+
+/**
@ -103,15 +108,19 @@
+/**
+ * @brief SQLite3 extension function for hash generation.
+ * @details Computes a hash equivalent to the one generated by MySQL for 'caching_sha2_password'.
+ * Output format matches MySQL's storage: '$A$<RRR>$<salt><hash>' where <RRR> is the
+ * 3-char zero-padded uppercase hex of (rounds/1000) — see MySQL
+ * sql/auth/sha2_password.cc::Caching_sha2_password::digest_round_separator().
+ * @param context SQLite3 context used for returning computation result.
+ * @param argc Number of arguments; either 1 or 2. One for random salt, two providing salt.
+ * @param argv Argument list; expected to hold either 1 or 2 arguments:
+ * 1. Password to be hashed; with len > 0 and of type 'SQLITE_TEXT'.
+ * 1. Optional salt; with (len > 0 && len <= 20) and of type ('SQLITE_TEXT' || 'SQLITE_BLOB'). If no salt is
+ * provided a randomly generated salt with length 20 will be used.
+ * @param argc Number of arguments; 1, 2, or 3.
+ * @param argv Argument list:
+ * 1. Password to be hashed; len > 0, type 'SQLITE_TEXT'.
+ * 2. Optional salt; len in (0,20], type 'SQLITE_TEXT' or 'SQLITE_BLOB'. Random if omitted.
+ * 3. Optional rounds; integer in [5000,4095000] in steps of 1000 (matches MySQL's
+ * caching_sha2_password_digest_rounds). Defaults to 5000 when omitted.
+ */
+static void caching_sha2_passwordFunc(sqlite3_context* context, int argc, sqlite3_value** argv) {
+ if (argc < 1 || argc > 2) {
+ if (argc < 1 || argc > 3) {
+ sqlite3_result_text(context, "Invalid number of arguments", -1, SQLITE_TRANSIENT);
+ return;
+ } else {
@ -125,11 +134,24 @@
+ }
+ }
+
+ /* Resolve rounds: default 5000 (MySQL 8.0 historical default), overridable via
+ argv[2]. Range matches MySQL's caching_sha2_password_digest_rounds bounds. */
+ long rounds = 5000;
+ if (argc == 3) {
+ rounds = (long)sqlite3_value_int64(argv[2]);
+ if (rounds < 5000 || rounds > 4095000 || (rounds % 1000) != 0) {
+ sqlite3_result_text(context,
+ "Invalid rounds: expected multiple of 1000 in [5000,4095000]",
+ -1, SQLITE_TRANSIENT);
+ return;
+ }
+ }
+
+ unsigned int salt_size = DEF_SALT_SIZE;
+ const char* cpass = (const char*)sqlite3_value_text(argv[0]);
+ unsigned char salt[DEF_SALT_SIZE + 1] = { 0 };
+
+ if (argc == 2) {
+ if (argc >= 2) {
+ salt_size = sqlite3_value_bytes(argv[1]);
+ const void* b_salt = sqlite3_value_blob(argv[1]);
+
@ -163,17 +185,19 @@
+ }
+ }
+
+ #define BASE_SHA2_SALT "$5$rounds=5000$"
+ #define BASE_SHA2_HASH "$A$005$"
+ /* Build the sha256_crypt salt-prefix "$5$rounds=<N>$" and the MySQL hash prefix
+ "$A$<RRR>$" dynamically from the resolved rounds value. Buffers fit comfortably:
+ "$5$rounds=4095000$" is 18 chars + 20 salt + NUL = 39 (<100). */
+ char sha2_salt[100] = { 0 };
+ char sha2_hash[100] = { 0 };
+ int salt_prefix_len = snprintf(sha2_salt, sizeof(sha2_salt), "$5$rounds=%ld$", rounds);
+ snprintf(sha2_hash, sizeof(sha2_hash), "$A$%03X$", (unsigned int)(rounds / 1000));
+
+ char sha2_buf[100] = { 0 };
+ char sha2_salt[100] = { BASE_SHA2_SALT };
+
+ strcat(sha2_salt, (const char*)salt);
+ sha256_crypt_r(cpass, sha2_salt, sha2_buf, sizeof(sha2_buf));
+
+ char sha2_hash[100] = { BASE_SHA2_HASH };
+ const char* sha256 = sha2_buf + salt_size + strlen(BASE_SHA2_SALT) + 1;
+ const char* sha256 = sha2_buf + salt_size + salt_prefix_len + 1;
+
+ strcat(sha2_hash, (const char*)salt);
+ strcat(sha2_hash, sha256);
@ -184,13 +208,14 @@
/*
** current_time()
**
@@ -133269,6 +133269,9 @@
@@ -133230,6 +133431,10 @@
FUNCTION(substr, 3, 0, 0, substrFunc ),
FUNCTION(substring, 2, 0, 0, substrFunc ),
FUNCTION(substring, 3, 0, 0, substrFunc ),
+ FUNCTION(mysql_native_password, 1, 0, 0, mysql_native_passwordFunc ),
+ FUNCTION(caching_sha2_password, 1, 0, 0, caching_sha2_passwordFunc ),
+ FUNCTION(caching_sha2_password, 2, 0, 0, caching_sha2_passwordFunc ),
+ FUNCTION(caching_sha2_password, 3, 0, 0, caching_sha2_passwordFunc ),
WAGGREGATE(sum, 1,0,0, sumStep, sumFinalize, sumFinalize, sumInverse, 0),
WAGGREGATE(total, 1,0,0, sumStep,totalFinalize,totalFinalize,sumInverse, 0),
WAGGREGATE(avg, 1,0,0, sumStep, avgFinalize, avgFinalize, sumInverse, 0),

@ -262,8 +262,29 @@ int test_pass_match(MYSQL* admin, const user_def_t& def) {
mysql_free_result(myres);
} else if (def.auth == "caching_sha2_password") {
// MySQL stores caching_sha2_password as '$A$<RRR>$<salt><hash>' (70 ASCII bytes).
// def.hash is the hex-encoding of those 70 bytes (140 chars). The 3 ASCII chars
// at offset 3..5 (hex offset 6..11) carry rounds/1000 in 3-char zero-padded
// uppercase hex. Decode and pass the resolved rounds value to the SQLite
// extension so the regenerated digest uses the same iteration count as the
// backend (matters once MySQL ships with default rounds != 5000).
long rounds = 5000;
if (def.hash.size() >= 12) {
string rounds_field;
for (size_t i = 6; i < 12; i += 2) {
rounds_field += static_cast<char>(stoi(def.hash.substr(i, 2), nullptr, 16));
}
try {
rounds = stol(rounds_field, nullptr, 16) * 1000;
} catch (const std::exception&) {
diag("Failed to parse rounds field '%s' from MySQL hash '%s'",
rounds_field.c_str(), def.hash.c_str());
}
}
const string GEN_SHA2_PASS {
"SELECT HEX(CACHING_SHA2_PASSWORD('" + def.pass + "', UNHEX('" + def.salt + "')))"
"SELECT HEX(CACHING_SHA2_PASSWORD('" + def.pass + "', UNHEX('" + def.salt + "'), "
+ std::to_string(rounds) + "))"
};
MYSQL_QUERY_T(admin, GEN_SHA2_PASS.c_str());
@ -274,8 +295,8 @@ int test_pass_match(MYSQL* admin, const user_def_t& def) {
ok(
def.hash == admin_hash,
"MySQL hash should match ProxySQL generated mysql:'%s', admin:'%s'",
def.hash.c_str(), admin_hash.c_str()
"MySQL hash should match ProxySQL generated mysql:'%s', admin:'%s', rounds:%ld",
def.hash.c_str(), admin_hash.c_str(), rounds
);
mysql_free_result(myres);
@ -404,13 +425,20 @@ const vector<inv_input_t> INV_INPUTS {
"ProxySQL Admin Error: wrong number of arguments to function CACHING_SHA2_PASSWORD()"
},
{
"SELECT CACHING_SHA2_PASSWORD('00', '00', '00')", 1,
"SELECT CACHING_SHA2_PASSWORD('00', '00', 5000, 'extra')", 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" },
{ "SELECT CACHING_SHA2_PASSWORD('00', '00', '00')", 0, "Invalid argument type" },
{ "SELECT CACHING_SHA2_PASSWORD('00', '00', 1000)", 0,
"Invalid rounds: expected multiple of 1000 in [5000,4095000]" },
{ "SELECT CACHING_SHA2_PASSWORD('00', '00', 5500)", 0,
"Invalid rounds: expected multiple of 1000 in [5000,4095000]" },
{ "SELECT CACHING_SHA2_PASSWORD('00', '00', 4096000)", 0,
"Invalid rounds: expected multiple of 1000 in [5000,4095000]" },
};

Loading…
Cancel
Save