Update package to follow the latest draft

pull/34/head
Andrey Kislyuk 2022-04-10 14:50:12 -07:00
parent 8d615eac2a
commit 86bcd9f275
No known key found for this signature in database
GPG Key ID: 8AFAFCD242818A52
12 changed files with 269 additions and 522 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: [kislyuk]

26
.github/workflows/ci.yml vendored Normal file
View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -2,10 +2,7 @@ requests-http-signature: A Requests auth module for HTTP Signature
==================================================================
**requests-http-signature** is a `Requests <https://github.com/requests/requests>`_ `authentication plugin
<http://docs.python-requests.org/en/master/user/authentication/>`_ (``requests.auth.AuthBase`` subclass) implementing
the `IETF HTTP Signatures draft RFC <https://tools.ietf.org/html/draft-richanna-http-message-signatures>`_. 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 <https://pypi.python.org/pypi/cryptography>`_.
the `IETF HTTP Message Signatures draft RFC <https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/>`_.
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 <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers>`_ 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://tools.ietf.org/html/draft-richanna-http-message-signatures>`_
* https://github.com/joyent/node-http-signature
* `Project home page (GitHub) <https://github.com/kislyuk/requests-http-signature>`_
* `Documentation (Read the Docs) <https://requests-http-signature.readthedocs.io/en/latest/>`_
* `IETF HTTP Signatures draft <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures>`_
* `http-message-signatures <https://github.com/pyauth/http-message-signatures>`_ - a dependency of this library that
handles much of the implementation
* `Project home page (GitHub) <https://github.com/pyauth/requests-http-signature>`_
* `Package distribution (PyPI) <https://pypi.python.org/pypi/requests-http-signature>`_
* `Change log <https://github.com/kislyuk/requests-http-signature/blob/master/Changes.rst>`_
* `Change log <https://github.com/pyauth/requests-http-signature/blob/master/Changes.rst>`_
Bugs
~~~~
Please report bugs, issues, feature requests, etc. on `GitHub <https://github.com/kislyuk/requests-http-signature/issues>`_.
Please report bugs, issues, feature requests, etc. on `GitHub <https://github.com/pyauth/requests-http-signature/issues>`_.
License
-------
Licensed under the terms of the `Apache License, Version 2.0 <http://www.apache.org/licenses/LICENSE-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/

View File

@ -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

View File

@ -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)

View File

@ -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'),
]

View File

@ -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")

View File

@ -1,5 +1,3 @@
[bdist_wheel]
universal=1
[flake8]
max-line-length=120
ignore: E301, E302, E401, E261, E265, E226, F401, W504
ignore: E401

View File

@ -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"]
}
)

View File

@ -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