1# Copyright 2025 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"""Signers and verifiers using certificates."""
16
17import base64
18from collections.abc import Iterable
19import logging
20import pathlib
21
22import certifi
23from cryptography import exceptions
24from cryptography import x509
25from cryptography.hazmat.primitives import hashes
26from cryptography.hazmat.primitives import serialization
27from cryptography.hazmat.primitives.asymmetric import ec
28from cryptography.x509 import oid
29from OpenSSL import crypto
30from sigstore_models.bundle import v1 as bundle_pb
31from sigstore_models.common import v1 as common_pb
32from typing_extensions import override
33
34from model_signing._signing import sign_ec_key as ec_key
35from model_signing._signing import sign_sigstore_pb as sigstore_pb
36
37
38logger = logging.getLogger(__name__)
39
40
41class Signer(ec_key.Signer):
42 """Signer using certificates."""
43
44 def __init__(
45 self,
46 private_key_path: pathlib.Path,
47 signing_certificate_path: pathlib.Path,
48 certificate_chain_paths: Iterable[pathlib.Path],
49 ):
50 """Initializes the signer with the key, certificate and trust chain.
51
52 Args:
53 private_key_path: The path to the PEM encoded private key.
54 signing_certificate_path: The path to the signing certificate.
55 certificate_chain_paths: Paths to other certificates used to
56 establish chain of trust.
57
58 Raises:
59 ValueError: Signing certificate's public key does not match the
60 private key's public pair.
61 """
62 super().__init__(private_key_path)
63 self._signing_certificate = x509.load_pem_x509_certificate(
64 signing_certificate_path.read_bytes()
65 )
66
67 public_key_from_key = self._private_key.public_key()
68 public_key_from_certificate = self._signing_certificate.public_key()
69 if public_key_from_key != public_key_from_certificate:
70 raise ValueError(
71 "The public key from the certificate does not match "
72 "the public key paired with the private key"
73 )
74
75 self._trust_chain = x509.load_pem_x509_certificates(
76 b"".join([path.read_bytes() for path in certificate_chain_paths])
77 )
78
79 @override
80 def _get_verification_material(self) -> bundle_pb.VerificationMaterial:
81 def _to_protobuf_certificate(certificate):
82 return common_pb.X509Certificate(
83 raw_bytes=base64.b64encode(
84 certificate.public_bytes(
85 encoding=serialization.Encoding.DER
86 )
87 )
88 )
89
90 chain = [_to_protobuf_certificate(self._signing_certificate)]
91 chain.extend(
92 [
93 _to_protobuf_certificate(certificate)
94 for certificate in self._trust_chain
95 ]
96 )
97
98 return bundle_pb.VerificationMaterial(
99 x509_certificate_chain=common_pb.X509CertificateChain(
100 certificates=chain
101 ),
102 tlog_entries=[],
103 )
104
105
106def _log_certificate_fingerprint(
107 where: str, certificate: x509.Certificate, hash_algorithm: hashes.Hash
108) -> None:
109 """Log the fingerprint of a certificate, for debugging.
110
111 Args:
112 where: Location of where this gets called from, useful for debugging.
113 certificate: Certificate to compute fingerprint of and log.
114 hash_algorithm: The algorithm used to compute the fingerprint.
115 """
116 fp = certificate.fingerprint(hash_algorithm)
117 logger.info(
118 f"[{where:^8}] {hash_algorithm.name} "
119 f"Fingerprint: {':'.join(f'{b:02X}' for b in fp)}"
120 )
121
122
123class Verifier(sigstore_pb.Verifier):
124 """Verifier for signatures generated via signing with certificates."""
125
126 def __init__(
127 self,
128 certificate_chain_paths: Iterable[pathlib.Path] = frozenset(),
129 log_fingerprints: bool = False,
130 ):
131 """Initializes the verifier with the list of certificates to use.
132
133 Args:
134 certificate_chain_paths: Paths to certificates used to verify
135 signature and establish chain of trust. By default this is empty,
136 in which case we would use the root certificates from the
137 operating system, as per `certifi.where()`.
138 log_fingerprints: Log the fingerprints of certificates
139 """
140 self._log_fingerprints = log_fingerprints
141
142 if not certificate_chain_paths:
143 certificate_chain_paths = [pathlib.Path(certifi.where())]
144
145 certificates = x509.load_pem_x509_certificates(
146 b"".join([path.read_bytes() for path in certificate_chain_paths])
147 )
148
149 self._store = crypto.X509Store()
150 for certificate in certificates:
151 if self._log_fingerprints:
152 _log_certificate_fingerprint(
153 "init", certificate, hashes.SHA256()
154 )
155 self._store.add_cert(crypto.X509.from_cryptography(certificate))
156
157 @override
158 def _verify_bundle(self, bundle: bundle_pb.Bundle) -> tuple[str, bytes]:
159 public_key = self._verify_certificates(bundle.verification_material)
160 envelope = bundle.dsse_envelope
161 try:
162 public_key.verify(
163 envelope.signatures[0].sig,
164 sigstore_pb.pae(envelope.payload),
165 ec.ECDSA(ec_key.get_ec_key_hash(public_key)),
166 )
167 except exceptions.InvalidSignature:
168 # Compatibility layer with pre 1.0 release
169 # Here, we patch over a bug in `pae` which mixed unicode `str` and
170 # `bytes`. As a result, additional escape characters were added to
171 # the material that got signed over.
172 public_key.verify(
173 envelope.signatures[0].sig,
174 sigstore_pb.pae_compat(envelope.payload),
175 # Note another bug here: the v0.2 signatures were generated with
176 # hardcoded SHA256 hash, instead of the one that matches the
177 # key type. To verify those signatures, we have to hardcode this
178 # here too (instead of `ec_key.get_ec_key_hash(public_key)`).
179 # For the hardcode path see:
180 # https://github.com/sigstore/model-transparency/blob/9737f0e28349bf43897857ada7beaa22ec18e9a6/src/model_signing/signature/key.py#L103
181 ec.ECDSA(hashes.SHA256()),
182 )
183
184 return envelope.payload_type, envelope.payload
185
186 def _verify_certificates(
187 self,
188 verification_material: bundle_pb.VerificationMaterial,
189 log_fingerprints: bool = False,
190 ) -> ec.EllipticCurvePublicKey:
191 """Verifies the certificate chain and returns the public key.
192
193 The public key is extracted from the signing certificate from the chain
194 of trust, after the chain is validated. It must match the public key
195 from the key used during signing.
196 """
197
198 def _to_openssl_certificate(certificate_bytes, log_fingerprints):
199 cert = x509.load_der_x509_certificate(certificate_bytes)
200 if log_fingerprints:
201 _log_certificate_fingerprint("verify", cert, hashes.SHA256())
202 return crypto.X509.from_cryptography(cert)
203
204 signing_chain = verification_material.x509_certificate_chain
205 signing_certificate = x509.load_der_x509_certificate(
206 signing_chain.certificates[0].raw_bytes
207 )
208
209 max_signing_time = signing_certificate.not_valid_before_utc
210 self._store.set_time(max_signing_time)
211
212 trust_chain_ssl = [
213 _to_openssl_certificate(
214 certificate.raw_bytes, self._log_fingerprints
215 )
216 for certificate in signing_chain.certificates[1:]
217 ]
218 signing_certificate_ssl = _to_openssl_certificate(
219 signing_chain.certificates[0].raw_bytes, self._log_fingerprints
220 )
221
222 store_context = crypto.X509StoreContext(
223 self._store, signing_certificate_ssl, trust_chain_ssl
224 )
225 store_context.verify_certificate()
226
227 extensions = signing_certificate.extensions
228 can_use_for_signing = False
229 try:
230 usage = extensions.get_extension_for_class(x509.KeyUsage)
231 if usage.value.digital_signature:
232 can_use_for_signing = True
233 except x509.ExtensionNotFound:
234 logger.warn("Certificate does not specify 'KeyUsage'.")
235
236 if not can_use_for_signing:
237 try:
238 usage = extensions.get_extension_for_class(
239 x509.ExtendedKeyUsage
240 )
241 if oid.ExtendedKeyUsageOID.CODE_SIGNING in usage.value:
242 can_use_for_signing = True
243 except x509.ExtensionNotFound:
244 logger.warn("Certificate does not specify 'ExtendedKeyUsage'.")
245
246 if not can_use_for_signing:
247 raise ValueError("Signing certificate cannot be used for signing")
248
249 return signing_certificate.public_key()