diff --git a/keystone/api/_shared/EC2_S3_Resource.py b/keystone/api/_shared/EC2_S3_Resource.py index 021cf4ad5f..803254816c 100644 --- a/keystone/api/_shared/EC2_S3_Resource.py +++ b/keystone/api/_shared/EC2_S3_Resource.py @@ -12,15 +12,20 @@ # Common base resource for EC2 and S3 Authentication +import datetime + from oslo_serialization import jsonutils +from oslo_utils import timeutils from werkzeug import exceptions from keystone.common import provider_api from keystone.common import utils +import keystone.conf from keystone import exception as ks_exceptions from keystone.i18n import _ from keystone.server import flask as ks_flask +CONF = keystone.conf.CONF PROVIDERS = provider_api.ProviderAPIs CRED_TYPE_EC2 = 'ec2' @@ -36,6 +41,31 @@ class ResourceBase(ks_flask.ResourceBase): # the ABC module. raise NotImplementedError() + @staticmethod + def _check_timestamp(credentials): + timestamp = ( + # AWS Signature v1/v2 + credentials.get('params', {}).get('Timestamp') or + # AWS Signature v4 + credentials.get('headers', {}).get('X-Amz-Date') or + credentials.get('params', {}).get('X-Amz-Date') + ) + if not timestamp: + # If the signed payload doesn't include a timestamp then the signer + # must have intentionally left it off + return + try: + timestamp = timeutils.parse_isotime(timestamp) + timestamp = timeutils.normalize_time(timestamp) + except Exception as e: + raise ks_exceptions.Unauthorized( + _('Credential timestamp is invalid: %s') % e) + auth_ttl = datetime.timedelta(minutes=CONF.credential.auth_ttl) + current_time = timeutils.normalize_time(timeutils.utcnow()) + if current_time > timestamp + auth_ttl: + raise ks_exceptions.Unauthorized( + _('Credential is expired')) + def handle_authenticate(self): # TODO(morgan): convert this dirty check to JSON Schema validation # this mirrors the previous behavior of the webob system where an @@ -101,6 +131,7 @@ class ResourceBase(ks_flask.ResourceBase): except AssertionError as e: raise ks_exceptions.Unauthorized from e + self._check_timestamp(credentials) roles = PROVIDERS.assignment_api.get_roles_for_user_and_project( user_ref['id'], project_ref['id']) diff --git a/keystone/conf/credential.py b/keystone/conf/credential.py index 3ccf6e633c..2066fbdfbd 100644 --- a/keystone/conf/credential.py +++ b/keystone/conf/credential.py @@ -61,6 +61,14 @@ Time to cache credential data in seconds. This has no effect unless global caching is enabled. """)) +auth_ttl = cfg.IntOpt( + 'auth_ttl', + default=15, + help=utils.fmt(""" +The length of time in minutes for which a signed EC2 or S3 token request is +valid from the timestamp contained in the token request. +""")) + GROUP_NAME = __name__.split('.')[-1] ALL_OPTS = [ @@ -68,7 +76,8 @@ ALL_OPTS = [ provider, key_repository, caching, - cache_time + cache_time, + auth_ttl ] diff --git a/keystone/tests/unit/test_contrib_ec2_core.py b/keystone/tests/unit/test_contrib_ec2_core.py index 1242142e01..4b514f8985 100644 --- a/keystone/tests/unit/test_contrib_ec2_core.py +++ b/keystone/tests/unit/test_contrib_ec2_core.py @@ -12,10 +12,15 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime +import hashlib + import http.client from keystoneclient.contrib.ec2 import utils as ec2_utils +from oslo_utils import timeutils from keystone.common import provider_api +from keystone.common import utils from keystone.tests import unit from keystone.tests.unit import test_v3 @@ -34,6 +39,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): def test_valid_authentication_response_with_proper_secret(self): signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': self.cred_blob['access'], 'secret': self.cred_blob['secret'], @@ -43,7 +49,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -53,6 +59,48 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): expected_status=http.client.OK) self.assertValidProjectScopedTokenResponse(resp, self.user) + def test_valid_authentication_response_with_signature_v4(self): + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + timestamp = utils.isotime(timeutils.utcnow()) + hashed_payload = ( + 'GET\n' + '/\n' + 'Action=Test\n' + 'host:localhost\n' + 'x-amz-date:' + timestamp + '\n' + '\n' + 'host;x-amz-date\n' + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ) + body_hash = hashlib.sha256(hashed_payload.encode()).hexdigest() + amz_credential = ( + 'AKIAIOSFODNN7EXAMPLE/%s/us-east-1/iam/aws4_request,' % + timestamp[:8]) + + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'Action': 'Test', + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-SignedHeaders': 'host,x-amz-date,', + 'X-Amz-Credential': amz_credential + }, + 'headers': { + 'X-Amz-Date': timestamp + }, + 'body_hash': body_hash + } + credentials['signature'] = signer.generate(credentials) + resp = self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http.client.OK) + self.assertValidProjectScopedTokenResponse(resp, self.user) + def test_authenticate_with_empty_body_returns_bad_request(self): self.post( '/ec2tokens', @@ -72,6 +120,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): def test_authenticate_without_proper_secret_returns_unauthorized(self): signer = ec2_utils.Ec2Signer('totally not the secret') + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': self.cred_blob['access'], 'secret': 'totally not the secret', @@ -81,7 +130,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -89,3 +138,75 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): '/ec2tokens', body={'credentials': credentials}, expected_status=http.client.UNAUTHORIZED) + + def test_authenticate_expired_request(self): + self.config_fixture.config( + group='credential', + auth_ttl=5 + ) + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + past = timeutils.utcnow() - datetime.timedelta(minutes=10) + timestamp = utils.isotime(past) + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'SignatureVersion': '2', + 'Action': 'Test', + 'Timestamp': timestamp + }, + } + credentials['signature'] = signer.generate(credentials) + self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http.client.UNAUTHORIZED) + + def test_authenticate_expired_request_v4(self): + self.config_fixture.config( + group='credential', + auth_ttl=5 + ) + signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) + past = timeutils.utcnow() - datetime.timedelta(minutes=10) + timestamp = utils.isotime(past) + hashed_payload = ( + 'GET\n' + '/\n' + 'Action=Test\n' + 'host:localhost\n' + 'x-amz-date:' + timestamp + '\n' + '\n' + 'host;x-amz-date\n' + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ) + body_hash = hashlib.sha256(hashed_payload.encode()).hexdigest() + amz_credential = ( + 'AKIAIOSFODNN7EXAMPLE/%s/us-east-1/iam/aws4_request,' % + timestamp[:8]) + + credentials = { + 'access': self.cred_blob['access'], + 'secret': self.cred_blob['secret'], + 'host': 'localhost', + 'verb': 'GET', + 'path': '/', + 'params': { + 'Action': 'Test', + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-SignedHeaders': 'host,x-amz-date,', + 'X-Amz-Credential': amz_credential + }, + 'headers': { + 'X-Amz-Date': timestamp + }, + 'body_hash': body_hash + } + credentials['signature'] = signer.generate(credentials) + self.post( + '/ec2tokens', + body={'credentials': credentials}, + expected_status=http.client.UNAUTHORIZED) diff --git a/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml b/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml new file mode 100644 index 0000000000..d0732ab4c3 --- /dev/null +++ b/releasenotes/notes/bug-1872737-f8e1ad3b6705b766.yaml @@ -0,0 +1,28 @@ +--- +feature: + - | + [`bug 1872737 `_] + Added a new config option ``auth_ttl`` in the ``[credential]`` config + section to allow configuring the period for which a signed token request + from AWS is valid. The default is 15 minutes in accordance with the AWS + Signature V4 API reference. +upgrade: + - | + [`bug 1872737 `_] + Added a default TTL of 15 minutes for signed EC2 credential requests, + where previously an EC2 signed token request was valid indefinitely. This + change in behavior is needed to protect against replay attacks. +security: + - | + [`bug 1872737 `_] + Fixed an incorrect EC2 token validation implementation in which the + timestamp of the signed request was ignored, which made EC2 and S3 token + requests vulnerable to replay attacks. The default TTL is 15 minutes but + is configurable. +fixes: + - | + [`bug 1872737 `_] + Fixed an incorrect EC2 token validation implementation in which the + timestamp of the signed request was ignored, which made EC2 and S3 token + requests vulnerable to replay attacks. The default TTL is 15 minutes but + is configurable.