Make JWT token signing algorithm configurable

The JWS token provider hardcodes ES256 (ECDSA P-256) for signing and
verifying tokens. This prevents operators from selecting stronger or
more appropriate algorithms without modifying the source code, and
blocks a future migration path to post-quantum algorithms like ML-DSA.

Add two new configuration options under [jwt_tokens]:

- jws_algorithm: selects the signing algorithm (ES256, ES384, ES512,
  or EdDSA). Default is ES256 for backward compatibility.
- jws_accepted_algorithms: list of algorithms accepted during token
  validation, enabling a migration window where tokens signed with a
  previous algorithm remain valid until they expire.

The keystone-manage create_jws_keypair command now generates the key
type that matches the configured algorithm.

Also fix _decode_token_from_id to pass algorithms as a list instead
of a string, and to catch InvalidKeyError and InvalidAlgorithmError
when iterating public keys of mixed types during algorithm migration.

Change-Id: Id97bb3152d4363c607aaac35b6c8f6628fa125d2
Signed-off-by: Mauricio Harley <mharley@redhat.com>
This commit is contained in:
Mauricio Harley committed 2026-04-17 10:37:21 +00:00
1 parent 80d5b7bf50
commit f9a4a551f5
7 files changed
+299 -15

No files matched your search

+5 -1
View File
@@ -715,7 +715,11 @@ class CreateJWSKeyPair(BasePermissionsSetup):
% {'path': public_key_path}
)
jwt_utils.create_jws_keypair(private_key_path, public_key_path)
jwt_utils.create_jws_keypair(
private_key_path,
public_key_path,
algorithm=CONF.jwt_tokens.jws_algorithm,
)
class TokenSetup(BasePermissionsSetup):
+19 -4
View File
@@ -10,19 +10,34 @@
# License for the specific language governing permissions and limitations
# under the License.
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
_ALGORITHM_KEY_GENERATORS = {
'ES256': lambda: ec.generate_private_key(ec.SECP256R1()),
'ES384': lambda: ec.generate_private_key(ec.SECP384R1()),
'ES512': lambda: ec.generate_private_key(ec.SECP521R1()),
'EdDSA': lambda: ed25519.Ed25519PrivateKey.generate(),
}
def create_jws_keypair(private_key_path, public_key_path):
"""Create an ECDSA key pair using an secp256r1, or NIST P-256, curve.
def create_jws_keypair(private_key_path, public_key_path, algorithm='ES256'):
"""Create an asymmetric key pair suitable for the given JWS algorithm.
:param private_key_path: location to save the private key
:param public_key_path: location to save the public key
:param algorithm: JWS algorithm name (ES256, ES384, ES512, EdDSA)
"""
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
generator = _ALGORITHM_KEY_GENERATORS.get(algorithm)
if generator is None:
raise ValueError(
'Unsupported JWS algorithm: {}. Supported: {}'.format(
algorithm, ', '.join(sorted(_ALGORITHM_KEY_GENERATORS))
)
)
private_key = generator()
with open(private_key_path, 'wb') as f:
f.write(
+37 -1
View File
@@ -14,6 +14,37 @@ from oslo_config import cfg
from keystone.conf import utils
SUPPORTED_JWS_ALGORITHMS = ['ES256', 'ES384', 'ES512', 'EdDSA']
jws_algorithm = cfg.StrOpt(
'jws_algorithm',
default='ES256',
choices=SUPPORTED_JWS_ALGORITHMS,
help=utils.fmt(
"""
The algorithm used to sign JWS tokens. Changing this value requires
regenerating the JWS key pair via `keystone-manage create_jws_keypair` since
each algorithm uses a different key type. Supported values: ES256 (ECDSA
P-256, default), ES384 (ECDSA P-384), ES512 (ECDSA P-521), EdDSA (Ed25519).
This option is only applicable when `keystone.conf [token] provider = jws`.
"""
),
)
jws_accepted_algorithms = cfg.ListOpt(
'jws_accepted_algorithms',
default=['ES256'],
help=utils.fmt(
"""
List of algorithms accepted when validating JWS token signatures. During an
algorithm migration, this should include both the old and new algorithm so that
tokens signed with the old algorithm remain valid until they expire. Each value
must be one of: ES256, ES384, ES512, EdDSA. This option is only applicable
when `keystone.conf [token] provider = jws`.
"""
),
)
jws_public_key_repository = cfg.StrOpt(
'jws_public_key_repository',
default='/etc/keystone/jws-keys/public',
@@ -49,7 +80,12 @@ issuing JWS tokens and setting `keystone.conf [token] provider = jws`.
GROUP_NAME = __name__.split('.')[-1]
ALL_OPTS = [jws_public_key_repository, jws_private_key_repository]
ALL_OPTS = [
jws_algorithm,
jws_accepted_algorithms,
jws_public_key_repository,
jws_private_key_repository,
]
def register_opts(conf):
@@ -16,6 +16,9 @@ import fixtures
from keystone.common import jwt_utils
from keystone.common import utils
import keystone.conf
CONF = keystone.conf.CONF
class JWSKeyRepository(fixtures.Fixture):
@@ -45,7 +48,11 @@ class JWSKeyRepository(fixtures.Fixture):
utils.create_directory(private_key_directory)
utils.create_directory(public_key_directory)
# create an asymmetric key pair for token signing and validation
# create an asymmetric key pair for token signing and validation,
# using the configured algorithm
algorithm = CONF.jwt_tokens.jws_algorithm
private_key_path = os.path.join(private_key_directory, 'private.pem')
public_key_path = os.path.join(public_key_directory, 'public.pem')
jwt_utils.create_jws_keypair(private_key_path, public_key_path)
jwt_utils.create_jws_keypair(
private_key_path, public_key_path, algorithm=algorithm
)
@@ -13,6 +13,11 @@
import os
import uuid
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import fixtures
from keystone.common import jwt_utils
from keystone.common import provider_api
from keystone.common import utils
@@ -115,3 +120,169 @@ class TestJWSProvider(unit.TestCase):
# make sure we iterate through all public keys on disk and we can still
# validate the token
self.provider.validate_token(token_id)
class TestCreateJWSKeypair(unit.TestCase):
"""Tests for jwt_utils.create_jws_keypair key generation per algorithm."""
_EXPECTED_KEY_TYPES = {
'ES256': (ec.EllipticCurvePrivateKey, ec.SECP256R1),
'ES384': (ec.EllipticCurvePrivateKey, ec.SECP384R1),
'ES512': (ec.EllipticCurvePrivateKey, ec.SECP521R1),
'EdDSA': (ed25519.Ed25519PrivateKey, None),
}
def _generate_and_load_keys(self, algorithm):
tmp = self.useFixture(fixtures.TempDir()).path
priv_path = os.path.join(tmp, 'private.pem')
pub_path = os.path.join(tmp, 'public.pem')
jwt_utils.create_jws_keypair(priv_path, pub_path, algorithm=algorithm)
with open(priv_path, 'rb') as f:
private_key = serialization.load_pem_private_key(
f.read(), password=None
)
with open(pub_path, 'rb') as f:
public_key = serialization.load_pem_public_key(f.read())
return private_key, public_key
def test_es256_generates_p256_key(self):
priv, pub = self._generate_and_load_keys('ES256')
self.assertIsInstance(priv, ec.EllipticCurvePrivateKey)
self.assertIsInstance(priv.curve, ec.SECP256R1)
def test_es384_generates_p384_key(self):
priv, pub = self._generate_and_load_keys('ES384')
self.assertIsInstance(priv, ec.EllipticCurvePrivateKey)
self.assertIsInstance(priv.curve, ec.SECP384R1)
def test_es512_generates_p521_key(self):
priv, pub = self._generate_and_load_keys('ES512')
self.assertIsInstance(priv, ec.EllipticCurvePrivateKey)
self.assertIsInstance(priv.curve, ec.SECP521R1)
def test_eddsa_generates_ed25519_key(self):
priv, pub = self._generate_and_load_keys('EdDSA')
self.assertIsInstance(priv, ed25519.Ed25519PrivateKey)
self.assertIsInstance(pub, ed25519.Ed25519PublicKey)
def test_unsupported_algorithm_raises_value_error(self):
tmp = self.useFixture(fixtures.TempDir()).path
self.assertRaises(
ValueError,
jwt_utils.create_jws_keypair,
os.path.join(tmp, 'priv.pem'),
os.path.join(tmp, 'pub.pem'),
algorithm='RS256',
)
class TestJWSProviderAlgorithmSelection(unit.TestCase):
"""End-to-end sign/verify for every supported algorithm."""
def _setup_provider(self, algorithm, accepted=None):
self.config_fixture.config(group='token', provider='jws')
self.config_fixture.config(group='jwt_tokens', jws_algorithm=algorithm)
if accepted is None:
accepted = [algorithm]
self.config_fixture.config(
group='jwt_tokens', jws_accepted_algorithms=accepted
)
self.useFixture(ksfixtures.JWSKeyRepository(self.config_fixture))
return jws.Provider()
def _make_token(self, prov):
token = token_model.TokenModel()
token.methods = ['password']
token.user_id = uuid.uuid4().hex
token.audit_id = provider.random_urlsafe_str()
token.expires_at = utils.isotime(
provider.default_expire_time(), subsecond=True
)
return prov.generate_id_and_issued_at(token)
def test_roundtrip_es256(self):
prov = self._setup_provider('ES256')
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_roundtrip_es384(self):
prov = self._setup_provider('ES384')
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_roundtrip_es512(self):
prov = self._setup_provider('ES512')
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_roundtrip_eddsa(self):
prov = self._setup_provider('EdDSA')
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_default_algorithm_is_es256(self):
"""Without explicit jws_algorithm, the default is ES256."""
self.config_fixture.config(group='token', provider='jws')
self.useFixture(ksfixtures.JWSKeyRepository(self.config_fixture))
prov = jws.Provider()
self.assertEqual('ES256', CONF.jwt_tokens.jws_algorithm)
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_multi_algorithm_validation(self):
"""Validate ES256 token when verifier accepts ES256 and ES384."""
prov = self._setup_provider('ES256', accepted=['ES256', 'ES384'])
token_id, _ = self._make_token(prov)
prov.validate_token(token_id)
def test_token_rejected_when_algorithm_not_accepted(self):
"""Reject ES256 token when verifier only accepts ES384."""
prov_sign = self._setup_provider('ES256', accepted=['ES256'])
token_id, _ = self._make_token(prov_sign)
# Reconfigure to only accept ES384; keep same key repos so
# the token is structurally valid but algorithmically rejected.
self.config_fixture.config(
group='jwt_tokens', jws_accepted_algorithms=['ES384']
)
prov_verify = jws.Provider()
self.assertRaises(
exception.TokenNotFound, prov_verify.validate_token, token_id
)
def test_algorithm_migration_scenario(self):
"""Simulate a migration from ES256 to EdDSA.
1. Generate a token with ES256.
2. Reconfigure to sign with EdDSA but accept both algorithms.
3. The old ES256 token must still validate.
4. A new EdDSA token must also validate.
"""
# Step 1: issue token with ES256
prov_old = self._setup_provider('ES256')
token_id_old, _ = self._make_token(prov_old)
# Save the ES256 public key so the new provider can still verify it
old_pub_repo = CONF.jwt_tokens.jws_public_key_repository
old_pub_keys = []
for fname in os.listdir(old_pub_repo):
path = os.path.join(old_pub_repo, fname)
with open(path, 'rb') as f:
old_pub_keys.append((fname, f.read()))
# Step 2: stand up a new provider with EdDSA
prov_new = self._setup_provider('EdDSA', accepted=['EdDSA', 'ES256'])
# Copy old ES256 public keys into the new public key repository
new_pub_repo = CONF.jwt_tokens.jws_public_key_repository
for fname, data in old_pub_keys:
dest = os.path.join(new_pub_repo, 'old_' + fname)
with open(dest, 'wb') as f:
f.write(data)
# Step 3: old token (ES256) must still validate
prov_new.validate_token(token_id_old)
# Step 4: new token (EdDSA) must also validate
token_id_new, _ = self._make_token(prov_new)
prov_new.validate_token(token_id_new)
+15 -7
View File
@@ -91,10 +91,13 @@ class Provider(base.Provider):
class JWSFormatter:
# NOTE(lbragstad): If in the future we expand support for different
# algorithms, make this configurable and validate it against a blessed list
# of supported algorithms.
algorithm = 'ES256'
@property
def algorithm(self):
return CONF.jwt_tokens.jws_algorithm
@property
def accepted_algorithms(self):
return CONF.jwt_tokens.jws_accepted_algorithms
@property
def private_key(self):
@@ -164,7 +167,7 @@ class JWSFormatter:
payload.pop(k)
token_id = jwt.encode(
payload, self.private_key, algorithm=JWSFormatter.algorithm
payload, self.private_key, algorithm=self.algorithm
)
return token_id, issued_at
@@ -217,10 +220,15 @@ class JWSFormatter:
return jwt.decode(
token_id,
public_key,
algorithms=JWSFormatter.algorithm,
algorithms=self.accepted_algorithms,
options=options,
)
except (jwt.InvalidSignatureError, jwt.DecodeError):
except (
jwt.InvalidSignatureError,
jwt.DecodeError,
jwt.InvalidKeyError,
jwt.InvalidAlgorithmError,
):
pass # nosec: We want to exhaustively try all public keys
raise exception.TokenNotFound(token_id=token_id)
@@ -0,0 +1,43 @@
---
features:
- |
The JWS token signing algorithm is now configurable via two new options
in ``[jwt_tokens]``:
* ``jws_algorithm`` selects the algorithm used when signing new tokens
(default ``ES256``, preserving backward compatibility). Supported
values are ``ES256``, ``ES384``, ``ES512``, and ``EdDSA``.
* ``jws_accepted_algorithms`` lists the algorithms accepted during
token validation. This allows operators to accept tokens signed
with a previous algorithm during a migration window.
Changing the signing algorithm requires regenerating the JWS key pair
via ``keystone-manage create_jws_keypair``, since each algorithm uses
a different key type.
upgrade:
- |
Two new configuration options have been added to the ``[jwt_tokens]``
section: ``jws_algorithm`` and ``jws_accepted_algorithms``. The
defaults (``ES256`` and ``['ES256']``) preserve the previous
behavior, so no action is required for existing deployments.
To migrate to a new algorithm, operators should:
1. Add the new algorithm to ``jws_accepted_algorithms`` alongside the
current one.
2. Generate a new key pair with ``keystone-manage create_jws_keypair``.
3. Set ``jws_algorithm`` to the new algorithm.
4. After all tokens signed with the old algorithm have expired, remove
the old algorithm from ``jws_accepted_algorithms``.
fixes:
- |
Fixed token validation to correctly handle mixed key types in the
public key repository. Previously, ``jwt.decode()`` could raise
``InvalidKeyError`` or ``InvalidAlgorithmError`` when encountering
public keys of a different type than expected, causing token
validation to fail instead of trying the next available key. These
exceptions are now handled gracefully during the key iteration loop.
- |
Fixed ``jwt.decode()`` call in ``_decode_token_from_id`` to pass
``algorithms`` as a list instead of a string. While PyJWT accepted
the string form, it was inconsistent with the documented API.