Skip to content

Commit a1c3e2f

Browse files
Add support for ES* algorithms (#22)
1 parent 411b15d commit a1c3e2f

15 files changed

Lines changed: 598 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.0] - 2026-03-10
9+
10+
- Add support for ES* algorithms for keys.
11+
812
## [1.0.4] - 2025-10-18 :musical_keyboard:
913

1014
- Add a `guardpost.protection` namespace with classes offering a strategy for

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ GuardPost is used in BlackSheep and has been tested with:
8787
- Okta
8888

8989
## If you have doubts about authentication vs authorization...
90+
9091
`Authentication` answers the question: _Who is the user who is initiating the
9192
action?_, or more in general: _Who is the user, or what is the service, that is
9293
initiating the action?_.

examples/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,21 @@ authorization.
4242

4343
This example illustrates a basic use of an authorization strategy, with support for
4444
dependency injection for authorization requirements.
45+
46+
47+
## example-08.py
48+
49+
This example illustrates how to validate JWTs signed with RSA keys (RS256),
50+
using an in-memory JWKS built from a generated RSA key pair.
51+
52+
53+
## example-09.py
54+
55+
This example illustrates how to validate JWTs signed with EC keys (ES256, ES384,
56+
ES512), using an in-memory JWKS built from generated EC key pairs.
57+
58+
59+
## example-10.py
60+
61+
This example illustrates how to validate JWTs signed with a symmetric secret key
62+
(HMAC), using the SymmetricJWTValidator with HS256, HS384, and HS512 algorithms.

examples/example-08.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
This example illustrates how to validate JWTs signed with RSA keys (RS256),
3+
using an in-memory JWKS built from a generated RSA key pair.
4+
"""
5+
import asyncio
6+
import base64
7+
8+
import jwt
9+
from cryptography.hazmat.primitives import serialization
10+
from cryptography.hazmat.primitives.asymmetric import rsa
11+
12+
from guardpost.jwks import JWKS, InMemoryKeysProvider
13+
from guardpost.jwts import AsymmetricJWTValidator
14+
15+
16+
def _int_to_base64url(value: int) -> str:
17+
length = (value.bit_length() + 7) // 8
18+
return base64.urlsafe_b64encode(value.to_bytes(length, "big")).rstrip(b"=").decode()
19+
20+
21+
def generate_rsa_key_pair():
22+
"""Generate an RSA private key and return (private_pem, jwk_dict)."""
23+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
24+
private_pem = private_key.private_bytes(
25+
encoding=serialization.Encoding.PEM,
26+
format=serialization.PrivateFormat.PKCS8,
27+
encryption_algorithm=serialization.NoEncryption(),
28+
)
29+
pub_numbers = private_key.public_key().public_numbers()
30+
jwk_dict = {
31+
"kty": "RSA",
32+
"kid": "my-rsa-key",
33+
"n": _int_to_base64url(pub_numbers.n),
34+
"e": _int_to_base64url(pub_numbers.e),
35+
}
36+
return private_pem, jwk_dict
37+
38+
39+
async def main():
40+
# 1. Generate an RSA key pair and build an in-memory JWKS
41+
private_pem, jwk_dict = generate_rsa_key_pair()
42+
jwks = JWKS.from_dict({"keys": [jwk_dict]})
43+
keys_provider = InMemoryKeysProvider(jwks)
44+
45+
# 2. Configure the validator for RS256
46+
validator = AsymmetricJWTValidator(
47+
valid_issuers=["https://example.com"],
48+
valid_audiences=["my-api"],
49+
keys_provider=keys_provider,
50+
algorithms=["RS256"],
51+
)
52+
53+
# 3. Sign a JWT with the RSA private key
54+
payload = {"iss": "https://example.com", "aud": "my-api", "sub": "user-123"}
55+
token = jwt.encode(
56+
payload,
57+
private_pem,
58+
algorithm="RS256",
59+
headers={"kid": "my-rsa-key"},
60+
)
61+
62+
# 4. Validate the token — returns the decoded claims on success
63+
claims = await validator.validate_jwt(token)
64+
65+
assert claims["sub"] == "user-123"
66+
print("RSA JWT validated successfully. Claims:", claims)
67+
68+
69+
asyncio.run(main())

examples/example-09.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
This example illustrates how to validate JWTs signed with EC keys (ES256, ES384,
3+
ES512), using an in-memory JWKS built from generated EC key pairs.
4+
"""
5+
import asyncio
6+
import base64
7+
8+
import jwt
9+
from cryptography.hazmat.primitives import serialization
10+
from cryptography.hazmat.primitives.asymmetric import ec
11+
12+
from guardpost.jwks import JWKS, InMemoryKeysProvider
13+
from guardpost.jwts import AsymmetricJWTValidator
14+
15+
16+
def _int_to_base64url(value: int, length: int) -> str:
17+
return base64.urlsafe_b64encode(value.to_bytes(length, "big")).rstrip(b"=").decode()
18+
19+
20+
def generate_ec_key_pair(curve, kid: str, alg: str, crv: str):
21+
"""Generate an EC private key and return (private_pem, jwk_dict)."""
22+
private_key = ec.generate_private_key(curve())
23+
private_pem = private_key.private_bytes(
24+
encoding=serialization.Encoding.PEM,
25+
format=serialization.PrivateFormat.PKCS8,
26+
encryption_algorithm=serialization.NoEncryption(),
27+
)
28+
pub_numbers = private_key.public_key().public_numbers()
29+
key_size = (curve().key_size + 7) // 8
30+
jwk_dict = {
31+
"kty": "EC",
32+
"kid": kid,
33+
"alg": alg,
34+
"crv": crv,
35+
"x": _int_to_base64url(pub_numbers.x, key_size),
36+
"y": _int_to_base64url(pub_numbers.y, key_size),
37+
}
38+
return private_pem, jwk_dict
39+
40+
41+
async def main():
42+
# 1. Generate EC key pairs for P-256 (ES256), P-384 (ES384), and P-521 (ES512)
43+
key_configs = [
44+
(ec.SECP256R1, "key-p256", "ES256", "P-256"),
45+
(ec.SECP384R1, "key-p384", "ES384", "P-384"),
46+
(ec.SECP521R1, "key-p521", "ES512", "P-521"),
47+
]
48+
private_keys = {}
49+
jwk_list = []
50+
for curve, kid, alg, crv in key_configs:
51+
private_pem, jwk_dict = generate_ec_key_pair(curve, kid, alg, crv)
52+
private_keys[kid] = (private_pem, alg)
53+
jwk_list.append(jwk_dict)
54+
55+
# 2. Build an in-memory JWKS and configure the validator for all EC algorithms
56+
jwks = JWKS.from_dict({"keys": jwk_list})
57+
keys_provider = InMemoryKeysProvider(jwks)
58+
validator = AsymmetricJWTValidator(
59+
valid_issuers=["https://example.com"],
60+
valid_audiences=["my-api"],
61+
keys_provider=keys_provider,
62+
algorithms=["ES256", "ES384", "ES512"],
63+
)
64+
65+
# 3. Sign and validate a JWT for each key
66+
for kid, (private_pem, alg) in private_keys.items():
67+
payload = {
68+
"iss": "https://example.com",
69+
"aud": "my-api",
70+
"sub": f"user-signed-with-{kid}",
71+
}
72+
token = jwt.encode(
73+
payload,
74+
private_pem,
75+
algorithm=alg,
76+
headers={"kid": kid},
77+
)
78+
79+
claims = await validator.validate_jwt(token)
80+
81+
assert claims["sub"] == f"user-signed-with-{kid}"
82+
print(f"EC JWT ({alg}) validated successfully. Claims:", claims)
83+
84+
85+
asyncio.run(main())

examples/example-10.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
This example illustrates how to validate JWTs signed with a symmetric secret key
3+
(HMAC), using the SymmetricJWTValidator with HS256, HS384, and HS512 algorithms.
4+
"""
5+
import asyncio
6+
7+
import jwt
8+
9+
from guardpost.jwts import SymmetricJWTValidator
10+
11+
12+
async def main():
13+
# The secret must be at least 64 bytes to satisfy HS512's minimum key length.
14+
secret = "super-secret-key-that-is-long-enough-for-all-hmac-algorithms-hs256-hs384-hs512!"
15+
16+
# 1. Validate a token signed with HS256 (default)
17+
validator_hs256 = SymmetricJWTValidator(
18+
valid_issuers=["https://example.com"],
19+
valid_audiences=["my-api"],
20+
secret_key=secret,
21+
algorithms=["HS256"],
22+
)
23+
24+
payload = {"iss": "https://example.com", "aud": "my-api", "sub": "user-123"}
25+
token_hs256 = jwt.encode(payload, secret, algorithm="HS256")
26+
27+
claims = await validator_hs256.validate_jwt(token_hs256)
28+
assert claims["sub"] == "user-123"
29+
print("HS256 JWT validated successfully. Claims:", claims)
30+
31+
# 2. Validate a token signed with HS384
32+
validator_hs384 = SymmetricJWTValidator(
33+
valid_issuers=["https://example.com"],
34+
valid_audiences=["my-api"],
35+
secret_key=secret,
36+
algorithms=["HS384"],
37+
)
38+
39+
token_hs384 = jwt.encode(payload, secret, algorithm="HS384")
40+
claims = await validator_hs384.validate_jwt(token_hs384)
41+
print("HS384 JWT validated successfully. Claims:", claims)
42+
43+
# 3. Validate a token signed with HS512
44+
validator_hs512 = SymmetricJWTValidator(
45+
valid_issuers=["https://example.com"],
46+
valid_audiences=["my-api"],
47+
secret_key=secret,
48+
algorithms=["HS512"],
49+
)
50+
51+
token_hs512 = jwt.encode(payload, secret, algorithm="HS512")
52+
claims = await validator_hs512.validate_jwt(token_hs512)
53+
print("HS512 JWT validated successfully. Claims:", claims)
54+
55+
56+
asyncio.run(main())

guardpost/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.4"
1+
__version__ = "1.1.0"

guardpost/jwks/__init__.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
from abc import ABC, abstractmethod
33
from dataclasses import dataclass
44
from enum import Enum
5-
from typing import List, Optional
5+
from typing import Dict, List, Optional, Type
66

77
from cryptography.hazmat.backends import default_backend
88
from cryptography.hazmat.primitives import serialization
9+
from cryptography.hazmat.primitives.asymmetric.ec import (
10+
SECP256R1,
11+
SECP384R1,
12+
SECP521R1,
13+
EllipticCurve,
14+
EllipticCurvePublicNumbers,
15+
)
916
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
1017

1118
from guardpost.errors import UnsupportedFeatureError
@@ -17,6 +24,13 @@ def _raise_if_missing(value: dict, *keys: str) -> None:
1724
raise ValueError(f"Missing {key}")
1825

1926

27+
_EC_CURVES: Dict[str, Type[EllipticCurve]] = {
28+
"P-256": SECP256R1,
29+
"P-384": SECP384R1,
30+
"P-521": SECP521R1,
31+
}
32+
33+
2034
class KeyType(Enum):
2135
EC = "EC"
2236
RSA = "RSA"
@@ -40,29 +54,51 @@ class JWK:
4054
A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data
4155
structure that represents a cryptographic key.
4256
57+
Supports RSA keys (kty="RSA") and EC keys (kty="EC") with curves
58+
P-256, P-384, and P-521.
59+
4360
For more information: https://datatracker.ietf.org/doc/html/rfc7517
4461
"""
4562

4663
kty: KeyType
47-
n: str
48-
e: str
4964
pem: bytes
5065
kid: Optional[str] = None
66+
# RSA parameters
67+
n: Optional[str] = None
68+
e: Optional[str] = None
69+
# EC parameters
70+
crv: Optional[str] = None
71+
x: Optional[str] = None
72+
y: Optional[str] = None
5173

5274
@classmethod
5375
def from_dict(cls, value) -> "JWK":
5476
key_type = KeyType.from_str(value.get("kty"))
5577

56-
if key_type != KeyType.RSA:
57-
raise UnsupportedFeatureError("This library supports only RSA public keys.")
58-
59-
_raise_if_missing(value, "n", "e")
60-
return cls(
61-
kty=key_type,
62-
n=value["n"],
63-
e=value["e"],
64-
kid=value.get("kid"),
65-
pem=rsa_pem_from_n_and_e(value["n"], value["e"]),
78+
if key_type == KeyType.RSA:
79+
_raise_if_missing(value, "n", "e")
80+
return cls(
81+
kty=key_type,
82+
n=value["n"],
83+
e=value["e"],
84+
kid=value.get("kid"),
85+
pem=rsa_pem_from_n_and_e(value["n"], value["e"]),
86+
)
87+
88+
if key_type == KeyType.EC:
89+
_raise_if_missing(value, "crv", "x", "y")
90+
return cls(
91+
kty=key_type,
92+
crv=value["crv"],
93+
x=value["x"],
94+
y=value["y"],
95+
kid=value.get("kid"),
96+
pem=ec_pem_from_x_y_crv(value["x"], value["y"], value["crv"]),
97+
)
98+
99+
raise UnsupportedFeatureError(
100+
f"Unsupported key type: {key_type.value}. "
101+
"This library supports RSA and EC public keys."
66102
)
67103

68104

@@ -131,3 +167,22 @@ def rsa_pem_from_n_and_e(n: str, e: str) -> bytes:
131167
format=serialization.PublicFormat.SubjectPublicKeyInfo,
132168
)
133169
)
170+
171+
172+
def ec_pem_from_x_y_crv(x: str, y: str, crv: str) -> bytes:
173+
curve_cls = _EC_CURVES.get(crv)
174+
if curve_cls is None:
175+
raise ValueError(
176+
f"Unsupported EC curve: {crv!r}. "
177+
f"Supported curves: {', '.join(_EC_CURVES)}."
178+
)
179+
x_int = _decode_value(x)
180+
y_int = _decode_value(y)
181+
return (
182+
EllipticCurvePublicNumbers(x=x_int, y=y_int, curve=curve_cls())
183+
.public_key(default_backend())
184+
.public_bytes(
185+
encoding=serialization.Encoding.PEM,
186+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
187+
)
188+
)

0 commit comments

Comments
 (0)