Repository: boto/botocore
Pull request #735 (2015-12-04 18:34:09)

Feature cloudfront sign
Provide the signer class. It will be used in CLI soon.

Also note that there is no (hard) dependency of rsa in this implementation.


Patch:
diff --git a/botocore/compat.py b/botocore/compat.py
index 343fc054f..0bc4f2d45 100644
--- a/botocore/compat.py
+++ b/botocore/compat.py
@@ -29,6 +29,7 @@ if six.PY3:
     from urllib.parse import urlencode
     from urllib.parse import unquote
     from urllib.parse import unquote_plus
+    from urllib.parse import urlparse
     from urllib.parse import urlsplit
     from urllib.parse import urlunsplit
     from urllib.parse import urljoin
@@ -69,6 +70,7 @@ else:
     from urllib import urlencode
     from urllib import unquote
     from urllib import unquote_plus
+    from urlparse import urlparse
     from urlparse import urlsplit
     from urlparse import urlunsplit
     from urlparse import urljoin
diff --git a/botocore/signers.py b/botocore/signers.py
index b7f64a1ad..7683cb15f 100644
--- a/botocore/signers.py
+++ b/botocore/signers.py
@@ -12,14 +12,17 @@
 # language governing permissions and limitations under the License.
 import datetime
 import weakref
+import json
+import base64
 
 import botocore
 import botocore.auth
+from botocore.compat import six, OrderedDict
 from botocore.awsrequest import create_request_object, prepare_request_dict
 from botocore.exceptions import UnknownSignatureVersionError
 from botocore.exceptions import UnknownClientMethodError
 from botocore.exceptions import UnsupportedSignatureVersionError
-from botocore.utils import fix_s3_host
+from botocore.utils import fix_s3_host, datetime2timestamp
 
 
 class RequestSigner(object):
@@ -213,6 +216,132 @@ class RequestSigner(object):
         return request.url
 
 
+class CloudFrontSigner(object):
+    '''A signer to create a signed CloudFront URL.
+
+    First you create a cloudfront signer based on a normalized RSA signer::
+
+        import rsa
+        def rsa_signer(message):
+            private_key = open('private_key.pem', 'r').read()
+            return rsa.sign(
+                message,
+                rsa.PrivateKey.load_pkcs1(private_key.encode('utf8')),
+                'SHA-1')  # CloudFront requires SHA-1 hash
+        cf_signer = CloudFrontSigner(key_id, rsa_signer)
+
+    To sign with a canned policy::
+
+        signed_url = cf_signer.generate_signed_url(
+            url, date_less_than=datetime(2015, 12, 1))
+
+    To sign with a custom policy::
+
+        signed_url = cf_signer.generate_signed_url(url, policy=my_policy)
+    '''
+
+    def __init__(self, key_id, rsa_signer):
+        """Create a CloudFrontSigner.
+
+        :type key_id: str
+        :param key_id: The CloudFront Key Pair ID
+
+        :type rsa_signer: callable
+        :param rsa_signer: An RSA signer.
+               Its only input parameter will be the message to be signed,
+               and its output will be the signed content as a binary string.
+               The hash algorithm needed by CloudFront is SHA-1.
+        """
+        self.key_id = key_id
+        self.rsa_signer = rsa_signer
+
+    def generate_presigned_url(self, url, date_less_than=None, policy=None):
+        """Creates a signed CloudFront URL based on given parameters.
+
+        :type url: str
+        :param url: The URL of the protected object
+
+        :type date_less_than: datetime
+        :param date_less_than: The URL will expire after that date and time
+
+        :type policy: str
+        :param policy: The custom policy, possibly built by self.build_policy()
+
+        :rtype: str
+        :return: The signed URL.
+        """
+        if (date_less_than is not None and policy is not None
+                or date_less_than is None and policy is None):
+            e = 'Need to provide either date_less_than or policy, but not both'
+            raise ValueError(e)
+        if date_less_than is not None:
+            # We still need to build a canned policy for signing purpose
+            policy = self.build_policy(url, date_less_than)
+        if isinstance(policy, six.text_type):
+            policy = policy.encode('utf8')
+        if date_less_than is not None:
+            params = ['Expires=%s' % int(datetime2timestamp(date_less_than))]
+        else:
+            params = ['Policy=%s' % self._url_b64encode(policy).decode('utf8')]
+        signature = self.rsa_signer(policy)
+        params.extend([
+            'Signature=%s' % self._url_b64encode(signature).decode('utf8'),
+            'Key-Pair-Id=%s' % self.key_id,
+            ])
+        return self._build_url(url, params)
+
+    def _build_url(self, base_url, extra_params):
+        separator = '&' if '?' in base_url else '?'
+        return base_url + separator + '&'.join(extra_params)
+
+    def build_policy(self, resource, date_less_than,
+                     date_greater_than=None, ip_address=None):
+        """A helper to build policy.
+
+        :type resource: str
+        :param resource: The URL or the stream filename of the protected object
+
+        :type date_less_than: datetime
+        :param date_less_than: The URL will expire after the time has passed
+
+        :type date_greater_than: datetime
+        :param date_greater_than: The URL will not be valid until this time
+
+        :type ip_address: str
+        :param ip_address: Use 'x.x.x.x' for an IP, or 'x.x.x.x/x' for a subnet
+
+        :rtype: str
+        :return: The policy in a compact string.
+        """
+        # Note:
+        # 1. Order in canned policy is significant. Special care has been taken
+        #    to ensure the output will match the order defined by the document.
+        #    There is also a test case to ensure that order.
+        #    SEE: http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-creating-signed-url-canned-policy.html#private-content-canned-policy-creating-policy-statement
+        # 2. Albeit the order in custom policy is not required by CloudFront,
+        #    we still use OrderedDict internally to ensure the result is stable
+        #    and also matches canned policy requirement.
+        #    SEE: http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-creating-signed-url-custom-policy.html
+        moment = int(datetime2timestamp(date_less_than))
+        condition = OrderedDict({"DateLessThan": {"AWS:EpochTime": moment}})
+        if ip_address:
+            if '/' not in ip_address:
+                ip_address += '/32'
+            condition["IpAddress"] = {"AWS:SourceIp": ip_address}
+        if date_greater_than:
+            moment = int(datetime2timestamp(date_greater_than))
+            condition["DateGreaterThan"] = {"AWS:EpochTime": moment}
+        ordered_payload = [('Resource', resource), ('Condition', condition)]
+        custom_policy = {"Statement": [OrderedDict(ordered_payload)]}
+        return json.dumps(custom_policy, separators=(',', ':'))
+
+    def _url_b64encode(self, data):
+        # Required by CloudFront. See also:
+        # http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-linux-openssl.html
+        return base64.b64encode(
+            data).replace(b'+', b'-').replace(b'=', b'_').replace(b'/', b'~')
+
+
 class S3PostPresigner(object):
     def __init__(self, request_signer):
         self._request_signer = request_signer
diff --git a/botocore/utils.py b/botocore/utils.py
index 804a3bdc2..61aa551a2 100644
--- a/botocore/utils.py
+++ b/botocore/utils.py
@@ -389,6 +389,27 @@ def parse_to_aware_datetime(value):
     return datetime_obj
 
 
+def datetime2timestamp(dt, default_timezone=None):
+    """Calculate the timestamp based on the given datetime instance.
+
+    :type dt: datetime
+    :param dt: A datetime object to be converted into timestamp
+    :type default_timezone: tzinfo
+    :param default_timezone: If it is provided as None, we treat it as tzutc().
+                             But it is only used when dt is a naive datetime.
+    :returns: The timestamp
+    """
+    epoch = datetime.datetime(1970, 1, 1)
+    if dt.tzinfo is None:
+        if default_timezone is None:
+            default_timezone = tzutc()
+        dt = dt.replace(tzinfo=default_timezone)
+    d = dt.replace(tzinfo=None) - dt.utcoffset() - epoch
+    if hasattr(d, "total_seconds"):
+        return d.total_seconds()  # Works in Python 2.7+
+    return (d.microseconds + (d.seconds + d.days * 24 * 3600) * 10**6) / 10**6
+
+
 def calculate_sha256(body, as_hex=False):
     """Calculate a sha256 checksum.
 


Test patch:
diff --git a/tests/unit/test_signers.py b/tests/unit/test_signers.py
index 67f05f18f..a9d263e4c 100644
--- a/tests/unit/test_signers.py
+++ b/tests/unit/test_signers.py
@@ -13,15 +13,17 @@
 
 import mock
 import datetime
+import json
 
 import botocore
 import botocore.auth
+from botocore.compat import six, urlparse, parse_qs
 
 from botocore.credentials import Credentials
 from botocore.exceptions import NoRegionError, UnknownSignatureVersionError
 from botocore.exceptions import UnknownClientMethodError, ParamValidationError
 from botocore.exceptions import UnsupportedSignatureVersionError
-from botocore.signers import RequestSigner, S3PostPresigner
+from botocore.signers import RequestSigner, S3PostPresigner, CloudFrontSigner
 
 from tests import unittest
 
@@ -250,6 +252,79 @@ class TestSigner(BaseSignerTest):
             self.signer.generate_presigned_url({})
 
 
+class TestCloudfrontSigner(unittest.TestCase):
+    def setUp(self):
+        self.signer = CloudFrontSigner("MY_KEY_ID", lambda message: b'signed')
+        # It helps but the long string diff will still be slightly different on
+        # Python 2.6/2.7/3.x. We won't soly rely on that anyway, so it's fine.
+        self.maxDiff = None
+
+    def test_build_canned_policy(self):
+        policy = self.signer.build_policy('foo', datetime.datetime(2016, 1, 1))
+        expected = (
+            '{"Statement":[{"Resource":"foo",'
+            '"Condition":{"DateLessThan":{"AWS:EpochTime":1451606400}}}]}')
+        self.assertEqual(json.loads(policy), json.loads(expected))
+        self.assertEqual(policy, expected)  # This is to ensure the right order
+
+    def test_build_custom_policy(self):
+        policy = self.signer.build_policy(
+            'foo', datetime.datetime(2016, 1, 1),
+            date_greater_than=datetime.datetime(2015, 12, 1),
+            ip_address='12.34.56.78/9')
+        expected = {
+            "Statement": [{
+                "Resource":"foo",
+                "Condition":{
+                    "DateGreaterThan":{"AWS:EpochTime":1448928000},
+                    "DateLessThan":{"AWS:EpochTime":1451606400},
+                    "IpAddress":{"AWS:SourceIp":"12.34.56.78/9"}
+                },
+            }]
+        }
+        self.assertEqual(json.loads(policy), expected)
+
+    def _urlparse(self, url):
+        if isinstance(url, six.binary_type):
+            # Not really necessary, but it helps to reduce noise on Python 2.x
+            url = url.decode('utf8')
+        return dict(urlparse(url)._asdict())  # Needs an unordered dict here
+
+    def assertEqualUrl(self, url1, url2):
+        # We compare long urls by their dictionary parts
+        parts1 = self._urlparse(url1)
+        parts2 = self._urlparse(url2)
+        self.assertEqual(
+            parse_qs(parts1.pop('query')), parse_qs(parts2.pop('query')))
+        self.assertEqual(parts1, parts2)
+
+    def test_generate_presign_url_with_expire_time(self):
+        signed_url = self.signer.generate_presigned_url(
+            'http://test.com/foo.txt',
+            date_less_than=datetime.datetime(2016, 1, 1))
+        expected = (
+            'http://test.com/foo.txt?Expires=1451606400&Signature=c2lnbmVk'
+            '&Key-Pair-Id=MY_KEY_ID')
+        self.assertEqualUrl(signed_url, expected)
+
+    def test_generate_presign_url_with_custom_policy(self):
+        policy = self.signer.build_policy(
+            'foo', datetime.datetime(2016, 1, 1),
+            date_greater_than=datetime.datetime(2015, 12, 1),
+            ip_address='12.34.56.78/9')
+        signed_url = self.signer.generate_presigned_url(
+            'http://test.com/index.html?foo=bar', policy=policy)
+        expected = (
+            'http://test.com/index.html?foo=bar'
+            '&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiZm9vIiwiQ29uZ'
+                'Gl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIj'
+                'oxNDUxNjA2NDAwfSwiSXBBZGRyZXNzIjp7IkFXUzpTb3VyY2VJcCI'
+                '6IjEyLjM0LjU2Ljc4LzkifSwiRGF0ZUdyZWF0ZXJUaGFuIjp7IkFX'
+                'UzpFcG9jaFRpbWUiOjE0NDg5MjgwMDB9fX1dfQ__'
+            '&Signature=c2lnbmVk&Key-Pair-Id=MY_KEY_ID')
+        self.assertEqualUrl(signed_url, expected)
+
+
 class TestS3PostPresigner(BaseSignerTest):
     def setUp(self):
         super(TestS3PostPresigner, self).setUp()
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 436b15db8..032228749 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -32,6 +32,7 @@ from botocore.utils import parse_key_val_file_contents
 from botocore.utils import parse_key_val_file
 from botocore.utils import parse_timestamp
 from botocore.utils import parse_to_aware_datetime
+from botocore.utils import datetime2timestamp
 from botocore.utils import CachedProperty
 from botocore.utils import ArgumentGenerator
 from botocore.utils import calculate_tree_hash
@@ -234,6 +235,18 @@ class TestParseTimestamps(unittest.TestCase):
             parse_timestamp('invalid date')
 
 
+class TestDatetime2Timestamp(unittest.TestCase):
+    def test_datetime2timestamp_naive(self):
+        self.assertEqual(
+            datetime2timestamp(datetime.datetime(1970, 1, 2)), 86400)
+
+    def test_datetime2timestamp_aware(self):
+        tzinfo = tzoffset("BRST", -10800)
+        self.assertEqual(
+            datetime2timestamp(datetime.datetime(1970, 1, 2, tzinfo=tzinfo)),
+            97200)
+
+
 class TestParseToUTCDatetime(unittest.TestCase):
     def test_handles_utc_time(self):
         original = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=tzutc())