Skip to content

Commit 9c52867

Browse files
authored
Merge pull request from GHSA-ffqj-6fqr-9h24
Co-authored-by: José Padilla <jpadilla@users.noreply.github.com>
1 parent 24b29ad commit 9c52867

File tree

6 files changed

+191
-23
lines changed

6 files changed

+191
-23
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ target/
6060

6161
.pytest_cache
6262
.mypy_cache
63+
pip-wheel-metadata/

jwt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from .jwks_client import PyJWKClient
2727

28-
__version__ = "2.3.0"
28+
__version__ = "2.4.0"
2929

3030
__title__ = "PyJWT"
3131
__description__ = "JSON Web Token implementation in Python"

jwt/algorithms.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
der_to_raw_signature,
1010
force_bytes,
1111
from_base64url_uint,
12+
is_pem_format,
13+
is_ssh_key,
1214
raw_to_der_signature,
1315
to_base64url_uint,
1416
)
@@ -183,14 +185,7 @@ def __init__(self, hash_alg):
183185
def prepare_key(self, key):
184186
key = force_bytes(key)
185187

186-
invalid_strings = [
187-
b"-----BEGIN PUBLIC KEY-----",
188-
b"-----BEGIN CERTIFICATE-----",
189-
b"-----BEGIN RSA PUBLIC KEY-----",
190-
b"ssh-rsa",
191-
]
192-
193-
if any(string_value in key for string_value in invalid_strings):
188+
if is_pem_format(key) or is_ssh_key(key):
194189
raise InvalidKeyError(
195190
"The specified key is an asymmetric key or x509 certificate and"
196191
" should not be used as an HMAC secret."
@@ -551,26 +546,28 @@ def __init__(self, **kwargs):
551546
pass
552547

553548
def prepare_key(self, key):
554-
555-
if isinstance(
556-
key,
557-
(Ed25519PrivateKey, Ed25519PublicKey, Ed448PrivateKey, Ed448PublicKey),
558-
):
559-
return key
560-
561549
if isinstance(key, (bytes, str)):
562550
if isinstance(key, str):
563551
key = key.encode("utf-8")
564552
str_key = key.decode("utf-8")
565553

566554
if "-----BEGIN PUBLIC" in str_key:
567-
return load_pem_public_key(key)
568-
if "-----BEGIN PRIVATE" in str_key:
569-
return load_pem_private_key(key, password=None)
570-
if str_key[0:4] == "ssh-":
571-
return load_ssh_public_key(key)
555+
key = load_pem_public_key(key)
556+
elif "-----BEGIN PRIVATE" in str_key:
557+
key = load_pem_private_key(key, password=None)
558+
elif str_key[0:4] == "ssh-":
559+
key = load_ssh_public_key(key)
572560

573-
raise TypeError("Expecting a PEM-formatted or OpenSSH key.")
561+
# Explicit check the key to prevent confusing errors from cryptography
562+
if not isinstance(
563+
key,
564+
(Ed25519PrivateKey, Ed25519PublicKey, Ed448PrivateKey, Ed448PublicKey),
565+
):
566+
raise InvalidKeyError(
567+
"Expecting a EllipticCurvePrivateKey/EllipticCurvePublicKey. Wrong key provided for EdDSA algorithms"
568+
)
569+
570+
return key
574571

575572
def sign(self, msg, key):
576573
"""

jwt/utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import binascii
3+
import re
34
from typing import Any, Union
45

56
try:
@@ -97,3 +98,63 @@ def raw_to_der_signature(raw_sig: bytes, curve: EllipticCurve) -> bytes:
9798
s = bytes_to_number(raw_sig[num_bytes:])
9899

99100
return encode_dss_signature(r, s)
101+
102+
103+
# Based on https://github.com/hynek/pem/blob/7ad94db26b0bc21d10953f5dbad3acfdfacf57aa/src/pem/_core.py#L224-L252
104+
_PEMS = {
105+
b"CERTIFICATE",
106+
b"TRUSTED CERTIFICATE",
107+
b"PRIVATE KEY",
108+
b"PUBLIC KEY",
109+
b"ENCRYPTED PRIVATE KEY",
110+
b"OPENSSH PRIVATE KEY",
111+
b"DSA PRIVATE KEY",
112+
b"RSA PRIVATE KEY",
113+
b"RSA PUBLIC KEY",
114+
b"EC PRIVATE KEY",
115+
b"DH PARAMETERS",
116+
b"NEW CERTIFICATE REQUEST",
117+
b"CERTIFICATE REQUEST",
118+
b"SSH2 PUBLIC KEY",
119+
b"SSH2 ENCRYPTED PRIVATE KEY",
120+
b"X509 CRL",
121+
}
122+
123+
_PEM_RE = re.compile(
124+
b"----[- ]BEGIN ("
125+
+ b"|".join(_PEMS)
126+
+ b""")[- ]----\r?
127+
.+?\r?
128+
----[- ]END \\1[- ]----\r?\n?""",
129+
re.DOTALL,
130+
)
131+
132+
133+
def is_pem_format(key: bytes) -> bool:
134+
return bool(_PEM_RE.search(key))
135+
136+
137+
# Based on https://github.com/pyca/cryptography/blob/bcb70852d577b3f490f015378c75cba74986297b/src/cryptography/hazmat/primitives/serialization/ssh.py#L40-L46
138+
_CERT_SUFFIX = b"-cert-v01@openssh.com"
139+
_SSH_PUBKEY_RC = re.compile(br"\A(\S+)[ \t]+(\S+)")
140+
_SSH_KEY_FORMATS = [
141+
b"ssh-ed25519",
142+
b"ssh-rsa",
143+
b"ssh-dss",
144+
b"ecdsa-sha2-nistp256",
145+
b"ecdsa-sha2-nistp384",
146+
b"ecdsa-sha2-nistp521",
147+
]
148+
149+
150+
def is_ssh_key(key: bytes) -> bool:
151+
if any(string_value in key for string_value in _SSH_KEY_FORMATS):
152+
return True
153+
154+
ssh_pubkey_match = _SSH_PUBKEY_RC.match(key)
155+
if ssh_pubkey_match:
156+
key_type = ssh_pubkey_match.group(1)
157+
if _CERT_SUFFIX == key_type[-len(_CERT_SUFFIX) :]:
158+
return True
159+
160+
return False

tests/test_advisory.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import jwt
2+
import pytest
3+
from jwt.exceptions import InvalidKeyError
4+
5+
priv_key_bytes = b'''-----BEGIN PRIVATE KEY-----
6+
MC4CAQAwBQYDK2VwBCIEIIbBhdo2ah7X32i50GOzrCr4acZTe6BezUdRIixjTAdL
7+
-----END PRIVATE KEY-----'''
8+
9+
pub_key_bytes = b'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPL1I9oiq+B8crkmuV4YViiUnhdLjCp3hvy1bNGuGfNL'
10+
11+
ssh_priv_key_bytes = b"""-----BEGIN EC PRIVATE KEY-----
12+
MHcCAQEEIOWc7RbaNswMtNtc+n6WZDlUblMr2FBPo79fcGXsJlGQoAoGCCqGSM49
13+
AwEHoUQDQgAElcy2RSSSgn2RA/xCGko79N+7FwoLZr3Z0ij/ENjow2XpUDwwKEKk
14+
Ak3TDXC9U8nipMlGcY7sDpXp2XyhHEM+Rw==
15+
-----END EC PRIVATE KEY-----"""
16+
17+
ssh_key_bytes = b"""ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJXMtkUkkoJ9kQP8QhpKO/TfuxcKC2a92dIo/xDY6MNl6VA8MChCpAJN0w1wvVPJ4qTJRnGO7A6V6dl8oRxDPkc="""
18+
19+
20+
class TestAdvisory:
21+
def test_ghsa_ffqj_6fqr_9h24(self):
22+
# Generate ed25519 private key
23+
# private_key = ed25519.Ed25519PrivateKey.generate()
24+
25+
# Get private key bytes as they would be stored in a file
26+
# priv_key_bytes = private_key.private_bytes(
27+
# encoding=serialization.Encoding.PEM,
28+
# format=serialization.PrivateFormat.PKCS8,
29+
# encryption_algorithm=serialization.NoEncryption(),
30+
# )
31+
32+
# Get public key bytes as they would be stored in a file
33+
# pub_key_bytes = private_key.public_key().public_bytes(
34+
# encoding=serialization.Encoding.OpenSSH,
35+
# format=serialization.PublicFormat.OpenSSH,
36+
# )
37+
38+
# Making a good jwt token that should work by signing it
39+
# with the private key
40+
# encoded_good = jwt.encode({"test": 1234}, priv_key_bytes, algorithm="EdDSA")
41+
encoded_good = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJ0ZXN0IjoxMjM0fQ.M5y1EEavZkHSlj9i8yi9nXKKyPBSAUhDRTOYZi3zZY11tZItDaR3qwAye8pc74_lZY3Ogt9KPNFbVOSGnUBHDg'
42+
43+
# Using HMAC with the public key to trick the receiver to think that the
44+
# public key is a HMAC secret
45+
encoded_bad = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0ZXN0IjoxMjM0fQ.6ulDpqSlbHmQ8bZXhZRLFko9SwcHrghCwh8d-exJEE4'
46+
47+
# Both of the jwt tokens are validated as valid
48+
jwt.decode(
49+
encoded_good,
50+
pub_key_bytes,
51+
algorithms=jwt.algorithms.get_default_algorithms(),
52+
)
53+
54+
with pytest.raises(InvalidKeyError):
55+
jwt.decode(
56+
encoded_bad,
57+
pub_key_bytes,
58+
algorithms=jwt.algorithms.get_default_algorithms(),
59+
)
60+
61+
# Of course the receiver should specify ed25519 algorithm to be used if
62+
# they specify ed25519 public key. However, if other algorithms are used,
63+
# the POC does not work
64+
# HMAC specifies illegal strings for the HMAC secret in jwt/algorithms.py
65+
#
66+
# invalid_str ings = [
67+
# b"-----BEGIN PUBLIC KEY-----",
68+
# b"-----BEGIN CERTIFICATE-----",
69+
# b"-----BEGIN RSA PUBLIC KEY-----",
70+
# b"ssh-rsa",
71+
# ]
72+
#
73+
# However, OKPAlgorithm (ed25519) accepts the following in jwt/algorithms.py:
74+
#
75+
# if "-----BEGIN PUBLIC" in str_key:
76+
# return load_pem_public_key(key)
77+
# if "-----BEGIN PRIVATE" in str_key:
78+
# return load_pem_private_key(key, password=None)
79+
# if str_key[0:4] == "ssh-":
80+
# return load_ssh_public_key(key)
81+
#
82+
# These should most likely made to match each other to prevent this behavior
83+
84+
# POC for the ecdsa-sha2-nistp256 format.
85+
# openssl ecparam -genkey -name prime256v1 -noout -out ec256-key-priv.pem
86+
# openssl ec -in ec256-key-priv.pem -pubout > ec256-key-pub.pem
87+
# ssh-keygen -y -f ec256-key-priv.pem > ec256-key-ssh.pub
88+
89+
# Making a good jwt token that should work by signing it with the private key
90+
# encoded_good = jwt.encode({"test": 1234}, ssh_priv_key_bytes, algorithm="ES256")
91+
encoded_good = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZXN0IjoxMjM0fQ.NX42mS8cNqYoL3FOW9ZcKw8Nfq2mb6GqJVADeMA1-kyHAclilYo_edhdM_5eav9tBRQTlL0XMeu_WFE_mz3OXg"
92+
93+
# Using HMAC with the ssh public key to trick the receiver to think that the public key is a HMAC secret
94+
# encoded_bad = jwt.encode({"test": 1234}, ssh_key_bytes, algorithm="HS256")
95+
encoded_bad = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZXN0IjoxMjM0fQ.5eYfbrbeGYmWfypQ6rMWXNZ8bdHcqKng5GPr9MJZITU"
96+
97+
# Both of the jwt tokens are validated as valid
98+
jwt.decode(
99+
encoded_good,
100+
ssh_key_bytes,
101+
algorithms=jwt.algorithms.get_default_algorithms()
102+
)
103+
104+
with pytest.raises(InvalidKeyError):
105+
jwt.decode(
106+
encoded_bad,
107+
ssh_key_bytes,
108+
algorithms=jwt.algorithms.get_default_algorithms()
109+
)

tests/test_algorithms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ class TestOKPAlgorithms:
679679
def test_okp_ed25519_should_reject_non_string_key(self):
680680
algo = OKPAlgorithm()
681681

682-
with pytest.raises(TypeError):
682+
with pytest.raises(InvalidKeyError):
683683
algo.prepare_key(None)
684684

685685
with open(key_path("testkey_ed25519")) as keyfile:

0 commit comments

Comments
 (0)