Skip to content

Commit e7c5250

Browse files
committed
Support embedded public key
1 parent c901341 commit e7c5250

2 files changed

Lines changed: 133 additions & 6 deletions

File tree

‎pulp_python/app/provenance.py‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88
from cryptography.hazmat.primitives.asymmetric import padding as crypto_padding
99
from cryptography.x509 import load_der_x509_certificate
1010
from django.conf import settings
11-
from pydantic import BaseModel, ConfigDict, Field
11+
from pydantic import Base64Bytes, BaseModel, ConfigDict, Field, model_validator
1212
from pydantic.alias_generators import to_snake
1313
from pypi_attestations import Attestation as _UpstreamAttestation
1414
from pypi_attestations import (
1515
Distribution,
1616
Envelope, # noqa - needed in module namespace for Pydantic model rebuild
1717
Publisher,
1818
VerificationError,
19-
VerificationMaterial,
2019
)
20+
from pypi_attestations import VerificationMaterial as _UpstreamVerificationMaterial
2121
from sigstore.dsse import Envelope as DSSEEnvelope
2222
from sigstore.dsse import _pae
2323

@@ -28,6 +28,30 @@
2828
SLSA_PROVENANCE_V02 = "https://slsa.dev/provenance/v0.2"
2929

3030

31+
class VerificationMaterial(_UpstreamVerificationMaterial):
32+
"""Extended verification material that supports optional certificate and public key.
33+
34+
PEP 740 requires a certificate, but this extension allows attestations signed
35+
with a custom key where the certificate is absent. The public_key field is
36+
accepted as an extra field and only present in the output when provided.
37+
"""
38+
39+
model_config = ConfigDict(extra="allow")
40+
41+
certificate: Base64Bytes | None = None
42+
43+
@model_validator(mode="after")
44+
def _validate_fields(self):
45+
# public_key cannot be present when certificate is present
46+
unexpected = set(self.model_extra or {}) - {"public_key"}
47+
if unexpected:
48+
raise ValueError(f"unexpected fields in verification_material: {unexpected}")
49+
public_key = getattr(self, "public_key", None)
50+
if self.certificate is not None and public_key is not None:
51+
raise ValueError("verification_material cannot contain both certificate and public_key")
52+
return self
53+
54+
3155
class _PermissivePolicy:
3256
"""A permissive verification policy that always succeeds."""
3357

@@ -110,6 +134,8 @@ def _has_valid_certificate(attestation):
110134
if vm is None:
111135
return False
112136
cert_bytes = vm.certificate
137+
if cert_bytes is None:
138+
return False
113139
load_der_x509_certificate(cert_bytes)
114140
return True
115141
except (ValueError, Exception):
@@ -180,6 +206,30 @@ def _verify_signature(attestation, public_key):
180206
raise VerificationError(f"signature verification failed: {e}")
181207

182208

209+
def _verify_embedded_key(attestation, server_key):
210+
"""Verify that the embedded public key matches the server-configured key."""
211+
vm = attestation.verification_material
212+
public_key = getattr(vm, "public_key", None) if vm else None
213+
if public_key is None:
214+
return
215+
try:
216+
embedded_key = serialization.load_pem_public_key(public_key.encode())
217+
except (ValueError, Exception) as e:
218+
raise VerificationError(f"invalid embedded public key: {e}")
219+
server_key_bytes = server_key.public_bytes(
220+
serialization.Encoding.PEM,
221+
serialization.PublicFormat.SubjectPublicKeyInfo,
222+
)
223+
embedded_key_bytes = embedded_key.public_bytes(
224+
serialization.Encoding.PEM,
225+
serialization.PublicFormat.SubjectPublicKeyInfo,
226+
)
227+
if server_key_bytes != embedded_key_bytes:
228+
raise VerificationError(
229+
"embedded public key does not match server-configured ATTESTATION_VERIFICATION_KEY"
230+
)
231+
232+
183233
def verify_provenance(filename, sha256, provenance, offline=True):
184234
"""Verify the provenance object is valid for the package.
185235
@@ -205,8 +255,10 @@ def verify_provenance(filename, sha256, provenance, offline=True):
205255
_enrich_publisher_from_statement(stmt, publisher)
206256
if verification_key:
207257
_verify_signature(attestation, verification_key)
258+
_verify_embedded_key(attestation, verification_key)
208259
else:
209260
raise VerificationError(
210-
"Attestation has no Sigstore certificate and no custom "
211-
"verification key is configured (ATTESTATION_VERIFICATION_KEY)"
261+
"Attestation has no Sigstore certificate or no custom "
262+
"verification key configured via ATTESTATION_VERIFICATION_KEY "
263+
"(embedded key in verification_material is optional)"
212264
)

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

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,33 @@ def _b64(data: bytes) -> str:
6767
return base64.b64encode(data).decode()
6868

6969

70-
def _make_attestation(statement_bytes, signature_bytes):
70+
def _make_attestation(statement_bytes, signature_bytes, verification_material=None):
7171
"""Return a single PEP-740 Attestation dict (SLSA flavour)."""
7272
return {
7373
"version": 1,
74-
"verification_material": None,
74+
"verification_material": verification_material,
7575
"envelope": {
7676
"statement": _b64(statement_bytes),
7777
"signature": _b64(signature_bytes),
7878
},
7979
}
8080

8181

82+
def _read_public_key_pem():
83+
"""Read the test public key as a PEM string."""
84+
with open(TEST_PUBLIC_KEY_PATH) as f:
85+
return f.read()
86+
87+
88+
def _make_embedded_key_material(public_key_pem):
89+
"""Build a verification_material dict with an embedded public key."""
90+
return {
91+
"certificate": None,
92+
"public_key": public_key_pem,
93+
"transparency_entries": [{"log_index": 0}],
94+
}
95+
96+
8297
def _make_provenance(attestation):
8398
"""Wrap an attestation into a full PEP-740 Provenance object."""
8499
return {
@@ -245,3 +260,63 @@ def test_slsa_attestation_via_content_upload(
245260
assert publisher["builder_id"] == "https://konflux-ci.dev/calunga"
246261
assert publisher["build_type"] == "https://konflux-ci.dev/PythonWheelBuild@v1"
247262
assert publisher["kind"] == "konflux-ci.dev"
263+
264+
265+
def test_slsa_embedded_key_accepted(
266+
python_bindings, python_content_factory, monitor_task, test_private_key, _provenance_file
267+
):
268+
"""An attestation with an embedded public key matching the server key is accepted."""
269+
content = python_content_factory()
270+
271+
stmt = _build_statement(content.filename, content.sha256)
272+
sig = _sign(stmt, test_private_key)
273+
vm = _make_embedded_key_material(_read_public_key_pem())
274+
att = _make_attestation(stmt, sig, verification_material=vm)
275+
prov = _make_provenance(att)
276+
277+
task = python_bindings.ContentProvenanceApi.create(
278+
package=content.pulp_href,
279+
file=_provenance_file(prov),
280+
verify=True,
281+
).task
282+
result = monitor_task(task)
283+
284+
prov_obj = python_bindings.ContentProvenanceApi.read(result.created_resources[-1])
285+
assert prov_obj.package == content.pulp_href
286+
stored_vm = prov_obj.provenance["attestation_bundles"][0]["attestations"][0][
287+
"verification_material"
288+
]
289+
assert stored_vm["public_key"] == _read_public_key_pem()
290+
291+
292+
def test_slsa_embedded_key_mismatch_rejected(
293+
python_bindings, python_content_factory, monitor_task, test_private_key, _provenance_file
294+
):
295+
"""An attestation with an embedded key that does not match the server key is rejected."""
296+
content = python_content_factory()
297+
298+
stmt = _build_statement(content.filename, content.sha256)
299+
sig = _sign(stmt, test_private_key)
300+
wrong_key = (
301+
"-----BEGIN PUBLIC KEY-----\n"
302+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArvxtuKOCCHGNd6agbC4b\n"
303+
"zX9S7qMnMRdz9bxxzkPf9a4T75St3EcrNQYxLqzcDBfeiiLa1wXQoraqFSGkvqX+\n"
304+
"YDFmtYRgnXEujUwYqHQBYrKtrQHxrQ2J3V1e+PqhJ4HCQq/uRZdx6KJ5DOlpYuJM\n"
305+
"aL2m7IozNJJkgjQH1frmDfM5/GuPVbgwN0QDnC0oSzNJFKtdVMD3SPnQGMPQ9qxf\n"
306+
"DiTZOk1tO3VD/xXoNJ6Zgf0D5RvGyLAfLflgojKImHezgpdE6iQyZAVU2ShKMq3M\n"
307+
"K+XtX39/ajXHJ1FCLXlRFoNoHltWioyvkmXHbhb9OxGncB+x6idSjgp7AR5y15HN\n"
308+
"DwIDAQAB\n"
309+
"-----END PUBLIC KEY-----\n"
310+
)
311+
vm = _make_embedded_key_material(wrong_key)
312+
att = _make_attestation(stmt, sig, verification_material=vm)
313+
prov = _make_provenance(att)
314+
315+
task = python_bindings.ContentProvenanceApi.create(
316+
package=content.pulp_href,
317+
file=_provenance_file(prov),
318+
verify=True,
319+
).task
320+
with pytest.raises(PulpTaskError) as exc_info:
321+
monitor_task(task)
322+
assert "embedded public key does not match" in exc_info.value.task.error["description"]

0 commit comments

Comments
 (0)