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 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 self._private_key = private_key
261 self._public_key = public_key
262
263 @property
264 def public_key(self) -> SSlibKey:
265 return self._public_key
266
267 @property
268 def private_bytes(self) -> bytes:
269 """Return the PEM encoded PKCS8 format private key as bytes
270
271 The return value can be used as file content when a Signer is loaded with
272 `Signer.from_priv_key_uri('file2:<FILEPATH>')`."""
273 return self._private_key.private_bytes(
274 encoding=Encoding.PEM,
275 format=PrivateFormat.PKCS8,
276 encryption_algorithm=NoEncryption(),
277 )
278
279 @classmethod
280 def from_priv_key_uri(
281 cls,
282 priv_key_uri: str,
283 public_key: Key,
284 secrets_handler: SecretsHandler | None = None,
285 ) -> CryptoSigner:
286 """Constructor for Signer to call
287
288 Please refer to Signer.from_priv_key_uri() documentation.
289
290 NOTE: pyca/cryptography is used to deserialize the key data. The
291 expected (and tested) encoding/format is PEM/PKCS8. Other formats may
292 but are not guaranteed to work.
293
294 URI has the format "file2:<PATH>", where PATH is a filesystem path to the
295 private key file. If CRYPTO_SIGNER_PATH_PREFIX environment variable
296 is set, the private key will be read from
297 ``CRYPTO_SIGNER_PATH_PREFIX + <SEPARATOR> + PATH``. The purpose of this
298 is to allow PATH to only encode an identifier (e.g. filename) while allowing
299 the signing system to store the private keys whereever it wants at runtime.
300
301 Raises:
302 UnsupportedLibraryError: pyca/cryptography not installed.
303 OSError: File cannot be read.
304 ValueError: Invalid passed arguments.
305 cryptography.exceptions.UnsupportedAlgorithm: pyca/cryptography
306 deserialization failed.
307 """
308 if CRYPTO_IMPORT_ERROR:
309 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
310
311 if not isinstance(public_key, SSlibKey):
312 raise ValueError(f"Expected SSlibKey for {priv_key_uri}")
313
314 uri = parse.urlparse(priv_key_uri)
315
316 if uri.scheme != cls.SCHEME:
317 raise ValueError(f"CryptoSigner does not support {priv_key_uri}")
318
319 prefix = os.environ.get(cls.PREFIX_ENV_VAR)
320 path = os.path.join(prefix, uri.path) if prefix else uri.path
321 try:
322 with open(path, "rb") as f:
323 private_pem = f.read()
324 except FileNotFoundError as e:
325 raise FileNotFoundError(
326 f"Private key not found in '{path}' (with ",
327 f"{cls.PREFIX_ENV_VAR}: {prefix}, path: {uri.path})",
328 ) from e
329
330 private_key = load_pem_private_key(private_pem, None)
331 return CryptoSigner(private_key, public_key)
332
333 @staticmethod
334 def generate_ed25519(
335 keyid: str | None = None,
336 ) -> CryptoSigner:
337 """Generate new key pair as "ed25519" signer.
338
339 Args:
340 keyid: Key identifier. If not passed, a default keyid is computed.
341
342 Raises:
343 UnsupportedLibraryError: pyca/cryptography not installed
344 """
345 if CRYPTO_IMPORT_ERROR:
346 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
347
348 private_key = Ed25519PrivateKey.generate()
349 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, ED25519)
350 return CryptoSigner(private_key, public_key)
351
352 @staticmethod
353 def generate_rsa(
354 keyid: str | None = None,
355 scheme: str | None = RSASSA_PSS_SHA256,
356 size: int = 3072,
357 ) -> CryptoSigner:
358 """Generate new key pair as rsa signer.
359
360 Args:
361 keyid: Key identifier. If not passed, a default keyid is computed.
362 scheme: RSA signing scheme. Default is "rsassa-pss-sha256".
363 size: RSA key size in bits. Default is 3072.
364
365 Raises:
366 UnsupportedLibraryError: pyca/cryptography not installed
367 """
368 if CRYPTO_IMPORT_ERROR:
369 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
370
371 private_key = generate_rsa_private_key(
372 public_exponent=65537,
373 key_size=size,
374 )
375 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
376 return CryptoSigner(private_key, public_key)
377
378 @staticmethod
379 def generate_ecdsa(
380 keyid: str | None = None,
381 scheme: str | None = None,
382 ) -> CryptoSigner:
383 """Generate new key pair for an ecdsa signer.
384
385 Args:
386 keyid: Key identifier. If not passed, a default keyid is computed.
387 scheme: A valid ecdsa scheme, which also selects the curve. If not
388 passed, "ecdsa-sha2-nistp256" is used.
389
390 Raises:
391 UnsupportedLibraryError: pyca/cryptography not installed
392 ValueError: Invalid scheme
393 """
394 if CRYPTO_IMPORT_ERROR:
395 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
396
397 scheme = ECDSA_SHA2_NISTP256 if scheme is None else scheme
398 if scheme not in _ECDSA_SCHEMES:
399 raise ValueError(f"Invalid scheme for ecdsa: {scheme}")
400
401 curve, _ = _get_ecdsa_curve_and_hash(scheme)
402 private_key = generate_ec_private_key(curve())
403 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
404 return CryptoSigner(private_key, public_key)
405
406 @staticmethod
407 def generate_mldsa(
408 keyid: str | None = None,
409 scheme: str | None = None,
410 ) -> CryptoSigner:
411 """Generate new key pair for a ML-DSA signer.
412
413 Args:
414 keyid: Key identifier. If not passed, a default keyid is computed.
415 scheme: A valid key scheme for ml-dsa. If not passed, "ml-dsa-65/1" is used
416
417 Raises:
418 UnsupportedLibraryError: pyca/cryptography not installed
419 """
420 if CRYPTO_IMPORT_ERROR:
421 raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)
422 if MLDSA_IMPORT_ERROR:
423 raise UnsupportedLibraryError(MLDSA_IMPORT_ERROR)
424
425 scheme = MLDSA_65_1 if scheme is None else scheme
426 if scheme == MLDSA_44_1:
427 private_key: PrivateKeyTypes = MLDSA44PrivateKey.generate()
428 elif scheme == MLDSA_65_1:
429 private_key = MLDSA65PrivateKey.generate()
430 elif scheme == MLDSA_87_1:
431 private_key = MLDSA87PrivateKey.generate()
432 else:
433 raise ValueError(f"Invalid scheme for ML-DSA: {scheme}")
434
435 public_key = SSlibKey.from_crypto(private_key.public_key(), keyid, scheme)
436 return CryptoSigner(private_key, public_key)
437
438 def sign(self, payload: bytes) -> Signature:
439 if self.public_key.keytype == KEY_TYPE_MLDSA:
440 # ml-dsa keytype specifies a domain-specific hash prefixing scheme
441 payload = get_mldsa_payload(payload, 1)
442
443 sig = self._private_key.sign(payload, *astuple(self._sign_args)) # type: ignore
444
445 return Signature(self.public_key.keyid, sig.hex())