summaryrefslogtreecommitdiffstats
path: root/third_party/python/mohawk
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /third_party/python/mohawk
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'third_party/python/mohawk')
-rw-r--r--third_party/python/mohawk/PKG-INFO19
-rw-r--r--third_party/python/mohawk/README.rst25
-rw-r--r--third_party/python/mohawk/mohawk/__init__.py2
-rw-r--r--third_party/python/mohawk/mohawk/base.py230
-rw-r--r--third_party/python/mohawk/mohawk/bewit.py167
-rw-r--r--third_party/python/mohawk/mohawk/exc.py98
-rw-r--r--third_party/python/mohawk/mohawk/receiver.py170
-rw-r--r--third_party/python/mohawk/mohawk/sender.py178
-rw-r--r--third_party/python/mohawk/mohawk/tests.py823
-rw-r--r--third_party/python/mohawk/mohawk/util.py267
-rw-r--r--third_party/python/mohawk/setup.cfg5
-rw-r--r--third_party/python/mohawk/setup.py25
12 files changed, 2009 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/__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'])