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:
1 parent
80d5b7bf50
commit
f9a4a551f5
7 files changed
+299
-15
No files matched your search
+5
-1
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user