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