Skip to content

Commit

Permalink
API-level DSSE signing support (#804)
Browse files Browse the repository at this point in the history
* hackety hack

Signed-off-by: William Woodruff <william@trailofbits.com>

* hackety hack

Signed-off-by: William Woodruff <william@trailofbits.com>

* sigstore: hackety hack

Signed-off-by: William Woodruff <william@trailofbits.com>

* hackety hack

Signed-off-by: William Woodruff <william@trailofbits.com>

* hackety hack

Signed-off-by: William Woodruff <william@trailofbits.com>

* sigstore: don't double encode

Signed-off-by: William Woodruff <william@trailofbits.com>

* fixup DSSE signing, refactor RekorClientError

Signed-off-by: William Woodruff <william@trailofbits.com>

* sigstore: docs

Signed-off-by: William Woodruff <william@trailofbits.com>

* sigstore: lintage

Signed-off-by: William Woodruff <william@trailofbits.com>

* make SigningResult generic over contents

Signed-off-by: William Woodruff <william@trailofbits.com>

* simplify condition

Signed-off-by: William Woodruff <william@trailofbits.com>

* sign: drop kw_only

Not supported until 3.10+

Signed-off-by: William Woodruff <william@trailofbits.com>

* sigstore: cleanup

Signed-off-by: William Woodruff <william@trailofbits.com>

* firmly pin in-toto-attestation, fix KindVersion

Signed-off-by: William Woodruff <william@trailofbits.com>

* bump sigstore-rekor-types

Signed-off-by: William Woodruff <william@trailofbits.com>

* pyproject: bump in-toto-attestation

Signed-off-by: William Woodruff <william@trailofbits.com>

* remove testing script

Signed-off-by: William Woodruff <william@trailofbits.com>

* CHANGELOG: record changes

Signed-off-by: William Woodruff <william@trailofbits.com>

---------

Signed-off-by: William Woodruff <william@trailofbits.com>
  • Loading branch information
woodruffw authored Jan 17, 2024
1 parent 5d53e26 commit bb9b1a0
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 54 deletions.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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",
"requests",
"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",
]
Expand All @@ -60,6 +62,7 @@ lint = [
# and let Dependabot periodically perform this update.
"ruff < 0.1.14",
"types-requests",
"types-protobuf",
"types-pyOpenSSL",
]
doc = ["pdoc"]
Expand Down
49 changes: 49 additions & 0 deletions sigstore/_internal/dsse.py
Original file line number Diff line number Diff line change
@@ -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)],
)
41 changes: 29 additions & 12 deletions sigstore/_internal/rekor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from __future__ import annotations

import json
import logging
from abc import ABC
from dataclasses import dataclass
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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()

Expand Down
109 changes: 69 additions & 40 deletions sigstore/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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():
Expand All @@ -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,
)

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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
Loading

0 comments on commit bb9b1a0

Please sign in to comment.