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