mirror of https://github.com/ansible/ansible.git
ansible-galaxy - add signature verification of the MANIFEST.json (#76681)
* ansible-galaxy collection install|verify: - Support verifying the origin of the MANIFEST.json when the Galaxy server has provided signatures. - Allow supplemental signatures to use during verification on the CLI/requirements file. * ansible-galaxy collection install: - Support disabling signature verification. This silences the warning provided by ansible-galaxy if the Galaxy server provided signatures it cannot use because no keyring is configured. - Store Galaxy server metadata alongside installed collections for provenance. This is used by 'ansible-galaxy collection verify --offline'. * Add unit tests for method that gets signatures from a Galaxy server * Add integration tests for user-provided signature sources - Test CLI option combinations - Test installing collections with valid/invalid signature sources - Test disabling GPG verification when installing collections - Test verifying collections with valid/invalid signature sources * Make signature verification advisory-by-default if signatures are provided by the Galaxy server - Make the default keyring None - Warn if the keyring is None but the Galaxy server provided signatures - Error if the keyring is None but the user supplied signatures - Error if the keyring is not None but is invalid * changelog * add ansible-galaxy user documentation for new options Co-authored-by: Matt Martz <matt@sivel.net> Co-authored-by: Sviatoslav Sydorenko <wk.cvs.github@sydorenko.org.ua> Co-authored-by: Martin Krizek <martin.krizek@gmail.com> Co-authored-by: Sandra McCann <samccann@redhat.com> Co-authored-by: Andy Mott <amott@redhat.com> Co-authored-by: John R Barker <john@johnrbarker.com>pull/77016/head
parent
d35bef68f5
commit
43e55db208
@ -0,0 +1,24 @@
|
||||
minor_changes:
|
||||
- >-
|
||||
``ansible-galaxy collection [install|verify]`` - use gpg to verify the authenticity of
|
||||
the signed ``MANIFEST.json`` with ASCII armored detached signatures provided by the Galaxy
|
||||
server. The keyring (which is not managed by ``ansible-galaxy``) must be provided with
|
||||
the ``--keyring`` option to use signature verification.
|
||||
If no ``--keyring`` is specified and the collection to ``install|verify`` has associated
|
||||
detached signatures on the Galaxy server, a warning is provided.
|
||||
- >-
|
||||
``ansible-galaxy collection [install|verify]`` - allow user-provided signature sources
|
||||
in addition to those from the Galaxy server.
|
||||
Each collection entry in a requirements file can specify a ``signatures`` key followed by
|
||||
a list of sources.
|
||||
Collection name(s) provided on the CLI can specify additional signature sources by using
|
||||
the ``--signatures`` CLI option.
|
||||
Signature sources should be URIs that can be opened with ``urllib.request.urlopen()``, such as
|
||||
"https://example.com/path/to/detached_signature.asc" or "file:///path/to/detached_signature.asc".
|
||||
The ``--keyring`` option must be specified if signature sources are provided.
|
||||
- >-
|
||||
``ansible-galaxy collection install`` - Store Galaxy server metadata alongside installed
|
||||
collections for provenance. Signatures obtained from the Galaxy server can be used for offline
|
||||
verification with ``ansible-galaxy collection verify --offline``.
|
||||
- >-
|
||||
``ansible-galaxy collection install`` - Add a global toggle to turn off GPG signature verification.
|
||||
@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2022, Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""Signature verification helpers."""
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.galaxy.user_agent import user_agent
|
||||
from ansible.module_utils.urls import open_url
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from dataclasses import dataclass, fields as dc_fields
|
||||
from functools import partial
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
try:
|
||||
# NOTE: It's in Python 3 stdlib and can be installed on Python 2
|
||||
# NOTE: via `pip install typing`. Unnecessary in runtime.
|
||||
# NOTE: `TYPE_CHECKING` is True during mypy-typecheck-time.
|
||||
from typing import TYPE_CHECKING
|
||||
except ImportError:
|
||||
TYPE_CHECKING = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ansible.utils.display import Display
|
||||
from typing import Tuple, Iterator, Optional
|
||||
|
||||
|
||||
IS_PY310_PLUS = sys.version_info[:2] >= (3, 10)
|
||||
|
||||
frozen_dataclass = partial(dataclass, frozen=True, **({'slots': True} if IS_PY310_PLUS else {}))
|
||||
|
||||
|
||||
def get_signature_from_source(source, display=None): # type: (str, Optional[Display]) -> str
|
||||
if display is not None:
|
||||
display.vvvv(f"Using signature at {source}")
|
||||
try:
|
||||
with open_url(
|
||||
source,
|
||||
http_agent=user_agent(),
|
||||
validate_certs=True,
|
||||
follow_redirects='safe'
|
||||
) as resp:
|
||||
signature = resp.read()
|
||||
except (HTTPError, URLError) as e:
|
||||
raise AnsibleError(
|
||||
f"Failed to get signature for collection verification from '{source}': {e}"
|
||||
) from e
|
||||
|
||||
return signature
|
||||
|
||||
|
||||
def run_gpg_verify(
|
||||
manifest_file, # type: str
|
||||
signature, # type: str
|
||||
keyring, # type: str
|
||||
display, # type: Display
|
||||
): # type: (...) -> Tuple[str, int]
|
||||
status_fd_read, status_fd_write = os.pipe()
|
||||
|
||||
# running the gpg command will create the keyring if it does not exist
|
||||
remove_keybox = not os.path.exists(keyring)
|
||||
|
||||
cmd = [
|
||||
'gpg',
|
||||
f'--status-fd={status_fd_write}',
|
||||
'--verify',
|
||||
'--batch',
|
||||
'--no-tty',
|
||||
'--no-default-keyring',
|
||||
f'--keyring={keyring}',
|
||||
'-',
|
||||
manifest_file,
|
||||
]
|
||||
cmd_str = ' '.join(cmd)
|
||||
display.vvvv(f"Running command '{cmd}'")
|
||||
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
pass_fds=(status_fd_write,),
|
||||
encoding='utf8',
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError) as err:
|
||||
raise AnsibleError(
|
||||
f"Failed during GnuPG verification with command '{cmd_str}': {err}"
|
||||
) from err
|
||||
else:
|
||||
stdout, stderr = p.communicate(input=signature)
|
||||
finally:
|
||||
os.close(status_fd_write)
|
||||
|
||||
if remove_keybox:
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(keyring)
|
||||
|
||||
with os.fdopen(status_fd_read) as f:
|
||||
stdout = f.read()
|
||||
display.vvvv(
|
||||
f"stdout: \n{stdout}\nstderr: \n{stderr}\n(exit code {p.returncode})"
|
||||
)
|
||||
return stdout, p.returncode
|
||||
|
||||
|
||||
def parse_gpg_errors(status_out): # type: (str) -> Iterator[GpgBaseError]
|
||||
for line in status_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
_dummy, status, remainder = line.split(maxsplit=2)
|
||||
except ValueError:
|
||||
_dummy, status = line.split(maxsplit=1)
|
||||
remainder = None
|
||||
|
||||
try:
|
||||
cls = GPG_ERROR_MAP[status]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
fields = [status]
|
||||
if remainder:
|
||||
fields.extend(
|
||||
remainder.split(
|
||||
None,
|
||||
len(dc_fields(cls)) - 2
|
||||
)
|
||||
)
|
||||
|
||||
yield cls(*fields)
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgBaseError(Exception):
|
||||
status: str
|
||||
|
||||
@classmethod
|
||||
def get_gpg_error_description(cls) -> str:
|
||||
"""Return the current class description."""
|
||||
return ' '.join(cls.__doc__.split())
|
||||
|
||||
def __post_init__(self):
|
||||
for field in dc_fields(self):
|
||||
super(GpgBaseError, self).__setattr__(field.name, field.type(getattr(self, field.name)))
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgExpSig(GpgBaseError):
|
||||
"""The signature with the keyid is good, but the signature is expired."""
|
||||
keyid: str
|
||||
username: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgExpKeySig(GpgBaseError):
|
||||
"""The signature with the keyid is good, but the signature was made by an expired key."""
|
||||
keyid: str
|
||||
username: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgRevKeySig(GpgBaseError):
|
||||
"""The signature with the keyid is good, but the signature was made by a revoked key."""
|
||||
keyid: str
|
||||
username: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgBadSig(GpgBaseError):
|
||||
"""The signature with the keyid has not been verified okay."""
|
||||
keyid: str
|
||||
username: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgErrSig(GpgBaseError):
|
||||
""""It was not possible to check the signature. This may be caused by
|
||||
a missing public key or an unsupported algorithm. A RC of 4
|
||||
indicates unknown algorithm, a 9 indicates a missing public
|
||||
key.
|
||||
"""
|
||||
keyid: str
|
||||
pkalgo: int
|
||||
hashalgo: int
|
||||
sig_class: str
|
||||
time: int
|
||||
rc: int
|
||||
fpr: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgNoPubkey(GpgBaseError):
|
||||
"""The public key is not available."""
|
||||
keyid: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgMissingPassPhrase(GpgBaseError):
|
||||
"""No passphrase was supplied."""
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgBadPassphrase(GpgBaseError):
|
||||
"""The supplied passphrase was wrong or not given."""
|
||||
keyid: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgNoData(GpgBaseError):
|
||||
"""No data has been found. Codes for WHAT are:
|
||||
- 1 :: No armored data.
|
||||
- 2 :: Expected a packet but did not find one.
|
||||
- 3 :: Invalid packet found, this may indicate a non OpenPGP
|
||||
message.
|
||||
- 4 :: Signature expected but not found.
|
||||
"""
|
||||
what: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgUnexpected(GpgBaseError):
|
||||
"""No data has been found. Codes for WHAT are:
|
||||
- 1 :: No armored data.
|
||||
- 2 :: Expected a packet but did not find one.
|
||||
- 3 :: Invalid packet found, this may indicate a non OpenPGP
|
||||
message.
|
||||
- 4 :: Signature expected but not found.
|
||||
"""
|
||||
what: str
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgError(GpgBaseError):
|
||||
"""This is a generic error status message, it might be followed by error location specific data."""
|
||||
location: str
|
||||
code: int
|
||||
more: str = ""
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgFailure(GpgBaseError):
|
||||
"""This is the counterpart to SUCCESS and used to indicate a program failure."""
|
||||
location: str
|
||||
code: int
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgBadArmor(GpgBaseError):
|
||||
"""The ASCII armor is corrupted."""
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgKeyExpired(GpgBaseError):
|
||||
"""The key has expired."""
|
||||
timestamp: int
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgKeyRevoked(GpgBaseError):
|
||||
"""The used key has been revoked by its owner."""
|
||||
|
||||
|
||||
@frozen_dataclass
|
||||
class GpgNoSecKey(GpgBaseError):
|
||||
"""The secret key is not available."""
|
||||
keyid: str
|
||||
|
||||
|
||||
GPG_ERROR_MAP = {
|
||||
'EXPSIG': GpgExpSig,
|
||||
'EXPKEYSIG': GpgExpKeySig,
|
||||
'REVKEYSIG': GpgRevKeySig,
|
||||
'BADSIG': GpgBadSig,
|
||||
'ERRSIG': GpgErrSig,
|
||||
'NO_PUBKEY': GpgNoPubkey,
|
||||
'MISSING_PASSPHRASE': GpgMissingPassPhrase,
|
||||
'BAD_PASSPHRASE': GpgBadPassphrase,
|
||||
'NODATA': GpgNoData,
|
||||
'UNEXPECTED': GpgUnexpected,
|
||||
'ERROR': GpgError,
|
||||
'FAILURE': GpgFailure,
|
||||
'BADARMOR': GpgBadArmor,
|
||||
'KEYEXPIRED': GpgKeyExpired,
|
||||
'KEYREVOKED': GpgKeyRevoked,
|
||||
'NO_SECKEY': GpgNoSecKey,
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
- name: generate revocation certificate
|
||||
expect:
|
||||
command: "gpg --homedir {{ gpg_homedir }} --output {{ gpg_homedir }}/revoke.asc --gen-revoke {{ fingerprint }}"
|
||||
responses:
|
||||
"Create a revocation certificate for this key": "y"
|
||||
"Please select the reason for the revocation": "0"
|
||||
"Enter an optional description": ""
|
||||
"Is this okay": "y"
|
||||
|
||||
- name: revoke key
|
||||
command: "gpg --no-tty --homedir {{ gpg_homedir }} --import {{ gpg_homedir }}/revoke.asc"
|
||||
|
||||
- name: list keys for debugging
|
||||
command: "gpg --no-tty --homedir {{ gpg_homedir }} --list-keys {{ gpg_user }}"
|
||||
@ -0,0 +1,24 @@
|
||||
- name: create empty gpg homedir
|
||||
file:
|
||||
state: "{{ item }}"
|
||||
path: "{{ gpg_homedir }}"
|
||||
mode: 0700
|
||||
loop:
|
||||
- absent
|
||||
- directory
|
||||
|
||||
- name: get username for generating key
|
||||
command: whoami
|
||||
register: user
|
||||
|
||||
- name: generate key for user with gpg
|
||||
command: "gpg --no-tty --homedir {{ gpg_homedir }} --passphrase SECRET --pinentry-mode loopback --quick-gen-key {{ user.stdout }} default default"
|
||||
|
||||
- name: list gpg keys for user
|
||||
command: "gpg --no-tty --homedir {{ gpg_homedir }} --list-keys {{ user.stdout }}"
|
||||
register: gpg_list_keys
|
||||
|
||||
- name: save gpg user and fingerprint of new key
|
||||
set_fact:
|
||||
gpg_user: "{{ user.stdout }}"
|
||||
fingerprint: "{{ gpg_list_keys.stdout_lines[1] | trim }}"
|
||||
Loading…
Reference in New Issue