diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..90d9e33 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [kislyuk] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ae1340 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: Test + +on: [push, pull_request] + +jobs: + CI: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + max-parallel: 8 + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install build dependencies + run: pip install build wheel + - name: Install package + run: pip install .[tests] + - name: Test + run: make test diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml deleted file mode 100644 index 1afcffd..0000000 --- a/.github/workflows/pythonpackage.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Python package - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-18.04 - strategy: - max-parallel: 4 - matrix: - python-version: [3.5, 3.6, 3.7] - - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install codecov wheel cryptography - pip install . - - name: Test - run: | - make test - bash <(curl -s https://codecov.io/bash) diff --git a/Makefile b/Makefile index 1b4c6d1..84bef37 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,22 @@ -test_deps: - pip install coverage flake8 wheel +SHELL=/bin/bash -lint: test_deps - flake8 $$(python setup.py --name | sed 's/-/_/g') +lint: + flake8 -test: test_deps lint - coverage run --source=$$(python setup.py --name | sed 's/-/_/g') ./test/test.py +test: lint + python ./test/test.py -v init_docs: cd docs; sphinx-quickstart docs: - $(MAKE) -C docs html + sphinx-build docs docs/html -install: clean - pip install wheel - python setup.py bdist_wheel +install: + -rm -rf dist + python -m build pip install --upgrade dist/*.whl -clean: - -rm -rf build dist - -rm -rf *.egg-info - -.PHONY: lint test test_deps docs install clean +.PHONY: test lint release docs include common.mk diff --git a/README.rst b/README.rst index 32e227f..6e66b38 100644 --- a/README.rst +++ b/README.rst @@ -2,10 +2,7 @@ requests-http-signature: A Requests auth module for HTTP Signature ================================================================== **requests-http-signature** is a `Requests `_ `authentication plugin `_ (``requests.auth.AuthBase`` subclass) implementing -the `IETF HTTP Signatures draft RFC `_. It has no -required dependencies outside the standard library. If you wish to use algorithms other than HMAC (namely, RSA and -ECDSA algorithms specified in the RFC), there is an optional dependency on -`cryptography `_. +the `IETF HTTP Message Signatures draft RFC `_. Installation ------------ @@ -19,65 +16,74 @@ Usage .. code-block:: python import requests - from requests_http_signature import HTTPSignatureAuth + from requests_http_signature import HTTPSignatureAuth, algorithms preshared_key_id = 'squirrel' - preshared_secret = 'monorail_cat' + preshared_secret = b'monorail_cat' url = 'http://example.com/path' - - requests.get(url, auth=HTTPSignatureAuth(key=preshared_secret, key_id=preshared_key_id)) -By default, only the ``Date`` header is signed (as per the RFC) for body-less requests such as GET. The ``Date`` header -is set if it is absent. In addition, for requests with bodies (such as POST), the ``Digest`` header is set to the SHA256 -of the request body and signed (an example of this appears in the RFC). To add other headers to the signature, pass an -array of header names in the ``headers`` keyword argument. + auth = HTTPSignatureAuth(key=preshared_secret, key_id=preshared_key_id, signature_algorithm=algorithms.HMAC_SHA256) + requests.get(url, auth=auth) + +By default, only the ``Date`` header and the ``@method``, ``@authority``, and ``@target-uri`` derived component +identifiers are signed for body-less requests such as GET. The ``Date`` header is set if it is absent. In addition, for +requests with bodies (such as POST), the ``Content-Digest`` header is set to the SHA256 of the request body using the +format described in the +`IETF Digest Fields draft RFC `_ and signed. +To add other headers to the signature, pass an array of header names in the ``covered_component_ids`` keyword argument. In addition to signing messages in the client, the class method ``HTTPSignatureAuth.verify()`` can be used to verify incoming requests: .. code-block:: python - def key_resolver(key_id, algorithm): - return 'monorail_cat' + class key_resolver: + def resolve_public_key(self, key_id): + assert key_id == 'squirrel' + return 'monorail_cat' - HTTPSignatureAuth.verify(request, key_resolver=key_resolver) + HTTPSignatureAuth.verify(request, signature_algorithm=algorithms.HMAC_SHA256, key_resolver=key_resolver) -Asymmetric key algorithms (RSA and ECDSA) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For asymmetric key algorithms, you should supply the private key as the ``key`` parameter to the ``HTTPSignatureAuth()`` -constructor as bytes in the PEM format: + +Asymmetric key algorithms +~~~~~~~~~~~~~~~~~~~~~~~~~ +To sign or verify messages with an asymmetric key algorithm, set the ``signature_algorithm`` keyword argument to +``algorithms.ED25519``, ``algorithms.ECDSA_P256_SHA256``, ``algorithms.RSA_V1_5_SHA256``, or +``algorithms.RSA_PSS_SHA512``. Note that signing with rsa-pss-sha512 is not currently supported due to a limitation of +the cryptography library. + +For asymmetric key algorithms, you can supply the private key as the ``key`` parameter to the ``HTTPSignatureAuth()`` +constructor as bytes in the PEM format, or configure the key resolver as follows: .. code-block:: python with open('key.pem', 'rb') as fh: - requests.get(url, auth=HTTPSignatureAuth(algorithm="rsa-sha256", key=fh.read(), key_id=preshared_key_id)) + auth = HTTPSignatureAuth(algorithm=algorithms.RSA_V1_5_SHA256, key=fh.read(), key_id=preshared_key_id) + requests.get(url, auth=auth) -When verifying, the ``key_resolver()`` callback should provide the public key as bytes in the PEM format as well. + class MyKeyResolver: + def resolve_public_key(self, key_id: str): + return public_key_pem_bytes[key_id] + + def resolve_private_key(self, key_id: str): + return private_key_pem_bytes[key_id] + + auth = HTTPSignatureAuth(algorithm=algorithms.RSA_V1_5_SHA256, key=fh.read(), key_resolver=MyKeyResolver()) + requests.get(url, auth=auth) Links ----- -* `IETF HTTP Signatures draft `_ -* https://github.com/joyent/node-http-signature -* `Project home page (GitHub) `_ -* `Documentation (Read the Docs) `_ +* `IETF HTTP Signatures draft `_ +* `http-message-signatures `_ - a dependency of this library that + handles much of the implementation +* `Project home page (GitHub) `_ * `Package distribution (PyPI) `_ -* `Change log `_ +* `Change log `_ Bugs ~~~~ -Please report bugs, issues, feature requests, etc. on `GitHub `_. +Please report bugs, issues, feature requests, etc. on `GitHub `_. License ------- Licensed under the terms of the `Apache License, Version 2.0 `_. - -.. image:: https://github.com/pyauth/requests-http-signature/workflows/Python%20package/badge.svg - :target: https://github.com/pyauth/requests-http-signature/actions -.. image:: https://codecov.io/github/kislyuk/requests-http-signature/coverage.svg?branch=master - :target: https://codecov.io/github/kislyuk/requests-http-signature?branch=master -.. image:: https://img.shields.io/pypi/v/requests-http-signature.svg - :target: https://pypi.python.org/pypi/requests-http-signature -.. image:: https://img.shields.io/pypi/l/requests-http-signature.svg - :target: https://pypi.python.org/pypi/requests-http-signature -.. image:: https://readthedocs.org/projects/requests-http-signature/badge/?version=latest - :target: https://requests-http-signature.readthedocs.org/ diff --git a/common.mk b/common.mk index aaf963f..0f3e934 100644 --- a/common.mk +++ b/common.mk @@ -1,28 +1,31 @@ SHELL=/bin/bash -eo pipefail -release_major: +release-major: $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v@{[$$1+1]}.0.0"')) $(MAKE) release -release_minor: +release-minor: $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.@{[$$2+1]}.0"')) $(MAKE) release -release_patch: +release-patch: $(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d+)\.(\d+)\.(\d+)/; print "v$$1.$$2.@{[$$3+1]}"')) $(MAKE) release release: - @if [[ -z $$TAG ]]; then echo "Use release_{major,minor,patch}"; exit 1; fi - $(eval REMOTE=$(shell git remote get-url origin | perl -ne '/([^\/\:]+\/.+?)(\.git)?$$/; print $$1')) + @if ! git diff --cached --exit-code; then echo "Commit staged files before proceeding"; exit 1; fi + @if [[ -z $$TAG ]]; then echo "Use release-{major,minor,patch}"; exit 1; fi + @if ! type -P pandoc; then echo "Please install pandoc"; exit 1; fi + @if ! type -P sponge; then echo "Please install moreutils"; exit 1; fi + @if ! type -P http; then echo "Please install httpie"; exit 1; fi + @if ! type -P twine; then echo "Please install twine"; exit 1; fi + $(eval REMOTE=$(shell git remote get-url origin | perl -ne '/([^\/\:]+\/[^\/\:]+?)(\.git)?$$/; print $$1')) $(eval GIT_USER=$(shell git config --get user.email)) $(eval GH_AUTH=$(shell if grep -q '@github.com' ~/.git-credentials; then echo $$(grep '@github.com' ~/.git-credentials | python3 -c 'import sys, urllib.parse as p; print(p.urlparse(sys.stdin.read()).netloc.split("@")[0])'); else echo $(GIT_USER); fi)) $(eval RELEASES_API=https://api.github.com/repos/${REMOTE}/releases) $(eval UPLOADS_API=https://uploads.github.com/repos/${REMOTE}/releases) git pull git clean -x --force $$(python setup.py --name) - sed -i -e "s/version=\([\'\"]\)[0-9]*\.[0-9]*\.[0-9]*/version=\1$${TAG:1}/" setup.py - git add setup.py TAG_MSG=$$(mktemp); \ echo "# Changes for ${TAG} ($$(date +%Y-%m-%d))" > $$TAG_MSG; \ git log --pretty=format:%s $$(git describe --abbrev=0)..HEAD >> $$TAG_MSG; \ @@ -32,15 +35,15 @@ release: git commit -m ${TAG}; \ git tag --sign --annotate --file $$TAG_MSG ${TAG} git push --follow-tags - http --auth ${GH_AUTH} ${RELEASES_API} tag_name=${TAG} name=${TAG} \ + http --check-status --auth ${GH_AUTH} ${RELEASES_API} tag_name=${TAG} name=${TAG} \ body="$$(git tag --list ${TAG} -n99 | perl -pe 's/^\S+\s*// if $$. == 1' | sed 's/^\s\s\s\s//')" $(MAKE) install - http --auth ${GH_AUTH} POST ${UPLOADS_API}/$$(http --auth ${GH_AUTH} ${RELEASES_API}/latest | jq .id)/assets \ + http --check-status --auth ${GH_AUTH} POST ${UPLOADS_API}/$$(http --auth ${GH_AUTH} ${RELEASES_API}/latest | jq .id)/assets \ name==$$(basename dist/*.whl) label=="Python Wheel" < dist/*.whl - $(MAKE) pypi_release + $(MAKE) release-pypi -pypi_release: - python setup.py sdist bdist_wheel +release-pypi: + python -m build twine upload dist/*.tar.gz dist/*.whl --sign --verbose .PHONY: release diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 56aff91..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = requests-http-signature -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index ab60257..fa6d554 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,158 +1,29 @@ -# -*- coding: utf-8 -*- -# -# requests-http-signature documentation build configuration file, created by -# sphinx-quickstart on Tue Aug 29 10:01:38 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +import guzzle_sphinx_theme -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages'] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'requests-http-signature' -copyright = u'2017, Andrey Kislyuk' -author = u'Andrey Kislyuk' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u'' -# The full version, including alpha/beta/rc tags. -release = u'' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. +project = "requests-http-signature" +copyright = "Andrey Kislyuk" +author = "Andrey Kislyuk" +version = "" +release = "" language = None +master_doc = "index" +extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] +source_suffix = [".rst", ".md"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +pygments_style = "sphinx" +autodoc_typehints = "description" -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = 'requests-http-signaturedoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', +html_theme_path = guzzle_sphinx_theme.html_theme_path() +html_theme = "guzzle_sphinx_theme" +html_theme_options = { + "project_nav_name": project, + "projectlink": "https://github.com/kislyuk/" + project, +} +html_sidebars = { + "**": [ + "logo-text.html", + # "globaltoc.html", + "localtoc.html", + "searchbox.html" + ] } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'requests-http-signature.tex', u'requests-http-signature Documentation', - u'Andrey Kislyuk', 'manual'), -] - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'requests-http-signature', u'requests-http-signature Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'requests-http-signature', u'requests-http-signature Documentation', - author, 'requests-http-signature', 'One line description of project.', - 'Miscellaneous'), -] - - - diff --git a/requests_http_signature/__init__.py b/requests_http_signature/__init__.py index db95b6d..2b52c69 100644 --- a/requests_http_signature/__init__.py +++ b/requests_http_signature/__init__.py @@ -1,184 +1,130 @@ -from __future__ import absolute_import, division, print_function, unicode_literals - -import base64, hashlib, hmac, time +import datetime import email.utils +import hashlib +import secrets +from typing import List +import http_sfv import requests -from requests.compat import urlparse + from requests.exceptions import RequestException +from http_message_signatures import (algorithms, HTTPSignatureComponentResolver, HTTPSignatureKeyResolver, # noqa: F401 + HTTPMessageSigner, HTTPMessageVerifier, HTTPSignatureAlgorithm, InvalidSignature) +from http_message_signatures.structures import CaseInsensitiveDict + class RequestsHttpSignatureException(RequestException): """An error occurred while constructing the HTTP Signature for your request.""" -class Crypto: - def __init__(self, algorithm): - if algorithm != "hmac-sha256": - from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives.asymmetric import rsa, ec - from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 - from cryptography.hazmat.primitives.hashes import SHA1, SHA256, SHA512 - self.__dict__.update(locals()) - def sign(self, string_to_sign, key, passphrase=None): - if self.algorithm == "hmac-sha256": - return hmac.new(key, string_to_sign, digestmod=hashlib.sha256).digest() - key = self.load_pem_private_key(key, password=passphrase, backend=self.default_backend()) - if self.algorithm in {"rsa-sha1", "rsa-sha256"}: - hasher = self.SHA1() if self.algorithm.endswith("sha1") else self.SHA256() - return key.sign(padding=self.PKCS1v15(), algorithm=hasher, data=string_to_sign) - elif self.algorithm in {"rsa-sha512"}: - hasher = self.SHA512() - return key.sign(padding=self.PKCS1v15(), algorithm=hasher, data=string_to_sign) - elif self.algorithm == "ecdsa-sha256": - return key.sign(signature_algorithm=self.ec.ECDSA(algorithm=self.SHA256()), data=string_to_sign) +class SingleKeyResolver(HTTPSignatureKeyResolver): + def __init__(self, key_id, key): + self.key_id = key_id + self.key = key + + def resolve_public_key(self, key_id): + assert key_id == self.key_id + return self.key + + def resolve_private_key(self, key_id): + assert key_id == self.key_id + return self.key - def verify(self, signature, string_to_sign, key): - if self.algorithm == "hmac-sha256": - assert signature == hmac.new(key, string_to_sign, digestmod=hashlib.sha256).digest() - else: - key = self.load_pem_public_key(key, backend=self.default_backend()) - hasher = self.SHA1() if self.algorithm.endswith("sha1") else self.SHA256() - if self.algorithm == "ecdsa-sha256": - key.verify(signature, string_to_sign, self.ec.ECDSA(hasher)) - else: - key.verify(signature, string_to_sign, self.PKCS1v15(), hasher) class HTTPSignatureAuth(requests.auth.AuthBase): + hasher_name = "sha-256" hasher_constructor = hashlib.sha256 - known_algorithms = { - "rsa-sha1", - "rsa-sha256", - "rsa-sha512", - "hmac-sha256", - "ecdsa-sha256", - } - def __init__(self, key, key_id, algorithm="hmac-sha256", headers=None, passphrase=None, expires_in=None): - """ - :param typing.Union[bytes, string] passphrase: The passphrase for an encrypted RSA private key - :param datetime.timedelta expires_in: The time after which this signature should expire - """ - assert algorithm in self.known_algorithms - self.key = key + def __init__(self, *, + key: bytes = None, + key_id: str, + label: str = None, + include_alg: bool = True, + use_nonce: bool = False, + covered_component_ids: List[str] = ("@method", "@authority", "@target-uri"), + expires_in: datetime.timedelta = None, + signature_algorithm: HTTPSignatureAlgorithm, + key_resolver: HTTPSignatureKeyResolver = None, + component_resolver_class: type = HTTPSignatureComponentResolver): + if key_resolver is None and key is None: + raise RequestsHttpSignatureException("Either key_resolver or key must be specified.") + if key_resolver is not None and key is not None: + raise RequestsHttpSignatureException("Either key_resolver or key must be specified, not both.") + if key_resolver is None: + key_resolver = SingleKeyResolver(key_id=key_id, key=key) + self.key_id = key_id - self.algorithm = algorithm - self.headers = [h.lower() for h in headers] if headers is not None else ["date"] - self.passphrase = passphrase if passphrase is None or isinstance(passphrase, bytes) else passphrase.encode() + self.label = label + self.include_alg = include_alg + self.use_nonce = use_nonce + self.covered_component_ids = covered_component_ids self.expires_in = expires_in + handler_args = dict(signature_algorithm=signature_algorithm, + key_resolver=key_resolver, + component_resolver_class=component_resolver_class) + self.signer = HTTPMessageSigner(**handler_args) def add_date(self, request, timestamp): if "Date" not in request.headers: request.headers["Date"] = email.utils.formatdate(timestamp, usegmt=True) def add_digest(self, request): - if request.body is None and "digest" in self.headers: + if request.body is None and "content-digest" in self.covered_component_ids: raise RequestsHttpSignatureException("Could not compute digest header for request without a body") - if request.body is not None and "Digest" not in request.headers: - if "digest" not in self.headers: - self.headers.append("digest") + if request.body is not None and "Content-Digest" not in request.headers: + if "content-digest" not in self.covered_component_ids: + self.covered_component_ids = list(self.covered_component_ids) + ["content-digest"] digest = self.hasher_constructor(request.body).digest() - request.headers["Digest"] = "SHA-256=" + base64.b64encode(digest).decode() + digest_node = http_sfv.Dictionary({self.hasher_name: digest}) + request.headers["Content-Digest"] = str(digest_node) - @classmethod - def get_string_to_sign(self, request, headers, created_timestamp, expires_timestamp): - sts = [] - for header in headers: - if header == "(request-target)": - path_url = requests.models.RequestEncodingMixin.path_url.fget(request) - sts.append("{}: {} {}".format(header, request.method.lower(), path_url)) - elif header == "(created)" and created_timestamp: - sts.append("{}: {}".format(header, created_timestamp)) - elif header == "(expires)": - assert (expires_timestamp is not None), \ - 'You should provide the "expires_in" argument when using the (expires) header' - sts.append("{}: {}".format(header, int(expires_timestamp))) - else: - if header.lower() == "host": - url = urlparse(request.url) - value = request.headers.get("host", url.hostname) - if url.scheme == "http" and url.port not in [None, 80] or url.scheme == "https" \ - and url.port not in [443, None]: - value = "{}:{}".format(value, url.port) - else: - value = request.headers[header] - sts.append("{k}: {v}".format(k=header.lower(), v=value)) - return "\n".join(sts).encode() + def get_nonce(self, request): + if self.use_nonce: + return secrets.token_urlsafe(16) - def create_signature_string(self, request): - created_timestamp = int(time.time()) - expires_timestamp = None - if self.expires_in is not None: - expires_timestamp = created_timestamp + self.expires_in.total_seconds() - self.add_date(request, created_timestamp) + def get_created(self, request): + created = datetime.datetime.now() + self.add_date(request, timestamp=int(created.timestamp())) + # TODO: add Date to covered components + return created + + def get_expires(self, request, created): + if self.expires_in: + return datetime.datetime.now() + self.expires_in + + def __call__(self, request): self.add_digest(request) - raw_sig = Crypto(self.algorithm).sign( - string_to_sign=self.get_string_to_sign(request, self.headers, created_timestamp, expires_timestamp), - key=self.key.encode() if isinstance(self.key, str) else self.key, - passphrase=self.passphrase, - ) - sig = base64.b64encode(raw_sig).decode() - sig_struct = [ - ("keyId", self.key_id), - ("algorithm", self.algorithm), - ("headers", " ".join(self.headers)), - ("signature", sig), - ] - if not (self.algorithm.startswith("rsa") or self.algorithm.startswith("hmac") or - self.algorithm.startswith("ecdsa")): - sig_struct.append(("created", int(created_timestamp))) - if expires_timestamp is not None: - sig_struct.append(("expires", int(expires_timestamp))) - return ",".join('{}="{}"'.format(k, v) for k, v in sig_struct) - - def __call__(self, request): - request.headers["Authorization"] = "Signature " + self.create_signature_string(request) + created = self.get_created(request) + expires = self.get_expires(request, created=created) + self.signer.sign(request, + key_id=self.key_id, + created=created, + expires=expires, + nonce=self.get_nonce(request), + label=self.label, + include_alg=self.include_alg, + covered_component_ids=self.covered_component_ids) return request @classmethod - def get_sig_struct(self, request, scheme="Authorization"): - sig_struct = request.headers[scheme] - if scheme == "Authorization": - sig_struct = sig_struct.split(" ", 1)[1] - return {i.split("=", 1)[0]: i.split("=", 1)[1].strip('"') for i in sig_struct.split(",")} - - @classmethod - def verify(self, request, key_resolver, scheme="Authorization"): - if scheme == "Authorization": - assert "Authorization" in request.headers, "No Authorization header found" - msg = 'Unexpected scheme found in Authorization header (expected "Signature")' - assert request.headers["Authorization"].startswith("Signature "), msg - elif scheme == "Signature": - assert "Signature" in request.headers, "No Signature header found" - else: - raise RequestsHttpSignatureException('Unknown signature scheme "{}"'.format(scheme)) - - sig_struct = self.get_sig_struct(request, scheme=scheme) - for field in "keyId", "algorithm", "signature": - assert field in sig_struct, 'Required signature parameter "{}" not found'.format(field) - assert sig_struct["algorithm"] in self.known_algorithms, "Unknown signature algorithm" - created_timestamp = int(sig_struct['created']) if 'created' in sig_struct else None - expires_timestamp = sig_struct.get('expires') - if expires_timestamp is not None: - expires_timestamp = int(expires_timestamp) - headers = sig_struct.get("headers", "date").split(" ") - sig = base64.b64decode(sig_struct["signature"]) - sts = self.get_string_to_sign(request, headers, created_timestamp, expires_timestamp=expires_timestamp) - key = key_resolver(key_id=sig_struct["keyId"], algorithm=sig_struct["algorithm"]) - Crypto(sig_struct["algorithm"]).verify(sig, sts, key) - if expires_timestamp is not None: - assert expires_timestamp > int(time.time()) - - -class HTTPSignatureHeaderAuth(HTTPSignatureAuth): - """ - https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-4 - Using "Signature" header instead of "Authorization" header. - """ - - def __call__(self, request): - request.headers["Signature"] = self.create_signature_string(request) - return request - - def verify(self, request, key_resolver): - return super().verify(request, key_resolver, scheme="Signature") + def verify(cls, request, *, + signature_algorithm: HTTPSignatureAlgorithm, + key_resolver: HTTPSignatureKeyResolver, + component_resolver_class: type = HTTPSignatureComponentResolver): + verifier = HTTPMessageVerifier(signature_algorithm=signature_algorithm, + key_resolver=key_resolver, + component_resolver_class=component_resolver_class) + verifier.verify(request) + headers = CaseInsensitiveDict(request.headers) + if "content-digest" in headers: + if request.body is None: + raise InvalidSignature("Found a content-digest header in a request with no body") + digest = http_sfv.Dictionary() + digest.parse(headers["content-digest"].encode()) + for k, v in digest.items(): + if k != cls.hasher_name: + raise InvalidSignature(f'Unsupported digest algorithm "{k}"') + raw_digest = v.value + expect_digest = cls.hasher_constructor(request.body).digest() + if raw_digest != expect_digest: + raise InvalidSignature("The content-digest header does not match the request body") diff --git a/setup.cfg b/setup.cfg index 2259339..a08592d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,3 @@ -[bdist_wheel] -universal=1 [flake8] max-line-length=120 -ignore: E301, E302, E401, E261, E265, E226, F401, W504 +ignore: E401 diff --git a/setup.py b/setup.py index 83f4396..8618a99 100755 --- a/setup.py +++ b/setup.py @@ -1,17 +1,34 @@ #!/usr/bin/env python -import os, glob from setuptools import setup, find_packages setup( name='requests-http-signature', version='0.2.0', - url='https://github.com/kislyuk/requests-http-signature', + url='https://github.com/pyauth/requests-http-signature', license='Apache Software License', author='Andrey Kislyuk', author_email='kislyuk@gmail.com', - description="A Requests auth module for HTTP Signature", + description="A Requests auth module for HTTP Message Signatures", long_description=open('README.rst').read(), + use_scm_version={ + "write_to": "requests_http_signature/version.py", + }, + setup_requires=['setuptools_scm >= 3.4.3'], + install_requires=[ + "http-message-signatures >= 0.2.0", + "http-sfv >= 0.9.3", + "requests >= 2.27.1" + ], + extras_require={ + "tests": [ + "flake8", + "coverage", + "build", + "wheel", + "mypy", + ] + }, packages=find_packages(exclude=['test']), include_package_data=True, platforms=['MacOS X', 'Posix'], @@ -22,17 +39,10 @@ setup( 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Development Status :: 5 - Production/Stable', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Topic :: Software Development :: Libraries :: Python Modules' ], - install_requires=[ - "requests" - ], - extras_require={ - "rsa": ["cryptography >= 1.8.2"], - "ecdsa": ["cryptography >= 1.8.2"] - } ) diff --git a/test/test.py b/test/test.py index c8e508d..0ed53ff 100755 --- a/test/test.py +++ b/test/test.py @@ -1,37 +1,31 @@ #!/usr/bin/env python -from __future__ import absolute_import, division, print_function, unicode_literals - import os, sys, unittest, logging, base64 -from datetime import timedelta import requests -from cryptography.fernet import Fernet from requests.adapters import HTTPAdapter -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # noqa +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from requests_http_signature import HTTPSignatureAuth, HTTPSignatureHeaderAuth, RequestsHttpSignatureException +from requests_http_signature import algorithms, HTTPSignatureAuth # noqa: E402 +from http_message_signatures import InvalidSignature # noqa: E402 + +logging.basicConfig(level="DEBUG") + +default_keyid = "my_key_id" hmac_secret = b"monorail_cat" passphrase = b"passw0rd" class TestAdapter(HTTPAdapter): - def __init__(self, testcase): - super(TestAdapter, self).__init__() - self.testcase = testcase + def __init__(self, auth): + super().__init__() + self.client_auth = auth def send(self, request, *args, **kwargs): - def key_resolver(key_id, algorithm): - if "pubkey" in request.headers: - return base64.b64decode(request.headers["pubkey"]) - return hmac_secret HTTPSignatureAuth.verify(request, - key_resolver=key_resolver, - scheme=request.headers.get("sigScheme", "Authorization")) - if "expectSig" in request.headers: - self.testcase.assertEqual(request.headers["expectSig"], - HTTPSignatureAuth.get_sig_struct(request)["signature"]) + signature_algorithm=self.client_auth.signer.signature_algorithm, + key_resolver=self.client_auth.signer.key_resolver) response = requests.Response() response.status_code = requests.codes.ok response.url = request.url @@ -45,80 +39,24 @@ class DigestlessSignatureAuth(HTTPSignatureAuth): class TestRequestsHTTPSignature(unittest.TestCase): def setUp(self): - logging.basicConfig(level="DEBUG") self.session = requests.Session() - self.session.mount("http://", TestAdapter(self)) - - def test_readme_example(self): - preshared_key_id = 'squirrel' - preshared_secret = 'monorail_cat' - url = 'http://example.com/path' - requests.get(url, auth=HTTPSignatureAuth(key=preshared_secret, key_id=preshared_key_id)) + self.auth = HTTPSignatureAuth(key_id=default_keyid, key=hmac_secret, signature_algorithm=algorithms.HMAC_SHA256) + self.session.mount("http://", TestAdapter(self.auth)) def test_basic_statements(self): url = 'http://example.com/path?query#fragment' - self.session.get(url, auth=HTTPSignatureAuth(key=hmac_secret, key_id="sekret")) - with self.assertRaises(AssertionError): - self.session.get(url, auth=HTTPSignatureAuth(key=hmac_secret[::-1], key_id="sekret")) - with self.assertRaisesRegex(RequestsHttpSignatureException, - "Could not compute digest header for request without a body"): - self.session.get(url, - auth=HTTPSignatureAuth(key=hmac_secret[::-1], key_id="sekret", headers=["date", "digest"])) + self.session.get(url, auth=self.auth) + self.auth.signer.key_resolver.resolve_public_key = lambda k: b"abc" + with self.assertRaises(InvalidSignature): + self.session.get(url, auth=self.auth) + self.auth.signer.key_resolver.resolve_private_key = lambda k: b"abc" + self.session.get(url, auth=self.auth) + self.session.post(url, auth=self.auth, data=b"xyz") def test_expired_signature(self): - with self.assertRaises(AssertionError): - preshared_key_id = 'squirrel' - key = Fernet.generate_key() - one_month = timedelta(days=-30) - headers = ["(expires)"] - auth = HTTPSignatureAuth(key=key, key_id=preshared_key_id, - expires_in=one_month, headers=headers) - - def key_resolver(key_id, algorithm): - return key - - url = 'http://example.com/path' - response = requests.get(url, auth=auth) - HTTPSignatureAuth.verify(response.request, key_resolver=key_resolver) - - def test_rfc_examples(self): - # The date in the RFC is wrong (2014 instead of 2012). - # See https://github.com/joyent/node-http-signature/issues/54 - # Also, the values in https://github.com/joyent/node-http-signature/blob/master/test/verify.test.js don't match - # up with ours. This is because node-http-signature seems to compute the content-length incorrectly in its test - # suite (it should be 18, but they use 17). - url = 'http://example.org/foo' - payload = {"hello": "world"} - date = "Thu, 05 Jan 2012 21:31:40 GMT" - auth = HTTPSignatureAuth(key=hmac_secret, - key_id="sekret", - headers=["(request-target)", "host", "date", "digest", "content-length"]) - self.session.post(url, json=payload, headers={"Date": date}, auth=auth) - - pubkey_fn = os.path.join(os.path.dirname(__file__), "pubkey.pem") - privkey_fn = os.path.join(os.path.dirname(__file__), "privkey.pem") - url = "http://example.com/foo?param=value&pet=dog" - - with open(pubkey_fn, "rb") as pubkey, open(privkey_fn, "rb") as privkey: - pubkey_b64 = base64.b64encode(pubkey.read()) - auth = DigestlessSignatureAuth(algorithm="rsa-sha256", key=privkey.read(), key_id="Test") - expect_sig = "ATp0r26dbMIxOopqw0OfABDT7CKMIoENumuruOtarj8n/97Q3htHFYpH8yOSQk3Z5zh8UxUym6FYTb5+A0Nz3NRsXJibnYi7brE/4tx5But9kkFGzG+xpUmimN4c3TMN7OFH//+r8hBf7BT9/GmHDUVZT2JzWGLZES2xDOUuMtA=" # noqa - headers = {"Date": date, "pubkey": pubkey_b64, "expectSig": expect_sig} - self.session.post(url, json=payload, headers=headers, auth=auth) - - with open(pubkey_fn, "rb") as pubkey, open(privkey_fn, "rb") as privkey: - pubkey_b64 = base64.b64encode(pubkey.read()) - auth = HTTPSignatureAuth(algorithm="rsa-sha256", key=privkey.read(), key_id="Test", - headers="(request-target) host date content-type digest content-length".split()) - expect_sig = "DkOOyDlO9rXmOiU+k6L86N4UFEcey2YD+/Bz8c+Sr6XVDtDCxUuFEHMO+Atag/V1iLu+3KczVrCwjaZ39Ox3RufJghHzhTffyEkfPI6Ivf271mfRU9+wLxuGj9f+ATVO14nvcZyQjAMLvV7qh35zQcYdeD5XyxLLjuYUnK14rYI=" # noqa - headers = {"Date": date, "pubkey": pubkey_b64, "expectSig": expect_sig, "content-type": "application/json"} - self.session.post(url, json=payload, headers=headers, auth=auth) - - auth = HTTPSignatureHeaderAuth(key=hmac_secret, - key_id="sekret", - headers=["(request-target)", "host", "date", "digest", "content-length"]) - self.session.post(url, json=payload, headers={"Date": date, "sigScheme": "Signature"}, auth=auth) + "TODO" + @unittest.skip("TODO") def test_rsa(self): from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa