Repository: apache/libcloud

Issue:
RFC for AWS SignatureVersion 4 for EC2 
As shortly discussed with @Kami in #407, this patch adds support for Signature Version 4 to the `SignedAWSConnection` class. v4 will be used when a Driver-class selects `EC2V4Connection` as its `connectionCls`. In this patch this is only enabled for the the new Frankfurt region, represented by a new `EC2EUCentralNodeDriver` class. 
Haven't created any tests yet, since this is just the PoC. If this has any chance of getting merged in, I'll do the tests.

I would like some feedback and thoughts on this work.
Some questions:
- Is this the way to go, with the Connection-classes specifying a version, and the Driver classes specifying the Connection class to use?
- I've chosen the `pre_connect_hook` to do the work, because we need both the headers and the parameters to calculate the signature. Hope that's ok?
- I’ve tested all regions for EC2 and all seem to work fine with the v4 authentication. I haven’t tested the only other part that uses the `SignedAWSConnection`, the `ELBConnection`, but I’m guessing that that also supports V4. It would reduce complexity if we could drop support for V2 in the SignedAWSConnection altogether. 
- The credential scope, as explained [here](http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html) needs the service we are requesting. This should be just ‘ec2’ for compute service. I found no existing way to retrieve this, so I introduced a [new class variable](https://github.com/gertjanol/libcloud/blob/34933f90a87cd83429b04aecfbd0e37f63f105f2/libcloud/compute/drivers/ec2.py#L1707) in the `EC2Connection`. I don’t like this, so maybe there is a better way?
- This patch now only works for GET-requests, but the EC2-driver doesn't use anything else. I think S3 uses other methods, but seems to have its own authentication routines. Why is that? Should I take other methods into consideration here?
- Performance of calculating this signature sucks :(. Haven't done any measuring, but v2 seems way faster.



Discussion:

Resolving patch:
diff --git a/libcloud/common/aws.py b/libcloud/common/aws.py
--- a/libcloud/common/aws.py
+++ b/libcloud/common/aws.py
@@ -14,6 +14,8 @@
 # limitations under the License.
 
 import base64
+from datetime import datetime
+import hashlib
 import hmac
 import time
 from hashlib import sha256
@@ -28,6 +30,21 @@
 from libcloud.utils.py3 import b, httplib, urlquote
 from libcloud.utils.xml import findtext, findall
 
+__all__ = [
+    'AWSBaseResponse',
+    'AWSGenericResponse',
+
+    'AWSTokenConnection',
+    'SignedAWSConnection',
+
+    'AWSRequestSignerAlgorithmV2',
+    'AWSRequestSignerAlgorithmV4',
+
+    'AWSDriver'
+]
+
+DEFAULT_SIGNATURE_VERSION = '2'
+
 
 class AWSBaseResponse(XmlResponse):
     namespace = None
@@ -130,17 +147,50 @@ def add_default_headers(self, headers):
         return super(AWSTokenConnection, self).add_default_headers(headers)
 
 
-class SignedAWSConnection(AWSTokenConnection):
+class AWSRequestSigner(object):
+    """
+    Class which handles signing the outgoing AWS requests.
+    """
 
-    def add_default_params(self, params):
+    def __init__(self, access_key, access_secret, version, connection):
+        """
+        :param access_key: Access key.
+        :type access_key: ``str``
+
+        :param access_secret: Access secret.
+        :type access_secret: ``str``
+
+        :param version: API version.
+        :type version: ``str``
+
+        :param connection: Connection instance.
+        :type connection: :class:`Connection`
+        """
+        self.access_key = access_key
+        self.access_secret = access_secret
+        self.version = version
+        # TODO: Remove cycling dependency between connection and signer
+        self.connection = connection
+
+    def get_request_params(self, params, method='GET', path='/'):
+        return params
+
+    def get_request_headers(self, params, headers, method='GET', path='/'):
+        return params, headers
+
+
+class AWSRequestSignerAlgorithmV2(AWSRequestSigner):
+    def get_request_params(self, params, method='GET', path='/'):
         params['SignatureVersion'] = '2'
         params['SignatureMethod'] = 'HmacSHA256'
-        params['AWSAccessKeyId'] = self.user_id
+        params['AWSAccessKeyId'] = self.access_key
         params['Version'] = self.version
         params['Timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ',
                                             time.gmtime())
-        params['Signature'] = self._get_aws_auth_param(params, self.key,
-                                                       self.action)
+        params['Signature'] = self._get_aws_auth_param(
+            params=params,
+            secret_key=self.access_secret,
+            path=path)
         return params
 
     def _get_aws_auth_param(self, params, secret_key, path='/'):
@@ -153,6 +203,8 @@ def _get_aws_auth_param(self, params, secret_key, path='/'):
                        HTTPRequestURI + "\n" +
                        CanonicalizedQueryString <from the preceding step>
         """
+        connection = self.connection
+
         keys = list(params.keys())
         keys.sort()
         pairs = []
@@ -163,10 +215,10 @@ def _get_aws_auth_param(self, params, secret_key, path='/'):
 
         qs = '&'.join(pairs)
 
-        hostname = self.host
-        if (self.secure and self.port != 443) or \
-           (not self.secure and self.port != 80):
-            hostname += ":" + str(self.port)
+        hostname = connection.host
+        if (connection.secure and connection.port != 443) or \
+           (not connection.secure and connection.port != 80):
+            hostname += ':' + str(connection.port)
 
         string_to_sign = '\n'.join(('GET', hostname, path, qs))
 
@@ -178,6 +230,147 @@ def _get_aws_auth_param(self, params, secret_key, path='/'):
         return b64_hmac.decode('utf-8')
 
 
+class AWSRequestSignerAlgorithmV4(AWSRequestSigner):
+    def get_request_params(self, params, method='GET', path='/'):
+        params['Version'] = self.version
+        return params
+
+    def get_request_headers(self, params, headers, method='GET', path='/'):
+        now = datetime.utcnow()
+        headers['X-AMZ-Date'] = now.strftime('%Y%m%dT%H%M%SZ')
+        headers['Authorization'] = \
+            self._get_authorization_v4_header(params=params, headers=headers,
+                                              dt=now, method=method, path=path)
+
+        return params, headers
+
+    def _get_authorization_v4_header(self, params, headers, dt, method='GET',
+                                     path='/'):
+        assert method == 'GET', 'AWS Signature V4 not implemented for ' \
+                                'other methods than GET'
+
+        credentials_scope = self._get_credential_scope(dt=dt)
+        signed_headers = self._get_signed_headers(headers=headers)
+        signature = self._get_signature(params=params, headers=headers,
+                                        dt=dt, method=method, path=path)
+
+        return 'AWS4-HMAC-SHA256 Credential=%(u)s/%(c)s, ' \
+               'SignedHeaders=%(sh)s, Signature=%(s)s' % {
+                   'u': self.access_key,
+                   'c': credentials_scope,
+                   'sh': signed_headers,
+                   's': signature
+               }
+
+    def _get_signature(self, params, headers, dt, method, path):
+        key = self._get_key_to_sign_with(dt)
+        string_to_sign = self._get_string_to_sign(params=params,
+                                                  headers=headers, dt=dt,
+                                                  method=method, path=path)
+        return _sign(key=key, msg=string_to_sign, hex=True)
+
+    def _get_key_to_sign_with(self, dt):
+        return _sign(
+            _sign(
+                _sign(
+                    _sign(('AWS4' + self.access_secret),
+                          dt.strftime('%Y%m%d')),
+                    self.connection.driver.region_name),
+                self.connection.service_name),
+            'aws4_request')
+
+    def _get_string_to_sign(self, params, headers, dt, method, path):
+        canonical_request = self._get_canonical_request(params=params,
+                                                        headers=headers,
+                                                        method=method,
+                                                        path=path)
+
+        return '\n'.join(['AWS4-HMAC-SHA256',
+                          dt.strftime('%Y%m%dT%H%M%SZ'),
+                          self._get_credential_scope(dt),
+                          _hash(canonical_request)])
+
+    def _get_credential_scope(self, dt):
+        return '/'.join([dt.strftime('%Y%m%d'),
+                         self.connection.driver.region_name,
+                         self.connection.service_name,
+                         'aws4_request'])
+
+    def _get_signed_headers(self, headers):
+        return ';'.join([k.lower() for k in sorted(headers.keys())])
+
+    def _get_canonical_headers(self, headers):
+        return '\n'.join([':'.join([k.lower(), v.strip()])
+                          for k, v in sorted(headers.items())]) + '\n'
+
+    def _get_payload_hash(self):
+        return _hash('')
+
+    def _get_request_params(self, params):
+        # For self.method == GET
+        return '&'.join(["%s=%s" %
+                         (urlquote(k, safe=''), urlquote(str(v), safe='~'))
+                         for k, v in sorted(params.items())])
+
+    def _get_canonical_request(self, params, headers, method, path):
+        return '\n'.join([
+            method,
+            path,
+            self._get_request_params(params),
+            self._get_canonical_headers(headers),
+            self._get_signed_headers(headers),
+            self._get_payload_hash()
+        ])
+
+
+class SignedAWSConnection(AWSTokenConnection):
+    def __init__(self, user_id, key, secure=True, host=None, port=None,
+                 url=None, timeout=None, token=None,
+                 signature_version=DEFAULT_SIGNATURE_VERSION):
+        super(SignedAWSConnection, self).__init__(user_id=user_id, key=key,
+                                                  secure=secure, host=host,
+                                                  port=port, url=url,
+                                                  timeout=timeout, token=token)
+        self.signature_version = str(signature_version)
+
+        if self.signature_version == '2':
+            signer_cls = AWSRequestSignerAlgorithmV2
+        elif signature_version == '4':
+            signer_cls = AWSRequestSignerAlgorithmV4
+        else:
+            raise ValueError('Unsupported signature_version: %s' %
+                             (signature_version))
+
+        self.signer = signer_cls(access_key=self.user_id,
+                                 access_secret=self.key,
+                                 version=self.version,
+                                 connection=self)
+
+    def add_default_params(self, params):
+        params = self.signer.get_request_params(params=params,
+                                                method=self.method,
+                                                path=self.action)
+        return params
+
+    def pre_connect_hook(self, params, headers):
+        params, headers = self.signer.get_request_headers(params=params,
+                                                          headers=headers,
+                                                          method=self.method,
+                                                          path=self.action)
+        return params, headers
+
+
+def _sign(key, msg, hex=False):
+    if hex:
+        return hmac.new(b(key), b(msg), hashlib.sha256).hexdigest()
+    else:
+        return hmac.new(b(key), b(msg), hashlib.sha256).digest()
+
+
+def _hash(msg):
+    return hashlib.sha256(b(msg)).hexdigest()
+
+
 class AWSDriver(BaseDriver):
     def __init__(self, key, secret=None, secure=True, host=None, port=None,
                  api_version=None, region=None, token=None, **kwargs):
diff --git a/libcloud/compute/drivers/ec2.py b/libcloud/compute/drivers/ec2.py
--- a/libcloud/compute/drivers/ec2.py
+++ b/libcloud/compute/drivers/ec2.py
@@ -35,6 +35,7 @@
 from libcloud.utils.publickey import get_pubkey_comment
 from libcloud.utils.iso8601 import parse_date
 from libcloud.common.aws import AWSBaseResponse, SignedAWSConnection
+from libcloud.common.aws import DEFAULT_SIGNATURE_VERSION
 from libcloud.common.types import (InvalidCredsError, MalformedResponseError,
                                    LibcloudError)
 from libcloud.compute.providers import Provider
@@ -377,6 +378,7 @@
         'endpoint': 'ec2.us-east-1.amazonaws.com',
         'api_name': 'ec2_us_east',
         'country': 'USA',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -421,6 +423,7 @@
         'endpoint': 'ec2.us-west-1.amazonaws.com',
         'api_name': 'ec2_us_west',
         'country': 'USA',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -461,6 +464,7 @@
         'endpoint': 'ec2.us-west-2.amazonaws.com',
         'api_name': 'ec2_us_west_oregon',
         'country': 'US',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -503,6 +507,7 @@
         'endpoint': 'ec2.eu-west-1.amazonaws.com',
         'api_name': 'ec2_eu_west',
         'country': 'Ireland',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -540,11 +545,42 @@
             't2.medium'
         ]
     },
+    # EU (Frankfurt) Region
+    'eu-central-1': {
+        'endpoint': 'ec2.eu-central-1.amazonaws.com',
+        'api_name': 'ec2_eu_central',
+        'country': 'Frankfurt',
+        'signature_version': '4',
+        'instance_types': [
+            'm3.medium',
+            'm3.large',
+            'm3.xlarge',
+            'm3.2xlarge',
+            'c3.large',
+            'c3.xlarge',
+            'c3.2xlarge',
+            'c3.4xlarge',
+            'c3.8xlarge',
+            'i2.xlarge',
+            'i2.2xlarge',
+            'i2.4xlarge',
+            'i2.8xlarge',
+            'r3.large',
+            'r3.xlarge',
+            'r3.2xlarge',
+            'r3.4xlarge',
+            'r3.8xlarge',
+            't2.micro',
+            't2.small',
+            't2.medium'
+        ]
+    },
     # Asia Pacific (Singapore) Region
     'ap-southeast-1': {
         'endpoint': 'ec2.ap-southeast-1.amazonaws.com',
         'api_name': 'ec2_ap_southeast',
         'country': 'Singapore',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -580,6 +616,7 @@
         'endpoint': 'ec2.ap-northeast-1.amazonaws.com',
         'api_name': 'ec2_ap_northeast',
         'country': 'Japan',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -621,6 +658,7 @@
         'endpoint': 'ec2.sa-east-1.amazonaws.com',
         'api_name': 'ec2_sa_east',
         'country': 'Brazil',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -646,6 +684,7 @@
         'endpoint': 'ec2.ap-southeast-2.amazonaws.com',
         'api_name': 'ec2_ap_southeast_2',
         'country': 'Australia',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -685,6 +724,7 @@
         'endpoint': 'ec2.us-gov-west-1.amazonaws.com',
         'api_name': 'ec2_us_govwest',
         'country': 'US',
+        'signature_version': '2',
         'instance_types': [
             't1.micro',
             'm1.small',
@@ -726,6 +766,7 @@
         # Nimbus clouds have 3 EC2-style instance types but their particular
         # RAM allocations are configured by the admin
         'country': 'custom',
+        'signature_version': '2',
         'instance_types': [
             'm1.small',
             'm1.large',
@@ -1679,6 +1720,7 @@ class EC2Connection(SignedAWSConnection):
     version = API_VERSION
     host = REGION_DETAILS['us-east-1']['endpoint']
     responseCls = EC2Response
+    service_name = 'ec2'
 
 
 class ExEC2AvailabilityZone(object):
@@ -1989,6 +2031,7 @@ class BaseEC2NodeDriver(NodeDriver):
     connectionCls = EC2Connection
     features = {'create_node': ['ssh_key']}
     path = '/'
+    signature_version = DEFAULT_SIGNATURE_VERSION
 
     NODE_STATE_MAP = {
         'pending': NodeState.PENDING,
@@ -4623,6 +4666,11 @@ def ex_replace_route(self, route_table, cidr,
 
         return self._get_boolean(res)
 
+    def _ex_connection_class_kwargs(self):
+        kwargs = super(BaseEC2NodeDriver, self)._ex_connection_class_kwargs()
+        kwargs['signature_version'] = self.signature_version
+        return kwargs
+
     def _to_nodes(self, object, xpath):
         return [self._to_node(el)
                 for el in object.findall(fixxpath(xpath=xpath,
@@ -5555,6 +5603,8 @@ def __init__(self, key, secret=None, secure=True, host=None, port=None,
         self.region_name = region
         self.api_name = details['api_name']
         self.country = details['country']
+        self.signature_version = details.pop('signature_version',
+                                             DEFAULT_SIGNATURE_VERSION)
 
         host = host or details['endpoint']
 
@@ -5647,6 +5697,7 @@ class EucNodeDriver(BaseEC2NodeDriver):
     api_name = 'ec2_us_east'
     region_name = 'us-east-1'
     connectionCls = EucConnection
+    signature_version = '2'
 
     def __init__(self, key, secret=None, secure=True, host=None,
                  path=None, port=None, api_version=DEFAULT_EUCA_API_VERSION):
@@ -5742,6 +5793,7 @@ class NimbusNodeDriver(BaseEC2NodeDriver):
     region_name = 'nimbus'
     friendly_name = 'Nimbus Private Cloud'
     connectionCls = NimbusConnection
+    signature_version = '2'
 
     def ex_describe_addresses(self, nodes):
         """
@@ -5783,6 +5835,7 @@ class OutscaleNodeDriver(BaseEC2NodeDriver):
     name = 'Outscale'
     website = 'http://www.outscale.com'
     path = '/'
+    signature_version = '2'
 
     NODE_STATE_MAP = {
         'pending': NodeState.PENDING,
diff --git a/libcloud/loadbalancer/drivers/elb.py b/libcloud/loadbalancer/drivers/elb.py
--- a/libcloud/loadbalancer/drivers/elb.py
+++ b/libcloud/loadbalancer/drivers/elb.py
@@ -44,6 +44,7 @@ class ELBConnection(SignedAWSConnection):
     version = VERSION
     host = HOST
     responseCls = ELBResponse
+    service_name = 'elb'
 
 
 class ElasticLBDriver(Driver):


Test patch:
diff --git a/libcloud/test/common/test_aws.py b/libcloud/test/common/test_aws.py
new file mode 100644
--- /dev/null
+++ b/libcloud/test/common/test_aws.py
@@ -0,0 +1,239 @@
+import sys
+import unittest
+from datetime import datetime
+
+import mock
+
+from libcloud.common.aws import SignedAWSConnection
+from libcloud.common.aws import AWSRequestSignerAlgorithmV4
+from libcloud.test import LibcloudTestCase
+
+
+class EC2MockDriver(object):
+    region_name = 'my_region'
+
+
+class AWSRequestSignerAlgorithmV4TestCase(LibcloudTestCase):
+
+    def setUp(self):
+        SignedAWSConnection.driver = EC2MockDriver()
+        SignedAWSConnection.service_name = 'my_service'
+        SignedAWSConnection.version = '2013-10-15'
+        self.connection = SignedAWSConnection('my_key', 'my_secret')
+
+        self.signer = AWSRequestSignerAlgorithmV4(access_key='my_key',
+                                                  access_secret='my_secret',
+                                                  version='2013-10-15',
+                                                  connection=self.connection)
+
+        SignedAWSConnection.action = '/my_action/'
+        SignedAWSConnection.driver = EC2MockDriver()
+
+        self.now = datetime(2015, 3, 4, hour=17, minute=34, second=52)
+
+    def test_v4_signature(self):
+        params = {
+            'Action': 'DescribeInstances',
+            'Version': '2013-10-15'
+        }
+        headers = {
+            'Host': 'ec2.eu-west-1.amazonaws.com',
+            'Accept-Encoding': 'gzip,deflate',
+            'X-AMZ-Date': '20150304T173452Z',
+            'User-Agent': 'libcloud/0.17.0 (Amazon EC2 (eu-central-1)) '
+        }
+        dt = self.now
+        sig = self.signer._get_authorization_v4_header(params=params,
+                                                       headers=headers,
+                                                       dt=dt,
+                                                       method='GET',
+                                                       path='/my_action/')
+        self.assertEqual(sig, 'AWS4-HMAC-SHA256 '
+                              'Credential=my_key/20150304/my_region/my_service/aws4_request, '
+                              'SignedHeaders=accept-encoding;host;user-agent;x-amz-date, '
+                              'Signature=f9868f8414b3c3f856c7955019cc1691265541f5162b9b772d26044280d39bd3')
+
+    def test_v4_signature_raises_error_if_request_method_not_GET(self):
+        with self.assertRaises(Exception):
+            self.signer._get_authorization_v4_header(params={}, headers={},
+                                                     dt=self.now, method='POST')
+
+    def test_v4_signature_contains_user_id(self):
+        sig = self.signer._get_authorization_v4_header(params={}, headers={},
+                                                       dt=self.now)
+        self.assertIn('Credential=my_key/', sig)
+
+    def test_v4_signature_contains_credential_scope(self):
+        with mock.patch('libcloud.common.aws.AWSRequestSignerAlgorithmV4._get_credential_scope') as mock_get_creds:
+            mock_get_creds.return_value = 'my_credential_scope'
+            sig = self.signer._get_authorization_v4_header(params={}, headers={}, dt=self.now)
+
+        self.assertIn('Credential=my_key/my_credential_scope, ', sig)
+
+    def test_v4_signature_contains_signed_headers(self):
+        with mock.patch('libcloud.common.aws.AWSRequestSignerAlgorithmV4._get_signed_headers') as mock_get_headers:
+            mock_get_headers.return_value = 'my_signed_headers'
+            sig = self.signer._get_authorization_v4_header({}, {}, self.now,
+                                                           method='GET',
+                                                           path='/')
+        self.assertIn('SignedHeaders=my_signed_headers, ', sig)
+
+    def test_v4_signature_contains_signature(self):
+        with mock.patch('libcloud.common.aws.AWSRequestSignerAlgorithmV4._get_signature') as mock_get_signature:
+            mock_get_signature.return_value = 'my_signature'
+            sig = self.signer._get_authorization_v4_header({}, {}, self.now)
+        self.assertIn('Signature=my_signature', sig)
+
+    def test_get_signature_(self):
+        def _sign(key, msg, hex=False):
+            if hex:
+                return 'H|%s|%s' % (key, msg)
+            else:
+                return '%s|%s' % (key, msg)
+
+        with mock.patch('libcloud.common.aws.AWSRequestSignerAlgorithmV4._get_key_to_sign_with') as mock_get_key:
+            with mock.patch('libcloud.common.aws.AWSRequestSignerAlgorithmV4._get_string_to_sign') as mock_get_string:
+                with mock.patch('libcloud.common.aws._sign', new=_sign):
+                    mock_get_key.return_value = 'my_signing_key'
+                    mock_get_string.return_value = 'my_string_to_sign'
+                    sig = self.signer._get_signature({}, {}, self.now,
+                                                     method='GET', path='/')
+
+        self.assertEqual(sig, 'H|my_signing_key|my_string_to_sign')
+
+    def test_get_string_to_sign(self):
+        with mock.patch('hashlib.sha256') as mock_sha256:
+            mock_sha256.return_value.hexdigest.return_value = 'chksum_of_canonical_request'
+            to_sign = self.signer._get_string_to_sign({}, {}, self.now,
+                                                      method='GET', path='/')
+
+        self.assertEqual(to_sign,
+                         'AWS4-HMAC-SHA256\n'
+                         '20150304T173452Z\n'
+                         '20150304/my_region/my_service/aws4_request\n'
+                         'chksum_of_canonical_request')
+
+    def test_get_key_to_sign_with(self):
+        def _sign(key, msg, hex=False):
+            return '%s|%s' % (key, msg)
+
+        with mock.patch('libcloud.common.aws._sign', new=_sign):
+            key = self.signer._get_key_to_sign_with(self.now)
+
+        self.assertEqual(key, 'AWS4my_secret|20150304|my_region|my_service|aws4_request')
+
+    def test_get_signed_headers_contains_all_headers_lowercased(self):
+        headers = {'Content-Type': 'text/plain', 'Host': 'my_host', 'X-Special-Header': ''}
+        signed_headers = self.signer._get_signed_headers(headers)
+
+        self.assertIn('content-type', signed_headers)
+        self.assertIn('host', signed_headers)
+        self.assertIn('x-special-header', signed_headers)
+
+    def test_get_signed_headers_concats_headers_sorted_lexically(self):
+        headers = {'Host': 'my_host', 'X-Special-Header': '', '1St-Header': '2', 'Content-Type': 'text/plain'}
+        signed_headers = self.signer._get_signed_headers(headers)
+
+        self.assertEqual(signed_headers, '1st-header;content-type;host;x-special-header')
+
+    def test_get_credential_scope(self):
+        scope = self.signer._get_credential_scope(self.now)
+        self.assertEqual(scope, '20150304/my_region/my_service/aws4_request')
+
+    def test_get_canonical_headers_joins_all_headers(self):
+        headers = {
+            'accept-encoding': 'gzip,deflate',
+            'host': 'my_host',
+        }
+        self.assertEqual(self.signer._get_canonical_headers(headers),
+                         'accept-encoding:gzip,deflate\n'
+                         'host:my_host\n')
+
+    def test_get_canonical_headers_sorts_headers_lexically(self):
+        headers = {
+            'accept-encoding': 'gzip,deflate',
+            'host': 'my_host',
+            '1st-header': '2',
+            'x-amz-date': '20150304T173452Z',
+            'user-agent': 'my-ua'
+        }
+        self.assertEqual(self.signer._get_canonical_headers(headers),
+                         '1st-header:2\n'
+                         'accept-encoding:gzip,deflate\n'
+                         'host:my_host\n'
+                         'user-agent:my-ua\n'
+                         'x-amz-date:20150304T173452Z\n')
+
+    def test_get_canonical_headers_lowercases_headers_names(self):
+        headers = {
+            'Accept-Encoding': 'GZIP,DEFLATE',
+            'User-Agent': 'My-UA'
+        }
+        self.assertEqual(self.signer._get_canonical_headers(headers),
+                         'accept-encoding:GZIP,DEFLATE\n'
+                         'user-agent:My-UA\n')
+
+    def test_get_canonical_headers_trims_header_values(self):
+        # TODO: according to AWS spec (and RFC 2616 Section 4.2.) excess whitespace
+        # from inside non-quoted strings should be stripped. Now we only strip the
+        # start and end of the string. See
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+        headers = {
+            'accept-encoding': '   gzip,deflate',
+            'user-agent': 'libcloud/0.17.0 '
+        }
+        self.assertEqual(self.signer._get_canonical_headers(headers),
+                         'accept-encoding:gzip,deflate\n'
+                         'user-agent:libcloud/0.17.0\n')
+
+    def test_get_request_params_joins_params_sorted_lexically(self):
+        self.assertEqual(self.signer._get_request_params({
+            'Action': 'DescribeInstances',
+            'Filter.1.Name': 'state',
+            'Version': '2013-10-15'
+        }),
+            'Action=DescribeInstances&Filter.1.Name=state&Version=2013-10-15')
+
+    def test_get_request_params_allows_integers_as_value(self):
+        self.assertEqual(self.signer._get_request_params({'Action': 'DescribeInstances', 'Port': 22}),
+                         'Action=DescribeInstances&Port=22')
+
+    def test_get_request_params_urlquotes_params_keys(self):
+        self.assertEqual(self.signer._get_request_params({'Action+Reaction': 'DescribeInstances'}),
+                         'Action%2BReaction=DescribeInstances')
+
+    def test_get_request_params_urlquotes_params_values(self):
+        self.assertEqual(self.signer._get_request_params({
+            'Action': 'DescribeInstances&Addresses',
+            'Port-Range': '2000 3000'
+        }),
+            'Action=DescribeInstances%26Addresses&Port-Range=2000%203000')
+
+    def test_get_request_params_urlquotes_params_values_allows_safe_chars_in_value(self):
+        # http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+        self.assertEqual('Action=a~b.c_d-e',
+                         self.signer._get_request_params({'Action': 'a~b.c_d-e'}))
+
+    def test_get_payload_hash_returns_digest_of_empty_string_for_GET_requests(self):
+        SignedAWSConnection.method = 'GET'
+        self.assertEqual(self.signer._get_payload_hash(),
+                         'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
+
+    def test_get_canonical_request(self):
+        req = self.signer._get_canonical_request(
+            {'Action': 'DescribeInstances', 'Version': '2013-10-15'},
+            {'Accept-Encoding': 'gzip,deflate', 'User-Agent': 'My-UA'},
+            method='GET',
+            path='/my_action/'
+        )
+        self.assertEqual(req, 'GET\n'
+                              '/my_action/\n'
+                              'Action=DescribeInstances&Version=2013-10-15\n'
+                              'accept-encoding:gzip,deflate\n'
+                              'user-agent:My-UA\n'
+                              '\n'
+                              'accept-encoding;user-agent\n'
+                              'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
+
+if __name__ == '__main__':
+    sys.exit(unittest.main())