diff --git a/plugins/filter/gpg_fingerprint.py b/plugins/filter/gpg_fingerprint.py new file mode 100644 index 000000000..bd9d21ecb --- /dev/null +++ b/plugins/filter/gpg_fingerprint.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023, Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = """ +name: gpg_fingerprint +short_description: Retrieve a GPG fingerprint from a GPG public or private key +author: Felix Fontein (@felixfontein) +version_added: 2.15.0 +description: + - "Takes the content of a private or public GPG key as input and returns its fingerprint." +options: + _input: + description: + - The content of a GPG public or private key. + type: string + required: true +requirements: + - GnuPG (C(gpg) executable) +seealso: + - plugin: community.crypto.gpg_fingerprint + plugin_type: lookup +""" + +EXAMPLES = """ +- name: Show fingerprint of GPG public key + ansible.builtin.debug: + msg: "{{ lookup('file', '/path/to/public_key.gpg') | community.crypto.gpg_fingerprint }}" +""" + +RETURN = """ + _value: + description: + - The fingerprint of the provided public or private GPG key. + type: string +""" + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common.text.converters import to_bytes, to_native +from ansible.module_utils.six import string_types + +from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import GPGError, get_fingerprint_from_bytes +from ansible_collections.community.crypto.plugins.plugin_utils.gnupg import PluginGPGRunner + + +def gpg_fingerprint(input): + if not isinstance(input, string_types): + raise AnsibleFilterError( + 'The input for the community.crypto.gpg_fingerprint filter must be a string; got {type} instead'.format(type=type(input)) + ) + try: + gpg = PluginGPGRunner() + return get_fingerprint_from_bytes(gpg, to_bytes(input)) + except GPGError as exc: + raise AnsibleFilterError(to_native(exc)) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'gpg_fingerprint': gpg_fingerprint, + } diff --git a/plugins/lookup/gpg_fingerprint.py b/plugins/lookup/gpg_fingerprint.py index 50dd1f6dc..e6a8fec69 100644 --- a/plugins/lookup/gpg_fingerprint.py +++ b/plugins/lookup/gpg_fingerprint.py @@ -22,6 +22,9 @@ required: true requirements: - GnuPG (C(gpg) executable) +seealso: + - plugin: community.crypto.gpg_fingerprint + plugin_type: filter """ EXAMPLES = """ diff --git a/plugins/module_utils/gnupg/cli.py b/plugins/module_utils/gnupg/cli.py index 4f709753c..caf0de25f 100644 --- a/plugins/module_utils/gnupg/cli.py +++ b/plugins/module_utils/gnupg/cli.py @@ -19,10 +19,13 @@ class GPGError(Exception): @six.add_metaclass(abc.ABCMeta) class GPGRunner(object): @abc.abstractmethod - def run_command(self, command, check_rc=True): + def run_command(self, command, check_rc=True, data=None): """ Run ``[gpg] + command`` and return ``(rc, stdout, stderr)``. + If ``data`` is not ``None``, it will be provided as stdin. + The code assumes it is a bytes string. + Returned stdout and stderr are native Python strings. Pass ``check_rc=False`` to allow return codes != 0. @@ -45,5 +48,17 @@ def get_fingerprint_from_stdout(stdout): def get_fingerprint_from_file(gpg_runner, path): if not os.path.exists(path): raise GPGError('{path} does not exist'.format(path=path)) - stdout = gpg_runner.run_command(['--no-keyring', '--with-colons', '--import-options', 'show-only', '--import', path], check_rc=True)[1] + stdout = gpg_runner.run_command( + ['--no-keyring', '--with-colons', '--import-options', 'show-only', '--import', path], + check_rc=True, + )[1] + return get_fingerprint_from_stdout(stdout) + + +def get_fingerprint_from_bytes(gpg_runner, content): + stdout = gpg_runner.run_command( + ['--no-keyring', '--with-colons', '--import-options', 'show-only', '--import', '/dev/stdin'], + data=content, + check_rc=True, + )[1] return get_fingerprint_from_stdout(stdout) diff --git a/plugins/plugin_utils/gnupg.py b/plugins/plugin_utils/gnupg.py index 3c10e3abf..0cd715bf0 100644 --- a/plugins/plugin_utils/gnupg.py +++ b/plugins/plugin_utils/gnupg.py @@ -24,10 +24,13 @@ def __init__(self, executable=None, cwd=None): self.executable = executable self.cwd = cwd - def run_command(self, command, check_rc=True): + def run_command(self, command, check_rc=True, data=None): """ Run ``[gpg] + command`` and return ``(rc, stdout, stderr)``. + If ``data`` is not ``None``, it will be provided as stdin. + The code assumes it is a bytes string. + Returned stdout and stderr are native Python strings. Pass ``check_rc=False`` to allow return codes != 0. @@ -35,7 +38,7 @@ def run_command(self, command, check_rc=True): """ command = [self.executable] + command p = Popen(command, shell=False, cwd=self.cwd, stdin=PIPE, stdout=PIPE, stderr=PIPE) - stdout, stderr = p.communicate() + stdout, stderr = p.communicate(input=data) stdout = to_native(stdout, errors='surrogate_or_replace') stderr = to_native(stderr, errors='surrogate_or_replace') if check_rc and p.returncode != 0: diff --git a/tests/integration/targets/filter_gpg_fingerprint/aliases b/tests/integration/targets/filter_gpg_fingerprint/aliases new file mode 100644 index 000000000..326a499c3 --- /dev/null +++ b/tests/integration/targets/filter_gpg_fingerprint/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 +destructive diff --git a/tests/integration/targets/filter_gpg_fingerprint/meta/main.yml b/tests/integration/targets/filter_gpg_fingerprint/meta/main.yml new file mode 100644 index 000000000..398d0cf6c --- /dev/null +++ b/tests/integration/targets/filter_gpg_fingerprint/meta/main.yml @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - prepare_jinja2_compat + - setup_remote_tmp_dir + - setup_gnupg diff --git a/tests/integration/targets/filter_gpg_fingerprint/tasks/main.yml b/tests/integration/targets/filter_gpg_fingerprint/tasks/main.yml new file mode 100644 index 000000000..071b490fd --- /dev/null +++ b/tests/integration/targets/filter_gpg_fingerprint/tasks/main.yml @@ -0,0 +1,80 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Run tests if GPG is available + when: has_gnupg + block: + - name: Create GPG key + ansible.builtin.command: + cmd: gpg --homedir "{{ remote_tmp_dir }}" --batch --generate-key + stdin: | + %echo Generating a basic OpenPGP key + %no-ask-passphrase + %no-protection + Key-Type: RSA + Key-Length: 4096 + Name-Real: Foo Bar + Name-Email: foo@bar.com + Expire-Date: 0 + %commit + %echo done + register: result + + - name: Extract fingerprint + ansible.builtin.shell: gpg --homedir "{{ remote_tmp_dir }}" --with-colons --fingerprint foo@bar.com | grep '^fpr:' + register: fingerprints + + - name: Show fingerprints + ansible.builtin.debug: + msg: "{{ fingerprints.stdout_lines | map('split', ':') | list }}" + + - name: Export public key + ansible.builtin.command: gpg --homedir "{{ remote_tmp_dir }}" --export --armor foo@bar.com + register: public_key + + - name: Export private key + ansible.builtin.command: gpg --homedir "{{ remote_tmp_dir }}" --export-secret-key --armor foo@bar.com + register: private_key + + - name: Gather fingerprints + ansible.builtin.set_fact: + public_key_fingerprint: "{{ public_key.stdout | community.crypto.gpg_fingerprint }}" + private_key_fingerprint: "{{ private_key.stdout | community.crypto.gpg_fingerprint }}" + + - name: Check whether fingerprints match + ansible.builtin.assert: + that: + - public_key_fingerprint == (fingerprints.stdout_lines[0] | split(':'))[9] + - private_key_fingerprint == (fingerprints.stdout_lines[0] | split(':'))[9] + + - name: Error scenario - wrong input type + ansible.builtin.set_fact: + failing_result: "{{ 42 | community.crypto.gpg_fingerprint }}" + register: result + ignore_errors: true + + - name: Check result + ansible.builtin.assert: + that: + - result is failed + - >- + 'The input for the community.crypto.gpg_fingerprint filter must be a string; got ' in result.msg + - >- + 'int' in result.msg + + - name: Error scenario - garbage input + ansible.builtin.set_fact: + failing_result: "{{ 'garbage' | community.crypto.gpg_fingerprint }}" + register: result + ignore_errors: true + + - name: Check result + ansible.builtin.assert: + that: + - result is failed + - >- + 'Running ' in result.msg + - >- + ('/gpg --no-keyring --with-colons --import-options show-only --import /dev/stdin yielded return code ') in result.msg