From 5fceef225954928625e29c73d4dd6a7495c34e9d Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 14 Apr 2026 22:49:32 -0400 Subject: [PATCH 1/2] Add a fingerprint to ManifestSignature closes #2261 --- CHANGES/2261.feature | 1 + .../0048_manifestsignature_fingerprint.py | 75 +++++++++++++++++++ pulp_container/app/models.py | 4 +- pulp_container/app/registry_api.py | 1 + pulp_container/app/serializers.py | 2 + pulp_container/app/tasks/sign.py | 1 + pulp_container/app/tasks/sync_stages.py | 1 + 7 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 CHANGES/2261.feature create mode 100644 pulp_container/app/migrations/0048_manifestsignature_fingerprint.py 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/0048_manifestsignature_fingerprint.py b/pulp_container/app/migrations/0048_manifestsignature_fingerprint.py new file mode 100644 index 000000000..1f231de09 --- /dev/null +++ b/pulp_container/app/migrations/0048_manifestsignature_fingerprint.py @@ -0,0 +1,75 @@ +# 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") + + updated = [] + 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 + updated.append(sig) + + if updated: + ManifestSignature.objects.bulk_update(updated, ["fingerprint"], batch_size=1000) + + +class Migration(migrations.Migration): + + dependencies = [ + ('container', '0047_containernamespace_pulp_labels'), + ] + + 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 775b63f46..04db67a4f 100644 --- a/pulp_container/app/models.py +++ b/pulp_container/app/models.py @@ -398,7 +398,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. @@ -416,6 +417,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 1e719cf7c..8c5a9c6be 100644 --- a/pulp_container/app/registry_api.py +++ b/pulp_container/app/registry_api.py @@ -1557,6 +1557,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 fc582fc9d..f18a12605 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 5ccdc4de5..1e98571c8 100644 --- a/pulp_container/app/tasks/sign.py +++ b/pulp_container/app/tasks/sign.py @@ -129,6 +129,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 f608e5623..f502c1cca 100644 --- a/pulp_container/app/tasks/sync_stages.py +++ b/pulp_container/app/tasks/sync_stages.py @@ -403,6 +403,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(), From b480fd5a606c5821619ef21ec3349b736fb4c8f7 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 21 Apr 2026 11:27:15 -0400 Subject: [PATCH 2/2] Fix misuse of gpg_verify It was trying to verify a detached signature without providing the detached data. PySequoia is stricter about this than gpg. --- pulp_container/app/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pulp_container/app/models.py b/pulp_container/app/models.py index 04db67a4f..c7870ea3c 100644 --- a/pulp_container/app/models.py +++ b/pulp_container/app/models.py @@ -698,7 +698,7 @@ def validate(self): manifest_file.name, env_vars={"REFERENCE": "test", "SIG_PATH": sig_path} ) - gpg_verify(self.public_key, signed["signature_path"]) + gpg_verify(self.public_key, signed["signature_path"], detached_data=manifest_file.name) class ContainerRepository(