diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0706cc..22a0d49a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ All versions prior to 0.9.0 are untracked. ## [Unreleased] +### Added + +* API: `Signer.sign()` can now take an in-toto `Statement` as an input, + producing a DSSE-formatted signature rather than a "bare" signature + ([#804](https://github.com/sigstore/sigstore-python/pull/804)) + + +* API: `SigningResult.content` has been added, representing either the + `hashedrekord` entry's message signature or the `dsse` entry's envelope + ([#804](https://github.com/sigstore/sigstore-python/pull/804)) + + +### Removed + +* API: `SigningResult.input_digest` has been removed; users who expect + to access the input digest may do so by inspecting the `hashedrekord` + or `dsse`-specific `SigningResult.content` + ([#804](https://github.com/sigstore/sigstore-python/pull/804)) + ### Changed * **BREAKING API CHANGE**: `sigstore.sign.SigningResult` has been removed diff --git a/pyproject.toml b/pyproject.toml index c5225d81..4ba399bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "cryptography >= 39", "id >= 1.1.0", "importlib_resources ~= 5.7; python_version < '3.11'", + "in-toto-attestation == 0.9.3", "pydantic >= 2,< 3", "pyjwt >= 2.1", "pyOpenSSL >= 23.0.0", @@ -36,6 +37,7 @@ dependencies = [ "rich ~= 13.0", "securesystemslib", "sigstore-protobuf-specs ~= 0.2.2", + # NOTE(ww): Under active development, so strictly pinned. "sigstore-rekor-types == 0.0.12", "tuf >= 2.1,< 4.0", ] @@ -60,6 +62,7 @@ lint = [ # and let Dependabot periodically perform this update. "ruff < 0.1.14", "types-requests", + "types-protobuf", "types-pyOpenSSL", ] doc = ["pdoc"] diff --git a/sigstore/_internal/dsse.py b/sigstore/_internal/dsse.py new file mode 100644 index 00000000..5dc3d961 --- /dev/null +++ b/sigstore/_internal/dsse.py @@ -0,0 +1,49 @@ +# Copyright 2022 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. + +""" +Functionality for building and manipulating DSSE envelopes. +""" + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from google.protobuf.json_format import MessageToJson +from in_toto_attestation.v1.statement import Statement +from sigstore_protobuf_specs.io.intoto import Envelope, Signature + + +def sign_intoto(key: ec.EllipticCurvePrivateKey, payload: Statement) -> Envelope: + """ + Create a DSSE envelope containing a signature over an in-toto formatted + attestation. + """ + + # See: + # https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md + # https://github.com/in-toto/attestation/blob/v1.0/spec/v1.0/envelope.md + + type_ = "application/vnd.in-toto+json" + payload_encoded = MessageToJson(payload.pb, sort_keys=True).encode() + # NOTE: `payload_encoded.decode()` to avoid printing `repr(bytes)`, which would + # add `b'...'` around the formatted payload. + pae = ( + f"DSSEv1 {len(type_)} {type_} {len(payload_encoded)} {payload_encoded.decode()}" + ) + + signature = key.sign(pae.encode(), ec.ECDSA(hashes.SHA256())) + return Envelope( + payload=payload_encoded, + payload_type=type_, + signatures=[Signature(sig=signature, keyid=None)], + ) diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index b2267c65..20c9a62d 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import logging from abc import ABC from dataclasses import dataclass @@ -72,7 +73,20 @@ class RekorClientError(Exception): A generic error in the Rekor client. """ - pass + def __init__(self, http_error: requests.HTTPError): + """ + Create a new `RekorClientError` from the given `requests.HTTPError`. + """ + if http_error.response: + 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}") class _Endpoint(ABC): @@ -94,7 +108,7 @@ def get(self) -> RekorLogInfo: try: resp.raise_for_status() except requests.HTTPError as http_error: - raise RekorClientError from http_error + raise RekorClientError(http_error) return RekorLogInfo.from_response(resp.json()) @property @@ -120,7 +134,7 @@ def get( Either `uuid` or `log_index` must be present, but not both. """ if not (bool(uuid) ^ bool(log_index)): - raise RekorClientError("uuid or log_index required, but not both") + raise ValueError("uuid or log_index required, but not both") resp: requests.Response @@ -132,26 +146,29 @@ def get( try: resp.raise_for_status() except requests.HTTPError as http_error: - raise RekorClientError from http_error + raise RekorClientError(http_error) return LogEntry._from_response(resp.json()) def post( self, - proposed_entry: rekor_types.Hashedrekord, + proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse, ) -> LogEntry: """ Submit a new entry for inclusion in the Rekor log. """ - resp: requests.Response = self.session.post( - self.url, json=proposed_entry.model_dump(mode="json", by_alias=True) - ) + payload = proposed_entry.model_dump(mode="json", by_alias=True) + logger.debug(f"proposed: {json.dumps(payload)}") + + resp: requests.Response = self.session.post(self.url, json=payload) try: resp.raise_for_status() except requests.HTTPError as http_error: - raise RekorClientError from http_error + raise RekorClientError(http_error) - return LogEntry._from_response(resp.json()) + integrated_entry = resp.json() + logger.debug(f"integrated: {integrated_entry}") + return LogEntry._from_response(integrated_entry) @property def retrieve(self) -> RekorEntriesRetrieve: @@ -170,7 +187,7 @@ class RekorEntriesRetrieve(_Endpoint): def post( self, - expected_entry: rekor_types.Hashedrekord, + expected_entry: rekor_types.Hashedrekord | rekor_types.Dsse, ) -> Optional[LogEntry]: """ Retrieves an extant Rekor entry, identified by its artifact signature, @@ -187,7 +204,7 @@ def post( except requests.HTTPError as http_error: if http_error.response and http_error.response.status_code == 404: return None - raise RekorClientError(resp.text) from http_error + raise RekorClientError(http_error) results = resp.json() diff --git a/sigstore/sign.py b/sigstore/sign.py index e03d6fad..c12c12e6 100644 --- a/sigstore/sign.py +++ b/sigstore/sign.py @@ -51,6 +51,7 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import Prehashed from cryptography.x509.oid import NameOID +from in_toto_attestation.v1.statement import Statement from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import ( Bundle, VerificationMaterial, @@ -70,7 +71,9 @@ KindVersion, TransparencyLogEntry, ) +from sigstore_protobuf_specs.io.intoto import Envelope +from sigstore._internal import dsse from sigstore._internal.fulcio import ( ExpiredCertificate, FulcioCertificateSigningResponse, @@ -79,7 +82,7 @@ from sigstore._internal.rekor.client import RekorClient from sigstore._internal.sct import verify_sct from sigstore._internal.trustroot import TrustedRoot -from sigstore._utils import B64Str, HexStr, PEMCert, sha256_streaming +from sigstore._utils import PEMCert, sha256_streaming from sigstore.oidc import ExpiredIdentity, IdentityToken from sigstore.transparency import LogEntry @@ -173,10 +176,9 @@ def _signing_cert( def sign( self, - input_: IO[bytes], + input_: IO[bytes] | Statement, ) -> Bundle: """Public API for signing blobs""" - input_digest = sha256_streaming(input_) private_key = self._private_key if not self._identity_token.in_validity_period(): @@ -187,57 +189,78 @@ def sign( except ExpiredCertificate as e: raise e - # TODO(alex): Retrieve the public key via TUF - # # Verify the SCT - sct = certificate_response.sct # noqa - cert = certificate_response.cert # noqa + sct = certificate_response.sct + cert = certificate_response.cert chain = certificate_response.chain verify_sct(sct, cert, chain, self._signing_ctx._rekor._ct_keyring) logger.debug("Successfully verified SCT...") - # Sign artifact - artifact_signature = private_key.sign( - input_digest, ec.ECDSA(Prehashed(hashes.SHA256())) - ) - b64_artifact_signature = B64Str(base64.b64encode(artifact_signature).decode()) - # Prepare inputs b64_cert = base64.b64encode( cert.public_bytes(encoding=serialization.Encoding.PEM) ) - # Create the transparency log entry - proposed_entry = rekor_types.Hashedrekord( - kind="hashedrekord", - api_version="0.0.1", - spec=rekor_types.hashedrekord.HashedrekordV001Schema( - signature=rekor_types.hashedrekord.Signature( - content=b64_artifact_signature, - public_key=rekor_types.hashedrekord.PublicKey( - content=b64_cert.decode() + # Sign artifact + content: MessageSignature | Envelope + proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse + if isinstance(input_, Statement): + content = dsse.sign_intoto(private_key, input_) + + # Create the proposed DSSE entry + proposed_entry = rekor_types.Dsse( + spec=rekor_types.dsse.DsseV001Schema( + proposed_content=rekor_types.dsse.ProposedContent( + envelope=content.to_json(), + verifiers=[b64_cert.decode()], ), ), - data=rekor_types.hashedrekord.Data( - hash=rekor_types.hashedrekord.Hash( - algorithm=rekor_types.hashedrekord.Algorithm.SHA256, - value=input_digest.hex(), - ) + ) + else: + input_digest = sha256_streaming(input_) + + artifact_signature = private_key.sign( + input_digest, ec.ECDSA(Prehashed(hashes.SHA256())) + ) + + content = MessageSignature( + message_digest=HashOutput( + algorithm=HashAlgorithm.SHA2_256, + digest=input_digest, ), - ), - ) + signature=artifact_signature, + ) + + # 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=rekor_types.hashedrekord.Algorithm.SHA256, + value=input_digest.hex(), + ) + ), + ), + ) + + # Submit the proposed entry to the transparency log entry = self._signing_ctx._rekor.log.entries.post(proposed_entry) logger.debug(f"Transparency log entry created with index: {entry.log_index}") return _make_bundle( - input_digest=HexStr(input_digest.hex()), + content=content, cert_pem=PEMCert( cert.public_bytes(encoding=serialization.Encoding.PEM).decode() ), - b64_signature=B64Str(b64_artifact_signature), log_entry=entry, ) @@ -308,7 +331,9 @@ def signer( def _make_bundle( - input_digest: HexStr, cert_pem: PEMCert, b64_signature: B64Str, log_entry: LogEntry + content: MessageSignature | Envelope, + cert_pem: PEMCert, + log_entry: LogEntry, ) -> Bundle: """ Convert the raw results of a Sigstore signing operation into a Sigstore bundle. @@ -332,10 +357,16 @@ def _make_bundle( checkpoint=Checkpoint(envelope=log_entry.inclusion_proof.checkpoint), ) + # TODO: This is a bit of a hack. + if isinstance(content, MessageSignature): + kind_version = KindVersion(kind="hashedrekord", version="0.0.1") + else: + kind_version = KindVersion(kind="dsse", version="0.0.1") + tlog_entry = TransparencyLogEntry( log_index=log_entry.log_index, log_id=LogId(key_id=bytes.fromhex(log_entry.log_id)), - kind_version=KindVersion(kind="hashedrekord", version="0.0.1"), + kind_version=kind_version, integrated_time=log_entry.integrated_time, inclusion_promise=InclusionPromise( signed_entry_timestamp=base64.b64decode(log_entry.inclusion_promise) @@ -354,13 +385,11 @@ def _make_bundle( bundle = Bundle( media_type="application/vnd.dev.sigstore.bundle+json;version=0.2", verification_material=material, - message_signature=MessageSignature( - message_digest=HashOutput( - algorithm=HashAlgorithm.SHA2_256, - digest=bytes.fromhex(input_digest), - ), - signature=base64.b64decode(b64_signature), - ), ) + if isinstance(content, MessageSignature): + bundle.message_signature = content + else: + bundle.dsse_envelope = content + return bundle diff --git a/sigstore/verify/models.py b/sigstore/verify/models.py index ad5ce841..3bb7d1ea 100644 --- a/sigstore/verify/models.py +++ b/sigstore/verify/models.py @@ -407,8 +407,6 @@ def rekor_entry(self, client: RekorClient) -> LogEntry: # (if we don't have one) *and* to cross-check whatever response # we receive. See below. expected_entry = rekor_types.Hashedrekord( - kind="hashedrekord", - api_version="0.0.1", spec=rekor_types.hashedrekord.HashedrekordV001Schema( signature=rekor_types.hashedrekord.Signature( content=base64.b64encode(self.signature).decode(),