diff --git a/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch b/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch new file mode 100644 index 0000000..38be3f4 --- /dev/null +++ b/ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch @@ -0,0 +1,651 @@ +From 857009723f14e9ad2f5f4c8614d72982b00ec27d Mon Sep 17 00:00:00 2001 +From: Emanuele Giuseppe Esposito +Date: Mon, 12 Jul 2021 21:47:37 +0200 +Subject: [PATCH 2/2] ssh-util: allow cloudinit to merge all ssh keys into a + custom user file, defined in AuthorizedKeysFile (#937) + +RH-Author: Emanuele Giuseppe Esposito +RH-MergeRequest: 5: ssh-util: allow cloudinit to merge all ssh keys into a custom user file, defined in AuthorizedKeysFile (#937) +RH-Commit: [1/1] 3ed352e47c34e2ed2a1f9f5d68bc8b8f9a1365a6 (eesposit/cloud-init-centos-) +RH-Bugzilla: 1979099 +RH-Acked-by: Miroslav Rezanina +RH-Acked-by: Mohamed Gamal Morsy + +Conflicts: upstream patch modifies tests/integration_tests/util.py, that is +not present in RHEL. + +commit 9b52405c6f0de5e00d5ee9c1d13540425d8f6bf5 +Author: Emanuele Giuseppe Esposito +Date: Mon Jul 12 20:21:02 2021 +0200 + + ssh-util: allow cloudinit to merge all ssh keys into a custom user file, defined in AuthorizedKeysFile (#937) + + This patch aims to fix LP1911680, by analyzing the files provided + in sshd_config and merge all keys into an user-specific file. Also + introduces additional tests to cover this specific case. + + The file is picked by analyzing the path given in AuthorizedKeysFile. + + If it points inside the current user folder (path is /home/user/*), it + means it is an user-specific file, so we can copy all user-keys there. + If it contains a %u or %h, it means that there will be a specific + authorized_keys file for each user, so we can copy all user-keys there. + If no path points to an user-specific file, for example when only + /etc/ssh/authorized_keys is given, default to ~/.ssh/authorized_keys. + Note that if there are more than a single user-specific file, the last + one will be picked. + + Signed-off-by: Emanuele Giuseppe Esposito + Co-authored-by: James Falcon + + LP: #1911680 + RHBZ:1862967 + +Signed-off-by: Emanuele Giuseppe Esposito +Signed-off-by: Miroslav Rezanina +--- + cloudinit/ssh_util.py | 22 +- + .../assets/keys/id_rsa.test1 | 38 +++ + .../assets/keys/id_rsa.test1.pub | 1 + + .../assets/keys/id_rsa.test2 | 38 +++ + .../assets/keys/id_rsa.test2.pub | 1 + + .../assets/keys/id_rsa.test3 | 38 +++ + .../assets/keys/id_rsa.test3.pub | 1 + + .../modules/test_ssh_keysfile.py | 85 ++++++ + tests/unittests/test_sshutil.py | 246 +++++++++++++++++- + 9 files changed, 456 insertions(+), 14 deletions(-) + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test1 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test1.pub + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test2 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test2.pub + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test3 + create mode 100644 tests/integration_tests/assets/keys/id_rsa.test3.pub + create mode 100644 tests/integration_tests/modules/test_ssh_keysfile.py + +diff --git a/cloudinit/ssh_util.py b/cloudinit/ssh_util.py +index c08042d6..89057262 100644 +--- a/cloudinit/ssh_util.py ++++ b/cloudinit/ssh_util.py +@@ -252,13 +252,15 @@ def render_authorizedkeysfile_paths(value, homedir, username): + def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + (ssh_dir, pw_ent) = users_ssh_info(username) + default_authorizedkeys_file = os.path.join(ssh_dir, 'authorized_keys') ++ user_authorizedkeys_file = default_authorizedkeys_file + auth_key_fns = [] + with util.SeLinuxGuard(ssh_dir, recursive=True): + try: + ssh_cfg = parse_ssh_config_map(sshd_cfg_file) ++ key_paths = ssh_cfg.get("authorizedkeysfile", ++ "%h/.ssh/authorized_keys") + auth_key_fns = render_authorizedkeysfile_paths( +- ssh_cfg.get("authorizedkeysfile", "%h/.ssh/authorized_keys"), +- pw_ent.pw_dir, username) ++ key_paths, pw_ent.pw_dir, username) + + except (IOError, OSError): + # Give up and use a default key filename +@@ -267,8 +269,22 @@ def extract_authorized_keys(username, sshd_cfg_file=DEF_SSHD_CFG): + "config from %r, using 'AuthorizedKeysFile' file " + "%r instead", DEF_SSHD_CFG, auth_key_fns[0]) + ++ # check if one of the keys is the user's one ++ for key_path, auth_key_fn in zip(key_paths.split(), auth_key_fns): ++ if any([ ++ '%u' in key_path, ++ '%h' in key_path, ++ auth_key_fn.startswith('{}/'.format(pw_ent.pw_dir)) ++ ]): ++ user_authorizedkeys_file = auth_key_fn ++ ++ if user_authorizedkeys_file != default_authorizedkeys_file: ++ LOG.debug( ++ "AuthorizedKeysFile has an user-specific authorized_keys, " ++ "using %s", user_authorizedkeys_file) ++ + # always store all the keys in the user's private file +- return (default_authorizedkeys_file, parse_authorized_keys(auth_key_fns)) ++ return (user_authorizedkeys_file, parse_authorized_keys(auth_key_fns)) + + + def setup_user_keys(keys, username, options=None): +diff --git a/tests/integration_tests/assets/keys/id_rsa.test1 b/tests/integration_tests/assets/keys/id_rsa.test1 +new file mode 100644 +index 00000000..bd4c822e +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test1 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++CHbTWbsFrHR1qFAAAACmphbWVzQG5ld3Q= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test1.pub b/tests/integration_tests/assets/keys/id_rsa.test1.pub +new file mode 100644 +index 00000000..3d2e26e1 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test1.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1GUb3ponbdRG8CA79sGy4uX6Wq5zfnRpLBX3z+Ly+W86fkZVwfJ63+tdCDiVnizSjAbeRbJAVjye9OT4b5Q1Tx83RqcDMEOL7gzOx6OQX2xyNMwWZdWsikgKpXdthA2M+/qv65tB2QPTV27d4H23n/OXziC2yGnSEF2h/LPy7X8Dbmzt9VTrsbIjs90q9pL7K8lIidmdvZ71VDN8kL6oCc3uBRlq0EqN2BS0l6JRR4NpWdpLXtvRqhP2IE7SwhxC05MGViUKMW3VMAwtnrgePdJ8ZkHtiPRn4vMUHYhZfXPOyMjrxiRg8o5Iv81iFBT17nmQlDtaeeA0dj/af8Dt00pIUOaAzlz0haB5nt+Omx8hgCgn8IDEUg0P48Qd3WAJLrGgSFpRXKHlYf9eTAq/WfHgCJnYEUIT7vmTJJEtlQn7nLSvcNTmS0xd5RHNUg/um7RHwMK6X4ZT4z/5MfJa0AQ6B5o0np9imjaKkOFuZNi3eSTaUwhqlHEYEWz7fux0= test1@host +diff --git a/tests/integration_tests/assets/keys/id_rsa.test2 b/tests/integration_tests/assets/keys/id_rsa.test2 +new file mode 100644 +index 00000000..5854d901 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test2 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++tZRSm2GEgnDGAvAAAACmphbWVzQG5ld3Q= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test2.pub b/tests/integration_tests/assets/keys/id_rsa.test2.pub +new file mode 100644 +index 00000000..f3831a57 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test2.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC8rnQPY9Y5ziKTIdVElLq0OGrOMvlwqKK8gPinVfwFgJXDzdcAQY4uci1TJVcc0AOWHp+lWrU1joDYlW3KCg8XpkXHymHshYyaeEN2fEsvIaxuF3UzW2JckP9H7dacYdEng8qtBq8wuCodGuJ5XdBVV+MVJ6jqNf/hOu4/pma8hMxlYmtdoamHEn+k/KQR2Q6LwCYpT0U2+KiPI9Lac22P0H/rfkh2Ba+t3tV/lhyzD0xHnktZKJ3BrznpEfrZiXoukQZMebWUmIyTW7zwk9Kq+iGFpSQsqQWlxDBwHSbvpbUo7KWUmyZfxs1euVmwj5aKJgjteXm9CbbXsMS415q+08C0MzG7weZODZQnnk1rF6Ft97aXHaTmRgYbDeLU7U5U3alnb84HvUu5xh3/mrE9rPTfCzBwY4lgY+Q1zjS90RL9Jxzti3weydm7OqTI6DOfQeJQLOhhRgthOkt/7IabDFLIARjTnpo5+QKxugoc4qI6aUnE1plkVCRfV696ngM= test2@host +diff --git a/tests/integration_tests/assets/keys/id_rsa.test3 b/tests/integration_tests/assets/keys/id_rsa.test3 +new file mode 100644 +index 00000000..2596c762 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test3 +@@ -0,0 +1,38 @@ ++-----BEGIN OPENSSH PRIVATE KEY----- ++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 ++eauhOY52PS/BcAAAAKamFtZXNAbmV3dAE= ++-----END OPENSSH PRIVATE KEY----- +diff --git a/tests/integration_tests/assets/keys/id_rsa.test3.pub b/tests/integration_tests/assets/keys/id_rsa.test3.pub +new file mode 100644 +index 00000000..057db632 +--- /dev/null ++++ b/tests/integration_tests/assets/keys/id_rsa.test3.pub +@@ -0,0 +1 @@ ++ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCk8bgx2RhAoPnv+qt4WuH0ZELbbLrqpk5ZlZGMLzzuutu8HM7r2FXImlMheV4kS+laIdhyMxHkbo00Wyc21nh/EOqZJzi9wWPncmwT9c2osqrqmxdp0Jvm+Q2kZvED5nlsmXMn3r0+AkggRuBzzqx6LiSBHYy49aq1ltjkaSk0JOpa4th4Ur7XigWu4DafJYf0w4hEMcr93n3umwfL4tMy3KY7tk+E7nvVpsrDqO+/CJJ4PFUT2RXsoBAi4z39LeB+2+BBCuifcYvfimCYhpt1IZ1t8eoYOiivImGVuq4lkQsE3bfoqBE1MICNW9TOONCvV5E/fGywbBU7oQIEYJAuX9piiACC3CfCP2Jeq1+IsrcTCWCOq5Tj4lDQJW0uEbeAhkH6qgxOFyjSmYdOuO19ALCvO2wEEW6dJrb8Ky25XWZDk7lmtnoAKj3I9EE2Sezr4KIp20y0K3OLeTZEPP2OliY9xqTTcuvsiR0LVOnr6MD7//I8dI7WjgKpbz0hDM0= test3@host +diff --git a/tests/integration_tests/modules/test_ssh_keysfile.py b/tests/integration_tests/modules/test_ssh_keysfile.py +new file mode 100644 +index 00000000..f82d7649 +--- /dev/null ++++ b/tests/integration_tests/modules/test_ssh_keysfile.py +@@ -0,0 +1,85 @@ ++import paramiko ++import pytest ++from io import StringIO ++from paramiko.ssh_exception import SSHException ++ ++from tests.integration_tests.instances import IntegrationInstance ++from tests.integration_tests.util import get_test_rsa_keypair ++ ++TEST_USER1_KEYS = get_test_rsa_keypair('test1') ++TEST_USER2_KEYS = get_test_rsa_keypair('test2') ++TEST_DEFAULT_KEYS = get_test_rsa_keypair('test3') ++ ++USERDATA = """\ ++#cloud-config ++bootcmd: ++ - sed -i 's;#AuthorizedKeysFile.*;AuthorizedKeysFile /etc/ssh/authorized_keys %h/.ssh/authorized_keys2;' /etc/ssh/sshd_config ++ssh_authorized_keys: ++ - {default} ++users: ++- default ++- name: test_user1 ++ ssh_authorized_keys: ++ - {user1} ++- name: test_user2 ++ ssh_authorized_keys: ++ - {user2} ++""".format( # noqa: E501 ++ default=TEST_DEFAULT_KEYS.public_key, ++ user1=TEST_USER1_KEYS.public_key, ++ user2=TEST_USER2_KEYS.public_key, ++) ++ ++ ++@pytest.mark.ubuntu ++@pytest.mark.user_data(USERDATA) ++def test_authorized_keys(client: IntegrationInstance): ++ expected_keys = [ ++ ('test_user1', '/home/test_user1/.ssh/authorized_keys2', ++ TEST_USER1_KEYS), ++ ('test_user2', '/home/test_user2/.ssh/authorized_keys2', ++ TEST_USER2_KEYS), ++ ('ubuntu', '/home/ubuntu/.ssh/authorized_keys2', ++ TEST_DEFAULT_KEYS), ++ ('root', '/root/.ssh/authorized_keys2', TEST_DEFAULT_KEYS), ++ ] ++ ++ for user, filename, keys in expected_keys: ++ contents = client.read_from_file(filename) ++ if user in ['ubuntu', 'root']: ++ # Our personal public key gets added by pycloudlib ++ lines = contents.split('\n') ++ assert len(lines) == 2 ++ assert keys.public_key.strip() in contents ++ else: ++ assert contents.strip() == keys.public_key.strip() ++ ++ # Ensure we can actually connect ++ ssh = paramiko.SSHClient() ++ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ++ paramiko_key = paramiko.RSAKey.from_private_key(StringIO( ++ keys.private_key)) ++ ++ # Will fail with AuthenticationException if ++ # we cannot connect ++ ssh.connect( ++ client.instance.ip, ++ username=user, ++ pkey=paramiko_key, ++ look_for_keys=False, ++ allow_agent=False, ++ ) ++ ++ # Ensure other uses can't connect using our key ++ other_users = [u[0] for u in expected_keys if u[2] != keys] ++ for other_user in other_users: ++ with pytest.raises(SSHException): ++ print('trying to connect as {} with key from {}'.format( ++ other_user, user)) ++ ssh.connect( ++ client.instance.ip, ++ username=other_user, ++ pkey=paramiko_key, ++ look_for_keys=False, ++ allow_agent=False, ++ ) +diff --git a/tests/unittests/test_sshutil.py b/tests/unittests/test_sshutil.py +index fd1d1bac..bcb8044f 100644 +--- a/tests/unittests/test_sshutil.py ++++ b/tests/unittests/test_sshutil.py +@@ -570,20 +570,33 @@ class TestBasicAuthorizedKeyParse(test_helpers.CiTestCase): + ssh_util.render_authorizedkeysfile_paths( + "%h/.keys", "/homedirs/bobby", "bobby")) + ++ def test_all(self): ++ self.assertEqual( ++ ["/homedirs/bobby/.keys", "/homedirs/bobby/.secret/keys", ++ "/keys/path1", "/opt/bobby/keys"], ++ ssh_util.render_authorizedkeysfile_paths( ++ "%h/.keys .secret/keys /keys/path1 /opt/%u/keys", ++ "/homedirs/bobby", "bobby")) ++ + + class TestMultipleSshAuthorizedKeysFile(test_helpers.CiTestCase): + + @patch("cloudinit.ssh_util.pwd.getpwnam") + def test_multiple_authorizedkeys_file_order1(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='bobby', pw_dir='/home2/bobby') ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') + m_getpwnam.return_value = fpw +- authorized_keys = self.tmp_path('authorized_keys') ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) + util.write_file(authorized_keys, VALID_CONTENT['rsa']) + +- user_keys = self.tmp_path('user_keys') ++ # /tmp/home2/bobby/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) + util.write_file(user_keys, VALID_CONTENT['dsa']) + +- sshd_config = self.tmp_path('sshd_config') ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") + util.write_file( + sshd_config, + "AuthorizedKeysFile %s %s" % (authorized_keys, user_keys) +@@ -593,33 +606,244 @@ class TestMultipleSshAuthorizedKeysFile(test_helpers.CiTestCase): + fpw.pw_name, sshd_config) + content = ssh_util.update_authorized_keys(auth_key_entries, []) + +- self.assertEqual("%s/.ssh/authorized_keys" % fpw.pw_dir, auth_key_fn) ++ self.assertEqual(user_keys, auth_key_fn) + self.assertTrue(VALID_CONTENT['rsa'] in content) + self.assertTrue(VALID_CONTENT['dsa'] in content) + + @patch("cloudinit.ssh_util.pwd.getpwnam") + def test_multiple_authorizedkeys_file_order2(self, m_getpwnam): +- fpw = FakePwEnt(pw_name='suzie', pw_dir='/home/suzie') ++ fpw = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') + m_getpwnam.return_value = fpw +- authorized_keys = self.tmp_path('authorized_keys') ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home/suzie/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) + util.write_file(authorized_keys, VALID_CONTENT['rsa']) + +- user_keys = self.tmp_path('user_keys') ++ # /tmp/home/suzie/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) + util.write_file(user_keys, VALID_CONTENT['dsa']) + +- sshd_config = self.tmp_path('sshd_config') ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") + util.write_file( + sshd_config, +- "AuthorizedKeysFile %s %s" % (authorized_keys, user_keys) ++ "AuthorizedKeysFile %s %s" % (user_keys, authorized_keys) + ) + + (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( +- fpw.pw_name, sshd_config ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_local_global(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys = rsa ++ authorized_keys = self.tmp_path('authorized_keys', dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ ++ # /tmp/home2/bobby/.ssh/user_keys = dsa ++ user_keys = self.tmp_path('user_keys', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, ++ user_keys, authorized_keys) ++ ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_local_global2(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ ++ # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ ++ # /tmp/home2/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %s %s" % (authorized_keys_global, ++ authorized_keys, user_keys) ++ ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_global(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ ++ # /tmp/etc/ssh/authorized_keys = rsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['rsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config') ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s" % (authorized_keys_global) + ) ++ ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) + content = ssh_util.update_authorized_keys(auth_key_entries, []) + + self.assertEqual("%s/.ssh/authorized_keys" % fpw.pw_dir, auth_key_fn) + self.assertTrue(VALID_CONTENT['rsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_multiuser(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home2/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ # /tmp/home2/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ # /tmp/home2/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ fpw2 = FakePwEnt(pw_name='suzie', pw_dir='/tmp/home/suzie') ++ user_ssh_folder = "%s/.ssh" % fpw2.pw_dir ++ # /tmp/home/suzie/.ssh/authorized_keys2 = ssh-xmss@openssh.com ++ authorized_keys2 = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys2, ++ VALID_CONTENT['ssh-xmss@openssh.com']) ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s" % ++ (authorized_keys_global, user_keys) ++ ) ++ ++ # process first user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['ssh-xmss@openssh.com'] in content) ++ ++ m_getpwnam.return_value = fpw2 ++ # process second user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw2.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(authorized_keys2, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['ssh-xmss@openssh.com'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['rsa'] in content) ++ ++ @patch("cloudinit.ssh_util.pwd.getpwnam") ++ def test_multiple_authorizedkeys_file_multiuser2(self, m_getpwnam): ++ fpw = FakePwEnt(pw_name='bobby', pw_dir='/tmp/home/bobby') ++ m_getpwnam.return_value = fpw ++ user_ssh_folder = "%s/.ssh" % fpw.pw_dir ++ # /tmp/home/bobby/.ssh/authorized_keys2 = rsa ++ authorized_keys = self.tmp_path('authorized_keys2', ++ dir=user_ssh_folder) ++ util.write_file(authorized_keys, VALID_CONTENT['rsa']) ++ # /tmp/home/bobby/.ssh/user_keys3 = dsa ++ user_keys = self.tmp_path('user_keys3', dir=user_ssh_folder) ++ util.write_file(user_keys, VALID_CONTENT['dsa']) ++ ++ fpw2 = FakePwEnt(pw_name='badguy', pw_dir='/tmp/home/badguy') ++ user_ssh_folder = "%s/.ssh" % fpw2.pw_dir ++ # /tmp/home/badguy/home/bobby = "" ++ authorized_keys2 = self.tmp_path('home/bobby', dir="/tmp/home/badguy") ++ ++ # /tmp/etc/ssh/authorized_keys = ecdsa ++ authorized_keys_global = self.tmp_path('etc/ssh/authorized_keys2', ++ dir="/tmp") ++ util.write_file(authorized_keys_global, VALID_CONTENT['ecdsa']) ++ ++ # /tmp/sshd_config ++ sshd_config = self.tmp_path('sshd_config', dir="/tmp") ++ util.write_file( ++ sshd_config, ++ "AuthorizedKeysFile %s %%h/.ssh/authorized_keys2 %s %s" % ++ (authorized_keys_global, user_keys, authorized_keys2) ++ ) ++ ++ # process first user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ self.assertEqual(user_keys, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['rsa'] in content) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) ++ self.assertTrue(VALID_CONTENT['dsa'] in content) ++ ++ m_getpwnam.return_value = fpw2 ++ # process second user ++ (auth_key_fn, auth_key_entries) = ssh_util.extract_authorized_keys( ++ fpw2.pw_name, sshd_config) ++ content = ssh_util.update_authorized_keys(auth_key_entries, []) ++ ++ # badguy should not take the key from the other user! ++ self.assertEqual(authorized_keys2, auth_key_fn) ++ self.assertTrue(VALID_CONTENT['ecdsa'] in content) + self.assertTrue(VALID_CONTENT['dsa'] in content) ++ self.assertFalse(VALID_CONTENT['rsa'] in content) + + # vi: ts=4 expandtab +-- +2.27.0 + diff --git a/ci-write-passwords-only-to-serial-console-lock-down-clo.patch b/ci-write-passwords-only-to-serial-console-lock-down-clo.patch new file mode 100644 index 0000000..272d903 --- /dev/null +++ b/ci-write-passwords-only-to-serial-console-lock-down-clo.patch @@ -0,0 +1,371 @@ +From f9564bd4477782e8cffe4be1d3c31c0338fb03b1 Mon Sep 17 00:00:00 2001 +From: Eduardo Otubo +Date: Mon, 5 Jul 2021 14:07:21 +0200 +Subject: [PATCH 1/2] write passwords only to serial console, lock down + cloud-init-output.log (#847) + +RH-Author: Eduardo Otubo +RH-MergeRequest: 4: write passwords only to serial console, lock down cloud-init-output.log (#847) +RH-Commit: [1/1] 7543b3458c01ea988e987336d84510157c00390d (otubo/cloud-init-src) +RH-Bugzilla: 1945892 +RH-Acked-by: Emanuele Giuseppe Esposito +RH-Acked-by: Miroslav Rezanina +RH-Acked-by: Mohamed Gamal Morsy + +commit b794d426b9ab43ea9d6371477466070d86e10668 +Author: Daniel Watkins +Date: Fri Mar 19 10:06:42 2021 -0400 + + write passwords only to serial console, lock down cloud-init-output.log (#847) + + Prior to this commit, when a user specified configuration which would + generate random passwords for users, cloud-init would cause those + passwords to be written to the serial console by emitting them on + stderr. In the default configuration, any stdout or stderr emitted by + cloud-init is also written to `/var/log/cloud-init-output.log`. This + file is world-readable, meaning that those randomly-generated passwords + were available to be read by any user with access to the system. This + presents an obvious security issue. + + This commit responds to this issue in two ways: + + * We address the direct issue by moving from writing the passwords to + sys.stderr to writing them directly to /dev/console (via + util.multi_log); this means that the passwords will never end up in + cloud-init-output.log + * To avoid future issues like this, we also modify the logging code so + that any files created in a log sink subprocess will only be + owner/group readable and, if it exists, will be owned by the adm + group. This results in `/var/log/cloud-init-output.log` no longer + being world-readable, meaning that if there are other parts of the + codebase that are emitting sensitive data intended for the serial + console, that data is no longer available to all users of the system. + + LP: #1918303 + +Signed-off-by: Eduardo Otubo +Signed-off-by: Miroslav Rezanina +--- + cloudinit/config/cc_set_passwords.py | 5 +- + cloudinit/config/tests/test_set_passwords.py | 40 +++++++++---- + cloudinit/tests/test_util.py | 56 +++++++++++++++++++ + cloudinit/util.py | 38 +++++++++++-- + .../modules/test_set_password.py | 24 ++++++++ + tests/integration_tests/test_logging.py | 22 ++++++++ + tests/unittests/test_util.py | 4 ++ + 7 files changed, 173 insertions(+), 16 deletions(-) + create mode 100644 tests/integration_tests/test_logging.py + +diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py +index d6b5682d..433de751 100755 +--- a/cloudinit/config/cc_set_passwords.py ++++ b/cloudinit/config/cc_set_passwords.py +@@ -78,7 +78,6 @@ password. + """ + + import re +-import sys + + from cloudinit.distros import ug_util + from cloudinit import log as logging +@@ -214,7 +213,9 @@ def handle(_name, cfg, cloud, log, args): + if len(randlist): + blurb = ("Set the following 'random' passwords\n", + '\n'.join(randlist)) +- sys.stderr.write("%s\n%s\n" % blurb) ++ util.multi_log( ++ "%s\n%s\n" % blurb, stderr=False, fallback_to_stdout=False ++ ) + + if expire: + expired_users = [] +diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py +index daa1ef51..bbe2ee8f 100644 +--- a/cloudinit/config/tests/test_set_passwords.py ++++ b/cloudinit/config/tests/test_set_passwords.py +@@ -74,10 +74,6 @@ class TestSetPasswordsHandle(CiTestCase): + + with_logs = True + +- def setUp(self): +- super(TestSetPasswordsHandle, self).setUp() +- self.add_patch('cloudinit.config.cc_set_passwords.sys.stderr', 'm_err') +- + def test_handle_on_empty_config(self, *args): + """handle logs that no password has changed when config is empty.""" + cloud = self.tmp_cloud(distro='ubuntu') +@@ -129,10 +125,12 @@ class TestSetPasswordsHandle(CiTestCase): + mock.call(['pw', 'usermod', 'ubuntu', '-p', '01-Jan-1970'])], + m_subp.call_args_list) + ++ @mock.patch(MODPATH + "util.multi_log") + @mock.patch(MODPATH + "util.is_BSD") + @mock.patch(MODPATH + "subp.subp") +- def test_handle_on_chpasswd_list_creates_random_passwords(self, m_subp, +- m_is_bsd): ++ def test_handle_on_chpasswd_list_creates_random_passwords( ++ self, m_subp, m_is_bsd, m_multi_log ++ ): + """handle parses command set random passwords.""" + m_is_bsd.return_value = False + cloud = self.tmp_cloud(distro='ubuntu') +@@ -146,10 +144,32 @@ class TestSetPasswordsHandle(CiTestCase): + self.assertIn( + 'DEBUG: Handling input for chpasswd as list.', + self.logs.getvalue()) +- self.assertNotEqual( +- [mock.call(['chpasswd'], +- '\n'.join(valid_random_pwds) + '\n')], +- m_subp.call_args_list) ++ ++ self.assertEqual(1, m_subp.call_count) ++ args, _kwargs = m_subp.call_args ++ self.assertEqual(["chpasswd"], args[0]) ++ ++ stdin = args[1] ++ user_pass = { ++ user: password ++ for user, password ++ in (line.split(":") for line in stdin.splitlines()) ++ } ++ ++ self.assertEqual(1, m_multi_log.call_count) ++ self.assertEqual( ++ mock.call(mock.ANY, stderr=False, fallback_to_stdout=False), ++ m_multi_log.call_args ++ ) ++ ++ self.assertEqual(set(["root", "ubuntu"]), set(user_pass.keys())) ++ written_lines = m_multi_log.call_args[0][0].splitlines() ++ for password in user_pass.values(): ++ for line in written_lines: ++ if password in line: ++ break ++ else: ++ self.fail("Password not emitted to console") + + + # vi: ts=4 expandtab +diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py +index b7a302f1..e811917e 100644 +--- a/cloudinit/tests/test_util.py ++++ b/cloudinit/tests/test_util.py +@@ -851,4 +851,60 @@ class TestEnsureFile: + assert "ab" == kwargs["omode"] + + ++@mock.patch("cloudinit.util.grp.getgrnam") ++@mock.patch("cloudinit.util.os.setgid") ++@mock.patch("cloudinit.util.os.umask") ++class TestRedirectOutputPreexecFn: ++ """This tests specifically the preexec_fn used in redirect_output.""" ++ ++ @pytest.fixture(params=["outfmt", "errfmt"]) ++ def preexec_fn(self, request): ++ """A fixture to gather the preexec_fn used by redirect_output. ++ ++ This enables simpler direct testing of it, and parameterises any tests ++ using it to cover both the stdout and stderr code paths. ++ """ ++ test_string = "| piped output to invoke subprocess" ++ if request.param == "outfmt": ++ args = (test_string, None) ++ elif request.param == "errfmt": ++ args = (None, test_string) ++ with mock.patch("cloudinit.util.subprocess.Popen") as m_popen: ++ util.redirect_output(*args) ++ ++ assert 1 == m_popen.call_count ++ _args, kwargs = m_popen.call_args ++ assert "preexec_fn" in kwargs, "preexec_fn not passed to Popen" ++ return kwargs["preexec_fn"] ++ ++ def test_preexec_fn_sets_umask( ++ self, m_os_umask, _m_setgid, _m_getgrnam, preexec_fn ++ ): ++ """preexec_fn should set a mask that avoids world-readable files.""" ++ preexec_fn() ++ ++ assert [mock.call(0o037)] == m_os_umask.call_args_list ++ ++ def test_preexec_fn_sets_group_id_if_adm_group_present( ++ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn ++ ): ++ """We should setgrp to adm if present, so files are owned by them.""" ++ fake_group = mock.Mock(gr_gid=mock.sentinel.gr_gid) ++ m_getgrnam.return_value = fake_group ++ ++ preexec_fn() ++ ++ assert [mock.call("adm")] == m_getgrnam.call_args_list ++ assert [mock.call(mock.sentinel.gr_gid)] == m_setgid.call_args_list ++ ++ def test_preexec_fn_handles_absent_adm_group_gracefully( ++ self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn ++ ): ++ """We should handle an absent adm group gracefully.""" ++ m_getgrnam.side_effect = KeyError("getgrnam(): name not found: 'adm'") ++ ++ preexec_fn() ++ ++ assert 0 == m_setgid.call_count ++ + # vi: ts=4 expandtab +diff --git a/cloudinit/util.py b/cloudinit/util.py +index 769f3425..4e0a72db 100644 +--- a/cloudinit/util.py ++++ b/cloudinit/util.py +@@ -359,7 +359,7 @@ def find_modules(root_dir): + + + def multi_log(text, console=True, stderr=True, +- log=None, log_level=logging.DEBUG): ++ log=None, log_level=logging.DEBUG, fallback_to_stdout=True): + if stderr: + sys.stderr.write(text) + if console: +@@ -368,7 +368,7 @@ def multi_log(text, console=True, stderr=True, + with open(conpath, 'w') as wfh: + wfh.write(text) + wfh.flush() +- else: ++ elif fallback_to_stdout: + # A container may lack /dev/console (arguably a container bug). If + # it does not exist, then write output to stdout. this will result + # in duplicate stderr and stdout messages if stderr was True. +@@ -623,6 +623,26 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + if not o_err: + o_err = sys.stderr + ++ # pylint: disable=subprocess-popen-preexec-fn ++ def set_subprocess_umask_and_gid(): ++ """Reconfigure umask and group ID to create output files securely. ++ ++ This is passed to subprocess.Popen as preexec_fn, so it is executed in ++ the context of the newly-created process. It: ++ ++ * sets the umask of the process so created files aren't world-readable ++ * if an adm group exists in the system, sets that as the process' GID ++ (so that the created file(s) are owned by root:adm) ++ """ ++ os.umask(0o037) ++ try: ++ group_id = grp.getgrnam("adm").gr_gid ++ except KeyError: ++ # No adm group, don't set a group ++ pass ++ else: ++ os.setgid(group_id) ++ + if outfmt: + LOG.debug("Redirecting %s to %s", o_out, outfmt) + (mode, arg) = outfmt.split(" ", 1) +@@ -632,7 +652,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + owith = "wb" + new_fp = open(arg, owith) + elif mode == "|": +- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE) ++ proc = subprocess.Popen( ++ arg, ++ shell=True, ++ stdin=subprocess.PIPE, ++ preexec_fn=set_subprocess_umask_and_gid, ++ ) + new_fp = proc.stdin + else: + raise TypeError("Invalid type for output format: %s" % outfmt) +@@ -654,7 +679,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None): + owith = "wb" + new_fp = open(arg, owith) + elif mode == "|": +- proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE) ++ proc = subprocess.Popen( ++ arg, ++ shell=True, ++ stdin=subprocess.PIPE, ++ preexec_fn=set_subprocess_umask_and_gid, ++ ) + new_fp = proc.stdin + else: + raise TypeError("Invalid type for error format: %s" % errfmt) +diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py +index b13f76fb..d7cf91a5 100644 +--- a/tests/integration_tests/modules/test_set_password.py ++++ b/tests/integration_tests/modules/test_set_password.py +@@ -116,6 +116,30 @@ class Mixin: + # Which are not the same + assert shadow_users["harry"] != shadow_users["dick"] + ++ def test_random_passwords_not_stored_in_cloud_init_output_log( ++ self, class_client ++ ): ++ """We should not emit passwords to the in-instance log file. ++ ++ LP: #1918303 ++ """ ++ cloud_init_output = class_client.read_from_file( ++ "/var/log/cloud-init-output.log" ++ ) ++ assert "dick:" not in cloud_init_output ++ assert "harry:" not in cloud_init_output ++ ++ def test_random_passwords_emitted_to_serial_console(self, class_client): ++ """We should emit passwords to the serial console. (LP: #1918303)""" ++ try: ++ console_log = class_client.instance.console_log() ++ except NotImplementedError: ++ # Assume that an exception here means that we can't use the console ++ # log ++ pytest.skip("NotImplementedError when requesting console log") ++ assert "dick:" in console_log ++ assert "harry:" in console_log ++ + def test_explicit_password_set_correctly(self, class_client): + """Test that an explicitly-specified password is set correctly.""" + shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client) +diff --git a/tests/integration_tests/test_logging.py b/tests/integration_tests/test_logging.py +new file mode 100644 +index 00000000..b31a0434 +--- /dev/null ++++ b/tests/integration_tests/test_logging.py +@@ -0,0 +1,22 @@ ++"""Integration tests relating to cloud-init's logging.""" ++ ++ ++class TestVarLogCloudInitOutput: ++ """Integration tests relating to /var/log/cloud-init-output.log.""" ++ ++ def test_var_log_cloud_init_output_not_world_readable(self, client): ++ """ ++ The log can contain sensitive data, it shouldn't be world-readable. ++ ++ LP: #1918303 ++ """ ++ # Check the file exists ++ assert client.execute("test -f /var/log/cloud-init-output.log").ok ++ ++ # Check its permissions are as we expect ++ perms, user, group = client.execute( ++ "stat -c %a:%U:%G /var/log/cloud-init-output.log" ++ ).split(":") ++ assert "640" == perms ++ assert "root" == user ++ assert "adm" == group +diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py +index 857629f1..e5292001 100644 +--- a/tests/unittests/test_util.py ++++ b/tests/unittests/test_util.py +@@ -572,6 +572,10 @@ class TestMultiLog(helpers.FilesystemMockingTestCase): + util.multi_log(logged_string) + self.assertEqual(logged_string, self.stdout.getvalue()) + ++ def test_logs_dont_go_to_stdout_if_fallback_to_stdout_is_false(self): ++ util.multi_log('something', fallback_to_stdout=False) ++ self.assertEqual('', self.stdout.getvalue()) ++ + def test_logs_go_to_log_if_given(self): + log = mock.MagicMock() + logged_string = 'something very important' +-- +2.27.0 + diff --git a/cloud-init.spec b/cloud-init.spec index 40d7610..be111ad 100644 --- a/cloud-init.spec +++ b/cloud-init.spec @@ -1,6 +1,6 @@ Name: cloud-init Version: 21.1 -Release: 3%{?dist} +Release: 4%{?dist} Summary: Cloud instance init scripts License: ASL 2.0 or GPLv3 URL: http://launchpad.net/cloud-init @@ -14,6 +14,10 @@ Patch0003: 0003-limit-permissions-on-def_log_file.patch Patch4: ci-rhel-cloud.cfg-remove-ssh_genkeytypes-in-settings.py.patch # For bz#1943511 - [Aliyun][RHEL9.0][cloud-init] cloud-init service failed to start with Alibaba instance Patch5: ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch +# For bz#1945892 - CVE-2021-3429 cloud-init: randomly generated passwords logged in clear-text to world-readable file [rhel-9.0] +Patch6: ci-write-passwords-only-to-serial-console-lock-down-clo.patch +# For bz#1979099 - [cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0] +Patch7: ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch # Source-git patches @@ -208,6 +212,14 @@ fi %config(noreplace) %{_sysconfdir}/rsyslog.d/21-cloudinit.conf %changelog +* Thu Jul 15 2021 Miroslav Rezanina - 21.1-4 +- ci-write-passwords-only-to-serial-console-lock-down-clo.patch [bz#1945892] +- ci-ssh-util-allow-cloudinit-to-merge-all-ssh-keys-into-.patch [bz#1979099] +- Resolves: bz#1945892 + (CVE-2021-3429 cloud-init: randomly generated passwords logged in clear-text to world-readable file [rhel-9.0]) +- Resolves: bz#1979099 + ([cloud-init]Customize ssh AuthorizedKeysFile causes login failure[RHEL-9.0]) + * Fri Jul 02 2021 Miroslav Rezanina - 21.1-3 - ci-Fix-requiring-device-number-on-EC2-derivatives-836.patch [bz#1943511] - Resolves: bz#1943511