diff --git a/changelogs/fragments/85700-deb822-repository-signed-by-multiple-keys.yml b/changelogs/fragments/85700-deb822-repository-signed-by-multiple-keys.yml new file mode 100644 index 00000000000..8d104a0b90c --- /dev/null +++ b/changelogs/fragments/85700-deb822-repository-signed-by-multiple-keys.yml @@ -0,0 +1,2 @@ +minor_changes: +- deb822_repository - The signed_by parameter now supports multiple keys. Input changed from string to list of strings. (https://github.com/ansible/ansible/issues/85700) diff --git a/lib/ansible/modules/deb822_repository.py b/lib/ansible/modules/deb822_repository.py index c41cd6e6360..24df1b3d29d 100644 --- a/lib/ansible/modules/deb822_repository.py +++ b/lib/ansible/modules/deb822_repository.py @@ -113,11 +113,14 @@ options: type: bool signed_by: description: - - Either a URL to a GPG key, absolute path to a keyring file, one or - more fingerprints of keys either in the C(trusted.gpg) keyring or in - the keyrings in the C(trusted.gpg.d/) directory, or an ASCII armored - GPG public key block. - type: str + - A string or list of strings that can be one of the below. + - a URL to a GPG key + - the absolute path to a keyring file + - one or more fingerprints of keys either in the C(trusted.gpg) keyring or in + the keyrings in the C(trusted.gpg.d/) directory + - An ASCII armored GPG public key block. + type: list + elements: str suites: description: - >- @@ -216,6 +219,18 @@ EXAMPLES = """ components: stable architectures: amd64 signed_by: https://download.example.com/linux/ubuntu/gpg + +- name: Add repo using multiple keys + deb822_repository: + name: example + types: deb + uris: https://download.example.com/linux/ubuntu + suites: '{{ ansible_distribution_release }}' + components: stable + architectures: amd64 + signed_by: + - https://download.example.com/linux/ubuntu/gpg + - https://download.example-two.com/linux/ubuntu/gpg """ RETURN = """ @@ -342,10 +357,164 @@ def is_armored(b_data): return b'-----BEGIN PGP PUBLIC KEY BLOCK-----' in b_data +PGP_BLOCK_ONLY_RE = re.compile( + r""" + \A + -----BEGIN\ PGP\ PUBLIC\ KEY\ BLOCK-----\n + (?:[A-Za-z0-9+/= ]*\n)* # base64 and armor headers + -----END\ PGP\ PUBLIC\ KEY\ BLOCK-----\n? + \Z + """, + re.VERBOSE, +) + + +def is_only_one_armored_block(value: str) -> bool: + return bool(PGP_BLOCK_ONLY_RE.match(value)) + + +def contains_exactly_one_public_key(module, value: str) -> bool: + rc, stdout, stderr = module.run_command( + ["gpg", "--batch", "--with-colons", "--show-keys"], + data=value, + ) + + if rc != 0: + return False + + pubs = [l for l in stdout.splitlines() if l.startswith("pub:")] + return len(pubs) == 1 + + +def is_strict_single_inline_pgp_key(module, value: str) -> bool: + value = value.strip("\n") + return ( + is_only_one_armored_block(value) + and contains_exactly_one_public_key(module, value) + ) + + +# GPG fingerprints are 40 hex characters, optionally with spaces +FINGERPRINT_RE = re.compile(r'^[A-Fa-f0-9]{4}(\s?[A-Fa-f0-9]{4}){9}$') + + +def is_fingerprint(value: str) -> bool: + """ + Check if a value is a GPG key fingerprint. + + Fingerprints are 40 hex characters, optionally separated by spaces. + Examples: + - "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2" + - "A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2 C3D4 E5F6 A1B2" + """ + return bool(FINGERPRINT_RE.match(value.strip())) + + +def dearmor_key(module, b_data): + """ + Convert ASCII-armored key to binary format using gpg --dearmor. + Returns binary key data. + """ + rc, stdout, stderr = module.run_command( + ['gpg', '--batch', '--yes', '--dearmor'], + data=b_data, + binary_data=True, + encoding=None, + ) + if rc != 0: + raise RuntimeError(f'Failed to dearmor key: {to_native(stderr)}') + # stdout should be bytes with encoding=None + return stdout + + +def fetch_key_data(module, key): + """ + Fetch key data from URL, file path, or return raw PGP data. + Returns bytes. + """ + # Check if it's a file path + if os.path.isfile(key): + with open(key, 'rb') as f: + return f.read() + + # Check if it's a URL + parts = generic_urlparse(urlparse(key)) + if parts.scheme: + try: + r = open_url(key, http_agent=get_user_agent()) + return r.read() + except Exception as exc: + raise RuntimeError('Could not fetch signed_by key.') from exc + + # Must be raw PGP data + return to_bytes(key) + + def write_signed_by_key(module, v, slug): + """ + Process signing keys from a list. + v: list of keys (URLs, file paths, fingerprints, or raw key data) + Returns: (changed, signed_by_filename, signed_by_data, fingerprints) + """ + changed = False + + # Separate fingerprints from other keys (URLs, files, PGP blocks) + fingerprints = [] + other_keys = [] + for key in v: + key = key.strip() + if is_fingerprint(key): + fingerprints.append(key) + else: + other_keys.append(key) + + # If only fingerprints, return them directly (no file needed) + if not other_keys: + return changed, None, None, fingerprints + + # Handle single inline PGP key ONLY if there are no fingerprints + if len(other_keys) == 1 and not fingerprints and is_only_one_armored_block(other_keys[0]): + return changed, None, other_keys[0], [] + + # For multiple keys, or when mixing with fingerprints, combine into a single file + if len(other_keys) > 1 or fingerprints: + all_key_data = [] + for key in other_keys: + key_data = fetch_key_data(module, key) + # Convert armored keys to binary for consistent format + if is_armored(key_data): + key_data = dearmor_key(module, key_data) + all_key_data.append(key_data) + combined_data = b''.join(all_key_data) + + tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) + with os.fdopen(tmpfd, 'wb') as f: + f.write(combined_data) + + ext = 'gpg' # Keys are always saved as binary .gpg file + filename = make_signed_by_filename(slug, ext) + + src_chksum = module.sha256(tmpfile) + dest_chksum = module.sha256(filename) + + if src_chksum != dest_chksum: + changed |= ensure_keyrings_dir(module) + if not module.check_mode: + module.atomic_move(tmpfile, filename) + changed = True + + changed |= module.set_mode_if_different(filename, S_IRWU_RG_RO, False) + return changed, filename, None, fingerprints + else: + # Single key (URL or file path) with no fingerprints + key_changed, signed_by_filename = process_single_key(module, other_keys[0], slug) + return key_changed, signed_by_filename, None, [] + + +def process_single_key(module, v, slug): changed = False if os.path.isfile(v): - return changed, v, None + return changed, v b_data = None @@ -358,11 +527,11 @@ def write_signed_by_key(module, v, slug): else: b_data = r.read() else: - # Not a file, nor a URL, just pass it through - return changed, None, v + # Not a file, nor a URL, must be a fingerprint, just pass it through + return changed, v if not b_data: - return changed, v, None + return changed, v tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) with os.fdopen(tmpfd, 'wb') as f: @@ -382,7 +551,7 @@ def write_signed_by_key(module, v, slug): changed |= module.set_mode_if_different(filename, S_IRWU_RG_RO, False) - return changed, filename, None + return changed, filename def install_python_debian(module, deb_pkg_name): @@ -462,7 +631,8 @@ def main(): 'type': 'bool', }, 'signed_by': { - 'type': 'str', + 'type': 'list', + 'elements': 'str', }, 'suites': { 'elements': 'str', @@ -616,12 +786,26 @@ def main(): value = format_bool(value) elif isinstance(value, int): value = to_native(value) + elif key == 'signed_by': + key_changed, signed_by_filename, signed_by_data, fingerprints = write_signed_by_key(module, value, slug) + changed |= key_changed + + # Build the Signed-By value + if signed_by_data: + # Single inline PGP key (no fingerprints in this case) + value = signed_by_data + else: + # Combine keyring file path with fingerprints + signed_by_parts = [] + if signed_by_filename: + signed_by_parts.append(signed_by_filename) + signed_by_parts.extend(fingerprints) + value = ' '.join(signed_by_parts) if signed_by_parts else None + + if value is None: + continue elif is_sequence(value): value = format_list(value) - elif key == 'signed_by': - key_changed, signed_by_filename, signed_by_data = write_signed_by_key(module, value, slug) - value = signed_by_filename or signed_by_data - changed |= key_changed if value.count('\n') > 0: value = format_multiline(value) diff --git a/test/integration/targets/deb822_repository/tasks/test.yml b/test/integration/targets/deb822_repository/tasks/test.yml index 94e47e08567..a1ff3f6e324 100644 --- a/test/integration/targets/deb822_repository/tasks/test.yml +++ b/test/integration/targets/deb822_repository/tasks/test.yml @@ -146,7 +146,7 @@ - remove_repos_1 is changed - remove_stats.results|map(attribute='stat')|selectattr('exists') == [] -- name: Add repo with signed_by +- name: Add repo with signed_by single key block deb822_repository: name: ansible-test types: deb @@ -181,6 +181,95 @@ signed_by: https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg register: signed_by_url +- name: Add repo with signed_by containing multiple keys - URLs and fingerprints + deb822_repository: + name: ansible-test + types: deb + uris: https://deb.debian.org + suites: stable + components: + - main + - contrib + - non-free + signed_by: + - https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg + - https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg + - ffffDDDDeeee44449999nnnn2222ddddvvvvzzzz + register: signed_by_multiple_keys + +- name: Add repo with signed_by containing multiple keys - url, filepath, fingerprint + deb822_repository: + name: ansible-test + types: deb + uris: https://deb.debian.org + suites: stable + components: + - main + - contrib + - non-free + signed_by: + - https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg + - /etc/apt/keyrings/ansible-test.gpg + - ffff DDDD eeee 4444 9999 nnnn 2222 dddd vvvv zzzz + register: signed_by_multiple_keys_two + +- name: Add repo with signed_by list of key blocks + deb822_repository: + name: ansible-test + types: deb + uris: https://deb.debian.org + suites: stable + components: + - main + - contrib + - non-free + signed_by: + - | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY + CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk + IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS + dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG + 3bHcln8DMpIJVXht78sL + =IE0r + -----END PGP PUBLIC KEY BLOCK----- + - | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY + CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk + IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS + dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG + 3bHcln8DMpIJVXht78sL + =IE0r + -----END PGP PUBLIC KEY BLOCK----- + register: signed_by_multiple_key_blocks + +- name: Add repo with signed_by as list with keyblock and url + deb822_repository: + name: ansible-test + types: deb + uris: https://deb.debian.org + suites: stable + components: + - main + - contrib + - non-free + signed_by: + - | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mDMEYCQjIxYJKwYBBAHaRw8BAQdAD/P5Nvvnvk66SxBBHDbhRml9ORg1WV5CvzKY + CuMfoIS0BmFiY2RlZoiQBBMWCgA4FiEErCIG1VhKWMWo2yfAREZd5NfO31cFAmAk + IyMCGyMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AACgkQREZd5NfO31fbOwD6ArzS + dM0Dkd5h2Ujy1b6KcAaVW9FOa5UNfJ9FFBtjLQEBAJ7UyWD3dZzhvlaAwunsk7DG + 3bHcln8DMpIJVXht78sL + =IE0r + -----END PGP PUBLIC KEY BLOCK----- + - https://ci-files.testing.ansible.com/test/integration/targets/apt_key/apt-key-example-binary.gpg + register: signed_by_multiple_keys_block_and_url + - assert: that: - signed_by_inline.key_filename is none @@ -189,6 +278,13 @@ - signed_by_url.key_filename == '/etc/apt/keyrings/ansible-test.gpg' - > 'BEGIN' not in signed_by_url.repo + - signed_by_multiple_keys is changed + - signed_by_multiple_keys.key_filename == '/etc/apt/keyrings/ansible-test.gpg' + - signed_by_multiple_keys.repo|trim == signed_by_multiple_keys_expected + - signed_by_multiple_keys_two.key_filename == '/etc/apt/keyrings/ansible-test.gpg' + - signed_by_multiple_keys_two.repo|trim == signed_by_multiple_keys_expected + - signed_by_multiple_key_blocks.key_filename == '/etc/apt/keyrings/ansible-test.gpg' + - signed_by_multiple_keys_block_and_url.key_filename == '/etc/apt/keyrings/ansible-test.gpg' vars: signed_by_inline_expected: |- Components: main contrib non-free @@ -206,6 +302,13 @@ Suites: stable Types: deb URIs: https://deb.debian.org + signed_by_multiple_keys_expected: |- + Components: main contrib non-free + X-Repolib-Name: ansible-test + Signed-By: /etc/apt/keyrings/ansible-test.gpg + Suites: stable + Types: deb + URIs: https://deb.debian.org - name: remove ansible-test repo deb822_repository: