Skip to content

Commit 4ad35d1

Browse files
authored
sigstore, test: break apart DSSE/artifact sign APIs (#956)
* sigstore, test: break apart DSSE/artifact sign APIs Signed-off-by: William Woodruff <william@trailofbits.com> * CHANGELOG: record/update Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore: update example Signed-off-by: William Woodruff <william@trailofbits.com> * sign: fix docstring Signed-off-by: William Woodruff <william@trailofbits.com> * CHANGELOG: cleanup Signed-off-by: William Woodruff <william@trailofbits.com> --------- Signed-off-by: William Woodruff <william@trailofbits.com>
1 parent cf71770 commit 4ad35d1

File tree

4 files changed

+127
-107
lines changed

4 files changed

+127
-107
lines changed

CHANGELOG.md

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,12 @@ All versions prior to 0.9.0 are untracked.
1010

1111
### Added
1212

13-
* API: `Signer.sign()` can now take a `Hashed` as an input,
14-
performing a signature on a pre-computed hash value
15-
([#860](https://github.com/sigstore/sigstore-python/pull/860))
13+
* API: `Signer.sign_artifact()` has been added, replacing the removed
14+
`Signer.sign()` API
1615

17-
* API: `Signer.sign()` can now take an in-toto `Statement` as an input,
18-
producing a DSSE-formatted signature rather than a "bare" signature
19-
([#804](https://github.com/sigstore/sigstore-python/pull/804))
20-
21-
* API: `SigningResult.content` has been added, representing either the
22-
`hashedrekord` entry's message signature or the `dsse` entry's envelope
23-
([#804](https://github.com/sigstore/sigstore-python/pull/804))
16+
* API: `Signer.sign_intoto()` has been added. It takes an in-toto `Statement`
17+
as an input, producing a DSSE-formatted signature rather than a "bare"
18+
signature ([#804](https://github.com/sigstore/sigstore-python/pull/804))
2419

2520
* API: "v3" Sigstore bundles are now supported during verification
2621
([#901](https://github.com/sigstore/sigstore-python/pull/901))
@@ -41,15 +36,16 @@ All versions prior to 0.9.0 are untracked.
4136
* **BREAKING API CHANGE**: `VerificationMaterials` has been removed.
4237
The public verification APIs now accept `sigstore.verify.models.Bundle`.
4338

39+
* **BREAKING API CHANGE**: `Signer.sign(...)` has been removed. Use
40+
either `sign_artifact(...)` or `sign_intoto(...)`, depending on whether
41+
you're signing opaque bytes or an in-toto statement.
42+
4443
* **BREAKING API CHANGE**: `VerificationResult` has been removed.
4544
The public verification and policy APIs now raise
4645
`sigstore.errors.VerificationError` on failure.
4746

4847
### Changed
4948

50-
* **BREAKING API CHANGE**: The `Signer.sign(...)` API now returns a `sigstore.verify.models.Bundle`,
51-
instead of a `SigningResult` ([#862](https://github.com/sigstore/sigstore-python/pull/862))
52-
5349
* **BREAKING API CHANGE**: `Verifier.verify(...)` now takes a `bytes | Hashed`
5450
as its verification input, rather than implicitly receiving the input through
5551
the `VerificationMaterials` parameter
@@ -59,10 +55,6 @@ All versions prior to 0.9.0 are untracked.
5955
a `Hashed` parameter to convey the digest used for Rekor entry lookup
6056
([#904](https://github.com/sigstore/sigstore-python/pull/904))
6157

62-
* **BREAKING API CHANGE**: `Signer.sign(...)` now takes a `bytes` instead of
63-
an `IO[bytes]` for input. Other input types (such as `Hashed` and
64-
`Statement`) are unchanged ([#921](https://github.com/sigstore/sigstore-python/pull/921))
65-
6658
* **BREAKING API CHANGE**: `Verifier.verify(...)` now takes a `sigstore.verify.models.Bundle`,
6759
instead of a `VerificationMaterials` ([#937](https://github.com/sigstore/sigstore-python/pull/937))
6860

sigstore/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ def _sign(args: argparse.Namespace) -> None:
622622
# digest and sign the prehash rather than buffering it fully.
623623
digest = sha256_digest(io)
624624
try:
625-
result = signer.sign(input_=digest)
625+
result = signer.sign_artifact(input_=digest)
626626
except ExpiredIdentity as exp_identity:
627627
print("Signature failed: identity token has expired")
628628
raise exp_identity

sigstore/sign.py

Lines changed: 111 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
3232
signing_ctx = SigningContext.production()
3333
with signing_ctx.signer(identity, cache=True) as signer:
34-
result = signer.sign(artifact)
34+
result = signer.sign_artifact(artifact)
3535
print(result)
3636
```
3737
"""
@@ -58,7 +58,6 @@
5858
from sigstore import hashes as sigstore_hashes
5959
from sigstore._internal.fulcio import (
6060
ExpiredCertificate,
61-
FulcioCertificateSigningResponse,
6261
FulcioClient,
6362
)
6463
from sigstore._internal.rekor.client import RekorClient
@@ -98,14 +97,12 @@ def __init__(
9897
self._identity_token = identity_token
9998
self._signing_ctx: SigningContext = signing_ctx
10099
self.__cached_private_key: Optional[ec.EllipticCurvePrivateKey] = None
101-
self.__cached_signing_certificate: Optional[
102-
FulcioCertificateSigningResponse
103-
] = None
100+
self.__cached_signing_certificate: Optional[x509.Certificate] = None
104101
if cache:
105102
_logger.debug("Generating ephemeral keys...")
106103
self.__cached_private_key = ec.generate_private_key(ec.SECP256R1())
107104
_logger.debug("Requesting ephemeral certificate...")
108-
self.__cached_signing_certificate = self._signing_cert(self._private_key)
105+
self.__cached_signing_certificate = self._signing_cert()
109106

110107
@property
111108
def _private_key(self) -> ec.EllipticCurvePrivateKey:
@@ -117,12 +114,22 @@ def _private_key(self) -> ec.EllipticCurvePrivateKey:
117114

118115
def _signing_cert(
119116
self,
120-
private_key: ec.EllipticCurvePrivateKey,
121-
) -> FulcioCertificateSigningResponse:
122-
"""Get or request a signing certificate from Fulcio."""
117+
) -> x509.Certificate:
118+
"""
119+
Get or request a signing certificate from Fulcio.
120+
121+
Internally, this performs a CSR against Fulcio and verifies that
122+
the returned certificate is present in Fulcio's CT log.
123+
"""
124+
125+
# Our CSR cannot possibly succeed if our underlying identity token
126+
# is expired.
127+
if not self._identity_token.in_validity_period():
128+
raise ExpiredIdentity
129+
123130
# If it exists, verify if the current certificate is expired
124131
if self.__cached_signing_certificate:
125-
not_valid_after = self.__cached_signing_certificate.cert.not_valid_after_utc
132+
not_valid_after = self.__cached_signing_certificate.not_valid_after_utc
126133
if datetime.now(timezone.utc) > not_valid_after:
127134
raise ExpiredCertificate
128135
return self.__cached_signing_certificate
@@ -147,110 +154,131 @@ def _signing_cert(
147154
critical=True,
148155
)
149156
)
150-
certificate_request = builder.sign(private_key, hashes.SHA256())
157+
certificate_request = builder.sign(self._private_key, hashes.SHA256())
151158

152159
certificate_response = self._signing_ctx._fulcio.signing_cert.post(
153160
certificate_request, self._identity_token
154161
)
155162

156-
return certificate_response
163+
# Verify the SCT
164+
sct = certificate_response.sct
165+
cert = certificate_response.cert
166+
chain = certificate_response.chain
167+
168+
verify_sct(sct, cert, chain, self._signing_ctx._trusted_root.ct_keyring())
157169

158-
def sign(
170+
_logger.debug("Successfully verified SCT...")
171+
172+
return cert
173+
174+
def _finalize_sign(
159175
self,
160-
input_: bytes | dsse.Statement | sigstore_hashes.Hashed,
176+
cert: x509.Certificate,
177+
content: MessageSignature | dsse.Envelope,
178+
proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse,
161179
) -> Bundle:
162180
"""
163-
Sign an input, and return a `Bundle` corresponding to the signed result.
181+
Perform the common "finalizing" steps in a Sigstore signing flow.
182+
"""
183+
# Submit the proposed entry to the transparency log
184+
entry = self._signing_ctx._rekor.log.entries.post(proposed_entry)
164185

165-
The input can be one of three forms:
186+
_logger.debug(f"Transparency log entry created with index: {entry.log_index}")
166187

167-
1. A `bytes` buffer;
168-
2. A `Hashed` object, containing a pre-hashed input (e.g., for inputs
169-
that are too large to buffer into memory);
170-
3. An in-toto `Statement` object.
188+
return Bundle._from_parts(cert, content, entry)
171189

172-
In cases (1) and (2), the signing operation will produce a `hashedrekord`
173-
entry within the bundle. In case (3), the signing operation will produce
174-
a DSSE envelope and corresponding `dsse` entry within the bundle.
190+
def sign_intoto(
191+
self,
192+
input_: dsse.Statement,
193+
) -> Bundle:
175194
"""
176-
private_key = self._private_key
195+
Sign the given in-toto statement, and return a `Bundle` containing
196+
the signed result.
177197
178-
if not self._identity_token.in_validity_period():
179-
raise ExpiredIdentity
198+
This API is **only** for in-toto statements; to sign arbitrary artifacts,
199+
use `sign_artifact` instead.
200+
"""
201+
cert = self._signing_cert()
180202

181-
try:
182-
certificate_response = self._signing_cert(private_key)
183-
except ExpiredCertificate as e:
184-
raise e
203+
# Prepare inputs
204+
b64_cert = base64.b64encode(
205+
cert.public_bytes(encoding=serialization.Encoding.PEM)
206+
)
185207

186-
# Verify the SCT
187-
sct = certificate_response.sct
188-
cert = certificate_response.cert
189-
chain = certificate_response.chain
208+
# Sign the statement, producing a DSSE envelope
209+
content = dsse._sign(self._private_key, input_)
190210

191-
verify_sct(sct, cert, chain, self._signing_ctx._trusted_root.ct_keyring())
211+
# Create the proposed DSSE log entry
212+
proposed_entry = rekor_types.Dsse(
213+
spec=rekor_types.dsse.DsseV001Schema(
214+
proposed_content=rekor_types.dsse.ProposedContent(
215+
envelope=content.to_json(),
216+
verifiers=[b64_cert.decode()],
217+
),
218+
),
219+
)
192220

193-
_logger.debug("Successfully verified SCT...")
221+
return self._finalize_sign(cert, content, proposed_entry)
222+
223+
def sign_artifact(
224+
self,
225+
input_: bytes | sigstore_hashes.Hashed,
226+
) -> Bundle:
227+
"""
228+
Sign an artifact, and return a `Bundle` corresponding to the signed result.
229+
230+
The input can be one of two forms:
231+
232+
1. A `bytes` buffer;
233+
2. A `Hashed` object, containing a pre-hashed input (e.g., for inputs
234+
that are too large to buffer into memory).
235+
236+
Regardless of the input format, the signing operation will produce a
237+
`hashedrekord` entry within the bundle. No other entry types
238+
are supported by this API.
239+
"""
240+
241+
cert = self._signing_cert()
194242

195243
# Prepare inputs
196244
b64_cert = base64.b64encode(
197245
cert.public_bytes(encoding=serialization.Encoding.PEM)
198246
)
199247

200248
# Sign artifact
201-
content: MessageSignature | dsse.Envelope
202-
proposed_entry: rekor_types.Hashedrekord | rekor_types.Dsse
203-
if isinstance(input_, dsse.Statement):
204-
content = dsse._sign(private_key, input_)
205-
206-
# Create the proposed DSSE entry
207-
proposed_entry = rekor_types.Dsse(
208-
spec=rekor_types.dsse.DsseV001Schema(
209-
proposed_content=rekor_types.dsse.ProposedContent(
210-
envelope=content.to_json(),
211-
verifiers=[b64_cert.decode()],
212-
),
213-
),
214-
)
215-
else:
216-
hashed_input = sha256_digest(input_)
249+
hashed_input = sha256_digest(input_)
217250

218-
artifact_signature = private_key.sign(
219-
hashed_input.digest, ec.ECDSA(hashed_input._as_prehashed())
220-
)
251+
artifact_signature = self._private_key.sign(
252+
hashed_input.digest, ec.ECDSA(hashed_input._as_prehashed())
253+
)
221254

222-
content = MessageSignature(
223-
message_digest=HashOutput(
224-
algorithm=hashed_input.algorithm,
225-
digest=hashed_input.digest,
226-
),
227-
signature=artifact_signature,
228-
)
255+
content = MessageSignature(
256+
message_digest=HashOutput(
257+
algorithm=hashed_input.algorithm,
258+
digest=hashed_input.digest,
259+
),
260+
signature=artifact_signature,
261+
)
229262

230-
# Create the proposed hashedrekord entry
231-
proposed_entry = rekor_types.Hashedrekord(
232-
spec=rekor_types.hashedrekord.HashedrekordV001Schema(
233-
signature=rekor_types.hashedrekord.Signature(
234-
content=base64.b64encode(artifact_signature).decode(),
235-
public_key=rekor_types.hashedrekord.PublicKey(
236-
content=b64_cert.decode()
237-
),
238-
),
239-
data=rekor_types.hashedrekord.Data(
240-
hash=rekor_types.hashedrekord.Hash(
241-
algorithm=hashed_input._as_hashedrekord_algorithm(),
242-
value=hashed_input.digest.hex(),
243-
)
263+
# Create the proposed hashedrekord entry
264+
proposed_entry = rekor_types.Hashedrekord(
265+
spec=rekor_types.hashedrekord.HashedrekordV001Schema(
266+
signature=rekor_types.hashedrekord.Signature(
267+
content=base64.b64encode(artifact_signature).decode(),
268+
public_key=rekor_types.hashedrekord.PublicKey(
269+
content=b64_cert.decode()
244270
),
245271
),
246-
)
247-
248-
# Submit the proposed entry to the transparency log
249-
entry = self._signing_ctx._rekor.log.entries.post(proposed_entry)
250-
251-
_logger.debug(f"Transparency log entry created with index: {entry.log_index}")
272+
data=rekor_types.hashedrekord.Data(
273+
hash=rekor_types.hashedrekord.Hash(
274+
algorithm=hashed_input._as_hashedrekord_algorithm(),
275+
value=hashed_input.digest.hex(),
276+
)
277+
),
278+
),
279+
)
252280

253-
return Bundle._from_parts(cert, content, entry)
281+
return self._finalize_sign(cert, content, proposed_entry)
254282

255283

256284
class SigningContext:

test/unit/test_sign.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def test_sign_rekor_entry_consistent(signer_and_ident):
4848

4949
payload = secrets.token_bytes(32)
5050
with ctx.signer(identity) as signer:
51-
expected_entry = signer.sign(payload).log_entry
51+
expected_entry = signer.sign_artifact(payload).log_entry
5252

5353
actual_entry = ctx._rekor.log.entries.get(log_index=expected_entry.log_index)
5454

@@ -74,7 +74,7 @@ def test_sct_verify_keyring_lookup_error(signer_and_ident, monkeypatch):
7474
payload = secrets.token_bytes(32)
7575
with pytest.raises(VerificationError, match=r"SCT verify failed:"):
7676
with ctx.signer(identity) as signer:
77-
signer.sign(payload)
77+
signer.sign_artifact(payload)
7878

7979

8080
@pytest.mark.online
@@ -95,7 +95,7 @@ def test_sct_verify_keyring_error(signer_and_ident, monkeypatch):
9595

9696
with pytest.raises(VerificationError):
9797
with ctx.signer(identity) as signer:
98-
signer.sign(payload)
98+
signer.sign_artifact(payload)
9999

100100

101101
@pytest.mark.online
@@ -112,7 +112,7 @@ def test_identity_proof_claim_lookup(signer_and_ident, monkeypatch):
112112
payload = secrets.token_bytes(32)
113113

114114
with ctx.signer(identity) as signer:
115-
expected_entry = signer.sign(payload).log_entry
115+
expected_entry = signer.sign_artifact(payload).log_entry
116116
actual_entry = ctx._rekor.log.entries.get(log_index=expected_entry.log_index)
117117

118118
assert expected_entry.body == actual_entry.body
@@ -135,7 +135,7 @@ def test_sign_prehashed(staging):
135135
)
136136

137137
with sign_ctx.signer(identity) as signer:
138-
bundle = signer.sign(hashed)
138+
bundle = signer.sign_artifact(hashed)
139139

140140
assert bundle._inner.message_signature.message_digest.algorithm == hashed.algorithm
141141
assert bundle._inner.message_signature.message_digest.digest == hashed.digest
@@ -167,6 +167,6 @@ def test_sign_dsse(staging):
167167
).build()
168168

169169
with ctx.signer(identity) as signer:
170-
bundle = signer.sign(stmt)
170+
bundle = signer.sign_intoto(stmt)
171171
# Ensures that all of our inner types serialize as expected.
172172
bundle.to_json()

0 commit comments

Comments
 (0)