diff --git a/functest_requirements.txt b/functest_requirements.txt index 774edb421..5fb29530a 100644 --- a/functest_requirements.txt +++ b/functest_requirements.txt @@ -1,6 +1,5 @@ pytest<10 -python-gnupg pytest-xdist pytest-timeout pytest-custom_exit_code -trustme~=1.2.1 \ No newline at end of file +trustme~=1.2.1 diff --git a/pulp_container/tests/functional/api/test_push_signatures.py b/pulp_container/tests/functional/api/test_push_signatures.py index 2d9e3e7f9..dd19652ba 100644 --- a/pulp_container/tests/functional/api/test_push_signatures.py +++ b/pulp_container/tests/functional/api/test_push_signatures.py @@ -1,55 +1,78 @@ """Tests that verify that an image signature can be pushed to Pulp.""" import base64 -import json +import subprocess import pytest from pulp_container.constants import SIGNATURE_TYPE +from pulp_container.tests.functional.conftest import verify_inline_signature from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP +def _podman_supports_sq_signing(): + """Return True if the local podman build supports --sign-by-sq-fingerprint.""" + result = subprocess.run( + ("podman", "push", "--help"), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + return "--sign-by-sq-fingerprint" in result.stdout.decode() + + @pytest.fixture -def distribution( +def signed_distribution( + signing_key_home, registry_client, local_registry, container_distribution_api, - signing_gpg_metadata, + container_namespace_api, add_to_cleanup, full_path, ): - """Return a distribution created after pushing a signed content to the Pulp Registry.""" + """Push an image signed with a Sequoia key (twice, for two distinct signatures). + + Parameterized (via `signing_key_home`) over a key that signs with its primary key and + one that signs with a dedicated subkey. + """ if registry_client.name != "podman": pytest.skip("This test requires podman to sign pulled content", allow_module_level=True) + if not _podman_supports_sq_signing(): + pytest.skip("This podman build does not support --sign-by-sq-fingerprint") image_path = f"{REGISTRY_V2_REPO_PULP}:manifest_a" registry_client.pull(image_path) - gpg, fingerprint, keyid = signing_gpg_metadata - - with registry_client.set_env(GNUPGHOME=str(gpg.gnupghome)): - local_registry.tag_and_push(image_path, full_path("test-1:manifest_a"), "--sign-by", keyid) - - # push the same image for the second time with a different signature (timestamp) - local_registry.tag_and_push(image_path, full_path("test-1:manifest_a"), "--sign-by", keyid) + # Point the Sequoia integration at the home holding our imported key. + sign_args = ("--sign-by-sq-fingerprint", signing_key_home.fingerprint) + with registry_client.set_env(SEQUOIA_HOME=str(signing_key_home.home)): + local_registry.tag_and_push(image_path, full_path("test-1:manifest_a"), *sign_args) + # push a second time to produce a distinct signature (timestamp) + local_registry.tag_and_push(image_path, full_path("test-1:manifest_a"), *sign_args) distribution = container_distribution_api.list(name="test-1").results[0] - add_to_cleanup(container_distribution_api, distribution.pulp_href) + # Clean up the namespace, which cascades to the distribution and the push repository. + add_to_cleanup(container_namespace_api, distribution.namespace) return distribution def test_assert_signed_image( + signing_key_home, local_registry, container_repository_api, container_manifest_api, container_signature_api, - signing_gpg_metadata, - distribution, + signed_distribution, full_path, ): - """Test whether an admin user can fetch a signature from the Pulp Registry.""" - gpg, fingerprint, keyid = signing_gpg_metadata + """Test whether an admin user can fetch a signature from the Pulp Registry. + + Runs against both primary-key and subkey signing. + """ + distribution = signed_distribution + fingerprint = signing_key_home.signing_fingerprint + keyid = signing_key_home.signing_keyid repository = container_repository_api.read(distribution.repository) manifest = container_manifest_api.list( @@ -75,13 +98,12 @@ def test_assert_signed_image( timestamps = [] for s in signatures: raw_s = base64.b64decode(s["content"]) - decrypted = gpg.decrypt(raw_s) - - assert decrypted.key_id == keyid - assert decrypted.fingerprint == fingerprint - assert decrypted.status == "signature valid" + sig_fingerprint, sig_key_id, json_s = verify_inline_signature( + signing_key_home.public_key, raw_s + ) - json_s = json.loads(decrypted.data) + assert sig_key_id == keyid + assert sig_fingerprint == fingerprint image_path = json_s["critical"]["identity"]["docker-reference"] assert image_path == f"{local_registry.name}/{full_path(distribution)}:manifest_a" diff --git a/pulp_container/tests/functional/api/test_sign_manifests.py b/pulp_container/tests/functional/api/test_sign_manifests.py index e0bdaea60..a30b453de 100644 --- a/pulp_container/tests/functional/api/test_sign_manifests.py +++ b/pulp_container/tests/functional/api/test_sign_manifests.py @@ -1,14 +1,48 @@ import pytest +from pulpcore.pytest_plugin import create_signing_service, remove_signing_service + from pulp_container.constants import SIGNATURE_TYPE from pulp_container.tests.functional.constants import REGISTRY_V2_REPO_PULP MANIFEST_TAG = "manifest_a" +# Builds an atomic container signature payload for the passed manifest and signs +# it with a Sequoia (sq) key, emitting an inline-signed binary OpenPGP message. +# See https://github.com/pulp/pulp_container/issues/2280. +SIGNING_SCRIPT_STRING = """#!/usr/bin/env bash + +set -e + +MANIFEST_PATH=$1 +FINGERPRINT="$PULP_SIGNING_KEY_FINGERPRINT" +SQ_HOME="{sq_home}" + +DIGEST="sha256:$(sha256sum "$MANIFEST_PATH" | awk '{{print $1}}')" + +PAYLOAD_FILE="$(mktemp)" +cat > "$PAYLOAD_FILE" < signs with its primary key. + "primary": (_GPG_FIXTURE_KEY_PRIVATE, _GPG_FIXTURE_KEY_PUBLIC), + # Sequoia ML-DSA (post-quantum) key -> signs with a dedicated subkey. + "subkey": (KEY_V6_MLDSA65_ED25519_PRIVATE, KEY_V6_MLDSA65_ED25519_PUBLIC), + } + private_url, public_url = keys[request.param] + + home = tmp_path_factory.mktemp(f"sq_home_{request.param}") + try: + _sq, fingerprint, _keyid = import_signing_key(private_url, home, backend="sq") + except TypeError: + pytest.skip("This pulpcore release does not support the Sequoia (sq) signing backend") + + public_key = requests.get(public_url).content.decode("utf-8") + signing_fingerprint, signing_keyid = sq_signing_identity(home, fingerprint, public_key) + + return SimpleNamespace( + home=home, + fingerprint=fingerprint, + signing_fingerprint=signing_fingerprint, + signing_keyid=signing_keyid, + public_key=public_key, + ) + + +def _keyid_from_fingerprint(fingerprint): + """Derive the key ID from an OpenPGP fingerprint, matching pulp_container's logic. + + For v4 fingerprints (40 hex chars) the key ID is the last 16 chars; for v6 (64 hex + chars) it is the first 16 chars. + """ + if len(fingerprint) == 40: + return fingerprint[-16:] + elif len(fingerprint) == 64: + return fingerprint[:16] + raise ValueError(f"Unexpected fingerprint length: {len(fingerprint)}") + + +def _verified_signing_key(public_key, raw_signature): + """Verify an inline-signed OpenPGP message and return (signing_key_fpr, payload_bytes). + + Works for both classic (RSA/ed25519) and post-quantum (ML-DSA) keys. Uses pysequoia + directly rather than pulpcore's gpg_verify because the latter pulls in Django models, + which aren't configured in the functional-test client process. + """ + from pysequoia import Cert, verify + + certs = Cert.split_bytes(public_key.encode("utf-8")) + result = verify(bytes=raw_signature, store=lambda key_ids: certs) + valid_sig = result.valid_sigs[0] + return valid_sig.signing_key.upper(), bytes(result.bytes) + + +def verify_inline_signature(public_key, raw_signature): + """Verify a signature blob and return (fingerprint, key_id, payload_dict).""" + fingerprint, payload_bytes = _verified_signing_key(public_key, raw_signature) + return fingerprint, _keyid_from_fingerprint(fingerprint), json.loads(payload_bytes) + + +def sq_signing_identity(sq_home, signer, public_key): + """Return the (fingerprint, key_id) actually used when signing with `signer`. + + A certificate may sign with a dedicated subkey rather than its primary key, so the + fingerprint recorded on produced signatures can differ from the certificate's primary + fingerprint. This signs throwaway data to discover the real signing (sub)key. + """ + completed = subprocess.run( + ("sq", "--home", str(sq_home), "sign", "--signer", signer, "--message", "--binary"), + input=b"probe", + capture_output=True, + ) + completed.check_returncode() + fingerprint, _payload = _verified_signing_key(public_key, completed.stdout) + return fingerprint, _keyid_from_fingerprint(fingerprint) + def gen_container_remote(url=REGISTRY_V2_FEED_URL, **kwargs): """Return a semi-random dict for use in creating a container Remote. diff --git a/pyproject.toml b/pyproject.toml index b0e385387..89ffacec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "jsonschema>=4.4,<4.27", "pulpcore>=3.111.0,<3.130", "pyjwt[crypto]>=2.4,<2.14", - "pysequoia>=0.1.33,<0.2.0" + "pysequoia>=0.1.35,<0.2.0" ] [project.urls]