Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API-level DSSE signing support #804

Merged
merged 28 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e2ce0eb
hackety hack
woodruffw Oct 13, 2023
2c873ff
Merge branch 'main' into ww/dsse
woodruffw Oct 18, 2023
476d527
hackety hack
woodruffw Oct 27, 2023
d3862c0
Merge branch 'main' into ww/dsse
woodruffw Dec 4, 2023
0da7215
Merge branch 'main' into ww/dsse
woodruffw Dec 6, 2023
f6899be
sigstore: hackety hack
woodruffw Dec 6, 2023
77526da
hackety hack
woodruffw Dec 6, 2023
63f9b29
hackety hack
woodruffw Dec 6, 2023
82a6fee
sigstore: don't double encode
woodruffw Dec 7, 2023
ff8d358
fixup DSSE signing, refactor RekorClientError
woodruffw Dec 7, 2023
470232c
sigstore: docs
woodruffw Dec 7, 2023
b46eac9
sigstore: lintage
woodruffw Dec 7, 2023
35a1e7f
make SigningResult generic over contents
woodruffw Dec 7, 2023
b3a6099
simplify condition
woodruffw Dec 7, 2023
09499f3
sign: drop kw_only
woodruffw Dec 7, 2023
cb99b93
sigstore: cleanup
woodruffw Dec 7, 2023
ea548a7
Merge branch 'main' into ww/dsse
woodruffw Dec 7, 2023
b24c9b7
firmly pin in-toto-attestation, fix KindVersion
woodruffw Dec 11, 2023
ffd3400
Merge branch 'main' into ww/dsse
woodruffw Dec 12, 2023
ee92e32
bump sigstore-rekor-types
woodruffw Dec 13, 2023
47ea07f
Merge branch 'main' into ww/dsse
woodruffw Dec 13, 2023
bdaef2d
pyproject: bump in-toto-attestation
woodruffw Dec 13, 2023
4728b32
Merge remote-tracking branch 'origin/main' into ww/dsse
woodruffw Dec 13, 2023
bbe07ac
Merge branch 'main' into ww/dsse
woodruffw Dec 19, 2023
d087a39
remove testing script
woodruffw Dec 19, 2023
0b23bc2
CHANGELOG: record changes
woodruffw Dec 19, 2023
ffcfa5b
Merge branch 'main' into ww/dsse
woodruffw Jan 9, 2024
86daf93
Merge branch 'main' into ww/dsse
woodruffw Jan 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
) -> 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