├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.rst ├── docs ├── Makefile ├── api.rst ├── conf.py ├── developers.rst ├── index.rst ├── security.rst ├── usage.rst └── why.rst ├── mohawk ├── __init__.py ├── base.py ├── bewit.py ├── exc.py ├── receiver.py ├── sender.py ├── tests.py └── util.py ├── requirements └── dev.txt ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.sw[po] 3 | .coverage 4 | .DS_Store 5 | .noseids 6 | .tox 7 | docs/_build/* 8 | *~ 9 | *.mo 10 | *.db 11 | *.egg-info 12 | _virtualenv 13 | build 14 | dist 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty # For python 2.6 2 | language: python 3 | matrix: 4 | include: 5 | - python: "2.7" 6 | env: TOXENV=py27 7 | - python: "3.4" 8 | env: TOXENV=py34 9 | - python: "3.5" 10 | env: TOXENV=py35 11 | - python: "3.6" 12 | env: TOXENV=py36 13 | - python: "3.7" 14 | env: TOXENV=py37 15 | sudo: required 16 | dist: xenial 17 | - python: "3.8" 18 | env: TOXENV=py38 19 | sudo: required 20 | dist: xenial 21 | - python: "3.7" 22 | env: TOXENV=docs 23 | sudo: required 24 | dist: xenial 25 | install: 26 | - pip install tox 27 | script: 28 | - tox 29 | notifications: 30 | irc: 31 | channels: 32 | - "irc.mozilla.org#payments" 33 | on_success: change 34 | on_failure: always 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Mozilla Corporation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the Mozilla Corporation nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Mohawk 3 | ====== 4 | .. image:: https://img.shields.io/pypi/v/mohawk.svg 5 | :target: https://pypi.python.org/pypi/mohawk 6 | :alt: Latest PyPI release 7 | 8 | .. image:: https://img.shields.io/pypi/dm/mohawk.svg 9 | :target: https://pypi.python.org/pypi/mohawk 10 | :alt: PyPI monthly download stats 11 | 12 | .. image:: https://travis-ci.org/kumar303/mohawk.svg?branch=master 13 | :target: https://travis-ci.org/kumar303/mohawk 14 | :alt: Travis master branch status 15 | 16 | .. image:: https://readthedocs.org/projects/mohawk/badge/?version=latest 17 | :target: https://mohawk.readthedocs.io/en/latest/?badge=latest 18 | :alt: Documentation status 19 | 20 | Mohawk is an alternate Python implementation of the 21 | `Hawk HTTP authorization scheme`_. 22 | 23 | Hawk lets two parties securely communicate with each other using 24 | messages signed by a shared key. 25 | It is based on `HTTP MAC access authentication`_ (which 26 | was based on parts of `OAuth 1.0`_). 27 | 28 | The Mohawk API is a little different from that of the Node library 29 | (i.e. `the living Hawk spec `_). 30 | It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. 31 | 32 | Full documentation: https://mohawk.readthedocs.io/ 33 | 34 | .. _`Hawk HTTP authorization scheme`: https://github.com/hueniverse/hawk 35 | .. _`HTTP MAC access authentication`: http://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 36 | .. _`OAuth 1.0`: http://tools.ietf.org/html/rfc5849 37 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mohawk.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mohawk.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Mohawk" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mohawk" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | === 4 | API 5 | === 6 | 7 | This is a detailed look at the Mohawk API. 8 | For general usage patterns see :ref:`usage`. 9 | 10 | Sender 11 | ====== 12 | 13 | .. autoclass:: mohawk.Sender 14 | :members: request_header, accept_response 15 | 16 | Receiver 17 | ======== 18 | 19 | .. autoclass:: mohawk.Receiver 20 | :members: response_header, respond 21 | 22 | .. _exceptions: 23 | 24 | Exceptions 25 | ========== 26 | 27 | .. automodule:: mohawk.exc 28 | :members: 29 | 30 | Base 31 | ==== 32 | 33 | .. autoclass:: mohawk.base.Resource 34 | 35 | .. autodata:: mohawk.base.EmptyValue 36 | 37 | This represents an empty value but not ``None``. 38 | 39 | This is typically used as a placeholder of a default value 40 | so that internal code can differentiate it from ``None``. 41 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Mohawk documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Feb 18 14:47:38 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | import sphinx_rtd_theme 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | conf_dir = os.path.dirname(__file__) 24 | sys.path.insert(0, os.path.abspath(os.path.join(conf_dir, '..'))) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.doctest', 37 | 'sphinx.ext.intersphinx', 38 | 'sphinx.ext.todo', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | #source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'Mohawk' 56 | copyright = u'2014, Kumar McMillan, Austin King' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '0.1' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '0.1' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | #keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'sphinx_rtd_theme' 111 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 112 | 113 | # Theme options are theme-specific and customize the look and feel of a theme 114 | # further. For a list of options available for each theme, see the 115 | # documentation. 116 | #html_theme_options = {} 117 | 118 | # Add any paths that contain custom themes here, relative to this directory. 119 | #html_theme_path = [] 120 | 121 | # The name for this set of Sphinx documents. If None, it defaults to 122 | # " v documentation". 123 | #html_title = None 124 | 125 | # A shorter title for the navigation bar. Default is the same as html_title. 126 | #html_short_title = None 127 | 128 | # The name of an image file (relative to this directory) to place at the top 129 | # of the sidebar. 130 | #html_logo = None 131 | 132 | # The name of an image file (within the static path) to use as favicon of the 133 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 134 | # pixels large. 135 | #html_favicon = None 136 | 137 | # Add any paths that contain custom static files (such as style sheets) here, 138 | # relative to this directory. They are copied after the builtin static files, 139 | # so a file named "default.css" will overwrite the builtin "default.css". 140 | html_static_path = ['_static'] 141 | 142 | # Add any extra paths that contain custom files (such as robots.txt or 143 | # .htaccess) here, relative to this directory. These files are copied 144 | # directly to the root of the documentation. 145 | #html_extra_path = [] 146 | 147 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 148 | # using the given strftime format. 149 | #html_last_updated_fmt = '%b %d, %Y' 150 | 151 | # If true, SmartyPants will be used to convert quotes and dashes to 152 | # typographically correct entities. 153 | #html_use_smartypants = True 154 | 155 | # Custom sidebar templates, maps document names to template names. 156 | #html_sidebars = {} 157 | 158 | # Additional templates that should be rendered to pages, maps page names to 159 | # template names. 160 | #html_additional_pages = {} 161 | 162 | # If false, no module index is generated. 163 | #html_domain_indices = True 164 | 165 | # If false, no index is generated. 166 | #html_use_index = True 167 | 168 | # If true, the index is split into individual pages for each letter. 169 | #html_split_index = False 170 | 171 | # If true, links to the reST sources are added to the pages. 172 | #html_show_sourcelink = True 173 | 174 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 175 | #html_show_sphinx = True 176 | 177 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 178 | #html_show_copyright = True 179 | 180 | # If true, an OpenSearch description file will be output, and all pages will 181 | # contain a tag referring to it. The value of this option must be the 182 | # base URL from which the finished HTML is served. 183 | #html_use_opensearch = '' 184 | 185 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 186 | #html_file_suffix = None 187 | 188 | # Output file base name for HTML help builder. 189 | htmlhelp_basename = 'Mohawkdoc' 190 | 191 | 192 | # -- Options for LaTeX output --------------------------------------------- 193 | 194 | latex_elements = { 195 | # The paper size ('letterpaper' or 'a4paper'). 196 | #'papersize': 'letterpaper', 197 | 198 | # The font size ('10pt', '11pt' or '12pt'). 199 | #'pointsize': '10pt', 200 | 201 | # Additional stuff for the LaTeX preamble. 202 | #'preamble': '', 203 | } 204 | 205 | # Grouping the document tree into LaTeX files. List of tuples 206 | # (source start file, target name, title, 207 | # author, documentclass [howto, manual, or own class]). 208 | latex_documents = [ 209 | ('index', 'Mohawk.tex', u'Mohawk Documentation', 210 | u'Kumar McMillan, Austin King', 'manual'), 211 | ] 212 | 213 | # The name of an image file (relative to this directory) to place at the top of 214 | # the title page. 215 | #latex_logo = None 216 | 217 | # For "manual" documents, if this is true, then toplevel headings are parts, 218 | # not chapters. 219 | #latex_use_parts = False 220 | 221 | # If true, show page references after internal links. 222 | #latex_show_pagerefs = False 223 | 224 | # If true, show URL addresses after external links. 225 | #latex_show_urls = False 226 | 227 | # Documents to append as an appendix to all manuals. 228 | #latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | #latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output --------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [ 239 | ('index', 'mohawk', u'Mohawk Documentation', 240 | [u'Kumar McMillan, Austin King'], 1) 241 | ] 242 | 243 | # If true, show URL addresses after external links. 244 | #man_show_urls = False 245 | 246 | 247 | # -- Options for Texinfo output ------------------------------------------- 248 | 249 | # Grouping the document tree into Texinfo files. List of tuples 250 | # (source start file, target name, title, author, 251 | # dir menu entry, description, category) 252 | texinfo_documents = [ 253 | ('index', 'Mohawk', u'Mohawk Documentation', 254 | u'Kumar McMillan, Austin King', 'Mohawk', 'One line description of project.', 255 | 'Miscellaneous'), 256 | ] 257 | 258 | # Documents to append as an appendix to all manuals. 259 | #texinfo_appendices = [] 260 | 261 | # If false, no module index is generated. 262 | #texinfo_domain_indices = True 263 | 264 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 265 | #texinfo_show_urls = 'footnote' 266 | 267 | # If true, do not generate a @detailmenu in the "Top" node's menu. 268 | #texinfo_no_detailmenu = False 269 | 270 | 271 | # Example configuration for intersphinx: refer to the Python standard library. 272 | intersphinx_mapping = {'http://docs.python.org/': None} 273 | -------------------------------------------------------------------------------- /docs/developers.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Developers 3 | ========== 4 | 5 | Grab the source from Github: https://github.com/kumar303/mohawk 6 | 7 | Run the tests 8 | ============= 9 | 10 | You can run the full test suite with the `tox`_ command:: 11 | 12 | tox 13 | 14 | To just run Python 2.7 unit tests type:: 15 | 16 | tox -e py27 17 | 18 | To just run doctests type:: 19 | 20 | tox -e docs 21 | 22 | Set up an environment 23 | ===================== 24 | 25 | Using a `virtualenv`_ you can set yourself up for development like this:: 26 | 27 | virtualenv _virtualenv 28 | source _virtualenv/bin/activate 29 | pip install -r requirements/dev.txt 30 | python setup.py develop 31 | 32 | .. note:: 33 | 34 | Development commands such as building docs and publishing a release were 35 | last tested on Python 3.7.4 so you will probably need a version close to that. 36 | Use ``tox`` to develop features for older Python versions. 37 | 38 | Build the docs 39 | ============== 40 | 41 | In your virtualenv, you can build the docs like this:: 42 | 43 | make -C docs/ html doctest 44 | open docs/_build/html/index.html 45 | 46 | Publish a release 47 | ================= 48 | 49 | Do this first to prepare for a release: 50 | 51 | - make sure the changelog is up to date 52 | - make sure you bumped the module version in ``setup.py`` 53 | - commit, tag (like ``git tag 0.3.1``), and push upstream 54 | (like ``git push --tags upstream``). 55 | 56 | Run this from the repository root to publish a new release to `PyPI`_ 57 | as both a source distribution and wheel:: 58 | 59 | rm -rf dist/* 60 | python setup.py sdist bdist_wheel 61 | twine upload dist/* 62 | 63 | 64 | .. _virtualenv: https://pypi.python.org/pypi/virtualenv 65 | .. _tox: https://tox.readthedocs.io/ 66 | .. _`PyPI`: https://pypi.python.org/pypi 67 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Mohawk documentation master file, created by 2 | sphinx-quickstart on Tue Feb 18 14:47:38 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ====== 7 | Mohawk 8 | ====== 9 | 10 | Mohawk is an alternate Python implementation of the 11 | `Hawk HTTP authorization scheme`_. 12 | 13 | .. image:: https://img.shields.io/pypi/v/mohawk.svg 14 | :target: https://pypi.python.org/pypi/mohawk 15 | :alt: Latest PyPI release 16 | 17 | .. image:: https://img.shields.io/pypi/dm/mohawk.svg 18 | :target: https://pypi.python.org/pypi/mohawk 19 | :alt: PyPI monthly download stats 20 | 21 | .. image:: https://travis-ci.org/kumar303/mohawk.svg?branch=master 22 | :target: https://travis-ci.org/kumar303/mohawk 23 | :alt: Travis master branch status 24 | 25 | .. image:: https://readthedocs.org/projects/mohawk/badge/?version=latest 26 | :target: https://mohawk.readthedocs.io/en/latest/?badge=latest 27 | :alt: Documentation status 28 | 29 | 30 | Hawk lets two parties securely communicate with each other using 31 | messages signed by a shared key. 32 | It is based on `HTTP MAC access authentication`_ (which 33 | was based on parts of `OAuth 1.0`_). 34 | 35 | The Mohawk API is a little different from that of the Node library 36 | (i.e. `the living Hawk spec `_). 37 | It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. 38 | 39 | .. _`Hawk HTTP authorization scheme`: https://github.com/hueniverse/hawk 40 | .. _`HTTP MAC access authentication`: http://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 41 | .. _`OAuth 1.0`: http://tools.ietf.org/html/rfc5849 42 | 43 | Installation 44 | ============ 45 | 46 | Requirements: 47 | 48 | * Python 2.7+ or 3.4+ 49 | * `six`_ 50 | 51 | Using `pip`_:: 52 | 53 | pip install mohawk 54 | 55 | 56 | If you want to install from source, visit https://github.com/kumar303/mohawk 57 | 58 | .. _pip: https://pip.readthedocs.io/ 59 | 60 | Bugs 61 | ==== 62 | 63 | You can submit bugs / patches on Github: https://github.com/kumar303/mohawk 64 | 65 | .. important:: 66 | 67 | If you think you found a security vulnerability please 68 | try emailing kumar.mcmillan@gmail.com before submitting a public issue. 69 | 70 | Topics 71 | ====== 72 | 73 | .. toctree:: 74 | :maxdepth: 2 75 | 76 | usage 77 | security 78 | api 79 | developers 80 | why 81 | 82 | Framework integration 83 | ===================== 84 | 85 | Mohawk is a low level library that focuses on Hawk communication. 86 | The following higher-level libraries integrate Mohawk 87 | into specific web frameworks: 88 | 89 | * `Hawkrest`_: adds Hawk to `Django Rest Framework`_ 90 | * Did we miss one? Send a `pull request`_ so we can link to it. 91 | 92 | .. _`Hawkrest`: https://hawkrest.readthedocs.io/ 93 | .. _`Django Rest Framework`: http://django-rest-framework.org/ 94 | .. _`pull request`: https://github.com/kumar303/mohawk 95 | 96 | TODO 97 | ==== 98 | 99 | * Support NTP-like (but secure) synchronization for local server time. 100 | See `TLSdate `_. 101 | * Support auto-retrying a :class:`mohawk.Sender` request with an offset if 102 | there is timestamp skew. 103 | 104 | Changelog 105 | --------- 106 | 107 | - **UNRELEASED** 108 | 109 | - Dropped support for Python 2.6. 110 | - (Unreleased features should be listed here.) 111 | 112 | - **1.1.0** (2019-10-28) 113 | 114 | - Support passing file-like objects (those implementing ``.read(n)``) 115 | as the ``content`` parameter for Resources. 116 | See :class:`mohawk.Sender` for details. 117 | 118 | - **1.0.0** (2019-01-09) 119 | 120 | - **Security related**: Bewit MACs were not compared in constant time and were thus 121 | possibly circumventable by an attacker. 122 | - **Breaking change**: Escape characters in header values (such as a back slash) 123 | are no longer allowed, potentially breaking clients that depended on this behavior. 124 | See https://github.com/kumar303/mohawk/issues/34 125 | - A sender is allowed to omit the content hash as long as their request has no 126 | content. The :class:`mohawk.Receiver` will skip the content hash check 127 | in this situation, regardless of the value of 128 | ``accept_untrusted_content``. See :ref:`empty-requests` for more details. 129 | - Introduced max limit of 4096 characters in the Authorization header 130 | - Changed default values of ``content`` and ``content_type`` arguments to 131 | :data:`mohawk.base.EmptyValue` in order to differentiate between 132 | misconfiguration and cases where these arguments are explicitly given as 133 | ``None`` (as with some web frameworks). See :ref:`skipping-content-checks` 134 | for more details. 135 | - Failing to pass ``content`` and ``content_type`` arguments to 136 | :class:`mohawk.Receiver` or :meth:`mohawk.Sender.accept_response` 137 | without specifying ``accept_untrusted_content=True`` will now raise 138 | :exc:`mohawk.exc.MissingContent` instead of :exc:`ValueError`. 139 | 140 | 141 | - **0.3.4** (2017-01-07) 142 | 143 | - Fixed ``AttributeError`` exception 144 | (it now raises :class:`mohawk.exc.MissingAuthorization`) for cases 145 | when the client sends a None type authorization header. 146 | See `issue 23 `_. 147 | - Fixed Python 3.6 compatibility problem (a regex pattern was using 148 | the deprecated ``LOCALE`` flag). 149 | See `issue 32 `_. 150 | 151 | - **0.3.3** (2016-07-12) 152 | 153 | - Fixed some cases where :class:`mohawk.exc.MacMismatch` was raised 154 | instead of :class:`mohawk.exc.MisComputedContentHash`. 155 | This follows the `Hawk HTTP authorization scheme`_ implementation 156 | more closely. 157 | See `issue 15 `_. 158 | - Published as a Python wheel 159 | 160 | - **0.3.2.1** (2016-02-25) 161 | 162 | - Re-did the ``0.3.2`` release; the tag was missing some commits. D'oh. 163 | 164 | - **0.3.2** (2016-02-24) 165 | 166 | - Improved Python 3 support. 167 | - Fixed bug in handling ``ext`` values that have more than one equal sign. 168 | - Configuration objects no longer need to be strictly dicts. 169 | 170 | - **0.3.1** (2016-01-07) 171 | 172 | - Initial bewit support (undocumented). 173 | Complete support with documentation is still forthcoming. 174 | 175 | - **0.3.0** (2015-06-22) 176 | 177 | - **Breaking change:** The ``seen_nonce()`` callback signature has changed. 178 | You must update your callback from ``seen_nonce(nonce, timestamp)`` 179 | to ``seen_nonce(sender_id, nonce, timestamp)`` to avoid unnecessary 180 | collisions. See :ref:`nonce` for details. 181 | 182 | - **0.2.2** (2015-01-05) 183 | 184 | - Receiver can now respond with a ``WWW-Authenticate`` header so that 185 | senders can adjust their timestamps. Thanks to jcwilson for the patch. 186 | 187 | - **0.2.1** (2014-03-03) 188 | 189 | - Fixed Python 2 bug in how unicode was converted to bytes 190 | when calculating a payload hash. 191 | 192 | - **0.2.0** (2014-03-03) 193 | 194 | - Added support for Python 3.3 or greater. 195 | - Added support for Python 2.6 (this was just a test suite fix). 196 | - Added `six`_ as dependency. 197 | - :attr:`mohawk.Sender.request_header` and 198 | :attr:`mohawk.Receiver.response_header` are now Unicode objects. 199 | They will never contain non-ascii characters though. 200 | 201 | - **0.1.0** (2014-02-19) 202 | 203 | - Implemented optional content hashing per spec but in a less error prone way 204 | - Added complete documentation 205 | 206 | - **0.0.4** (2014-02-11) 207 | 208 | - Bug fix: response processing now re-uses sender's nonce and timestamp 209 | per the Node Hawk lib 210 | - No longer assume content-type: text/plain if content type is not 211 | specificed 212 | 213 | - **0.0.3** (2014-02-07) 214 | 215 | - Bug fix: Macs were made using URL safe base64 encoding which differs 216 | from the Node Hawk lib (it just uses regular base64) 217 | - exposed ``localtime_in_seconds`` on ``TokenExpired`` exception 218 | per Hawk spec 219 | - better localtime offset and skew handling 220 | 221 | - **0.0.2** (2014-02-06) 222 | 223 | - Responding with a custom ext now works 224 | - Protected app and dlg according to spec when accepting responses 225 | 226 | - **0.0.1** (2014-02-05) 227 | 228 | - initial release of partial implementation 229 | 230 | .. _six: https://pypi.python.org/pypi/six 231 | 232 | Indices and tables 233 | ================== 234 | 235 | * :ref:`genindex` 236 | * :ref:`modindex` 237 | * :ref:`search` 238 | -------------------------------------------------------------------------------- /docs/security.rst: -------------------------------------------------------------------------------- 1 | .. _security: 2 | 3 | ======================= 4 | Security Considerations 5 | ======================= 6 | 7 | `Hawk`_ HTTP authorization uses a message authentication code (MAC) 8 | algorithm to provide partial cryptographic verification of HTTP 9 | requests/responses. 10 | 11 | .. important:: 12 | 13 | Take a look at Hawk's own `security considerations`_. 14 | 15 | Here are some additional security considerations: 16 | 17 | * ``mohawk`` is intended to be used as a low-level library. 18 | You should *never* expose its :ref:`exceptions` publicly, say, 19 | in an HTTP response, as they may provide hints to an attacker. 20 | * Using a shared secret for signatures means that if the secret leaks out 21 | then messages can be signed all day long. 22 | Make sure secrets are stored somewhere safe and never 23 | transmitted over an insecure channel. 24 | For example, putting a shared secret in memory on a web browser page 25 | may or may not be secure enough. 26 | * What does *partial verification* mean? 27 | While all major request/response artifacts are signed 28 | (URL, protocol, method, content), 29 | *only* the ``content-type`` header is signed. You'll want to make sure your 30 | sender and receiver aren't susceptible to header poisoning in case an attacker 31 | finds a way to replay a valid Hawk request with additional headers. 32 | For example, if an attacker can find a way to replay a request and add 33 | the header ``x-token: hijacked-token`` then the request might still be 34 | valid because this random header is not part of the signature. 35 | * Consider :ref:`nonce`. 36 | * Hawk lets you verify that you're talking to the person you think you are. 37 | In a lot of ways, this is more trustworthy than SSL/TLS but to guard 38 | against your own `stupidity`_ as well as prevent general eavesdropping, 39 | you should probably use both HTTPS and Hawk. 40 | * The `Hawk`_ spec says that signing request/response content is *optional* 41 | but just for extra paranoia, Mohawk 42 | raises an exception if you skip content checks unintentionally. 43 | Read :ref:`skipping-content-checks` for how to intentionally make it 44 | optional. This does not apply to :ref:`empty requests `. 45 | 46 | .. _`Hawk`: https://github.com/hueniverse/hawk 47 | .. _stupidity: http://benlog.com/2010/09/07/defending-against-your-own-stupidity/ 48 | .. _`security considerations`: https://github.com/hueniverse/hawk#security-considerations 49 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | ============ 4 | Using Mohawk 5 | ============ 6 | 7 | There are two parties involved in `Hawk`_ communication: a 8 | :class:`sender ` and a :class:`receiver `. 9 | They use a shared secret to sign and verify each other's messages. 10 | 11 | **Sender** 12 | A client who wants to access a Hawk-protected resource. 13 | The client will sign their request and upon 14 | receiving a response will also verify the response signature. 15 | 16 | **Receiver** 17 | A server that uses Hawk to protect its resources. The server will check 18 | the signature of an incoming request before accepting it. It also signs 19 | its response using the same shared secret. 20 | 21 | What are some good use cases for Hawk? This library was built for the case of 22 | securing API connections between two back-end servers. Hawk is a good 23 | fit for this because you can keep the shared secret safe on each machine. 24 | Hawk may not be a good fit for scenarios where you can't protect the shared 25 | secret. 26 | 27 | After getting familiar with usage, you may want to consult the :ref:`security` 28 | section. 29 | 30 | .. testsetup:: usage 31 | 32 | class Requests: 33 | def post(self, *a, **kw): pass 34 | requests = Requests() 35 | 36 | credentials = {'id': 'some-sender', 37 | 'key': 'a long, complicated secret', 38 | 'algorithm': 'sha256'} 39 | allowed_senders = {} 40 | allowed_senders['some-sender'] = credentials 41 | 42 | class Memcache: 43 | def get(self, *a, **kw): 44 | return False 45 | def set(self, *a, **kw): pass 46 | memcache = Memcache() 47 | 48 | .. _`sending-request`: 49 | 50 | Sending a request 51 | ================= 52 | 53 | Let's say you want to make an HTTP request like this: 54 | 55 | .. doctest:: usage 56 | 57 | >>> url = 'https://some-service.net/system' 58 | >>> method = 'POST' 59 | >>> content = 'one=1&two=2' 60 | >>> content_type = 'application/x-www-form-urlencoded' 61 | 62 | Set up your Hawk request by creating a :class:`mohawk.Sender` object 63 | with all the elements of the request that you need to sign: 64 | 65 | .. doctest:: usage 66 | 67 | >>> from mohawk import Sender 68 | >>> sender = Sender({'id': 'some-sender', 69 | ... 'key': 'a long, complicated secret', 70 | ... 'algorithm': 'sha256'}, 71 | ... url, 72 | ... method, 73 | ... content=content, 74 | ... content_type=content_type) 75 | 76 | This provides you with a Hawk ``Authorization`` header to send along 77 | with your request: 78 | 79 | .. doctest:: usage 80 | 81 | >>> sender.request_header 82 | 'Hawk mac="...", hash="...", id="some-sender", ts="...", nonce="..."' 83 | 84 | Using the `requests`_ library just as an example, you would send your POST 85 | like this: 86 | 87 | .. doctest:: usage 88 | 89 | >>> requests.post(url, data=content, 90 | ... headers={'Authorization': sender.request_header, 91 | ... 'Content-Type': content_type}) 92 | 93 | Notice how both the content and content-type values were signed by the Sender. 94 | In the case of a GET request you'll probably need to sign empty strings like 95 | ``Sender(..., 'GET', content='', content_type='')``, 96 | that is, if your request library doesn't 97 | automatically set a content-type for GET requests. 98 | 99 | If you only intend to work with :class:`mohawk.Sender`, 100 | skip down to :ref:`verify-response`. 101 | 102 | .. _`receiving-request`: 103 | 104 | Receiving a request 105 | =================== 106 | 107 | On the receiving end, such as a web server, you'll need to set up a 108 | :class:`mohawk.Receiver` object to accept and respond to 109 | :class:`mohawk.Sender` requests. 110 | 111 | First, you need to give the receiver a callable that it can use to look 112 | up sender credentials: 113 | 114 | .. doctest:: usage 115 | 116 | >>> def lookup_credentials(sender_id): 117 | ... if sender_id in allowed_senders: 118 | ... # Return a credentials dictionary formatted like the sender example. 119 | ... return allowed_senders[sender_id] 120 | ... else: 121 | ... raise LookupError('unknown sender') 122 | 123 | An incoming request will probably arrive in an object like this, 124 | depending on your web server framework: 125 | 126 | .. doctest:: usage 127 | 128 | >>> request = {'headers': {'Authorization': sender.request_header, 129 | ... 'Content-Type': content_type}, 130 | ... 'url': url, 131 | ... 'method': method, 132 | ... 'content': content} 133 | 134 | Create a :class:`mohawk.Receiver` using values from the incoming request: 135 | 136 | .. doctest:: usage 137 | 138 | >>> from mohawk import Receiver 139 | >>> receiver = Receiver(lookup_credentials, 140 | ... request['headers']['Authorization'], 141 | ... request['url'], 142 | ... request['method'], 143 | ... content=request['content'], 144 | ... content_type=request['headers']['Content-Type']) 145 | 146 | If this constructor does not raise any :ref:`exceptions` then the signature of 147 | the request is correct and you can proceed. 148 | 149 | .. important:: 150 | 151 | The server running :class:`mohawk.Receiver` code should synchronize its 152 | clock with something like `TLSdate`_ to make sure it compares timestamps 153 | correctly. 154 | 155 | Responding to a request 156 | ======================= 157 | 158 | It's optional per the `Hawk`_ spec but a :class:`mohawk.Receiver` 159 | should sign its response back to the client to prevent certain attacks. 160 | 161 | The receiver starts by building a message it wants to respond with: 162 | 163 | .. doctest:: usage 164 | 165 | >>> response_content = '{"msg": "Hello, dear friend"}' 166 | >>> response_content_type = 'application/json' 167 | >>> header = receiver.respond(content=response_content, 168 | ... content_type=response_content_type) 169 | 170 | This provides you with a similar Hawk header to use in the response: 171 | 172 | .. doctest:: usage 173 | 174 | >>> receiver.response_header 175 | 'Hawk mac="...", hash="...="' 176 | 177 | Using your web server's framework, respond with a 178 | ``Server-Authorization`` header. For example: 179 | 180 | .. doctest:: usage 181 | 182 | >>> response = { 183 | ... 'headers': {'Server-Authorization': receiver.response_header, 184 | ... 'Content-Type': response_content_type}, 185 | ... 'content': response_content 186 | ... } 187 | 188 | .. _`verify-response`: 189 | 190 | Verifying a response 191 | ==================== 192 | 193 | When the :class:`mohawk.Sender` 194 | receives a response it should verify the signature to 195 | make sure nothing has been tampered with: 196 | 197 | .. doctest:: usage 198 | 199 | >>> sender.accept_response(response['headers']['Server-Authorization'], 200 | ... content=response['content'], 201 | ... content_type=response['headers']['Content-Type']) 202 | 203 | 204 | If this method does not raise any :ref:`exceptions` then the signature of 205 | the response is correct and you can proceed. 206 | 207 | Allowing senders to adjust their timestamps 208 | =========================================== 209 | 210 | The easiest way to avoid timestamp problems is to synchronize your 211 | server clock using something like `TLSdate`_. 212 | 213 | If a sender's clock is out of sync with the receiver, its message might 214 | expire prematurely. In this case the receiver should respond with a header 215 | the sender can use to adjust its timestamp. 216 | 217 | When receiving a request you might get a :class:`mohawk.exc.TokenExpired` 218 | exception. You can access the ``www_authenticate`` property on the 219 | exception object to respond correctly like this: 220 | 221 | .. doctest:: usage 222 | :hide: 223 | 224 | >>> exp_sender = Sender({'id': 'some-sender', 225 | ... 'key': 'a long, complicated secret', 226 | ... 'algorithm': 'sha256'}, 227 | ... url, 228 | ... method, 229 | ... content=content, 230 | ... content_type=content_type, 231 | ... _timestamp=1) 232 | >>> request['headers']['Authorization'] = exp_sender.request_header 233 | 234 | .. doctest:: usage 235 | 236 | >>> from mohawk.exc import TokenExpired 237 | >>> try: 238 | ... receiver = Receiver(lookup_credentials, 239 | ... request['headers']['Authorization'], 240 | ... request['url'], 241 | ... request['method'], 242 | ... content=request['content'], 243 | ... content_type=request['headers']['Content-Type']) 244 | ... except TokenExpired as expiry: 245 | ... response['headers']['WWW-Authenticate'] = expiry.www_authenticate 246 | ... print(expiry.www_authenticate) 247 | Hawk ts="...", tsm="...", error="token with UTC timestamp...has expired..." 248 | 249 | .. doctest:: usage 250 | :hide: 251 | 252 | >>> request['headers']['Authorization'] = sender.request_header 253 | 254 | A compliant client can look for this response header and parse the 255 | ``ts`` property (the server's "now" timestamp) and 256 | the ``tsm`` property (a MAC calculation of ``ts``). It can then recalculate the 257 | MAC using its own credentials and if the MACs both match it can trust that this 258 | is the real server's timestamp. This allows the sender to retry the request 259 | with an adjusted timestamp. 260 | 261 | .. _nonce: 262 | 263 | Using a nonce to prevent replay attacks 264 | ======================================= 265 | 266 | A replay attack is when someone copies a Hawk authorized message and 267 | re-sends the message without altering it. 268 | Because the Hawk signature would still be valid, the receiver may 269 | accept the message. This could have unintended side effects such as increasing 270 | the quantity of an item just purchased if it were a commerce API that had an 271 | ``increment-item`` service. 272 | 273 | Hawk protects against replay attacks in a couple ways. First, a receiver checks 274 | the timestamp of the message which may result in a 275 | :class:`mohawk.exc.TokenExpired` exception. 276 | Second, every message includes a `cryptographic nonce`_ 277 | which is a unique 278 | identifier. In combination with the sender's id and the request's timestamp, a 279 | receiver can use the nonce to know if it has *already* received the request. If 280 | so, the :class:`mohawk.exc.AlreadyProcessed` exception is raised. 281 | 282 | By default, Mohawk doesn't know how to check nonce values; this is something 283 | your application needs to do. 284 | 285 | .. important:: 286 | 287 | If you don't configure nonce checking, your application could be 288 | susceptible to replay attacks. 289 | 290 | Make a callable that returns True if a sender's nonce plus its timestamp has been 291 | seen already. Here is an example using something like memcache: 292 | 293 | .. doctest:: usage 294 | 295 | >>> def seen_nonce(sender_id, nonce, timestamp): 296 | ... key = '{id}:{nonce}:{ts}'.format(id=sender_id, nonce=nonce, 297 | ... ts=timestamp) 298 | ... if memcache.get(key): 299 | ... # We have already processed this nonce + timestamp. 300 | ... return True 301 | ... else: 302 | ... # Save this nonce + timestamp for later. 303 | ... memcache.set(key, True) 304 | ... return False 305 | 306 | Because messages will expire after a short time you don't need to store 307 | nonces for much longer than that timeout. See :class:`mohawk.Receiver` 308 | for the default timeout. 309 | 310 | Pass your callable as a ``seen_nonce`` argument to :class:`mohawk.Receiver`: 311 | 312 | .. doctest:: usage 313 | 314 | >>> receiver = Receiver(lookup_credentials, 315 | ... request['headers']['Authorization'], 316 | ... request['url'], 317 | ... request['method'], 318 | ... content=request['content'], 319 | ... content_type=request['headers']['Content-Type'], 320 | ... seen_nonce=seen_nonce) 321 | 322 | If ``seen_nonce()`` returns True, :class:`mohawk.exc.AlreadyProcessed` 323 | will be raised. 324 | 325 | When a *sender* calls :meth:`mohawk.Sender.accept_response`, it will receive 326 | a Hawk message but the nonce will be that of the original request. 327 | In other words, the nonce received is the same nonce that the sender 328 | generated and signed when initiating the request. 329 | This generally means you don't have to worry about *response* replay attacks. 330 | However, if you 331 | expose your :meth:`mohawk.Sender.accept_response` call 332 | somewhere publicly over HTTP then you 333 | may need to protect against response replay attacks. 334 | You can do so by constructing a :class:`mohawk.Sender` with 335 | the same ``seen_nonce`` keyword: 336 | 337 | .. doctest:: usage 338 | 339 | >>> sender = Sender({'id': 'some-sender', 340 | ... 'key': 'a long, complicated secret', 341 | ... 'algorithm': 'sha256'}, 342 | ... url, 343 | ... method, 344 | ... content=content, 345 | ... content_type=content_type, 346 | ... seen_nonce=seen_nonce) 347 | 348 | .. _`cryptographic nonce`: http://en.wikipedia.org/wiki/Cryptographic_nonce 349 | 350 | .. _skipping-content-checks: 351 | 352 | Skipping content checks 353 | ======================= 354 | 355 | In some cases you may not be able to hash request/response content. For 356 | example, the content could be too large. If you run into this, Hawk 357 | might not be the best fit for you but Hawk does allow you to accept 358 | content without a declared hash if you wish. 359 | 360 | .. important:: 361 | 362 | By allowing content without a declared hash, both the sender and 363 | receiver are susceptible to content tampering. 364 | 365 | You can send a request without signing the content by passing this keyword 366 | argument to a :class:`mohawk.Sender`: 367 | 368 | .. doctest:: usage 369 | 370 | >>> sender = Sender(credentials, url, method, always_hash_content=False) 371 | 372 | This says to skip hashing of the ``content`` and ``content_type`` values 373 | if they are both :data:`mohawk.base.EmptyValue`. 374 | 375 | Now you'll get an ``Authorization`` header without a ``hash`` attribute: 376 | 377 | .. doctest:: usage 378 | 379 | >>> sender.request_header 380 | 'Hawk mac="...", id="some-sender", ts="...", nonce="..."' 381 | 382 | The :class:`mohawk.Receiver` must also be constructed to accept content 383 | without a declared hash using ``accept_untrusted_content=True``: 384 | 385 | .. doctest:: usage 386 | 387 | >>> receiver = Receiver(lookup_credentials, 388 | ... sender.request_header, 389 | ... request['url'], 390 | ... request['method'], 391 | ... content=request['content'], 392 | ... content_type=request['headers']['Content-Type'], 393 | ... accept_untrusted_content=True) 394 | 395 | This will skip checking the hash of ``content`` and ``content_type`` only if 396 | the ``Authorization`` header omits the ``hash`` attribute. If the ``hash`` 397 | attribute is present, it will be checked as normal. 398 | 399 | .. _empty-requests: 400 | 401 | Empty requests 402 | ============== 403 | 404 | For requests whose ``content`` (and by extension ``content_type``) is ``None`` 405 | or an empty string, it is acceptable for the sender to omit the declared hash, 406 | regardless of the ``accept_untrusted_content`` value provided to the 407 | :class:`mohawk.Receiver`. For example, a ``GET`` request typically has 408 | empty content and some libraries may or may not hash the content. 409 | 410 | If the ``hash`` attribute *is* present, a ``None`` value for either 411 | ``content`` or ``content_type`` will be coerced to an empty string 412 | prior to hashing. 413 | 414 | Generating protected URLs 415 | ========================= 416 | 417 | Hawk lets you protect a URL with a token derived from a secret key. 418 | After a period of time, access to the URL will expire. 419 | As an example, you could use this to deliver a URL for purchased media, 420 | such a zip file of MP3s. The user could access the URL for a short period 421 | of time but after that, the same URL would not be accessible. 422 | 423 | In the Hawk spec, this is referred to as `Single URI Authorization, or bewit`_. 424 | 425 | .. _`Single URI Authorization, or bewit`: https://github.com/hueniverse/hawk/#single-uri-authorization 426 | 427 | Here's an example of protecting access to this URL with Mohawk: 428 | 429 | .. doctest:: usage 430 | 431 | >>> url = 'https://site.org/purchases/music-album.zip' 432 | 433 | Let's say you want to allow access for 5 minutes: 434 | 435 | 436 | .. doctest:: usage 437 | 438 | >>> from mohawk.util import utc_now 439 | >>> url_expires_at = utc_now() + (60 * 5) 440 | 441 | Set up Hawk credentials like in previous examples: 442 | 443 | .. doctest:: usage 444 | 445 | >>> credentials = { 446 | ... 'id': 'some-recipient', 447 | ... 'key': 'a long, complicated secret', 448 | ... 'algorithm': 'sha256' 449 | ... } 450 | 451 | 452 | Define the resource that you want to protect: 453 | 454 | .. doctest:: usage 455 | 456 | >>> from mohawk.base import Resource 457 | >>> resource = Resource( 458 | ... credentials=credentials, 459 | ... url=url, 460 | ... method='GET', 461 | ... nonce='', 462 | ... timestamp=url_expires_at, 463 | ... ) 464 | 465 | Generate a bewit token: 466 | 467 | .. doctest:: usage 468 | 469 | >>> from mohawk.bewit import get_bewit 470 | >>> bewit = get_bewit(resource) 471 | 472 | Add that token as a ``bewit`` query string parameter back to the same URL: 473 | 474 | .. doctest:: usage 475 | 476 | >>> protected_url = '{url}?bewit={bewit}'.format(url=url, bewit=bewit) 477 | >>> protected_url 478 | 'https://site.org/purchases/music-album.zip?bewit=...' 479 | 480 | Now you can deliver this bewit protected URL to the recipient. 481 | 482 | Serving protected URLs 483 | ====================== 484 | 485 | When handling a request for a bewit protected URL on the server, you can 486 | begin by checking the bewit to make sure it's valid. 487 | If ``True``, the server can respond with access to the resource. 488 | The ``check_bewit`` function returns ``True`` or ``False`` and will also 489 | raise an exception for invalid ``bewit`` values. 490 | 491 | .. doctest:: usage 492 | 493 | >>> allowed_recipients = {} 494 | >>> allowed_recipients['some-recipient'] = credentials 495 | >>> def lookup_credentials(recipient_id): 496 | ... if recipient_id in allowed_recipients: 497 | ... # Return a credentials dictionary 498 | ... return allowed_recipients[recipient_id] 499 | ... else: 500 | ... raise LookupError('unknown recipient_id') 501 | >>> from mohawk.bewit import check_bewit 502 | >>> check_bewit(protected_url, credential_lookup=lookup_credentials) 503 | True 504 | 505 | 506 | .. note:: 507 | 508 | Well, that was complicated! At a future time, 509 | ``get_bewit`` and ``check_bewit`` will be complimented with a higher 510 | level function that is easier to work with. 511 | See https://github.com/kumar303/mohawk/issues/17 512 | 513 | 514 | Logging 515 | ======= 516 | 517 | All internal `logging `_ 518 | channels stem from ``mohawk``. For example, the ``mohawk.receiver`` 519 | channel will just contain receiver messages. These channels correspond 520 | to the submodules within mohawk. 521 | 522 | To debug :class:`mohawk.exc.MacMismatch` :ref:`exceptions` 523 | and other authorization errors, set the ``mohawk`` channel to ``DEBUG``. 524 | 525 | Going further 526 | ============= 527 | 528 | Well, hey, that about summarizes the concepts and basic usage of Mohawk. 529 | Check out the :ref:`API` for details. 530 | Also make sure you are familiar with :ref:`security`. 531 | 532 | .. _`TLSdate`: http://linux-audit.com/tlsdate-the-secure-alternative-for-ntpd-ntpdate-and-rdate/ 533 | .. _`Hawk`: https://github.com/hueniverse/hawk 534 | .. _`requests`: http://docs.python-requests.org/ 535 | -------------------------------------------------------------------------------- /docs/why.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Why Mohawk? 3 | =========== 4 | 5 | * I started using `PyHawk`_ because it was written by Austin King and he's 6 | awesome. 7 | * `PyHawk`_ is a direct port from Node but this did not seem to fit right 8 | with Python, especially in how Node's style is to attempt internal error 9 | recovery and Python's style is to raise exceptions that calling code 10 | can recover from. 11 | * I was paranoid about how `PyHawk`_ (and maybe the Node lib too) makes it 12 | easy to ignore content hashing. If programmers accidentally 13 | disregard hash checks then that would be bad. 14 | * I started patching `PyHawk`_ but became confused about the lifecycle of 15 | the request/response. 16 | * PyHawk didn't have a lot of tests for edge cases (like content tampering) so 17 | it was hard to patch. 18 | * I started on some Django middleware using PyHawk and found myself creating a 19 | lot of adapters for undocumented internal dictionary structures which felt 20 | wrong. 21 | * The PyHawk/Node API relies on pre-generated header artifacts but this feels 22 | clunky to me. I wanted that to be an implementation detail. 23 | * The required order in which you need to pre-generate artifacts is not 24 | implicitly enforced by the PyHawk/Node API which can lead to mistakes 25 | if programmers re-use objects across requests. 26 | * I re-wrote the class/function interface into something that I thought made 27 | sense then I re-wrote it three more times until it started to 28 | actually make sense. 29 | * I developed test first with a comprehensive suite focusing on the 30 | threat model that Hawk is designed to protect you from. 31 | This helped me arrive at an API that should help developers write secure 32 | applications by default. 33 | * I re-used a lot of `PyHawk`_ code :) 34 | 35 | .. _`PyHawk`: https://github.com/mozilla/PyHawk 36 | -------------------------------------------------------------------------------- /mohawk/__init__.py: -------------------------------------------------------------------------------- 1 | from .sender import * 2 | from .receiver import * 3 | -------------------------------------------------------------------------------- /mohawk/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | import pprint 4 | 5 | import six 6 | from six.moves.urllib.parse import urlparse 7 | 8 | from .exc import (AlreadyProcessed, 9 | MacMismatch, 10 | MisComputedContentHash, 11 | TokenExpired, 12 | MissingContent) 13 | from .util import (calculate_mac, 14 | calculate_payload_hash, 15 | calculate_ts_mac, 16 | prepare_header_val, 17 | random_string, 18 | strings_match, 19 | utc_now) 20 | 21 | default_ts_skew_in_seconds = 60 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | class HawkEmptyValue(object): 26 | 27 | def __eq__(self, other): 28 | return isinstance(other, self.__class__) 29 | 30 | def __ne__(self, other): 31 | return (not self.__eq__(other)) 32 | 33 | def __nonzero__(self): 34 | return False 35 | 36 | def __bool__(self): 37 | return False 38 | 39 | def __repr__(self): 40 | return 'EmptyValue' 41 | 42 | EmptyValue = HawkEmptyValue() 43 | 44 | 45 | class HawkAuthority: 46 | 47 | def _authorize(self, mac_type, parsed_header, resource, 48 | their_timestamp=None, 49 | timestamp_skew_in_seconds=default_ts_skew_in_seconds, 50 | localtime_offset_in_seconds=0, 51 | accept_untrusted_content=False): 52 | 53 | now = utc_now(offset_in_seconds=localtime_offset_in_seconds) 54 | 55 | their_hash = parsed_header.get('hash', '') 56 | their_mac = parsed_header.get('mac', '') 57 | mac = calculate_mac(mac_type, resource, their_hash) 58 | if not strings_match(mac, their_mac): 59 | raise MacMismatch('MACs do not match; ours: {ours}; ' 60 | 'theirs: {theirs}' 61 | .format(ours=mac, theirs=their_mac)) 62 | 63 | check_hash = True 64 | 65 | if 'hash' not in parsed_header: 66 | # The request did not hash its content. 67 | if not resource.content and not resource.content_type: 68 | # It is acceptable to not receive a hash if there is no content 69 | # to hash. 70 | log.debug('NOT calculating/verifying payload hash ' 71 | '(no hash in header, request body is empty)') 72 | check_hash = False 73 | elif accept_untrusted_content: 74 | # Allow the request, even if it has content. Missing content or 75 | # content_type values will be coerced to the empty string for 76 | # hashing purposes. 77 | log.debug('NOT calculating/verifying payload hash ' 78 | '(no hash in header, accept_untrusted_content=True)') 79 | check_hash = False 80 | 81 | if check_hash: 82 | if not their_hash: 83 | log.info('request unexpectedly did not hash its content') 84 | 85 | content_hash = resource.gen_content_hash() 86 | 87 | if not strings_match(content_hash, their_hash): 88 | # The hash declared in the header is incorrect. 89 | # Content could have been tampered with. 90 | log.debug('mismatched content: {content}' 91 | .format(content=repr(resource.content))) 92 | log.debug('mismatched content-type: {typ}' 93 | .format(typ=repr(resource.content_type))) 94 | raise MisComputedContentHash( 95 | 'Our hash {ours} ({algo}) did not ' 96 | 'match theirs {theirs}' 97 | .format(ours=content_hash, 98 | theirs=their_hash, 99 | algo=resource.credentials['algorithm'])) 100 | 101 | if resource.seen_nonce: 102 | if resource.seen_nonce(resource.credentials['id'], 103 | parsed_header['nonce'], 104 | parsed_header['ts']): 105 | raise AlreadyProcessed('Nonce {nonce} with timestamp {ts} ' 106 | 'has already been processed for {id}' 107 | .format(nonce=parsed_header['nonce'], 108 | ts=parsed_header['ts'], 109 | id=resource.credentials['id'])) 110 | else: 111 | log.warning('seen_nonce was None; not checking nonce. ' 112 | 'You may be vulnerable to replay attacks') 113 | 114 | their_ts = int(their_timestamp or parsed_header['ts']) 115 | 116 | if math.fabs(their_ts - now) > timestamp_skew_in_seconds: 117 | message = ('token with UTC timestamp {ts} has expired; ' 118 | 'it was compared to {now}' 119 | .format(ts=their_ts, now=now)) 120 | tsm = calculate_ts_mac(now, resource.credentials) 121 | if isinstance(tsm, six.binary_type): 122 | tsm = tsm.decode('ascii') 123 | www_authenticate = ('Hawk ts="{ts}", tsm="{tsm}", error="{error}"' 124 | .format(ts=now, tsm=tsm, error=message)) 125 | raise TokenExpired(message, 126 | localtime_in_seconds=now, 127 | www_authenticate=www_authenticate) 128 | 129 | log.debug('authorized OK') 130 | 131 | def _make_header(self, resource, mac, additional_keys=None): 132 | keys = additional_keys 133 | if not keys: 134 | # These are the default header keys that you'd send with a 135 | # request header. Response headers are odd because they 136 | # exclude a bunch of keys. 137 | keys = ('id', 'ts', 'nonce', 'ext', 'app', 'dlg') 138 | 139 | header = u'Hawk mac="{mac}"'.format(mac=prepare_header_val(mac)) 140 | 141 | if resource.content_hash: 142 | header = u'{header}, hash="{hash}"'.format( 143 | header=header, 144 | hash=prepare_header_val(resource.content_hash)) 145 | 146 | if 'id' in keys: 147 | header = u'{header}, id="{id}"'.format( 148 | header=header, 149 | id=prepare_header_val(resource.credentials['id'])) 150 | 151 | if 'ts' in keys: 152 | header = u'{header}, ts="{ts}"'.format( 153 | header=header, ts=prepare_header_val(resource.timestamp)) 154 | 155 | if 'nonce' in keys: 156 | header = u'{header}, nonce="{nonce}"'.format( 157 | header=header, nonce=prepare_header_val(resource.nonce)) 158 | 159 | # These are optional so we need to check if they have values first. 160 | 161 | if 'ext' in keys and resource.ext: 162 | header = u'{header}, ext="{ext}"'.format( 163 | header=header, ext=prepare_header_val(resource.ext)) 164 | 165 | if 'app' in keys and resource.app: 166 | header = u'{header}, app="{app}"'.format( 167 | header=header, app=prepare_header_val(resource.app)) 168 | 169 | if 'dlg' in keys and resource.dlg: 170 | header = u'{header}, dlg="{dlg}"'.format( 171 | header=header, dlg=prepare_header_val(resource.dlg)) 172 | 173 | log.debug('Hawk header for URL={url} method={method}: {header}' 174 | .format(url=resource.url, method=resource.method, 175 | header=header)) 176 | return header 177 | 178 | 179 | class Resource: 180 | """ 181 | Normalized request / response resource. 182 | 183 | :param credentials: 184 | A dict of credentials; it must have the keys: 185 | ``id``, ``key``, and ``algorithm``. 186 | See :ref:`sending-request` for an example. 187 | :type credentials_map: dict 188 | 189 | :param url: Absolute URL of the request / response. 190 | :type url: str 191 | 192 | :param method: Method of the request / response. E.G. POST, GET 193 | :type method: str 194 | 195 | :param content=EmptyValue: Byte string of request / response body. 196 | :type content=EmptyValue: str 197 | 198 | :param content_type=EmptyValue: content-type header value for request / response. 199 | :type content_type=EmptyValue: str 200 | 201 | :param always_hash_content=True: 202 | When True, ``content`` and ``content_type`` must be provided. 203 | Read :ref:`skipping-content-checks` to learn more. 204 | :type always_hash_content=True: bool 205 | 206 | :param ext=None: 207 | An external `Hawk`_ string. If not None, this value will be 208 | signed so that the sender can trust it. 209 | :type ext=None: str 210 | 211 | :param app=None: 212 | A `Hawk`_ string identifying an external application. 213 | :type app=None: str 214 | 215 | :param dlg=None: 216 | A `Hawk`_ string identifying a "delegated by" value. 217 | :type dlg=None: str 218 | 219 | :param timestamp=utc_now(): 220 | A unix timestamp integer, in UTC 221 | :type timestamp: int 222 | 223 | :param nonce=None: 224 | A string that when coupled with the timestamp will 225 | uniquely identify this request / response. 226 | :type nonce=None: str 227 | 228 | :param seen_nonce=None: 229 | A callable that returns True if a nonce has been seen. 230 | See :ref:`nonce` for details. 231 | :type seen_nonce=None: callable 232 | 233 | .. _`Hawk`: https://github.com/hueniverse/hawk 234 | """ 235 | 236 | def __init__(self, **kw): 237 | self.credentials = kw.pop('credentials') 238 | self.credentials['id'] = prepare_header_val(self.credentials['id']) 239 | self.method = kw.pop('method').upper() 240 | self.content = kw.pop('content', EmptyValue) 241 | self.content_type = kw.pop('content_type', EmptyValue) 242 | self.always_hash_content = kw.pop('always_hash_content', True) 243 | self.ext = kw.pop('ext', None) 244 | self.app = kw.pop('app', None) 245 | self.dlg = kw.pop('dlg', None) 246 | 247 | self.timestamp = str(kw.pop('timestamp', None) or utc_now()) 248 | 249 | self.nonce = kw.pop('nonce', None) 250 | if self.nonce is None: 251 | self.nonce = random_string(6) 252 | 253 | # This is a lookup function for checking nonces. 254 | self.seen_nonce = kw.pop('seen_nonce', None) 255 | 256 | self.url = kw.pop('url') 257 | if not self.url: 258 | raise ValueError('url was empty') 259 | url_parts = self.parse_url(self.url) 260 | log.debug('parsed URL parts: \n{parts}' 261 | .format(parts=pprint.pformat(url_parts))) 262 | 263 | self.name = url_parts['resource'] or '' 264 | self.host = url_parts['hostname'] or '' 265 | self.port = str(url_parts['port']) 266 | 267 | if kw.keys(): 268 | raise TypeError('Unknown keyword argument(s): {0}' 269 | .format(kw.keys())) 270 | 271 | @property 272 | def content_hash(self): 273 | if not hasattr(self, '_content_hash'): 274 | raise AttributeError( 275 | 'Cannot access content_hash because it has not been generated') 276 | return self._content_hash 277 | 278 | def gen_content_hash(self): 279 | if self.content == EmptyValue or self.content_type == EmptyValue: 280 | if self.always_hash_content: 281 | # Be really strict about allowing developers to skip content 282 | # hashing. If they get this far they may be unintentiionally 283 | # skipping it. 284 | raise MissingContent( 285 | 'payload content and/or content_type cannot be ' 286 | 'empty when always_hash_content is True') 287 | log.debug('NOT hashing content') 288 | self._content_hash = None 289 | else: 290 | self._content_hash = calculate_payload_hash( 291 | self.content, self.credentials['algorithm'], 292 | self.content_type) 293 | return self.content_hash 294 | 295 | def parse_url(self, url): 296 | url_parts = urlparse(url) 297 | url_dict = { 298 | 'scheme': url_parts.scheme, 299 | 'hostname': url_parts.hostname, 300 | 'port': url_parts.port, 301 | 'path': url_parts.path, 302 | 'resource': url_parts.path, 303 | 'query': url_parts.query, 304 | } 305 | if len(url_dict['query']) > 0: 306 | url_dict['resource'] = '%s?%s' % (url_dict['resource'], 307 | url_dict['query']) 308 | 309 | if url_parts.port is None: 310 | if url_parts.scheme == 'http': 311 | url_dict['port'] = 80 312 | elif url_parts.scheme == 'https': 313 | url_dict['port'] = 443 314 | return url_dict 315 | -------------------------------------------------------------------------------- /mohawk/bewit.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64encode, b64decode 2 | from collections import namedtuple 3 | import logging 4 | import re 5 | 6 | import six 7 | 8 | from .base import Resource 9 | from .util import (calculate_mac, 10 | strings_match, 11 | utc_now, 12 | validate_header_attr) 13 | from .exc import (CredentialsLookupError, 14 | InvalidBewit, 15 | MacMismatch, 16 | TokenExpired) 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | def get_bewit(resource): 22 | """ 23 | Returns a bewit identifier for the resource as a string. 24 | 25 | :param resource: 26 | Resource to generate a bewit for 27 | :type resource: `mohawk.base.Resource` 28 | """ 29 | if resource.method != 'GET': 30 | raise ValueError('bewits can only be generated for GET requests') 31 | if resource.nonce != '': 32 | raise ValueError('bewits must use an empty nonce') 33 | mac = calculate_mac( 34 | 'bewit', 35 | resource, 36 | None, 37 | ) 38 | 39 | if isinstance(mac, six.binary_type): 40 | mac = mac.decode('ascii') 41 | 42 | if resource.ext is None: 43 | ext = '' 44 | else: 45 | validate_header_attr(resource.ext, name='ext') 46 | ext = resource.ext 47 | 48 | # b64encode works only with bytes in python3, but all of our parameters are 49 | # in unicode, so we need to encode them. The cleanest way to do this that 50 | # works in both python 2 and 3 is to use string formatting to get a 51 | # unicode string, and then explicitly encode it to bytes. 52 | inner_bewit = u"{id}\\{exp}\\{mac}\\{ext}".format( 53 | id=resource.credentials['id'], 54 | exp=resource.timestamp, 55 | mac=mac, 56 | ext=ext, 57 | ) 58 | inner_bewit_bytes = inner_bewit.encode('ascii') 59 | bewit_bytes = urlsafe_b64encode(inner_bewit_bytes) 60 | # Now decode the resulting bytes back to a unicode string 61 | return bewit_bytes.decode('ascii') 62 | 63 | 64 | bewittuple = namedtuple('bewittuple', 'id expiration mac ext') 65 | 66 | 67 | def parse_bewit(bewit): 68 | """ 69 | Returns a `bewittuple` representing the parts of an encoded bewit string. 70 | This has the following named attributes: 71 | (id, expiration, mac, ext) 72 | 73 | :param bewit: 74 | A base64 encoded bewit string 75 | :type bewit: str 76 | """ 77 | decoded_bewit = b64decode(bewit).decode('ascii') 78 | bewit_parts = decoded_bewit.split("\\") 79 | if len(bewit_parts) != 4: 80 | raise InvalidBewit('Expected 4 parts to bewit: %s' % decoded_bewit) 81 | return bewittuple(*bewit_parts) 82 | 83 | 84 | def strip_bewit(url): 85 | """ 86 | Strips the bewit parameter out of a url. 87 | 88 | Returns (encoded_bewit, stripped_url) 89 | 90 | Raises InvalidBewit if no bewit found. 91 | 92 | :param url: 93 | The url containing a bewit parameter 94 | :type url: str 95 | """ 96 | m = re.search('[?&]bewit=([^&]+)', url) 97 | if not m: 98 | raise InvalidBewit('no bewit data found') 99 | bewit = m.group(1) 100 | stripped_url = url[:m.start()] + url[m.end():] 101 | return bewit, stripped_url 102 | 103 | 104 | def check_bewit(url, credential_lookup, now=None): 105 | """ 106 | Validates the given bewit. 107 | 108 | Returns True if the resource has a valid bewit parameter attached, 109 | or raises a subclass of HawkFail otherwise. 110 | 111 | :param credential_lookup: 112 | Callable to look up the credentials dict by sender ID. 113 | The credentials dict must have the keys: 114 | ``id``, ``key``, and ``algorithm``. 115 | See :ref:`receiving-request` for an example. 116 | :type credential_lookup: callable 117 | 118 | :param now=None: 119 | Unix epoch time for the current time to determine if bewit has expired. 120 | If None, then the current time as given by utc_now() is used. 121 | :type now=None: integer 122 | """ 123 | raw_bewit, stripped_url = strip_bewit(url) 124 | bewit = parse_bewit(raw_bewit) 125 | try: 126 | credentials = credential_lookup(bewit.id) 127 | except LookupError: 128 | raise CredentialsLookupError('Could not find credentials for ID {0}' 129 | .format(bewit.id)) 130 | 131 | res = Resource(url=stripped_url, 132 | method='GET', 133 | credentials=credentials, 134 | timestamp=bewit.expiration, 135 | nonce='', 136 | ext=bewit.ext, 137 | ) 138 | mac = calculate_mac('bewit', res, None) 139 | mac = mac.decode('ascii') 140 | 141 | if not strings_match(mac, bewit.mac): 142 | raise MacMismatch('bewit with mac {bewit_mac} did not match expected mac {expected_mac}' 143 | .format(bewit_mac=bewit.mac, 144 | expected_mac=mac)) 145 | 146 | # Check that the timestamp isn't expired 147 | if now is None: 148 | # TODO: Add offset/skew 149 | now = utc_now() 150 | if int(bewit.expiration) < now: 151 | # TODO: Refactor TokenExpired to handle this better 152 | raise TokenExpired('bewit with UTC timestamp {ts} has expired; ' 153 | 'it was compared to {now}' 154 | .format(ts=bewit.expiration, now=now), 155 | localtime_in_seconds=now, 156 | www_authenticate='' 157 | ) 158 | 159 | return True 160 | -------------------------------------------------------------------------------- /mohawk/exc.py: -------------------------------------------------------------------------------- 1 | """ 2 | If you want to catch any exception that might be raised, 3 | catch :class:`mohawk.exc.HawkFail`. 4 | 5 | .. important:: 6 | 7 | Never expose an exception message publicly, say, in an HTTP 8 | response, as it may provide hints to an attacker. 9 | """ 10 | 11 | 12 | class HawkFail(Exception): 13 | """ 14 | All Mohawk exceptions derive from this base. 15 | """ 16 | 17 | 18 | class MissingAuthorization(HawkFail): 19 | """ 20 | No authorization header was sent by the client. 21 | """ 22 | 23 | 24 | class InvalidCredentials(HawkFail): 25 | """ 26 | The specified Hawk credentials are invalid. 27 | 28 | For example, the dict could be formatted incorrectly. 29 | """ 30 | 31 | 32 | class CredentialsLookupError(HawkFail): 33 | """ 34 | A :class:`mohawk.Receiver` could not look up the 35 | credentials for an incoming request. 36 | """ 37 | 38 | 39 | class BadHeaderValue(HawkFail): 40 | """ 41 | There was an error with an attribute or value when parsing 42 | or creating a Hawk header. 43 | """ 44 | 45 | 46 | class MacMismatch(HawkFail): 47 | """ 48 | The locally calculated MAC did not match the MAC that was sent. 49 | """ 50 | 51 | 52 | class MisComputedContentHash(HawkFail): 53 | """ 54 | The signature of the content did not match the actual content. 55 | """ 56 | 57 | 58 | class TokenExpired(HawkFail): 59 | """ 60 | The timestamp on a message received has expired. 61 | 62 | You may also receive this message if your server clock is out of sync. 63 | Consider synchronizing it with something like `TLSdate`_. 64 | 65 | If you are unable to synchronize your clock universally, 66 | The `Hawk`_ spec mentions how you can `adjust`_ 67 | your sender's time to match that of the receiver in the case 68 | of unexpected expiration. 69 | 70 | The ``www_authenticate`` attribute of this exception is a header 71 | that can be returned to the client. If the value is not None, it 72 | will include a timestamp HMAC'd with the sender's credentials. 73 | This will allow the client 74 | to verify the value and safely apply an offset. 75 | 76 | .. _`Hawk`: https://github.com/hueniverse/hawk 77 | .. _`adjust`: https://github.com/hueniverse/hawk#future-time-manipulation 78 | .. _`TLSdate`: http://linux-audit.com/tlsdate-the-secure-alternative-for-ntpd-ntpdate-and-rdate/ 79 | """ 80 | #: Current local time in seconds that was used to compare timestamps. 81 | localtime_in_seconds = None 82 | # A header containing an HMAC'd server timestamp that the sender can verify. 83 | www_authenticate = None 84 | 85 | def __init__(self, *args, **kw): 86 | self.localtime_in_seconds = kw.pop('localtime_in_seconds') 87 | self.www_authenticate = kw.pop('www_authenticate') 88 | super(HawkFail, self).__init__(*args, **kw) 89 | 90 | 91 | class AlreadyProcessed(HawkFail): 92 | """ 93 | The message has already been processed and cannot be re-processed. 94 | 95 | See :ref:`nonce` for details. 96 | """ 97 | 98 | 99 | class InvalidBewit(HawkFail): 100 | """ 101 | The bewit is invalid; e.g. it doesn't contain the right number of 102 | parameters. 103 | """ 104 | 105 | 106 | class MissingContent(HawkFail): 107 | """ 108 | A payload's `content` or `content_type` were not provided. 109 | 110 | See :ref:`skipping-content-checks` for details. 111 | """ 112 | -------------------------------------------------------------------------------- /mohawk/receiver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from .base import (default_ts_skew_in_seconds, 5 | HawkAuthority, 6 | Resource, 7 | EmptyValue) 8 | from .exc import CredentialsLookupError, MissingAuthorization 9 | from .util import (calculate_mac, 10 | parse_authorization_header, 11 | validate_credentials) 12 | 13 | __all__ = ['Receiver'] 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | class Receiver(HawkAuthority): 18 | """ 19 | A Hawk authority that will receive and respond to requests. 20 | 21 | :param credentials_map: 22 | Callable to look up the credentials dict by sender ID. 23 | The credentials dict must have the keys: 24 | ``id``, ``key``, and ``algorithm``. 25 | See :ref:`receiving-request` for an example. 26 | :type credentials_map: callable 27 | 28 | :param request_header: 29 | A `Hawk`_ ``Authorization`` header 30 | such as one created by :class:`mohawk.Sender`. 31 | :type request_header: str 32 | 33 | :param url: Absolute URL of the request. 34 | :type url: str 35 | 36 | :param method: Method of the request. E.G. POST, GET 37 | :type method: str 38 | 39 | :param content=EmptyValue: Byte string of request body. 40 | :type content=EmptyValue: str 41 | 42 | :param content_type=EmptyValue: content-type header value for request. 43 | :type content_type=EmptyValue: str 44 | 45 | :param accept_untrusted_content=False: 46 | When True, allow requests that do not hash their content. 47 | Read :ref:`skipping-content-checks` to learn more. 48 | :type accept_untrusted_content=False: bool 49 | 50 | :param localtime_offset_in_seconds=0: 51 | Seconds to add to local time in case it's out of sync. 52 | :type localtime_offset_in_seconds=0: float 53 | 54 | :param timestamp_skew_in_seconds=60: 55 | Max seconds until a message expires. Upon expiry, 56 | :class:`mohawk.exc.TokenExpired` is raised. 57 | :type timestamp_skew_in_seconds=60: float 58 | 59 | .. _`Hawk`: https://github.com/hueniverse/hawk 60 | """ 61 | #: Value suitable for a ``Server-Authorization`` header. 62 | response_header = None 63 | 64 | def __init__(self, 65 | credentials_map, 66 | request_header, 67 | url, 68 | method, 69 | content=EmptyValue, 70 | content_type=EmptyValue, 71 | seen_nonce=None, 72 | localtime_offset_in_seconds=0, 73 | accept_untrusted_content=False, 74 | timestamp_skew_in_seconds=default_ts_skew_in_seconds, 75 | **auth_kw): 76 | 77 | self.response_header = None # make into property that can raise exc? 78 | self.credentials_map = credentials_map 79 | self.seen_nonce = seen_nonce 80 | 81 | log.debug('accepting request {header}'.format(header=request_header)) 82 | 83 | if not request_header: 84 | raise MissingAuthorization() 85 | 86 | parsed_header = parse_authorization_header(request_header) 87 | 88 | try: 89 | credentials = self.credentials_map(parsed_header['id']) 90 | except LookupError: 91 | etype, val, tb = sys.exc_info() 92 | log.debug('Catching {etype}: {val}'.format(etype=etype, val=val)) 93 | raise CredentialsLookupError( 94 | 'Could not find credentials for ID {0}' 95 | .format(parsed_header['id'])) 96 | validate_credentials(credentials) 97 | 98 | resource = Resource(url=url, 99 | method=method, 100 | ext=parsed_header.get('ext', None), 101 | app=parsed_header.get('app', None), 102 | dlg=parsed_header.get('dlg', None), 103 | credentials=credentials, 104 | nonce=parsed_header['nonce'], 105 | seen_nonce=self.seen_nonce, 106 | content=content, 107 | timestamp=parsed_header['ts'], 108 | content_type=content_type) 109 | 110 | self._authorize( 111 | 'header', parsed_header, resource, 112 | timestamp_skew_in_seconds=timestamp_skew_in_seconds, 113 | localtime_offset_in_seconds=localtime_offset_in_seconds, 114 | accept_untrusted_content=accept_untrusted_content, 115 | **auth_kw) 116 | 117 | # Now that we verified an incoming request, we can re-use some of its 118 | # properties to build our response header. 119 | 120 | self.parsed_header = parsed_header 121 | self.resource = resource 122 | 123 | def respond(self, 124 | content=EmptyValue, 125 | content_type=EmptyValue, 126 | always_hash_content=True, 127 | ext=None): 128 | """ 129 | Respond to the request. 130 | 131 | This generates the :attr:`mohawk.Receiver.response_header` 132 | attribute. 133 | 134 | :param content=EmptyValue: Byte string of response body that will be sent. 135 | :type content=EmptyValue: str 136 | 137 | :param content_type=EmptyValue: content-type header value for response. 138 | :type content_type=EmptyValue: str 139 | 140 | :param always_hash_content=True: 141 | When True, ``content`` and ``content_type`` must be provided. 142 | Read :ref:`skipping-content-checks` to learn more. 143 | :type always_hash_content=True: bool 144 | 145 | :param ext=None: 146 | An external `Hawk`_ string. If not None, this value will be 147 | signed so that the sender can trust it. 148 | :type ext=None: str 149 | 150 | .. _`Hawk`: https://github.com/hueniverse/hawk 151 | """ 152 | 153 | log.debug('generating response header') 154 | 155 | resource = Resource(url=self.resource.url, 156 | credentials=self.resource.credentials, 157 | ext=ext, 158 | app=self.parsed_header.get('app', None), 159 | dlg=self.parsed_header.get('dlg', None), 160 | method=self.resource.method, 161 | content=content, 162 | content_type=content_type, 163 | always_hash_content=always_hash_content, 164 | nonce=self.parsed_header['nonce'], 165 | timestamp=self.parsed_header['ts']) 166 | 167 | mac = calculate_mac('response', resource, resource.gen_content_hash()) 168 | 169 | self.response_header = self._make_header(resource, mac, 170 | additional_keys=['ext']) 171 | return self.response_header 172 | -------------------------------------------------------------------------------- /mohawk/sender.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .base import (default_ts_skew_in_seconds, 4 | HawkAuthority, 5 | Resource, 6 | EmptyValue) 7 | from .util import (calculate_mac, 8 | parse_authorization_header, 9 | validate_credentials) 10 | 11 | __all__ = ['Sender'] 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class Sender(HawkAuthority): 16 | """ 17 | A Hawk authority that will emit requests and verify responses. 18 | 19 | :param credentials: Dict of credentials with keys ``id``, ``key``, 20 | and ``algorithm``. See :ref:`usage` for an example. 21 | :type credentials: dict 22 | 23 | :param url: Absolute URL of the request. 24 | :type url: str 25 | 26 | :param method: Method of the request. E.G. POST, GET 27 | :type method: str 28 | 29 | :param content=EmptyValue: Byte string of request body or a file-like object. 30 | :type content=EmptyValue: str or file-like object 31 | 32 | :param content_type=EmptyValue: content-type header value for request. 33 | :type content_type=EmptyValue: str 34 | 35 | :param always_hash_content=True: 36 | When True, ``content`` and ``content_type`` must be provided. 37 | Read :ref:`skipping-content-checks` to learn more. 38 | :type always_hash_content=True: bool 39 | 40 | :param nonce=None: 41 | A string that when coupled with the timestamp will 42 | uniquely identify this request to prevent replays. 43 | If None, a nonce will be generated for you. 44 | :type nonce=None: str 45 | 46 | :param ext=None: 47 | An external `Hawk`_ string. If not None, this value will be signed 48 | so that the receiver can trust it. 49 | :type ext=None: str 50 | 51 | :param app=None: 52 | A `Hawk`_ application string. If not None, this value will be signed 53 | so that the receiver can trust it. 54 | :type app=None: str 55 | 56 | :param dlg=None: 57 | A `Hawk`_ delegation string. If not None, this value will be signed 58 | so that the receiver can trust it. 59 | :type dlg=None: str 60 | 61 | :param seen_nonce=None: 62 | A callable that returns True if a nonce has been seen. 63 | See :ref:`nonce` for details. 64 | :type seen_nonce=None: callable 65 | 66 | .. _`Hawk`: https://github.com/hueniverse/hawk 67 | """ 68 | #: Value suitable for an ``Authorization`` header. 69 | request_header = None 70 | 71 | def __init__(self, credentials, 72 | url, 73 | method, 74 | content=EmptyValue, 75 | content_type=EmptyValue, 76 | always_hash_content=True, 77 | nonce=None, 78 | ext=None, 79 | app=None, 80 | dlg=None, 81 | seen_nonce=None, 82 | # For easier testing: 83 | _timestamp=None): 84 | 85 | self.reconfigure(credentials) 86 | self.request_header = None 87 | self.seen_nonce = seen_nonce 88 | 89 | log.debug('generating request header') 90 | self.req_resource = Resource(url=url, 91 | credentials=self.credentials, 92 | ext=ext, 93 | app=app, 94 | dlg=dlg, 95 | nonce=nonce, 96 | method=method, 97 | content=content, 98 | always_hash_content=always_hash_content, 99 | timestamp=_timestamp, 100 | content_type=content_type) 101 | 102 | mac = calculate_mac('header', self.req_resource, 103 | self.req_resource.gen_content_hash()) 104 | self.request_header = self._make_header(self.req_resource, mac) 105 | 106 | def accept_response(self, 107 | response_header, 108 | content=EmptyValue, 109 | content_type=EmptyValue, 110 | accept_untrusted_content=False, 111 | localtime_offset_in_seconds=0, 112 | timestamp_skew_in_seconds=default_ts_skew_in_seconds, 113 | **auth_kw): 114 | """ 115 | Accept a response to this request. 116 | 117 | :param response_header: 118 | A `Hawk`_ ``Server-Authorization`` header 119 | such as one created by :class:`mohawk.Receiver`. 120 | :type response_header: str 121 | 122 | :param content=EmptyValue: Byte string of the response body received. 123 | :type content=EmptyValue: str 124 | 125 | :param content_type=EmptyValue: 126 | Content-Type header value of the response received. 127 | :type content_type=EmptyValue: str 128 | 129 | :param accept_untrusted_content=False: 130 | When True, allow responses that do not hash their content. 131 | Read :ref:`skipping-content-checks` to learn more. 132 | :type accept_untrusted_content=False: bool 133 | 134 | :param localtime_offset_in_seconds=0: 135 | Seconds to add to local time in case it's out of sync. 136 | :type localtime_offset_in_seconds=0: float 137 | 138 | :param timestamp_skew_in_seconds=60: 139 | Max seconds until a message expires. Upon expiry, 140 | :class:`mohawk.exc.TokenExpired` is raised. 141 | :type timestamp_skew_in_seconds=60: float 142 | 143 | .. _`Hawk`: https://github.com/hueniverse/hawk 144 | """ 145 | log.debug('accepting response {header}' 146 | .format(header=response_header)) 147 | 148 | parsed_header = parse_authorization_header(response_header) 149 | 150 | resource = Resource(ext=parsed_header.get('ext', None), 151 | content=content, 152 | content_type=content_type, 153 | # The following response attributes are 154 | # in reference to the original request, 155 | # not to the reponse header: 156 | timestamp=self.req_resource.timestamp, 157 | nonce=self.req_resource.nonce, 158 | url=self.req_resource.url, 159 | method=self.req_resource.method, 160 | app=self.req_resource.app, 161 | dlg=self.req_resource.dlg, 162 | credentials=self.credentials, 163 | seen_nonce=self.seen_nonce) 164 | 165 | self._authorize( 166 | 'response', parsed_header, resource, 167 | # Per Node lib, a responder macs the *sender's* timestamp. 168 | # It does not create its own timestamp. 169 | # I suppose a slow response could time out here. Maybe only check 170 | # mac failures, not timeouts? 171 | their_timestamp=resource.timestamp, 172 | timestamp_skew_in_seconds=timestamp_skew_in_seconds, 173 | localtime_offset_in_seconds=localtime_offset_in_seconds, 174 | accept_untrusted_content=accept_untrusted_content, 175 | **auth_kw) 176 | 177 | def reconfigure(self, credentials): 178 | validate_credentials(credentials) 179 | self.credentials = credentials 180 | -------------------------------------------------------------------------------- /mohawk/tests.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | from unittest import TestCase 4 | from base64 import b64decode, urlsafe_b64encode 5 | 6 | import mock 7 | from nose.tools import eq_, raises 8 | import six 9 | 10 | from . import Receiver, Sender 11 | from .base import Resource, EmptyValue 12 | from .exc import (AlreadyProcessed, 13 | BadHeaderValue, 14 | CredentialsLookupError, 15 | HawkFail, 16 | InvalidCredentials, 17 | MacMismatch, 18 | MisComputedContentHash, 19 | MissingAuthorization, 20 | TokenExpired, 21 | InvalidBewit, 22 | MissingContent) 23 | from .util import (parse_authorization_header, 24 | utc_now, 25 | calculate_payload_hash, 26 | calculate_ts_mac, 27 | validate_credentials) 28 | from .bewit import (get_bewit, 29 | check_bewit, 30 | strip_bewit, 31 | parse_bewit) 32 | 33 | 34 | # Ensure deprecation warnings are turned to exceptions 35 | warnings.filterwarnings('error') 36 | 37 | 38 | class Base(TestCase): 39 | 40 | def setUp(self): 41 | self.credentials = { 42 | 'id': 'my-hawk-id', 43 | 'key': 'my hAwK sekret', 44 | 'algorithm': 'sha256', 45 | } 46 | 47 | # This callable might be replaced by tests. 48 | def seen_nonce(id, nonce, ts): 49 | return False 50 | self.seen_nonce = seen_nonce 51 | 52 | def credentials_map(self, id): 53 | # Pretend this is doing something more interesting like looking up 54 | # a credentials by ID in a database. 55 | if self.credentials['id'] != id: 56 | raise LookupError('No credentialsuration for Hawk ID {id}' 57 | .format(id=id)) 58 | return self.credentials 59 | 60 | 61 | class TestConfig(Base): 62 | 63 | @raises(InvalidCredentials) 64 | def test_no_id(self): 65 | c = self.credentials.copy() 66 | del c['id'] 67 | validate_credentials(c) 68 | 69 | @raises(InvalidCredentials) 70 | def test_no_key(self): 71 | c = self.credentials.copy() 72 | del c['key'] 73 | validate_credentials(c) 74 | 75 | @raises(InvalidCredentials) 76 | def test_no_algo(self): 77 | c = self.credentials.copy() 78 | del c['algorithm'] 79 | validate_credentials(c) 80 | 81 | @raises(InvalidCredentials) 82 | def test_no_credentials(self): 83 | validate_credentials(None) 84 | 85 | def test_non_dict_credentials(self): 86 | class WeirdThing(object): 87 | def __getitem__(self, key): 88 | return 'whatever' 89 | validate_credentials(WeirdThing()) 90 | 91 | 92 | class TestSender(Base): 93 | 94 | def setUp(self): 95 | super(TestSender, self).setUp() 96 | self.url = 'http://site.com/foo?bar=1' 97 | 98 | def Sender(self, method='GET', **kw): 99 | credentials = kw.pop('credentials', self.credentials) 100 | kw.setdefault('content', '') 101 | kw.setdefault('content_type', '') 102 | sender = Sender(credentials, self.url, method, **kw) 103 | return sender 104 | 105 | def receive(self, request_header, url=None, method='GET', **kw): 106 | credentials_map = kw.pop('credentials_map', self.credentials_map) 107 | kw.setdefault('content', '') 108 | kw.setdefault('content_type', '') 109 | kw.setdefault('seen_nonce', self.seen_nonce) 110 | return Receiver(credentials_map, request_header, 111 | url or self.url, method, **kw) 112 | 113 | def test_get_ok(self): 114 | method = 'GET' 115 | sn = self.Sender(method=method) 116 | self.receive(sn.request_header, method=method) 117 | 118 | def test_post_ok(self): 119 | method = 'POST' 120 | sn = self.Sender(method=method) 121 | self.receive(sn.request_header, method=method) 122 | 123 | def test_post_content_ok(self): 124 | method = 'POST' 125 | content = 'foo=bar&baz=2' 126 | sn = self.Sender(method=method, content=content) 127 | self.receive(sn.request_header, method=method, content=content) 128 | 129 | def test_post_content_type_ok(self): 130 | method = 'POST' 131 | content = '{"bar": "foobs"}' 132 | content_type = 'application/json' 133 | sn = self.Sender(method=method, content=content, 134 | content_type=content_type) 135 | self.receive(sn.request_header, method=method, content=content, 136 | content_type=content_type) 137 | 138 | def test_post_content_type_with_trailing_charset(self): 139 | method = 'POST' 140 | content = '{"bar": "foobs"}' 141 | content_type = 'application/json; charset=utf8' 142 | sn = self.Sender(method=method, content=content, 143 | content_type=content_type) 144 | self.receive(sn.request_header, method=method, content=content, 145 | content_type='application/json; charset=other') 146 | 147 | @raises(MissingContent) 148 | def test_missing_payload_details(self): 149 | self.Sender(method='POST', content=EmptyValue, 150 | content_type=EmptyValue) 151 | 152 | def test_skip_payload_hashing(self): 153 | method = 'POST' 154 | content = '{"bar": "foobs"}' 155 | content_type = 'application/json' 156 | sn = self.Sender(method=method, content=EmptyValue, 157 | content_type=EmptyValue, 158 | always_hash_content=False) 159 | self.assertFalse('hash="' in sn.request_header) 160 | self.receive(sn.request_header, method=method, content=content, 161 | content_type=content_type, 162 | accept_untrusted_content=True) 163 | 164 | def test_empty_payload_hashing(self): 165 | method = 'GET' 166 | content = None 167 | content_type = None 168 | sn = self.Sender(method=method, content=content, 169 | content_type=content_type) 170 | self.assertTrue('hash="' in sn.request_header) 171 | self.receive(sn.request_header, method=method, content=content, 172 | content_type=content_type) 173 | 174 | def test_empty_payload_hashing_always_hash_false(self): 175 | method = 'GET' 176 | content = None 177 | content_type = None 178 | sn = self.Sender(method=method, content=content, 179 | content_type=content_type, 180 | always_hash_content=False) 181 | self.assertTrue('hash="' in sn.request_header) 182 | self.receive(sn.request_header, method=method, content=content, 183 | content_type=content_type) 184 | 185 | def test_empty_payload_hashing_accept_untrusted(self): 186 | method = 'GET' 187 | content = None 188 | content_type = None 189 | sn = self.Sender(method=method, content=content, 190 | content_type=content_type) 191 | self.assertTrue('hash="' in sn.request_header) 192 | self.receive(sn.request_header, method=method, content=content, 193 | content_type=content_type, 194 | accept_untrusted_content=True) 195 | 196 | @raises(MissingContent) 197 | def test_cannot_skip_content_only(self): 198 | self.Sender(method='POST', content=EmptyValue, 199 | content_type='application/json') 200 | 201 | @raises(MissingContent) 202 | def test_cannot_skip_content_type_only(self): 203 | self.Sender(method='POST', content='{"foo": "bar"}', 204 | content_type=EmptyValue) 205 | 206 | @raises(MacMismatch) 207 | def test_tamper_with_host(self): 208 | sn = self.Sender() 209 | self.receive(sn.request_header, url='http://TAMPERED-WITH.com') 210 | 211 | @raises(MacMismatch) 212 | def test_tamper_with_method(self): 213 | sn = self.Sender(method='GET') 214 | self.receive(sn.request_header, method='POST') 215 | 216 | @raises(MacMismatch) 217 | def test_tamper_with_path(self): 218 | sn = self.Sender() 219 | self.receive(sn.request_header, 220 | url='http://site.com/TAMPERED?bar=1') 221 | 222 | @raises(MacMismatch) 223 | def test_tamper_with_query(self): 224 | sn = self.Sender() 225 | self.receive(sn.request_header, 226 | url='http://site.com/foo?bar=TAMPERED') 227 | 228 | @raises(MacMismatch) 229 | def test_tamper_with_scheme(self): 230 | sn = self.Sender() 231 | self.receive(sn.request_header, url='https://site.com/foo?bar=1') 232 | 233 | @raises(MacMismatch) 234 | def test_tamper_with_port(self): 235 | sn = self.Sender() 236 | self.receive(sn.request_header, 237 | url='http://site.com:8000/foo?bar=1') 238 | 239 | @raises(MisComputedContentHash) 240 | def test_tamper_with_content(self): 241 | sn = self.Sender() 242 | self.receive(sn.request_header, content='stuff=nope') 243 | 244 | def test_non_ascii_content(self): 245 | content = u'Ivan Kristi\u0107' 246 | sn = self.Sender(content=content) 247 | self.receive(sn.request_header, content=content) 248 | 249 | @raises(MacMismatch) 250 | def test_tamper_with_content_type(self): 251 | sn = self.Sender(method='POST') 252 | self.receive(sn.request_header, content_type='application/json') 253 | 254 | @raises(AlreadyProcessed) 255 | def test_nonce_fail(self): 256 | 257 | def seen_nonce(id, nonce, ts): 258 | return True 259 | 260 | sn = self.Sender() 261 | 262 | self.receive(sn.request_header, seen_nonce=seen_nonce) 263 | 264 | def test_nonce_ok(self): 265 | 266 | def seen_nonce(id, nonce, ts): 267 | return False 268 | 269 | sn = self.Sender(seen_nonce=seen_nonce) 270 | self.receive(sn.request_header) 271 | 272 | @raises(TokenExpired) 273 | def test_expired_ts(self): 274 | now = utc_now() - 120 275 | sn = self.Sender(_timestamp=now) 276 | self.receive(sn.request_header) 277 | 278 | def test_expired_exception_reports_localtime(self): 279 | now = utc_now() 280 | ts = now - 120 281 | sn = self.Sender(_timestamp=ts) # force expiry 282 | 283 | exc = None 284 | with mock.patch('mohawk.base.utc_now') as fake_now: 285 | fake_now.return_value = now 286 | try: 287 | self.receive(sn.request_header) 288 | except: 289 | etype, exc, tb = sys.exc_info() 290 | 291 | eq_(type(exc), TokenExpired) 292 | eq_(exc.localtime_in_seconds, now) 293 | 294 | def test_localtime_offset(self): 295 | now = utc_now() - 120 296 | sn = self.Sender(_timestamp=now) 297 | # Without an offset this will raise an expired exception. 298 | self.receive(sn.request_header, localtime_offset_in_seconds=-120) 299 | 300 | def test_localtime_skew(self): 301 | now = utc_now() - 120 302 | sn = self.Sender(_timestamp=now) 303 | # Without an offset this will raise an expired exception. 304 | self.receive(sn.request_header, timestamp_skew_in_seconds=120) 305 | 306 | @raises(MacMismatch) 307 | def test_hash_tampering(self): 308 | sn = self.Sender() 309 | header = sn.request_header.replace('hash="', 'hash="nope') 310 | self.receive(header) 311 | 312 | @raises(MacMismatch) 313 | def test_bad_secret(self): 314 | cfg = { 315 | 'id': 'my-hawk-id', 316 | 'key': 'INCORRECT; YOU FAIL', 317 | 'algorithm': 'sha256', 318 | } 319 | sn = self.Sender(credentials=cfg) 320 | self.receive(sn.request_header) 321 | 322 | @raises(MacMismatch) 323 | def test_unexpected_algorithm(self): 324 | cr = self.credentials.copy() 325 | cr['algorithm'] = 'sha512' 326 | sn = self.Sender(credentials=cr) 327 | 328 | # Validate with mismatched credentials (sha256). 329 | self.receive(sn.request_header) 330 | 331 | @raises(InvalidCredentials) 332 | def test_invalid_credentials(self): 333 | cfg = self.credentials.copy() 334 | # Create an invalid credentials. 335 | del cfg['algorithm'] 336 | 337 | self.Sender(credentials=cfg) 338 | 339 | @raises(CredentialsLookupError) 340 | def test_unknown_id(self): 341 | cr = self.credentials.copy() 342 | cr['id'] = 'someone-else' 343 | sn = self.Sender(credentials=cr) 344 | 345 | self.receive(sn.request_header) 346 | 347 | @raises(MacMismatch) 348 | def test_bad_ext(self): 349 | sn = self.Sender(ext='my external data') 350 | 351 | header = sn.request_header.replace('my external data', 'TAMPERED') 352 | self.receive(header) 353 | 354 | @raises(BadHeaderValue) 355 | def test_duplicate_keys(self): 356 | sn = self.Sender(ext='someext') 357 | header = sn.request_header + ', ext="otherext"' 358 | self.receive(header) 359 | 360 | @raises(BadHeaderValue) 361 | def test_ext_with_quotes(self): 362 | sn = self.Sender(ext='quotes=""') 363 | self.receive(sn.request_header) 364 | 365 | @raises(BadHeaderValue) 366 | def test_ext_with_new_line(self): 367 | sn = self.Sender(ext="new line \n in the middle") 368 | self.receive(sn.request_header) 369 | 370 | def test_ext_with_equality_sign(self): 371 | sn = self.Sender(ext="foo=bar&foo2=bar2;foo3=bar3") 372 | self.receive(sn.request_header) 373 | parsed = parse_authorization_header(sn.request_header) 374 | eq_(parsed['ext'], "foo=bar&foo2=bar2;foo3=bar3") 375 | 376 | @raises(HawkFail) 377 | def test_non_hawk_scheme(self): 378 | parse_authorization_header('Basic user:base64pw') 379 | 380 | @raises(HawkFail) 381 | def test_invalid_key(self): 382 | parse_authorization_header('Hawk mac="validmac" unknownkey="value"') 383 | 384 | def test_ext_with_all_valid_characters(self): 385 | valid_characters = "!#$%&'()*+,-./:;<=>?@[]^_`{|}~ azAZ09_" 386 | sender = self.Sender(ext=valid_characters) 387 | parsed = parse_authorization_header(sender.request_header) 388 | eq_(parsed['ext'], valid_characters) 389 | 390 | @raises(BadHeaderValue) 391 | def test_ext_with_illegal_chars(self): 392 | self.Sender(ext="something like \t is illegal") 393 | 394 | def test_unparseable_header(self): 395 | try: 396 | parse_authorization_header('Hawk mac="somemac", unparseable') 397 | except BadHeaderValue as exc: 398 | error_msg = str(exc) 399 | self.assertTrue("Couldn't parse Hawk header" in error_msg) 400 | self.assertTrue("unparseable" in error_msg) 401 | else: 402 | self.fail('should raise') 403 | 404 | @raises(BadHeaderValue) 405 | def test_ext_with_illegal_unicode(self): 406 | self.Sender(ext=u'Ivan Kristi\u0107') 407 | 408 | @raises(BadHeaderValue) 409 | def test_too_long_header(self): 410 | sn = self.Sender(ext='a'*5000) 411 | self.receive(sn.request_header) 412 | 413 | @raises(BadHeaderValue) 414 | def test_ext_with_illegal_utf8(self): 415 | # This isn't allowed because the escaped byte chars are out of 416 | # range. 417 | self.Sender(ext=u'Ivan Kristi\u0107'.encode('utf8')) 418 | 419 | def test_app_ok(self): 420 | app = 'custom-app' 421 | sn = self.Sender(app=app) 422 | self.receive(sn.request_header) 423 | parsed = parse_authorization_header(sn.request_header) 424 | eq_(parsed['app'], app) 425 | 426 | @raises(MacMismatch) 427 | def test_tampered_app(self): 428 | app = 'custom-app' 429 | sn = self.Sender(app=app) 430 | header = sn.request_header.replace(app, 'TAMPERED-WITH') 431 | self.receive(header) 432 | 433 | def test_dlg_ok(self): 434 | dlg = 'custom-dlg' 435 | sn = self.Sender(dlg=dlg) 436 | self.receive(sn.request_header) 437 | parsed = parse_authorization_header(sn.request_header) 438 | eq_(parsed['dlg'], dlg) 439 | 440 | @raises(MacMismatch) 441 | def test_tampered_dlg(self): 442 | dlg = 'custom-dlg' 443 | sn = self.Sender(dlg=dlg, app='some-app') 444 | header = sn.request_header.replace(dlg, 'TAMPERED-WITH') 445 | self.receive(header) 446 | 447 | def test_file_content(self): 448 | method = "POST" 449 | content = six.BytesIO(b"FILE CONTENT") 450 | sn = self.Sender(method, content=content) 451 | self.receive(sn.request_header, method=method, content=content.getvalue()) 452 | 453 | def test_binary_file_content(self): 454 | method = "POST" 455 | content = six.BytesIO(b"\x00\xffCONTENT\xff\x00") 456 | sn = self.Sender(method, content=content) 457 | self.receive(sn.request_header, method=method, content=content.getvalue()) 458 | 459 | @raises(MisComputedContentHash) 460 | def test_bad_file_content(self): 461 | method = "POST" 462 | content = six.BytesIO(b"FILE CONTENT") 463 | sn = self.Sender(method, content=content) 464 | self.receive(sn.request_header, method=method, content="BAD FILE CONTENT") 465 | 466 | 467 | class TestReceiver(Base): 468 | 469 | def setUp(self): 470 | super(TestReceiver, self).setUp() 471 | self.url = 'http://site.com/' 472 | self.sender = None 473 | self.receiver = None 474 | 475 | def receive(self, method='GET', **kw): 476 | url = kw.pop('url', self.url) 477 | sender = kw.pop('sender', None) 478 | sender_kw = kw.pop('sender_kw', {}) 479 | sender_kw.setdefault('content', '') 480 | sender_kw.setdefault('content_type', '') 481 | sender_url = kw.pop('sender_url', url) 482 | 483 | credentials_map = kw.pop('credentials_map', 484 | lambda id: self.credentials) 485 | 486 | if sender: 487 | self.sender = sender 488 | else: 489 | self.sender = Sender(self.credentials, sender_url, method, 490 | **sender_kw) 491 | 492 | kw.setdefault('content', '') 493 | kw.setdefault('content_type', '') 494 | self.receiver = Receiver(credentials_map, 495 | self.sender.request_header, url, method, 496 | **kw) 497 | 498 | def respond(self, **kw): 499 | accept_kw = kw.pop('accept_kw', {}) 500 | accept_kw.setdefault('content', '') 501 | accept_kw.setdefault('content_type', '') 502 | receiver = kw.pop('receiver', self.receiver) 503 | 504 | kw.setdefault('content', '') 505 | kw.setdefault('content_type', '') 506 | receiver.respond(**kw) 507 | self.sender.accept_response(receiver.response_header, **accept_kw) 508 | 509 | return receiver.response_header 510 | 511 | @raises(InvalidCredentials) 512 | def test_invalid_credentials_lookup(self): 513 | # Return invalid credentials. 514 | self.receive(credentials_map=lambda *a: {}) 515 | 516 | def test_get_ok(self): 517 | method = 'GET' 518 | self.receive(method=method) 519 | self.respond() 520 | 521 | def test_post_ok(self): 522 | method = 'POST' 523 | self.receive(method=method) 524 | self.respond() 525 | 526 | @raises(MisComputedContentHash) 527 | def test_respond_with_wrong_content(self): 528 | self.receive() 529 | self.respond(content='real content', 530 | accept_kw=dict(content='TAMPERED WITH')) 531 | 532 | @raises(MisComputedContentHash) 533 | def test_respond_with_wrong_content_type(self): 534 | self.receive() 535 | self.respond(content_type='text/html', 536 | accept_kw=dict(content_type='application/json')) 537 | 538 | @raises(MissingAuthorization) 539 | def test_missing_authorization(self): 540 | Receiver(lambda id: self.credentials, None, '/', 'GET') 541 | 542 | @raises(MacMismatch) 543 | def test_respond_with_wrong_url(self): 544 | self.receive(url='http://fakesite.com') 545 | wrong_receiver = self.receiver 546 | 547 | self.receive(url='http://realsite.com') 548 | 549 | self.respond(receiver=wrong_receiver) 550 | 551 | @raises(MacMismatch) 552 | def test_respond_with_wrong_method(self): 553 | self.receive(method='GET') 554 | wrong_receiver = self.receiver 555 | 556 | self.receive(method='POST') 557 | 558 | self.respond(receiver=wrong_receiver) 559 | 560 | @raises(MacMismatch) 561 | def test_respond_with_wrong_nonce(self): 562 | self.receive(sender_kw=dict(nonce='another-nonce')) 563 | wrong_receiver = self.receiver 564 | 565 | self.receive() 566 | 567 | # The nonce must match the one sent in the original request. 568 | self.respond(receiver=wrong_receiver) 569 | 570 | def test_respond_with_unhashed_content(self): 571 | self.receive() 572 | 573 | self.respond(always_hash_content=False, content=None, 574 | content_type=None, 575 | accept_kw=dict(accept_untrusted_content=True)) 576 | 577 | @raises(TokenExpired) 578 | def test_respond_with_expired_ts(self): 579 | self.receive() 580 | hdr = self.receiver.respond(content='', content_type='') 581 | 582 | with mock.patch('mohawk.base.utc_now') as fn: 583 | fn.return_value = 0 # force an expiry 584 | try: 585 | self.sender.accept_response(hdr, content='', content_type='') 586 | except TokenExpired: 587 | etype, exc, tb = sys.exc_info() 588 | hdr = parse_authorization_header(exc.www_authenticate) 589 | calculated = calculate_ts_mac(fn(), self.credentials) 590 | if isinstance(calculated, six.binary_type): 591 | calculated = calculated.decode('ascii') 592 | eq_(hdr['tsm'], calculated) 593 | raise 594 | 595 | def test_respond_with_bad_ts_skew_ok(self): 596 | now = utc_now() - 120 597 | 598 | self.receive() 599 | hdr = self.receiver.respond(content='', content_type='') 600 | 601 | with mock.patch('mohawk.base.utc_now') as fn: 602 | fn.return_value = now 603 | 604 | # Without an offset this will raise an expired exception. 605 | self.sender.accept_response(hdr, content='', content_type='', 606 | timestamp_skew_in_seconds=120) 607 | 608 | def test_respond_with_ext(self): 609 | self.receive() 610 | 611 | ext = 'custom-ext' 612 | self.respond(ext=ext) 613 | header = parse_authorization_header(self.receiver.response_header) 614 | eq_(header['ext'], ext) 615 | 616 | @raises(MacMismatch) 617 | def test_respond_with_wrong_app(self): 618 | self.receive(sender_kw=dict(app='TAMPERED-WITH', dlg='delegation')) 619 | self.receiver.respond(content='', content_type='') 620 | wrong_receiver = self.receiver 621 | 622 | self.receive(sender_kw=dict(app='real-app', dlg='delegation')) 623 | 624 | self.sender.accept_response(wrong_receiver.response_header, 625 | content='', content_type='') 626 | 627 | @raises(MacMismatch) 628 | def test_respond_with_wrong_dlg(self): 629 | self.receive(sender_kw=dict(app='app', dlg='TAMPERED-WITH')) 630 | self.receiver.respond(content='', content_type='') 631 | wrong_receiver = self.receiver 632 | 633 | self.receive(sender_kw=dict(app='app', dlg='real-dlg')) 634 | 635 | self.sender.accept_response(wrong_receiver.response_header, 636 | content='', content_type='') 637 | 638 | @raises(MacMismatch) 639 | def test_receive_wrong_method(self): 640 | self.receive(method='GET') 641 | wrong_sender = self.sender 642 | self.receive(method='POST', sender=wrong_sender) 643 | 644 | @raises(MacMismatch) 645 | def test_receive_wrong_url(self): 646 | self.receive(url='http://fakesite.com/') 647 | wrong_sender = self.sender 648 | self.receive(url='http://realsite.com/', sender=wrong_sender) 649 | 650 | @raises(MisComputedContentHash) 651 | def test_receive_wrong_content(self): 652 | self.receive(sender_kw=dict(content='real request'), 653 | content='real request') 654 | wrong_sender = self.sender 655 | self.receive(content='TAMPERED WITH', sender=wrong_sender) 656 | 657 | def test_expected_unhashed_empty_content(self): 658 | # This test sets up a scenario where the receiver will receive empty 659 | # strings for content and content_type and no content hash in the auth 660 | # header. 661 | # This is to account for callers that might provide empty strings for 662 | # the payload when in fact there is literally no content. In this case, 663 | # mohawk depends on the presence of the content hash in the auth header 664 | # to determine how to treat the empty strings: no hash in the header 665 | # implies that no hashing is expected to occur on the server. 666 | self.receive(content='', 667 | content_type='', 668 | sender_kw=dict(content=EmptyValue, 669 | content_type=EmptyValue, 670 | always_hash_content=False)) 671 | 672 | @raises(MisComputedContentHash) 673 | def test_expected_unhashed_empty_content_with_content_type(self): 674 | # This test sets up a scenario where the receiver will receive an 675 | # empty content string and no content hash in the auth header, but 676 | # some value for content_type. 677 | # This is to confirm that the hash is calculated and compared (to the 678 | # hash of mock empty payload, which should fail) when it appears that 679 | # the sender has sent a 0-length payload body. 680 | self.receive(content='', 681 | content_type='text/plain', 682 | sender_kw=dict(content=EmptyValue, 683 | content_type=EmptyValue, 684 | always_hash_content=False)) 685 | 686 | @raises(MisComputedContentHash) 687 | def test_expected_unhashed_content_with_empty_content_type(self): 688 | # This test sets up a scenario where the receiver will receive some 689 | # content but the empty string for the content_type and no content hash 690 | # in the auth header. 691 | # This is to confirm that the hash is calculated and compared (to the 692 | # hash of mock empty payload, which should fail) when the sender has 693 | # sent unhashed content. 694 | self.receive(content='some content', 695 | content_type='', 696 | sender_kw=dict(content=EmptyValue, 697 | content_type=EmptyValue, 698 | always_hash_content=False)) 699 | 700 | def test_empty_content_with_content_type(self): 701 | # This test sets up a scenario where the receiver will receive an 702 | # empty content string, some value for content_type and a content hash. 703 | # This is to confirm that the hash is calculated and compared correctly 704 | # when the sender has sent a hashed 0-length payload body. 705 | self.receive(content='', 706 | content_type='text/plain', 707 | sender_kw=dict(content='', 708 | content_type='text/plain')) 709 | 710 | def test_expected_unhashed_no_content(self): 711 | # This test sets up a scenario where the receiver will receive None for 712 | # content and content_type and no content hash in the auth header. 713 | # This is like test_expected_unhashed_empty_content(), but tests for 714 | # the less ambiguous case where the caller has explicitly passed in None 715 | # to indicate that there is no content to hash. 716 | self.receive(content=None, 717 | content_type=None, 718 | sender_kw=dict(content=EmptyValue, 719 | content_type=EmptyValue, 720 | always_hash_content=False)) 721 | 722 | @raises(MisComputedContentHash) 723 | def test_expected_unhashed_no_content_with_content_type(self): 724 | # This test sets up a scenario where the receiver will receive None for 725 | # content and no content hash in the auth header, but some value for 726 | # content_type. 727 | # In this case, the content will be coerced to the empty string for 728 | # hashing purposes. The request should fail, as the there is no content 729 | # hash in the request to compare against. While this may not be in 730 | # accordance with the js reference spec, it's the safest (ie. most 731 | # secure) way of handling this bizarre set of circumstances. 732 | self.receive(content=None, 733 | content_type='text/plain', 734 | sender_kw=dict(content=EmptyValue, 735 | content_type=EmptyValue, 736 | always_hash_content=False)) 737 | 738 | @raises(MisComputedContentHash) 739 | def test_expected_unhashed_content_with_no_content_type(self): 740 | # This test sets up a scenario where the receiver will receive some 741 | # content but no value for the content_type and no content hash in 742 | # the auth header. 743 | # This is to confirm that the hash is calculated and compared (to the 744 | # hash of mock empty payload, which should fail) when the sender has 745 | # sent unhashed content. 746 | self.receive(content='some content', 747 | content_type=None, 748 | sender_kw=dict(content=EmptyValue, 749 | content_type=EmptyValue, 750 | always_hash_content=False)) 751 | 752 | def test_no_content_with_content_type(self): 753 | # This test sets up a scenario where the receiver will receive None for 754 | # the content string, some value for content_type and a content hash. 755 | # This is to confirm that coercing None to the empty string when a hash 756 | # is expected allows the hash to be calculated and compared correctly 757 | # as if the sender has sent a hashed 0-length payload body. 758 | self.receive(content=None, 759 | content_type='text/plain', 760 | sender_kw=dict(content='', 761 | content_type='text/plain')) 762 | 763 | @raises(MissingContent) 764 | def test_cannot_receive_empty_content_only(self): 765 | content_type = 'text/plain' 766 | self.receive(sender_kw=dict(content='', 767 | content_type=content_type), 768 | content=EmptyValue, content_type=content_type) 769 | 770 | @raises(MissingContent) 771 | def test_cannot_receive_empty_content_type_only(self): 772 | content = '' 773 | self.receive(sender_kw=dict(content=content, 774 | content_type='text/plain'), 775 | content=content, content_type=EmptyValue) 776 | 777 | @raises(MisComputedContentHash) 778 | def test_receive_wrong_content_type(self): 779 | self.receive(sender_kw=dict(content_type='text/html'), 780 | content_type='text/html') 781 | wrong_sender = self.sender 782 | 783 | self.receive(content_type='application/json', 784 | sender=wrong_sender) 785 | 786 | 787 | class TestSendAndReceive(Base): 788 | 789 | def test(self): 790 | credentials = { 791 | 'id': 'some-id', 792 | 'key': 'some secret', 793 | 'algorithm': 'sha256' 794 | } 795 | 796 | url = 'https://my-site.com/' 797 | method = 'POST' 798 | 799 | # The client sends a request with a Hawk header. 800 | content = 'foo=bar&baz=nooz' 801 | content_type = 'application/x-www-form-urlencoded' 802 | 803 | sender = Sender(credentials, 804 | url, method, 805 | content=content, 806 | content_type=content_type) 807 | 808 | # The server receives a request and authorizes access. 809 | receiver = Receiver(lambda id: credentials, 810 | sender.request_header, 811 | url, method, 812 | content=content, 813 | content_type=content_type) 814 | 815 | # The server responds with a similar Hawk header. 816 | content = 'we are friends' 817 | content_type = 'text/plain' 818 | receiver.respond(content=content, 819 | content_type=content_type) 820 | 821 | # The client receives a response and authorizes access. 822 | sender.accept_response(receiver.response_header, 823 | content=content, 824 | content_type=content_type) 825 | 826 | 827 | class TestBewit(Base): 828 | 829 | # Test cases copied from 830 | # https://github.com/hueniverse/hawk/blob/492632da51ecedd5f59ce96f081860ad24ce6532/test/uri.js 831 | 832 | def setUp(self): 833 | self.credentials = { 834 | 'id': '123456', 835 | 'key': '2983d45yun89q', 836 | 'algorithm': 'sha256', 837 | } 838 | 839 | def make_credential_lookup(self, credentials_map): 840 | # Helper function to make a lookup function given a dictionary of 841 | # credentials 842 | def lookup(client_id): 843 | # Will raise a KeyError if missing; which is a subclass of 844 | # LookupError 845 | return credentials_map[client_id] 846 | return lookup 847 | 848 | def test_bewit(self): 849 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 850 | method='GET', credentials=self.credentials, 851 | timestamp=1356420407 + 300, 852 | nonce='', 853 | ) 854 | bewit = get_bewit(res) 855 | 856 | expected = '123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 857 | eq_(b64decode(bewit).decode('ascii'), expected) 858 | 859 | def test_bewit_with_binary_id(self): 860 | # Check for exceptions in get_bewit call with binary id 861 | binary_credentials = self.credentials.copy() 862 | binary_credentials['id'] = binary_credentials['id'].encode('ascii') 863 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 864 | method='GET', credentials=binary_credentials, 865 | timestamp=1356420407 + 300, 866 | nonce='', 867 | ) 868 | get_bewit(res) 869 | 870 | def test_bewit_with_ext(self): 871 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 872 | method='GET', credentials=self.credentials, 873 | timestamp=1356420407 + 300, 874 | nonce='', 875 | ext='xandyandz' 876 | ) 877 | bewit = get_bewit(res) 878 | 879 | expected = '123456\\1356420707\\kscxwNR2tJpP1T1zDLNPbB5UiKIU9tOSJXTUdG7X9h8=\\xandyandz' 880 | eq_(b64decode(bewit).decode('ascii'), expected) 881 | 882 | @raises(BadHeaderValue) 883 | def test_bewit_with_invalid_ext(self): 884 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 885 | method='GET', credentials=self.credentials, 886 | timestamp=1356420407 + 300, 887 | nonce='', 888 | ext='xand\\yandz') 889 | get_bewit(res) 890 | 891 | @raises(BadHeaderValue) 892 | def test_bewit_with_backslashes_in_id(self): 893 | credentials = self.credentials 894 | credentials['id'] = '123\\456' 895 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 896 | method='GET', credentials=self.credentials, 897 | timestamp=1356420407 + 300, 898 | nonce='') 899 | get_bewit(res) 900 | 901 | def test_bewit_with_port(self): 902 | res = Resource(url='https://example.com:8080/somewhere/over/the/rainbow', 903 | method='GET', credentials=self.credentials, 904 | timestamp=1356420407 + 300, nonce='', ext='xandyandz') 905 | bewit = get_bewit(res) 906 | 907 | expected = '123456\\1356420707\\hZbJ3P2cKEo4ky0C8jkZAkRyCZueg4WSNbxV7vq3xHU=\\xandyandz' 908 | eq_(b64decode(bewit).decode('ascii'), expected) 909 | 910 | @raises(ValueError) 911 | def test_bewit_with_nonce(self): 912 | res = Resource(url='https://example.com/somewhere/over/the/rainbow', 913 | method='GET', credentials=self.credentials, 914 | timestamp=1356420407 + 300, 915 | nonce='n1') 916 | get_bewit(res) 917 | 918 | @raises(ValueError) 919 | def test_bewit_invalid_method(self): 920 | res = Resource(url='https://example.com:8080/somewhere/over/the/rainbow', 921 | method='POST', credentials=self.credentials, 922 | timestamp=1356420407 + 300, nonce='') 923 | get_bewit(res) 924 | 925 | def test_strip_bewit(self): 926 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 927 | bewit = urlsafe_b64encode(bewit).decode('ascii') 928 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 929 | 930 | raw_bewit, stripped_url = strip_bewit(url) 931 | self.assertEqual(raw_bewit, bewit) 932 | self.assertEqual(stripped_url, "https://example.com/somewhere/over/the/rainbow") 933 | 934 | @raises(InvalidBewit) 935 | def test_strip_url_without_bewit(self): 936 | url = "https://example.com/somewhere/over/the/rainbow" 937 | strip_bewit(url) 938 | 939 | def test_parse_bewit(self): 940 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 941 | bewit = urlsafe_b64encode(bewit).decode('ascii') 942 | bewit = parse_bewit(bewit) 943 | self.assertEqual(bewit.id, '123456') 944 | self.assertEqual(bewit.expiration, '1356420707') 945 | self.assertEqual(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') 946 | self.assertEqual(bewit.ext, '') 947 | 948 | def test_parse_bewit_with_ext(self): 949 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xandyandz' 950 | bewit = urlsafe_b64encode(bewit).decode('ascii') 951 | bewit = parse_bewit(bewit) 952 | self.assertEqual(bewit.id, '123456') 953 | self.assertEqual(bewit.expiration, '1356420707') 954 | self.assertEqual(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') 955 | self.assertEqual(bewit.ext, 'xandyandz') 956 | 957 | @raises(InvalidBewit) 958 | def test_parse_bewit_with_ext_and_backslashes(self): 959 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xand\\yandz' 960 | bewit = urlsafe_b64encode(bewit).decode('ascii') 961 | parse_bewit(bewit) 962 | 963 | @raises(InvalidBewit) 964 | def test_parse_invalid_bewit_with_only_one_part(self): 965 | bewit = b'12345' 966 | bewit = urlsafe_b64encode(bewit).decode('ascii') 967 | bewit = parse_bewit(bewit) 968 | 969 | @raises(InvalidBewit) 970 | def test_parse_invalid_bewit_with_only_two_parts(self): 971 | bewit = b'1\\2' 972 | bewit = urlsafe_b64encode(bewit).decode('ascii') 973 | bewit = parse_bewit(bewit) 974 | 975 | def test_validate_bewit(self): 976 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 977 | bewit = urlsafe_b64encode(bewit).decode('ascii') 978 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 979 | credential_lookup = self.make_credential_lookup({ 980 | self.credentials['id']: self.credentials, 981 | }) 982 | self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) 983 | 984 | def test_validate_bewit_with_ext(self): 985 | bewit = b'123456\\1356420707\\kscxwNR2tJpP1T1zDLNPbB5UiKIU9tOSJXTUdG7X9h8=\\xandyandz' 986 | bewit = urlsafe_b64encode(bewit).decode('ascii') 987 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 988 | credential_lookup = self.make_credential_lookup({ 989 | self.credentials['id']: self.credentials, 990 | }) 991 | self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) 992 | 993 | @raises(InvalidBewit) 994 | def test_validate_bewit_with_ext_and_backslashes(self): 995 | bewit = b'123456\\1356420707\\b82LLIxG5UDkaChLU953mC+SMrbniV1sb8KiZi9cSsc=\\xand\\yandz' 996 | bewit = urlsafe_b64encode(bewit).decode('ascii') 997 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 998 | credential_lookup = self.make_credential_lookup({ 999 | self.credentials['id']: self.credentials, 1000 | }) 1001 | check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10) 1002 | 1003 | @raises(TokenExpired) 1004 | def test_validate_expired_bewit(self): 1005 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 1006 | bewit = urlsafe_b64encode(bewit).decode('ascii') 1007 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 1008 | credential_lookup = self.make_credential_lookup({ 1009 | self.credentials['id']: self.credentials, 1010 | }) 1011 | check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 1000) 1012 | 1013 | @raises(CredentialsLookupError) 1014 | def test_validate_bewit_with_unknown_credentials(self): 1015 | bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' 1016 | bewit = urlsafe_b64encode(bewit).decode('ascii') 1017 | url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) 1018 | credential_lookup = self.make_credential_lookup({ 1019 | 'other_id': self.credentials, 1020 | }) 1021 | check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10) 1022 | 1023 | 1024 | class TestPayloadHash(Base): 1025 | def test_hash_file_read_blocks(self): 1026 | payload = six.BytesIO(b"\x00\xffhello world\xff\x00") 1027 | h1 = calculate_payload_hash(payload, 'sha256', 'application/json', block_size=1) 1028 | payload.seek(0) 1029 | h2 = calculate_payload_hash(payload, 'sha256', 'application/json', block_size=1024) 1030 | self.assertEqual(h1, h2) 1031 | -------------------------------------------------------------------------------- /mohawk/util.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode, urlsafe_b64encode 2 | import calendar 3 | import hashlib 4 | import hmac 5 | import logging 6 | import math 7 | import os 8 | import pprint 9 | import re 10 | import sys 11 | import time 12 | 13 | import six 14 | 15 | from .exc import ( 16 | BadHeaderValue, 17 | HawkFail, 18 | InvalidCredentials) 19 | 20 | 21 | HAWK_VER = 1 22 | HAWK_HEADER_RE = re.compile(r'(?P\w+)=\"(?P[^\"\\]*)\"\s*(?:,\s*|$)') 23 | MAX_LENGTH = 4096 24 | log = logging.getLogger(__name__) 25 | allowable_header_keys = set(['id', 'ts', 'tsm', 'nonce', 'hash', 26 | 'error', 'ext', 'mac', 'app', 'dlg']) 27 | 28 | 29 | def validate_credentials(creds): 30 | if not hasattr(creds, '__getitem__'): 31 | raise InvalidCredentials('credentials must be a dict-like object') 32 | try: 33 | creds['id'] 34 | creds['key'] 35 | creds['algorithm'] 36 | except KeyError: 37 | etype, val, tb = sys.exc_info() 38 | raise InvalidCredentials('{etype}: {val}' 39 | .format(etype=etype, val=val)) 40 | 41 | 42 | def random_string(length): 43 | """Generates a random string for a given length.""" 44 | # this conservatively gets 8*length bits and then returns 6*length of 45 | # them. Grabbing (6/8)*length bits could lose some entropy off the ends. 46 | return urlsafe_b64encode(os.urandom(length))[:length] 47 | 48 | 49 | def calculate_payload_hash(payload, algorithm, content_type, block_size=1024): 50 | """Calculates a hash for a given payload.""" 51 | p_hash = hashlib.new(algorithm) 52 | 53 | parts = [] 54 | parts.append('hawk.' + str(HAWK_VER) + '.payload\n') 55 | parts.append(parse_content_type(content_type) + '\n') 56 | parts.append(payload or '') 57 | parts.append('\n') 58 | 59 | for i, p in enumerate(parts): 60 | # Make sure we are about to hash binary strings. 61 | if hasattr(p, "read"): 62 | log.debug("part %i being handled as a file object", i) 63 | while True: 64 | block = p.read(block_size) 65 | if not block: 66 | break 67 | p_hash.update(block) 68 | elif not isinstance(p, six.binary_type): 69 | p = p.encode('utf8') 70 | p_hash.update(p) 71 | else: 72 | p_hash.update(p) 73 | parts[i] = p 74 | 75 | log.debug('calculating payload hash from:\n{parts}' 76 | .format(parts=pprint.pformat(parts))) 77 | 78 | return b64encode(p_hash.digest()) 79 | 80 | 81 | def calculate_mac(mac_type, resource, content_hash): 82 | """Calculates a message authorization code (MAC).""" 83 | normalized = normalize_string(mac_type, resource, content_hash) 84 | log.debug(u'normalized resource for mac calc: {norm}' 85 | .format(norm=normalized)) 86 | digestmod = getattr(hashlib, resource.credentials['algorithm']) 87 | 88 | # Make sure we are about to hash binary strings. 89 | 90 | if not isinstance(normalized, six.binary_type): 91 | normalized = normalized.encode('utf8') 92 | key = resource.credentials['key'] 93 | if not isinstance(key, six.binary_type): 94 | key = key.encode('ascii') 95 | 96 | result = hmac.new(key, normalized, digestmod) 97 | return b64encode(result.digest()) 98 | 99 | 100 | def calculate_ts_mac(ts, credentials): 101 | """Calculates a message authorization code (MAC) for a timestamp.""" 102 | normalized = ('hawk.{hawk_ver}.ts\n{ts}\n' 103 | .format(hawk_ver=HAWK_VER, ts=ts)) 104 | log.debug(u'normalized resource for ts mac calc: {norm}' 105 | .format(norm=normalized)) 106 | digestmod = getattr(hashlib, credentials['algorithm']) 107 | 108 | if not isinstance(normalized, six.binary_type): 109 | normalized = normalized.encode('utf8') 110 | key = credentials['key'] 111 | if not isinstance(key, six.binary_type): 112 | key = key.encode('ascii') 113 | 114 | result = hmac.new(key, normalized, digestmod) 115 | return b64encode(result.digest()) 116 | 117 | 118 | def normalize_string(mac_type, resource, content_hash): 119 | """Serializes mac_type and resource into a HAWK string.""" 120 | 121 | normalized = [ 122 | 'hawk.' + str(HAWK_VER) + '.' + mac_type, 123 | normalize_header_attr(resource.timestamp), 124 | normalize_header_attr(resource.nonce), 125 | normalize_header_attr(resource.method or ''), 126 | normalize_header_attr(resource.name or ''), 127 | normalize_header_attr(resource.host), 128 | normalize_header_attr(resource.port), 129 | normalize_header_attr(content_hash or '') 130 | ] 131 | 132 | # The blank lines are important. They follow what the Node Hawk lib does. 133 | 134 | normalized.append(normalize_header_attr(resource.ext or '')) 135 | 136 | if resource.app: 137 | normalized.append(normalize_header_attr(resource.app)) 138 | normalized.append(normalize_header_attr(resource.dlg or '')) 139 | 140 | # Add trailing new line. 141 | normalized.append('') 142 | 143 | normalized = '\n'.join(normalized) 144 | 145 | return normalized 146 | 147 | 148 | def parse_content_type(content_type): 149 | """Cleans up content_type.""" 150 | if content_type: 151 | return content_type.split(';')[0].strip().lower() 152 | else: 153 | return '' 154 | 155 | 156 | def parse_authorization_header(auth_header): 157 | """ 158 | Example Authorization header: 159 | 160 | 'Hawk id="dh37fgj492je", ts="1367076201", nonce="NPHgnG", ext="and 161 | welcome!", mac="CeWHy4d9kbLGhDlkyw2Nh3PJ7SDOdZDa267KH4ZaNMY="' 162 | """ 163 | if len(auth_header) > MAX_LENGTH: 164 | raise BadHeaderValue('Header exceeds maximum length of {max_length}'.format( 165 | max_length=MAX_LENGTH)) 166 | 167 | # Make sure we have a unicode object for consistency. 168 | if isinstance(auth_header, six.binary_type): 169 | auth_header = auth_header.decode('utf8') 170 | 171 | scheme, attributes_string = auth_header.split(' ', 1) 172 | 173 | if scheme.lower() != 'hawk': 174 | raise HawkFail("Unknown scheme '{scheme}' when parsing header" 175 | .format(scheme=scheme)) 176 | 177 | 178 | attributes = {} 179 | 180 | def replace_attribute(match): 181 | """Extract the next key="value"-pair in the header.""" 182 | key = match.group('key') 183 | value = match.group('value') 184 | if key not in allowable_header_keys: 185 | raise HawkFail("Unknown Hawk key '{key}' when parsing header" 186 | .format(key=key)) 187 | validate_header_attr(value, name=key) 188 | if key in attributes: 189 | raise BadHeaderValue('Duplicate key in header: {key}'.format(key=key)) 190 | attributes[key] = value 191 | 192 | # Iterate over all the key="value"-pairs in the header, replace them with 193 | # an empty string, and store the extracted attribute in the attributes 194 | # dict. Correctly formed headers will then leave nothing unparsed (''). 195 | unparsed_header = HAWK_HEADER_RE.sub(replace_attribute, attributes_string) 196 | if unparsed_header != '': 197 | raise BadHeaderValue("Couldn't parse Hawk header", unparsed_header) 198 | 199 | log.debug('parsed Hawk header: {header} into: \n{parsed}' 200 | .format(header=auth_header, parsed=pprint.pformat(attributes))) 201 | return attributes 202 | 203 | 204 | def strings_match(a, b): 205 | # Constant time string comparision, mitigates side channel attacks. 206 | if len(a) != len(b): 207 | return False 208 | result = 0 209 | 210 | def byte_ints(buf): 211 | for ch in buf: 212 | # In Python 3, if we have a bytes object, iterating it will 213 | # already get the integer value. In older pythons, we need 214 | # to use ord(). 215 | if not isinstance(ch, int): 216 | ch = ord(ch) 217 | yield ch 218 | 219 | for x, y in zip(byte_ints(a), byte_ints(b)): 220 | result |= x ^ y 221 | return result == 0 222 | 223 | 224 | def utc_now(offset_in_seconds=0.0): 225 | # TODO: add support for SNTP server? See ntplib module. 226 | return int(math.floor(calendar.timegm(time.gmtime()) + 227 | float(offset_in_seconds))) 228 | 229 | 230 | # Allowed value characters: 231 | # !#$%&'()*+,-./:;<=>?@[]^_`{|}~ and space, a-z, A-Z, 0-9, \, " 232 | _header_attribute_chars = re.compile( 233 | r"^[ a-zA-Z0-9_\!#\$%&'\(\)\*\+,\-\./\:;<\=>\?@\[\]\^`\{\|\}~]*$") 234 | 235 | 236 | def validate_header_attr(val, name=None): 237 | if not _header_attribute_chars.match(val): 238 | raise BadHeaderValue('header value name={name} value={val} ' 239 | 'contained an illegal character' 240 | .format(name=name or '?', val=repr(val))) 241 | 242 | 243 | def prepare_header_val(val): 244 | if isinstance(val, six.binary_type): 245 | val = val.decode('utf-8') 246 | validate_header_attr(val) 247 | return val 248 | 249 | 250 | def normalize_header_attr(val): 251 | if isinstance(val, six.binary_type): 252 | return val.decode('utf-8') 253 | return val 254 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # For testing. 2 | mock >= 3.0.5 3 | nose >= 1.3.7 4 | 5 | # For documentation. 6 | docutils >= 0.15.2 7 | Sphinx >= 1.2.1 8 | sphinx-rtd-theme >= 0.4.3 9 | 10 | # For publishing to PyPI. 11 | wheel >= 0.33.6 12 | twine >= 1.6.5 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup(name='mohawk', 5 | version='1.1.0', 6 | description="Library for Hawk HTTP authorization", 7 | long_description=""" 8 | Hawk lets two parties securely communicate with each other using 9 | messages signed by a shared key. 10 | It is based on HTTP MAC access authentication (which 11 | was based on parts of OAuth 1.0). 12 | 13 | The Mohawk API is a little different from that of the Node library 14 | (i.e. https://github.com/hueniverse/hawk). 15 | It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. 16 | 17 | Read more: https://github.com/kumar303/mohawk/ 18 | """, 19 | author='Kumar McMillan, Austin King', 20 | author_email='kumar.mcmillan@gmail.com', 21 | license='MPL 2.0 (Mozilla Public License)', 22 | url='https://github.com/kumar303/mohawk', 23 | include_package_data=True, 24 | classifiers=[ 25 | 'Intended Audience :: Developers', 26 | 'Natural Language :: English', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python :: 2', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 2.6', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3.4', 33 | 'Programming Language :: Python :: 3.5', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Topic :: Internet :: WWW/HTTP', 37 | ], 38 | packages=find_packages(exclude=['tests']), 39 | install_requires=['six']) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For info on tox see https://tox.readthedocs.io/ 2 | 3 | [tox] 4 | # Also see .travis.yml where this is maintained separately. 5 | envlist=py27,py34,py35,py36,py37,py38,docs 6 | 7 | [base] 8 | deps= 9 | -r{toxinidir}/requirements/dev.txt 10 | 11 | [testenv] 12 | deps={[base]deps} 13 | commands= 14 | nosetests [] 15 | 16 | [testenv:docs] 17 | basepython=python3.7 18 | changedir=docs 19 | deps={[base]deps} 20 | commands= 21 | sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 22 | sphinx-build -b doctest -d {envtmpdir}/doctrees . {envtmpdir}/doctest 23 | --------------------------------------------------------------------------------