Skip to content

Commit 8faeace

Browse files
jkuramonpetgrave64
andauthored
Add Rekor v2 client (#1422)
Co-authored-by: Ramon Petgrave <[email protected]>
1 parent c78b1b9 commit 8faeace

File tree

6 files changed

+405
-67
lines changed

6 files changed

+405
-67
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ All versions prior to 0.9.0 are untracked.
2020
[#1402](https://github.com/sigstore/sigstore-python/pull/1402)
2121

2222

23+
* Added a `RekorV2Client` for posting new entries to a Rekor V2 instance.
24+
[#1400](https://github.com/sigstore/sigstore-python/pull/1422)
25+
2326
### Fixed
2427

2528
* Avoid instantiation issues with `TransparencyLogEntry` when `InclusionPromise` is not present.

sigstore/_internal/rekor/__init__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,86 @@
1616
APIs for interacting with Rekor.
1717
"""
1818

19+
from __future__ import annotations
20+
1921
import base64
22+
from abc import ABC, abstractmethod
23+
from typing import Any, NewType
2024

2125
import rekor_types
26+
import requests
2227
from cryptography.x509 import Certificate
2328

2429
from sigstore._utils import base64_encode_pem_cert
30+
from sigstore.dsse import Envelope
2531
from sigstore.hashes import Hashed
32+
from sigstore.models import LogEntry
2633

2734
__all__ = [
2835
"_hashedrekord_from_parts",
2936
]
3037

38+
EntryRequestBody = NewType("EntryRequestBody", dict[str, Any])
39+
40+
41+
class RekorClientError(Exception):
42+
"""
43+
A generic error in the Rekor client.
44+
"""
45+
46+
def __init__(self, http_error: requests.HTTPError):
47+
"""
48+
Create a new `RekorClientError` from the given `requests.HTTPError`.
49+
"""
50+
if http_error.response is not None:
51+
try:
52+
error = rekor_types.Error.model_validate_json(http_error.response.text)
53+
super().__init__(f"{error.code}: {error.message}")
54+
except Exception:
55+
super().__init__(
56+
f"Rekor returned an unknown error with HTTP {http_error.response.status_code}"
57+
)
58+
else:
59+
super().__init__(f"Unexpected Rekor error: {http_error}")
60+
61+
62+
class RekorLogSubmitter(ABC):
63+
"""
64+
Abstract class to represent a Rekor log entry submitter.
65+
66+
Intended to be implemented by RekorClient and RekorV2Client.
67+
"""
68+
69+
@abstractmethod
70+
def create_entry(
71+
self,
72+
request: EntryRequestBody,
73+
) -> LogEntry:
74+
"""
75+
Submit the request to Rekor.
76+
"""
77+
pass
78+
79+
@classmethod
80+
@abstractmethod
81+
def _build_hashed_rekord_request(
82+
self, hashed_input: Hashed, signature: bytes, certificate: Certificate
83+
) -> EntryRequestBody:
84+
"""
85+
Construct a hashed rekord request to submit to Rekor.
86+
"""
87+
pass
88+
89+
@classmethod
90+
@abstractmethod
91+
def _build_dsse_request(
92+
self, envelope: Envelope, certificate: Certificate
93+
) -> EntryRequestBody:
94+
"""
95+
Construct a dsse request to submit to Rekor.
96+
"""
97+
pass
98+
3199

32100
# TODO: This should probably live somewhere better.
33101
def _hashedrekord_from_parts(

sigstore/_internal/rekor/client.py

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from __future__ import annotations
2020

21+
import base64
2122
import json
2223
import logging
2324
from abc import ABC
@@ -26,8 +27,17 @@
2627

2728
import rekor_types
2829
import requests
30+
from cryptography.hazmat.primitives import serialization
31+
from cryptography.x509 import Certificate
2932

3033
from sigstore._internal import USER_AGENT
34+
from sigstore._internal.rekor import (
35+
EntryRequestBody,
36+
RekorClientError,
37+
RekorLogSubmitter,
38+
)
39+
from sigstore.dsse import Envelope
40+
from sigstore.hashes import Hashed
3141
from sigstore.models import LogEntry
3242

3343
_logger = logging.getLogger(__name__)
@@ -62,27 +72,6 @@ def from_response(cls, dict_: dict[str, Any]) -> RekorLogInfo:
6272
)
6373

6474

65-
class RekorClientError(Exception):
66-
"""
67-
A generic error in the Rekor client.
68-
"""
69-
70-
def __init__(self, http_error: requests.HTTPError):
71-
"""
72-
Create a new `RekorClientError` from the given `requests.HTTPError`.
73-
"""
74-
if http_error.response is not None:
75-
try:
76-
error = rekor_types.Error.model_validate_json(http_error.response.text)
77-
super().__init__(f"{error.code}: {error.message}")
78-
except Exception:
79-
super().__init__(
80-
f"Rekor returned an unknown error with HTTP {http_error.response.status_code}"
81-
)
82-
else:
83-
super().__init__(f"Unexpected Rekor error: {http_error}")
84-
85-
8675
class _Endpoint(ABC):
8776
def __init__(self, url: str, session: requests.Session) -> None:
8877
self.url = url
@@ -145,13 +134,12 @@ def get(
145134

146135
def post(
147136
self,
148-
proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse,
137+
payload: EntryRequestBody,
149138
) -> LogEntry:
150139
"""
151140
Submit a new entry for inclusion in the Rekor log.
152141
"""
153142

154-
payload = proposed_entry.model_dump(mode="json", by_alias=True)
155143
_logger.debug(f"proposed: {json.dumps(payload)}")
156144

157145
resp: requests.Response = self.session.post(self.url, json=payload)
@@ -216,7 +204,7 @@ def post(
216204
return oldest_entry
217205

218206

219-
class RekorClient:
207+
class RekorClient(RekorLogSubmitter):
220208
"""The internal Rekor client"""
221209

222210
def __init__(self, url: str) -> None:
@@ -261,3 +249,63 @@ def log(self) -> RekorLog:
261249
Returns a `RekorLog` adapter for making requests to a Rekor log.
262250
"""
263251
return RekorLog(f"{self.url}/log", session=self.session)
252+
253+
def create_entry(self, request: EntryRequestBody) -> LogEntry:
254+
"""
255+
Submit the request to Rekor.
256+
"""
257+
return self.log.entries.post(request)
258+
259+
def _build_hashed_rekord_request( # type: ignore[override]
260+
self, hashed_input: Hashed, signature: bytes, certificate: Certificate
261+
) -> EntryRequestBody:
262+
"""
263+
Construct a hashed rekord payload to submit to Rekor.
264+
"""
265+
rekord = rekor_types.Hashedrekord(
266+
spec=rekor_types.hashedrekord.HashedrekordV001Schema(
267+
signature=rekor_types.hashedrekord.Signature(
268+
content=base64.b64encode(signature).decode(),
269+
public_key=rekor_types.hashedrekord.PublicKey(
270+
content=base64.b64encode(
271+
certificate.public_bytes(
272+
encoding=serialization.Encoding.PEM
273+
)
274+
).decode()
275+
),
276+
),
277+
data=rekor_types.hashedrekord.Data(
278+
hash=rekor_types.hashedrekord.Hash(
279+
algorithm=hashed_input._as_hashedrekord_algorithm(),
280+
value=hashed_input.digest.hex(),
281+
)
282+
),
283+
),
284+
)
285+
return EntryRequestBody(rekord.model_dump(mode="json", by_alias=True))
286+
287+
def _build_dsse_request( # type: ignore[override]
288+
self, envelope: Envelope, certificate: Certificate
289+
) -> EntryRequestBody:
290+
"""
291+
Construct a dsse request to submit to Rekor.
292+
"""
293+
dsse = rekor_types.Dsse(
294+
spec=rekor_types.dsse.DsseSchema(
295+
# NOTE: mypy can't see that this kwarg is correct due to two interacting
296+
# behaviors/bugs (one pydantic, one datamodel-codegen):
297+
# See: <https://github.com/pydantic/pydantic/discussions/7418#discussioncomment-9024927>
298+
# See: <https://github.com/koxudaxi/datamodel-code-generator/issues/1903>
299+
proposed_content=rekor_types.dsse.ProposedContent( # type: ignore[call-arg]
300+
envelope=envelope.to_json(),
301+
verifiers=[
302+
base64.b64encode(
303+
certificate.public_bytes(
304+
encoding=serialization.Encoding.PEM
305+
)
306+
).decode()
307+
],
308+
),
309+
),
310+
)
311+
return EntryRequestBody(dsse.model_dump(mode="json", by_alias=True))

0 commit comments

Comments
 (0)