Skip to content

Commit bb9b1a0

Browse files
authored
API-level DSSE signing support (#804)
* 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>
1 parent 5d53e26 commit bb9b1a0

File tree

6 files changed

+169
-54
lines changed

6 files changed

+169
-54
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ All versions prior to 0.9.0 are untracked.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
* API: `Signer.sign()` can now take an in-toto `Statement` as an input,
14+
producing a DSSE-formatted signature rather than a "bare" signature
15+
([#804](https://github.com/sigstore/sigstore-python/pull/804))
16+
17+
18+
* API: `SigningResult.content` has been added, representing either the
19+
`hashedrekord` entry's message signature or the `dsse` entry's envelope
20+
([#804](https://github.com/sigstore/sigstore-python/pull/804))
21+
22+
23+
### Removed
24+
25+
* API: `SigningResult.input_digest` has been removed; users who expect
26+
to access the input digest may do so by inspecting the `hashedrekord`
27+
or `dsse`-specific `SigningResult.content`
28+
([#804](https://github.com/sigstore/sigstore-python/pull/804))
29+
1130
### Changed
1231

1332
* **BREAKING API CHANGE**: `sigstore.sign.SigningResult` has been removed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ dependencies = [
2929
"cryptography >= 39",
3030
"id >= 1.1.0",
3131
"importlib_resources ~= 5.7; python_version < '3.11'",
32+
"in-toto-attestation == 0.9.3",
3233
"pydantic >= 2,< 3",
3334
"pyjwt >= 2.1",
3435
"pyOpenSSL >= 23.0.0",
3536
"requests",
3637
"rich ~= 13.0",
3738
"securesystemslib",
3839
"sigstore-protobuf-specs ~= 0.2.2",
40+
# NOTE(ww): Under active development, so strictly pinned.
3941
"sigstore-rekor-types == 0.0.12",
4042
"tuf >= 2.1,< 4.0",
4143
]
@@ -60,6 +62,7 @@ lint = [
6062
# and let Dependabot periodically perform this update.
6163
"ruff < 0.1.14",
6264
"types-requests",
65+
"types-protobuf",
6366
"types-pyOpenSSL",
6467
]
6568
doc = ["pdoc"]

sigstore/_internal/dsse.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright 2022 The Sigstore Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Functionality for building and manipulating DSSE envelopes.
17+
"""
18+
19+
from cryptography.hazmat.primitives import hashes
20+
from cryptography.hazmat.primitives.asymmetric import ec
21+
from google.protobuf.json_format import MessageToJson
22+
from in_toto_attestation.v1.statement import Statement
23+
from sigstore_protobuf_specs.io.intoto import Envelope, Signature
24+
25+
26+
def sign_intoto(key: ec.EllipticCurvePrivateKey, payload: Statement) -> Envelope:
27+
"""
28+
Create a DSSE envelope containing a signature over an in-toto formatted
29+
attestation.
30+
"""
31+
32+
# See:
33+
# https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md
34+
# https://github.com/in-toto/attestation/blob/v1.0/spec/v1.0/envelope.md
35+
36+
type_ = "application/vnd.in-toto+json"
37+
payload_encoded = MessageToJson(payload.pb, sort_keys=True).encode()
38+
# NOTE: `payload_encoded.decode()` to avoid printing `repr(bytes)`, which would
39+
# add `b'...'` around the formatted payload.
40+
pae = (
41+
f"DSSEv1 {len(type_)} {type_} {len(payload_encoded)} {payload_encoded.decode()}"
42+
)
43+
44+
signature = key.sign(pae.encode(), ec.ECDSA(hashes.SHA256()))
45+
return Envelope(
46+
payload=payload_encoded,
47+
payload_type=type_,
48+
signatures=[Signature(sig=signature, keyid=None)],
49+
)

sigstore/_internal/rekor/client.py

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

1919
from __future__ import annotations
2020

21+
import json
2122
import logging
2223
from abc import ABC
2324
from dataclasses import dataclass
@@ -72,7 +73,20 @@ class RekorClientError(Exception):
7273
A generic error in the Rekor client.
7374
"""
7475

75-
pass
76+
def __init__(self, http_error: requests.HTTPError):
77+
"""
78+
Create a new `RekorClientError` from the given `requests.HTTPError`.
79+
"""
80+
if http_error.response:
81+
try:
82+
error = rekor_types.Error.model_validate_json(http_error.response.text)
83+
super().__init__(f"{error.code}: {error.message}")
84+
except Exception:
85+
super().__init__(
86+
f"Rekor returned an unknown error with HTTP {http_error.response.status_code}"
87+
)
88+
else:
89+
super().__init__(f"Unexpected Rekor error: {http_error}")
7690

7791

7892
class _Endpoint(ABC):
@@ -94,7 +108,7 @@ def get(self) -> RekorLogInfo:
94108
try:
95109
resp.raise_for_status()
96110
except requests.HTTPError as http_error:
97-
raise RekorClientError from http_error
111+
raise RekorClientError(http_error)
98112
return RekorLogInfo.from_response(resp.json())
99113

100114
@property
@@ -120,7 +134,7 @@ def get(
120134
Either `uuid` or `log_index` must be present, but not both.
121135
"""
122136
if not (bool(uuid) ^ bool(log_index)):
123-
raise RekorClientError("uuid or log_index required, but not both")
137+
raise ValueError("uuid or log_index required, but not both")
124138

125139
resp: requests.Response
126140

@@ -132,26 +146,29 @@ def get(
132146
try:
133147
resp.raise_for_status()
134148
except requests.HTTPError as http_error:
135-
raise RekorClientError from http_error
149+
raise RekorClientError(http_error)
136150
return LogEntry._from_response(resp.json())
137151

138152
def post(
139153
self,
140-
proposed_entry: rekor_types.Hashedrekord,
154+
proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse,
141155
) -> LogEntry:
142156
"""
143157
Submit a new entry for inclusion in the Rekor log.
144158
"""
145159

146-
resp: requests.Response = self.session.post(
147-
self.url, json=proposed_entry.model_dump(mode="json", by_alias=True)
148-
)
160+
payload = proposed_entry.model_dump(mode="json", by_alias=True)
161+
logger.debug(f"proposed: {json.dumps(payload)}")
162+
163+
resp: requests.Response = self.session.post(self.url, json=payload)
149164
try:
150165
resp.raise_for_status()
151166
except requests.HTTPError as http_error:
152-
raise RekorClientError from http_error
167+
raise RekorClientError(http_error)
153168

154-
return LogEntry._from_response(resp.json())
169+
integrated_entry = resp.json()
170+
logger.debug(f"integrated: {integrated_entry}")
171+
return LogEntry._from_response(integrated_entry)
155172

156173
@property
157174
def retrieve(self) -> RekorEntriesRetrieve:
@@ -170,7 +187,7 @@ class RekorEntriesRetrieve(_Endpoint):
170187

171188
def post(
172189
self,
173-
expected_entry: rekor_types.Hashedrekord,
190+
expected_entry: rekor_types.Hashedrekord | rekor_types.Dsse,
174191
) -> Optional[LogEntry]:
175192
"""
176193
Retrieves an extant Rekor entry, identified by its artifact signature,
@@ -187,7 +204,7 @@ def post(
187204
except requests.HTTPError as http_error:
188205
if http_error.response and http_error.response.status_code == 404:
189206
return None
190-
raise RekorClientError(resp.text) from http_error
207+
raise RekorClientError(http_error)
191208

192209
results = resp.json()
193210

sigstore/sign.py

Lines changed: 69 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from cryptography.hazmat.primitives.asymmetric import ec
5252
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
5353
from cryptography.x509.oid import NameOID
54+
from in_toto_attestation.v1.statement import Statement
5455
from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import (
5556
Bundle,
5657
VerificationMaterial,
@@ -70,7 +71,9 @@
7071
KindVersion,
7172
TransparencyLogEntry,
7273
)
74+
from sigstore_protobuf_specs.io.intoto import Envelope
7375

76+
from sigstore._internal import dsse
7477
from sigstore._internal.fulcio import (
7578
ExpiredCertificate,
7679
FulcioCertificateSigningResponse,
@@ -79,7 +82,7 @@
7982
from sigstore._internal.rekor.client import RekorClient
8083
from sigstore._internal.sct import verify_sct
8184
from sigstore._internal.trustroot import TrustedRoot
82-
from sigstore._utils import B64Str, HexStr, PEMCert, sha256_streaming
85+
from sigstore._utils import PEMCert, sha256_streaming
8386
from sigstore.oidc import ExpiredIdentity, IdentityToken
8487
from sigstore.transparency import LogEntry
8588

@@ -173,10 +176,9 @@ def _signing_cert(
173176

174177
def sign(
175178
self,
176-
input_: IO[bytes],
179+
input_: IO[bytes] | Statement,
177180
) -> Bundle:
178181
"""Public API for signing blobs"""
179-
input_digest = sha256_streaming(input_)
180182
private_key = self._private_key
181183

182184
if not self._identity_token.in_validity_period():
@@ -187,57 +189,78 @@ def sign(
187189
except ExpiredCertificate as e:
188190
raise e
189191

190-
# TODO(alex): Retrieve the public key via TUF
191-
#
192192
# Verify the SCT
193-
sct = certificate_response.sct # noqa
194-
cert = certificate_response.cert # noqa
193+
sct = certificate_response.sct
194+
cert = certificate_response.cert
195195
chain = certificate_response.chain
196196

197197
verify_sct(sct, cert, chain, self._signing_ctx._rekor._ct_keyring)
198198

199199
logger.debug("Successfully verified SCT...")
200200

201-
# Sign artifact
202-
artifact_signature = private_key.sign(
203-
input_digest, ec.ECDSA(Prehashed(hashes.SHA256()))
204-
)
205-
b64_artifact_signature = B64Str(base64.b64encode(artifact_signature).decode())
206-
207201
# Prepare inputs
208202
b64_cert = base64.b64encode(
209203
cert.public_bytes(encoding=serialization.Encoding.PEM)
210204
)
211205

212-
# Create the transparency log entry
213-
proposed_entry = rekor_types.Hashedrekord(
214-
kind="hashedrekord",
215-
api_version="0.0.1",
216-
spec=rekor_types.hashedrekord.HashedrekordV001Schema(
217-
signature=rekor_types.hashedrekord.Signature(
218-
content=b64_artifact_signature,
219-
public_key=rekor_types.hashedrekord.PublicKey(
220-
content=b64_cert.decode()
206+
# Sign artifact
207+
content: MessageSignature | Envelope
208+
proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse
209+
if isinstance(input_, Statement):
210+
content = dsse.sign_intoto(private_key, input_)
211+
212+
# Create the proposed DSSE entry
213+
proposed_entry = rekor_types.Dsse(
214+
spec=rekor_types.dsse.DsseV001Schema(
215+
proposed_content=rekor_types.dsse.ProposedContent(
216+
envelope=content.to_json(),
217+
verifiers=[b64_cert.decode()],
221218
),
222219
),
223-
data=rekor_types.hashedrekord.Data(
224-
hash=rekor_types.hashedrekord.Hash(
225-
algorithm=rekor_types.hashedrekord.Algorithm.SHA256,
226-
value=input_digest.hex(),
227-
)
220+
)
221+
else:
222+
input_digest = sha256_streaming(input_)
223+
224+
artifact_signature = private_key.sign(
225+
input_digest, ec.ECDSA(Prehashed(hashes.SHA256()))
226+
)
227+
228+
content = MessageSignature(
229+
message_digest=HashOutput(
230+
algorithm=HashAlgorithm.SHA2_256,
231+
digest=input_digest,
228232
),
229-
),
230-
)
233+
signature=artifact_signature,
234+
)
235+
236+
# Create the proposed hashedrekord entry
237+
proposed_entry = rekor_types.Hashedrekord(
238+
spec=rekor_types.hashedrekord.HashedrekordV001Schema(
239+
signature=rekor_types.hashedrekord.Signature(
240+
content=base64.b64encode(artifact_signature).decode(),
241+
public_key=rekor_types.hashedrekord.PublicKey(
242+
content=b64_cert.decode()
243+
),
244+
),
245+
data=rekor_types.hashedrekord.Data(
246+
hash=rekor_types.hashedrekord.Hash(
247+
algorithm=rekor_types.hashedrekord.Algorithm.SHA256,
248+
value=input_digest.hex(),
249+
)
250+
),
251+
),
252+
)
253+
254+
# Submit the proposed entry to the transparency log
231255
entry = self._signing_ctx._rekor.log.entries.post(proposed_entry)
232256

233257
logger.debug(f"Transparency log entry created with index: {entry.log_index}")
234258

235259
return _make_bundle(
236-
input_digest=HexStr(input_digest.hex()),
260+
content=content,
237261
cert_pem=PEMCert(
238262
cert.public_bytes(encoding=serialization.Encoding.PEM).decode()
239263
),
240-
b64_signature=B64Str(b64_artifact_signature),
241264
log_entry=entry,
242265
)
243266

@@ -308,7 +331,9 @@ def signer(
308331

309332

310333
def _make_bundle(
311-
input_digest: HexStr, cert_pem: PEMCert, b64_signature: B64Str, log_entry: LogEntry
334+
content: MessageSignature | Envelope,
335+
cert_pem: PEMCert,
336+
log_entry: LogEntry,
312337
) -> Bundle:
313338
"""
314339
Convert the raw results of a Sigstore signing operation into a Sigstore bundle.
@@ -332,10 +357,16 @@ def _make_bundle(
332357
checkpoint=Checkpoint(envelope=log_entry.inclusion_proof.checkpoint),
333358
)
334359

360+
# TODO: This is a bit of a hack.
361+
if isinstance(content, MessageSignature):
362+
kind_version = KindVersion(kind="hashedrekord", version="0.0.1")
363+
else:
364+
kind_version = KindVersion(kind="dsse", version="0.0.1")
365+
335366
tlog_entry = TransparencyLogEntry(
336367
log_index=log_entry.log_index,
337368
log_id=LogId(key_id=bytes.fromhex(log_entry.log_id)),
338-
kind_version=KindVersion(kind="hashedrekord", version="0.0.1"),
369+
kind_version=kind_version,
339370
integrated_time=log_entry.integrated_time,
340371
inclusion_promise=InclusionPromise(
341372
signed_entry_timestamp=base64.b64decode(log_entry.inclusion_promise)
@@ -354,13 +385,11 @@ def _make_bundle(
354385
bundle = Bundle(
355386
media_type="application/vnd.dev.sigstore.bundle+json;version=0.2",
356387
verification_material=material,
357-
message_signature=MessageSignature(
358-
message_digest=HashOutput(
359-
algorithm=HashAlgorithm.SHA2_256,
360-
digest=bytes.fromhex(input_digest),
361-
),
362-
signature=base64.b64decode(b64_signature),
363-
),
364388
)
365389

390+
if isinstance(content, MessageSignature):
391+
bundle.message_signature = content
392+
else:
393+
bundle.dsse_envelope = content
394+
366395
return bundle

0 commit comments

Comments
 (0)