diff options
Diffstat (limited to 'third_party/python/mohawk')
-rw-r--r-- | third_party/python/mohawk/PKG-INFO | 19 | ||||
-rw-r--r-- | third_party/python/mohawk/README.rst | 25 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk.egg-info/PKG-INFO | 19 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk.egg-info/SOURCES.txt | 15 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk.egg-info/dependency_links.txt | 1 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk.egg-info/requires.txt | 1 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk.egg-info/top_level.txt | 1 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/__init__.py | 2 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/base.py | 230 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/bewit.py | 167 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/exc.py | 98 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/receiver.py | 170 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/sender.py | 178 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/tests.py | 823 | ||||
-rw-r--r-- | third_party/python/mohawk/mohawk/util.py | 267 | ||||
-rw-r--r-- | third_party/python/mohawk/setup.cfg | 5 | ||||
-rw-r--r-- | third_party/python/mohawk/setup.py | 25 |
17 files changed, 2046 insertions, 0 deletions
diff --git a/third_party/python/mohawk/PKG-INFO b/third_party/python/mohawk/PKG-INFO new file mode 100644 index 0000000000..131f03cfc5 --- /dev/null +++ b/third_party/python/mohawk/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 1.1 +Name: mohawk +Version: 0.3.4 +Summary: Library for Hawk HTTP authorization +Home-page: https://github.com/kumar303/mohawk +Author: Kumar McMillan, Austin King +Author-email: kumar.mcmillan@gmail.com +License: MPL 2.0 (Mozilla Public License) +Description: UNKNOWN +Platform: UNKNOWN +Classifier: Intended Audience :: Developers +Classifier: Natural Language :: English +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 2.6 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.3 +Classifier: Topic :: Internet :: WWW/HTTP diff --git a/third_party/python/mohawk/README.rst b/third_party/python/mohawk/README.rst new file mode 100644 index 0000000000..e53a8f7e3e --- /dev/null +++ b/third_party/python/mohawk/README.rst @@ -0,0 +1,25 @@ +====== +Mohawk +====== +.. image:: https://img.shields.io/pypi/v/mohawk.svg + :target: https://pypi.python.org/pypi/mohawk + :alt: Latest PyPI release + +.. image:: https://img.shields.io/pypi/dm/mohawk.svg + :target: https://pypi.python.org/pypi/mohawk + :alt: PyPI monthly download stats + +.. image:: https://travis-ci.org/kumar303/mohawk.svg?branch=master + :target: https://travis-ci.org/kumar303/mohawk + :alt: Travis master branch status + +.. image:: https://readthedocs.org/projects/mohawk/badge/?version=latest + :target: https://mohawk.readthedocs.io/en/latest/?badge=latest + :alt: Documentation status + +Mohawk is an alternate Python implementation of the +`Hawk HTTP authorization scheme`_. + +Full documentation: https://mohawk.readthedocs.io/ + +.. _`Hawk HTTP authorization scheme`: https://github.com/hueniverse/hawk diff --git a/third_party/python/mohawk/mohawk.egg-info/PKG-INFO b/third_party/python/mohawk/mohawk.egg-info/PKG-INFO new file mode 100644 index 0000000000..131f03cfc5 --- /dev/null +++ b/third_party/python/mohawk/mohawk.egg-info/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 1.1 +Name: mohawk +Version: 0.3.4 +Summary: Library for Hawk HTTP authorization +Home-page: https://github.com/kumar303/mohawk +Author: Kumar McMillan, Austin King +Author-email: kumar.mcmillan@gmail.com +License: MPL 2.0 (Mozilla Public License) +Description: UNKNOWN +Platform: UNKNOWN +Classifier: Intended Audience :: Developers +Classifier: Natural Language :: English +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 2.6 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3.3 +Classifier: Topic :: Internet :: WWW/HTTP diff --git a/third_party/python/mohawk/mohawk.egg-info/SOURCES.txt b/third_party/python/mohawk/mohawk.egg-info/SOURCES.txt new file mode 100644 index 0000000000..880beddbc4 --- /dev/null +++ b/third_party/python/mohawk/mohawk.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +README.rst +setup.py +mohawk/__init__.py +mohawk/base.py +mohawk/bewit.py +mohawk/exc.py +mohawk/receiver.py +mohawk/sender.py +mohawk/tests.py +mohawk/util.py +mohawk.egg-info/PKG-INFO +mohawk.egg-info/SOURCES.txt +mohawk.egg-info/dependency_links.txt +mohawk.egg-info/requires.txt +mohawk.egg-info/top_level.txt
\ No newline at end of file diff --git a/third_party/python/mohawk/mohawk.egg-info/dependency_links.txt b/third_party/python/mohawk/mohawk.egg-info/dependency_links.txt new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/third_party/python/mohawk/mohawk.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/third_party/python/mohawk/mohawk.egg-info/requires.txt b/third_party/python/mohawk/mohawk.egg-info/requires.txt new file mode 100644 index 0000000000..ffe2fce498 --- /dev/null +++ b/third_party/python/mohawk/mohawk.egg-info/requires.txt @@ -0,0 +1 @@ +six diff --git a/third_party/python/mohawk/mohawk.egg-info/top_level.txt b/third_party/python/mohawk/mohawk.egg-info/top_level.txt new file mode 100644 index 0000000000..2b859fd06c --- /dev/null +++ b/third_party/python/mohawk/mohawk.egg-info/top_level.txt @@ -0,0 +1 @@ +mohawk diff --git a/third_party/python/mohawk/mohawk/__init__.py b/third_party/python/mohawk/mohawk/__init__.py new file mode 100644 index 0000000000..a79e7b7164 --- /dev/null +++ b/third_party/python/mohawk/mohawk/__init__.py @@ -0,0 +1,2 @@ +from .sender import * +from .receiver import * diff --git a/third_party/python/mohawk/mohawk/base.py b/third_party/python/mohawk/mohawk/base.py new file mode 100644 index 0000000000..4935110568 --- /dev/null +++ b/third_party/python/mohawk/mohawk/base.py @@ -0,0 +1,230 @@ +import logging +import math +import pprint + +import six +from six.moves.urllib.parse import urlparse + +from .exc import (AlreadyProcessed, + MacMismatch, + MisComputedContentHash, + TokenExpired) +from .util import (calculate_mac, + calculate_payload_hash, + calculate_ts_mac, + prepare_header_val, + random_string, + strings_match, + utc_now) + +default_ts_skew_in_seconds = 60 +log = logging.getLogger(__name__) + + +class HawkAuthority: + + def _authorize(self, mac_type, parsed_header, resource, + their_timestamp=None, + timestamp_skew_in_seconds=default_ts_skew_in_seconds, + localtime_offset_in_seconds=0, + accept_untrusted_content=False): + + now = utc_now(offset_in_seconds=localtime_offset_in_seconds) + + their_hash = parsed_header.get('hash', '') + their_mac = parsed_header.get('mac', '') + mac = calculate_mac(mac_type, resource, their_hash) + if not strings_match(mac, their_mac): + raise MacMismatch('MACs do not match; ours: {ours}; ' + 'theirs: {theirs}' + .format(ours=mac, theirs=their_mac)) + + if 'hash' not in parsed_header and accept_untrusted_content: + # The request did not hash its content. + log.debug('NOT calculating/verifiying payload hash ' + '(no hash in header)') + check_hash = False + content_hash = None + else: + check_hash = True + content_hash = resource.gen_content_hash() + + if check_hash and not their_hash: + log.info('request unexpectedly did not hash its content') + + if check_hash: + if not strings_match(content_hash, their_hash): + # The hash declared in the header is incorrect. + # Content could have been tampered with. + log.debug('mismatched content: {content}' + .format(content=repr(resource.content))) + log.debug('mismatched content-type: {typ}' + .format(typ=repr(resource.content_type))) + raise MisComputedContentHash( + 'Our hash {ours} ({algo}) did not ' + 'match theirs {theirs}' + .format(ours=content_hash, + theirs=their_hash, + algo=resource.credentials['algorithm'])) + + if resource.seen_nonce: + if resource.seen_nonce(resource.credentials['id'], + parsed_header['nonce'], + parsed_header['ts']): + raise AlreadyProcessed('Nonce {nonce} with timestamp {ts} ' + 'has already been processed for {id}' + .format(nonce=parsed_header['nonce'], + ts=parsed_header['ts'], + id=resource.credentials['id'])) + else: + log.warn('seen_nonce was None; not checking nonce. ' + 'You may be vulnerable to replay attacks') + + their_ts = int(their_timestamp or parsed_header['ts']) + + if math.fabs(their_ts - now) > timestamp_skew_in_seconds: + message = ('token with UTC timestamp {ts} has expired; ' + 'it was compared to {now}' + .format(ts=their_ts, now=now)) + tsm = calculate_ts_mac(now, resource.credentials) + if isinstance(tsm, six.binary_type): + tsm = tsm.decode('ascii') + www_authenticate = ('Hawk ts="{ts}", tsm="{tsm}", error="{error}"' + .format(ts=now, tsm=tsm, error=message)) + raise TokenExpired(message, + localtime_in_seconds=now, + www_authenticate=www_authenticate) + + log.debug('authorized OK') + + def _make_header(self, resource, mac, additional_keys=None): + keys = additional_keys + if not keys: + # These are the default header keys that you'd send with a + # request header. Response headers are odd because they + # exclude a bunch of keys. + keys = ('id', 'ts', 'nonce', 'ext', 'app', 'dlg') + + header = u'Hawk mac="{mac}"'.format(mac=prepare_header_val(mac)) + + if resource.content_hash: + header = u'{header}, hash="{hash}"'.format( + header=header, + hash=prepare_header_val(resource.content_hash)) + + if 'id' in keys: + header = u'{header}, id="{id}"'.format( + header=header, + id=prepare_header_val(resource.credentials['id'])) + + if 'ts' in keys: + header = u'{header}, ts="{ts}"'.format( + header=header, ts=prepare_header_val(resource.timestamp)) + + if 'nonce' in keys: + header = u'{header}, nonce="{nonce}"'.format( + header=header, nonce=prepare_header_val(resource.nonce)) + + # These are optional so we need to check if they have values first. + + if 'ext' in keys and resource.ext: + header = u'{header}, ext="{ext}"'.format( + header=header, ext=prepare_header_val(resource.ext)) + + if 'app' in keys and resource.app: + header = u'{header}, app="{app}"'.format( + header=header, app=prepare_header_val(resource.app)) + + if 'dlg' in keys and resource.dlg: + header = u'{header}, dlg="{dlg}"'.format( + header=header, dlg=prepare_header_val(resource.dlg)) + + log.debug('Hawk header for URL={url} method={method}: {header}' + .format(url=resource.url, method=resource.method, + header=header)) + return header + + +class Resource: + """ + Normalized request/response resource. + """ + + def __init__(self, **kw): + self.credentials = kw.pop('credentials') + self.method = kw.pop('method').upper() + self.content = kw.pop('content', None) + self.content_type = kw.pop('content_type', None) + self.always_hash_content = kw.pop('always_hash_content', True) + self.ext = kw.pop('ext', None) + self.app = kw.pop('app', None) + self.dlg = kw.pop('dlg', None) + + self.timestamp = str(kw.pop('timestamp', None) or utc_now()) + + self.nonce = kw.pop('nonce', None) + if self.nonce is None: + self.nonce = random_string(6) + + # This is a lookup function for checking nonces. + self.seen_nonce = kw.pop('seen_nonce', None) + + self.url = kw.pop('url') + if not self.url: + raise ValueError('url was empty') + url_parts = self.parse_url(self.url) + log.debug('parsed URL parts: \n{parts}' + .format(parts=pprint.pformat(url_parts))) + + self.name = url_parts['resource'] or '' + self.host = url_parts['hostname'] or '' + self.port = str(url_parts['port']) + + if kw.keys(): + raise TypeError('Unknown keyword argument(s): {0}' + .format(kw.keys())) + + @property + def content_hash(self): + if not hasattr(self, '_content_hash'): + raise AttributeError( + 'Cannot access content_hash because it has not been generated') + return self._content_hash + + def gen_content_hash(self): + if self.content is None or self.content_type is None: + if self.always_hash_content: + # Be really strict about allowing developers to skip content + # hashing. If they get this far they may be unintentiionally + # skipping it. + raise ValueError( + 'payload content and/or content_type cannot be ' + 'empty without an explicit allowance') + log.debug('NOT hashing content') + self._content_hash = None + else: + self._content_hash = calculate_payload_hash( + self.content, self.credentials['algorithm'], + self.content_type) + return self.content_hash + + def parse_url(self, url): + url_parts = urlparse(url) + url_dict = { + 'scheme': url_parts.scheme, + 'hostname': url_parts.hostname, + 'port': url_parts.port, + 'path': url_parts.path, + 'resource': url_parts.path, + 'query': url_parts.query, + } + if len(url_dict['query']) > 0: + url_dict['resource'] = '%s?%s' % (url_dict['resource'], + url_dict['query']) + + if url_parts.port is None: + if url_parts.scheme == 'http': + url_dict['port'] = 80 + elif url_parts.scheme == 'https': + url_dict['port'] = 443 + return url_dict diff --git a/third_party/python/mohawk/mohawk/bewit.py b/third_party/python/mohawk/mohawk/bewit.py new file mode 100644 index 0000000000..ec83923655 --- /dev/null +++ b/third_party/python/mohawk/mohawk/bewit.py @@ -0,0 +1,167 @@ +from base64 import urlsafe_b64encode, b64decode +from collections import namedtuple +import logging +import re + +import six + +from .base import Resource +from .util import (calculate_mac, + utc_now) +from .exc import (CredentialsLookupError, + InvalidBewit, + MacMismatch, + TokenExpired) + +log = logging.getLogger(__name__) + + +def get_bewit(resource): + """ + Returns a bewit identifier for the resource as a string. + + :param resource: + Resource to generate a bewit for + :type resource: `mohawk.base.Resource` + """ + if resource.method != 'GET': + raise ValueError('bewits can only be generated for GET requests') + if resource.nonce != '': + raise ValueError('bewits must use an empty nonce') + mac = calculate_mac( + 'bewit', + resource, + None, + ) + + if isinstance(mac, six.binary_type): + mac = mac.decode('ascii') + + if resource.ext is None: + ext = '' + else: + ext = resource.ext + + # Strip out \ from the client id + # since that can break parsing the response + # NB that the canonical implementation does not do this as of + # Oct 28, 2015, so this could break compat. + # We can leave \ in ext since validators can limit how many \ they split + # on (although again, the canonical implementation does not do this) + client_id = six.text_type(resource.credentials['id']) + if "\\" in client_id: + log.warn("Stripping backslash character(s) '\\' from client_id") + client_id = client_id.replace("\\", "") + + # b64encode works only with bytes in python3, but all of our parameters are + # in unicode, so we need to encode them. The cleanest way to do this that + # works in both python 2 and 3 is to use string formatting to get a + # unicode string, and then explicitly encode it to bytes. + inner_bewit = u"{id}\\{exp}\\{mac}\\{ext}".format( + id=client_id, + exp=resource.timestamp, + mac=mac, + ext=ext, + ) + inner_bewit_bytes = inner_bewit.encode('ascii') + bewit_bytes = urlsafe_b64encode(inner_bewit_bytes) + # Now decode the resulting bytes back to a unicode string + return bewit_bytes.decode('ascii') + + +bewittuple = namedtuple('bewittuple', 'id expiration mac ext') + + +def parse_bewit(bewit): + """ + Returns a `bewittuple` representing the parts of an encoded bewit string. + This has the following named attributes: + (id, expiration, mac, ext) + + :param bewit: + A base64 encoded bewit string + :type bewit: str + """ + decoded_bewit = b64decode(bewit).decode('ascii') + bewit_parts = decoded_bewit.split("\\", 3) + if len(bewit_parts) != 4: + raise InvalidBewit('Expected 4 parts to bewit: %s' % decoded_bewit) + return bewittuple(*decoded_bewit.split("\\", 3)) + + +def strip_bewit(url): + """ + Strips the bewit parameter out of a url. + + Returns (encoded_bewit, stripped_url) + + Raises InvalidBewit if no bewit found. + + :param url: + The url containing a bewit parameter + :type url: str + """ + m = re.search('[?&]bewit=([^&]+)', url) + if not m: + raise InvalidBewit('no bewit data found') + bewit = m.group(1) + stripped_url = url[:m.start()] + url[m.end():] + return bewit, stripped_url + + +def check_bewit(url, credential_lookup, now=None): + """ + Validates the given bewit. + + Returns True if the resource has a valid bewit parameter attached, + or raises a subclass of HawkFail otherwise. + + :param credential_lookup: + Callable to look up the credentials dict by sender ID. + The credentials dict must have the keys: + ``id``, ``key``, and ``algorithm``. + See :ref:`receiving-request` for an example. + :type credential_lookup: callable + + :param now=None: + Unix epoch time for the current time to determine if bewit has expired. + If None, then the current time as given by utc_now() is used. + :type now=None: integer + """ + raw_bewit, stripped_url = strip_bewit(url) + bewit = parse_bewit(raw_bewit) + try: + credentials = credential_lookup(bewit.id) + except LookupError: + raise CredentialsLookupError('Could not find credentials for ID {0}' + .format(bewit.id)) + + res = Resource(url=stripped_url, + method='GET', + credentials=credentials, + timestamp=bewit.expiration, + nonce='', + ext=bewit.ext, + ) + mac = calculate_mac('bewit', res, None) + mac = mac.decode('ascii') + + if mac != bewit.mac: + raise MacMismatch('bewit with mac {bewit_mac} did not match expected mac {expected_mac}' + .format(bewit_mac=bewit.mac, + expected_mac=mac)) + + # Check that the timestamp isn't expired + if now is None: + # TODO: Add offset/skew + now = utc_now() + if int(bewit.expiration) < now: + # TODO: Refactor TokenExpired to handle this better + raise TokenExpired('bewit with UTC timestamp {ts} has expired; ' + 'it was compared to {now}' + .format(ts=bewit.expiration, now=now), + localtime_in_seconds=now, + www_authenticate='' + ) + + return True diff --git a/third_party/python/mohawk/mohawk/exc.py b/third_party/python/mohawk/mohawk/exc.py new file mode 100644 index 0000000000..9376995f2c --- /dev/null +++ b/third_party/python/mohawk/mohawk/exc.py @@ -0,0 +1,98 @@ +""" +If you want to catch any exception that might be raised, +catch :class:`mohawk.exc.HawkFail`. +""" + + +class HawkFail(Exception): + """ + All Mohawk exceptions derive from this base. + """ + + +class MissingAuthorization(HawkFail): + """ + No authorization header was sent by the client. + """ + + +class InvalidCredentials(HawkFail): + """ + The specified Hawk credentials are invalid. + + For example, the dict could be formatted incorrectly. + """ + + +class CredentialsLookupError(HawkFail): + """ + A :class:`mohawk.Receiver` could not look up the + credentials for an incoming request. + """ + + +class BadHeaderValue(HawkFail): + """ + There was an error with an attribute or value when parsing + or creating a Hawk header. + """ + + +class MacMismatch(HawkFail): + """ + The locally calculated MAC did not match the MAC that was sent. + """ + + +class MisComputedContentHash(HawkFail): + """ + The signature of the content did not match the actual content. + """ + + +class TokenExpired(HawkFail): + """ + The timestamp on a message received has expired. + + You may also receive this message if your server clock is out of sync. + Consider synchronizing it with something like `TLSdate`_. + + If you are unable to synchronize your clock universally, + The `Hawk`_ spec mentions how you can `adjust`_ + your sender's time to match that of the receiver in the case + of unexpected expiration. + + The ``www_authenticate`` attribute of this exception is a header + that can be returned to the client. If the value is not None, it + will include a timestamp HMAC'd with the sender's credentials. + This will allow the client + to verify the value and safely apply an offset. + + .. _`Hawk`: https://github.com/hueniverse/hawk + .. _`adjust`: https://github.com/hueniverse/hawk#future-time-manipulation + .. _`TLSdate`: http://linux-audit.com/tlsdate-the-secure-alternative-for-ntpd-ntpdate-and-rdate/ + """ + #: Current local time in seconds that was used to compare timestamps. + localtime_in_seconds = None + # A header containing an HMAC'd server timestamp that the sender can verify. + www_authenticate = None + + def __init__(self, *args, **kw): + self.localtime_in_seconds = kw.pop('localtime_in_seconds') + self.www_authenticate = kw.pop('www_authenticate') + super(HawkFail, self).__init__(*args, **kw) + + +class AlreadyProcessed(HawkFail): + """ + The message has already been processed and cannot be re-processed. + + See :ref:`nonce` for details. + """ + + +class InvalidBewit(HawkFail): + """ + The bewit is invalid; e.g. it doesn't contain the right number of + parameters. + """ diff --git a/third_party/python/mohawk/mohawk/receiver.py b/third_party/python/mohawk/mohawk/receiver.py new file mode 100644 index 0000000000..509729ea8d --- /dev/null +++ b/third_party/python/mohawk/mohawk/receiver.py @@ -0,0 +1,170 @@ +import logging +import sys + +from .base import default_ts_skew_in_seconds, HawkAuthority, Resource +from .exc import CredentialsLookupError, MissingAuthorization +from .util import (calculate_mac, + parse_authorization_header, + validate_credentials) + +__all__ = ['Receiver'] +log = logging.getLogger(__name__) + + +class Receiver(HawkAuthority): + """ + A Hawk authority that will receive and respond to requests. + + :param credentials_map: + Callable to look up the credentials dict by sender ID. + The credentials dict must have the keys: + ``id``, ``key``, and ``algorithm``. + See :ref:`receiving-request` for an example. + :type credentials_map: callable + + :param request_header: + A `Hawk`_ ``Authorization`` header + such as one created by :class:`mohawk.Sender`. + :type request_header: str + + :param url: Absolute URL of the request. + :type url: str + + :param method: Method of the request. E.G. POST, GET + :type method: str + + :param content=None: Byte string of request body. + :type content=None: str + + :param content_type=None: content-type header value for request. + :type content_type=None: str + + :param accept_untrusted_content=False: + When True, allow requests that do not hash their content or + allow None type ``content`` and ``content_type`` + arguments. Read :ref:`skipping-content-checks` + to learn more. + :type accept_untrusted_content=False: bool + + :param localtime_offset_in_seconds=0: + Seconds to add to local time in case it's out of sync. + :type localtime_offset_in_seconds=0: float + + :param timestamp_skew_in_seconds=60: + Max seconds until a message expires. Upon expiry, + :class:`mohawk.exc.TokenExpired` is raised. + :type timestamp_skew_in_seconds=60: float + + .. _`Hawk`: https://github.com/hueniverse/hawk + """ + #: Value suitable for a ``Server-Authorization`` header. + response_header = None + + def __init__(self, + credentials_map, + request_header, + url, + method, + content=None, + content_type=None, + seen_nonce=None, + localtime_offset_in_seconds=0, + accept_untrusted_content=False, + timestamp_skew_in_seconds=default_ts_skew_in_seconds, + **auth_kw): + + self.response_header = None # make into property that can raise exc? + self.credentials_map = credentials_map + self.seen_nonce = seen_nonce + + log.debug('accepting request {header}'.format(header=request_header)) + + if not request_header: + raise MissingAuthorization() + + parsed_header = parse_authorization_header(request_header) + + try: + credentials = self.credentials_map(parsed_header['id']) + except LookupError: + etype, val, tb = sys.exc_info() + log.debug('Catching {etype}: {val}'.format(etype=etype, val=val)) + raise CredentialsLookupError( + 'Could not find credentials for ID {0}' + .format(parsed_header['id'])) + validate_credentials(credentials) + + resource = Resource(url=url, + method=method, + ext=parsed_header.get('ext', None), + app=parsed_header.get('app', None), + dlg=parsed_header.get('dlg', None), + credentials=credentials, + nonce=parsed_header['nonce'], + seen_nonce=self.seen_nonce, + content=content, + timestamp=parsed_header['ts'], + content_type=content_type) + + self._authorize( + 'header', parsed_header, resource, + timestamp_skew_in_seconds=timestamp_skew_in_seconds, + localtime_offset_in_seconds=localtime_offset_in_seconds, + accept_untrusted_content=accept_untrusted_content, + **auth_kw) + + # Now that we verified an incoming request, we can re-use some of its + # properties to build our response header. + + self.parsed_header = parsed_header + self.resource = resource + + def respond(self, + content=None, + content_type=None, + always_hash_content=True, + ext=None): + """ + Respond to the request. + + This generates the :attr:`mohawk.Receiver.response_header` + attribute. + + :param content=None: Byte string of response body that will be sent. + :type content=None: str + + :param content_type=None: content-type header value for response. + :type content_type=None: str + + :param always_hash_content=True: + When True, ``content`` and ``content_type`` cannot be None. + Read :ref:`skipping-content-checks` to learn more. + :type always_hash_content=True: bool + + :param ext=None: + An external `Hawk`_ string. If not None, this value will be + signed so that the sender can trust it. + :type ext=None: str + + .. _`Hawk`: https://github.com/hueniverse/hawk + """ + + log.debug('generating response header') + + resource = Resource(url=self.resource.url, + credentials=self.resource.credentials, + ext=ext, + app=self.parsed_header.get('app', None), + dlg=self.parsed_header.get('dlg', None), + method=self.resource.method, + content=content, + content_type=content_type, + always_hash_content=always_hash_content, + nonce=self.parsed_header['nonce'], + timestamp=self.parsed_header['ts']) + + mac = calculate_mac('response', resource, resource.gen_content_hash()) + + self.response_header = self._make_header(resource, mac, + additional_keys=['ext']) + return self.response_header diff --git a/third_party/python/mohawk/mohawk/sender.py b/third_party/python/mohawk/mohawk/sender.py new file mode 100644 index 0000000000..b6f3edc170 --- /dev/null +++ b/third_party/python/mohawk/mohawk/sender.py @@ -0,0 +1,178 @@ +import logging + +from .base import default_ts_skew_in_seconds, HawkAuthority, Resource +from .util import (calculate_mac, + parse_authorization_header, + validate_credentials) + +__all__ = ['Sender'] +log = logging.getLogger(__name__) + + +class Sender(HawkAuthority): + """ + A Hawk authority that will emit requests and verify responses. + + :param credentials: Dict of credentials with keys ``id``, ``key``, + and ``algorithm``. See :ref:`usage` for an example. + :type credentials: dict + + :param url: Absolute URL of the request. + :type url: str + + :param method: Method of the request. E.G. POST, GET + :type method: str + + :param content=None: Byte string of request body. + :type content=None: str + + :param content_type=None: content-type header value for request. + :type content_type=None: str + + :param always_hash_content=True: + When True, ``content`` and ``content_type`` cannot be None. + Read :ref:`skipping-content-checks` to learn more. + :type always_hash_content=True: bool + + :param nonce=None: + A string that when coupled with the timestamp will + uniquely identify this request to prevent replays. + If None, a nonce will be generated for you. + :type nonce=None: str + + :param ext=None: + An external `Hawk`_ string. If not None, this value will be signed + so that the receiver can trust it. + :type ext=None: str + + :param app=None: + A `Hawk`_ application string. If not None, this value will be signed + so that the receiver can trust it. + :type app=None: str + + :param dlg=None: + A `Hawk`_ delegation string. If not None, this value will be signed + so that the receiver can trust it. + :type dlg=None: str + + :param seen_nonce=None: + A callable that returns True if a nonce has been seen. + See :ref:`nonce` for details. + :type seen_nonce=None: callable + + .. _`Hawk`: https://github.com/hueniverse/hawk + """ + #: Value suitable for an ``Authorization`` header. + request_header = None + + def __init__(self, credentials, + url, + method, + content=None, + content_type=None, + always_hash_content=True, + nonce=None, + ext=None, + app=None, + dlg=None, + seen_nonce=None, + # For easier testing: + _timestamp=None): + + self.reconfigure(credentials) + self.request_header = None + self.seen_nonce = seen_nonce + + log.debug('generating request header') + self.req_resource = Resource(url=url, + credentials=self.credentials, + ext=ext, + app=app, + dlg=dlg, + nonce=nonce, + method=method, + content=content, + always_hash_content=always_hash_content, + timestamp=_timestamp, + content_type=content_type) + + mac = calculate_mac('header', self.req_resource, + self.req_resource.gen_content_hash()) + self.request_header = self._make_header(self.req_resource, mac) + + def accept_response(self, + response_header, + content=None, + content_type=None, + accept_untrusted_content=False, + localtime_offset_in_seconds=0, + timestamp_skew_in_seconds=default_ts_skew_in_seconds, + **auth_kw): + """ + Accept a response to this request. + + :param response_header: + A `Hawk`_ ``Server-Authorization`` header + such as one created by :class:`mohawk.Receiver`. + :type response_header: str + + :param content=None: Byte string of the response body received. + :type content=None: str + + :param content_type=None: + Content-Type header value of the response received. + :type content_type=None: str + + :param accept_untrusted_content=False: + When True, allow responses that do not hash their content or + allow None type ``content`` and ``content_type`` + arguments. Read :ref:`skipping-content-checks` + to learn more. + :type accept_untrusted_content=False: bool + + :param localtime_offset_in_seconds=0: + Seconds to add to local time in case it's out of sync. + :type localtime_offset_in_seconds=0: float + + :param timestamp_skew_in_seconds=60: + Max seconds until a message expires. Upon expiry, + :class:`mohawk.exc.TokenExpired` is raised. + :type timestamp_skew_in_seconds=60: float + + .. _`Hawk`: https://github.com/hueniverse/hawk + """ + log.debug('accepting response {header}' + .format(header=response_header)) + + parsed_header = parse_authorization_header(response_header) + + resource = Resource(ext=parsed_header.get('ext', None), + content=content, + content_type=content_type, + # The following response attributes are + # in reference to the original request, + # not to the reponse header: + timestamp=self.req_resource.timestamp, + nonce=self.req_resource.nonce, + url=self.req_resource.url, + method=self.req_resource.method, + app=self.req_resource.app, + dlg=self.req_resource.dlg, + credentials=self.credentials, + seen_nonce=self.seen_nonce) + + self._authorize( + 'response', parsed_header, resource, + # Per Node lib, a responder macs the *sender's* timestamp. + # It does not create its own timestamp. + # I suppose a slow response could time out here. Maybe only check + # mac failures, not timeouts? + their_timestamp=resource.timestamp, + timestamp_skew_in_seconds=timestamp_skew_in_seconds, + localtime_offset_in_seconds=localtime_offset_in_seconds, + accept_untrusted_content=accept_untrusted_content, + **auth_kw) + + def reconfigure(self, credentials): + validate_credentials(credentials) + self.credentials = credentials diff --git a/third_party/python/mohawk/mohawk/tests.py b/third_party/python/mohawk/mohawk/tests.py new file mode 100644 index 0000000000..eeb71506d1 --- /dev/null +++ b/third_party/python/mohawk/mohawk/tests.py @@ -0,0 +1,823 @@ +import sys +from unittest import TestCase +from base64 import b64decode, urlsafe_b64encode + +import mock +from nose.tools import eq_, raises +import six + +from . import Receiver, Sender +from .base import Resource +from .exc import (AlreadyProcessed, + BadHeaderValue, + CredentialsLookupError, + InvalidCredentials, + MacMismatch, + MisComputedContentHash, + MissingAuthorization, + TokenExpired, + InvalidBewit) +from .util import (parse_authorization_header, + utc_now, + calculate_ts_mac, + validate_credentials) +from .bewit import (get_bewit, + check_bewit, + strip_bewit, + parse_bewit) + + +class Base(TestCase): + + def setUp(self): + self.credentials = { + 'id': 'my-hawk-id', + 'key': 'my hAwK sekret', + 'algorithm': 'sha256', + } + + # This callable might be replaced by tests. + def seen_nonce(id, nonce, ts): + return False + self.seen_nonce = seen_nonce + + def credentials_map(self, id): + # Pretend this is doing something more interesting like looking up + # a credentials by ID in a database. + if self.credentials['id'] != id: + raise LookupError('No credentialsuration for Hawk ID {id}' + .format(id=id)) + return self.credentials + + +class TestConfig(Base): + + @raises(InvalidCredentials) + def test_no_id(self): + c = self.credentials.copy() + del c['id'] + validate_credentials(c) + + @raises(InvalidCredentials) + def test_no_key(self): + c = self.credentials.copy() + del c['key'] + validate_credentials(c) + + @raises(InvalidCredentials) + def test_no_algo(self): + c = self.credentials.copy() + del c['algorithm'] + validate_credentials(c) + + @raises(InvalidCredentials) + def test_no_credentials(self): + validate_credentials(None) + + def test_non_dict_credentials(self): + class WeirdThing(object): + def __getitem__(self, key): + return 'whatever' + validate_credentials(WeirdThing()) + + +class TestSender(Base): + + def setUp(self): + super(TestSender, self).setUp() + self.url = 'http://site.com/foo?bar=1' + + def Sender(self, method='GET', **kw): + credentials = kw.pop('credentials', self.credentials) + kw.setdefault('content', '') + kw.setdefault('content_type', '') + sender = Sender(credentials, self.url, method, **kw) + return sender + + def receive(self, request_header, url=None, method='GET', **kw): + credentials_map = kw.pop('credentials_map', self.credentials_map) + kw.setdefault('content', '') + kw.setdefault('content_type', '') + kw.setdefault('seen_nonce', self.seen_nonce) + return Receiver(credentials_map, request_header, + url or self.url, method, **kw) + + def test_get_ok(self): + method = 'GET' + sn = self.Sender(method=method) + self.receive(sn.request_header, method=method) + + def test_post_ok(self): + method = 'POST' + sn = self.Sender(method=method) + self.receive(sn.request_header, method=method) + + def test_post_content_ok(self): + method = 'POST' + content = 'foo=bar&baz=2' + sn = self.Sender(method=method, content=content) + self.receive(sn.request_header, method=method, content=content) + + def test_post_content_type_ok(self): + method = 'POST' + content = '{"bar": "foobs"}' + content_type = 'application/json' + sn = self.Sender(method=method, content=content, + content_type=content_type) + self.receive(sn.request_header, method=method, content=content, + content_type=content_type) + + def test_post_content_type_with_trailing_charset(self): + method = 'POST' + content = '{"bar": "foobs"}' + content_type = 'application/json; charset=utf8' + sn = self.Sender(method=method, content=content, + content_type=content_type) + self.receive(sn.request_header, method=method, content=content, + content_type='application/json; charset=other') + + @raises(ValueError) + def test_missing_payload_details(self): + self.Sender(method='POST', content=None, content_type=None) + + def test_skip_payload_hashing(self): + method = 'POST' + content = '{"bar": "foobs"}' + content_type = 'application/json' + sn = self.Sender(method=method, content=None, content_type=None, + always_hash_content=False) + self.receive(sn.request_header, method=method, content=content, + content_type=content_type, + accept_untrusted_content=True) + + @raises(ValueError) + def test_cannot_skip_content_only(self): + self.Sender(method='POST', content=None, + content_type='application/json') + + @raises(ValueError) + def test_cannot_skip_content_type_only(self): + self.Sender(method='POST', content='{"foo": "bar"}', + content_type=None) + + @raises(MacMismatch) + def test_tamper_with_host(self): + sn = self.Sender() + self.receive(sn.request_header, url='http://TAMPERED-WITH.com') + + @raises(MacMismatch) + def test_tamper_with_method(self): + sn = self.Sender(method='GET') + self.receive(sn.request_header, method='POST') + + @raises(MacMismatch) + def test_tamper_with_path(self): + sn = self.Sender() + self.receive(sn.request_header, + url='http://site.com/TAMPERED?bar=1') + + @raises(MacMismatch) + def test_tamper_with_query(self): + sn = self.Sender() + self.receive(sn.request_header, + url='http://site.com/foo?bar=TAMPERED') + + @raises(MacMismatch) + def test_tamper_with_scheme(self): + sn = self.Sender() + self.receive(sn.request_header, url='https://site.com/foo?bar=1') + + @raises(MacMismatch) + def test_tamper_with_port(self): + sn = self.Sender() + self.receive(sn.request_header, + url='http://site.com:8000/foo?bar=1') + + @raises(MisComputedContentHash) + def test_tamper_with_content(self): + sn = self.Sender() + self.receive(sn.request_header, content='stuff=nope') + + def test_non_ascii_content(self): + content = u'Ivan Kristi\u0107' + sn = self.Sender(content=content) + self.receive(sn.request_header, content=content) + + @raises(MacMismatch) + def test_tamper_with_content_type(self): + sn = self.Sender(method='POST') + self.receive(sn.request_header, content_type='application/json') + + @raises(AlreadyProcessed) + def test_nonce_fail(self): + + def seen_nonce(id, nonce, ts): + return True + + sn = self.Sender() + + self.receive(sn.request_header, seen_nonce=seen_nonce) + + def test_nonce_ok(self): + + def seen_nonce(id, nonce, ts): + return False + + sn = self.Sender(seen_nonce=seen_nonce) + self.receive(sn.request_header) + + @raises(TokenExpired) + def test_expired_ts(self): + now = utc_now() - 120 + sn = self.Sender(_timestamp=now) + self.receive(sn.request_header) + + def test_expired_exception_reports_localtime(self): + now = utc_now() + ts = now - 120 + sn = self.Sender(_timestamp=ts) # force expiry + + exc = None + with mock.patch('mohawk.base.utc_now') as fake_now: + fake_now.return_value = now + try: + self.receive(sn.request_header) + except: + etype, exc, tb = sys.exc_info() + + eq_(type(exc), TokenExpired) + eq_(exc.localtime_in_seconds, now) + + def test_localtime_offset(self): + now = utc_now() - 120 + sn = self.Sender(_timestamp=now) + # Without an offset this will raise an expired exception. + self.receive(sn.request_header, localtime_offset_in_seconds=-120) + + def test_localtime_skew(self): + now = utc_now() - 120 + sn = self.Sender(_timestamp=now) + # Without an offset this will raise an expired exception. + self.receive(sn.request_header, timestamp_skew_in_seconds=120) + + @raises(MacMismatch) + def test_hash_tampering(self): + sn = self.Sender() + header = sn.request_header.replace('hash="', 'hash="nope') + self.receive(header) + + @raises(MacMismatch) + def test_bad_secret(self): + cfg = { + 'id': 'my-hawk-id', + 'key': 'INCORRECT; YOU FAIL', + 'algorithm': 'sha256', + } + sn = self.Sender(credentials=cfg) + self.receive(sn.request_header) + + @raises(MacMismatch) + def test_unexpected_algorithm(self): + cr = self.credentials.copy() + cr['algorithm'] = 'sha512' + sn = self.Sender(credentials=cr) + + # Validate with mismatched credentials (sha256). + self.receive(sn.request_header) + + @raises(InvalidCredentials) + def test_invalid_credentials(self): + cfg = self.credentials.copy() + # Create an invalid credentials. + del cfg['algorithm'] + + self.Sender(credentials=cfg) + + @raises(CredentialsLookupError) + def test_unknown_id(self): + cr = self.credentials.copy() + cr['id'] = 'someone-else' + sn = self.Sender(credentials=cr) + + self.receive(sn.request_header) + + @raises(MacMismatch) + def test_bad_ext(self): + sn = self.Sender(ext='my external data') + + header = sn.request_header.replace('my external data', 'TAMPERED') + self.receive(header) + + def test_ext_with_quotes(self): + sn = self.Sender(ext='quotes=""') + self.receive(sn.request_header) + parsed = parse_authorization_header(sn.request_header) + eq_(parsed['ext'], 'quotes=""') + + def test_ext_with_new_line(self): + sn = self.Sender(ext="new line \n in the middle") + self.receive(sn.request_header) + parsed = parse_authorization_header(sn.request_header) + eq_(parsed['ext'], "new line \n in the middle") + + def test_ext_with_equality_sign(self): + sn = self.Sender(ext="foo=bar&foo2=bar2;foo3=bar3") + self.receive(sn.request_header) + parsed = parse_authorization_header(sn.request_header) + eq_(parsed['ext'], "foo=bar&foo2=bar2;foo3=bar3") + + @raises(BadHeaderValue) + def test_ext_with_illegal_chars(self): + self.Sender(ext="something like \t is illegal") + + @raises(BadHeaderValue) + def test_ext_with_illegal_unicode(self): + self.Sender(ext=u'Ivan Kristi\u0107') + + @raises(BadHeaderValue) + def test_ext_with_illegal_utf8(self): + # This isn't allowed because the escaped byte chars are out of + # range. It's a little odd but this is what the Node lib does + # implicitly with its regex. + self.Sender(ext=u'Ivan Kristi\u0107'.encode('utf8')) + + def test_app_ok(self): + app = 'custom-app' + sn = self.Sender(app=app) + self.receive(sn.request_header) + parsed = parse_authorization_header(sn.request_header) + eq_(parsed['app'], app) + + @raises(MacMismatch) + def test_tampered_app(self): + app = 'custom-app' + sn = self.Sender(app=app) + header = sn.request_header.replace(app, 'TAMPERED-WITH') + self.receive(header) + + def test_dlg_ok(self): + dlg = 'custom-dlg' + sn = self.Sender(dlg=dlg) + self.receive(sn.request_header) + parsed = parse_authorization_header(sn.request_header) + eq_(parsed['dlg'], dlg) + + @raises(MacMismatch) + def test_tampered_dlg(self): + dlg = 'custom-dlg' + sn = self.Sender(dlg=dlg, app='some-app') + header = sn.request_header.replace(dlg, 'TAMPERED-WITH') + self.receive(header) + + +class TestReceiver(Base): + + def setUp(self): + super(TestReceiver, self).setUp() + self.url = 'http://site.com/' + self.sender = None + self.receiver = None + + def receive(self, method='GET', **kw): + url = kw.pop('url', self.url) + sender = kw.pop('sender', None) + sender_kw = kw.pop('sender_kw', {}) + sender_kw.setdefault('content', '') + sender_kw.setdefault('content_type', '') + sender_url = kw.pop('sender_url', url) + + credentials_map = kw.pop('credentials_map', + lambda id: self.credentials) + + if sender: + self.sender = sender + else: + self.sender = Sender(self.credentials, sender_url, method, + **sender_kw) + + kw.setdefault('content', '') + kw.setdefault('content_type', '') + self.receiver = Receiver(credentials_map, + self.sender.request_header, url, method, + **kw) + + def respond(self, **kw): + accept_kw = kw.pop('accept_kw', {}) + accept_kw.setdefault('content', '') + accept_kw.setdefault('content_type', '') + receiver = kw.pop('receiver', self.receiver) + + kw.setdefault('content', '') + kw.setdefault('content_type', '') + receiver.respond(**kw) + self.sender.accept_response(receiver.response_header, **accept_kw) + + return receiver.response_header + + @raises(InvalidCredentials) + def test_invalid_credentials_lookup(self): + # Return invalid credentials. + self.receive(credentials_map=lambda *a: {}) + + def test_get_ok(self): + method = 'GET' + self.receive(method=method) + self.respond() + + def test_post_ok(self): + method = 'POST' + self.receive(method=method) + self.respond() + + @raises(MisComputedContentHash) + def test_respond_with_wrong_content(self): + self.receive() + self.respond(content='real content', + accept_kw=dict(content='TAMPERED WITH')) + + @raises(MisComputedContentHash) + def test_respond_with_wrong_content_type(self): + self.receive() + self.respond(content_type='text/html', + accept_kw=dict(content_type='application/json')) + + @raises(MissingAuthorization) + def test_missing_authorization(self): + Receiver(lambda id: self.credentials, None, '/', 'GET') + + @raises(MacMismatch) + def test_respond_with_wrong_url(self): + self.receive(url='http://fakesite.com') + wrong_receiver = self.receiver + + self.receive(url='http://realsite.com') + + self.respond(receiver=wrong_receiver) + + @raises(MacMismatch) + def test_respond_with_wrong_method(self): + self.receive(method='GET') + wrong_receiver = self.receiver + + self.receive(method='POST') + + self.respond(receiver=wrong_receiver) + + @raises(MacMismatch) + def test_respond_with_wrong_nonce(self): + self.receive(sender_kw=dict(nonce='another-nonce')) + wrong_receiver = self.receiver + + self.receive() + + # The nonce must match the one sent in the original request. + self.respond(receiver=wrong_receiver) + + def test_respond_with_unhashed_content(self): + self.receive() + + self.respond(always_hash_content=False, content=None, + content_type=None, + accept_kw=dict(accept_untrusted_content=True)) + + @raises(TokenExpired) + def test_respond_with_expired_ts(self): + self.receive() + hdr = self.receiver.respond(content='', content_type='') + + with mock.patch('mohawk.base.utc_now') as fn: + fn.return_value = 0 # force an expiry + try: + self.sender.accept_response(hdr, content='', content_type='') + except TokenExpired: + etype, exc, tb = sys.exc_info() + hdr = parse_authorization_header(exc.www_authenticate) + calculated = calculate_ts_mac(fn(), self.credentials) + if isinstance(calculated, six.binary_type): + calculated = calculated.decode('ascii') + eq_(hdr['tsm'], calculated) + raise + + def test_respond_with_bad_ts_skew_ok(self): + now = utc_now() - 120 + + self.receive() + hdr = self.receiver.respond(content='', content_type='') + + with mock.patch('mohawk.base.utc_now') as fn: + fn.return_value = now + + # Without an offset this will raise an expired exception. + self.sender.accept_response(hdr, content='', content_type='', + timestamp_skew_in_seconds=120) + + def test_respond_with_ext(self): + self.receive() + + ext = 'custom-ext' + self.respond(ext=ext) + header = parse_authorization_header(self.receiver.response_header) + eq_(header['ext'], ext) + + @raises(MacMismatch) + def test_respond_with_wrong_app(self): + self.receive(sender_kw=dict(app='TAMPERED-WITH', dlg='delegation')) + self.receiver.respond(content='', content_type='') + wrong_receiver = self.receiver + + self.receive(sender_kw=dict(app='real-app', dlg='delegation')) + + self.sender.accept_response(wrong_receiver.response_header, + content='', content_type='') + + @raises(MacMismatch) + def test_respond_with_wrong_dlg(self): + self.receive(sender_kw=dict(app='app', dlg='TAMPERED-WITH')) + self.receiver.respond(content='', content_type='') + wrong_receiver = self.receiver + + self.receive(sender_kw=dict(app='app', dlg='real-dlg')) + + self.sender.accept_response(wrong_receiver.response_header, + content='', content_type='') + + @raises(MacMismatch) + def test_receive_wrong_method(self): + self.receive(method='GET') + wrong_sender = self.sender + self.receive(method='POST', sender=wrong_sender) + + @raises(MacMismatch) + def test_receive_wrong_url(self): + self.receive(url='http://fakesite.com/') + wrong_sender = self.sender + self.receive(url='http://realsite.com/', sender=wrong_sender) + + @raises(MisComputedContentHash) + def test_receive_wrong_content(self): + self.receive(sender_kw=dict(content='real request'), + content='real request') + wrong_sender = self.sender + self.receive(content='TAMPERED WITH', sender=wrong_sender) + + @raises(MisComputedContentHash) + def test_unexpected_unhashed_content(self): + self.receive(sender_kw=dict(content=None, content_type=None, + always_hash_content=False)) + + @raises(ValueError) + def test_cannot_receive_empty_content_only(self): + content_type = 'text/plain' + self.receive(sender_kw=dict(content='<content>', + content_type=content_type), + content=None, content_type=content_type) + + @raises(ValueError) + def test_cannot_receive_empty_content_type_only(self): + content = '<content>' + self.receive(sender_kw=dict(content=content, + content_type='text/plain'), + content=content, content_type=None) + + @raises(MisComputedContentHash) + def test_receive_wrong_content_type(self): + self.receive(sender_kw=dict(content_type='text/html'), + content_type='text/html') + wrong_sender = self.sender + + self.receive(content_type='application/json', + sender=wrong_sender) + + +class TestSendAndReceive(Base): + + def test(self): + credentials = { + 'id': 'some-id', + 'key': 'some secret', + 'algorithm': 'sha256' + } + + url = 'https://my-site.com/' + method = 'POST' + + # The client sends a request with a Hawk header. + content = 'foo=bar&baz=nooz' + content_type = 'application/x-www-form-urlencoded' + + sender = Sender(credentials, + url, method, + content=content, + content_type=content_type) + + # The server receives a request and authorizes access. + receiver = Receiver(lambda id: credentials, + sender.request_header, + url, method, + content=content, + content_type=content_type) + + # The server responds with a similar Hawk header. + content = 'we are friends' + content_type = 'text/plain' + receiver.respond(content=content, + content_type=content_type) + + # The client receives a response and authorizes access. + sender.accept_response(receiver.response_header, + content=content, + content_type=content_type) + + +class TestBewit(Base): + + # Test cases copied from + # https://github.com/hueniverse/hawk/blob/492632da51ecedd5f59ce96f081860ad24ce6532/test/uri.js + + def setUp(self): + self.credentials = { + 'id': '123456', + 'key': '2983d45yun89q', + 'algorithm': 'sha256', + } + + def make_credential_lookup(self, credentials_map): + # Helper function to make a lookup function given a dictionary of + # credentials + def lookup(client_id): + # Will raise a KeyError if missing; which is a subclass of + # LookupError + return credentials_map[client_id] + return lookup + + def test_bewit(self): + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, + nonce='', + ) + bewit = get_bewit(res) + + expected = '123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + eq_(b64decode(bewit).decode('ascii'), expected) + + def test_bewit_with_binary_id(self): + # Check for exceptions in get_bewit call with binary id + binary_credentials = self.credentials.copy() + binary_credentials['id'] = binary_credentials['id'].encode('ascii') + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=binary_credentials, + timestamp=1356420407 + 300, + nonce='', + ) + get_bewit(res) + + def test_bewit_with_ext(self): + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, + nonce='', + ext='xandyandz' + ) + bewit = get_bewit(res) + + expected = '123456\\1356420707\\kscxwNR2tJpP1T1zDLNPbB5UiKIU9tOSJXTUdG7X9h8=\\xandyandz' + eq_(b64decode(bewit).decode('ascii'), expected) + + def test_bewit_with_ext_and_backslashes(self): + credentials = self.credentials + credentials['id'] = '123\\456' + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, + nonce='', + ext='xand\\yandz' + ) + bewit = get_bewit(res) + + expected = '123456\\1356420707\\b82LLIxG5UDkaChLU953mC+SMrbniV1sb8KiZi9cSsc=\\xand\\yandz' + eq_(b64decode(bewit).decode('ascii'), expected) + + def test_bewit_with_port(self): + res = Resource(url='https://example.com:8080/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, nonce='', ext='xandyandz') + bewit = get_bewit(res) + + expected = '123456\\1356420707\\hZbJ3P2cKEo4ky0C8jkZAkRyCZueg4WSNbxV7vq3xHU=\\xandyandz' + eq_(b64decode(bewit).decode('ascii'), expected) + + @raises(ValueError) + def test_bewit_with_nonce(self): + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, + nonce='n1') + get_bewit(res) + + @raises(ValueError) + def test_bewit_invalid_method(self): + res = Resource(url='https://example.com:8080/somewhere/over/the/rainbow', + method='POST', credentials=self.credentials, + timestamp=1356420407 + 300, nonce='') + get_bewit(res) + + def test_strip_bewit(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + + raw_bewit, stripped_url = strip_bewit(url) + self.assertEquals(raw_bewit, bewit) + self.assertEquals(stripped_url, "https://example.com/somewhere/over/the/rainbow") + + @raises(InvalidBewit) + def test_strip_url_without_bewit(self): + url = "https://example.com/somewhere/over/the/rainbow" + strip_bewit(url) + + def test_parse_bewit(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + bewit = urlsafe_b64encode(bewit).decode('ascii') + bewit = parse_bewit(bewit) + self.assertEquals(bewit.id, '123456') + self.assertEquals(bewit.expiration, '1356420707') + self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') + self.assertEquals(bewit.ext, '') + + def test_parse_bewit_with_ext(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xandyandz' + bewit = urlsafe_b64encode(bewit).decode('ascii') + bewit = parse_bewit(bewit) + self.assertEquals(bewit.id, '123456') + self.assertEquals(bewit.expiration, '1356420707') + self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') + self.assertEquals(bewit.ext, 'xandyandz') + + def test_parse_bewit_with_ext_and_backslashes(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xand\\yandz' + bewit = urlsafe_b64encode(bewit).decode('ascii') + bewit = parse_bewit(bewit) + self.assertEquals(bewit.id, '123456') + self.assertEquals(bewit.expiration, '1356420707') + self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') + self.assertEquals(bewit.ext, 'xand\\yandz') + + @raises(InvalidBewit) + def test_parse_invalid_bewit_with_only_one_part(self): + bewit = b'12345' + bewit = urlsafe_b64encode(bewit).decode('ascii') + bewit = parse_bewit(bewit) + + @raises(InvalidBewit) + def test_parse_invalid_bewit_with_only_two_parts(self): + bewit = b'1\\2' + bewit = urlsafe_b64encode(bewit).decode('ascii') + bewit = parse_bewit(bewit) + + def test_validate_bewit(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + credential_lookup = self.make_credential_lookup({ + self.credentials['id']: self.credentials, + }) + self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) + + def test_validate_bewit_with_ext(self): + bewit = b'123456\\1356420707\\kscxwNR2tJpP1T1zDLNPbB5UiKIU9tOSJXTUdG7X9h8=\\xandyandz' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + credential_lookup = self.make_credential_lookup({ + self.credentials['id']: self.credentials, + }) + self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) + + def test_validate_bewit_with_ext_and_backslashes(self): + bewit = b'123456\\1356420707\\b82LLIxG5UDkaChLU953mC+SMrbniV1sb8KiZi9cSsc=\\xand\\yandz' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + credential_lookup = self.make_credential_lookup({ + self.credentials['id']: self.credentials, + }) + self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) + + @raises(TokenExpired) + def test_validate_expired_bewit(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + credential_lookup = self.make_credential_lookup({ + self.credentials['id']: self.credentials, + }) + check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 1000) + + @raises(CredentialsLookupError) + def test_validate_bewit_with_unknown_credentials(self): + bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' + bewit = urlsafe_b64encode(bewit).decode('ascii') + url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) + credential_lookup = self.make_credential_lookup({ + 'other_id': self.credentials, + }) + check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10) diff --git a/third_party/python/mohawk/mohawk/util.py b/third_party/python/mohawk/mohawk/util.py new file mode 100644 index 0000000000..46a28e94ce --- /dev/null +++ b/third_party/python/mohawk/mohawk/util.py @@ -0,0 +1,267 @@ +from base64 import b64encode, urlsafe_b64encode +import calendar +import hashlib +import hmac +import logging +import math +import os +import pprint +import re +import sys +import time + +import six + +from .exc import ( + BadHeaderValue, + HawkFail, + InvalidCredentials) + + +HAWK_VER = 1 +log = logging.getLogger(__name__) +allowable_header_keys = set(['id', 'ts', 'tsm', 'nonce', 'hash', + 'error', 'ext', 'mac', 'app', 'dlg']) + + +def validate_credentials(creds): + if not hasattr(creds, '__getitem__'): + raise InvalidCredentials('credentials must be a dict-like object') + try: + creds['id'] + creds['key'] + creds['algorithm'] + except KeyError: + etype, val, tb = sys.exc_info() + raise InvalidCredentials('{etype}: {val}' + .format(etype=etype, val=val)) + + +def random_string(length): + """Generates a random string for a given length.""" + # this conservatively gets 8*length bits and then returns 6*length of + # them. Grabbing (6/8)*length bits could lose some entropy off the ends. + return urlsafe_b64encode(os.urandom(length))[:length] + + +def calculate_payload_hash(payload, algorithm, content_type): + """Calculates a hash for a given payload.""" + p_hash = hashlib.new(algorithm) + + parts = [] + parts.append('hawk.' + str(HAWK_VER) + '.payload\n') + parts.append(parse_content_type(content_type) + '\n') + parts.append(payload or '') + parts.append('\n') + + for i, p in enumerate(parts): + # Make sure we are about to hash binary strings. + if not isinstance(p, six.binary_type): + p = p.encode('utf8') + p_hash.update(p) + parts[i] = p + + log.debug('calculating payload hash from:\n{parts}' + .format(parts=pprint.pformat(parts))) + + return b64encode(p_hash.digest()) + + +def calculate_mac(mac_type, resource, content_hash): + """Calculates a message authorization code (MAC).""" + normalized = normalize_string(mac_type, resource, content_hash) + log.debug(u'normalized resource for mac calc: {norm}' + .format(norm=normalized)) + digestmod = getattr(hashlib, resource.credentials['algorithm']) + + # Make sure we are about to hash binary strings. + + if not isinstance(normalized, six.binary_type): + normalized = normalized.encode('utf8') + key = resource.credentials['key'] + if not isinstance(key, six.binary_type): + key = key.encode('ascii') + + result = hmac.new(key, normalized, digestmod) + return b64encode(result.digest()) + + +def calculate_ts_mac(ts, credentials): + """Calculates a message authorization code (MAC) for a timestamp.""" + normalized = ('hawk.{hawk_ver}.ts\n{ts}\n' + .format(hawk_ver=HAWK_VER, ts=ts)) + log.debug(u'normalized resource for ts mac calc: {norm}' + .format(norm=normalized)) + digestmod = getattr(hashlib, credentials['algorithm']) + + if not isinstance(normalized, six.binary_type): + normalized = normalized.encode('utf8') + key = credentials['key'] + if not isinstance(key, six.binary_type): + key = key.encode('ascii') + + result = hmac.new(key, normalized, digestmod) + return b64encode(result.digest()) + + +def normalize_string(mac_type, resource, content_hash): + """Serializes mac_type and resource into a HAWK string.""" + + normalized = [ + 'hawk.' + str(HAWK_VER) + '.' + mac_type, + normalize_header_attr(resource.timestamp), + normalize_header_attr(resource.nonce), + normalize_header_attr(resource.method or ''), + normalize_header_attr(resource.name or ''), + normalize_header_attr(resource.host), + normalize_header_attr(resource.port), + normalize_header_attr(content_hash or '') + ] + + # The blank lines are important. They follow what the Node Hawk lib does. + + normalized.append(normalize_header_attr(resource.ext or '')) + + if resource.app: + normalized.append(normalize_header_attr(resource.app)) + normalized.append(normalize_header_attr(resource.dlg or '')) + + # Add trailing new line. + normalized.append('') + + normalized = '\n'.join(normalized) + + return normalized + + +def parse_content_type(content_type): + """Cleans up content_type.""" + if content_type: + return content_type.split(';')[0].strip().lower() + else: + return '' + + +def parse_authorization_header(auth_header): + """ + Example Authorization header: + + 'Hawk id="dh37fgj492je", ts="1367076201", nonce="NPHgnG", ext="and + welcome!", mac="CeWHy4d9kbLGhDlkyw2Nh3PJ7SDOdZDa267KH4ZaNMY="' + """ + attributes = {} + + # Make sure we have a unicode object for consistency. + if isinstance(auth_header, six.binary_type): + auth_header = auth_header.decode('utf8') + + parts = auth_header.split(',') + auth_scheme_parts = parts[0].split(' ') + if 'hawk' != auth_scheme_parts[0].lower(): + raise HawkFail("Unknown scheme '{scheme}' when parsing header" + .format(scheme=auth_scheme_parts[0].lower())) + + # Replace 'Hawk key: value' with 'key: value' + # which matches the rest of parts + parts[0] = auth_scheme_parts[1] + + for part in parts: + attr_parts = part.split('=') + key = attr_parts[0].strip() + if key not in allowable_header_keys: + raise HawkFail("Unknown Hawk key '{key}' when parsing header" + .format(key=key)) + + if len(attr_parts) > 2: + attr_parts[1] = '='.join(attr_parts[1:]) + + # Chop of quotation marks + value = attr_parts[1] + + if attr_parts[1].find('"') == 0: + value = attr_parts[1][1:] + + if value.find('"') > -1: + value = value[0:-1] + + validate_header_attr(value, name=key) + value = unescape_header_attr(value) + attributes[key] = value + + log.debug('parsed Hawk header: {header} into: \n{parsed}' + .format(header=auth_header, parsed=pprint.pformat(attributes))) + return attributes + + +def strings_match(a, b): + # Constant time string comparision, mitigates side channel attacks. + if len(a) != len(b): + return False + result = 0 + + def byte_ints(buf): + for ch in buf: + # In Python 3, if we have a bytes object, iterating it will + # already get the integer value. In older pythons, we need + # to use ord(). + if not isinstance(ch, int): + ch = ord(ch) + yield ch + + for x, y in zip(byte_ints(a), byte_ints(b)): + result |= x ^ y + return result == 0 + + +def utc_now(offset_in_seconds=0.0): + # TODO: add support for SNTP server? See ntplib module. + return int(math.floor(calendar.timegm(time.gmtime()) + + float(offset_in_seconds))) + + +# Allowed value characters: +# !#$%&'()*+,-./:;<=>?@[]^_`{|}~ and space, a-z, A-Z, 0-9, \, " +_header_attribute_chars = re.compile( + r"^[ a-zA-Z0-9_\!#\$%&'\(\)\*\+,\-\./\:;<\=>\?@\[\]\^`\{\|\}~\"\\]*$") + + +def validate_header_attr(val, name=None): + if not _header_attribute_chars.match(val): + raise BadHeaderValue('header value name={name} value={val} ' + 'contained an illegal character' + .format(name=name or '?', val=repr(val))) + + +def escape_header_attr(val): + + # Ensure we are working with Unicode for consistency. + if isinstance(val, six.binary_type): + val = val.decode('utf8') + + # Escape quotes and slash like the hawk reference code. + val = val.replace('\\', '\\\\') + val = val.replace('"', '\\"') + val = val.replace('\n', '\\n') + return val + + +def unescape_header_attr(val): + # Un-do the hawk escaping. + val = val.replace('\\n', '\n') + val = val.replace('\\\\', '\\').replace('\\"', '"') + return val + + +def prepare_header_val(val): + val = escape_header_attr(val) + validate_header_attr(val) + return val + + +def normalize_header_attr(val): + if not val: + val = '' + + # Normalize like the hawk reference code. + val = escape_header_attr(val) + return val diff --git a/third_party/python/mohawk/setup.cfg b/third_party/python/mohawk/setup.cfg new file mode 100644 index 0000000000..861a9f5542 --- /dev/null +++ b/third_party/python/mohawk/setup.cfg @@ -0,0 +1,5 @@ +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 + diff --git a/third_party/python/mohawk/setup.py b/third_party/python/mohawk/setup.py new file mode 100644 index 0000000000..ddaf9026c2 --- /dev/null +++ b/third_party/python/mohawk/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup, find_packages + + +setup(name='mohawk', + version='0.3.4', + description="Library for Hawk HTTP authorization", + long_description='', + author='Kumar McMillan, Austin King', + author_email='kumar.mcmillan@gmail.com', + license='MPL 2.0 (Mozilla Public License)', + url='https://github.com/kumar303/mohawk', + include_package_data=True, + classifiers=[ + 'Intended Audience :: Developers', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Topic :: Internet :: WWW/HTTP', + ], + packages=find_packages(exclude=['tests']), + install_requires=['six']) |