Skip to content

Commit 918f867

Browse files
DarkaMaulwoodruffw
andauthored
Timestamp Authority Verification (#1206)
Co-authored-by: William Woodruff <[email protected]>
1 parent 9ca5639 commit 918f867

11 files changed

+693
-11
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ All versions prior to 0.9.0 are untracked.
1616
* API: Added `signature` property to `Envelope` class for accessing raw
1717
signature bytes ([#1211](https://github.com/sigstore/sigstore-python/pull/1211))
1818

19+
* Signed timestamps embedded in bundles are now automatically verified
20+
against Timestamp Authorities provided within the Trusted Root ([#1206]
21+
(https://github.com/sigstore/sigstore-python/pull/1206))
22+
1923
### Fixed
2024

2125
* Fixed a CLI parsing bug introduced in 3.5.1 where a warning about

sigstore/_internal/timestamp.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
Utilities to deal with sources of signed time.
17+
"""
18+
19+
import enum
20+
from dataclasses import dataclass
21+
from datetime import datetime
22+
23+
24+
class TimestampSource(enum.Enum):
25+
"""Represents the source of a timestamp."""
26+
27+
TIMESTAMP_AUTHORITY = enum.auto()
28+
TRANSPARENCY_SERVICE = enum.auto()
29+
30+
31+
@dataclass
32+
class TimestampVerificationResult:
33+
"""Represents a timestamp used by the Verifier.
34+
35+
A Timestamp either comes from a Timestamping Service (RFC3161) or the Transparency
36+
Service.
37+
"""
38+
39+
source: TimestampSource
40+
time: datetime

sigstore/models.py

+12
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,18 @@ def _dsse_envelope(self) -> dsse.Envelope | None:
568568
return dsse.Envelope(self._inner.dsse_envelope)
569569
return None
570570

571+
@property
572+
def signature(self) -> bytes:
573+
"""
574+
Returns the signature bytes of this bundle.
575+
Either from the DSSE Envelope or from the message itself.
576+
"""
577+
return (
578+
self._dsse_envelope.signature
579+
if self._dsse_envelope
580+
else self._inner.message_signature.signature
581+
)
582+
571583
@property
572584
def verification_material(self) -> VerificationMaterial:
573585
"""

sigstore/verify/verifier.py

+175-11
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
X509StoreFlags,
3737
)
3838
from pydantic import ValidationError
39+
from rfc3161_client import TimeStampResponse, VerifierBuilder
40+
from rfc3161_client import VerificationError as Rfc3161VerificationError
3941

4042
from sigstore import dsse
4143
from sigstore._internal.rekor import _hashedrekord_from_parts
@@ -44,6 +46,7 @@
4446
_get_precertificate_signed_certificate_timestamps,
4547
verify_sct,
4648
)
49+
from sigstore._internal.timestamp import TimestampSource, TimestampVerificationResult
4750
from sigstore._internal.trust import ClientTrustConfig, KeyringPurpose, TrustedRoot
4851
from sigstore._utils import base64_encode_pem_cert, sha256_digest
4952
from sigstore.errors import VerificationError
@@ -53,6 +56,14 @@
5356

5457
_logger = logging.getLogger(__name__)
5558

59+
# Limit the number of timestamps to prevent DoS
60+
# From https://github.com/sigstore/sigstore-go/blob/e92142f0734064ebf6001f188b7330a1212245fe/pkg/verify/tsa.go#L29
61+
MAX_ALLOWED_TIMESTAMP: int = 32
62+
63+
# When verifying a timestamp, this threshold represents the minimum number of required
64+
# timestamps to consider a signature valid.
65+
VERIFY_TIMESTAMP_THRESHOLD: int = 1
66+
5667

5768
class Verifier:
5869
"""
@@ -108,6 +119,155 @@ def _from_trust_config(cls, trust_config: ClientTrustConfig) -> Verifier:
108119
trusted_root=trust_config.trusted_root,
109120
)
110121

122+
def _verify_signed_timestamp(
123+
self, timestamp_response: TimeStampResponse, signature: bytes
124+
) -> TimestampVerificationResult | None:
125+
"""
126+
Verify a Signed Timestamp using the TSA provided by the Trusted Root.
127+
"""
128+
cert_authorities = self._trusted_root.get_timestamp_authorities()
129+
for certificate_authority in cert_authorities:
130+
certificates = certificate_authority.certificates(allow_expired=True)
131+
132+
builder = VerifierBuilder()
133+
for certificate in certificates:
134+
builder.add_root_certificate(certificate)
135+
136+
verifier = builder.build()
137+
try:
138+
verifier.verify(timestamp_response, signature)
139+
except Rfc3161VerificationError as e:
140+
_logger.debug("Unable to verify Timestamp with CA.")
141+
_logger.exception(e)
142+
continue
143+
144+
if (
145+
certificate_authority.validity_period_start
146+
and certificate_authority.validity_period_end
147+
):
148+
if (
149+
certificate_authority.validity_period_start
150+
<= timestamp_response.tst_info.gen_time
151+
< certificate_authority.validity_period_end
152+
):
153+
return TimestampVerificationResult(
154+
source=TimestampSource.TIMESTAMP_AUTHORITY,
155+
time=timestamp_response.tst_info.gen_time,
156+
)
157+
158+
_logger.debug(
159+
"Unable to verify Timestamp because not in CA time range."
160+
)
161+
else:
162+
_logger.debug(
163+
"Unable to verify Timestamp because no validity provided."
164+
)
165+
166+
return None
167+
168+
def _verify_timestamp_authority(
169+
self, bundle: Bundle
170+
) -> List[TimestampVerificationResult]:
171+
"""
172+
Verify that the given bundle has been timestamped by a trusted timestamp authority
173+
and that the timestamp is valid.
174+
175+
Returns the number of valid signed timestamp in the bundle.
176+
"""
177+
timestamp_responses = (
178+
bundle.verification_material.timestamp_verification_data.rfc3161_timestamps
179+
)
180+
if len(timestamp_responses) > MAX_ALLOWED_TIMESTAMP:
181+
msg = f"Too many signed timestamp: {len(timestamp_responses)} > {MAX_ALLOWED_TIMESTAMP}"
182+
raise VerificationError(msg)
183+
184+
if len(set(timestamp_responses)) != len(timestamp_responses):
185+
msg = "Duplicate timestamp found"
186+
raise VerificationError(msg)
187+
188+
# The Signer sends a hash of the signature as the messageImprint in a TimeStampReq
189+
# to the Timestamping Service
190+
signature_hash = sha256_digest(bundle.signature).digest
191+
verified_timestamps = []
192+
for tsr in timestamp_responses:
193+
if verified_timestamp := self._verify_signed_timestamp(tsr, signature_hash):
194+
verified_timestamps.append(verified_timestamp)
195+
196+
return verified_timestamps
197+
198+
def _establish_time(self, bundle: Bundle) -> List[TimestampVerificationResult]:
199+
"""
200+
Establish the time for bundle verification.
201+
202+
This method uses timestamps from two possible sources:
203+
1. RFC3161 signed timestamps from a Timestamping Authority (TSA)
204+
2. Transparency Log timestamps
205+
"""
206+
verified_timestamps = []
207+
208+
# If a timestamp from the timestamping service is available, the Verifier MUST
209+
# perform path validation using the timestamp from the Timestamping Service.
210+
if bundle.verification_material.timestamp_verification_data.rfc3161_timestamps:
211+
if not self._trusted_root.get_timestamp_authorities():
212+
msg = (
213+
"no Timestamp Authorities have been provided to validate this "
214+
"bundle but it contains a signed timestamp"
215+
)
216+
raise VerificationError(msg)
217+
218+
timestamp_from_tsa = self._verify_timestamp_authority(bundle)
219+
if len(timestamp_from_tsa) < VERIFY_TIMESTAMP_THRESHOLD:
220+
msg = (
221+
f"not enough timestamps validated to meet the validation "
222+
f"threshold ({len(timestamp_from_tsa)}/{VERIFY_TIMESTAMP_THRESHOLD})"
223+
)
224+
raise VerificationError(msg)
225+
226+
verified_timestamps.extend(timestamp_from_tsa)
227+
228+
# If a timestamp from the Transparency Service is available, the Verifier MUST
229+
# perform path validation using the timestamp from the Transparency Service.
230+
if timestamp := bundle.log_entry.integrated_time:
231+
verified_timestamps.append(
232+
TimestampVerificationResult(
233+
source=TimestampSource.TRANSPARENCY_SERVICE,
234+
time=datetime.fromtimestamp(timestamp, tz=timezone.utc),
235+
)
236+
)
237+
return verified_timestamps
238+
239+
def _verify_chain_at_time(
240+
self, certificate: X509, timestamp_result: TimestampVerificationResult
241+
) -> List[X509]:
242+
"""
243+
Verify the validity of the certificate chain at the given time.
244+
245+
Raises a VerificationError if the chain can't be built or be verified.
246+
"""
247+
# NOTE: The `X509Store` object cannot have its time reset once the `set_time`
248+
# method been called on it. To get around this, we construct a new one in each
249+
# call.
250+
store = X509Store()
251+
# NOTE: By explicitly setting the flags here, we ensure that OpenSSL's
252+
# PARTIAL_CHAIN default does not change on us. Enabling PARTIAL_CHAIN
253+
# would be strictly more conformant of OpenSSL, but we currently
254+
# *want* the "long" chain behavior of performing path validation
255+
# down to a self-signed root.
256+
store.set_flags(X509StoreFlags.X509_STRICT)
257+
for parent_cert_ossl in self._fulcio_certificate_chain:
258+
store.add_cert(parent_cert_ossl)
259+
260+
store.set_time(timestamp_result.time)
261+
262+
store_ctx = X509StoreContext(store, certificate)
263+
264+
try:
265+
# get_verified_chain returns the full chain including the end-entity certificate
266+
# and chain should contain only CA certificates
267+
return store_ctx.get_verified_chain()[1:]
268+
except X509StoreContextError as e:
269+
raise VerificationError(f"failed to build chain: {e}")
270+
111271
def _verify_common_signing_cert(
112272
self, bundle: Bundle, policy: VerificationPolicy
113273
) -> None:
@@ -120,6 +280,7 @@ def _verify_common_signing_cert(
120280

121281
# In order to verify an artifact, we need to achieve the following:
122282
#
283+
# 0. Establish a time for the signature.
123284
# 1. Verify that the signing certificate chains to the root of trust
124285
# and is valid at the time of signing.
125286
# 2. Verify the signing certificate's SCT.
@@ -135,7 +296,7 @@ def _verify_common_signing_cert(
135296
# 8. Verify the transparency log entry's consistency against the other
136297
# materials, to prevent variants of CVE-2022-36056.
137298
#
138-
# This method performs steps (1) through (6) above. Its caller
299+
# This method performs steps (0) through (6) above. Its caller
139300
# MUST perform steps (7) and (8) separately, since they vary based on
140301
# the kind of verification being performed (i.e. hashedrekord, DSSE, etc.)
141302

@@ -154,20 +315,23 @@ def _verify_common_signing_cert(
154315
for parent_cert_ossl in self._fulcio_certificate_chain:
155316
store.add_cert(parent_cert_ossl)
156317

318+
# (0): Establishing a Time for the Signature
319+
# First, establish a time for the signature. This timestamp is required to
320+
# validate the certificate chain, so this step comes first.
321+
# While this step is optional and only performed if timestamp data has been
322+
# provided within the bundle, providing a signed timestamp without a TSA to
323+
# verify it result in a VerificationError.
324+
verified_timestamps = self._establish_time(bundle)
325+
if not verified_timestamps:
326+
raise VerificationError("not enough sources of verified time")
327+
157328
# (1): verify that the signing certificate is signed by the root
158329
# certificate and that the signing certificate was valid at the
159330
# time of signing.
160-
sign_date = cert.not_valid_before_utc
161331
cert_ossl = X509.from_cryptography(cert)
162-
163-
store.set_time(sign_date)
164-
store_ctx = X509StoreContext(store, cert_ossl)
165-
try:
166-
# get_verified_chain returns the full chain including the end-entity certificate
167-
# and chain should contain only CA certificates
168-
chain = store_ctx.get_verified_chain()[1:]
169-
except X509StoreContextError as e:
170-
raise VerificationError(f"failed to build chain: {e}")
332+
chain: list[X509] = []
333+
for vts in verified_timestamps:
334+
chain = self._verify_chain_at_time(cert_ossl, vts)
171335

172336
# (2): verify the signing certificate's SCT.
173337
sct = _get_precertificate_signed_certificate_timestamps(cert)[0]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"subject": {
3+
"organization": "GitHub, Inc.",
4+
"commonName": "Internal Services Root"
5+
},
6+
"certChain": {
7+
"certificates": [
8+
{
9+
"rawBytes": "MIIB3DCCAWKgAwIBAgIUchkNsH36Xa04b1LqIc+qr9DVecMwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDQxNDAwMDAwMFoXDTI0MDQxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUD5ZNbSqYMd6r8qpOOEX9ibGnZT9GsuXOhr/f8U9FJugBGExKYp40OULS0erjZW7xV9xV52NnJf5OeDq4e5ZKqNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMIMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaW1RudOgVt0leqY0WKYbuPr47wAwCgYIKoZIzj0EAwMDaAAwZQIwbUH9HvD4ejCZJOWQnqAlkqURllvu9M8+VqLbiRK+zSfZCZwsiljRn8MQQRSkXEE5AjEAg+VxqtojfVfu8DhzzhCx9GKETbJHb19iV72mMKUbDAFmzZ6bQ8b54Zb8tidy5aWe"
10+
},
11+
{
12+
"rawBytes": "MIICEDCCAZWgAwIBAgIUX8ZO5QXP7vN4dMQ5e9sU3nub8OgwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTI4MDQxMjAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEvMLY/dTVbvIJYANAuszEwJnQE1llftynyMKIMhh48HmqbVr5ygybzsLRLVKbBWOdZ21aeJz+gZiytZetqcyF9WlER5NEMf6JV7ZNojQpxHq4RHGoGSceQv/qvTiZxEDKo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaW1RudOgVt0leqY0WKYbuPr47wAwHwYDVR0jBBgwFoAU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaQAwZgIxAK1B185ygCrIYFlIs3GjswjnwSMG6LY8woLVdakKDZxVa8f8cqMs1DhcxJ0+09w95QIxAO+tBzZk7vjUJ9iJgD4R6ZWTxQWKqNm74jO99o+o9sv4FI/SZTZTFyMn0IJEHdNmyA=="
13+
}
14+
]
15+
},
16+
"validFor": {
17+
"start": "2023-04-14T00:00:00.000Z",
18+
"end": "2024-04-14T00:00:00.000Z"
19+
}
20+
}

0 commit comments

Comments
 (0)