Repository: getmoto/moto

Issue:
High Security Vulnerability CVE-2024-23342 (ecdsa lib)
There is a `High` security vulnerability [CVE-2024-23342](https://nvd.nist.gov/vuln/detail/CVE-2024-23342) in the `ecdsa` lib, used by `moto`(see [setup.cfg](https://github.com/getmoto/moto/blob/master/setup.cfg#L50)). We are using `moto` in our codebase, and the vulnerability is being flagged by our security scans, which is blocking our release. 

- When `ecdsa` lib is required by `moto`?
- Any possibility to make its installation optional?
- Any chance it can be replaced by another lib, to mitigate the risk? 



Discussion:
According to `ecdsa` GitHub repo (see [here](https://github.com/tlsfuzzer/python-ecdsa/security/advisories/GHSA-wj6h-64fc-37mp)), "_As such, we don't plan to release a fix to this vulnerability._" 
Hi @tabdunabi, `ecdsa` is a transient dependency for two of our direct dependencies: `python-jose` and `sshpubkeys`.

`python-jose` is planned to be replaced - see https://github.com/getmoto/moto/issues/7244
`sshpubkeys` is required for the EC2 submodule in Moto, and indirectly in a few other submodules (DS, EBS, EFS, among others) that rely on EC2 for some of their operations.

The 'stock' moto installation does not pull in the `ecdsa`-dependency - i.e. `pip install moto` is safe. Only if you use any of the listed submodules, and use `pip install moto[ec2, ..]`, will the dependency be downloaded. So it is already optional.

At the end of the day, however, this is a library meant for testing purposes - not to be used with actual data in production. So I don't see how this is an security risk for Moto users.
Thank you @bblommers!, I appreciate your quick response. 

Resolving patch:
diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -8,7 +8,7 @@
 from collections import OrderedDict
 from typing import Any, Dict, List, Optional, Set, Tuple
 
-from jose import jws
+from joserfc import jwk, jwt
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
@@ -444,7 +444,7 @@ def __init__(
         with open(
             os.path.join(os.path.dirname(__file__), "resources/jwks-private.json")
         ) as f:
-            self.json_web_key = json.loads(f.read())
+            self.json_web_key = jwk.RSAKey.import_key(json.loads(f.read()))
 
     @property
     def backend(self) -> "CognitoIdpBackend":
@@ -543,10 +543,10 @@ def create_jwt(
             "username" if token_use == "access" else "cognito:username": username,
         }
         payload.update(extra_data or {})
-        headers = {"kid": "dummy"}  # KID as present in jwks-public.json
+        headers = {"kid": "dummy", "alg": "RS256"}  # KID as present in jwks-public.json
 
         return (
-            jws.sign(payload, self.json_web_key, headers, algorithm="RS256"),
+            jwt.encode(headers, payload, self.json_web_key),
             expires_in,
         )
 
diff --git a/moto/ec2/utils.py b/moto/ec2/utils.py
--- a/moto/ec2/utils.py
+++ b/moto/ec2/utils.py
@@ -669,31 +669,66 @@ def generate_instance_identity_document(instance: Any) -> Dict[str, Any]:
     return document
 
 
+def _convert_rfc4716(data: bytes) -> bytes:
+    """Convert an RFC 4716 public key to OpenSSH authorized_keys format"""
+
+    # Normalize line endings and join continuation lines
+    data_normalized = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
+    data_joined = data_normalized.replace(b"\\\n", b"")
+    lines = data_joined.splitlines()
+
+    # Trim header and footer
+    if lines[0] != b"---- BEGIN SSH2 PUBLIC KEY ----":
+        raise ValueError("Invalid RFC4716 header line")
+    if lines[-1] != b"---- END SSH2 PUBLIC KEY ----":
+        raise ValueError("Invalid RFC4716 footer line")
+    lines = lines[1:-1]
+
+    # Leading lines containing a colon are headers
+    headers = {}
+    num_header_lines = 0
+    for line in lines:
+        if b":" not in line:
+            break
+        num_header_lines += 1
+        header_name, header_value = line.split(b": ")
+        headers[header_name.lower()] = header_value
+
+    # Remaining lines are key data
+    data_lines = lines[num_header_lines:]
+    b64_key = b"".join(data_lines)
+
+    # Extract the algo name from the binary packet
+    packet = base64.b64decode(b64_key)
+    alg_len = int.from_bytes(packet[:4], "big")
+    alg = packet[4 : 4 + alg_len]
+
+    result_parts = [alg, b64_key]
+    if b"comment" in headers:
+        result_parts.append(headers[b"comment"])
+    return b" ".join(result_parts)
+
+
 def public_key_parse(
     key_material: Union[str, bytes]
 ) -> Union[RSAPublicKey, Ed25519PublicKey]:
-    # These imports take ~.5s; let's keep them local
-    import sshpubkeys.exceptions
-    from sshpubkeys.keys import SSHKey
-
     try:
-        if not isinstance(key_material, bytes):
+        if isinstance(key_material, str):
             key_material = key_material.encode("ascii")
+        key_material = base64.b64decode(key_material)
 
-        decoded_key = base64.b64decode(key_material)
-        public_key = SSHKey(decoded_key.decode("ascii"))
-    except (sshpubkeys.exceptions.InvalidKeyException, UnicodeDecodeError):
-        raise ValueError("bad key")
+        if key_material.startswith(b"---- BEGIN SSH2 PUBLIC KEY ----"):
+            # cryptography doesn't parse RFC4716 key format, so we have to convert it first
+            key_material = _convert_rfc4716(key_material)
 
-    if public_key.rsa:
-        return public_key.rsa
+        public_key = serialization.load_ssh_public_key(key_material)
 
-    # `cryptography` currently does not support RSA RFC4716/SSH2 format, otherwise we could get rid of `sshpubkeys` and
-    # simply use `load_ssh_public_key()`
-    if public_key.key_type == b"ssh-ed25519":
-        return serialization.load_ssh_public_key(decoded_key)  # type: ignore[return-value]
+        if not isinstance(public_key, (RSAPublicKey, Ed25519PublicKey)):
+            raise ValueError("bad key")
+    except UnicodeDecodeError:
+        raise ValueError("bad key")
 
-    raise ValueError("bad key")
+    return public_key
 
 
 def public_key_fingerprint(public_key: Union[RSAPublicKey, Ed25519PublicKey]) -> str:


Test patch:
diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py
--- a/tests/test_cognitoidp/test_cognitoidp.py
+++ b/tests/test_cognitoidp/test_cognitoidp.py
@@ -13,13 +13,17 @@
 import pytest
 import requests
 from botocore.exceptions import ClientError, ParamValidationError
-from jose import jws, jwt
+from joserfc import jwk, jws, jwt
 
 import moto.cognitoidp.models
-from moto import mock_aws, settings
+from moto import cognitoidp, mock_aws, settings
 from moto.cognitoidp.utils import create_id
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 from moto.core import set_initial_no_auth_action_count
+from moto.utilities.utils import load_resource
+
+private_key = load_resource(cognitoidp.__name__, "resources/jwks-private.json")
+PUBLIC_KEY = jwk.RSAKey.import_key(private_key)
 
 
 @mock_aws
@@ -1543,7 +1547,8 @@ def test_group_in_access_token():
         ChallengeResponses={"USERNAME": username, "NEW_PASSWORD": new_password},
     )
 
-    claims = jwt.get_unverified_claims(result["AuthenticationResult"]["AccessToken"])
+    payload = jwt.decode(result["AuthenticationResult"]["AccessToken"], PUBLIC_KEY)
+    claims = payload.claims
     assert claims["cognito:groups"] == [group_name]
 
 
@@ -1604,7 +1609,8 @@ def test_other_attributes_in_id_token():
         ChallengeResponses={"USERNAME": username, "NEW_PASSWORD": new_password},
     )
 
-    claims = jwt.get_unverified_claims(result["AuthenticationResult"]["IdToken"])
+    payload = jwt.decode(result["AuthenticationResult"]["IdToken"], PUBLIC_KEY)
+    claims = payload.claims
     assert claims["cognito:groups"] == [group_name]
     assert claims["custom:myattr"] == "some val"
 
@@ -2957,7 +2963,7 @@ def test_token_legitimacy():
 
     path = "../../moto/cognitoidp/resources/jwks-public.json"
     with open(os.path.join(os.path.dirname(__file__), path)) as f:
-        json_web_key = json.loads(f.read())["keys"][0]
+        json_web_key = jwk.RSAKey.import_key(json.loads(f.read())["keys"][0])
 
     for auth_flow in ["ADMIN_NO_SRP_AUTH", "ADMIN_USER_PASSWORD_AUTH"]:
         outputs = authentication_flow(conn, auth_flow)
@@ -2968,14 +2974,14 @@ def test_token_legitimacy():
         issuer = (
             f"https://cognito-idp.us-west-2.amazonaws.com/{outputs['user_pool_id']}"
         )
-        id_claims = json.loads(jws.verify(id_token, json_web_key, "RS256"))
+        id_claims = jwt.decode(id_token, json_web_key, ["RS256"]).claims
         assert id_claims["iss"] == issuer
         assert id_claims["aud"] == client_id
         assert id_claims["token_use"] == "id"
         assert id_claims["cognito:username"] == username
         for k, v in outputs["additional_fields"].items():
             assert id_claims[k] == v
-        access_claims = json.loads(jws.verify(access_token, json_web_key, "RS256"))
+        access_claims = jwt.decode(access_token, json_web_key, ["RS256"]).claims
         assert access_claims["iss"] == issuer
         assert access_claims["client_id"] == client_id
         assert access_claims["token_use"] == "access"
@@ -4938,8 +4944,10 @@ def test_idtoken_contains_kid_header():
 
 def verify_kid_header(token):
     """Verifies the kid-header is corresponds with the public key"""
-    headers = jwt.get_unverified_headers(token)
-    kid = headers["kid"]
+    if isinstance(token, str):
+        token = token.encode("ascii")
+    sig = jws.extract_compact(token)
+    kid = sig.headers()["kid"]
 
     key_index = -1
     keys = fetch_public_keys()
diff --git a/tests/test_ec2/test_key_pairs.py b/tests/test_ec2/test_key_pairs.py
--- a/tests/test_ec2/test_key_pairs.py
+++ b/tests/test_ec2/test_key_pairs.py
@@ -27,7 +27,7 @@
 qusUO07jKuSxzPumXBeU+JEtx0J1tqZwJlpGt2R+0qN7nKnPl2+hx \
 moto@github.com"""
 
-RSA_PUBLIC_KEY_RFC4716 = b"""\
+RSA_PUBLIC_KEY_RFC4716_1 = b"""\
 ---- BEGIN SSH2 PUBLIC KEY ----
 AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
 Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
@@ -38,6 +38,44 @@
 ---- END SSH2 PUBLIC KEY ----
 """
 
+RSA_PUBLIC_KEY_RFC4716_2 = b"""\
+---- BEGIN SSH2 PUBLIC KEY ----
+cOmmENt: moto@github.com
+AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
+Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
+mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
+VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
+FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
+wJlpGt2R+0qN7nKnPl2+hx
+---- END SSH2 PUBLIC KEY ----
+"""
+
+RSA_PUBLIC_KEY_RFC4716_3 = b"""\
+---- BEGIN SSH2 PUBLIC KEY ----
+Comment: "1024-bit RSA, converted from OpenSSH by me@example.com"
+x-command: /home/me/bin/lock-in-guest.sh
+AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
+Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
+mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
+VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
+FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
+wJlpGt2R+0qN7nKnPl2+hx
+---- END SSH2 PUBLIC KEY ----
+"""
+
+RSA_PUBLIC_KEY_RFC4716_4 = b"""\
+---- BEGIN SSH2 PUBLIC KEY ----
+Comment: This is my public key for use on \
+servers which I don't like.
+AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
+Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
+mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
+VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
+FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
+wJlpGt2R+0qN7nKnPl2+hx
+---- END SSH2 PUBLIC KEY ----
+"""
+
 RSA_PUBLIC_KEY_FINGERPRINT = "6a:49:07:1c:7e:bd:d2:bd:96:25:fe:b5:74:83:ae:fd"
 
 DSA_PUBLIC_KEY_OPENSSH = b"""ssh-dss \
@@ -157,10 +195,20 @@ def test_key_pairs_delete_exist_boto3():
     "public_key,fingerprint",
     [
         (RSA_PUBLIC_KEY_OPENSSH, RSA_PUBLIC_KEY_FINGERPRINT),
-        (RSA_PUBLIC_KEY_RFC4716, RSA_PUBLIC_KEY_FINGERPRINT),
+        (RSA_PUBLIC_KEY_RFC4716_1, RSA_PUBLIC_KEY_FINGERPRINT),
+        (RSA_PUBLIC_KEY_RFC4716_2, RSA_PUBLIC_KEY_FINGERPRINT),
+        (RSA_PUBLIC_KEY_RFC4716_3, RSA_PUBLIC_KEY_FINGERPRINT),
+        (RSA_PUBLIC_KEY_RFC4716_4, RSA_PUBLIC_KEY_FINGERPRINT),
         (ED25519_PUBLIC_KEY_OPENSSH, ED25519_PUBLIC_KEY_FINGERPRINT),
     ],
-    ids=["rsa-openssh", "rsa-rfc4716", "ed25519"],
+    ids=[
+        "rsa-openssh",
+        "rsa-rfc4716-1",
+        "rsa-rfc4716-2",
+        "rsa-rfc4716-3",
+        "rsa-rfc4716-4",
+        "ed25519",
+    ],
 )
 def test_key_pairs_import_boto3(public_key, fingerprint):
     client = boto3.client("ec2", "us-west-1")
@@ -188,6 +236,18 @@ def test_key_pairs_import_boto3(public_key, fingerprint):
     assert kp1["KeyName"] in all_names
 
 
+@mock_aws
+def test_key_pairs_import_invalid_key():
+    client = boto3.client("ec2", "us-west-1")
+
+    with pytest.raises(ClientError) as exc:
+        client.import_key_pair(
+            KeyName="sth", PublicKeyMaterial="---- BEGIN SSH2 PUBLIC KEY ----\nsth"
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidKeyPair.Format"
+
+
 @mock_aws
 def test_key_pairs_import_exist_boto3():
     client = boto3.client("ec2", "us-west-1")