Skip to content

Commit a54cb29

Browse files
authored
Techdebt: Remove ECDSA dependency (#7356)
1 parent 3f65f94 commit a54cb29

6 files changed

Lines changed: 145 additions & 53 deletions

File tree

docs/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ readthedocs-sphinx-search
66
docker
77
openapi_spec_validator
88
PyYAML>=5.1
9-
python-jose[cryptography]>=3.1.0,<4.0.0
9+
joserfc>=0.9.0

moto/cognitoidp/models.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections import OrderedDict
99
from typing import Any, Dict, List, Optional, Set, Tuple
1010

11-
from jose import jws
11+
from joserfc import jwk, jwt
1212

1313
from moto.core.base_backend import BackendDict, BaseBackend
1414
from moto.core.common_models import BaseModel
@@ -444,7 +444,7 @@ def __init__(
444444
with open(
445445
os.path.join(os.path.dirname(__file__), "resources/jwks-private.json")
446446
) as f:
447-
self.json_web_key = json.loads(f.read())
447+
self.json_web_key = jwk.RSAKey.import_key(json.loads(f.read()))
448448

449449
@property
450450
def backend(self) -> "CognitoIdpBackend":
@@ -543,10 +543,10 @@ def create_jwt(
543543
"username" if token_use == "access" else "cognito:username": username,
544544
}
545545
payload.update(extra_data or {})
546-
headers = {"kid": "dummy"} # KID as present in jwks-public.json
546+
headers = {"kid": "dummy", "alg": "RS256"} # KID as present in jwks-public.json
547547

548548
return (
549-
jws.sign(payload, self.json_web_key, headers, algorithm="RS256"),
549+
jwt.encode(headers, payload, self.json_web_key),
550550
expires_in,
551551
)
552552

moto/ec2/utils.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -669,31 +669,66 @@ def generate_instance_identity_document(instance: Any) -> Dict[str, Any]:
669669
return document
670670

671671

672+
def _convert_rfc4716(data: bytes) -> bytes:
673+
"""Convert an RFC 4716 public key to OpenSSH authorized_keys format"""
674+
675+
# Normalize line endings and join continuation lines
676+
data_normalized = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
677+
data_joined = data_normalized.replace(b"\\\n", b"")
678+
lines = data_joined.splitlines()
679+
680+
# Trim header and footer
681+
if lines[0] != b"---- BEGIN SSH2 PUBLIC KEY ----":
682+
raise ValueError("Invalid RFC4716 header line")
683+
if lines[-1] != b"---- END SSH2 PUBLIC KEY ----":
684+
raise ValueError("Invalid RFC4716 footer line")
685+
lines = lines[1:-1]
686+
687+
# Leading lines containing a colon are headers
688+
headers = {}
689+
num_header_lines = 0
690+
for line in lines:
691+
if b":" not in line:
692+
break
693+
num_header_lines += 1
694+
header_name, header_value = line.split(b": ")
695+
headers[header_name.lower()] = header_value
696+
697+
# Remaining lines are key data
698+
data_lines = lines[num_header_lines:]
699+
b64_key = b"".join(data_lines)
700+
701+
# Extract the algo name from the binary packet
702+
packet = base64.b64decode(b64_key)
703+
alg_len = int.from_bytes(packet[:4], "big")
704+
alg = packet[4 : 4 + alg_len]
705+
706+
result_parts = [alg, b64_key]
707+
if b"comment" in headers:
708+
result_parts.append(headers[b"comment"])
709+
return b" ".join(result_parts)
710+
711+
672712
def public_key_parse(
673713
key_material: Union[str, bytes]
674714
) -> Union[RSAPublicKey, Ed25519PublicKey]:
675-
# These imports take ~.5s; let's keep them local
676-
import sshpubkeys.exceptions
677-
from sshpubkeys.keys import SSHKey
678-
679715
try:
680-
if not isinstance(key_material, bytes):
716+
if isinstance(key_material, str):
681717
key_material = key_material.encode("ascii")
718+
key_material = base64.b64decode(key_material)
682719

683-
decoded_key = base64.b64decode(key_material)
684-
public_key = SSHKey(decoded_key.decode("ascii"))
685-
except (sshpubkeys.exceptions.InvalidKeyException, UnicodeDecodeError):
686-
raise ValueError("bad key")
720+
if key_material.startswith(b"---- BEGIN SSH2 PUBLIC KEY ----"):
721+
# cryptography doesn't parse RFC4716 key format, so we have to convert it first
722+
key_material = _convert_rfc4716(key_material)
687723

688-
if public_key.rsa:
689-
return public_key.rsa
724+
public_key = serialization.load_ssh_public_key(key_material)
690725

691-
# `cryptography` currently does not support RSA RFC4716/SSH2 format, otherwise we could get rid of `sshpubkeys` and
692-
# simply use `load_ssh_public_key()`
693-
if public_key.key_type == b"ssh-ed25519":
694-
return serialization.load_ssh_public_key(decoded_key) # type: ignore[return-value]
726+
if not isinstance(public_key, (RSAPublicKey, Ed25519PublicKey)):
727+
raise ValueError("bad key")
728+
except UnicodeDecodeError:
729+
raise ValueError("bad key")
695730

696-
raise ValueError("bad key")
731+
return public_key
697732

698733

699734
def public_key_fingerprint(public_key: Union[RSAPublicKey, Ed25519PublicKey]) -> str:

setup.cfg

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,11 @@ moto = py.typed
4444

4545
[options.extras_require]
4646
all =
47-
python-jose[cryptography]>=3.1.0,<4.0.0
48-
ecdsa!=0.15
47+
joserfc>=0.9.0
4948
docker>=3.0.0
5049
graphql-core
5150
PyYAML>=5.1
5251
cfn-lint>=0.40.0
53-
sshpubkeys>=3.1.0
5452
openapi-spec-validator>=0.5.0
5553
pyparsing>=3.0.7
5654
jsondiff>=1.1.2
@@ -59,13 +57,11 @@ all =
5957
setuptools
6058
multipart
6159
proxy =
62-
python-jose[cryptography]>=3.1.0,<4.0.0
63-
ecdsa!=0.15
60+
joserfc>=0.9.0
6461
docker>=2.5.1
6562
graphql-core
6663
PyYAML>=5.1
6764
cfn-lint>=0.40.0
68-
sshpubkeys>=3.1.0
6965
openapi-spec-validator>=0.5.0
7066
pyparsing>=3.0.7
7167
jsondiff>=1.1.2
@@ -74,13 +70,11 @@ proxy =
7470
setuptools
7571
multipart
7672
server =
77-
python-jose[cryptography]>=3.1.0,<4.0.0
78-
ecdsa!=0.15
73+
joserfc>=0.9.0
7974
docker>=3.0.0
8075
graphql-core
8176
PyYAML>=5.1
8277
cfn-lint>=0.40.0
83-
sshpubkeys>=3.1.0
8478
openapi-spec-validator>=0.5.0
8579
pyparsing>=3.0.7
8680
jsondiff>=1.1.2
@@ -94,10 +88,9 @@ acmpca =
9488
amp =
9589
apigateway =
9690
PyYAML>=5.1
97-
python-jose[cryptography]>=3.1.0,<4.0.0
98-
ecdsa!=0.15
91+
joserfc>=0.9.0
9992
openapi-spec-validator>=0.5.0
100-
apigatewayv2 =
93+
apigatewayv2 =
10194
PyYAML>=5.1
10295
openapi-spec-validator>=0.5.0
10396
applicationautoscaling =
@@ -112,13 +105,11 @@ batch_simple =
112105
budgets =
113106
ce =
114107
cloudformation =
115-
python-jose[cryptography]>=3.1.0,<4.0.0
116-
ecdsa!=0.15
108+
joserfc>=0.9.0
117109
docker>=3.0.0
118110
graphql-core
119111
PyYAML>=5.1
120112
cfn-lint>=0.40.0
121-
sshpubkeys>=3.1.0
122113
openapi-spec-validator>=0.5.0
123114
pyparsing>=3.0.7
124115
jsondiff>=1.1.2
@@ -133,8 +124,7 @@ codecommit =
133124
codepipeline =
134125
cognitoidentity =
135126
cognitoidp =
136-
python-jose[cryptography]>=3.1.0,<4.0.0
137-
ecdsa!=0.15
127+
joserfc>=0.9.0
138128
comprehend =
139129
config =
140130
databrew =
@@ -150,7 +140,7 @@ dynamodbstreams =
150140
docker>=3.0.0
151141
py-partiql-parser==0.5.1
152142
ebs =
153-
ec2 = sshpubkeys>=3.1.0
143+
ec2 =
154144
ec2instanceconnect =
155145
ecr =
156146
ecs =
@@ -204,8 +194,7 @@ redshiftdata =
204194
rekognition =
205195
resourcegroups =
206196
resourcegroupstaggingapi =
207-
python-jose[cryptography]>=3.1.0,<4.0.0
208-
ecdsa!=0.15
197+
joserfc>=0.9.0
209198
docker>=3.0.0
210199
graphql-core
211200
PyYAML>=5.1

tests/test_cognitoidp/test_cognitoidp.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313
import pytest
1414
import requests
1515
from botocore.exceptions import ClientError, ParamValidationError
16-
from jose import jws, jwt
16+
from joserfc import jwk, jws, jwt
1717

1818
import moto.cognitoidp.models
19-
from moto import mock_aws, settings
19+
from moto import cognitoidp, mock_aws, settings
2020
from moto.cognitoidp.utils import create_id
2121
from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
2222
from moto.core import set_initial_no_auth_action_count
23+
from moto.utilities.utils import load_resource
24+
25+
private_key = load_resource(cognitoidp.__name__, "resources/jwks-private.json")
26+
PUBLIC_KEY = jwk.RSAKey.import_key(private_key)
2327

2428

2529
@mock_aws
@@ -1543,7 +1547,8 @@ def test_group_in_access_token():
15431547
ChallengeResponses={"USERNAME": username, "NEW_PASSWORD": new_password},
15441548
)
15451549

1546-
claims = jwt.get_unverified_claims(result["AuthenticationResult"]["AccessToken"])
1550+
payload = jwt.decode(result["AuthenticationResult"]["AccessToken"], PUBLIC_KEY)
1551+
claims = payload.claims
15471552
assert claims["cognito:groups"] == [group_name]
15481553

15491554

@@ -1604,7 +1609,8 @@ def test_other_attributes_in_id_token():
16041609
ChallengeResponses={"USERNAME": username, "NEW_PASSWORD": new_password},
16051610
)
16061611

1607-
claims = jwt.get_unverified_claims(result["AuthenticationResult"]["IdToken"])
1612+
payload = jwt.decode(result["AuthenticationResult"]["IdToken"], PUBLIC_KEY)
1613+
claims = payload.claims
16081614
assert claims["cognito:groups"] == [group_name]
16091615
assert claims["custom:myattr"] == "some val"
16101616

@@ -2957,7 +2963,7 @@ def test_token_legitimacy():
29572963

29582964
path = "../../moto/cognitoidp/resources/jwks-public.json"
29592965
with open(os.path.join(os.path.dirname(__file__), path)) as f:
2960-
json_web_key = json.loads(f.read())["keys"][0]
2966+
json_web_key = jwk.RSAKey.import_key(json.loads(f.read())["keys"][0])
29612967

29622968
for auth_flow in ["ADMIN_NO_SRP_AUTH", "ADMIN_USER_PASSWORD_AUTH"]:
29632969
outputs = authentication_flow(conn, auth_flow)
@@ -2968,14 +2974,14 @@ def test_token_legitimacy():
29682974
issuer = (
29692975
f"https://cognito-idp.us-west-2.amazonaws.com/{outputs['user_pool_id']}"
29702976
)
2971-
id_claims = json.loads(jws.verify(id_token, json_web_key, "RS256"))
2977+
id_claims = jwt.decode(id_token, json_web_key, ["RS256"]).claims
29722978
assert id_claims["iss"] == issuer
29732979
assert id_claims["aud"] == client_id
29742980
assert id_claims["token_use"] == "id"
29752981
assert id_claims["cognito:username"] == username
29762982
for k, v in outputs["additional_fields"].items():
29772983
assert id_claims[k] == v
2978-
access_claims = json.loads(jws.verify(access_token, json_web_key, "RS256"))
2984+
access_claims = jwt.decode(access_token, json_web_key, ["RS256"]).claims
29792985
assert access_claims["iss"] == issuer
29802986
assert access_claims["client_id"] == client_id
29812987
assert access_claims["token_use"] == "access"
@@ -4938,8 +4944,10 @@ def test_idtoken_contains_kid_header():
49384944

49394945
def verify_kid_header(token):
49404946
"""Verifies the kid-header is corresponds with the public key"""
4941-
headers = jwt.get_unverified_headers(token)
4942-
kid = headers["kid"]
4947+
if isinstance(token, str):
4948+
token = token.encode("ascii")
4949+
sig = jws.extract_compact(token)
4950+
kid = sig.headers()["kid"]
49434951

49444952
key_index = -1
49454953
keys = fetch_public_keys()

tests/test_ec2/test_key_pairs.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
qusUO07jKuSxzPumXBeU+JEtx0J1tqZwJlpGt2R+0qN7nKnPl2+hx \
2828
moto@github.com"""
2929

30-
RSA_PUBLIC_KEY_RFC4716 = b"""\
30+
RSA_PUBLIC_KEY_RFC4716_1 = b"""\
3131
---- BEGIN SSH2 PUBLIC KEY ----
3232
AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
3333
Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
@@ -38,6 +38,44 @@
3838
---- END SSH2 PUBLIC KEY ----
3939
"""
4040

41+
RSA_PUBLIC_KEY_RFC4716_2 = b"""\
42+
---- BEGIN SSH2 PUBLIC KEY ----
43+
cOmmENt: moto@github.com
44+
AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
45+
Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
46+
mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
47+
VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
48+
FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
49+
wJlpGt2R+0qN7nKnPl2+hx
50+
---- END SSH2 PUBLIC KEY ----
51+
"""
52+
53+
RSA_PUBLIC_KEY_RFC4716_3 = b"""\
54+
---- BEGIN SSH2 PUBLIC KEY ----
55+
Comment: "1024-bit RSA, converted from OpenSSH by me@example.com"
56+
x-command: /home/me/bin/lock-in-guest.sh
57+
AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
58+
Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
59+
mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
60+
VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
61+
FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
62+
wJlpGt2R+0qN7nKnPl2+hx
63+
---- END SSH2 PUBLIC KEY ----
64+
"""
65+
66+
RSA_PUBLIC_KEY_RFC4716_4 = b"""\
67+
---- BEGIN SSH2 PUBLIC KEY ----
68+
Comment: This is my public key for use on \
69+
servers which I don't like.
70+
AAAAB3NzaC1yc2EAAAADAQABAAABAQDusXfgTE4eBP50NglSzCSEGnIL6+cr6m3H6cZANO
71+
Q+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37LQx4YIAcBi4Zd023
72+
mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzbZlPN45ZCTk9ck0fS
73+
VHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r91aM5q6QOQm219lct
74+
FM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPumXBeU+JEtx0J1tqZ
75+
wJlpGt2R+0qN7nKnPl2+hx
76+
---- END SSH2 PUBLIC KEY ----
77+
"""
78+
4179
RSA_PUBLIC_KEY_FINGERPRINT = "6a:49:07:1c:7e:bd:d2:bd:96:25:fe:b5:74:83:ae:fd"
4280

4381
DSA_PUBLIC_KEY_OPENSSH = b"""ssh-dss \
@@ -157,10 +195,20 @@ def test_key_pairs_delete_exist_boto3():
157195
"public_key,fingerprint",
158196
[
159197
(RSA_PUBLIC_KEY_OPENSSH, RSA_PUBLIC_KEY_FINGERPRINT),
160-
(RSA_PUBLIC_KEY_RFC4716, RSA_PUBLIC_KEY_FINGERPRINT),
198+
(RSA_PUBLIC_KEY_RFC4716_1, RSA_PUBLIC_KEY_FINGERPRINT),
199+
(RSA_PUBLIC_KEY_RFC4716_2, RSA_PUBLIC_KEY_FINGERPRINT),
200+
(RSA_PUBLIC_KEY_RFC4716_3, RSA_PUBLIC_KEY_FINGERPRINT),
201+
(RSA_PUBLIC_KEY_RFC4716_4, RSA_PUBLIC_KEY_FINGERPRINT),
161202
(ED25519_PUBLIC_KEY_OPENSSH, ED25519_PUBLIC_KEY_FINGERPRINT),
162203
],
163-
ids=["rsa-openssh", "rsa-rfc4716", "ed25519"],
204+
ids=[
205+
"rsa-openssh",
206+
"rsa-rfc4716-1",
207+
"rsa-rfc4716-2",
208+
"rsa-rfc4716-3",
209+
"rsa-rfc4716-4",
210+
"ed25519",
211+
],
164212
)
165213
def test_key_pairs_import_boto3(public_key, fingerprint):
166214
client = boto3.client("ec2", "us-west-1")
@@ -188,6 +236,18 @@ def test_key_pairs_import_boto3(public_key, fingerprint):
188236
assert kp1["KeyName"] in all_names
189237

190238

239+
@mock_aws
240+
def test_key_pairs_import_invalid_key():
241+
client = boto3.client("ec2", "us-west-1")
242+
243+
with pytest.raises(ClientError) as exc:
244+
client.import_key_pair(
245+
KeyName="sth", PublicKeyMaterial="---- BEGIN SSH2 PUBLIC KEY ----\nsth"
246+
)
247+
err = exc.value.response["Error"]
248+
assert err["Code"] == "InvalidKeyPair.Format"
249+
250+
191251
@mock_aws
192252
def test_key_pairs_import_exist_boto3():
193253
client = boto3.client("ec2", "us-west-1")

0 commit comments

Comments
 (0)