1"""Signer implementation for pyca/cryptography signing."""
2
3from __future__ import annotations
4
5import logging
6import os
7from dataclasses import astuple, dataclass
8from typing import cast
9from urllib import parse
10
11from securesystemslib.exceptions import KeyMismatchError, UnsupportedLibraryError
12from securesystemslib.signer._constants import (
13 ECDSA_SHA2_NISTP256,
14 ECDSA_SHA2_NISTP384,
15 ECDSA_SHA2_NISTP521,
16 ED25519,
17 KEY_TYPE_ECDSA,
18 KEY_TYPE_ED25519,
19 KEY_TYPE_MLDSA,
20 KEY_TYPE_RSA,
21 MLDSA_44_1,
22 MLDSA_65_1,
23 MLDSA_87_1,
24 RSA_PKCS1V15_SHA224,
25 RSA_PKCS1V15_SHA256,
26 RSA_PKCS1V15_SHA384,
27 RSA_PKCS1V15_SHA512,
28 RSASSA_PSS_SHA224,
29 RSASSA_PSS_SHA256,
30 RSASSA_PSS_SHA384,
31 RSASSA_PSS_SHA512,
32)
33from securesystemslib.signer._key import Key, SSlibKey
34from securesystemslib.signer._signature import Signature
35from securesystemslib.signer._signer import SecretsHandler, Signer
36from securesystemslib.signer._utils import get_mldsa_payload
37
38CRYPTO_IMPORT_ERROR = None
39MLDSA_IMPORT_ERROR = None
40try:
41 from cryptography.hazmat.primitives.asymmetric.ec import (
42 ECDSA,
43 SECP256R1,
44 SECP384R1,
45 SECP521R1,
46 EllipticCurve,
47 EllipticCurvePrivateKey,
48 )
49 from cryptography.hazmat.primitives.asymmetric.ec import (
50 generate_private_key as generate_ec_private_key,
51 )
52 from cryptography.hazmat.primitives.asymmetric.ed25519 import (
53 Ed25519PrivateKey,
54 )
55 from cryptography.hazmat.primitives.asymmetric.padding import (
56 MGF1,
57 PSS,
58 PKCS1v15,
59 )
60 from cryptography.hazmat.primitives.asymmetric.rsa import (
61 AsymmetricPadding,
62 RSAPrivateKey,
63 )
64 from cryptography.hazmat.primitives.asymmetric.rsa import (
65 generate_private_key as generate_rsa_private_key,
66 )
67 from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
68 from cryptography.hazmat.primitives.hashes import (
69 SHA256,
70 SHA384,
71 SHA512,
72 HashAlgorithm,
73 )
74 from cryptography.hazmat.primitives.serialization import (
75 Encoding,
76 NoEncryption,
77 PrivateFormat,
78 load_pem_private_key,
79 )
80
81 from securesystemslib.signer._crypto_utils import get_hash_algorithm
82
83except ImportError:
84 CRYPTO_IMPORT_ERROR = "'pyca/cryptography' library required"
85
86# Handle ML-DSA support separately for cryptography 48 dependency:
87# This should not be needed but https://github.com/secure-systems-lab/securesystemslib/issues/1203
88try:
89 from cryptography.hazmat.primitives.asymmetric.mldsa import (
90 MLDSA44PrivateKey,
91 MLDSA65PrivateKey,
92 MLDSA87PrivateKey,
93 )
94except ImportError:
95 MLDSA_IMPORT_ERROR = "'cryptography>=48.0.0' required for ML-DSA support"
96 MLDSA44PrivateKey = None # type: ignore[assignment, misc]
97 MLDSA65PrivateKey = None # type: ignore[assignment, misc]
98 MLDSA87PrivateKey = None # type: ignore[assignment, misc]
99
100logger = logging.getLogger(__name__)
101
102
103@dataclass
104class _RSASignArgs:
105 padding: AsymmetricPadding
106 hash_algo: HashAlgorithm
107
108
109@dataclass
110class _ECDSASignArgs:
111 sig_algo: ECDSA
112
113
114@dataclass
115class _NoSignArgs:
116 pass
117
118
119# keep in sync with _get_ecdsa_curve_and_hash() below
120_ECDSA_SCHEMES = [
121 ECDSA_SHA2_NISTP256,
122 ECDSA_SHA2_NISTP384,
123 ECDSA_SHA2_NISTP521,
124]
125
126
127def _get_ecdsa_curve_and_hash(
128 scheme: str,
129) -> tuple[type[EllipticCurve], HashAlgorithm]:
130 """Helper to return curve and hash algorithm for an ecdsa scheme.
131
132 An ecdsa scheme fixes both, and the pairs must agree with the ones
133 SSlibKey._verify() uses, or signatures will not verify.
134 """
135 # built here and not at module scope, so that importing this module
136 # still works when pyca/cryptography is not installed
137 curves_and_hashes: dict[str, tuple[type[EllipticCurve], HashAlgorithm]] = {
138 ECDSA_SHA2_NISTP256: (SECP256R1, SHA256()),
139 ECDSA_SHA2_NISTP384: (SECP384R1, SHA384()),
140 ECDSA_SHA2_NISTP521: (SECP521R1, SHA512()),
141 }
142
143 return curves_and_hashes[scheme]
144
145
146def _get_rsa_padding(name: str, hash_algorithm: HashAlgorithm) -> AsymmetricPadding:
147 """Helper to return rsa signature padding for name."""
148 padding: AsymmetricPadding
149 if name == "pss":
150 padding = PSS(mgf=MGF1(hash_algorithm), salt_length=PSS.DIGEST_LENGTH)
151
152 if name == "pkcs1v15":
153 padding = PKCS1v15()
154
155 return padding
156
157
158class CryptoSigner(Signer):
159 """File-based signer using the cryptography (pyca/cryptography) library.
160
161 Supports signing with RSA, ECDSA, Ed25519, and ML-DSA keys.
162
163 The private key URI scheme is: ``file2:<PATH>``, where ``<PATH>`` is the filesystem
164 path to a PEM-encoded PKCS#8 private key file. If the ``CRYPTO_SIGNER_PATH_PREFIX``
165 environment variable is set, the path will be resolved relative to that prefix.
166
167 A CryptoSigner can be instantiated with:
168
169 * ``Signer.from_priv_key_uri("file2:<PATH>", public_key)``:
170 Generic way to load from an existing private key file.
171 * ``CryptoSigner.generate_*()`` factory methods generate new key pairs
172 * ``CryptoSigner(privkey, pubkey)``: Direct instantiation using existing
173 pyca/cryptography private key objects.
174 """
175
176 SCHEME = "file2"
177 PREFIX_ENV_VAR = "CRYPTO_SIGNER_PATH_PREFIX"
178
179 def __init__(
180 self,
181 private_key: PrivateKeyTypes,
182 public_key: SSlibKey | None = None,
183 ):
184 def assert_type(
185 name: str, key: PrivateKeyTypes, typ: type[PrivateKeyTypes]
186 ) -> None:
187 if not isinstance(key, typ):
188 raise ValueError(f"invalid {name} key: {type(key)}")
189
190 if CRYPTO_IMPORT_ERROR:
191 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
192
193 if public_key is None:
194 public_key = SSlibKey.from_crypto(private_key.public_key())
195
196 if public_key.keytype == KEY_TYPE_MLDSA and MLDSA_IMPORT_ERROR:
197 raise UnsupportedLibraryError(MLDSA_IMPORT_ERROR)
198
199 self._private_key: PrivateKeyTypes
200 self._sign_args: _RSASignArgs | _ECDSASignArgs | _NoSignArgs
201
202 if public_key.keytype == KEY_TYPE_RSA and public_key.scheme in [
203 RSASSA_PSS_SHA224,
204 RSASSA_PSS_SHA256,
205 RSASSA_PSS_SHA384,
206 RSASSA_PSS_SHA512,
207 RSA_PKCS1V15_SHA224,
208 RSA_PKCS1V15_SHA256,
209 RSA_PKCS1V15_SHA384,
210 RSA_PKCS1V15_SHA512,
211 ]:
212 assert_type(KEY_TYPE_RSA, private_key, RSAPrivateKey)
213
214 hash_name = public_key.get_hash_algorithm_name()
215 hash_algo = get_hash_algorithm(hash_name)
216
217 padding_name = public_key.get_padding_name()
218 padding = _get_rsa_padding(padding_name, hash_algo)
219
220 self._sign_args = _RSASignArgs(padding, hash_algo)
221
222 # for backwards compat the spec-deprecated ecdsa keytypes (which are
223 # named after the scheme) are accepted in addition to "ecdsa"
224 elif (
225 public_key.keytype in [KEY_TYPE_ECDSA, public_key.scheme]
226 and public_key.scheme in _ECDSA_SCHEMES
227 ):
228 assert_type(KEY_TYPE_ECDSA, private_key, EllipticCurvePrivateKey)
229 ec_key = cast(EllipticCurvePrivateKey, private_key)
230
231 curve, hash_algo = _get_ecdsa_curve_and_hash(public_key.scheme)
232 if not isinstance(ec_key.curve, curve):
233 raise ValueError(
234 f"bad curve {ec_key.curve.name} for {public_key.scheme}"
235 )
236
237 self._sign_args = _ECDSASignArgs(ECDSA(hash_algo))
238
239 elif public_key.keytype == KEY_TYPE_ED25519 and public_key.scheme == ED25519:
240 assert_type(KEY_TYPE_ED25519, private_key, Ed25519PrivateKey)
241 self._sign_args = _NoSignArgs()
242
243 elif public_key.keytype == KEY_TYPE_MLDSA and public_key.scheme == MLDSA_44_1:
244 assert_type(KEY_TYPE_MLDSA, private_key, MLDSA44PrivateKey)
245 self._sign_args = _NoSignArgs()
246
247 elif public_key.keytype == KEY_TYPE_MLDSA and public_key.scheme == MLDSA_65_1:
248 assert_type(KEY_TYPE_MLDSA, private_key, MLDSA65PrivateKey)
249 self._sign_args = _NoSignArgs()
250
251 elif public_key.keytype == KEY_TYPE_MLDSA and public_key.scheme == MLDSA_87_1:
252 assert_type(KEY_TYPE_MLDSA, private_key, MLDSA87PrivateKey)
253 self._sign_args = _NoSignArgs()
254
255 else:
256 raise ValueError(
257 f"unsupported public key {public_key.keytype}/{public_key.scheme}"
258 )
259
260 if public_key._crypto_key() != private_key.public_key():
261 raise KeyMismatchError("CryptoSigner private key does not match public key")
262
263 self._private_key = private_key
264 self._public_key = public_key
265
266 @property
267 def public_key(self) -> SSlibKey:
268 return self._public_key
269
270 @property
271 def private_bytes(self) -> bytes:
272 """Return the PEM encoded PKCS8 format private key as bytes
273
274 The return value can be used as file content when a Signer is loaded with
275 `Signer.from_priv_key_uri('file2:<FILEPATH>')`."""
276 return self._private_key.private_bytes(
277 encoding=Encoding.PEM,
278 format=PrivateFormat.PKCS8,
279 encryption_algorithm=NoEncryption(),
280 )
281
282 @classmethod
283 def from_priv_key_uri(
284 cls,
285 priv_key_uri: str,
286 public_key: Key,
287 secrets_handler: SecretsHandler | None = None,
288 ) -> CryptoSigner:
289 """Constructor for Signer to call
290
291 Please refer to Signer.from_priv_key_uri() documentation.
292
293 NOTE: pyca/cryptography is used to deserialize the key data. The
294 expected (and tested) encoding/format is PEM/PKCS8. Other formats may
295 but are not guaranteed to work.
296
297 URI has the format "file2:<PATH>", where PATH is a filesystem path to the
298 private key file. If CRYPTO_SIGNER_PATH_PREFIX environment variable
299 is set, the private key will be read from
300 ``CRYPTO_SIGNER_PATH_PREFIX + <SEPARATOR> + PATH``. The purpose of this
301 is to allow PATH to only encode an identifier (e.g. filename) while allowing
302 the signing system to store the private keys whereever it wants at runtime.
303
304 Raises:
305 UnsupportedLibraryError: pyca/cryptography not installed.
306 KeyMismatchError: Private key does not match public key.
307 OSError: File cannot be read.
308 ValueError: Invalid passed arguments.
309 cryptography.exceptions.UnsupportedAlgorithm: pyca/cryptography
310 deserialization failed.
311 """
312 if CRYPTO_IMPORT_ERROR:
313 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
314
315 if not isinstance(public_key, SSlibKey):
316 raise ValueError(f"Expected SSlibKey for {priv_key_uri}")
317
318 uri = parse.urlparse(priv_key_uri)
319
320 if uri.scheme != cls.SCHEME:
321 raise ValueError(f"CryptoSigner does not support {priv_key_uri}")
322
323 prefix = os.environ.get(cls.PREFIX_ENV_VAR)
324 path = os.path.join(prefix, uri.path) if prefix else uri.path
325 try:
326 with open(path, "rb") as f:
327 private_pem = f.read()
328 except FileNotFoundError as e:
329 raise FileNotFoundError(
330 f"Private key not found in '{path}' (with ",
331 f"{cls.PREFIX_ENV_VAR}: {prefix}, path: {uri.path})",
332 ) from e
333
334 private_key = load_pem_private_key(private_pem, None)
335 return CryptoSigner(private_key, public_key)
336
337 @staticmethod
338 def generate_ed25519(
339 keyid: str | None = None,
340 ) -> CryptoSigner:
341 """Generate new key pair as "ed25519" signer.
342
343 Args:
344 keyid: Key identifier. If not passed, a default keyid is computed.
345
346 Raises:
347 UnsupportedLibraryError: pyca/cryptography not installed
348 """
349 if CRYPTO_IMPORT_ERROR:
350 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
351
352 private_key = Ed25519PrivateKey.generate()
353 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, ED25519)
354 return CryptoSigner(private_key, public_key)
355
356 @staticmethod
357 def generate_rsa(
358 keyid: str | None = None,
359 scheme: str | None = RSASSA_PSS_SHA256,
360 size: int = 3072,
361 ) -> CryptoSigner:
362 """Generate new key pair as rsa signer.
363
364 Args:
365 keyid: Key identifier. If not passed, a default keyid is computed.
366 scheme: RSA signing scheme. Default is "rsassa-pss-sha256".
367 size: RSA key size in bits. Default is 3072.
368
369 Raises:
370 UnsupportedLibraryError: pyca/cryptography not installed
371 """
372 if CRYPTO_IMPORT_ERROR:
373 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
374
375 private_key = generate_rsa_private_key(
376 public_exponent=65537,
377 key_size=size,
378 )
379 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
380 return CryptoSigner(private_key, public_key)
381
382 @staticmethod
383 def generate_ecdsa(
384 keyid: str | None = None,
385 scheme: str | None = None,
386 ) -> CryptoSigner:
387 """Generate new key pair for an ecdsa signer.
388
389 Args:
390 keyid: Key identifier. If not passed, a default keyid is computed.
391 scheme: A valid ecdsa scheme, which also selects the curve. If not
392 passed, "ecdsa-sha2-nistp256" is used.
393
394 Raises:
395 UnsupportedLibraryError: pyca/cryptography not installed
396 ValueError: Invalid scheme
397 """
398 if CRYPTO_IMPORT_ERROR:
399 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
400
401 scheme = ECDSA_SHA2_NISTP256 if scheme is None else scheme
402 if scheme not in _ECDSA_SCHEMES:
403 raise ValueError(f"Invalid scheme for ecdsa: {scheme}")
404
405 curve, _ = _get_ecdsa_curve_and_hash(scheme)
406 private_key = generate_ec_private_key(curve())
407 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
408 return CryptoSigner(private_key, public_key)
409
410 @staticmethod
411 def generate_mldsa(
412 keyid: str | None = None,
413 scheme: str | None = None,
414 ) -> CryptoSigner:
415 """Generate new key pair for a ML-DSA signer.
416
417 Args:
418 keyid: Key identifier. If not passed, a default keyid is computed.
419 scheme: A valid key scheme for ml-dsa. If not passed, "ml-dsa-65/1" is used
420
421 Raises:
422 UnsupportedLibraryError: pyca/cryptography not installed
423 """
424 if CRYPTO_IMPORT_ERROR:
425 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
426 if MLDSA_IMPORT_ERROR:
427 raise UnsupportedLibraryError(MLDSA_IMPORT_ERROR)
428
429 scheme = MLDSA_65_1 if scheme is None else scheme
430 if scheme == MLDSA_44_1:
431 private_key: PrivateKeyTypes = MLDSA44PrivateKey.generate()
432 elif scheme == MLDSA_65_1:
433 private_key = MLDSA65PrivateKey.generate()
434 elif scheme == MLDSA_87_1:
435 private_key = MLDSA87PrivateKey.generate()
436 else:
437 raise ValueError(f"Invalid scheme for ML-DSA: {scheme}")
438
439 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
440 return CryptoSigner(private_key, public_key)
441
442 def sign(self, payload: bytes) -> Signature:
443 if self.public_key.keytype == KEY_TYPE_MLDSA:
444 # ml-dsa keytype specifies a domain-specific hash prefixing scheme
445 payload = get_mldsa_payload(payload, 1)
446
447 sig = self._private_key.sign(payload, *astuple(self._sign_args)) # type: ignore
448
449 return Signature(self.public_key.keyid, sig.hex())