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

Timestamp Authority Verification #1206

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All versions prior to 0.9.0 are untracked.

## [Unreleased]

### Added

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

### Fixed

* Fixed a CLI parsing bug introduced in 3.5.1 where a warning about
Expand Down
11 changes: 11 additions & 0 deletions sigstore/dsse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ def __eq__(self, other: object) -> bool:

return self._inner == other._inner

@property
def signature(self) -> bytes:
"""Return the decoded bytes of the Envelope signature."""
if len(self._inner.signatures) == 0:
return b""

signature_bytes = self._inner.signatures[0].sig
if not signature_bytes:
return b""
Comment on lines +234 to +239
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than forwarding these invalid states, we should enforce the invariant that a dsse.Envelope always has exactly one non-empty signature member.

This should be done in the Envelope constructor -- there's an example of this pattern here:

https://github.com/trail-of-forks/sigstore-python/blob/5f23327c1520b757670f6e3aeccda1da66c4805f/sigstore/models.py#L444

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep changes contained, this is part of an other PR : #1211

return signature_bytes


def _pae(type_: str, body: bytes) -> bytes:
"""
Expand Down
12 changes: 12 additions & 0 deletions sigstore/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,18 @@ def _dsse_envelope(self) -> dsse.Envelope | None:
return dsse.Envelope(self._inner.dsse_envelope)
return None

@property
def signature(self) -> bytes:
"""
Returns the signature bytes of this bundle.
Either from the DSSE Envelope or from the message itself.
"""
return (
self._dsse_envelope.signature
if self._dsse_envelope
else self._inner.message_signature.signature
)

@property
def verification_material(self) -> VerificationMaterial:
"""
Expand Down
40 changes: 40 additions & 0 deletions sigstore/timestamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 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.

"""
Utilities to deal with Signed Timestamps.
"""

import enum
from dataclasses import dataclass
from datetime import datetime


class TimestampSource(enum.Enum):
"""Represents the source of a timestamp."""

TIMESTAMP_AUTHORITY = enum.auto()
TRANSPARENCY_SERVICE = enum.auto()


@dataclass
class TimestampVerificationResult:
"""Represents a timestamp used by the Verifier.

A Timestamp either comes from a Timestamping Service (RFC3161) or the Transparency
Service.
"""

source: TimestampSource
time: datetime
186 changes: 175 additions & 11 deletions sigstore/verify/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import base64
import logging
from datetime import datetime, timezone
from typing import List, cast
from typing import List, Union, cast

import rekor_types
from cryptography.exceptions import InvalidSignature
Expand All @@ -36,6 +36,8 @@
X509StoreFlags,
)
from pydantic import ValidationError
from rfc3161_client import TimeStampResponse, VerifierBuilder
from rfc3161_client import VerificationError as Rfc3161VerificationError

from sigstore import dsse
from sigstore._internal.rekor import _hashedrekord_from_parts
Expand All @@ -49,10 +51,19 @@
from sigstore.errors import VerificationError
from sigstore.hashes import Hashed
from sigstore.models import Bundle
from sigstore.timestamp import TimestampSource, TimestampVerificationResult
from sigstore.verify.policy import VerificationPolicy

_logger = logging.getLogger(__name__)

# Limit the number of timestamps to prevent DoS
# From https://github.com/sigstore/sigstore-go/blob/e92142f0734064ebf6001f188b7330a1212245fe/pkg/verify/tsa.go#L29
MAX_ALLOWED_TIMESTAMP: int = 32

# When verifying a timestamp, this threshold represents the minimum number of required
# timestamps to consider a signature valid.
VERIFY_TIMESTAMP_THRESHOLD: int = 1


class Verifier:
"""
Expand Down Expand Up @@ -108,6 +119,156 @@ def _from_trust_config(cls, trust_config: ClientTrustConfig) -> Verifier:
trusted_root=trust_config.trusted_root,
)

def _verify_signed_timestamp(
self, timestamp_response: TimeStampResponse, signature: bytes
) -> Union[None, TimestampVerificationResult]:
"""
Verify a Signed Timestamp using the TSA provided by the Trusted Root.
"""
cert_authorities = self._trusted_root.get_timestamp_authorities()
for certificate_authority in cert_authorities:
certificates = certificate_authority.certificates(allow_expired=True)

builder = VerifierBuilder()
for certificate in certificates[:-1]:
builder.add_intermediate_certificate(certificate)
builder.add_root_certificate(certificates[-1])

verifier = builder.build()
try:
verifier.verify(timestamp_response, signature)
except Rfc3161VerificationError as e:
_logger.debug("Unable to verify Timestamp with CA.")
_logger.exception(e)
continue

if (
certificate_authority.validity_period_start
and certificate_authority.validity_period_end
):
if (
certificate_authority.validity_period_start
<= timestamp_response.tst_info.gen_time
< certificate_authority.validity_period_end
):
return TimestampVerificationResult(
source=TimestampSource.TIMESTAMP_AUTHORITY,
time=timestamp_response.tst_info.gen_time,
)

_logger.debug(
"Unable to verify Timestamp because not in CA time range."
)
else:
_logger.debug(
"Unable to verify Timestamp because no validity provided."
)

return None

def _verify_timestamp_authority(
self, bundle: Bundle
) -> List[TimestampVerificationResult]:
"""
Verify that the given bundle has been timestamped by a trusted timestamp authority
and that the timestamp is valid.
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved

Returns the number of valid signed timestamp in the bundle.
"""
timestamp_responses: List[TimeStampResponse] = (
bundle.verification_material.timestamp_verification_data.rfc3161_timestamps
)
if len(timestamp_responses) > MAX_ALLOWED_TIMESTAMP:
msg = f"Too many signed timestamp: {len(timestamp_responses)} > {MAX_ALLOWED_TIMESTAMP}"
raise VerificationError(msg)

if len(set(timestamp_responses)) != len(timestamp_responses):
msg = "Duplicate timestamp found"
raise VerificationError(msg)

# The Signer sends a hash of the signature as the messageImprint in a TimeStampReq
# to the Timestamping Service
signature_hash = sha256_digest(bundle.signature).digest
verified_timestamps: List[TimestampVerificationResult] = []
for tsr in timestamp_responses:
if verified_timestamp := self._verify_signed_timestamp(tsr, signature_hash):
verified_timestamps.append(verified_timestamp)

return verified_timestamps

def _establish_time(self, bundle: Bundle) -> List[TimestampVerificationResult]:
"""
Establish timestamps source for the verification.

We both source signed timestamp (per RFC3161) and Transparency Log timestamp as
time sources. As per the spec, if both are available, the Verifier performs
path validation twice. If either fails, verification fails.
"""
verified_timestamps: List[TimestampVerificationResult] = []

# If a timestamp from the timestamping service is available, the Verifier MUST
# perform path validation using the timestamp from the Timestamping Service.
if bundle.verification_material.timestamp_verification_data.rfc3161_timestamps:
if not self._trusted_root.get_timestamp_authorities():
msg = (
"no Timestamp Authorities have been provided to validate this "
"bundle but it contains a signed timestamp"
)
raise VerificationError(msg)

timestamp_from_tsa = self._verify_timestamp_authority(bundle)
if len(timestamp_from_tsa) < VERIFY_TIMESTAMP_THRESHOLD:
msg = (
f"not enough timestamps validated to meet the validation "
f"threshold ({len(timestamp_from_tsa)}/{VERIFY_TIMESTAMP_THRESHOLD})"
)
raise VerificationError(msg)

verified_timestamps.extend(timestamp_from_tsa)

# If a timestamp from the Transparency Service is available, the Verifier MUST
# perform path validation using the timestamp from the Transparency Service.
if timestamp := bundle.log_entry.integrated_time:
verified_timestamps.append(
TimestampVerificationResult(
source=TimestampSource.TRANSPARENCY_SERVICE,
time=datetime.fromtimestamp(timestamp, tz=timezone.utc),
)
)
return verified_timestamps

def _verify_chain_at_time(
self, certificate: X509, timestamp_result: TimestampVerificationResult
) -> List[X509]:
"""
Verify the validity of the certificate chain at the given tive.

Raises a VerificationError if the chain can't be built or be verified.
"""
# NOTE: The `X509Store` object cannot have its time reset once the `set_time`
# method been called on it. To get around this, we construct a new one in each
# call.
store = X509Store()
# NOTE: By explicitly setting the flags here, we ensure that OpenSSL's
# PARTIAL_CHAIN default does not change on us. Enabling PARTIAL_CHAIN
# would be strictly more conformant of OpenSSL, but we currently
# *want* the "long" chain behavior of performing path validation
# down to a self-signed root.
store.set_flags(X509StoreFlags.X509_STRICT)
for parent_cert_ossl in self._fulcio_certificate_chain:
store.add_cert(parent_cert_ossl)

store.set_time(timestamp_result.time)

store_ctx = X509StoreContext(store, certificate)

try:
# get_verified_chain returns the full chain including the end-entity certificate
# and chain should contain only CA certificates
return store_ctx.get_verified_chain()[1:]
except X509StoreContextError as e:
raise VerificationError(f"failed to build chain: {e}")

def _verify_common_signing_cert(
self, bundle: Bundle, policy: VerificationPolicy
) -> None:
Expand Down Expand Up @@ -154,20 +315,23 @@ def _verify_common_signing_cert(
for parent_cert_ossl in self._fulcio_certificate_chain:
store.add_cert(parent_cert_ossl)

# (0): Establishing a Time for the Signature
# First, establish a time for the signature. This timestamp is required to
# validate the certificate chain, so this step comes first.
# While this step is optional and only performed if timestamp data has been
# provided within the bundle, providing a signed timestamp without a TSA to
# verify it result in a VerificationError.
verified_timestamps = self._establish_time(bundle)
if not verified_timestamps:
raise VerificationError("not enough sources of verified time")

# (1): verify that the signing certificate is signed by the root
# certificate and that the signing certificate was valid at the
# time of signing.
sign_date = cert.not_valid_before_utc
cert_ossl = X509.from_cryptography(cert)

store.set_time(sign_date)
store_ctx = X509StoreContext(store, cert_ossl)
try:
# get_verified_chain returns the full chain including the end-entity certificate
# and chain should contain only CA certificates
chain = store_ctx.get_verified_chain()[1:]
except X509StoreContextError as e:
raise VerificationError(f"failed to build chain: {e}")
chain: list[X509] = []
for vts in verified_timestamps:
chain = self._verify_chain_at_time(cert_ossl, vts)

# (2): verify the signing certificate's SCT.
sct = _get_precertificate_signed_certificate_timestamps(cert)[0]
Expand Down
20 changes: 20 additions & 0 deletions test/assets/trusted_root/certificate_authority.missingroot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"subject": {
"organization": "GitHub, Inc.",
"commonName": "Internal Services Root"
},
"certChain": {
"certificates": [
{
"rawBytes": "MIIB3DCCAWKgAwIBAgIUchkNsH36Xa04b1LqIc+qr9DVecMwCgYIKoZIzj0EAwMwMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMB4XDTIzMDQxNDAwMDAwMFoXDTI0MDQxMzAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgVGltZXN0YW1waW5nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUD5ZNbSqYMd6r8qpOOEX9ibGnZT9GsuXOhr/f8U9FJugBGExKYp40OULS0erjZW7xV9xV52NnJf5OeDq4e5ZKqNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMIMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaW1RudOgVt0leqY0WKYbuPr47wAwCgYIKoZIzj0EAwMDaAAwZQIwbUH9HvD4ejCZJOWQnqAlkqURllvu9M8+VqLbiRK+zSfZCZwsiljRn8MQQRSkXEE5AjEAg+VxqtojfVfu8DhzzhCx9GKETbJHb19iV72mMKUbDAFmzZ6bQ8b54Zb8tidy5aWe"
},
{
"rawBytes": "MIICEDCCAZWgAwIBAgIUX8ZO5QXP7vN4dMQ5e9sU3nub8OgwCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZJbnRlcm5hbCBTZXJ2aWNlcyBSb290MB4XDTIzMDQxNDAwMDAwMFoXDTI4MDQxMjAwMDAwMFowMjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMRkwFwYDVQQDExBUU0EgaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEvMLY/dTVbvIJYANAuszEwJnQE1llftynyMKIMhh48HmqbVr5ygybzsLRLVKbBWOdZ21aeJz+gZiytZetqcyF9WlER5NEMf6JV7ZNojQpxHq4RHGoGSceQv/qvTiZxEDKo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaW1RudOgVt0leqY0WKYbuPr47wAwHwYDVR0jBBgwFoAU9NYYlobnAG4c0/qjxyH/lq/wz+QwCgYIKoZIzj0EAwMDaQAwZgIxAK1B185ygCrIYFlIs3GjswjnwSMG6LY8woLVdakKDZxVa8f8cqMs1DhcxJ0+09w95QIxAO+tBzZk7vjUJ9iJgD4R6ZWTxQWKqNm74jO99o+o9sv4FI/SZTZTFyMn0IJEHdNmyA=="
}
]
},
"validFor": {
"start": "2023-04-14T00:00:00.000Z",
"end": "2024-04-14T00:00:00.000Z"
}
}
Loading
Loading