Skip to content

Commit

Permalink
Add gpg_fingerprint lookup and filter (#639)
Browse files Browse the repository at this point in the history
* Add gpg_fingerprint lookup.

* Work around problems on some CI targets.

* Use get_bin_path to find the gpg executable. Document that we need it.

* Improve and test error handling.

* Refactor (potentially) common code to module_utils and plugin_utils.

This will be useful to create a filter version of this, and further lookups, filters, and modules.

* Do not create a keyring when there isn't one.

* Fixups.

* Fix description.

* More fixes for lookup.

* Also add a gpg_fingerprint filter.

* Improve formulation.

Co-authored-by: Sandra McCann <samccann@redhat.com>

---------

Co-authored-by: Sandra McCann <samccann@redhat.com>
  • Loading branch information
felixfontein and samccann authored Aug 2, 2023
1 parent 5e630ff commit ba456c5
Show file tree
Hide file tree
Showing 20 changed files with 545 additions and 0 deletions.
68 changes: 68 additions & 0 deletions plugins/filter/gpg_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# 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,
}
64 changes: 64 additions & 0 deletions plugins/lookup/gpg_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# 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 file
author: Felix Fontein (@felixfontein)
version_added: 2.15.0
description:
- "Takes a list of filenames pointing to GPG public or private key files. Returns the fingerprints for each of these keys."
options:
_terms:
description:
- A path to a GPG public or private key.
type: list
elements: path
required: true
requirements:
- GnuPG (C(gpg) executable)
seealso:
- plugin: community.crypto.gpg_fingerprint
plugin_type: filter
"""

EXAMPLES = """
- name: Show fingerprint of GPG public key
ansible.builtin.debug:
msg: "{{ lookup('community.crypto.gpg_fingerprint', '/path/to/public_key.gpg') }}"
"""

RETURN = """
_value:
description:
- The fingerprints of the provided public or private GPG keys.
- The list has one entry for every path provided.
type: list
elements: string
"""

from ansible.plugins.lookup import LookupBase
from ansible.errors import AnsibleLookupError
from ansible.module_utils.common.text.converters import to_native

from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import GPGError, get_fingerprint_from_file
from ansible_collections.community.crypto.plugins.plugin_utils.gnupg import PluginGPGRunner


class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
self.set_options(direct=kwargs)

try:
gpg = PluginGPGRunner(cwd=self._loader.get_basedir())
result = []
for path in terms:
result.append(get_fingerprint_from_file(gpg, path))
return result
except GPGError as exc:
raise AnsibleLookupError(to_native(exc))
64 changes: 64 additions & 0 deletions plugins/module_utils/gnupg/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# 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

import abc
import os

from ansible.module_utils import six


class GPGError(Exception):
pass


@six.add_metaclass(abc.ABCMeta)
class GPGRunner(object):
@abc.abstractmethod
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.
Raises a ``GPGError`` in case of errors.
"""
pass


def get_fingerprint_from_stdout(stdout):
lines = stdout.splitlines(False)
for line in lines:
if line.startswith('fpr:'):
parts = line.split(':')
if len(parts) <= 9 or not parts[9]:
raise GPGError('Result line "{line}" does not have fingerprint as 10th component'.format(line=line))
return parts[9]
raise GPGError('Cannot extract fingerprint from stdout "{stdout}"'.format(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]
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)
51 changes: 51 additions & 0 deletions plugins/plugin_utils/gnupg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2023, Felix Fontein <felix@fontein.de>
# 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

from subprocess import Popen, PIPE

from ansible.module_utils.common.process import get_bin_path
from ansible.module_utils.common.text.converters import to_native

from ansible_collections.community.crypto.plugins.module_utils.gnupg.cli import GPGError, GPGRunner


class PluginGPGRunner(GPGRunner):
def __init__(self, executable=None, cwd=None):
if executable is None:
try:
executable = get_bin_path('gpg')
except ValueError as e:
raise GPGError('Cannot find the `gpg` executable on the controller')
self.executable = executable
self.cwd = cwd

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.
Raises a ``GPGError`` in case of errors.
"""
command = [self.executable] + command
p = Popen(command, shell=False, cwd=self.cwd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
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:
raise GPGError('Running {cmd} yielded return code {rc} with stdout: "{stdout}" and stderr: "{stderr}")'.format(
cmd=' '.join(command),
rc=p.returncode,
stdout=to_native(stdout, errors='surrogate_or_replace'),
stderr=to_native(stderr, errors='surrogate_or_replace'),
))
return p.returncode, stdout, stderr
6 changes: 6 additions & 0 deletions tests/integration/targets/filter_gpg_fingerprint/aliases
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions tests/integration/targets/filter_gpg_fingerprint/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions tests/integration/targets/lookup_gpg_fingerprint/aliases
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit ba456c5

Please sign in to comment.