Skip to content

Commit fed7639

Browse files
committed
wip - domain support
1 parent 9104cbe commit fed7639

7 files changed

Lines changed: 139 additions & 41 deletions

File tree

‎.github/workflows/scripts/before_install.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ legacy_component_name: "pulp_python"
5050
component_name: "python"
5151
component_version: "${COMPONENT_VERSION}"
5252
pulp_env: {}
53-
pulp_settings: {"allowed_export_paths": "/tmp", "allowed_import_paths": "/tmp", "api_root": "/pulp/", "attestation_verification_key": "/etc/pki/attestation/test-key.pem", "orphan_protection_time": 0, "pypi_api_hostname": "https://pulp:443"}
53+
pulp_settings: {"allowed_export_paths": "/tmp", "allowed_import_paths": "/tmp", "api_root": "/pulp/", "attestation_verification_keys": {"default": "/etc/pki/attestation/test-key.pem"}, "orphan_protection_time": 0, "pypi_api_hostname": "https://pulp:443"}
5454
pulp_scheme: "https"
5555
image:
5656
name: "pulp"

‎CHANGES/1340.feature‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Added custom (non-PEP 740) support for verifying attestations signed with a server-configured public key via `ATTESTATION_VERIFICATION_KEY`.
1+
Added custom (non-PEP 740) support for verifying attestations signed with a per-domain public key configured via `ATTESTATION_VERIFICATION_KEYS`.

‎docs/user/guides/attestation.md‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,27 +100,32 @@ Pulp Python supports verifying attestations signed with a custom public key inst
100100
certificate. This is useful for environments that sign packages with their own key (e.g. internal
101101
build systems) rather than using Sigstore's keyless signing.
102102

103-
To enable this, configure the `ATTESTATION_VERIFICATION_KEY` setting with the path to a PEM public
104-
key file:
103+
To enable this, configure the `ATTESTATION_VERIFICATION_KEYS` setting, which maps a domain name to
104+
the path of a PEM public key file. Each domain trusts only its own key. Without domains enabled, all
105+
content belongs to the `default` domain.
105106

106107
```
107-
PULP_ATTESTATION_VERIFICATION_KEY=/path/to/public-key.pem
108+
PULP_ATTESTATION_VERIFICATION_KEYS=@json {"default": "/path/to/public-key.pem"}
108109
```
109110

110-
When this setting is configured, Pulp uses two verification paths that coexist on the same server:
111+
Pulp uses two verification paths that coexist on the same server:
111112

112113
- **Attestations with a Sigstore certificate** are verified through the standard PEP 740 path,
113-
regardless of whether `ATTESTATION_VERIFICATION_KEY` is set.
114-
- **Attestations without a Sigstore certificate** are verified against the server-configured key using RSA
115-
PKCS1v15 signatures.
114+
regardless of `ATTESTATION_VERIFICATION_KEYS`.
115+
- **Attestations without a Sigstore certificate** are verified against the key configured for the
116+
current domain using RSA PKCS1v15 signatures. In a domain without a configured key, they are
117+
rejected.
116118

117119
This means a single Pulp instance can host packages with Sigstore certificates and
118120
internally-signed packages with key-based attestations. Each attestation is independently
119121
routed to the appropriate verification path based on whether it contains a certificate.
120122
Key-signed attestations must omit `verification_material` or set it to `null`.
123+
Attestations with a certificate that can't be parsed are rejected.
121124

122125
### Limitations
123126

124-
- A single key is used across all repositories and domains.
127+
- Each domain can have only one key.
128+
- Attestations are verified only on upload. Adding existing content to another repository does not
129+
verify them again.
125130
- The key-based path only supports RSA PKCS1v15 with SHA-256 signatures.
126131
- Publisher enrichment is supported for SLSA v0.2 provenance statements only.

‎pulp_python/app/provenance.py‎

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
2-
import logging
32
from typing import Annotated, Literal, Union, get_args
43
from urllib.parse import urlparse
54

6-
from cryptography.exceptions import InvalidSignature
5+
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
76
from cryptography.hazmat.primitives import hashes, serialization
87
from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding
8+
from cryptography.hazmat.primitives.asymmetric import rsa
99
from cryptography.x509 import load_der_x509_certificate
1010
from django.conf import settings
1111
from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@
2121
from sigstore.dsse import Envelope as DSSEEnvelope
2222
from sigstore.dsse import _pae
2323

24-
log = logging.getLogger(__name__)
24+
from pulpcore.plugin.util import get_domain
2525

2626
_verification_key_cache = {}
2727

@@ -92,28 +92,44 @@ class Provenance(BaseModel):
9292
attestation_bundles: list[AttestationBundle]
9393

9494

95-
def _load_verification_key():
96-
"""Load the configured attestation verification public key, with caching."""
97-
key_path = getattr(settings, "ATTESTATION_VERIFICATION_KEY", None)
95+
def _load_verification_key(domain_name):
96+
"""Load the attestation verification public key configured for the domain, with caching."""
97+
keys = settings.ATTESTATION_VERIFICATION_KEYS
98+
if not isinstance(keys, dict):
99+
raise VerificationError(
100+
"ATTESTATION_VERIFICATION_KEYS must map domain names to public key paths"
101+
)
102+
key_path = keys.get(domain_name)
98103
if not key_path:
99104
return None
100105
if key_path not in _verification_key_cache:
101-
with open(key_path, "rb") as f:
102-
_verification_key_cache[key_path] = serialization.load_pem_public_key(f.read())
106+
try:
107+
with open(key_path, "rb") as f:
108+
key = serialization.load_pem_public_key(f.read())
109+
except (OSError, ValueError, UnsupportedAlgorithm) as e:
110+
raise VerificationError(
111+
f"could not load the verification key for domain '{domain_name}' "
112+
f"from {key_path}: {e}"
113+
)
114+
if not isinstance(key, rsa.RSAPublicKey):
115+
raise VerificationError(
116+
f"the verification key for domain '{domain_name}' in {key_path} "
117+
"must be an RSA public key"
118+
)
119+
_verification_key_cache[key_path] = key
103120
return _verification_key_cache[key_path]
104121

105122

106-
def _has_valid_certificate(attestation):
107-
"""Check whether the attestation contains a valid X.509 certificate."""
108-
try:
109-
vm = attestation.verification_material
110-
if vm is None:
111-
return False
112-
cert_bytes = vm.certificate
113-
load_der_x509_certificate(cert_bytes)
114-
return True
115-
except (ValueError, Exception):
123+
def _has_certificate(attestation):
124+
"""Check whether the attestation carries a certificate, rejecting unparseable ones."""
125+
vm = attestation.verification_material
126+
if vm is None:
116127
return False
128+
try:
129+
load_der_x509_certificate(vm.certificate)
130+
except ValueError as e:
131+
raise VerificationError(f"invalid X.509 certificate in verification_material: {e}")
132+
return True
117133

118134

119135
def _verify_statement_subject(attestation, dist):
@@ -185,28 +201,30 @@ def verify_provenance(filename, sha256, provenance, offline=True):
185201
186202
Each attestation is routed to one of two coexisting verification paths:
187203
1. Attestations with a valid Sigstore certificate follow the standard PEP 740 path.
188-
2. Attestations without a certificate are verified against the server-configured
189-
ATTESTATION_VERIFICATION_KEY using RSA PKCS1v15 signatures, with support for SLSA
190-
v0.2 publisher enrichment.
204+
2. Attestations without a certificate are verified against the key configured for the
205+
current domain in ATTESTATION_VERIFICATION_KEYS using RSA PKCS1v15 signatures, with
206+
support for SLSA v0.2 publisher enrichment.
207+
Attestations with a certificate that can't be parsed are rejected.
191208
"""
192209
dist = Distribution(name=filename, digest=sha256)
193210
for bundle in provenance.attestation_bundles:
194211
publisher = bundle.publisher
195212
policy = publisher._as_policy()
196213
for attestation in bundle.attestations:
197-
if _has_valid_certificate(attestation):
214+
if _has_certificate(attestation):
198215
sig_bundle = attestation.to_bundle()
199216
checkpoint = sig_bundle.log_entry._inner.inclusion_proof.checkpoint
200217
staging = "sigstage.dev" in checkpoint.envelope
201218
attestation.verify(policy, dist, staging=staging, offline=offline)
202219
else:
203-
verification_key = _load_verification_key()
220+
domain_name = get_domain().name
221+
verification_key = _load_verification_key(domain_name)
204222
if verification_key:
205223
stmt = _verify_statement_subject(attestation, dist)
206224
_enrich_publisher_from_statement(stmt, publisher)
207225
_verify_signature(attestation, verification_key)
208226
else:
209227
raise VerificationError(
210-
"Attestation has no Sigstore certificate and no custom "
211-
"verification key configured via ATTESTATION_VERIFICATION_KEY"
228+
"Attestation has no Sigstore certificate, and "
229+
f"ATTESTATION_VERIFICATION_KEYS has no key for domain '{domain_name}'"
212230
)

‎pulp_python/app/settings.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
PYPI_API_HOSTNAME = "https://" + socket.getfqdn()
55
PYPI_PATH_PREFIX = "/pypi/"
66

7-
ATTESTATION_VERIFICATION_KEY = None
7+
ATTESTATION_VERIFICATION_KEYS = {}
88

99
DRF_ACCESS_POLICY = {
1010
"dynaconf_merge_unique": True,

‎pulp_python/tests/functional/api/test_key_signed_attestations.py‎

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@
44
attestations that carry an RSA signature instead of a Sigstore certificate.
55
66
A static test keypair is shipped in .ci/assets/keys/ and the matching
7-
public key is configured as PULP_ATTESTATION_VERIFICATION_KEY so that
7+
public key is configured for the default domain in PULP_ATTESTATION_VERIFICATION_KEYS so that
88
signature verification is fully exercised end-to-end.
99
"""
1010

1111
import base64
12+
import hashlib
1213
import json
1314
import os
1415

1516
import pytest
1617
from cryptography.hazmat.primitives import hashes, serialization
1718
from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding
19+
from cryptography.hazmat.primitives.asymmetric import rsa
1820

1921
from pulpcore.tests.functional.utils import PulpTaskError
2022

23+
from pulp_python.tests.functional.constants import PYTHON_EGG_FILENAME, PYTHON_URL
24+
2125
DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json"
2226

2327
TEST_PRIVATE_KEY_PATH = "/etc/pki/attestation/test-key-private.pem"
@@ -98,13 +102,15 @@ def _make_provenance(attestation):
98102

99103
@pytest.fixture(scope="session")
100104
def test_private_key(pulp_settings):
101-
"""Load the CI-generated test attestation signing key."""
105+
"""Load the static test attestation signing key from .ci/assets/keys/."""
102106
if not os.path.exists(TEST_PRIVATE_KEY_PATH):
103107
pytest.skip(
104108
f"Test attestation private key not found at {TEST_PRIVATE_KEY_PATH} (not running in CI container?)"
105109
)
106-
if pulp_settings.ATTESTATION_VERIFICATION_KEY != TEST_PUBLIC_KEY_PATH:
107-
pytest.fail(f"ATTESTATION_VERIFICATION_KEY is not set to {TEST_PUBLIC_KEY_PATH}")
110+
if pulp_settings.ATTESTATION_VERIFICATION_KEYS.get("default") != TEST_PUBLIC_KEY_PATH:
111+
pytest.fail(
112+
f"ATTESTATION_VERIFICATION_KEYS does not map the default domain to {TEST_PUBLIC_KEY_PATH}"
113+
)
108114
with open(TEST_PRIVATE_KEY_PATH, "rb") as f:
109115
return serialization.load_pem_private_key(f.read(), password=None)
110116

@@ -244,3 +250,71 @@ def test_key_signed_attestation_via_content_upload(
244250
assert publisher["builder_id"] == "https://konflux-ci.dev/calunga"
245251
assert publisher["build_type"] == "https://konflux-ci.dev/PythonWheelBuild@v1"
246252
assert publisher["kind"] == "konflux-ci.dev"
253+
254+
255+
def test_invalid_certificate_not_verified_with_key(
256+
python_bindings, python_content_factory, monitor_task, test_private_key, _provenance_file
257+
):
258+
"""An attestation with an unparseable certificate is rejected, not verified with the key."""
259+
content = python_content_factory()
260+
261+
stmt = _build_statement(content.filename, content.sha256)
262+
att = _make_attestation(stmt, _sign(stmt, test_private_key))
263+
att["verification_material"] = {
264+
"certificate": _b64(b"not-a-certificate"),
265+
"transparency_entries": [{"log_index": 0}],
266+
}
267+
prov = _make_provenance(att)
268+
269+
task = python_bindings.ContentProvenanceApi.create(
270+
package=content.pulp_href,
271+
file=_provenance_file(prov),
272+
verify=True,
273+
).task
274+
with pytest.raises(PulpTaskError) as exc_info:
275+
monitor_task(task)
276+
assert "invalid X.509 certificate" in exc_info.value.task.error["description"]
277+
278+
279+
def test_key_signed_attestation_with_other_key_rejected(
280+
python_bindings, python_content_factory, monitor_task, test_private_key, _provenance_file
281+
):
282+
"""An attestation signed with a key other than the domain's configured key is rejected."""
283+
content = python_content_factory()
284+
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
285+
286+
stmt = _build_statement(content.filename, content.sha256)
287+
att = _make_attestation(stmt, _sign(stmt, other_key))
288+
prov = _make_provenance(att)
289+
290+
task = python_bindings.ContentProvenanceApi.create(
291+
package=content.pulp_href,
292+
file=_provenance_file(prov),
293+
verify=True,
294+
).task
295+
with pytest.raises(PulpTaskError) as exc_info:
296+
monitor_task(task)
297+
assert "signature verification failed" in exc_info.value.task.error["description"]
298+
299+
300+
def test_key_signed_attestation_rejected_in_domain_without_key(
301+
python_bindings, domain_factory, download_python_file, monitor_task, test_private_key
302+
):
303+
"""A key configured for the default domain is not trusted in another domain."""
304+
domain = domain_factory()
305+
python_file = download_python_file(PYTHON_EGG_FILENAME, PYTHON_URL)
306+
with open(python_file, "rb") as f:
307+
sha256 = hashlib.sha256(f.read()).hexdigest()
308+
309+
stmt = _build_statement(PYTHON_EGG_FILENAME, sha256)
310+
att = _make_attestation(stmt, _sign(stmt, test_private_key))
311+
body = {
312+
"file": python_file,
313+
"relative_path": PYTHON_EGG_FILENAME,
314+
"attestations": json.dumps([att]),
315+
}
316+
task = python_bindings.ContentPackagesApi.create(**body, pulp_domain=domain.name).task
317+
with pytest.raises(PulpTaskError) as exc_info:
318+
monitor_task(task)
319+
err_msg = f"ATTESTATION_VERIFICATION_KEYS has no key for domain '{domain.name}'"
320+
assert err_msg in exc_info.value.task.error["description"]

‎template_config.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ pulp_settings:
4747
allowed_import_paths: "/tmp"
4848
api_root: "/pulp/"
4949
orphan_protection_time: 0
50-
attestation_verification_key: "/etc/pki/attestation/test-key.pem"
50+
attestation_verification_keys:
51+
default: "/etc/pki/attestation/test-key.pem"
5152
pypi_api_hostname: "https://pulp:443"
5253
pulp_settings_azure:
5354
MEDIA_ROOT: ""

0 commit comments

Comments
 (0)