From fbf5f1636397cd660b25520f8cb999ae64873976 Mon Sep 17 00:00:00 2001 From: Daniel Albers Date: Mon, 31 Mar 2025 00:37:15 +0200 Subject: [PATCH] Support checking for expired and untrusted keys Adds state `trusted`. Fixes #9949 --- .../9950-pacman_key-verify-key-validity.yml | 3 + plugins/modules/pacman_key.py | 171 +++++++++++------ tests/unit/plugins/modules/test_pacman_key.py | 180 ++++++++++++++++-- 3 files changed, 276 insertions(+), 78 deletions(-) create mode 100644 changelogs/fragments/9950-pacman_key-verify-key-validity.yml diff --git a/changelogs/fragments/9950-pacman_key-verify-key-validity.yml b/changelogs/fragments/9950-pacman_key-verify-key-validity.yml new file mode 100644 index 0000000000..a315b694bf --- /dev/null +++ b/changelogs/fragments/9950-pacman_key-verify-key-validity.yml @@ -0,0 +1,3 @@ +--- +minor_change: + - pacman_key - support verifying that keys are trusted and not expired (https://github.com/ansible-collections/community.general/pull/9950) diff --git a/plugins/modules/pacman_key.py b/plugins/modules/pacman_key.py index f98fb6f8a3..d16bc0c556 100644 --- a/plugins/modules/pacman_key.py +++ b/plugins/modules/pacman_key.py @@ -78,9 +78,9 @@ options: default: /etc/pacman.d/gnupg state: description: - - Ensures that the key is present (added) or absent (revoked). + - Ensures that the key is present (added), trusted (signed and not expired) or absent (revoked). default: present - choices: [absent, present] + choices: [absent, present, trusted] type: str """ @@ -129,12 +129,55 @@ from ansible.module_utils.urls import fetch_url from ansible.module_utils.common.text.converters import to_native +class GpgListResult: + """Wraps gpg --list-* output.""" + + def __init__(self, line) -> None: + self._parts = line.split(':') + + @property + def kind(self): + return self._parts[0] + + @property + def valid(self): + return self._parts[1] + + @property + def is_fully_valid(self): + return self.valid == 'f' + + @property + def key(self): + return self._parts[4] + + @property + def user_id(self): + return self._parts[9] + + +def gpg_get_first(lines, kind, attr): + for line in lines: + glr = GpgListResult(line) + if glr.kind == kind: + return getattr(glr, attr) + + +def gpg_gather_all(lines, kind, attr): + result = [] + for line in lines: + glr = GpgListResult(line) + if glr.kind == kind: + result.append(getattr(glr, attr)) + return result + + class PacmanKey(object): def __init__(self, module): self.module = module # obtain binary paths for gpg & pacman-key - self.gpg = module.get_bin_path('gpg', required=True) - self.pacman_key = module.get_bin_path('pacman-key', required=True) + self.gpg_binary = module.get_bin_path('gpg', required=True) + self.pacman_key_binary = module.get_bin_path('pacman-key', required=True) # obtain module parameters keyid = module.params['id'] @@ -150,43 +193,68 @@ class PacmanKey(object): # sanitise key ID & check if key exists in the keyring keyid = self.sanitise_keyid(keyid) - key_present = self.key_in_keyring(keyring, keyid) + key_validity = self.key_validity(keyring, keyid) + key_present = len(key_validity) > 0 + key_valid = any(key_validity) # check mode if module.check_mode: - if state == "present": + if state in ['present', 'trusted']: changed = (key_present and force_update) or not key_present + if not changed and state == 'trusted': + changed = not (key_valid and self.key_is_trusted(keyring, keyid)) module.exit_json(changed=changed) - elif state == "absent": - if key_present: - module.exit_json(changed=True) - module.exit_json(changed=False) - - if state == "present": - if key_present and not force_update: - module.exit_json(changed=False) + if state == 'absent': + module.exit_json(changed=key_present) + if state in ['present', 'trusted']: + trusted = key_valid and self.key_is_trusted(keyring, keyid) + if not force_update: + if (state == 'present' and key_present) or (state == 'trusted' and trusted): + module.exit_json(changed=False) + changed = False if data: file = self.save_key(data) self.add_key(keyring, file, keyid, verify) - module.exit_json(changed=True) + changed = True elif file: self.add_key(keyring, file, keyid, verify) - module.exit_json(changed=True) + changed = True elif url: data = self.fetch_key(url) file = self.save_key(data) self.add_key(keyring, file, keyid, verify) - module.exit_json(changed=True) + changed = True elif keyserver: self.recv_key(keyring, keyid, keyserver) - module.exit_json(changed=True) - elif state == "absent": + changed = True + if changed or (state == 'trusted' and not trusted): + self.lsign_key(keyring=keyring, keyid=keyid) + changed = True + module.exit_json(changed=changed) + elif state == 'absent': if key_present: self.remove_key(keyring, keyid) module.exit_json(changed=True) module.exit_json(changed=False) + def gpg(self, args, /, keyring=None, **kwargs): + cmd = [self.gpg_binary] + if keyring: + cmd.append(f'--homedir={keyring}') + cmd.extend(['--no-permission-warning', '--with-colons', '--quiet', '--batch', '--no-tty']) + return self.module.run_command(cmd + args, **kwargs) + + def pacman_key(self, args, /, keyring, **kwargs): + return self.module.run_command( + [self.pacman_key_binary, '--gpgdir', keyring] + args, + **kwargs, + ) + + def pacman_machine_key(self, keyring): + _, stdout, _ = self.gpg(['--list-secret-key'], keyring=keyring) + return gpg_get_first(stdout.splitlines(), 'sec', 'key') + def is_hexadecimal(self, string): """Check if a given string is valid hexadecimal""" try: @@ -216,14 +284,11 @@ class PacmanKey(object): def recv_key(self, keyring, keyid, keyserver): """Receives key via keyserver""" - cmd = [self.pacman_key, '--gpgdir', keyring, '--keyserver', keyserver, '--recv-keys', keyid] - self.module.run_command(cmd, check_rc=True) - self.lsign_key(keyring, keyid) + self.pacman_key(['--keyserver', keyserver, '--recv-keys', keyid], keyring=keyring, check_rc=True) def lsign_key(self, keyring, keyid): """Locally sign key""" - cmd = [self.pacman_key, '--gpgdir', keyring] - self.module.run_command(cmd + ['--lsign-key', keyid], check_rc=True) + self.pacman_key(['--lsign-key', keyid], keyring=keyring, check_rc=True) def save_key(self, data): "Saves key data to a temporary file" @@ -238,14 +303,11 @@ class PacmanKey(object): """Add key to pacman's keyring""" if verify: self.verify_keyfile(keyfile, keyid) - cmd = [self.pacman_key, '--gpgdir', keyring, '--add', keyfile] - self.module.run_command(cmd, check_rc=True) - self.lsign_key(keyring, keyid) + self.pacman_key(['--add', keyfile], keyring=keyring, check_rc=True) def remove_key(self, keyring, keyid): """Remove key from pacman's keyring""" - cmd = [self.pacman_key, '--gpgdir', keyring, '--delete', keyid] - self.module.run_command(cmd, check_rc=True) + self.pacman_key(['--delete', keyid], keyring=keyring, check_rc=True) def verify_keyfile(self, keyfile, keyid): """Verify that keyfile matches the specified key ID""" @@ -254,48 +316,29 @@ class PacmanKey(object): elif keyid is None: self.module.fail_json(msg="expected a key ID, got none") - rc, stdout, stderr = self.module.run_command( - [ - self.gpg, - '--with-colons', - '--with-fingerprint', - '--batch', - '--no-tty', - '--show-keys', - keyfile - ], + rc, stdout, stderr = self.gpg( + ['--with-fingerprint', '--show-keys', keyfile], check_rc=True, ) - extracted_keyid = None - for line in stdout.splitlines(): - if line.startswith('fpr:'): - extracted_keyid = line.split(':')[9] - break - + extracted_keyid = gpg_get_first(stdout.splitlines(), 'fpr', 'user_id') if extracted_keyid != keyid: self.module.fail_json(msg="key ID does not match. expected %s, got %s" % (keyid, extracted_keyid)) - def key_in_keyring(self, keyring, keyid): - "Check if the key ID is in pacman's keyring" - rc, stdout, stderr = self.module.run_command( - [ - self.gpg, - '--with-colons', - '--batch', - '--no-tty', - '--no-default-keyring', - '--keyring=%s/pubring.gpg' % keyring, - '--list-keys', keyid - ], - check_rc=False, - ) + def key_validity(self, keyring, keyid): + "Check if the key ID is in pacman's keyring and not expired" + rc, stdout, stderr = self.gpg(['--no-default-keyring', '--list-keys', keyid], keyring=keyring, check_rc=False) if rc != 0: if stderr.find("No public key") >= 0: - return False + return [] else: self.module.fail_json(msg="gpg returned an error: %s" % stderr) - return True + return gpg_gather_all(stdout.splitlines(), 'uid', 'is_fully_valid') + + def key_is_trusted(self, keyring, keyid): + """Check if key is signed and not expired.""" + _, stdout, _ = self.gpg(['--check-signatures', keyid], keyring=keyring) + return self.pacman_machine_key(keyring) in gpg_gather_all(stdout.splitlines(), 'sig', 'key') def main(): @@ -309,7 +352,11 @@ def main(): verify=dict(type='bool', default=True), force_update=dict(type='bool', default=False), keyring=dict(type='path', default='/etc/pacman.d/gnupg'), - state=dict(type='str', default='present', choices=['absent', 'present']), + state=dict( + type='str', + default='present', + choices=['absent', 'present', 'trusted'], + ), ), supports_check_mode=True, mutually_exclusive=(('data', 'file', 'url', 'keyserver'),), diff --git a/tests/unit/plugins/modules/test_pacman_key.py b/tests/unit/plugins/modules/test_pacman_key.py index ac85708985..58b2d232d6 100644 --- a/tests/unit/plugins/modules/test_pacman_key.py +++ b/tests/unit/plugins/modules/test_pacman_key.py @@ -17,8 +17,9 @@ MOCK_BIN_PATH = '/mocked/path' TESTING_KEYID = '14F26682D0916CDD81E37B6D61B7B526D98F0353' TESTING_KEYFILE_PATH = '/tmp/pubkey.asc' -# gpg --{show,list}-key output (key present) -GPG_SHOWKEY_OUTPUT = '''tru::1:1616373715:0:3:1:5 +# gpg --{show,list}-key output (key present, but expired) +GPG_SHOWKEY_OUTPUT_EXPIRED = """ +tru::1:1616373715:0:3:1:5 pub:-:4096:1:61B7B526D98F0353:1437155332:::-:::scSC::::::23::0: fpr:::::::::14F26682D0916CDD81E37B6D61B7B526D98F0353: uid:-::::1437155332::E57D1F9BFF3B404F9F30333629369B08DF5E2161::Mozilla Software Releases ::::::::::0: @@ -27,24 +28,76 @@ fpr:::::::::F2EF4E6E6AE75B95F11F1EB51C69C4E55E9905DB: sub:e:4096:1:BBBEBDBB24C6F355:1498143157:1561215157:::::s::::::23: fpr:::::::::DCEAC5D96135B91C4EA672ABBBBEBDBB24C6F355: sub:e:4096:1:F1A6668FBB7D572E:1559247338:1622319338:::::s::::::23: -fpr:::::::::097B313077AE62A02F84DA4DF1A6668FBB7D572E:''' +fpr:::::::::097B313077AE62A02F84DA4DF1A6668FBB7D572E: +""".strip() + +# gpg --{show,list}-key output (key present and trusted) +GPG_SHOWKEY_OUTPUT_TRUSTED = """ +tru::1:1616373715:0:3:1:5 +pub:f:4096:1:61B7B526D98F0353:1437155332:::-:::scSC::::::23::0: +fpr:::::::::14F26682D0916CDD81E37B6D61B7B526D98F0353: +uid:f::::1437155332::E57D1F9BFF3B404F9F30333629369B08DF5E2161::Mozilla Software Releases ::::::::::0: +sub:e:4096:1:1C69C4E55E9905DB:1437155572:1500227572:::::s::::::23: +fpr:::::::::F2EF4E6E6AE75B95F11F1EB51C69C4E55E9905DB: +sub:e:4096:1:BBBEBDBB24C6F355:1498143157:1561215157:::::s::::::23: +fpr:::::::::DCEAC5D96135B91C4EA672ABBBBEBDBB24C6F355: +sub:e:4096:1:F1A6668FBB7D572E:1559247338:1622319338:::::s::::::23: +fpr:::::::::097B313077AE62A02F84DA4DF1A6668FBB7D572E: +""".strip() + +GPG_LIST_SECRET_KEY_OUTPUT = """ +sec:u:2048:1:58FCCBCC131FCCAB:1406639814:::u:::scSC:::+:::23::0: +fpr:::::::::AC0F357BE07F1493C34DCAB258FCCBCC131FCCAB: +grp:::::::::C1227FFDD039AD942F777EA0639E1F1EAA96AB12: +uid:u::::1406639814::79311EDEA01302E0DBBB2F33AE799F8BB677652F::Pacman Keyring Master Key ::::::::::0: +""".lstrip() + +GPG_CHECK_SIGNATURES_OUTPUT = """ +tru::1:1742507906:1750096255:3:1:5 +pub:f:4096:1:61B7B526D98F0353:1437155332:::-:::scSC::::::23:1742507897:1 https\x3a//185.125.188.26\x3a443: +fpr:::::::::14F26682D0916CDD81E37B6D61B7B526D98F0353: +uid:f::::1437155332::E57D1F9BFF3B404F9F30333629369B08DF5E2161::Mozilla Software Releases :::::::::1742507897:1: +sig:!::1:61B7B526D98F0353:1437155332::::Mozilla Software Releases :13x:::::2: +sig:!::1:58FCCBCC131FCCAB:1742507905::::Pacman Keyring Master Key :10l::AC0F357BE07F1493C34DCAB258FCCBCC131FCCAB:::8: +sub:f:4096:1:E36D3B13F3D93274:1683308659:1746380659:::::s::::::23: +fpr:::::::::ADD7079479700DCADFDD5337E36D3B13F3D93274: +sig:!::1:61B7B526D98F0353:1683308659::::Mozilla Software Releases :18x::14F26682D0916CDD81E37B6D61B7B526D98F0353:::10: +sub:e:4096:1:1C69C4E55E9905DB:1437155572:1500227572:::::s::::::23: +fpr:::::::::F2EF4E6E6AE75B95F11F1EB51C69C4E55E9905DB: +sig:!::1:61B7B526D98F0353:1437155572::::Mozilla Software Releases :18x:::::2: +sub:e:4096:1:BBBEBDBB24C6F355:1498143157:1561215157:::::s::::::23: +fpr:::::::::DCEAC5D96135B91C4EA672ABBBBEBDBB24C6F355: +sig:!::1:61B7B526D98F0353:1498143157::::Mozilla Software Releases :18x::14F26682D0916CDD81E37B6D61B7B526D98F0353:::8: +sub:e:4096:1:F1A6668FBB7D572E:1559247338:1622319338:::::s::::::23: +fpr:::::::::097B313077AE62A02F84DA4DF1A6668FBB7D572E: +sig:!::1:61B7B526D98F0353:1559247338::::Mozilla Software Releases :18x::14F26682D0916CDD81E37B6D61B7B526D98F0353:::10: +sub:e:4096:1:EBE41E90F6F12F6D:1621282261:1684354261:::::s::::::23: +fpr:::::::::4360FE2109C49763186F8E21EBE41E90F6F12F6D: +sig:!::1:61B7B526D98F0353:1621282261::::Mozilla Software Releases :18x::14F26682D0916CDD81E37B6D61B7B526D98F0353:::10: +""".strip() # gpg --{show,list}-key output (key absent) -GPG_NOKEY_OUTPUT = '''gpg: error reading key: No public key -tru::1:1616373715:0:3:1:5''' +GPG_NOKEY_OUTPUT = """ +gpg: error reading key: No public key +tru::1:1616373715:0:3:1:5 +""".strip() # pacman-key output (successful invocation) -PACMAN_KEY_SUCCESS = '''==> Updating trust database... -gpg: next trustdb check due at 2021-08-02''' +PACMAN_KEY_SUCCESS = """ +==> Updating trust database... +gpg: next trustdb check due at 2021-08-02 +""".strip() # expected command for gpg --list-keys KEYID RUN_CMD_LISTKEYS = [ MOCK_BIN_PATH, + '--homedir=/etc/pacman.d/gnupg', + '--no-permission-warning', '--with-colons', + '--quiet', '--batch', '--no-tty', '--no-default-keyring', - '--keyring=/etc/pacman.d/gnupg/pubring.gpg', '--list-keys', TESTING_KEYID, ] @@ -52,10 +105,12 @@ RUN_CMD_LISTKEYS = [ # expected command for gpg --show-keys KEYFILE RUN_CMD_SHOW_KEYFILE = [ MOCK_BIN_PATH, + '--no-permission-warning', '--with-colons', - '--with-fingerprint', + '--quiet', '--batch', '--no-tty', + '--with-fingerprint', '--show-keys', TESTING_KEYFILE_PATH, ] @@ -69,6 +124,29 @@ RUN_CMD_LSIGN_KEY = [ TESTING_KEYID, ] +RUN_CMD_LIST_SECRET_KEY = [ + MOCK_BIN_PATH, + '--homedir=/etc/pacman.d/gnupg', + '--no-permission-warning', + '--with-colons', + '--quiet', + '--batch', + '--no-tty', + '--list-secret-key', +] + +# expected command for gpg --check-signatures +RUN_CMD_CHECK_SIGNATURES = [ + MOCK_BIN_PATH, + '--homedir=/etc/pacman.d/gnupg', + '--no-permission-warning', + '--with-colons', + '--quiet', + '--batch', + '--no-tty', + '--check-signatures', + TESTING_KEYID, +] TESTCASES = [ # @@ -152,7 +230,7 @@ TESTCASES = [ {'check_rc': False}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, '', ), ), @@ -222,7 +300,7 @@ TESTCASES = [ {'check_rc': False}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, '', ), ), @@ -248,7 +326,77 @@ TESTCASES = [ {'check_rc': False}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, + '', + ), + ), + ], + 'changed': False, + }, + ], + # state trusted & key expired + [ + { + 'state': 'trusted', + 'id': TESTING_KEYID, + 'data': 'FAKEDATA', + '_ansible_check_mode': True, + }, + { + 'id': 'state_trusted_key_expired', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + { + 'check_rc': False, + }, + ( + 0, + GPG_SHOWKEY_OUTPUT_EXPIRED, + '', + ), + ), + ], + 'changed': True, + }, + ], + # state & key trusted + [ + { + 'state': 'trusted', + 'id': TESTING_KEYID, + 'data': 'FAKEDATA', + '_ansible_check_mode': True, + }, + { + 'id': 'state_and_key_trusted', + 'run_command.calls': [ + ( + RUN_CMD_LISTKEYS, + { + 'check_rc': False, + }, + ( + 0, + GPG_SHOWKEY_OUTPUT_TRUSTED, + '', + ), + ), + ( + RUN_CMD_CHECK_SIGNATURES, + {}, + ( + 0, + GPG_CHECK_SIGNATURES_OUTPUT, + '', + ), + ), + ( + RUN_CMD_LIST_SECRET_KEY, + {}, + ( + 0, + GPG_LIST_SECRET_KEY_OUTPUT, '', ), ), @@ -270,7 +418,7 @@ TESTCASES = [ {'check_rc': False}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, '', ), ), @@ -339,7 +487,7 @@ TESTCASES = [ {'check_rc': True}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, '', ), ), @@ -397,7 +545,7 @@ TESTCASES = [ {'check_rc': True}, ( 0, - GPG_SHOWKEY_OUTPUT.replace('61B7B526D98F0353', '61B7B526D98F0354'), + GPG_SHOWKEY_OUTPUT_EXPIRED.replace('61B7B526D98F0353', '61B7B526D98F0354'), '', ), ), @@ -485,7 +633,7 @@ gpg: imported: 1 {'check_rc': True}, ( 0, - GPG_SHOWKEY_OUTPUT, + GPG_SHOWKEY_OUTPUT_EXPIRED, '', ), ),