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 elliptic curve keys."""
16
17import base64
18from datetime import datetime
19import hashlib
20import pathlib
21
22from cryptography import exceptions
23from cryptography.hazmat.primitives import hashes
24from cryptography.hazmat.primitives import serialization
25from cryptography.hazmat.primitives.asymmetric import ec
26from cryptography.hazmat.primitives.asymmetric import types as crypto_types
27from google.protobuf import json_format
28from sigstore_models import intoto as intoto_pb
29from sigstore_models.bundle import v1 as bundle_pb
30from sigstore_models.common import v1 as common_pb
31from typing_extensions import override
32
33from model_signing._signing import sign_sigstore_pb as sigstore_pb
34from model_signing._signing import signing
35
36
37def _check_supported_ec_key(public_key: crypto_types.PublicKeyTypes):
38 """Checks if the elliptic curve key is supported by our package.
39
40 We only support a family of curves, trying to match those specified by
41 Sigstore's protobuf specs.
42 See https://github.com/sigstore/model-transparency/issues/385.
43
44 Args:
45 public_key: The public key to check. Can be obtained from a private key.
46
47 Raises:
48 ValueError: The key is not supported, or is not an elliptic curve one.
49 """
50 if not isinstance(public_key, ec.EllipticCurvePublicKey):
51 raise ValueError("Only elliptic curve keys are supported")
52
53 curve = public_key.curve.name
54 if curve not in ["secp256r1", "secp384r1", "secp521r1"]:
55 raise ValueError(f"Unsupported key for curve '{curve}'")
56
57
58def get_ec_key_hash(
59 public_key: ec.EllipticCurvePublicKey,
60) -> hashes.HashAlgorithm:
61 """Returns the public key hashing algorithm.
62
63 We need to record this in the sigstore bundle when signing and retrieve when
64 performing verification. This is according to sigstore protobuf specs and is
65 used both when signing with keys and when signing with certificates.
66
67 Args:
68 public_key: The public key to get the hash algorithm from.
69
70 Raises:
71 ValueError: The key is not supported.
72 """
73 key_size = public_key.curve.key_size
74
75 match key_size:
76 case 256:
77 return hashes.SHA256()
78 case 384:
79 return hashes.SHA384()
80 case 521:
81 return hashes.SHA512()
82 case _:
83 raise ValueError(f"Unexpected key size {key_size}")
84
85
86class Signer(sigstore_pb.Signer):
87 """Signer using an elliptic curve private key."""
88
89 def __init__(
90 self,
91 private_key_path: pathlib.Path,
92 password: str | None = None,
93 tsa_url: str | None = None,
94 ):
95 """Initializes the signer with the private key and optional password.
96
97 Args:
98 private_key_path: The path to the PEM encoded private key.
99 password: Optional password for the private key.
100 tsa_url: Optional URL of an RFC 3161 Timestamp Authority.
101 """
102 self._private_key = serialization.load_pem_private_key(
103 private_key_path.read_bytes(), password
104 )
105 _check_supported_ec_key(self._private_key.public_key())
106 self._tsa_url = tsa_url
107
108 @override
109 def sign(self, payload: signing.Payload) -> signing.Signature:
110 raw_payload = json_format.MessageToJson(payload.statement.pb).encode(
111 "utf-8"
112 )
113
114 signature_bytes = self._private_key.sign(
115 sigstore_pb.pae(raw_payload),
116 ec.ECDSA(get_ec_key_hash(self._private_key.public_key())),
117 )
118
119 raw_signature = intoto_pb.Signature(
120 sig=base64.b64encode(signature_bytes), keyid=""
121 )
122
123 envelope = intoto_pb.Envelope(
124 payload=base64.b64encode(raw_payload),
125 payload_type=signing._IN_TOTO_JSON_PAYLOAD_TYPE,
126 signatures=[raw_signature],
127 )
128
129 verification_material = self._get_verification_material()
130
131 if self._tsa_url:
132 verification_material.timestamp_verification_data = (
133 sigstore_pb.request_timestamp(signature_bytes, self._tsa_url)
134 )
135
136 return sigstore_pb.Signature(
137 bundle_pb.Bundle(
138 media_type=sigstore_pb._BUNDLE_MEDIA_TYPE,
139 verification_material=verification_material,
140 dsse_envelope=envelope,
141 )
142 )
143
144 def _get_verification_material(self) -> bundle_pb.VerificationMaterial:
145 """Returns the verification material to include in the bundle."""
146 public_key = self._private_key.public_key()
147
148 raw_bytes = public_key.public_bytes(
149 encoding=serialization.Encoding.PEM,
150 format=serialization.PublicFormat.SubjectPublicKeyInfo,
151 )
152
153 hash_bytes = hashlib.sha256(raw_bytes).digest().hex()
154
155 return bundle_pb.VerificationMaterial(
156 public_key=common_pb.PublicKeyIdentifier(hint=hash_bytes),
157 tlog_entries=[],
158 )
159
160
161class Verifier(sigstore_pb.Verifier):
162 """Verifier for signatures generated with an elliptic curve private key."""
163
164 def __init__(
165 self,
166 public_key_path: pathlib.Path,
167 trust_config: pathlib.Path | None = None,
168 require_timestamp: bool = False,
169 ):
170 """Initializes the verifier with the public key to use.
171
172 Args:
173 public_key_path: The path to the public key to use. This must be
174 paired with the private key used to generate the signature.
175 trust_config: A Sigstore client trust configuration or trusted root.
176 require_timestamp: Reject bundles without an RFC 3161 timestamp.
177 """
178 super().__init__(trust_config, require_timestamp=require_timestamp)
179 self._public_key = serialization.load_pem_public_key(
180 public_key_path.read_bytes()
181 )
182 _check_supported_ec_key(self._public_key)
183
184 @override
185 def _verify_bundle(
186 self, bundle: bundle_pb.Bundle, verification_time: datetime | None
187 ) -> tuple[str, bytes]:
188 raw_bytes = self._public_key.public_bytes(
189 encoding=serialization.Encoding.PEM,
190 format=serialization.PublicFormat.SubjectPublicKeyInfo,
191 )
192
193 hash_bytes = hashlib.sha256(raw_bytes).digest().hex()
194
195 if bundle.verification_material.public_key.hint:
196 key_hint = bundle.verification_material.public_key.hint
197 if key_hint != hash_bytes:
198 raise ValueError(
199 "Key mismatch: The public key hash in the signature's "
200 "verification material does not match the provided "
201 "public key. "
202 )
203 else:
204 print(
205 "WARNING: This model's signature uses an older "
206 "verification material format. Please re-sign "
207 "with an updated signer to use a public key "
208 "identifier hash. "
209 )
210
211 envelope = bundle.dsse_envelope
212 try:
213 self._public_key.verify(
214 envelope.signatures[0].sig,
215 sigstore_pb.pae(envelope.payload),
216 ec.ECDSA(get_ec_key_hash(self._public_key)),
217 )
218 except exceptions.InvalidSignature:
219 # Compatibility layer with pre 1.0 release
220 # Here, we patch over a bug in `pae` which mixed unicode `str` and
221 # `bytes`. As a result, additional escape characters were added to
222 # the material that got signed over.
223 self._public_key.verify(
224 envelope.signatures[0].sig,
225 sigstore_pb.pae_compat(envelope.payload),
226 ec.ECDSA(get_ec_key_hash(self._public_key)),
227 )
228
229 return envelope.payload_type, envelope.payload