Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/2237.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Don't blow up on encountering PQC signatures.
6 changes: 4 additions & 2 deletions pulp_container/app/registry_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1545,8 +1545,10 @@ def put(self, request, path, pk):
except binascii.Error:
raise ManifestSignatureInvalid(digest=pk)

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

sig_digest = hashlib.sha256(signature_raw).hexdigest()
Expand Down
5 changes: 4 additions & 1 deletion pulp_container/app/tasks/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ async def create_signature(manifest, reference, signing_service):
data = sig_fp.read()
encoded_sig = base64.b64encode(data).decode()
sig_digest = hashlib.sha256(data).hexdigest()
sig_json = extract_data_from_signature(data, manifest.digest)
try:
sig_json = extract_data_from_signature(data, manifest.digest)
except ValueError:
raise
manifest_digest = sig_json["critical"]["image"]["docker-manifest-digest"]

signature = ManifestSignature(
Expand Down
6 changes: 4 additions & 2 deletions pulp_container/app/tasks/sync_stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,10 @@ def create_manifest(self, manifest_data, raw_text_data, media_type, digest=None)
def _create_signature_declarative_content(
self, signature_raw, man_dc, name=None, signature_b64=None
):
signature_json = extract_data_from_signature(signature_raw, man_dc.content.digest)
if signature_json is None:
try:
signature_json = extract_data_from_signature(signature_raw, man_dc.content.digest)
except ValueError as exc:
log.warning("Error processing signature on sync: {}".format(str(exc)))
return

sig_digest = hashlib.sha256(signature_raw).hexdigest()
Expand Down
81 changes: 55 additions & 26 deletions pulp_container/app/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import base64
import hashlib
import fnmatch
import re
import subprocess
import gnupg
import json
import logging
import time
Expand All @@ -15,6 +12,8 @@
from functools import partial
from rest_framework.exceptions import Throttled

from pysequoia.packet import PacketPile, Tag

from pulpcore.plugin.models import Artifact, Task
from pulpcore.plugin.util import get_domain

Expand All @@ -32,9 +31,6 @@
SIGNATURE_SCHEMA,
)

KEY_ID_REGEX_COMPILED = re.compile(r"keyid ([0-9A-F]+)")
TIMESTAMP_REGEX_COMPILED = re.compile(r"created ([0-9]+)")

signature_validator = Draft7Validator(SIGNATURE_SCHEMA)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -86,6 +82,20 @@ def urlpath_sanitize(*args):
return "/".join(segments)


def keyid_from_fingerprint(fingerprint):
"""Derive a key ID from an OpenPGP fingerprint.

For v4 fingerprints (40 hex chars / 20 bytes), the key ID is the last 8 bytes.
For v6 fingerprints (64 hex chars / 32 bytes), the key ID is the first 8 bytes.
"""
if len(fingerprint) == 40:
return fingerprint[-16:]
elif len(fingerprint) == 64:
return fingerprint[:16]
else:
raise ValueError(f"Unexpected fingerprint length: {len(fingerprint)}")


def extract_data_from_signature(signature_raw, man_digest):
"""
Extract data from an "integrated" signature, aka a signed non-encrypted document.
Expand All @@ -98,37 +108,56 @@ def extract_data_from_signature(signature_raw, man_digest):
dict: JSON representation of the document and available data about signature

"""
gpg = gnupg.GPG()
crypt_obj = gpg.decrypt(signature_raw, extra_args=["--skip-verify"])
if not crypt_obj.data:
log.info(
"It is not possible to read the signed document, GPG error: {}".format(crypt_obj.stderr)
try:
pile = PacketPile.from_bytes(signature_raw)
except Exception as exc:
raise ValueError(
"Signed document for manifest {} is un-parseable: {}".format(man_digest, str(exc))
)
return

literal_data = None
signing_key_id = None
signing_key_fingerprint = None
signature_timestamp = None

for packet in pile:
if packet.tag == Tag.Literal:
literal_data = bytes(packet.literal_data)
elif packet.tag == Tag.Signature:
if packet.issuer_key_id is not None:
signing_key_id = packet.issuer_key_id.upper()
Comment on lines +127 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the signing_key_fingerprint just stay None in this scenario? I don't see the fingerprint on the signature model so is it even needed?

Copy link
Copy Markdown
Contributor Author

@dralley dralley Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, not needed. Eventually, yes, because the model needs to add a fingerprint field. So I added a little bit extra just to make the next steps easier.

elif packet.issuer_fingerprint is not None:
signing_key_fingerprint = packet.issuer_fingerprint.upper()
signing_key_id = keyid_from_fingerprint(signing_key_fingerprint)
else:
raise ValueError(
"Signature for manifest {} has no fingerprint or key_id".format(man_digest)
)
if packet.signature_created is not None:
signature_timestamp = int(packet.signature_created.timestamp())

if not literal_data:
raise ValueError("Signature for manifest {} has no literal data".format(man_digest))

try:
sig_json = json.loads(crypt_obj.data)
sig_json = json.loads(literal_data)
except Exception as exc:
log.info(
"Signed document cannot be parsed to create a signature for {}."
" Error: {}".format(man_digest, str(exc))
raise ValueError(
"Signed document cannot be parsed to create a signature for {}. Error: {}".format(
man_digest, str(exc)
)
)
return

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

if errors:
log.info("The signature for {} is not synced due to: {}".format(man_digest, errors))
return

# decrypted and unverified signatures do not have prepopulated the key_id and timestamp
# fields; thus, it is necessary to use the debugging utilities of gpg to extract these
# fields since they are not encrypted and still readable without decrypting the signature first
packets = subprocess.check_output(["gpg", "--list-packets"], input=signature_raw).decode()
sig_json["signing_key_id"] = KEY_ID_REGEX_COMPILED.search(packets).group(1)
sig_json["signature_timestamp"] = TIMESTAMP_REGEX_COMPILED.search(packets).group(1)
raise ValueError("The signature for {} is not synced due to: {}".format(man_digest, errors))

sig_json["signing_key_id"] = signing_key_id
sig_json["signing_key_fingerprint"] = signing_key_fingerprint
sig_json["signature_timestamp"] = signature_timestamp

return sig_json

Expand Down
8 changes: 4 additions & 4 deletions pulp_container/tests/functional/api/test_sync_signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,17 @@ def test_sync_image_with_pqc_signatures(
).results
assert len(signatures) > 0

# Assert that signature using the "old" Red Hat signing release keys exist
# Assert that a signature using one of the "old" Red Hat signing release keys exist
expected_key_ids = ["199E2F91FD431D51", "E60D446E63405576"]
assert any(s.key_id in expected_key_ids for s in signatures), (
f"No signature found with key_ids {expected_key_ids}; "
f"found key_ids: {sorted({s.key_id for s in signatures})}"
)

# First 8 bytes (16 hex chars) of the fingerprint the Red Hat PQC (ML-DSA-87) signing key
# which is FCD355B305707A62DA143AB6E422397E50FE8467A2A95343D246D6276AFEDF8F
# Assert that a signature using the Red Hat PQC (ML-DSA-87) signing key exists
# Fingerprint: FCD355B305707A62DA143AB6E422397E50FE8467A2A95343D246D6276AFEDF8F
# Key ID => first 8 bytes (16 hex chars)
expected_key_id = "FCD355B305707A62"
pytest.xfail("pulp_container does not yet support PQC (ML-DSA-87) signatures")
assert any(s.key_id == expected_key_id for s in signatures), (
f"No signature found with key_id {expected_key_id!r}; "
f"found key_ids: {sorted({s.key_id for s in signatures})}"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies = [
"jsonschema>=4.4,<4.27",
"pulpcore>=3.73.2,<3.115",
"pyjwt[crypto]>=2.4,<2.13",
"pysequoia==0.1.32"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the author promised to switch to semantic versioning after the next release

]

[project.urls]
Expand Down
Loading