Skip to content

Commit 9a98cf2

Browse files
committed
Sign packages when modifying repo content
Assisted By: GPT-5.1-Codex fixes #1300
1 parent dac969b commit 9a98cf2

File tree

10 files changed

+421
-50
lines changed

10 files changed

+421
-50
lines changed

docs/user/guides/sign_packages.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Sign Debian Packages
22

3-
Sign a Debian package using a registered APT package signing service.
3+
Sign a Debian package using a registered package signing service.
44

5-
Currently, only on-upload signing is supported.
5+
Currently, only signing on upload and when modifying a repo's content are supported.
66

77
## On Upload
88

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 4.2.27 on 2025-12-29 19:23
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django_lifecycle.mixins
6+
import pulpcore.app.models.base
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('core', '0145_domainize_import_export'),
13+
('deb', '0034_package_signing'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='DebPackageSigningResult',
19+
fields=[
20+
('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, primary_key=True, serialize=False)),
21+
('pulp_created', models.DateTimeField(auto_now_add=True)),
22+
('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)),
23+
('sha256', models.TextField(max_length=64)),
24+
('package_signing_fingerprint', models.TextField(max_length=40)),
25+
('result', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.content')),
26+
],
27+
options={
28+
'unique_together': {('sha256', 'package_signing_fingerprint')},
29+
},
30+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
31+
),
32+
]

pulp_deb/app/models/signing_service.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
from typing import Optional
88

99
import gnupg
10-
from pulpcore.plugin.models import SigningService
10+
from django.db import models
11+
from pulpcore.plugin.models import BaseModel, Content, SigningService
12+
13+
14+
class UnsignedPackage(Exception):
15+
"""Raised when a deb package is unsigned and has no _gpgorigin signature."""
16+
17+
18+
class FingerprintMismatch(Exception):
19+
"""Raised when a deb package is signed with a different key fingerprint."""
1120

1221

1322
def prepare_gpg(temp_directory_name, public_key, pubkey_fingerprint):
@@ -212,22 +221,22 @@ def validate(self):
212221
except KeyError:
213222
raise Exception(f"Malformed output from signing script: {return_value}")
214223

215-
# Prepare GPG:
224+
self.validate_signature(signed_deb)
225+
226+
def validate_signature(self, deb_package_path: str):
227+
"""Validate that the deb package is signed with our pubkey."""
228+
with tempfile.TemporaryDirectory() as temp_directory_name:
216229
gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint)
217230

218-
self._validate_deb_package(
219-
signed_deb, self.pubkey_fingerprint, temp_directory_name, gpg
231+
self._check_deb_signature(
232+
deb_package_path, self.pubkey_fingerprint, temp_directory_name, gpg
220233
)
221234

222235
@staticmethod
223-
def _validate_deb_package(
224-
deb_package_path: str, pubkey_fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG
236+
def _check_deb_signature(
237+
deb_package_path: str, fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG
225238
):
226-
"""
227-
Validate that the deb package at @deb_package_path is correctly signed.
228-
229-
This is a placeholder for future validation logic if needed.
230-
"""
239+
"""Check the deb package signature matches the provided fingerprint."""
231240
# unpack the archive
232241
cmd = ["ar", "x", deb_package_path]
233242
res = subprocess.run(cmd, cwd=temp_directory_name, capture_output=True)
@@ -247,7 +256,7 @@ def _validate_deb_package(
247256
# verify combined data with _gpgorigin detached signature
248257
gpgorigin_path = temp_dir / "_gpgorigin"
249258
if not gpgorigin_path.exists():
250-
raise Exception(
259+
raise UnsignedPackage(
251260
f"_gpgorigin file not found for {deb_package_path}. Package is unsigned."
252261
)
253262
with gpgorigin_path.open("rb") as gpgorigin:
@@ -256,7 +265,20 @@ def _validate_deb_package(
256265
raise Exception(
257266
f"GPG Verification of the signed package {deb_package_path} failed!"
258267
)
259-
if verified.pubkey_fingerprint != pubkey_fingerprint:
260-
raise Exception(
268+
if verified.pubkey_fingerprint != fingerprint:
269+
raise FingerprintMismatch(
261270
f"'{deb_package_path}' appears to have been signed using the wrong key!"
262271
)
272+
273+
274+
class DebPackageSigningResult(BaseModel):
275+
"""
276+
A model used for storing the result of signing a deb package.
277+
"""
278+
279+
sha256 = models.TextField(max_length=64)
280+
package_signing_fingerprint = models.TextField(max_length=40)
281+
result = models.ForeignKey(Content, on_delete=models.CASCADE)
282+
283+
class Meta:
284+
unique_together = ("sha256", "package_signing_fingerprint")

pulp_deb/app/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from .publishing import publish, publish_verbatim
33
from .synchronizing import synchronize
44
from .copy import copy_content
5+
from .signing import sign_and_create, signed_add_and_remove

pulp_deb/app/tasks/signing.py

Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
from pathlib import Path
22
from tempfile import NamedTemporaryFile
33

4-
from pulpcore.plugin.models import Upload, UploadChunk, Artifact, CreatedResource, PulpTemporaryFile
5-
from pulpcore.plugin.tasking import general_create
4+
from pulpcore.plugin.models import (
5+
Upload,
6+
UploadChunk,
7+
Artifact,
8+
ContentArtifact,
9+
CreatedResource,
10+
PulpTemporaryFile,
11+
)
12+
from pulpcore.plugin.tasking import add_and_remove, general_create
613
from pulpcore.plugin.util import get_url
714

8-
from pulp_deb.app.models.signing_service import AptPackageSigningService
15+
from pulp_deb.app.models.signing_service import (
16+
AptPackageSigningService,
17+
DebPackageSigningResult,
18+
FingerprintMismatch,
19+
UnsignedPackage,
20+
)
21+
from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent
922

1023

1124
def _save_file(fileobj, final_package):
@@ -22,6 +35,18 @@ def _save_upload(uploadobj, final_package):
2235
final_package.flush()
2336

2437

38+
def _sign_file(package_file, signing_service, signing_fingerprint):
39+
result = signing_service.sign(package_file.name, pubkey_fingerprint=signing_fingerprint)
40+
signed_package_path = Path(result["deb_package"])
41+
if not signed_package_path.exists():
42+
raise Exception(f"Signing script did not create the signed package: {result}")
43+
artifact = Artifact.init_and_validate(str(signed_package_path))
44+
artifact.save()
45+
resource = CreatedResource(content_object=artifact)
46+
resource.save()
47+
return artifact
48+
49+
2550
def sign_and_create(
2651
app_label,
2752
serializer_name,
@@ -43,16 +68,7 @@ def sign_and_create(
4368
uploaded_package = Upload.objects.get(pk=temporary_file_pk)
4469
_save_upload(uploaded_package, final_package)
4570

46-
result = package_signing_service.sign(
47-
final_package.name, pubkey_fingerprint=signing_fingerprint
48-
)
49-
signed_package_path = Path(result["deb_package"])
50-
if not signed_package_path.exists():
51-
raise Exception(f"Signing script did not create the signed package: {result}")
52-
artifact = Artifact.init_and_validate(str(signed_package_path))
53-
artifact.save()
54-
resource = CreatedResource(content_object=artifact)
55-
resource.save()
71+
artifact = _sign_file(final_package, package_signing_service, signing_fingerprint)
5672
uploaded_package.delete()
5773
# Create Package content
5874
data["artifact"] = get_url(artifact)
@@ -64,3 +80,89 @@ def sign_and_create(
6480
if "upload" in data:
6581
del data["upload"]
6682
general_create(app_label, serializer_name, data=data, context=context, *args, **kwargs)
83+
84+
85+
def _update_content_units(content_units, old_pk, new_pk):
86+
while str(old_pk) in content_units:
87+
content_units.remove(str(old_pk))
88+
89+
if str(new_pk) not in content_units:
90+
content_units.append(str(new_pk))
91+
92+
# Repoint PackageReleaseComponents included in this transaction to the new package.
93+
for prc in PackageReleaseComponent.objects.filter(pk__in=content_units, package_id=old_pk):
94+
new_prc, _ = PackageReleaseComponent.objects.get_or_create(
95+
release_component=prc.release_component,
96+
package_id=new_pk,
97+
_pulp_domain=prc._pulp_domain,
98+
)
99+
100+
while str(prc.pk) in content_units:
101+
content_units.remove(str(prc.pk))
102+
103+
if str(new_prc.pk) not in content_units:
104+
content_units.append(str(new_prc.pk))
105+
106+
107+
def _check_package_signature(repository, package_path):
108+
try:
109+
repository.package_signing_service.validate_signature(package_path)
110+
except (UnsignedPackage, FingerprintMismatch):
111+
return False
112+
113+
return True
114+
115+
116+
def signed_add_and_remove(
117+
repository_pk, add_content_units, remove_content_units, base_version_pk=None
118+
):
119+
repo = AptRepository.objects.get(pk=repository_pk)
120+
121+
if repo.package_signing_service:
122+
# sign each package and replace it in the add_content_units list
123+
for package in Package.objects.filter(pk__in=add_content_units):
124+
content_artifact = package.contentartifact_set.first()
125+
artifact_obj = content_artifact.artifact
126+
package_id = package.pk
127+
128+
with NamedTemporaryFile(mode="wb", dir=".", delete=False) as final_package:
129+
artifact_file = artifact_obj.file
130+
_save_file(artifact_file, final_package)
131+
132+
# check if the package is already signed with our fingerprint
133+
if _check_package_signature(repo, final_package.name):
134+
continue
135+
136+
# check if the package has been signed in the past with our fingerprint
137+
if existing_result := DebPackageSigningResult.objects.filter(
138+
sha256=content_artifact.artifact.sha256,
139+
package_signing_fingerprint=repo.package_signing_fingerprint,
140+
).first():
141+
_update_content_units(add_content_units, package_id, existing_result.result.pk)
142+
continue
143+
144+
# create a new signed version of the package
145+
artifact = _sign_file(
146+
final_package, repo.package_signing_service, repo.package_signing_fingerprint
147+
)
148+
signed_package = package
149+
signed_package.pk = None
150+
signed_package.pulp_id = None
151+
signed_package.sha256 = artifact.sha256
152+
signed_package.save()
153+
ContentArtifact.objects.create(
154+
artifact=artifact,
155+
content=signed_package,
156+
relative_path=content_artifact.relative_path,
157+
)
158+
DebPackageSigningResult.objects.create(
159+
sha256=artifact_obj.sha256,
160+
package_signing_fingerprint=repo.package_signing_fingerprint,
161+
result=signed_package,
162+
)
163+
164+
_update_content_units(add_content_units, package_id, signed_package.pk)
165+
resource = CreatedResource(content_object=signed_package)
166+
resource.save()
167+
168+
return add_and_remove(repository_pk, add_content_units, remove_content_units, base_version_pk)

pulp_deb/app/viewsets/content.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from drf_spectacular.utils import extend_schema
2222

2323
from pulp_deb.app import models, serializers
24-
from pulp_deb.app.tasks import signing as deb_sign
24+
from pulp_deb.app.tasks import sign_and_create
2525

2626

2727
class GenericContentFilter(ContentFilter):
@@ -312,7 +312,7 @@ def create(self, request):
312312
serializer.validated_data.get("repository"),
313313
]
314314
task = dispatch(
315-
deb_sign.sign_and_create,
315+
sign_and_create,
316316
exclusive_resources=task_exclusive,
317317
args=tuple(task_args.values()),
318318
kwargs={

pulp_deb/app/viewsets/repository.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
from pulp_deb.app.models.content.content import Package
99
from pulp_deb.app.models.content.structure_content import PackageReleaseComponent
1010
from pulp_deb.app.serializers import AptRepositorySyncURLSerializer
11+
from pulp_deb.app.tasks import signed_add_and_remove
1112

1213
from pulpcore.plugin.util import extract_pk, get_url
1314
from pulpcore.plugin.actions import ModifyRepositoryActionMixin
1415
from pulpcore.plugin.serializers import (
1516
AsyncOperationResponseSerializer,
1617
RepositoryAddRemoveContentSerializer,
1718
)
18-
from pulpcore.plugin.models import RepositoryVersion
19+
from pulpcore.plugin.models import ContentArtifact, RepositoryVersion
1920
from pulpcore.plugin.tasking import dispatch
2021
from pulpcore.plugin.viewsets import (
2122
OperationPostponedResponse,
@@ -29,6 +30,8 @@
2930

3031

3132
class AptModifyRepositoryActionMixin(ModifyRepositoryActionMixin):
33+
modify_task = signed_add_and_remove
34+
3235
@extend_schema(
3336
description="Trigger an asynchronous task to create a new repository version.",
3437
summary="Modify Repository Content",
@@ -37,13 +40,25 @@ class AptModifyRepositoryActionMixin(ModifyRepositoryActionMixin):
3740
@action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer)
3841
def modify(self, request, pk):
3942
remove_content_units = request.data.get("remove_content_units", [])
40-
package_hrefs = [href for href in remove_content_units if "/packages/" in href]
43+
remove_package_hrefs = [href for href in remove_content_units if "/packages/" in href]
4144

42-
if package_hrefs:
43-
prc_hrefs = self._get_matching_prc_hrefs(package_hrefs)
45+
if remove_package_hrefs:
46+
prc_hrefs = self._get_matching_prc_hrefs(remove_package_hrefs)
4447
remove_content_units.extend(prc_hrefs)
4548
request.data["remove_content_units"] = remove_content_units
4649

50+
add_content_units = request.data.get("add_content_units", [])
51+
package_ids = [extract_pk(href) for href in add_content_units if "/packages/" in href]
52+
repository = self.get_object()
53+
if add_content_units and repository.package_signing_service:
54+
ondemand_ca = ContentArtifact.objects.filter(
55+
content_id__in=package_ids, artifact__isnull=True
56+
)
57+
if ondemand_ca.count() > 0:
58+
raise DRFValidationError(
59+
_("Cannot add on-demand content to repo with set package signing service.")
60+
)
61+
4762
return super().modify(request, pk)
4863

4964
def _get_matching_prc_hrefs(self, package_hrefs):
@@ -345,9 +360,9 @@ def _process_config(self, config):
345360
number=entry["dest_base_version"]
346361
).pk
347362
except RepositoryVersion.DoesNotExist:
348-
message = _(
349-
"Version {version} does not exist for repository " "'{repo}'."
350-
).format(version=entry["dest_base_version"], repo=dest_repo.name)
363+
message = _("Version {version} does not exist for repository '{repo}'.").format(
364+
version=entry["dest_base_version"], repo=dest_repo.name
365+
)
351366
raise DRFValidationError(detail=message)
352367

353368
if entry.get("content") is not None:

0 commit comments

Comments
 (0)