diff --git a/CHANGES/2261.feature b/CHANGES/2261.feature new file mode 100644 index 000000000..4a371e81b --- /dev/null +++ b/CHANGES/2261.feature @@ -0,0 +1 @@ +Add a signature fingerprint to the ManifestSignature model for improved forwards compatibility with OpenPGP v6 diff --git a/pulp_container/app/migrations/0049_manifestsignature_fingerprint.py b/pulp_container/app/migrations/0049_manifestsignature_fingerprint.py new file mode 100644 index 000000000..0b1920cea --- /dev/null +++ b/pulp_container/app/migrations/0049_manifestsignature_fingerprint.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.11 on 2026-04-15 02:37 + +import base64 +import logging + +from django.db import migrations, models + +from pysequoia.packet import PacketPile, Tag + + +logger = logging.getLogger(__name__) + + +def keyid_from_fingerprint(fingerprint): + if len(fingerprint) == 40: + return fingerprint[-16:] + elif len(fingerprint) == 64: + return fingerprint[:16] + else: + raise ValueError(f"Unexpected fingerprint length: {len(fingerprint)}") + + +def populate_fingerprint(apps, schema_editor): + ManifestSignature = apps.get_model("container", "ManifestSignature") + + batch = [] + for sig in ManifestSignature.objects.filter(fingerprint__isnull=True).iterator(): + try: + signature_raw = base64.b64decode(sig.data) + pile = PacketPile.from_bytes(signature_raw) + except Exception as exc: + logger.warning("Could not parse signature %s, skipping fingerprint extraction", sig.pk) + logger.warning(str(exc)) + continue + + fingerprint = None + for packet in pile: + if packet.tag == Tag.Signature: + if packet.issuer_fingerprint is not None: + fingerprint = packet.issuer_fingerprint.upper() + break + elif packet.issuer_key_id is not None: + # No fingerprint available, only key_id — nothing new to store + break + + if fingerprint: + assert sig.key_id == keyid_from_fingerprint(fingerprint), ( + f"Signature {sig.pk}: key_id {sig.key_id} does not match " + f"fingerprint {fingerprint}" + ) + sig.fingerprint = fingerprint + batch.append(sig) + + if len(batch) > 500: + ManifestSignature.objects.bulk_update(batch, ["fingerprint"]) + batch.clear() + + if batch: + ManifestSignature.objects.bulk_update(batch, ["fingerprint"]) + +class Migration(migrations.Migration): + + dependencies = [ + ('container', '0048_containerremote_includes_excludes'), + ] + + operations = [ + migrations.AddField( + model_name='manifestsignature', + name='fingerprint', + field=models.TextField(db_index=True, null=True), + ), + migrations.RunPython( + populate_fingerprint, + reverse_code=migrations.RunPython.noop, + elidable=True, + ), + ] diff --git a/pulp_container/app/models.py b/pulp_container/app/models.py index e3ec5ad59..6ffc29f80 100644 --- a/pulp_container/app/models.py +++ b/pulp_container/app/models.py @@ -399,7 +399,8 @@ class ManifestSignature(Content): digest (models.TextField): A signature sha256 digest prepended with its algorithm `sha256:`. type (models.TextField): A signature type as specified in signature metadata. Currently it's only "atomic container signature". - key_id (models.TextField): A key id identified by gpg (last 8 bytes of the fingerprint). + key_id (models.TextField): A PGP key id (last 8 bytes of the fingerprint). + fingerprint (models.TextField): A PGP key fingerprint timestamp (models.PositiveIntegerField): A signature timestamp identified by gpg. creator (models.TextField): A signature creator. data (models.TextField): A signature, base64 encoded. @@ -417,6 +418,7 @@ class ManifestSignature(Content): digest = models.TextField() type = models.TextField(choices=SIGNATURE_CHOICES) key_id = models.TextField(db_index=True) + fingerprint = models.TextField(null=True, db_index=True) timestamp = models.PositiveIntegerField() creator = models.TextField(blank=True) data = models.TextField() diff --git a/pulp_container/app/registry_api.py b/pulp_container/app/registry_api.py index b3db3c1ed..92adfcbc9 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1745,6 +1745,7 @@ def put(self, request, path, pk): digest=f"sha256:{sig_digest}", type=SIGNATURE_TYPE.ATOMIC_SHORT, key_id=signature_json["signing_key_id"], + fingerprint=signature_json["signing_key_fingerprint"], timestamp=signature_json["signature_timestamp"], creator=signature_json["optional"].get("creator"), data=signature_dict["content"], diff --git a/pulp_container/app/serializers.py b/pulp_container/app/serializers.py index f0bbef00d..bb2fc948d 100644 --- a/pulp_container/app/serializers.py +++ b/pulp_container/app/serializers.py @@ -186,6 +186,7 @@ class ManifestSignatureSerializer(NoArtifactContentSerializer): digest = serializers.CharField(help_text="sha256 digest of the signature blob") type = serializers.CharField(help_text="Container signature type, e.g. 'atomic'") key_id = serializers.CharField(help_text="Signing key ID") + fingerprint = serializers.CharField(help_text="Signing key fingerprint", allow_null=True) timestamp = serializers.IntegerField(help_text="Timestamp of a signature") creator = serializers.CharField(help_text="Signature creator") signed_manifest = DetailRelatedField( @@ -201,6 +202,7 @@ class Meta: "digest", "type", "key_id", + "fingerprint", "timestamp", "creator", "signed_manifest", diff --git a/pulp_container/app/tasks/sign.py b/pulp_container/app/tasks/sign.py index 2875abbc2..74a66858d 100644 --- a/pulp_container/app/tasks/sign.py +++ b/pulp_container/app/tasks/sign.py @@ -128,6 +128,7 @@ async def create_signature(manifest, reference, signing_service): digest=f"sha256:{sig_digest}", type=SIGNATURE_TYPE.ATOMIC_SHORT, key_id=sig_json["signing_key_id"], + fingerprint=sig_json["signing_key_fingerprint"], timestamp=sig_json["signature_timestamp"], creator=sig_json["optional"].get("creator"), data=encoded_sig, diff --git a/pulp_container/app/tasks/sync_stages.py b/pulp_container/app/tasks/sync_stages.py index 82f749dd0..9f572db96 100644 --- a/pulp_container/app/tasks/sync_stages.py +++ b/pulp_container/app/tasks/sync_stages.py @@ -484,6 +484,7 @@ def _create_signature_declarative_content( digest=f"sha256:{sig_digest}", type=SIGNATURE_TYPE.ATOMIC_SHORT, key_id=signature_json["signing_key_id"], + fingerprint=signature_json["signing_key_fingerprint"], timestamp=signature_json["signature_timestamp"], creator=signature_json["optional"].get("creator"), data=signature_b64 or base64.b64encode(signature_raw).decode(), diff --git a/pulp_container/app/utils.py b/pulp_container/app/utils.py index 6ec840891..d578bd62e 100644 --- a/pulp_container/app/utils.py +++ b/pulp_container/app/utils.py @@ -125,10 +125,13 @@ def extract_data_from_signature(signature_raw, man_digest): elif packet.tag == Tag.Signature: if packet.issuer_key_id is not None: signing_key_id = packet.issuer_key_id.upper() - elif packet.issuer_fingerprint is not None: + + if packet.issuer_fingerprint is not None: signing_key_fingerprint = packet.issuer_fingerprint.upper() - signing_key_id = keyid_from_fingerprint(signing_key_fingerprint) - else: + if signing_key_id is None: + signing_key_id = keyid_from_fingerprint(signing_key_fingerprint) + + if signing_key_id is None and signing_key_fingerprint is None: raise ValueError( "Signature for manifest {} has no fingerprint or key_id".format(man_digest) ) diff --git a/pulp_container/tests/functional/api/test_push_signatures.py b/pulp_container/tests/functional/api/test_push_signatures.py index e1f4936f6..b20e5f91b 100644 --- a/pulp_container/tests/functional/api/test_push_signatures.py +++ b/pulp_container/tests/functional/api/test_push_signatures.py @@ -63,6 +63,7 @@ def test_assert_signed_image( assert manifest.digest in signature.name assert signature.signed_manifest == manifest.pulp_href assert signature.key_id == keyid + assert signature.fingerprint == fingerprint path = f"/extensions/v2/{full_path(distribution)}/signatures/{manifest.digest}" response, _ = local_registry.get_response("GET", path) @@ -77,6 +78,7 @@ def test_assert_signed_image( decrypted = gpg.decrypt(raw_s) assert decrypted.key_id == keyid + assert decrypted.fingerprint == fingerprint assert decrypted.status == "signature valid" json_s = json.loads(decrypted.data) diff --git a/pulp_container/tests/functional/api/test_sign_manifests.py b/pulp_container/tests/functional/api/test_sign_manifests.py index 66dcc86ec..1706ccfff 100644 --- a/pulp_container/tests/functional/api/test_sign_manifests.py +++ b/pulp_container/tests/functional/api/test_sign_manifests.py @@ -32,7 +32,7 @@ def test_sign_manifest( monitor_task, ): """Test whether a user can sign a manifest by leveraging a signing service.""" - _, _, keyid = signing_gpg_metadata + _, fingerprint, keyid = signing_gpg_metadata sign_data = {"manifest_signing_service": container_signing_service.pulp_href} response = container_push_repository_api.sign(distribution.repository, sign_data) @@ -49,6 +49,7 @@ def test_sign_manifest( signature = signatures.results[0] assert signature.key_id == keyid + assert signature.fingerprint == fingerprint assert signature.type == SIGNATURE_TYPE.ATOMIC_SHORT manifest = container_manifest_api.read(tag.tagged_manifest) diff --git a/pulp_container/tests/functional/api/test_sync_signatures.py b/pulp_container/tests/functional/api/test_sync_signatures.py index 3cbd1621a..1edfedc1a 100644 --- a/pulp_container/tests/functional/api/test_sync_signatures.py +++ b/pulp_container/tests/functional/api/test_sync_signatures.py @@ -161,6 +161,8 @@ def test_sync_image_with_pqc_signatures( assert len(signatures) > 0 # Assert that a signature using one of the "old" Red Hat signing release keys exist + # The legacy key signatures do not have key fingerprint packets, therefore checking for those + # fingerprints fail. The signatures created by the modern key below do have fingerprint packets. expected_key_ids = ["199E2F91FD431D51", "E60D446E63405576"] assert any(s.key_id in expected_key_ids for s in signatures), ( f"No signature found with key_ids {expected_key_ids}; " @@ -171,10 +173,15 @@ def test_sync_image_with_pqc_signatures( # Fingerprint: FCD355B305707A62DA143AB6E422397E50FE8467A2A95343D246D6276AFEDF8F # Key ID => first 8 bytes (16 hex chars) expected_key_id = "FCD355B305707A62" + expected_fingerprint = "FCD355B305707A62DA143AB6E422397E50FE8467A2A95343D246D6276AFEDF8F" assert any(s.key_id == expected_key_id for s in signatures), ( f"No signature found with key_id {expected_key_id!r}; " f"found key_ids: {sorted({s.key_id for s in signatures})}" ) + assert any(s.fingerprint == expected_fingerprint for s in signatures), ( + f"No signature found with fingerprint {expected_fingerprint!r}; " + f"found fingerprints: {[s.fingerprint for s in signatures]}" + ) # ubi10-micro:latest is a manifest list; collect all listed manifests and verify # that each has at least one signature