diff --git a/keystone/conf/credential.py b/keystone/conf/credential.py index b7877a8165..048d222837 100644 --- a/keystone/conf/credential.py +++ b/keystone/conf/credential.py @@ -46,12 +46,21 @@ share this repository with the repository used to manage keys for Fernet tokens. """)) +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 = [ driver, provider, - key_repository + key_repository, + auth_ttl ] diff --git a/keystone/contrib/ec2/controllers.py b/keystone/contrib/ec2/controllers.py index 3883a16251..390d45fe52 100644 --- a/keystone/contrib/ec2/controllers.py +++ b/keystone/contrib/ec2/controllers.py @@ -33,11 +33,13 @@ Glance to list images needed to perform the requested task. """ import abc +import datetime import sys import uuid from keystoneclient.contrib.ec2 import utils as ec2_utils from oslo_serialization import jsonutils +from oslo_utils import timeutils import six from six.moves import http_client @@ -46,11 +48,13 @@ from keystone.common import controller from keystone.common import dependency from keystone.common import utils from keystone.common import wsgi +import keystone.conf from keystone import exception from keystone.i18n import _ from keystone.token import controllers as token_controllers CRED_TYPE_EC2 = 'ec2' +CONF = keystone.conf.CONF @dependency.requires('assignment_api', 'catalog_api', 'credential_api', @@ -89,6 +93,30 @@ class Ec2ControllerCommon(object): raise exception.Unauthorized( message=_('EC2 signature not supplied.')) + def _check_timestamp(self, 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 exception.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 exception.Unauthorized( + _('Credential is expired')) + @abc.abstractmethod def authenticate(self, context, credentials=None, ec2Credentials=None): """Validate a signed EC2 request and provide a token. @@ -147,6 +175,7 @@ class Ec2ControllerCommon(object): six.reraise(exception.Unauthorized, exception.Unauthorized(e), sys.exc_info()[2]) + self._check_timestamp(credentials) roles = self.assignment_api.get_roles_for_user_and_project( user_ref['id'], tenant_ref['id'] ) diff --git a/keystone/tests/unit/test_contrib_ec2_core.py b/keystone/tests/unit/test_contrib_ec2_core.py index d5f747219f..431210bae4 100644 --- a/keystone/tests/unit/test_contrib_ec2_core.py +++ b/keystone/tests/unit/test_contrib_ec2_core.py @@ -12,9 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime +import hashlib + from keystoneclient.contrib.ec2 import utils as ec2_utils +from oslo_utils import timeutils from six.moves import http_client +from keystone.common import utils from keystone.contrib.ec2 import controllers from keystone.tests import unit from keystone.tests.unit import test_v2 @@ -22,6 +27,14 @@ from keystone.tests.unit import test_v3 class EC2ContribCoreV2(test_v2.RestfulTestCase): + def setUp(self): + super(EC2ContribCoreV2, self).setUp() + + self.cred_blob, self.credential = unit.new_ec2_credential( + self.user_foo['id'], self.tenant_bar['id']) + self.credential_api.create_credential( + self.credential['id'], self.credential) + def config_overrides(self): super(EC2ContribCoreV2, self).config_overrides() @@ -59,6 +72,7 @@ class EC2ContribCoreV2(test_v2.RestfulTestCase): credential['id'], credential) signer = ec2_utils.Ec2Signer(cred_blob['secret']) + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': cred_blob['access'], 'secret': cred_blob['secret'], @@ -68,7 +82,7 @@ class EC2ContribCoreV2(test_v2.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -107,6 +121,7 @@ class EC2ContribCoreV2(test_v2.RestfulTestCase): credential['id'], credential) signer = ec2_utils.Ec2Signer('totally not the secret') + timestamp = utils.isotime(timeutils.utcnow()) credentials = { 'access': cred_blob['access'], 'secret': 'totally not the secret', @@ -116,7 +131,7 @@ class EC2ContribCoreV2(test_v2.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -126,6 +141,80 @@ class EC2ContribCoreV2(test_v2.RestfulTestCase): 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.public_request( + method='POST', + path='/v2.0/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.public_request( + method='POST', + path='/v2.0/ec2tokens', + body={'credentials': credentials}, + expected_status=http_client.UNAUTHORIZED) + class EC2ContribCoreV3(test_v3.RestfulTestCase): def setUp(self): @@ -140,6 +229,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'], @@ -149,7 +239,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -159,6 +249,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', @@ -178,6 +310,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', @@ -187,7 +320,7 @@ class EC2ContribCoreV3(test_v3.RestfulTestCase): 'params': { 'SignatureVersion': '2', 'Action': 'Test', - 'Timestamp': '2007-01-31T23:59:59Z' + 'Timestamp': timestamp }, } credentials['signature'] = signer.generate(credentials) @@ -195,3 +328,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.