Skip to content

Commit b9426b9

Browse files
committed
Fix handling PQC Signatures (pysequoia)
closes #2237 Assisted-By: claude-opus-4.6 (cherry picked from commit 8f2e71a)
1 parent 4cb385d commit b9426b9

7 files changed

Lines changed: 137 additions & 32 deletions

File tree

CHANGES/2237.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Don't blow up on encountering PQC signatures.

pulp_container/app/registry_api.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,8 +1493,10 @@ def put(self, request, path, pk):
14931493
except binascii.Error:
14941494
raise ManifestSignatureInvalid(digest=pk)
14951495

1496-
signature_json = extract_data_from_signature(signature_raw, manifest.digest)
1497-
if signature_json is None:
1496+
try:
1497+
signature_json = extract_data_from_signature(signature_raw, manifest.digest)
1498+
except ValueError as exc:
1499+
log.warning("Error processing signature on upload: {}".format(exc))
14981500
raise ManifestSignatureInvalid(digest=pk)
14991501

15001502
sig_digest = hashlib.sha256(signature_raw).hexdigest()

pulp_container/app/tasks/sign.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,10 @@ async def create_signature(manifest, reference, signing_service):
133133
data = sig_fp.read()
134134
encoded_sig = base64.b64encode(data).decode()
135135
sig_digest = hashlib.sha256(data).hexdigest()
136-
sig_json = extract_data_from_signature(data, manifest.digest)
136+
try:
137+
sig_json = extract_data_from_signature(data, manifest.digest)
138+
except ValueError:
139+
raise
137140
manifest_digest = sig_json["critical"]["image"]["docker-manifest-digest"]
138141

139142
signature = ManifestSignature(

pulp_container/app/tasks/sync_stages.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,10 @@ def create_manifest(self, manifest_data, raw_text_data, media_type, digest=None)
414414
def _create_signature_declarative_content(
415415
self, signature_raw, man_dc, name=None, signature_b64=None
416416
):
417-
signature_json = extract_data_from_signature(signature_raw, man_dc.content.digest)
418-
if signature_json is None:
417+
try:
418+
signature_json = extract_data_from_signature(signature_raw, man_dc.content.digest)
419+
except ValueError as exc:
420+
log.warning("Error processing signature on sync: {}".format(str(exc)))
419421
return
420422

421423
sig_digest = hashlib.sha256(signature_raw).hexdigest()

pulp_container/app/utils.py

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import base64
22
import hashlib
33
import fnmatch
4-
import re
5-
import subprocess
6-
import gnupg
74
import json
85
import logging
96
import time
@@ -15,6 +12,8 @@
1512
from functools import partial
1613
from rest_framework.exceptions import Throttled
1714

15+
from pysequoia.packet import PacketPile, Tag
16+
1817
from pulpcore.plugin.models import Artifact, Task
1918

2019
from pulp_container.constants import (
@@ -31,9 +30,6 @@
3130
SIGNATURE_SCHEMA,
3231
)
3332

34-
KEY_ID_REGEX_COMPILED = re.compile(r"keyid ([0-9A-F]+)")
35-
TIMESTAMP_REGEX_COMPILED = re.compile(r"created ([0-9]+)")
36-
3733
signature_validator = Draft7Validator(SIGNATURE_SCHEMA)
3834

3935
log = logging.getLogger(__name__)
@@ -78,6 +74,20 @@ def urlpath_sanitize(*args):
7874
return "/".join(segments)
7975

8076

77+
def keyid_from_fingerprint(fingerprint):
78+
"""Derive a key ID from an OpenPGP fingerprint.
79+
80+
For v4 fingerprints (40 hex chars / 20 bytes), the key ID is the last 8 bytes.
81+
For v6 fingerprints (64 hex chars / 32 bytes), the key ID is the first 8 bytes.
82+
"""
83+
if len(fingerprint) == 40:
84+
return fingerprint[-16:]
85+
elif len(fingerprint) == 64:
86+
return fingerprint[:16]
87+
else:
88+
raise ValueError(f"Unexpected fingerprint length: {len(fingerprint)}")
89+
90+
8191
def extract_data_from_signature(signature_raw, man_digest):
8292
"""
8393
Extract data from an "integrated" signature, aka a signed non-encrypted document.
@@ -90,37 +100,56 @@ def extract_data_from_signature(signature_raw, man_digest):
90100
dict: JSON representation of the document and available data about signature
91101
92102
"""
93-
gpg = gnupg.GPG()
94-
crypt_obj = gpg.decrypt(signature_raw, extra_args=["--skip-verify"])
95-
if not crypt_obj.data:
96-
log.info(
97-
"It is not possible to read the signed document, GPG error: {}".format(crypt_obj.stderr)
103+
try:
104+
pile = PacketPile.from_bytes(signature_raw)
105+
except Exception as exc:
106+
raise ValueError(
107+
"Signed document for manifest {} is un-parseable: {}".format(man_digest, str(exc))
98108
)
99-
return
109+
110+
literal_data = None
111+
signing_key_id = None
112+
signing_key_fingerprint = None
113+
signature_timestamp = None
114+
115+
for packet in pile:
116+
if packet.tag == Tag.Literal:
117+
literal_data = bytes(packet.literal_data)
118+
elif packet.tag == Tag.Signature:
119+
if packet.issuer_key_id is not None:
120+
signing_key_id = packet.issuer_key_id.upper()
121+
elif packet.issuer_fingerprint is not None:
122+
signing_key_fingerprint = packet.issuer_fingerprint.upper()
123+
signing_key_id = keyid_from_fingerprint(signing_key_fingerprint)
124+
else:
125+
raise ValueError(
126+
"Signature for manifest {} has no fingerprint or key_id".format(man_digest)
127+
)
128+
if packet.signature_created is not None:
129+
signature_timestamp = int(packet.signature_created.timestamp())
130+
131+
if not literal_data:
132+
raise ValueError("Signature for manifest {} has no literal data".format(man_digest))
100133

101134
try:
102-
sig_json = json.loads(crypt_obj.data)
135+
sig_json = json.loads(literal_data)
103136
except Exception as exc:
104-
log.info(
105-
"Signed document cannot be parsed to create a signature for {}."
106-
" Error: {}".format(man_digest, str(exc))
137+
raise ValueError(
138+
"Signed document cannot be parsed to create a signature for {}. Error: {}".format(
139+
man_digest, str(exc)
140+
)
107141
)
108-
return
109142

110143
errors = []
111144
for error in signature_validator.iter_errors(sig_json):
112145
errors.append(f'{".".join(error.path)}: {error.message}')
113146

114147
if errors:
115-
log.info("The signature for {} is not synced due to: {}".format(man_digest, errors))
116-
return
117-
118-
# decrypted and unverified signatures do not have prepopulated the key_id and timestamp
119-
# fields; thus, it is necessary to use the debugging utilities of gpg to extract these
120-
# fields since they are not encrypted and still readable without decrypting the signature first
121-
packets = subprocess.check_output(["gpg", "--list-packets"], input=signature_raw).decode()
122-
sig_json["signing_key_id"] = KEY_ID_REGEX_COMPILED.search(packets).group(1)
123-
sig_json["signature_timestamp"] = TIMESTAMP_REGEX_COMPILED.search(packets).group(1)
148+
raise ValueError("The signature for {} is not synced due to: {}".format(man_digest, errors))
149+
150+
sig_json["signing_key_id"] = signing_key_id
151+
sig_json["signing_key_fingerprint"] = signing_key_fingerprint
152+
sig_json["signature_timestamp"] = signature_timestamp
124153

125154
return sig_json
126155

pulp_container/tests/functional/api/test_sync_signatures.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
MANIFEST_LIST_TAG = "7.9"
1616
SIGSTORE_URL = "https://access.redhat.com/webassets/docker/content/sigstore"
1717

18+
UBI10_MICRO_REPOSITORY_NAME = "ubi10-micro"
19+
UBI10_MICRO_TAG = "latest"
20+
1821

1922
@pytest.fixture
2023
def synced_repository(
@@ -127,3 +130,67 @@ def test_sync_images_without_sigstore_requiring_signatures(
127130

128131
tags = container_tag_api.list(repository_version=synced_repository.latest_version_href).results
129132
assert len(tags) == 0
133+
134+
135+
def test_sync_image_with_pqc_signatures(
136+
delete_orphans_pre,
137+
container_repository_api,
138+
container_remote_api,
139+
container_signature_api,
140+
container_tag_api,
141+
container_manifest_api,
142+
gen_object_with_cleanup,
143+
):
144+
"""Sync ubi10-micro:latest from registry.access.redhat.com with all signatures."""
145+
data = gen_container_remote(
146+
url=REDHAT_REGISTRY_V2,
147+
upstream_name=UBI10_MICRO_REPOSITORY_NAME,
148+
policy="on_demand",
149+
include_tags=[UBI10_MICRO_TAG],
150+
sigstore=SIGSTORE_URL,
151+
)
152+
remote = gen_object_with_cleanup(container_remote_api, data)
153+
154+
repo = gen_object_with_cleanup(
155+
container_repository_api, ContainerContainerRepository(**gen_repo())
156+
)
157+
158+
sync_data = ContainerRepositorySyncURL(remote=remote.pulp_href, signed_only=False)
159+
response = container_repository_api.sync(repo.pulp_href, sync_data)
160+
monitor_task(response.task)
161+
162+
repo = container_repository_api.read(repo.pulp_href)
163+
164+
tags = container_tag_api.list(repository_version=repo.latest_version_href).results
165+
assert len(tags) == 1
166+
assert tags[0].name == UBI10_MICRO_TAG
167+
168+
signatures = container_signature_api.list(repository_version=repo.latest_version_href).results
169+
assert len(signatures) > 0
170+
171+
# Assert that a signature using one of the "old" Red Hat signing release keys exist
172+
expected_key_ids = ["199E2F91FD431D51", "E60D446E63405576"]
173+
assert any(s.key_id in expected_key_ids for s in signatures), (
174+
f"No signature found with key_ids {expected_key_ids}; "
175+
f"found key_ids: {sorted({s.key_id for s in signatures})}"
176+
)
177+
178+
# Assert that a signature using the Red Hat PQC (ML-DSA-87) signing key exists
179+
# Fingerprint: FCD355B305707A62DA143AB6E422397E50FE8467A2A95343D246D6276AFEDF8F
180+
# Key ID => first 8 bytes (16 hex chars)
181+
expected_key_id = "FCD355B305707A62"
182+
assert any(s.key_id == expected_key_id for s in signatures), (
183+
f"No signature found with key_id {expected_key_id!r}; "
184+
f"found key_ids: {sorted({s.key_id for s in signatures})}"
185+
)
186+
187+
# ubi10-micro:latest is a manifest list; collect all listed manifests and verify
188+
# that each has at least one signature
189+
manifest_list = container_manifest_api.read(tags[0].tagged_manifest)
190+
listed_manifests = [
191+
container_manifest_api.read(lm_href) for lm_href in manifest_list.listed_manifests
192+
]
193+
for lm in listed_manifests:
194+
lm_signatures = [s for s in signatures if s.signed_manifest == lm.pulp_href]
195+
assert len(lm_signatures) > 0, f"No signatures found for manifest {lm.digest}"
196+
assert all(s.name.startswith(lm.digest) for s in lm_signatures)

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
jsonschema>=4.4,<4.24
22
pulpcore>=3.49.0,<3.70
33
pyjwt[crypto]>=2.4,<2.10
4-
pycares<4.9
4+
pycares<4.9
5+
pysequoia==0.1.32

0 commit comments

Comments
 (0)