diff --git a/changelogs/fragments/add_salt_param_to_gen_sha256_hash.yml b/changelogs/fragments/add_salt_param_to_gen_sha256_hash.yml new file mode 100644 index 00000000..c49ba1d4 --- /dev/null +++ b/changelogs/fragments/add_salt_param_to_gen_sha256_hash.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - mysql_user - Add salt parameter to generate static hash for `caching_sha2_password` and `sha256_password` plugins. diff --git a/plugins/module_utils/implementations/mysql/hash.py b/plugins/module_utils/implementations/mysql/hash.py new file mode 100644 index 00000000..0068a0c1 --- /dev/null +++ b/plugins/module_utils/implementations/mysql/hash.py @@ -0,0 +1,125 @@ +""" +Generate MySQL sha256 compatible plugins hash for a given password and salt + +based on + * https://www.akkadia.org/drepper/SHA-crypt.txt + * https://crypto.stackexchange.com/questions/77427/whats-the-algorithm-behind-mysqls-sha256-password-hashing-scheme/111174#111174 + * https://github.com/hashcat/hashcat/blob/master/tools/test_modules/m07400.pm +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import hashlib + + +def _to64(v, n): + """Convert a 32-bit integer to a base-64 string""" + i64 = ( + [".", "/"] + + [chr(x) for x in range(48, 58)] + + [chr(x) for x in range(65, 91)] + + [chr(x) for x in range(97, 123)] + ) + result = "" + while n > 0: + n -= 1 + result += i64[v & 0x3F] + v >>= 6 + return result + + +def _hashlib_sha256(data): + """Return SHA-256 digest from hashlib .""" + return hashlib.sha256(data).digest() + + +def _sha256_digest(key, salt, loops): + """Return a SHA-256 digest of the concatenation of the key, the salt, and the key, repeated as necessary.""" + # https://www.akkadia.org/drepper/SHA-crypt.txt + num_bytes = 32 + bytes_key = key.encode() + bytes_salt = salt.encode() + digest_b = _hashlib_sha256(bytes_key + bytes_salt + bytes_key) + + tmp = bytes_key + bytes_salt + for i in range(len(bytes_key), 0, -num_bytes): + tmp += digest_b if i > num_bytes else digest_b[:i] + + i = len(bytes_key) + while i > 0: + tmp += digest_b if (i & 1) != 0 else bytes_key + i >>= 1 + + digest_a = _hashlib_sha256(tmp) + + tmp = b"" + for i in range(len(bytes_key)): + tmp += bytes_key + + digest_dp = _hashlib_sha256(tmp) + + byte_sequence_p = b"" + for i in range(len(bytes_key), 0, -num_bytes): + byte_sequence_p += digest_dp if i > num_bytes else digest_dp[:i] + + tmp = b"" + til = 16 + digest_a[0] + + for i in range(til): + tmp += bytes_salt + + digest_ds = _hashlib_sha256(tmp) + + byte_sequence_s = b"" + for i in range(len(bytes_salt), 0, -num_bytes): + byte_sequence_s += digest_ds if i > num_bytes else digest_ds[:i] + + digest_c = digest_a + + for i in range(loops): + tmp = byte_sequence_p if (i & 1) else digest_c + if i % 3: + tmp += byte_sequence_s + if i % 7: + tmp += byte_sequence_p + tmp += digest_c if (i & 1) else byte_sequence_p + digest_c = _hashlib_sha256(tmp) + + inc1, inc2, mod, end = (10, 21, 30, 0) + + i = 0 + tmp = "" + + while True: + tmp += _to64( + (digest_c[i] << 16) + | (digest_c[(i + inc1) % mod] << 8) + | digest_c[(i + inc1 * 2) % mod], + 4, + ) + i = (i + inc2) % mod + if i == end: + break + + tmp += _to64((digest_c[31] << 8) | digest_c[30], 3) + + return tmp + + +def mysql_sha256_password_hash(password, salt): + """Return a MySQL compatible caching_sha2_password hash in raw format.""" + if len(salt) != 20: + raise ValueError("Salt must be 20 characters long.") + + count = 5 + iteration = 1000 * count + + digest = _sha256_digest(password, salt, iteration) + return "$A${0:>03}${1}{2}".format(count, salt, digest) + + +def mysql_sha256_password_hash_hex(password, salt): + """Return a MySQL compatible caching_sha2_password hash in hex format.""" + return mysql_sha256_password_hash(password, salt).encode().hex().upper() diff --git a/plugins/module_utils/user.py b/plugins/module_utils/user.py index 25b17346..80da47e7 100644 --- a/plugins/module_utils/user.py +++ b/plugins/module_utils/user.py @@ -1,4 +1,6 @@ from __future__ import (absolute_import, division, print_function) + + __metaclass__ = type # This code is part of Ansible, but is an independent component. @@ -19,6 +21,10 @@ mysql_driver, get_server_implementation, ) +from ansible_collections.community.mysql.plugins.module_utils.implementations.mysql.hash import ( + mysql_sha256_password_hash, + mysql_sha256_password_hash_hex, +) class InvalidPrivsError(Exception): @@ -135,7 +141,7 @@ def get_existing_authentication(cursor, user, host): def user_add(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, new_priv, + plugin, plugin_hash_string, plugin_auth_string, salt, new_priv, attributes, tls_requires, reuse_existing_password, module, password_expire, password_expire_interval): # If attributes are set, perform a sanity check to ensure server supports user attributes before creating user @@ -181,6 +187,12 @@ def user_add(cursor, user, host, host_all, password, encrypted, # Mysql and MariaDB differ in naming pam plugin and Syntax to set it if plugin == 'pam': # Used by MariaDB which requires the USING keyword, not BY query_with_args = "CREATE USER %s@%s IDENTIFIED WITH %s USING %s", (user, host, plugin, plugin_auth_string) + elif salt: + if plugin in ['caching_sha2_password', 'sha256_password']: + generated_hash_string = mysql_sha256_password_hash_hex(password=plugin_auth_string, salt=salt) + else: + module.fail_json(msg="salt not handled for %s authentication plugin" % plugin) + query_with_args = ("CREATE USER %s@%s IDENTIFIED WITH %s AS 0x" + generated_hash_string), (user, host, plugin) else: query_with_args = "CREATE USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string) elif plugin: @@ -221,7 +233,7 @@ def is_hash(password): def user_mod(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, new_priv, + plugin, plugin_hash_string, plugin_auth_string, salt, new_priv, append_privs, subtract_privs, attributes, tls_requires, module, password_expire, password_expire_interval, role=False, maria_role=False): changed = False @@ -342,7 +354,11 @@ def user_mod(cursor, user, host, host_all, password, encrypted, if plugin_hash_string and current_plugin[1] != plugin_hash_string: update = True - if plugin_auth_string and current_plugin[1] != plugin_auth_string: + if salt: + if plugin in ['caching_sha2_password', 'sha256_password']: + if current_plugin[1] != mysql_sha256_password_hash(password=plugin_auth_string, salt=salt): + update = True + elif plugin_auth_string and current_plugin[1] != plugin_auth_string: # this case can cause more updates than expected, # as plugin can hash auth_string in any way it wants # and there's no way to figure it out for @@ -356,6 +372,12 @@ def user_mod(cursor, user, host, host_all, password, encrypted, # Mysql and MariaDB differ in naming pam plugin and syntax to set it if plugin in ('pam', 'ed25519'): query_with_args = "ALTER USER %s@%s IDENTIFIED WITH %s USING %s", (user, host, plugin, plugin_auth_string) + elif salt: + if plugin in ['caching_sha2_password', 'sha256_password']: + generated_hash_string = mysql_sha256_password_hash_hex(password=plugin_auth_string, salt=salt) + else: + module.fail_json(msg="salt not handled for %s authentication plugin" % plugin) + query_with_args = ("ALTER USER %s@%s IDENTIFIED WITH %s AS 0x" + generated_hash_string), (user, host, plugin) else: query_with_args = "ALTER USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string) else: diff --git a/plugins/modules/mysql_role.py b/plugins/modules/mysql_role.py index df8b5fec..032b41ea 100644 --- a/plugins/modules/mysql_role.py +++ b/plugins/modules/mysql_role.py @@ -931,7 +931,7 @@ def update(self, users, privs, check_mode=False, if privs: result = user_mod(self.cursor, self.name, self.host, - None, None, None, None, None, None, + None, None, None, None, None, None, None, privs, append_privs, subtract_privs, None, None, self.module, None, None, role=True, maria_role=self.is_mariadb) diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index 55e34a32..0c7021b6 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -139,8 +139,16 @@ description: - User's plugin auth_string (``CREATE USER user IDENTIFIED WITH plugin BY plugin_auth_string``). - If I(plugin) is ``pam`` (MariaDB) or ``auth_pam`` (MySQL) an optional I(plugin_auth_string) can be used to choose a specific PAM service. + - You need to define a I(salt) to have idempotence on password change with ``caching_sha2_password`` and ``sha256_password`` plugins. type: str version_added: '0.1.0' + salt: + description: + - Salt used to generate password hash from I(plugin_auth_string). + - Salt length must be 20 characters. + - Salt only support ``caching_sha2_password`` or ``sha256_password`` authentication I(plugin). + type: str + version_added: '3.10.0' resource_limits: description: - Limit the user for certain server resources. Provided since MySQL 5.6 / MariaDB 10.2. @@ -369,6 +377,13 @@ priv: '*.*:ALL' state: present +- name: Create user 'bob' authenticated with plugin 'caching_sha2_password' and static salt + community.mysql.mysql_user: + name: bob + plugin: caching_sha2_password + plugin_auth_string: password + salt: 1234567890abcdefghij + - name: Limit bob's resources to 10 queries per hour and 5 connections per hour community.mysql.mysql_user: name: bob @@ -440,6 +455,7 @@ def main(): plugin=dict(default=None, type='str'), plugin_hash_string=dict(default=None, type='str'), plugin_auth_string=dict(default=None, type='str'), + salt=dict(default=None, type='str'), resource_limits=dict(type='dict'), force_context=dict(type='bool', default=False), session_vars=dict(type='dict'), @@ -480,6 +496,7 @@ def main(): plugin = module.params["plugin"] plugin_hash_string = module.params["plugin_hash_string"] plugin_auth_string = module.params["plugin_auth_string"] + salt = module.params["salt"] resource_limits = module.params["resource_limits"] session_vars = module.params["session_vars"] column_case_sensitive = module.params["column_case_sensitive"] @@ -499,6 +516,14 @@ def main(): module.fail_json(msg="password_expire_interval value \ should be positive number") + if salt: + if not plugin_auth_string: + module.fail_json(msg="salt requires plugin_auth_string") + if len(salt) != 20: + module.fail_json(msg="salt must be 20 characters long") + if plugin not in ['caching_sha2_password', 'sha256_password']: + module.fail_json(msg="salt requires caching_sha2_password or sha256_password plugin") + cursor = None try: if check_implicit_admin: @@ -542,13 +567,13 @@ def main(): try: if update_password == "always": result = user_mod(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, + plugin, plugin_hash_string, plugin_auth_string, salt, priv, append_privs, subtract_privs, attributes, tls_requires, module, password_expire, password_expire_interval) else: result = user_mod(cursor, user, host, host_all, None, encrypted, - None, None, None, + None, None, None, None, priv, append_privs, subtract_privs, attributes, tls_requires, module, password_expire, password_expire_interval) changed = result['changed'] @@ -566,7 +591,7 @@ def main(): priv = None # avoid granting unwanted privileges reuse_existing_password = update_password == 'on_new_username' result = user_add(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, + plugin, plugin_hash_string, plugin_auth_string, salt, priv, attributes, tls_requires, reuse_existing_password, module, password_expire, password_expire_interval) changed = result['changed'] diff --git a/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml b/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml index d8ff04d5..b5ed6c5c 100644 --- a/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml +++ b/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml @@ -13,6 +13,7 @@ test_plugin_auth_string: 'Fdt8fd^34ds' test_plugin_new_hash: '*E74368AC90460FA669F6D41BFB7F2A877DB73745' test_plugin_new_auth_string: 'c$K01LsmK7nJnIR4!h' + test_salt: 'TDwqdanU82d0yNtvaabb' test_default_priv_type: 'SELECT' test_default_priv: '*.*:{{ test_default_priv_type }}' @@ -475,3 +476,71 @@ - include_tasks: utils/remove_user.yml vars: user_name: "{{ test_user_name }}" + + # ============================================================ + # Test plugin auth with a salt + # + - name: Plugin auth | Create user with plugin auth and salt + community.mysql.mysql_user: + <<: *mysql_params + name: "{{ test_user_name }}" + host: "%" + plugin: caching_sha2_password + plugin_auth_string: "{{ test_plugin_auth_string }}" + salt: "{{ test_salt }}" + priv: "{{ test_default_priv }}" + + - name: Plugin auth | Connect with user and password + ansible.builtin.command: '{{ mysql_command }} -u {{ test_user_name }} -p{{ test_plugin_auth_string }} -e "SELECT 1"' + + - name: Plugin auth | Alter user with same plugin auth and same salt + community.mysql.mysql_user: + <<: *mysql_params + name: "{{ test_user_name }}" + host: "%" + plugin: caching_sha2_password + plugin_auth_string: "{{ test_plugin_auth_string }}" + salt: "{{ test_salt }}" + priv: "{{ test_default_priv }}" + register: result + failed_when: result is changed + + - name: cleanup user + ansible.builtin.include_tasks: utils/remove_user.yml + vars: + user_name: "{{ test_user_name }}" + + - name: Plugin auth | Create user with too short salt (should fail) + community.mysql.mysql_user: + <<: *mysql_params + name: "{{ test_user_name }}" + host: "%" + plugin: caching_sha2_password + plugin_auth_string: "{{ test_plugin_auth_string }}" + salt: "1234567890az" + priv: "{{ test_default_priv }}" + register: result + failed_when: result is success + + - name: Plugin auth | Create user with salt and no plugin auth string (should fail) + community.mysql.mysql_user: + <<: *mysql_params + name: "{{ test_user_name }}" + host: "%" + plugin: caching_sha2_password + salt: "{{ test_salt }}" + priv: "{{ test_default_priv }}" + register: result + failed_when: result is success + + - name: Plugin auth | Create user with salt and plugin not handled by internal hash generation (should fail) + community.mysql.mysql_user: + <<: *mysql_params + name: "{{ test_user_name }}" + host: "%" + plugin: mysql_native_password + plugin_auth_string: "{{ test_plugin_auth_string }}" + salt: "{{ test_salt }}" + priv: "{{ test_default_priv }}" + register: result + failed_when: result is success