From 879945f56103d937a7fee84bfe7662dc2a5be708 Mon Sep 17 00:00:00 2001
From: sxt1001 <shixuantong1@huawei.com>
Date: Thu, 17 Oct 2024 20:45:07 +0800
Subject: [PATCH] feat: Ensure random passwords contain multiple character
 types (#5815)

Reference:https://github.com/canonical/cloud-init/commit/879945f56103d937a7fee84bfe7662dc2a5be708
Conflict:(1)change cloudinit/config/tests/test_set_passwords.py not tests/unittests/config/test_cc_set_passwords.py
(2)add "import pytest" for test_set_passwords.py

The complexity of the random password generated by the
rand_user_password() method may not meet the security configuration
requirements of the system authentication module. This can cause
chpasswd to fail.

This commit ensures we generate a password using 4 different character
classes.

Fixes GH-5814

Co-authored-by: James Falcon <james.falcon@canonical.com>
---
 cloudinit/config/cc_set_passwords.py         | 35 +++++++++++++----
 cloudinit/config/tests/test_set_passwords.py | 40 ++++++++++++++++++++
 2 files changed, 68 insertions(+), 7 deletions(-)

diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
index 3843aaf..6fe2ba3 100755
--- a/cloudinit/config/cc_set_passwords.py
+++ b/cloudinit/config/cc_set_passwords.py
@@ -78,6 +78,8 @@ password.
 """
 
 import re
+import random
+import string
 
 from cloudinit.distros import ug_util
 from cloudinit import log as logging
@@ -85,14 +87,8 @@ from cloudinit.ssh_util import update_ssh_config
 from cloudinit import subp
 from cloudinit import util
 
-from string import ascii_letters, digits
-
 LOG = logging.getLogger(__name__)
 
-# We are removing certain 'painful' letters/numbers
-PW_SET = (''.join([x for x in ascii_letters + digits
-                   if x not in 'loLOI01']))
-
 
 def handle_ssh_pwauth(pw_auth, distro):
     """Apply sshd PasswordAuthentication changes.
@@ -230,7 +226,32 @@ def handle(_name, cfg, cloud, log, args):
 
 
 def rand_user_password(pwlen=20):
-    return util.rand_str(pwlen, select_from=PW_SET)
+    if pwlen < 4:
+        raise ValueError("Password length must be at least 4 characters.")
+
+    # There are often restrictions on the minimum number of character
+    # classes required in a password, so ensure we at least one character
+    # from each class.
+    res_rand_list = [
+        random.choice(string.digits),
+        random.choice(string.ascii_lowercase),
+        random.choice(string.ascii_uppercase),
+        random.choice(string.punctuation),
+    ]
+
+    res_rand_list.extend(
+        list(
+            util.rand_str(
+                pwlen - len(res_rand_list),
+                select_from=string.digits
+                + string.ascii_lowercase
+                + string.ascii_uppercase
+                + string.punctuation,
+            )
+        )
+    )
+    random.shuffle(res_rand_list)
+    return "".join(res_rand_list)
 
 
 def chpasswd(distro, plist_in, hashed=False):
diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py
index 79118a1..9703a4b 100644
--- a/cloudinit/config/tests/test_set_passwords.py
+++ b/cloudinit/config/tests/test_set_passwords.py
@@ -1,5 +1,8 @@
 # This file is part of cloud-init. See LICENSE file for license information.
 
+import string
+import pytest
+
 from unittest import mock
 
 from cloudinit.config import cc_set_passwords as setpass
@@ -167,4 +170,41 @@ class TestSetPasswordsHandle(CiTestCase):
                 self.fail("Password not emitted to console")
 
 
+class TestRandUserPassword:
+    def _get_str_class_num(self, str):
+        return sum(
+            [
+                any(c.islower() for c in str),
+                any(c.isupper() for c in str),
+                any(c.isupper() for c in str),
+                any(c in string.punctuation for c in str),
+            ]
+        )
+
+    @pytest.mark.parametrize(
+        "strlen, expected_result",
+        [
+            (1, ValueError),
+            (2, ValueError),
+            (3, ValueError),
+            (4, 4),
+            (5, 4),
+            (5, 4),
+            (6, 4),
+            (20, 4),
+        ],
+    )
+    def test_rand_user_password(self, strlen, expected_result):
+        if expected_result is ValueError:
+            with pytest.raises(
+                expected_result,
+                match="Password length must be at least 4 characters.",
+            ):
+                setpass.rand_user_password(strlen)
+        else:
+            rand_password = setpass.rand_user_password(strlen)
+            assert len(rand_password) == strlen
+            assert self._get_str_class_num(rand_password) == expected_result
+
+
 # vi: ts=4 expandtab
-- 
2.33.0


