Skip to content

Commit 392ca28

Browse files
author
adrianabedon
committed
package signing
1 parent 68ded91 commit 392ca28

File tree

12 files changed

+859
-25
lines changed

12 files changed

+859
-25
lines changed

CHANGES/1300.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added (tech preview) support for signing Debian packages when uploading to a Repository.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 4.2.25 on 2025-10-23 21:43
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', '0001_initial_squashed_0031_add_domains'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='AptPackageSigningService',
19+
fields=[
20+
('signingservice_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.signingservice')),
21+
],
22+
options={
23+
'abstract': False,
24+
},
25+
bases=('core.signingservice',),
26+
),
27+
migrations.AddField(
28+
model_name='aptrepository',
29+
name='package_signing_fingerprint',
30+
field=models.TextField(max_length=40, null=True),
31+
),
32+
migrations.AddField(
33+
model_name='aptrepository',
34+
name='package_signing_service',
35+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='deb.aptpackagesigningservice'),
36+
),
37+
migrations.CreateModel(
38+
name='AptRepositoryReleasePackageSigningFingerprintOverride',
39+
fields=[
40+
('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, primary_key=True, serialize=False)),
41+
('pulp_created', models.DateTimeField(auto_now_add=True)),
42+
('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)),
43+
('package_signing_fingerprint', models.TextField(max_length=40)),
44+
('release_distribution', models.TextField()),
45+
('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='package_signing_fingerprint_release_overrides', to='deb.aptrepository')),
46+
],
47+
options={
48+
'unique_together': {('repository', 'release_distribution')},
49+
},
50+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
51+
),
52+
]

pulp_deb/app/models/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
SourcePackage,
1010
)
1111

12-
from .signing_service import AptReleaseSigningService
12+
from .signing_service import AptReleaseSigningService, AptPackageSigningService
1313

1414
from .content.metadata import (
1515
Release,
@@ -28,4 +28,8 @@
2828

2929
from .remote import AptRemote
3030

31-
from .repository import AptRepository, AptRepositoryReleaseServiceOverride
31+
from .repository import (
32+
AptRepository,
33+
AptRepositoryReleaseServiceOverride,
34+
AptRepositoryReleasePackageSigningFingerprintOverride,
35+
)

pulp_deb/app/models/repository.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
SourceIndex,
2929
SourcePackage,
3030
SourcePackageReleaseComponent,
31+
AptPackageSigningService,
3132
)
3233

3334
import logging
@@ -66,13 +67,24 @@ class AptRepository(Repository, AutoAddObjPermsMixin):
6667
signing_service = models.ForeignKey(
6768
AptReleaseSigningService, on_delete=models.PROTECT, null=True
6869
)
70+
71+
package_signing_service = models.ForeignKey(
72+
AptPackageSigningService, on_delete=models.SET_NULL, null=True
73+
)
74+
75+
package_signing_fingerprint = models.TextField(null=True, max_length=40)
76+
6977
# Implicit signing_service_release_overrides
78+
# Implicit package_signing_fingerprint_release_overrides
7079

7180
class Meta:
7281
default_related_name = "%(app_label)s_%(model_name)s"
7382
permissions = [
7483
("manage_roles_aptrepository", "Can manage roles on APT repositories"),
75-
("modify_content_aptrepository", "Add content to, or remove content from a repository"),
84+
(
85+
"modify_content_aptrepository",
86+
"Add content to, or remove content from a repository",
87+
),
7688
("repair_aptrepository", "Copy an APT repository"),
7789
("sync_aptrepository", "Sync an APT repository"),
7890
("delete_aptrepository_version", "Delete a repository version"),
@@ -91,6 +103,21 @@ def release_signing_service(self, release):
91103
except AptRepositoryReleaseServiceOverride.DoesNotExist:
92104
return self.signing_service
93105

106+
def release_package_signing_fingerprint(self, release):
107+
"""
108+
Return the Package Signing Fingerprint specified in the overrides if there is one for this
109+
release, else return self.package_signing_fingerprint.
110+
"""
111+
if isinstance(release, Release):
112+
release = release.distribution
113+
try:
114+
override = self.package_signing_fingerprint_release_overrides.get(
115+
release_distribution=release
116+
)
117+
return override.package_signing_fingerprint
118+
except AptRepositoryReleasePackageSigningFingerprintOverride.DoesNotExist:
119+
return self.package_signing_fingerprint
120+
94121
def initialize_new_version(self, new_version):
95122
"""
96123
Remove old metadata from the repo before performing anything else for the new version. This
@@ -125,7 +152,9 @@ class AptRepositoryReleaseServiceOverride(BaseModel):
125152
"""
126153

127154
repository = models.ForeignKey(
128-
AptRepository, on_delete=models.CASCADE, related_name="signing_service_release_overrides"
155+
AptRepository,
156+
on_delete=models.CASCADE,
157+
related_name="signing_service_release_overrides",
129158
)
130159
signing_service = models.ForeignKey(AptReleaseSigningService, on_delete=models.PROTECT)
131160
release_distribution = models.TextField()
@@ -134,6 +163,24 @@ class Meta:
134163
unique_together = (("repository", "release_distribution"),)
135164

136165

166+
class AptRepositoryReleasePackageSigningFingerprintOverride(BaseModel):
167+
"""
168+
Override the signing fingerprint that a single Release will use in this AptRepository for
169+
signing packages.
170+
"""
171+
172+
repository = models.ForeignKey(
173+
AptRepository,
174+
on_delete=models.CASCADE,
175+
related_name="package_signing_fingerprint_release_overrides",
176+
)
177+
package_signing_fingerprint = models.TextField(max_length=40)
178+
release_distribution = models.TextField()
179+
180+
class Meta:
181+
unique_together = (("repository", "release_distribution"),)
182+
183+
137184
def find_dist_components(package_ids, content_set):
138185
"""
139186
Given a list of package_ids and a content_set, this function will find all distribution-

pulp_deb/app/models/signing_service.py

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,30 @@
11
import os
2+
from pathlib import Path
3+
import shutil
4+
import subprocess
5+
from typing import Optional
26
import gnupg
37
import tempfile
48

59
from pulpcore.plugin.models import SigningService
10+
from importlib_resources import files
11+
12+
13+
def prepare_gpg(temp_directory_name, public_key, pubkey_fingerprint):
14+
# Prepare GPG:
15+
# gpg = gnupg.GPG(gnupghome=temp_directory_name)
16+
gpg = gnupg.GPG(keyring=str(Path(temp_directory_name) / ".keyring"))
17+
gpg.import_keys(public_key)
18+
imported_keys = gpg.list_keys()
19+
20+
if len(imported_keys) != 1:
21+
message = "We have imported more than one key! Aborting validation!"
22+
raise RuntimeError(message)
23+
24+
if imported_keys[0]["fingerprint"] != pubkey_fingerprint:
25+
message = "The signing service fingerprint does not appear to match its public key!"
26+
raise RuntimeError(message)
27+
return gpg
628

729

830
class AptReleaseSigningService(SigningService):
@@ -70,19 +92,7 @@ def validate(self):
7092
raise RuntimeError(message.format(signature_file, signature_type))
7193

7294
# Prepare GPG:
73-
gpg = gnupg.GPG(gnupghome=temp_directory_name)
74-
gpg.import_keys(self.public_key)
75-
imported_keys = gpg.list_keys()
76-
77-
if len(imported_keys) != 1:
78-
message = "We have imported more than one key! Aborting validation!"
79-
raise RuntimeError(message)
80-
81-
if imported_keys[0]["fingerprint"] != self.pubkey_fingerprint:
82-
message = (
83-
"The signing service fingerprint does not appear to match its public key!"
84-
)
85-
raise RuntimeError(message)
95+
gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint)
8696

8797
# Verify InRelease file
8898
inline_path = signatures.get("inline")
@@ -138,3 +148,105 @@ def validate(self):
138148
if verified.pubkey_fingerprint != self.pubkey_fingerprint:
139149
message = "'{}' appears to have been signed using the wrong key!"
140150
raise RuntimeError(message.format(detached_path))
151+
152+
153+
class AptPackageSigningService(SigningService):
154+
"""
155+
A model used for signing Apt packages.
156+
157+
The pubkey_fingerprint should be passed explicitly in the sign method.
158+
"""
159+
160+
def _env_variables(self, env_vars=None):
161+
# Prevent the signing service pubkey to be used for signing a package.
162+
# The pubkey should be provided explicitly.
163+
_env_vars = {"PULP_SIGNING_KEY_FINGERPRINT": None}
164+
if env_vars:
165+
_env_vars.update(env_vars)
166+
return super()._env_variables(_env_vars)
167+
168+
def sign(
169+
self,
170+
filename: str,
171+
env_vars: Optional[dict] = None,
172+
pubkey_fingerprint: Optional[str] = None,
173+
):
174+
"""
175+
Sign a package @filename using @pubkey_fingerprint.
176+
177+
Args:
178+
filename: The absolute path to the package to be signed.
179+
env_vars: (optional) Dict of env_vars to be passed to the signing script.
180+
pubkey_fingerprint: The V4 fingerprint that correlates with the private key to use.
181+
"""
182+
if not pubkey_fingerprint:
183+
raise ValueError("A pubkey_fingerprint must be provided.")
184+
_env_vars = env_vars or {}
185+
_env_vars["PULP_SIGNING_KEY_FINGERPRINT"] = pubkey_fingerprint
186+
return super().sign(filename, _env_vars)
187+
188+
def validate(self):
189+
"""
190+
Validate a signing service for an Apt package signature.
191+
192+
Specifically, it validates that self.signing_script can sign an apt package with
193+
the sample key self.pubkey and that the self.sign() method returns:
194+
195+
```json
196+
{"apt_package": "<path/to/package.deb>"}
197+
```
198+
199+
Recreates the check that "debsig-verify" would be doing because debsig-verify is
200+
complicated to set up correctly, and doing so would add a dependency that is not available
201+
on rpm-based systems.
202+
"""
203+
with tempfile.TemporaryDirectory() as temp_directory_name:
204+
# copy test deb package
205+
sample_deb = shutil.copy(
206+
files("pulp_deb").joinpath("tests/functional/data/packages/frigg_1.0_ppc64.deb"),
207+
temp_directory_name,
208+
)
209+
return_value = self.sign(sample_deb, pubkey_fingerprint=self.pubkey_fingerprint)
210+
try:
211+
signed_deb = return_value["deb_package"]
212+
except KeyError:
213+
raise Exception(f"Malformed output from signing script: {return_value}")
214+
215+
# Prepare GPG:
216+
gpg = prepare_gpg(temp_directory_name, self.public_key, self.pubkey_fingerprint)
217+
218+
self._validate_deb_package(signed_deb, self.pubkey_fingerprint, temp_directory_name, gpg)
219+
220+
221+
@staticmethod
222+
def _validate_deb_package(deb_package_path: str, pubkey_fingerprint: str, temp_directory_name: str, gpg: gnupg.GPG):
223+
"""
224+
Validate that the deb package at @deb_package_path is correctly signed.
225+
226+
This is a placeholder for future validation logic if needed.
227+
"""
228+
# unpack the archive
229+
cmd = ["ar", "x", deb_package_path]
230+
res = subprocess.run(cmd, cwd=temp_directory_name, capture_output=True)
231+
if res.returncode != 0:
232+
raise Exception(f"Failed to read package {deb_package_path}. Please check the package.")
233+
234+
# cat the unpacked archive bits together
235+
temp_dir = Path(temp_directory_name)
236+
with (temp_dir / "combined").open("wb") as combined:
237+
for filename in ("debian-binary", "control.*", "data.*"):
238+
# There will only be one control.tar.gz (or whatever) file, but we have to glob
239+
# and iterate because the compression type can vary.
240+
for x in temp_dir.glob(filename):
241+
with x.open("rb") as f:
242+
shutil.copyfileobj(f, combined)
243+
244+
# verify combined data with _gpgorigin detached signature
245+
with (temp_dir / "_gpgorigin").open("rb") as gpgorigin:
246+
verified = gpg.verify_file(gpgorigin, str(temp_dir / "combined"))
247+
if not verified.valid:
248+
raise Exception(f"GPG Verification of the signed package {deb_package_path} failed!")
249+
if verified.pubkey_fingerprint != pubkey_fingerprint:
250+
raise Exception(
251+
f"'{deb_package_path}' appears to have been signed using the wrong key!"
252+
)

pulp_deb/app/serializers/content_serializers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,37 @@ def deferred_validate(self, data):
694694

695695
return data
696696

697+
def validate(self, data):
698+
validated_data = super().validate(data)
699+
sign_package = self.context.get("sign_package", None)
700+
# choose branch, if not set externally
701+
if sign_package is None:
702+
sign_package = bool(
703+
validated_data.get("repository")
704+
and validated_data["repository"].package_signing_service
705+
)
706+
self.context["sign_package"] = sign_package
707+
708+
# normal branch
709+
if sign_package is False:
710+
return validated_data
711+
712+
# signing branch
713+
if not validated_data["repository"].package_signing_fingerprint:
714+
raise ValidationError(
715+
_(
716+
"To sign a package on upload, the associated Repository must set both"
717+
"'package_signing_service' and 'package_signing_fingerprint'."
718+
)
719+
)
720+
721+
if not validated_data.get("file") and not validated_data.get("upload"):
722+
raise ValidationError(
723+
_("To sign a package on upload, a file or upload must be provided.")
724+
)
725+
726+
return validated_data
727+
697728
class Meta(SinglePackageUploadSerializer.Meta):
698729
fields = (
699730
SinglePackageUploadSerializer.Meta.fields

0 commit comments

Comments
 (0)