Repository: pyca/cryptography

Issue:
X25519 certificates
I'd like to equip entities with X25519 keys and certificates, so they can verify each others' identity as being inside my PKI hierarchy (Noise for IoT, kinda).

It turns out to be possible to do this with openssl using `-force_pubkey` (yes, lovely ^^):
```
# generate CSR with an auxiliary key, e.g. Ed255
openssl req -new -key tmp-ed255-privkey.pem -out ed255-signed.csr
# then sign this CSR, replacing the public key
openssl x509 -req -in ed255-signed.csr -CAkey ca-privkey.pem -CA ca-cert.pem -force_pubkey x255-pubkey.pem -out x255-cert.pem -CAcreateserial
```

Would implementing this potentially be in scope here? It would be a little hacky presumably :) But if that's acceptable, I could try and prepare a PR.


Discussion:
FWIW, simply allowing X25519PublicKey (and X448PublicKey) in `cryptography.x509.CertificateBuilder.public_key` does the trick for me.
Is there official standard/RFC for X25519 in X.509?
RFC 8410
This is both easier and harder now. Easier in that we now independently do signing and generate all the ASN.1 ourselves in rust. Harder in that to support this we'd need to expand the X25519/X448 APIs to support signing, which is a bit weird. I think if we're going to do this we need to be able to strongly articulate why in documentation. e.g. the normal model is ed25519 is for signing and x25519 is for kex, why would x25519 for signing be desirable (and if it's fine to use it, why ever use ed25519?).
I believe there is a misunderstanding.

First, there are no X25519 signatures in standard cryptography (and the pyca shouldn't invent them). There are ways to convert between Ed25519 and X25519 keys (since they're on a birationally equivalent curve), and it's _probably_ OK to use one key for both signing and key exchange (there are some preprints on this), but also _likely_ not a good idea (general principles are to separate identity/signing from key exchange, e.g. due to different lifecycles).

That's not what this issue is about though: It asks for the possibility of certifying an X25519 key exchange key. With a separate signing key of any type (RSA, Ed25519, ...).

Operationally, this "just' means putting the X25519 public key in the SPKI of the cert and signing over it with the signing key. It's useful for the usual reasons: extend a third party's trust in a signing key to the certified key exchange key. This may or may not be a niche use case (establishing secure channels via key exchange against a closed-universe of X25519 recipients) - it's definitely a legitimate use case, but I'd understand if pyca/cryptography prefers not so support it.

The openssl snippet at the top was maybe confusing: In openssl, there must always be a CSR. This is just how its API works, and not an intrinsic requirement about X.509 certificates. For certificates for signing keys, the CSR is possible, and done to prove that the certificate is awarded to someone holding the private key. However, creating a certificate should really (additionally or mainly) mean that the certifier somehow legitimises the public key in its certificates. As a CA, I don't just sign any CSR, I inspect the public key and check that I want to extend it a certificate (I also review the other parameters of the certificate such as key uses, validity, extensions, etc.).

Since there is precedent for non-signing keys in the openssl universe, the `-force_pubkey` flag was added as a hacky way to work around the "CSR only" API (some ancillary discussion can be found here: https://groups.google.com/g/mailing.openssl.users/c/1QuxCKPZ_-o). The snippet shows how to do this (use a fake Ed255 key to create a CSR and swap out the public key for the X255 key of interest ant certificate generation time), but this is not how things ideally should work.

I don't think pyca/cryptography needs to follow the openssl API. In an ideal (from my perspective) world, there would be an API to create certificates for any algorithm's public keys (with either requester or granter choice of other parameters such as key uses, validity, extensions etc.), and an optional (additional) API that supports CSRs à la openssl (for signing keys only), implemented in terms of the more general API.

Makes sense?



Resolving patch:
diff --git a/src/cryptography/hazmat/primitives/asymmetric/types.py b/src/cryptography/hazmat/primitives/asymmetric/types.py
--- a/src/cryptography/hazmat/primitives/asymmetric/types.py
+++ b/src/cryptography/hazmat/primitives/asymmetric/types.py
@@ -10,6 +10,8 @@
     ed25519,
     ed448,
     rsa,
+    x25519,
+    x448,
 )
 
 
@@ -27,3 +29,12 @@
     dsa.DSAPrivateKey,
     ec.EllipticCurvePrivateKey,
 ]
+CERTIFICATE_PUBLIC_KEY_TYPES = typing.Union[
+    dsa.DSAPublicKey,
+    rsa.RSAPublicKey,
+    ec.EllipticCurvePublicKey,
+    ed25519.Ed25519PublicKey,
+    ed448.Ed448PublicKey,
+    x25519.X25519PublicKey,
+    x448.X448PublicKey,
+]
diff --git a/src/cryptography/x509/base.py b/src/cryptography/x509/base.py
--- a/src/cryptography/x509/base.py
+++ b/src/cryptography/x509/base.py
@@ -17,8 +17,11 @@
     ed25519,
     ed448,
     rsa,
+    x25519,
+    x448,
 )
 from cryptography.hazmat.primitives.asymmetric.types import (
+    CERTIFICATE_PUBLIC_KEY_TYPES,
     PRIVATE_KEY_TYPES as PRIVATE_KEY_TYPES,
     PUBLIC_KEY_TYPES as PUBLIC_KEY_TYPES,
 )
@@ -101,7 +104,7 @@ def version(self) -> Version:
         """
 
     @abc.abstractmethod
-    def public_key(self) -> PUBLIC_KEY_TYPES:
+    def public_key(self) -> CERTIFICATE_PUBLIC_KEY_TYPES:
         """
         Returns the public key
         """
@@ -578,7 +581,7 @@ def __init__(
         self,
         issuer_name: typing.Optional[Name] = None,
         subject_name: typing.Optional[Name] = None,
-        public_key: typing.Optional[PUBLIC_KEY_TYPES] = None,
+        public_key: typing.Optional[CERTIFICATE_PUBLIC_KEY_TYPES] = None,
         serial_number: typing.Optional[int] = None,
         not_valid_before: typing.Optional[datetime.datetime] = None,
         not_valid_after: typing.Optional[datetime.datetime] = None,
@@ -631,7 +634,7 @@ def subject_name(self, name: Name) -> "CertificateBuilder":
 
     def public_key(
         self,
-        key: PUBLIC_KEY_TYPES,
+        key: CERTIFICATE_PUBLIC_KEY_TYPES,
     ) -> "CertificateBuilder":
         """
         Sets the requestor's public key (as found in the signing request).
@@ -644,12 +647,15 @@ def public_key(
                 ec.EllipticCurvePublicKey,
                 ed25519.Ed25519PublicKey,
                 ed448.Ed448PublicKey,
+                x25519.X25519PublicKey,
+                x448.X448PublicKey,
             ),
         ):
             raise TypeError(
                 "Expecting one of DSAPublicKey, RSAPublicKey,"
-                " EllipticCurvePublicKey, Ed25519PublicKey or"
-                " Ed448PublicKey."
+                " EllipticCurvePublicKey, Ed25519PublicKey,"
+                " Ed448PublicKey, X25519PublicKey, or "
+                "X448PublicKey."
             )
         if self._public_key is not None:
             raise ValueError("The public key may only be set once.")


Test patch:
diff --git a/tests/x509/test_x509.py b/tests/x509/test_x509.py
--- a/tests/x509/test_x509.py
+++ b/tests/x509/test_x509.py
@@ -27,6 +27,8 @@
     ed448,
     padding,
     rsa,
+    x25519,
+    x448,
 )
 from cryptography.hazmat.primitives.asymmetric.utils import (
     decode_dss_signature,
@@ -2968,6 +2970,56 @@ def test_build_cert_with_public_ed448_rsa_sig(self, backend):
         assert isinstance(cert.signature_hash_algorithm, hashes.SHA256)
         assert isinstance(cert.public_key(), ed448.Ed448PublicKey)
 
+    @pytest.mark.supported(
+        only_if=lambda backend: (
+            backend.x25519_supported() and backend.x448_supported()
+        ),
+        skip_message="Requires OpenSSL with x25519 & x448 support",
+    )
+    @pytest.mark.parametrize(
+        ("priv_key_cls", "pub_key_cls"),
+        [
+            (x25519.X25519PrivateKey, x25519.X25519PublicKey),
+            (x448.X448PrivateKey, x448.X448PublicKey),
+        ],
+    )
+    def test_build_cert_with_public_x25519_x448_rsa_sig(
+        self, priv_key_cls, pub_key_cls, backend
+    ):
+        issuer_private_key = RSA_KEY_2048.private_key(backend)
+        subject_private_key = priv_key_cls.generate()
+
+        not_valid_before = datetime.datetime(2002, 1, 1, 12, 1)
+        not_valid_after = datetime.datetime(2030, 12, 31, 8, 30)
+
+        builder = (
+            x509.CertificateBuilder()
+            .serial_number(777)
+            .issuer_name(
+                x509.Name([x509.NameAttribute(NameOID.COUNTRY_NAME, "US")])
+            )
+            .subject_name(
+                x509.Name([x509.NameAttribute(NameOID.COUNTRY_NAME, "US")])
+            )
+            .public_key(subject_private_key.public_key())
+            .not_valid_before(not_valid_before)
+            .not_valid_after(not_valid_after)
+        )
+
+        cert = builder.sign(issuer_private_key, hashes.SHA256(), backend)
+        assert cert.signature_hash_algorithm is not None
+        issuer_private_key.public_key().verify(
+            cert.signature,
+            cert.tbs_certificate_bytes,
+            padding.PKCS1v15(),
+            cert.signature_hash_algorithm,
+        )
+        assert cert.signature_algorithm_oid == (
+            SignatureAlgorithmOID.RSA_WITH_SHA256
+        )
+        assert isinstance(cert.signature_hash_algorithm, hashes.SHA256)
+        assert isinstance(cert.public_key(), pub_key_cls)
+
     def test_build_cert_with_rsa_key_too_small(self, backend):
         issuer_private_key = RSA_KEY_512.private_key(backend)
         subject_private_key = RSA_KEY_512.private_key(backend)