Skip to content

Commit fc6bbd7

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

File tree

10 files changed

+427
-51
lines changed

10 files changed

+427
-51
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: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@
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 InvalidSignature(Exception):
19+
"""When GPG verification fails due to the signature (NO_PUBKEY, EXPSIG, etc)."""
20+
21+
22+
class FingerprintMismatch(Exception):
23+
"""Raised when a deb package is signed with a different key fingerprint."""
1124

1225

1326
def prepare_gpg(temp_directory_name, public_key, pubkey_fingerprint):
@@ -212,22 +225,22 @@ def validate(self):
212225
except KeyError:
213226
raise Exception(f"Malformed output from signing script: {return_value}")
214227

215-
# Prepare GPG:
228+
self.validate_signature(signed_deb)
229+
230+
def validate_signature(self, deb_package_path: str):
231+
"""Validate that the deb package is signed with our pubkey."""
232+
with tempfile.TemporaryDirectory() as temp_directory_name:
216233
gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint)
217234

218-
self._validate_deb_package(
219-
signed_deb, self.pubkey_fingerprint, temp_directory_name, gpg
235+
self._check_deb_signature(
236+
deb_package_path, self.pubkey_fingerprint, temp_directory_name, gpg
220237
)
221238

222239
@staticmethod
223-
def _validate_deb_package(
224-
deb_package_path: str, pubkey_fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG
240+
def _check_deb_signature(
241+
deb_package_path: str, fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG
225242
):
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-
"""
243+
"""Check the deb package signature matches the provided fingerprint."""
231244
# unpack the archive
232245
cmd = ["ar", "x", deb_package_path]
233246
res = subprocess.run(cmd, cwd=temp_directory_name, capture_output=True)
@@ -247,16 +260,29 @@ def _validate_deb_package(
247260
# verify combined data with _gpgorigin detached signature
248261
gpgorigin_path = temp_dir / "_gpgorigin"
249262
if not gpgorigin_path.exists():
250-
raise Exception(
263+
raise UnsignedPackage(
251264
f"_gpgorigin file not found for {deb_package_path}. Package is unsigned."
252265
)
253266
with gpgorigin_path.open("rb") as gpgorigin:
254267
verified = gpg.verify_file(gpgorigin, str(temp_dir / "combined"))
255268
if not verified.valid:
256-
raise Exception(
269+
raise InvalidSignature(
257270
f"GPG Verification of the signed package {deb_package_path} failed!"
258271
)
259-
if verified.pubkey_fingerprint != pubkey_fingerprint:
260-
raise Exception(
272+
if verified.pubkey_fingerprint != fingerprint:
273+
raise FingerprintMismatch(
261274
f"'{deb_package_path}' appears to have been signed using the wrong key!"
262275
)
276+
277+
278+
class DebPackageSigningResult(BaseModel):
279+
"""
280+
A model used for storing the result of signing a deb package.
281+
"""
282+
283+
sha256 = models.TextField(max_length=64)
284+
package_signing_fingerprint = models.TextField(max_length=40)
285+
result = models.ForeignKey(Content, on_delete=models.CASCADE)
286+
287+
class Meta:
288+
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: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
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+
InvalidSignature,
20+
UnsignedPackage,
21+
)
22+
from pulp_deb.app.models import AptRepository, Package, PackageReleaseComponent
923

1024

1125
def _save_file(fileobj, final_package):
@@ -22,6 +36,18 @@ def _save_upload(uploadobj, final_package):
2236
final_package.flush()
2337

2438

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

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