diff --git a/CHANGELOG.md b/CHANGELOG.md index e27928a38..61ada3b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All versions prior to 0.9.0 are untracked. * Added support for ed25519 keys. [#1377](https://github.com/sigstore/sigstore-python/pull/1377) +* Added a `RekorV2Client` for posting new entries to a Rekor V2 instance. + [#1400](https://github.com/sigstore/sigstore-python/pull/1400) + ### Fixed * Avoid instantiation issues with `TransparencyLogEntry` when `InclusionPromise` is not present. diff --git a/sigstore/_internal/rekor/__init__.py b/sigstore/_internal/rekor/__init__.py index 7c1d3e364..3cc68fde7 100644 --- a/sigstore/_internal/rekor/__init__.py +++ b/sigstore/_internal/rekor/__init__.py @@ -16,19 +16,57 @@ APIs for interacting with Rekor. """ +from __future__ import annotations + import base64 +from abc import ABC, abstractmethod import rekor_types from cryptography.x509 import Certificate +from sigstore._internal.rekor.v2_types.dev.sigstore.rekor import v2 from sigstore._utils import base64_encode_pem_cert +from sigstore.dsse import Envelope from sigstore.hashes import Hashed +from sigstore.models import LogEntry __all__ = [ "_hashedrekord_from_parts", ] +class RekorLogSubmitter(ABC): + @abstractmethod + def create_entry( + self, + request: rekor_types.Hashedrekord | rekor_types.Dsse | v2.CreateEntryRequest, + ) -> LogEntry: + """ + Submit the request to Rekor. + """ + pass + + @classmethod + @abstractmethod + def _build_hashed_rekord_request( + self, hashed_input: Hashed, signature: bytes, certificate: Certificate + ) -> rekor_types.Hashedrekord | v2.CreateEntryRequest: + """ + Construct a hashed rekord request to submit to Rekor. + """ + pass + + @classmethod + @abstractmethod + def _build_dsse_request( + self, envelope: Envelope, certificate: Certificate + ) -> rekor_types.Dsse | v2.CreateEntryRequest: + """ + Construct a dsse request to submit to Rekor. + """ + pass + + # TODO: This should probably live somewhere better. def _hashedrekord_from_parts( cert: Certificate, sig: bytes, hashed: Hashed diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 80801579d..8d011b223 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -18,6 +18,7 @@ from __future__ import annotations +import base64 import json import logging from abc import ABC @@ -26,8 +27,15 @@ import rekor_types import requests +from cryptography.hazmat.primitives import serialization +from cryptography.x509 import Certificate from sigstore._internal import USER_AGENT +from sigstore._internal.rekor import ( + RekorLogSubmitter, +) +from sigstore.dsse import Envelope +from sigstore.hashes import Hashed from sigstore.models import LogEntry _logger = logging.getLogger(__name__) @@ -216,7 +224,7 @@ def post( return oldest_entry -class RekorClient: +class RekorClient(RekorLogSubmitter): """The internal Rekor client""" def __init__(self, url: str) -> None: @@ -261,3 +269,63 @@ def log(self) -> RekorLog: Returns a `RekorLog` adapter for making requests to a Rekor log. """ return RekorLog(f"{self.url}/log", session=self.session) + + def create_entry( # type: ignore[override] + self, request: rekor_types.Hashedrekord | rekor_types.Dsse + ) -> LogEntry: + """ + Submit the request to Rekor. + """ + return self.log.entries.post(request) + + def _build_hashed_rekord_request( # type: ignore[override] + self, hashed_input: Hashed, signature: bytes, certificate: Certificate + ) -> rekor_types.Hashedrekord: + """ + Construct a hashed rekord request to submit to Rekor. + """ + return rekor_types.Hashedrekord( + spec=rekor_types.hashedrekord.HashedrekordV001Schema( + signature=rekor_types.hashedrekord.Signature( + content=base64.b64encode(signature).decode(), + public_key=rekor_types.hashedrekord.PublicKey( + content=base64.b64encode( + certificate.public_bytes( + encoding=serialization.Encoding.PEM + ) + ).decode() + ), + ), + data=rekor_types.hashedrekord.Data( + hash=rekor_types.hashedrekord.Hash( + algorithm=hashed_input._as_hashedrekord_algorithm(), + value=hashed_input.digest.hex(), + ) + ), + ), + ) + + def _build_dsse_request( # type: ignore[override] + self, envelope: Envelope, certificate: Certificate + ) -> rekor_types.Dsse: + """ + Construct a dsse request to submit to Rekor. + """ + return rekor_types.Dsse( + spec=rekor_types.dsse.DsseSchema( + # NOTE: mypy can't see that this kwarg is correct due to two interacting + # behaviors/bugs (one pydantic, one datamodel-codegen): + # See: + # See: + proposed_content=rekor_types.dsse.ProposedContent( # type: ignore[call-arg] + envelope=envelope.to_json(), + verifiers=[ + base64.b64encode( + certificate.public_bytes( + encoding=serialization.Encoding.PEM + ) + ).decode() + ], + ), + ), + ) diff --git a/sigstore/_internal/rekor/client_v2.py b/sigstore/_internal/rekor/client_v2.py new file mode 100644 index 000000000..1709ed098 --- /dev/null +++ b/sigstore/_internal/rekor/client_v2.py @@ -0,0 +1,176 @@ +# Copyright 2025 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Client implementation for interacting with RekorV2. +""" + +from __future__ import annotations + +import json +import logging + +import rekor_types +import requests +from cryptography.hazmat.primitives import serialization +from cryptography.x509 import Certificate + +from sigstore._internal import USER_AGENT +from sigstore._internal.rekor import RekorLogSubmitter +from sigstore._internal.rekor.v2_types.dev.sigstore.common import v1 as common_v1 +from sigstore._internal.rekor.v2_types.dev.sigstore.rekor import v2 +from sigstore._internal.rekor.v2_types.io import intoto as v2_intoto +from sigstore.dsse import Envelope +from sigstore.hashes import Hashed +from sigstore.models import LogEntry + +_logger = logging.getLogger(__name__) + +DEFAULT_REKOR_URL = "https://rekor.sigstore.dev" +STAGING_REKOR_URL = "https://rekor.sigstage.dev" + +# TODO: Link to merged documenation. +# See https://github.com/sigstore/rekor-tiles/pull/255/files#diff-eb568acf84d583e4d3734b07773e96912277776bad39c560392aa33ea2cf2210R196 +CREATE_ENTRIES_TIMEOUT_SECONDS = 20 + +DEFAULT_KEY_DETAILS = common_v1.PublicKeyDetails.PKIX_ECDSA_P384_SHA_256 + + +class RekorV2Client(RekorLogSubmitter): + """The internal Rekor client for the v2 API""" + + # TODO: implement get_tile, get_entry_bundle, get_checkpoint. + + def __init__(self, base_url: str) -> None: + """ + Create a new `RekorV2Client` from the given URL. + """ + self.url = f"{base_url}/api/v2" + self.session = requests.Session() + self.session.headers.update( + { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + ) + + def __del__(self) -> None: + """ + Terminates the underlying network session. + """ + self.session.close() + + # TODO: when we remove the original Rekor client, remove the type ignore here + def create_entry(self, request: v2.CreateEntryRequest) -> LogEntry: # type: ignore[override] + """ + Submit a new entry for inclusion in the Rekor log. + """ + payload = request.to_dict() + _logger.debug(f"proposed: {json.dumps(payload)}") + resp = self.session.post( + f"{self.url}/log/entries", + json=payload, + timeout=CREATE_ENTRIES_TIMEOUT_SECONDS, + ) + + try: + resp.raise_for_status() + except requests.HTTPError as http_error: + raise RekorClientError(http_error) + + integrated_entry = resp.json() + _logger.debug(f"integrated: {integrated_entry}") + return LogEntry._from_dict_rekor(integrated_entry) + + @classmethod + def _build_hashed_rekord_request( + cls, + hashed_input: Hashed, + signature: bytes, + certificate: Certificate, + ) -> v2.CreateEntryRequest: + """ + Construct a hashed rekord request to submit to Rekor. + """ + return v2.CreateEntryRequest( + hashed_rekord_request_v0_0_2=v2.HashedRekordRequestV002( + digest=hashed_input.digest, + signature=v2.Signature( + content=signature, + verifier=v2.Verifier( + x509_certificate=common_v1.X509Certificate( + raw_bytes=certificate.public_bytes( + encoding=serialization.Encoding.DER + ) + ), + key_details=DEFAULT_KEY_DETAILS, # type: ignore[arg-type] + ), + ), + ) + ) + + @classmethod + def _build_dsse_request( + cls, envelope: Envelope, certificate: Certificate + ) -> v2.CreateEntryRequest: + """ + Construct a dsse request to submit to Rekor. + """ + return v2.CreateEntryRequest( + dsse_request_v0_0_2=v2.DsseRequestV002( + envelope=v2_intoto.Envelope( + payload=envelope._inner.payload, + payload_type=envelope._inner.payload_type, + signatures=[ + v2_intoto.Signature( + keyid=signature.keyid, + sig=signature.sig, + ) + for signature in envelope._inner.signatures + ], + ), + verifiers=[ + v2.Verifier( + x509_certificate=common_v1.X509Certificate( + raw_bytes=certificate.public_bytes( + encoding=serialization.Encoding.DER + ) + ), + key_details=DEFAULT_KEY_DETAILS, # type: ignore[arg-type] + ) + ], + ) + ) + + +class RekorClientError(Exception): + """ + A generic error in the Rekor client. + """ + + def __init__(self, http_error: requests.HTTPError): + """ + Create a new `RekorClientError` from the given `requests.HTTPError`. + """ + if http_error.response is not None: + try: + error = rekor_types.Error.model_validate_json(http_error.response.text) + super().__init__(f"{error.code}: {error.message}") + except Exception: + super().__init__( + f"Rekor returned an unknown error with HTTP {http_error.response.status_code}" + ) + else: + super().__init__(f"Unexpected Rekor error: {http_error}") diff --git a/sigstore/_internal/rekor/v2_types/README.md b/sigstore/_internal/rekor/v2_types/README.md new file mode 100644 index 000000000..3e53910ce --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/README.md @@ -0,0 +1,11 @@ +# V2 Types + +TODO: Eventually move these types to sigstore/protobuf-specs. + +These are types meant to be used with RekorV2. + +Generated from running `make python` in sigstore/rekor-tiles to generate (although not checked into git) and copied into here, **plus** formatting and lint fixes (lots of `noqa` comments). + +Linting is still not expected to pass yet, since `interrogate` docstrings for **all** modules and classes. + +Eventually, we will move these types into sigstore/protobuf-specs. diff --git a/sigstore/_internal/rekor/v2_types/__init__.py b/sigstore/_internal/rekor/v2_types/__init__.py new file mode 100644 index 000000000..8706278bd --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/__init__.py @@ -0,0 +1,3 @@ +""" +Types for RekorV2 +""" diff --git a/sigstore/_internal/rekor/v2_types/dev/__init__.py b/sigstore/_internal/rekor/v2_types/dev/__init__.py new file mode 100644 index 000000000..dd8a6f2ae --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/__init__.py @@ -0,0 +1,3 @@ +""" +Types used for RekorV2 +""" diff --git a/sigstore/_internal/rekor/v2_types/dev/sigstore/__init__.py b/sigstore/_internal/rekor/v2_types/dev/sigstore/__init__.py new file mode 100644 index 000000000..8706278bd --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/sigstore/__init__.py @@ -0,0 +1,3 @@ +""" +Types for RekorV2 +""" diff --git a/sigstore/_internal/rekor/v2_types/dev/sigstore/common/__init__.py b/sigstore/_internal/rekor/v2_types/dev/sigstore/common/__init__.py new file mode 100644 index 000000000..87bbea4ee --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/sigstore/common/__init__.py @@ -0,0 +1,3 @@ +""" +Common types used by Sigstore services +""" diff --git a/sigstore/_internal/rekor/v2_types/dev/sigstore/common/v1/__init__.py b/sigstore/_internal/rekor/v2_types/dev/sigstore/common/v1/__init__.py new file mode 100644 index 000000000..52c51fc84 --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/sigstore/common/v1/__init__.py @@ -0,0 +1,322 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: sigstore_common.proto +# plugin: python-betterproto +# This file has been @generated + +""" +V1 of the common types +""" + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from dataclasses import dataclass +else: + from pydantic.dataclasses import dataclass + +from datetime import datetime +from typing import ( + Optional, +) + +import betterproto +from pydantic import model_validator +from pydantic.dataclasses import rebuild_dataclass + + +class HashAlgorithm(betterproto.Enum): + """ + Only a subset of the secure hash standard algorithms are supported. + See for more + details. + UNSPECIFIED SHOULD not be used, primary reason for inclusion is to force + any proto JSON serialization to emit the used hash algorithm, as default + option is to *omit* the default value of an enum (which is the first + value, represented by '0'. + """ + + UNSPECIFIED = 0 + SHA2_256 = 1 + SHA2_384 = 2 + SHA2_512 = 3 + SHA3_256 = 4 + SHA3_384 = 5 + + @classmethod + def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any: + from pydantic_core import core_schema + + return core_schema.int_schema(ge=0) + + +class PublicKeyDetails(betterproto.Enum): + """ + Details of a specific public key, capturing the the key encoding method, + and signature algorithm. + PublicKeyDetails captures the public key/hash algorithm combinations + recommended in the Sigstore ecosystem. + This is modelled as a linear set as we want to provide a small number of + opinionated options instead of allowing every possible permutation. + Any changes to this enum MUST be reflected in the algorithm registry. + See: docs/algorithm-registry.md + To avoid the possibility of contradicting formats such as PKCS1 with + ED25519 the valid permutations are listed as a linear set instead of a + cartesian set (i.e one combined variable instead of two, one for encoding + and one for the signature algorithm). + """ + + UNSPECIFIED = 0 + PKCS1_RSA_PKCS1V5 = 1 + """RSA""" + + PKCS1_RSA_PSS = 2 + PKIX_RSA_PKCS1V5 = 3 + PKIX_RSA_PSS = 4 + PKIX_RSA_PKCS1V15_2048_SHA256 = 9 + """RSA public key in PKIX format, PKCS#1v1.5 signature""" + + PKIX_RSA_PKCS1V15_3072_SHA256 = 10 + PKIX_RSA_PKCS1V15_4096_SHA256 = 11 + PKIX_RSA_PSS_2048_SHA256 = 16 + """RSA public key in PKIX format, RSASSA-PSS signature""" + + PKIX_RSA_PSS_3072_SHA256 = 17 + PKIX_RSA_PSS_4096_SHA256 = 18 + PKIX_ECDSA_P256_HMAC_SHA_256 = 6 + """ECDSA""" + + PKIX_ECDSA_P256_SHA_256 = 5 + PKIX_ECDSA_P384_SHA_384 = 12 + PKIX_ECDSA_P521_SHA_512 = 13 + PKIX_ED25519 = 7 + """Ed 25519""" + + PKIX_ED25519_PH = 8 + PKIX_ECDSA_P384_SHA_256 = 19 + """ + These algorithms are deprecated and should not be used, but they + were/are being used by most Sigstore clients implementations. + """ + + PKIX_ECDSA_P521_SHA_256 = 20 + LMS_SHA256 = 14 + """ + LMS and LM-OTS + + These keys and signatures may be used by private Sigstore + deployments, but are not currently supported by the public + good instance. + + USER WARNING: LMS and LM-OTS are both stateful signature schemes. + Using them correctly requires discretion and careful consideration + to ensure that individual secret keys are not used more than once. + In addition, LM-OTS is a single-use scheme, meaning that it + MUST NOT be used for more than one signature per LM-OTS key. + If you cannot maintain these invariants, you MUST NOT use these + schemes. + """ + + LMOTS_SHA256 = 15 + + @classmethod + def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any: + from pydantic_core import core_schema + + return core_schema.int_schema(ge=0) + + +class SubjectAlternativeNameType(betterproto.Enum): + UNSPECIFIED = 0 + EMAIL = 1 + URI = 2 + OTHER_NAME = 3 + """ + OID 1.3.6.1.4.1.57264.1.7 + See https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726417--othername-san + for more details. + """ + + @classmethod + def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any: + from pydantic_core import core_schema + + return core_schema.int_schema(ge=0) + + +@dataclass(eq=False, repr=False) +class HashOutput(betterproto.Message): + """ + HashOutput captures a digest of a 'message' (generic octet sequence) + and the corresponding hash algorithm used. + """ + + algorithm: "HashAlgorithm" = betterproto.enum_field(1) + digest: bytes = betterproto.bytes_field(2) + """ + This is the raw octets of the message digest as computed by + the hash algorithm. + """ + + +@dataclass(eq=False, repr=False) +class MessageSignature(betterproto.Message): + """MessageSignature stores the computed signature over a message.""" + + message_digest: "HashOutput" = betterproto.message_field(1) + """ + Message digest can be used to identify the artifact. + Clients MUST NOT attempt to use this digest to verify the associated + signature; it is intended solely for identification. + """ + + signature: bytes = betterproto.bytes_field(2) + """ + The raw bytes as returned from the signature algorithm. + The signature algorithm (and so the format of the signature bytes) + are determined by the contents of the 'verification_material', + either a key-pair or a certificate. If using a certificate, the + certificate contains the required information on the signature + algorithm. + When using a key pair, the algorithm MUST be part of the public + key, which MUST be communicated out-of-band. + """ + + +@dataclass(eq=False, repr=False) +class LogId(betterproto.Message): + """LogId captures the identity of a transparency log.""" + + key_id: bytes = betterproto.bytes_field(1) + """The unique identity of the log, represented by its public key.""" + + +@dataclass(eq=False, repr=False) +class Rfc3161SignedTimestamp(betterproto.Message): + """This message holds a RFC 3161 timestamp.""" + + signed_timestamp: bytes = betterproto.bytes_field(1) + """ + Signed timestamp is the DER encoded TimeStampResponse. + See https://www.rfc-editor.org/rfc/rfc3161.html#section-2.4.2 + """ + + +@dataclass(eq=False, repr=False) +class PublicKey(betterproto.Message): + raw_bytes: Optional[bytes] = betterproto.bytes_field(1, optional=True) + """ + DER-encoded public key, encoding method is specified by the + key_details attribute. + """ + + key_details: "PublicKeyDetails" = betterproto.enum_field(2) + """Key encoding and signature algorithm to use for this key.""" + + valid_for: Optional["TimeRange"] = betterproto.message_field(3, optional=True) + """Optional validity period for this key, *inclusive* of the endpoints.""" + + +@dataclass(eq=False, repr=False) +class PublicKeyIdentifier(betterproto.Message): + """ + PublicKeyIdentifier can be used to identify an (out of band) delivered + key, to verify a signature. + """ + + hint: str = betterproto.string_field(1) + """ + Optional unauthenticated hint on which key to use. + The format of the hint must be agreed upon out of band by the + signer and the verifiers, and so is not subject to this + specification. + Example use-case is to specify the public key to use, from a + trusted key-ring. + Implementors are RECOMMENDED to derive the value from the public + key as described in RFC 6962. + See: + """ + + +@dataclass(eq=False, repr=False) +class ObjectIdentifier(betterproto.Message): + """An ASN.1 OBJECT IDENTIFIER""" + + id: list[int] = betterproto.int32_field(1) + + +@dataclass(eq=False, repr=False) +class ObjectIdentifierValuePair(betterproto.Message): + """An OID and the corresponding (byte) value.""" + + oid: "ObjectIdentifier" = betterproto.message_field(1) + value: bytes = betterproto.bytes_field(2) + + +@dataclass(eq=False, repr=False) +class DistinguishedName(betterproto.Message): + organization: str = betterproto.string_field(1) + common_name: str = betterproto.string_field(2) + + +@dataclass(eq=False, repr=False) +class X509Certificate(betterproto.Message): + raw_bytes: bytes = betterproto.bytes_field(1) + """DER-encoded X.509 certificate.""" + + +@dataclass(eq=False, repr=False) +class SubjectAlternativeName(betterproto.Message): + type: "SubjectAlternativeNameType" = betterproto.enum_field(1) + regexp: Optional[str] = betterproto.string_field(2, optional=True, group="identity") + """ + A regular expression describing the expected value for + the SAN. + """ + + value: Optional[str] = betterproto.string_field(3, optional=True, group="identity") + """The exact value to match against.""" + + @model_validator(mode="after") + def check_oneof(cls: Any, values: Any) -> Any: + return cls._validate_field_groups(values) + + +@dataclass(eq=False, repr=False) +class X509CertificateChain(betterproto.Message): + """ + A collection of X.509 certificates. + This "chain" can be used in multiple contexts, such as providing a root CA + certificate within a TUF root of trust or multiple untrusted certificates for + the purpose of chain building. + """ + + certificates: list["X509Certificate"] = betterproto.message_field(1) + """ + One or more DER-encoded certificates. + + In some contexts (such as `VerificationMaterial.x509_certificate_chain`), this sequence + has an imposed order. Unless explicitly specified, there is otherwise no + guaranteed order. + """ + + +@dataclass(eq=False, repr=False) +class TimeRange(betterproto.Message): + """ + The time range is closed and includes both the start and end times, + (i.e., [start, end]). + End is optional to be able to capture a period that has started but + has no known end. + """ + + start: datetime = betterproto.message_field(1) + end: Optional[datetime] = betterproto.message_field(2, optional=True) + + +rebuild_dataclass(HashOutput) # type: ignore[arg-type] +rebuild_dataclass(MessageSignature) # type: ignore[arg-type] +rebuild_dataclass(PublicKey) # type: ignore[arg-type] +rebuild_dataclass(ObjectIdentifierValuePair) # type: ignore[arg-type] +rebuild_dataclass(SubjectAlternativeName) # type: ignore[arg-type] +rebuild_dataclass(X509CertificateChain) # type: ignore[arg-type] +rebuild_dataclass(TimeRange) # type: ignore[arg-type] diff --git a/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/__init__.py b/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/__init__.py new file mode 100644 index 000000000..c8b7267e1 --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/__init__.py @@ -0,0 +1,3 @@ +""" +Types used by Rekor +""" diff --git a/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/v2/__init__.py b/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/v2/__init__.py new file mode 100644 index 000000000..f3ed48afe --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/dev/sigstore/rekor/v2/__init__.py @@ -0,0 +1,202 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: dsse.proto, entry.proto, hashedrekord.proto, rekor_service.proto, verifier.proto +# plugin: python-betterproto +# This file has been @generated + +""" +Types used by RekorV2 +""" + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from dataclasses import dataclass +else: + from pydantic.dataclasses import dataclass + +from typing import ( + Optional, +) + +import betterproto +from pydantic import model_validator +from pydantic.dataclasses import rebuild_dataclass + +from .....io import intoto as ____io_intoto__ +from ...common import v1 as __common_v1__ + + +@dataclass(eq=False, repr=False) +class PublicKey(betterproto.Message): + """PublicKey contains an encoded public key""" + + raw_bytes: bytes = betterproto.bytes_field(1) + """DER-encoded public key""" + + +@dataclass(eq=False, repr=False) +class Verifier(betterproto.Message): + """ + Either a public key or a X.509 cerificiate with an embedded public key + """ + + public_key: Optional["PublicKey"] = betterproto.message_field( + 1, optional=True, group="verifier" + ) + """ + DER-encoded public key. Encoding method is specified by the key_details attribute + """ + + x509_certificate: Optional["__common_v1__.X509Certificate"] = ( + betterproto.message_field(2, optional=True, group="verifier") + ) + """DER-encoded certificate""" + + key_details: "__common_v1__.PublicKeyDetails" = betterproto.enum_field(3) + """Key encoding and signature algorithm to use for this key""" + + @model_validator(mode="after") + def check_oneof(cls: Any, values: Any) -> Any: + return cls._validate_field_groups(values) + + +@dataclass(eq=False, repr=False) +class Signature(betterproto.Message): + """A signature and an associated verifier""" + + content: bytes = betterproto.bytes_field(1) + verifier: "Verifier" = betterproto.message_field(2) + + +@dataclass(eq=False, repr=False) +class HashedRekordRequestV002(betterproto.Message): + """A request to add a hashedrekord to the log""" + + digest: bytes = betterproto.bytes_field(1) + """The hashed data""" + + signature: "Signature" = betterproto.message_field(2) + """ + A single signature over the hashed data with the verifier needed to validate it + """ + + +@dataclass(eq=False, repr=False) +class HashedRekordLogEntryV002(betterproto.Message): + data: "__common_v1__.HashOutput" = betterproto.message_field(1) + """The hashed data""" + + signature: "Signature" = betterproto.message_field(2) + """ + A single signature over the hashed data with the verifier needed to validate it + """ + + +@dataclass(eq=False, repr=False) +class DsseRequestV002(betterproto.Message): + """A request to add a DSSE entry to the log""" + + envelope: "____io_intoto__.Envelope" = betterproto.message_field(1) + """A DSSE envelope""" + + verifiers: list["Verifier"] = betterproto.message_field(2) + """ + All necessary verification material to verify all signatures embedded in the envelope + """ + + +@dataclass(eq=False, repr=False) +class DsseLogEntryV002(betterproto.Message): + payload_hash: "__common_v1__.HashOutput" = betterproto.message_field(1) + """The hash of the DSSE payload""" + + signatures: list["Signature"] = betterproto.message_field(2) + """ + Signatures and their associated verification material used to verify the payload + """ + + +@dataclass(eq=False, repr=False) +class Entry(betterproto.Message): + """ + Entry is the message that is canonicalized and uploaded to the log. + This format is meant to be compliant with Rekor v1 entries in that + the `apiVersion` and `kind` can be parsed before parsing the spec. + Clients are expected to understand and handle the differences in the + contents of `spec` between Rekor v1 (a polymorphic OpenAPI defintion) + and Rekor v2 (a typed proto defintion). + """ + + kind: str = betterproto.string_field(1) + api_version: str = betterproto.string_field(2) + spec: "Spec" = betterproto.message_field(3) + + +@dataclass(eq=False, repr=False) +class Spec(betterproto.Message): + """Spec contains one of the Rekor entry types.""" + + hashed_rekord_v0_0_2: Optional["HashedRekordLogEntryV002"] = ( + betterproto.message_field(1, optional=True, group="spec") + ) + dsse_v0_0_2: Optional["DsseLogEntryV002"] = betterproto.message_field( + 2, optional=True, group="spec" + ) + + @model_validator(mode="after") + def check_oneof(cls: Any, values: Any) -> Any: + return cls._validate_field_groups(values) + + +@dataclass(eq=False, repr=False) +class CreateEntryRequest(betterproto.Message): + """Create a new HashedRekord or DSSE""" + + hashed_rekord_request_v0_0_2: Optional["HashedRekordRequestV002"] = ( + betterproto.message_field(1, optional=True, group="spec") + ) + dsse_request_v0_0_2: Optional["DsseRequestV002"] = betterproto.message_field( + 2, optional=True, group="spec" + ) + + @model_validator(mode="after") + def check_oneof(cls: Any, values: Any) -> Any: + return cls._validate_field_groups(values) + + +@dataclass(eq=False, repr=False) +class TileRequest(betterproto.Message): + """ + Request for a full or partial tile (see https://github.com/C2SP/C2SP/blob/main/tlog-tiles.md#merkle-tree) + """ + + l: int = betterproto.uint32_field(1) # noqa: E741 + n: str = betterproto.string_field(2) + """ + N must be either an index encoded as zero-padded 3-digit path elements, e.g. "x123/x456/789", + and may end with ".p/", where "" is a uint8 + """ + + +@dataclass(eq=False, repr=False) +class EntryBundleRequest(betterproto.Message): + """ + Request for a full or partial entry bundle (see https://github.com/C2SP/C2SP/blob/main/tlog-tiles.md#log-entries) + """ + + n: str = betterproto.string_field(1) + """ + N must be either an index encoded as zero-padded 3-digit path elements, e.g. "x123/x456/789", + and may end with ".p/", where "" is a uint8 + """ + + +rebuild_dataclass(Verifier) # type: ignore[arg-type] +rebuild_dataclass(Signature) # type: ignore[arg-type] +rebuild_dataclass(HashedRekordRequestV002) # type: ignore[arg-type] +rebuild_dataclass(HashedRekordLogEntryV002) # type: ignore[arg-type] +rebuild_dataclass(DsseRequestV002) # type: ignore[arg-type] +rebuild_dataclass(DsseLogEntryV002) # type: ignore[arg-type] +rebuild_dataclass(Entry) # type: ignore[arg-type] +rebuild_dataclass(Spec) # type: ignore[arg-type] +rebuild_dataclass(CreateEntryRequest) # type: ignore[arg-type] diff --git a/sigstore/_internal/rekor/v2_types/io/__init__.py b/sigstore/_internal/rekor/v2_types/io/__init__.py new file mode 100644 index 000000000..75a02cef7 --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/io/__init__.py @@ -0,0 +1,3 @@ +""" +Types used in log entries +""" diff --git a/sigstore/_internal/rekor/v2_types/io/intoto/__init__.py b/sigstore/_internal/rekor/v2_types/io/intoto/__init__.py new file mode 100644 index 000000000..cba742504 --- /dev/null +++ b/sigstore/_internal/rekor/v2_types/io/intoto/__init__.py @@ -0,0 +1,66 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: envelope.proto +# plugin: python-betterproto +# This file has been @generated + +""" +Types related to intoto +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dataclasses import dataclass +else: + from pydantic.dataclasses import dataclass + +import betterproto +from pydantic.dataclasses import rebuild_dataclass + + +@dataclass(eq=False, repr=False) +class Envelope(betterproto.Message): + """An authenticated message of arbitrary type.""" + + payload: bytes = betterproto.bytes_field(1) + """ + Message to be signed. (In JSON, this is encoded as base64.) + REQUIRED. + """ + + payload_type: str = betterproto.string_field(2) + """ + String unambiguously identifying how to interpret payload. + REQUIRED. + """ + + signatures: list["Signature"] = betterproto.message_field(3) + """ + Signature over: + PAE(type, payload) + Where PAE is defined as: + PAE(type, payload) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(payload) + SP + payload + + = concatenation + SP = ASCII space [0x20] + "DSSEv1" = ASCII [0x44, 0x53, 0x53, 0x45, 0x76, 0x31] + LEN(s) = ASCII decimal encoding of the byte length of s, with no leading zeros + REQUIRED (length >= 1). + """ + + +@dataclass(eq=False, repr=False) +class Signature(betterproto.Message): + sig: bytes = betterproto.bytes_field(1) + """ + Signature itself. (In JSON, this is encoded as base64.) + REQUIRED. + """ + + keyid: str = betterproto.string_field(2) + """ + *Unauthenticated* hint identifying which public key was used. + OPTIONAL. + """ + + +rebuild_dataclass(Envelope) # type: ignore[arg-type] diff --git a/sigstore/sign.py b/sigstore/sign.py index 643cc7960..d806070bb 100644 --- a/sigstore/sign.py +++ b/sigstore/sign.py @@ -38,7 +38,6 @@ from __future__ import annotations -import base64 import logging from collections.abc import Iterator from contextlib import contextmanager @@ -47,7 +46,7 @@ import cryptography.x509 as x509 import rekor_types -from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID from sigstore_protobuf_specs.dev.sigstore.common.v1 import ( @@ -182,7 +181,7 @@ def _finalize_sign( Perform the common "finalizing" steps in a Sigstore signing flow. """ # Submit the proposed entry to the transparency log - entry = self._signing_ctx._rekor.log.entries.post(proposed_entry) + entry = self._signing_ctx._rekor.create_entry(proposed_entry) _logger.debug(f"Transparency log entry created with index: {entry.log_index}") @@ -211,26 +210,12 @@ def sign_dsse( """ cert = self._signing_cert() - # Prepare inputs - b64_cert = base64.b64encode( - cert.public_bytes(encoding=serialization.Encoding.PEM) - ) - # Sign the statement, producing a DSSE envelope content = dsse._sign(self._private_key, input_) # Create the proposed DSSE log entry - proposed_entry = rekor_types.Dsse( - spec=rekor_types.dsse.DsseSchema( - # NOTE: mypy can't see that this kwarg is correct due to two interacting - # behaviors/bugs (one pydantic, one datamodel-codegen): - # See: - # See: - proposed_content=rekor_types.dsse.ProposedContent( # type: ignore[call-arg] - envelope=content.to_json(), - verifiers=[b64_cert.decode()], - ), - ), + proposed_entry = self._signing_ctx._rekor._build_dsse_request( + envelope=content, certificate=cert ) return self._finalize_sign(cert, content, proposed_entry) @@ -255,11 +240,6 @@ def sign_artifact( cert = self._signing_cert() - # Prepare inputs - b64_cert = base64.b64encode( - cert.public_bytes(encoding=serialization.Encoding.PEM) - ) - # Sign artifact hashed_input = sha256_digest(input_) @@ -276,21 +256,8 @@ def sign_artifact( ) # Create the proposed hashedrekord entry - proposed_entry = rekor_types.Hashedrekord( - spec=rekor_types.hashedrekord.HashedrekordV001Schema( - signature=rekor_types.hashedrekord.Signature( - content=base64.b64encode(artifact_signature).decode(), - public_key=rekor_types.hashedrekord.PublicKey( - content=b64_cert.decode() - ), - ), - data=rekor_types.hashedrekord.Data( - hash=rekor_types.hashedrekord.Hash( - algorithm=hashed_input._as_hashedrekord_algorithm(), - value=hashed_input.digest.hex(), - ) - ), - ), + proposed_entry = self._signing_ctx._rekor._build_hashed_rekord_request( + hashed_input=hashed_input, signature=artifact_signature, certificate=cert ) return self._finalize_sign(cert, content, proposed_entry) diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 768625cb9..b9ef29891 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -214,7 +214,7 @@ def ctx_cls(): return ctx_cls, IdentityToken(token) -@pytest.fixture +@pytest.fixture(scope="session") def staging() -> tuple[type[SigningContext], type[Verifier], IdentityToken]: """ Returns a SigningContext, Verifier, and IdentityToken for the staging environment. diff --git a/test/unit/internal/rekor/test_client_v2.py b/test/unit/internal/rekor/test_client_v2.py new file mode 100644 index 000000000..76c531626 --- /dev/null +++ b/test/unit/internal/rekor/test_client_v2.py @@ -0,0 +1,213 @@ +import hashlib +import secrets + +import pytest + +from sigstore import dsse +from sigstore._internal.rekor.client import DEFAULT_REKOR_URL, STAGING_REKOR_URL +from sigstore._internal.rekor.client_v2 import ( + DEFAULT_KEY_DETAILS, + Certificate, + Hashed, + LogEntry, + RekorV2Client, + common_v1, + serialization, + v2, + v2_intoto, +) +from sigstore._utils import sha256_digest +from sigstore.models import rekor_v1 +from sigstore.sign import ec + +ALPHA_REKOR_V2_URL = "https://log2025-alpha1.rekor.sigstage.dev" +LOCAL_REKOR_V2_URL = "http://localhost:3000" + + +# TODO: add staging and production URLs when available, +# and local after using scaffolding/setup-sigstore-env action +@pytest.fixture( + scope="session", + params=[ + ALPHA_REKOR_V2_URL, + pytest.param(STAGING_REKOR_URL, marks=pytest.mark.xfail), + pytest.param(DEFAULT_REKOR_URL, marks=pytest.mark.skip), + pytest.param(LOCAL_REKOR_V2_URL, marks=pytest.mark.skip), + ], +) +def client(request) -> RekorV2Client: + """ + Returns a RekorV2Client. This fixture is paramaterized to return clients with various URLs. + Test fuctions that consume this fixture will run once for each URL. + """ + return RekorV2Client(base_url=request.param) + + +@pytest.fixture(scope="session") +def sample_cert_and_private_key( + staging, +) -> tuple[Certificate, ec.EllipticCurvePrivateKey]: + """ + Returns a sample Certificate and ec.EllipticCurvePrivateKey. + """ + sign_ctx_cls, _, identity = staging + with sign_ctx_cls().signer(identity) as signer: + return signer._signing_cert(), signer._private_key + + +@pytest.fixture(scope="session") +def sample_hashed_rekord_request_materials( + sample_cert_and_private_key, +) -> tuple[Hashed, bytes, Certificate]: + """ + Creates materials needed for `RekorV2Client._build_hashed_rekord_create_entry_request`. + """ + cert, private_key = sample_cert_and_private_key + hashed_input = sha256_digest(secrets.token_bytes(32)) + signature = private_key.sign( + hashed_input.digest, ec.ECDSA(hashed_input._as_prehashed()) + ) + return hashed_input, signature, cert + + +@pytest.fixture(scope="session") +def sample_dsse_request_materials( + sample_cert_and_private_key, +) -> tuple[dsse.Envelope, Certificate]: + """ + Creates materials needed for `RekorV2Client._build_dsse_create_entry_request`. + """ + cert, private_key = sample_cert_and_private_key + stmt = ( + dsse.StatementBuilder() + .subjects( + [ + dsse.Subject( + name="null", digest={"sha256": hashlib.sha256(b"").hexdigest()} + ) + ] + ) + .predicate_type("https://cosign.sigstore.dev/attestation/v1") + .predicate( + { + "Data": "", + "Timestamp": "2023-12-07T00:37:58Z", + } + ) + ).build() + envelope = dsse._sign(key=private_key, stmt=stmt) + return envelope, cert + + +@pytest.fixture(scope="session") +def sample_hashed_rekord_create_entry_request( + sample_hashed_rekord_request_materials, +) -> v2.CreateEntryRequest: + """ + Returns a sample `CreateEntryRequest` for for hashedrekor. + """ + hashed_input, signature, cert = sample_hashed_rekord_request_materials + return RekorV2Client._build_hashed_rekord_request( + hashed_input=hashed_input, + signature=signature, + certificate=cert, + ) + + +@pytest.fixture(scope="session") +def sample_dsse_create_entry_request( + sample_dsse_request_materials, +) -> v2.CreateEntryRequest: + """ + Returns a sample `CreateEntryRequest` for for dsse. + """ + envelope, cert = sample_dsse_request_materials + return RekorV2Client._build_dsse_request(envelope=envelope, certificate=cert) + + +@pytest.mark.ambient_oidc +def test_build_hashed_rekord_create_entry_request( + sample_hashed_rekord_request_materials, +): + """ + Ensures that we produce the request `CreateEntryRequest` correctly for hashedrekords. + """ + hashed_input, signature, cert = sample_hashed_rekord_request_materials + expected_request = v2.CreateEntryRequest( + hashed_rekord_request_v0_0_2=v2.HashedRekordRequestV002( + digest=hashed_input.digest, + signature=v2.Signature( + content=signature, + verifier=v2.Verifier( + x509_certificate=common_v1.X509Certificate( + raw_bytes=cert.public_bytes(encoding=serialization.Encoding.DER) + ), + key_details=DEFAULT_KEY_DETAILS, + ), + ), + ) + ) + actual_request = RekorV2Client._build_hashed_rekord_request( + hashed_input=hashed_input, + signature=signature, + certificate=cert, + ) + assert expected_request == actual_request + + +@pytest.mark.ambient_oidc +def test_build_dsse_create_entry_request(sample_dsse_request_materials): + """ + Ensures that we produce the request `CreateEntryRequest` correctly for dsses. + """ + envelope, cert = sample_dsse_request_materials + expected_request = v2.CreateEntryRequest( + dsse_request_v0_0_2=v2.DsseRequestV002( + envelope=v2_intoto.Envelope( + payload=envelope._inner.payload, + payload_type=envelope._inner.payload_type, + signatures=[ + v2_intoto.Signature( + keyid=signature.keyid, + sig=signature.sig, + ) + for signature in envelope._inner.signatures + ], + ), + verifiers=[ + v2.Verifier( + x509_certificate=common_v1.X509Certificate( + raw_bytes=cert.public_bytes(encoding=serialization.Encoding.DER) + ), + key_details=DEFAULT_KEY_DETAILS, + ) + ], + ) + ) + actual_request = RekorV2Client._build_dsse_request( + envelope=envelope, certificate=cert + ) + assert expected_request == actual_request + + +@pytest.mark.parametrize( + "sample_create_entry_request", + [ + sample_hashed_rekord_create_entry_request.__name__, + sample_dsse_create_entry_request.__name__, + ], +) +@pytest.mark.ambient_oidc +def test_create_entry( + request: pytest.FixtureRequest, + sample_create_entry_request: str, + client: RekorV2Client, +): + """ + Sends a request to RekorV2 and ensure's the response is parseable to a `LogEntry` and a `TransparencyLogEntry`. + """ + log_entry = client.create_entry( + request.getfixturevalue(sample_create_entry_request) + ) + assert isinstance(log_entry, LogEntry) + assert isinstance(log_entry._to_rekor(), rekor_v1.TransparencyLogEntry)