Compare commits

...

77 Commits

Author SHA1 Message Date
Adam Romanek 6306c36c1d
Update README.rst - fix HTTPSignatureAuth params (#38)
The `key` and `key_resolver` params are mutually exclusive.
2023-04-11 18:58:51 -07:00
Andrey Kislyuk aff77a5db3 Update actions 2023-03-05 11:54:11 -08:00
Andrey Kislyuk 2baa832c66
Use gh to manage releases 2023-03-04 16:38:19 -08:00
Andrey Kislyuk fe661e5be8
Fix typo in package data manifest 2022-10-15 22:00:13 -07:00
Andrey Kislyuk 358f719912 Update isort config 2022-09-11 11:42:53 -07:00
Andrey Kislyuk 45f540a22c
Use isort and black 2022-08-31 10:50:44 -07:00
Andrey Kislyuk c643c8ccd7 Sort imports 2022-08-21 08:54:43 -07:00
Marc Seguí Coll d9091e090d
fix(readme): updated HTTPSignatureAuth arguments (#34)
Changed `algorithm` to `signature_algorithm` while instantiating `HTTPSignatureAuth` objects in the README.rst file
2022-05-04 08:56:42 -07:00
Andrey Kislyuk 434e68f87e
RSA-PSS now supported by cryptography 2022-04-26 15:18:24 -07:00
Andrey Kislyuk 04ce9858fe
Use mypy check-untyped-defs 2022-04-21 16:28:37 -07:00
Andrey Kislyuk e710760497
Fix GHA mypy run 2022-04-19 11:43:25 -07:00
Andrey Kislyuk c22bbb74b1
v0.7.1 2022-04-19 11:39:44 -07:00
Andrey Kislyuk 198e2dae5c
Add typing information 2022-04-19 11:39:09 -07:00
Andrey Kislyuk 83abe85c81
Update README.rst 2022-04-15 16:56:58 -07:00
Andrey Kislyuk a5df2f1c57
Update README.rst 2022-04-15 16:55:40 -07:00
Andrey Kislyuk c39ce5114a
Update README.rst 2022-04-15 12:19:21 -07:00
Andrey Kislyuk c242bb4a8d
Update README.rst 2022-04-15 12:02:27 -07:00
Andrey Kislyuk 2aa9ca0bf2
Remove duplicative content 2022-04-15 12:01:47 -07:00
Andrey Kislyuk 1d7413b890
Relax requests version range to use LTS 2022-04-15 11:02:07 -07:00
Andrey Kislyuk 617f6265f0
Remove version since it is set by scm 2022-04-15 10:56:42 -07:00
Andrey Kislyuk ab8b01c9ae
Clarify naming 2022-04-15 10:34:49 -07:00
Andrey Kislyuk 69f17689f5
Mention that Authorization header is signed 2022-04-15 00:08:46 -07:00
Andrey Kislyuk 7fd0077c58
Expand verify example 2022-04-15 00:01:39 -07:00
Andrey Kislyuk d8b7916639
Clarify naming 2022-04-14 23:54:34 -07:00
Andrey Kislyuk adecf44d46
Use max-age=24h by default 2022-04-14 23:52:20 -07:00
Andrey Kislyuk e3e7c0f4c7
Use a class variable to set content digest alg 2022-04-14 23:48:53 -07:00
Andrey Kislyuk b68a6e7db4
Use max-age=36h by default 2022-04-14 23:33:05 -07:00
Andrey Kislyuk 9b7d9269b5
v0.7.0 2022-04-14 19:18:56 -07:00
Andrey Kislyuk 7ed5b7a022
Use max-age, auto-cover Authorization header 2022-04-14 19:18:23 -07:00
Andrey Kislyuk 6b5169c898
Fix typing of key_resolver 2022-04-13 13:27:43 -07:00
Andrey Kislyuk ca2b461a87
Add note on nonce parameter 2022-04-12 15:23:21 -07:00
Andrey Kislyuk 7324cb2f03
Correct anchor for API docs 2022-04-12 12:53:19 -07:00
Andrey Kislyuk 99dc5f76fb
v0.6.0 2022-04-12 12:02:12 -07:00
Andrey Kislyuk fc73c56301
Support and document verifying responses 2022-04-12 12:01:18 -07:00
Andrey Kislyuk 755d9325b8
Add section header 2022-04-11 10:15:52 -07:00
Andrey Kislyuk b0ce7c34e8
Add type hint for PreparedRequest 2022-04-11 08:51:07 -07:00
Andrey Kislyuk 3e12002ff2
Wrap code blocks 2022-04-11 08:48:43 -07:00
Andrey Kislyuk a01c9a6128
Add note about reconstructing incoming requests 2022-04-11 08:46:23 -07:00
Andrey Kislyuk 40c4a1522c
Fix API documentation anchor 2022-04-10 22:33:18 -07:00
Andrey Kislyuk 349c6e1cfe
No fixed keys for verifying 2022-04-10 22:25:59 -07:00
Andrey Kislyuk 0970baaff4
Fix wording in docstring 2022-04-10 22:23:58 -07:00
Andrey Kislyuk c9e8e39eff
Clarify digests doc 2022-04-10 22:17:53 -07:00
Andrey Kislyuk 65d0d98659
Fix doc formatting 2022-04-10 22:15:13 -07:00
Andrey Kislyuk 28e6043426
v0.5.0 2022-04-10 22:10:36 -07:00
Andrey Kislyuk c1acb39a5d
Pass through verified body in VerifiedRequest 2022-04-10 22:10:03 -07:00
Andrey Kislyuk 5d029f4f17
v0.4.0 2022-04-10 17:24:58 -07:00
Andrey Kislyuk d71b15bb07
Add require_components to verify; expand docs 2022-04-10 17:24:30 -07:00
Andrey Kislyuk fabae549c8
Bump dependency version 2022-04-10 16:43:05 -07:00
Andrey Kislyuk 1e3cb991e7
Expand docs 2022-04-10 15:54:55 -07:00
Andrey Kislyuk 1fe5e37fef
Add links to API docs 2022-04-10 15:13:00 -07:00
Andrey Kislyuk fdc7d3c334
Add docs target 2022-04-10 15:05:55 -07:00
Andrey Kislyuk 59573e7dd5
v0.3.0 2022-04-10 14:55:45 -07:00
Andrey Kislyuk 86bcd9f275
Update package to follow the latest draft 2022-04-10 14:52:41 -07:00
Jean-Hugues de Raigniac 8d615eac2a
minor PEP 8 fixes, expired signature check (#26) 2021-04-27 06:47:50 -07:00
Georg Krause 606fd8f891
Set correct Scheme for HTTPSignatureHeaderAuth.verify (#25) 2021-04-25 16:10:21 -07:00
Georg Krause d1129fdd9a
Allow missing created field (#23)
* Do not add created field if algorithm is rsa, hmac or ecdsa

* Disable check for W504

This is mutually exclusive with W503, but it seems to check for both. So
no matter where the linebreak is, the linter says its wrong. This fixes
this behavior and allows to use at least one option.

* Allow github actions to run on pull request

* Make created field optional on verification
2021-04-25 16:10:03 -07:00
Georg Krause 7f668ef084
Remove deprecated setuptools entrypoint of flake8 (#24) 2021-04-25 16:08:08 -07:00
Andrey Kislyuk fbd98984ce
Use twine for uploading releases 2020-08-29 13:15:52 -07:00
Andrey Kislyuk f19154d774
v0.2.0 2020-08-29 12:50:02 -07:00
Ashwin Ramaswami f2ca109baa
Fix typo (#21) 2020-08-29 12:46:13 -07:00
Andrey Kislyuk c0c8fd7255
Merge branch 'master' of github.com:kislyuk/requests-http-signature 2020-04-25 14:03:39 -07:00
Andrey Kislyuk 6b415c19f2
Fix handling of readme example
Fixes #5
2020-04-25 14:02:36 -07:00
Alberto Coletta a330eff250
Update keyword argument name in README (#3) 2020-04-25 07:45:04 -07:00
Arjen Brouwer ed24c9a04c
Implemented support for (created) and (expires) special headers (#14) 2020-04-25 07:20:06 -07:00
Andrey Kislyuk f1ced58164
Revert "Use one-shot signing. Fixes #16"
This reverts commit 053678a22e.
2020-04-25 07:18:14 -07:00
Andrey Kislyuk c29461aa4a
Update RFC links 2020-04-25 07:15:51 -07:00
Andrey Kislyuk 053678a22e
Use one-shot signing. Fixes #16 2020-04-25 06:58:40 -07:00
Andrey Kislyuk b6d74a46e0
Switch to github actions 2020-04-24 21:13:04 -07:00
Andrey Kislyuk f814088b6a
Adjust python tested versions 2020-04-24 21:07:14 -07:00
Andrey Kislyuk 2824b9b6e5
Add test for HTTPSignatureHeaderAuth 2020-04-24 12:56:20 -07:00
Andrey Kislyuk d7770e3ae9
Raise informative error when unable to compute body digest 2020-04-24 09:11:44 -07:00
Andrey Kislyuk e3a2e2d631
Fix release manager script version regexp 2019-05-01 08:20:50 -07:00
Andrey Kislyuk 92d203ce65
Fix tests 2018-11-06 02:46:30 -08:00
Andrey Kislyuk bb88d15a55
v0.1.0 2018-11-05 18:52:16 -08:00
Andrey Kislyuk 8d1c0d07f3
Merge pull request #2 from kevingill1966/master
Changes to support Irish Revenue Modernisation Project
2018-10-22 10:55:27 -07:00
Kevin Gill be44d4f19f Support https://tools.ietf.org/html/draft-cavage-http-signatures-08#section-4,
which uses a Signature header instead of Authorization header.
2018-10-19 21:36:45 +01:00
Kevin Gill 80c9bb8c14 Added RSA512 algorithm. 2018-10-19 16:38:40 +01:00
16 changed files with 662 additions and 464 deletions

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

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

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

@ -0,0 +1,33 @@
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@v3
- uses: actions/setup-python@v4
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
black:
runs-on: ubuntu-22.04
steps:
- uses: psf/black@stable
isort:
runs-on: ubuntu-22.04
steps:
- uses: isort/isort-action@v1.1.0

View File

@ -1,22 +0,0 @@
language: python
python:
- 2.7
- 3.4
- 3.5
- 3.6
- nightly
dist: trusty
sudo: false
before_install:
- pip install --quiet codecov wheel cryptography
install:
- make install
script:
- make test
after_success:
- bash <(curl -s https://codecov.io/bash)

View File

@ -1,3 +1,55 @@
Changes for v0.7.1 (2022-04-19)
===============================
- Add typing information
- Documentation improvements
Changes for v0.7.0 (2022-04-14)
===============================
- Use max-age, auto-cover Authorization header
- Documentation improvements
Changes for v0.6.0 (2022-04-12)
===============================
- Support verifying responses
- Documentation improvements
Changes for v0.5.0 (2022-04-10)
===============================
- Pass through verified body in VerifiedRequest
Changes for v0.4.0 (2022-04-10)
===============================
- Add require_components to verify; expand docs
Changes for v0.3.0 (2022-04-10)
===============================
- Update package to follow the latest draft
- Test and release infrastructure updates
Changes for v0.2.0 (2020-08-29)
===============================
- Implemented support for (created) and (expires) special headers (#14)
- Raise informative error when unable to compute body digest
Changes for v0.1.0 (2018-11-05)
===============================
- Support using a Signature header instead of Authorization header.
- Add RSA512 algorithm.
Changes for v0.0.3 (2017-09-19)
===============================

View File

@ -1,27 +1,23 @@
test_deps:
pip install coverage flake8 wheel
SHELL=/bin/bash
lint: test_deps
./setup.py flake8
lint:
flake8
mypy --check-untyped-defs requests_http_signature
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,9 +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-cavage-http-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 standard <https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/>`_.
Installation
------------
@ -18,65 +16,121 @@ 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'
url = 'http://example.com/path'
requests.get(url, auth=HTTPSignatureAuth(key=preshared_secret, key_id=preshared_key_id))
preshared_secret = b'monorail_cat'
url = 'https://example.com/'
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 ``header`` keyword argument.
auth = HTTPSignatureAuth(key=preshared_secret,
key_id=preshared_key_id,
signature_algorithm=algorithms.HMAC_SHA256)
requests.get(url, auth=auth)
In addition to signing messages in the client, the class method ``HTTPSignatureAuth.verify()`` can be used to verify
incoming requests:
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,
the ``Authorization`` header is signed if it is present, and 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 <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.
See the `API documentation <https://pyauth.github.io/requests-http-signature/#id3>`_ for the full list of options and
details.
Verifying responses
~~~~~~~~~~~~~~~~~~~
The class method ``HTTPSignatureAuth.verify()`` can be used to verify responses received back from the server:
.. code-block:: python
def key_resolver(key_id, algorithm):
return 'monorail_cat'
class MyKeyResolver:
def resolve_public_key(self, key_id):
assert key_id == 'squirrel'
return 'monorail_cat'
HTTPSignatureAuth.verify(request, key_resolver=key_resolver)
response = requests.get(url, auth=auth)
verify_result = HTTPSignatureAuth.verify(response,
signature_algorithm=algorithms.HMAC_SHA256,
key_resolver=MyKeyResolver())
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:
More generally, you can reconstruct an arbitrary request using the
`Requests API <https://docs.python-requests.org/en/latest/api/#requests.Request>`_ and pass it to ``verify()``:
.. code-block:: python
request = requests.Request(...) # Reconstruct the incoming request using the Requests API
prepared_request = request.prepare() # Generate a PreparedRequest
HTTPSignatureAuth.verify(prepared_request, ...)
To verify incoming requests and sign responses in the context of an HTTP server, see the
`flask-http-signature <https://github.com/pyauth/flask-http-signature>`_ and
`http-message-signatures <https://github.com/pyauth/http-message-signatures>`_ packages.
.. admonition:: See what is signed
It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP message
signatures. The gist of this rule is: if your application neglects to verify that the information it trusts is
what was actually signed, the attacker can supply a valid signature but point you to malicious data that wasn't signed
by that signature. Failure to follow this rule can lead to vulnerability against signature wrapping and substitution
attacks.
In requests-http-signature, you can ensure that the information signed is what you expect to be signed by only trusting
the data returned by the ``verify()`` method::
verify_result = HTTPSignatureAuth.verify(message, ...)
See the `API documentation <https://pyauth.github.io/requests-http-signature/#id3>`_ for full details.
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``.
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(signature_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(signature_algorithm=algorithms.RSA_V1_5_SHA256,
key_resolver=MyKeyResolver(),
key_id="my-key-id")
requests.get(url, auth=auth)
Digest algorithms
~~~~~~~~~~~~~~~~~
To generate a Content-Digest header using SHA-512 instead of the default SHA-256, subclass ``HTTPSignatureAuth`` as
follows::
class MySigner(HTTPSignatureAuth):
signing_content_digest_algorithm = "sha-512"
Links
-----
* `IETF HTTP Signatures draft <https://tools.ietf.org/html/draft-cavage-http-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/>`_
* `Project home page (GitHub) <https://github.com/pyauth/requests-http-signature>`_
* `Package documentation <https://pyauth.github.io/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>`_
* `http-message-signatures <https://github.com/pyauth/http-message-signatures>`_ - a dependency of this library that
handles much of the implementation
* `IETF HTTP Signatures draft <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures>`_
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://travis-ci.org/kislyuk/requests-http-signature.png
:target: https://travis-ci.org/kislyuk/requests-http-signature
.. 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,26 @@
SHELL=/bin/bash -eo pipefail
release_major:
$(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d)+\.(\d)+\.(\d+)+/; print "v@{[$$1+1]}.0.0"'))
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:
$(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d)+\.(\d)+\.(\d+)+/; print "v$$1.@{[$$2+1]}.0"'))
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:
$(eval export TAG=$(shell git describe --tags --match 'v*.*.*' | perl -ne '/^v(\d)+\.(\d)+\.(\d+)+/; print "v$$1.$$2.@{[$$3+1]}"'))
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'))
$(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)
@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 gh; then echo "Please install gh"; exit 1; fi
@if ! type -P twine; then echo "Please install twine"; exit 1; fi
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,14 +30,23 @@ 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} \
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 \
name==$$(basename dist/*.whl) label=="Python Wheel" < dist/*.whl
$(MAKE) pypi_release
gh release create ${TAG} dist/*.whl --notes="$$(git tag --list ${TAG} -n99 | perl -pe 's/^\S+\s*// if $$. == 1' | sed 's/^\s\s\s\s//')"
$(MAKE) release-pypi
$(MAKE) release-docs
pypi_release:
python setup.py sdist bdist_wheel upload --sign
release-pypi:
python -m build
twine upload dist/*.tar.gz dist/*.whl --sign --verbose
release-docs:
$(MAKE) docs
-git branch -D gh-pages
git checkout -B gh-pages-stage
touch docs/html/.nojekyll
git add --force docs/html
git commit -m "Docs for ${TAG}"
git push --force origin $$(git subtree split --prefix docs/html --branch gh-pages):refs/heads/gh-pages
git checkout -
.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,34 @@
# -*- 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 os
import sys
# 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('.'))
import guzzle_sphinx_theme
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

@ -5,7 +5,6 @@ API documentation
.. automodule:: requests_http_signature
:members:
:special-members:
.. toctree::
:maxdepth: 2

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[tool.black]
line-length = 120
exclude = ".*/version.py"
[tool.isort]
profile = "black"
line_length = 120

View File

@ -1,119 +1,289 @@
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 Sequence, Type, Union
import http_sfv
import requests
from requests.compat import urlparse
from http_message_signatures import ( # noqa: F401
HTTPMessageSigner,
HTTPMessageVerifier,
HTTPSignatureAlgorithm,
HTTPSignatureComponentResolver,
HTTPSignatureKeyResolver,
InvalidSignature,
algorithms,
)
from http_message_signatures.structures import CaseInsensitiveDict, VerifyResult
from requests.exceptions import RequestException
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
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()
signer = key.signer(padding=self.PKCS1v15(), algorithm=hasher)
elif self.algorithm == "ecdsa-sha256":
signer = key.signer(signature_algorithm=self.ec.ECDSA(algorithm=self.SHA256()))
signer.update(string_to_sign)
return signer.finalize()
class RequestsHttpSignatureException(RequestException):
"""An error occurred while constructing the HTTP Signature for your request."""
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_constructor = hashlib.sha256
known_algorithms = {
"rsa-sha1",
"rsa-sha256",
"hmac-sha256",
"ecdsa-sha256",
}
"""
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 Message Signatures draft RFC
<https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/>`_.
:param signature_algorithm:
One of ``requests_http_signature.algorithms.HMAC_SHA256``,
``requests_http_signature.algorithms.ECDSA_P256_SHA256``,
``requests_http_signature.algorithms.ED25519``,
``requests_http_signature.algorithms.RSA_PSS_SHA512``, or
``requests_http_signature.algorithms.RSA_V1_5_SHA256``.
:param key:
Key material that will be used to sign the request. In the case of HMAC, this should be the raw bytes of the
shared secret; for all other algorithms, this should be the bytes of the PEM-encoded private key material.
:param key_id: The key ID to use in the signature.
:param key_resolver:
Instead of specifying a fixed key, you can instead pass a key resolver, which should be an instance of a
subclass of ``http_message_signatures.HTTPSignatureKeyResolver``. A key resolver should have two methods,
``get_private_key(key_id)`` (required only for signing) and ``get_public_key(key_id)`` (required only for
verifying). Your implementation should ensure that the key id is recognized and return the corresponding
key material as PEM bytes (or shared secret bytes for HMAC).
:param covered_component_ids:
A list of lowercased header names or derived component IDs (``@method``, ``@target-uri``, ``@authority``,
``@scheme``, ``@request-target``, ``@path``, ``@query``, ``@query-params``, ``@status``, or
``@request-response``, as specified in the standard) to sign. By default, ``@method``, ``@authority``,
and ``@target-uri`` are covered, and the ``Authorization``, ``Content-Digest``, and ``Date`` header fields
are always covered if present.
:param label: The label to use to identify the signature.
:param include_alg:
By default, the signature parameters will include the ``alg`` parameter, using it to identify the signature
algorithm. If you wish not to include this parameter, set this to ``False``.
:param use_nonce:
Set this to ``True`` to include a unique message-specific nonce in the signature parameters. The format of
the nonce can be controlled by subclassing this class and overloading the ``get_nonce()`` method.
:param expires_in:
Use this to set the ``expires`` signature parameter to the time of signing plus the given timedelta.
"""
component_resolver_class: type = HTTPSignatureComponentResolver
"""
A subclass of ``http_message_signatures.HTTPSignatureComponentResolver`` can be used to override this value
to customize the retrieval of header and derived component values if needed.
"""
_content_digest_hashers = {"sha-256": hashlib.sha256, "sha-512": hashlib.sha512}
signing_content_digest_algorithm = "sha-256"
"The hash algorithm to use to generate the Content-Digest header field (either ``sha-256`` or ``sha-512``)."
_auto_cover_header_fields = {"authorization", "content-digest", "date"}
def __init__(
self,
*,
signature_algorithm: Type[HTTPSignatureAlgorithm],
key: bytes = None,
key_id: str,
key_resolver: HTTPSignatureKeyResolver = None,
covered_component_ids: Sequence[str] = ("@method", "@authority", "@target-uri"),
label: str = None,
include_alg: bool = True,
use_nonce: bool = False,
expires_in: datetime.timedelta = None,
):
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)
def __init__(self, key, key_id, algorithm="hmac-sha256", headers=None, passphrase=None):
assert algorithm in self.known_algorithms
self.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
self.signer = HTTPMessageSigner(
signature_algorithm=signature_algorithm,
key_resolver=key_resolver,
component_resolver_class=self.component_resolver_class,
)
def add_date(self, request, timestamp=None):
def add_date(self, request, timestamp):
if "Date" not in request.headers:
if timestamp is None:
timestamp = time.time()
request.headers["Date"] = email.utils.formatdate(timestamp, usegmt=True)
def add_digest(self, request):
if request.body is not None and "Digest" not in request.headers:
if "digest" not in self.headers:
self.headers.append("digest")
digest = self.hasher_constructor(request.body).digest()
request.headers["Digest"] = "SHA-256=" + base64.b64encode(digest).decode()
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:
if "Content-Digest" not in request.headers:
hasher = self._content_digest_hashers[self.signing_content_digest_algorithm]
digest = hasher(request.body).digest()
digest_node = http_sfv.Dictionary({self.signing_content_digest_algorithm: digest})
request.headers["Content-Digest"] = str(digest_node)
@classmethod
def get_string_to_sign(self, request, headers):
sts = []
for header in headers:
if header == "(request-target)":
path_url = requests.models.RequestEncodingMixin.path_url.fget(request)
sts.append("(request-target): {} {}".format(request.method.lower(), path_url))
else:
if header.lower() == "host":
value = request.headers.get("host", urlparse(request.url).hostname)
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 get_created(self, request):
created = datetime.datetime.now()
self.add_date(request, timestamp=int(created.timestamp()))
return created
def get_expires(self, request, created):
if self.expires_in:
return datetime.datetime.now() + self.expires_in
def get_covered_component_ids(self, request):
covered_component_ids = CaseInsensitiveDict((k, None) for k in self.covered_component_ids)
headers = CaseInsensitiveDict(request.headers)
for header in self._auto_cover_header_fields:
if header in headers:
covered_component_ids.setdefault(header, None)
return list(covered_component_ids)
def __call__(self, request):
self.add_date(request)
self.add_digest(request)
raw_sig = Crypto(self.algorithm).sign(string_to_sign=self.get_string_to_sign(request, self.headers),
key=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)]
request.headers["Authorization"] = "Signature " + ",".join('{}="{}"'.format(k, v) for k, v in sig_struct)
created = self.get_created(request)
expires = self.get_expires(request, created=created)
covered_component_ids = self.get_covered_component_ids(request)
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=covered_component_ids,
)
return request
@classmethod
def get_sig_struct(self, request):
scheme, sig_struct = request.headers["Authorization"].split(" ", 1)
return {i.split("=", 1)[0]: i.split("=", 1)[1].strip('"') for i in sig_struct.split(",")}
def get_body(cls, message):
if isinstance(message, requests.Response):
return message.content
return message.body
@classmethod
def verify(self, request, key_resolver):
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
sig_struct = self.get_sig_struct(request)
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"
headers = sig_struct.get("headers", "date").split(" ")
sig = base64.b64decode(sig_struct["signature"])
sts = self.get_string_to_sign(request, headers)
key = key_resolver(key_id=sig_struct["keyId"], algorithm=sig_struct["algorithm"])
Crypto(sig_struct["algorithm"]).verify(sig, sts, key)
def verify(
cls,
message: Union[requests.PreparedRequest, requests.Response],
*,
require_components: Sequence[str] = ("@method", "@authority", "@target-uri"),
signature_algorithm: Type[HTTPSignatureAlgorithm],
key_resolver: HTTPSignatureKeyResolver,
max_age: datetime.timedelta = datetime.timedelta(days=1),
) -> VerifyResult:
"""
Verify an HTTP message signature.
.. admonition:: See what is signed
It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP
message signatures. The gist of this rule is: if your application neglects to verify that the information it
trusts is what was actually signed, the attacker can supply a valid signature but point you to malicious data
that wasn't signed by that signature. Failure to follow this rule can lead to vulnerability against signature
wrapping and substitution attacks.
You can ensure that the information signed is what you expect to be signed by only trusting the *VerifyResult*
tuple returned by ``verify()``.
:param message:
The HTTP response or request to verify. You can either pass a received response, or reconstruct an arbitrary
request using the `Requests API <https://docs.python-requests.org/en/latest/api/#requests.Request>`_::
request = requests.Request(...)
prepared_request = request.prepare()
HTTPSignatureAuth.verify(prepared_request, ...)
:param require_components:
A list of lowercased header names or derived component IDs (``@method``, ``@target-uri``, ``@authority``,
``@scheme``, ``@request-target``, ``@path``, ``@query``, ``@query-params``, ``@status``, or
``@request-response``, as specified in the standard) to require to be covered by the signature. If the
``content-digest`` header field is specified here (recommended for messages that have a body), it will be
verified by matching it against the digest hash computed on the body of the message (expected to be bytes).
If this parameter is not specified, ``verify()`` will set it to ``("@method", "@authority", "@target-uri")``
for messages without a body, and ``("@method", "@authority", "@target-uri", "content-digest")`` for messages
with a body.
:param signature_algorithm:
The algorithm expected to be used by the signature. Any signature not using the expected algorithm will
cause an ``InvalidSignature`` exception. Must be one of ``requests_http_signature.algorithms.HMAC_SHA256``,
``requests_http_signature.algorithms.ECDSA_P256_SHA256``,
``requests_http_signature.algorithms.ED25519``,
``requests_http_signature.algorithms.RSA_PSS_SHA512``, or
``requests_http_signature.algorithms.RSA_V1_5_SHA256``.
:param key_resolver:
A key resolver, which should be an instance of a subclass of
``http_message_signatures.HTTPSignatureKeyResolver``. A key resolver should have two methods,
``get_private_key(key_id)`` (required only for signing) and ``get_public_key(key_id)`` (required only for
verifying). Your implementation should ensure that the key id is recognized and return the corresponding
key material as PEM bytes (or shared secret bytes for HMAC).
:param max_age:
The maximum age of the signature, defined as the difference between the ``created`` parameter value and now.
:returns: *VerifyResult*, a namedtuple with the following attributes:
* ``label`` (str): The label for the signature
* ``algorithm``: (same as ``signature_algorithm`` above)
* ``covered_components``: A mapping of component names to their values, as covered by the signature
* ``parameters``: A mapping of signature parameters to their values, as covered by the signature, including
"alg", "created", "expires", "keyid", and "nonce". To protect against replay attacks, retrieve the "nonce"
parameter here and check that it has not been seen before.
* ``body``: The message body for messages that have a body and pass validation of the covered
content-digest; ``None`` otherwise.
:raises: ``InvalidSignature`` - raised whenever signature validation fails for any reason.
"""
body = cls.get_body(message)
if body is not None:
if "content-digest" not in require_components and '"content-digest"' not in require_components:
require_components = list(require_components) + ["content-digest"]
verifier = HTTPMessageVerifier(
signature_algorithm=signature_algorithm,
key_resolver=key_resolver,
component_resolver_class=cls.component_resolver_class,
)
verify_results = verifier.verify(message, max_age=max_age)
if len(verify_results) != 1:
raise InvalidSignature("Multiple signatures are not supported.")
verify_result = verify_results[0]
for component_name in require_components:
component_key = component_name
if not component_key.startswith('"'):
component_key = str(http_sfv.List([http_sfv.Item(component_name)]))
if component_key not in verify_result.covered_components:
raise InvalidSignature(f"A required component, {component_key}, was not covered by the signature.")
if component_key == '"content-digest"':
if body is None:
raise InvalidSignature("Found a content-digest header in a message with no body")
digest = http_sfv.Dictionary()
digest.parse(verify_result.covered_components[component_key].encode())
if len(digest) < 1:
raise InvalidSignature("Found a content-digest header with no digests")
for k, v in digest.items():
if k not in cls._content_digest_hashers:
raise InvalidSignature(f'Unsupported content digest algorithm "{k}"')
raw_digest = v.value
hasher = cls._content_digest_hashers[k]
expect_digest = hasher(body).digest()
if raw_digest != expect_digest:
raise InvalidSignature("The content-digest header does not match the message body")
verify_result = verify_result._replace(body=body)
return verify_result

View File

View File

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

View File

@ -1,38 +1,47 @@
#!/usr/bin/env python
import os, glob
from setuptools import setup, find_packages
from setuptools import find_packages, setup
setup(
name='requests-http-signature',
version='0.0.3',
url='https://github.com/kislyuk/requests-http-signature',
license='Apache Software License',
author='Andrey Kislyuk',
author_email='kislyuk@gmail.com',
description="A Requests auth module for HTTP Signature",
long_description=open('README.rst').read(),
packages=find_packages(exclude=['test']),
include_package_data=True,
platforms=['MacOS X', 'Posix'],
test_suite='test',
classifiers=[
'Intended Audience :: Developers',
'License :: OSI Approved :: Apache Software License',
'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',
'Topic :: Software Development :: Libraries :: Python Modules'
],
install_requires=[
"requests"
],
name="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 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.4.3", "http-sfv >= 0.9.3", "requests >= 2.25.1"],
extras_require={
"rsa": ["cryptography >= 1.8.2"],
"ecdsa": ["cryptography >= 1.8.2"]
}
"tests": [
"flake8",
"coverage",
"build",
"wheel",
"mypy",
"types-requests",
]
},
packages=find_packages(exclude=["test"]),
include_package_data=True,
package_data={
"requests_http_signature": ["py.typed"],
},
platforms=["MacOS X", "Posix"],
test_suite="test",
classifiers=[
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: POSIX",
"Programming Language :: Python",
"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",
],
)

View File

@ -1,107 +1,146 @@
#!/usr/bin/env python
from __future__ import absolute_import, division, print_function, unicode_literals
import os, sys, unittest, json, logging, base64
import base64
import io
import json
import logging
import os
import sys
import unittest
import http_sfv
import requests
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
from http_message_signatures import HTTPMessageSigner # noqa: E402
from http_message_signatures import InvalidSignature # noqa: E402
from requests_http_signature import HTTPSignatureAuth, algorithms # 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):
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)
if "expectSig" in request.headers:
self.testcase.assertEqual(request.headers["expectSig"],
HTTPSignatureAuth.get_sig_struct(request)["signature"])
verify_args = dict(
signature_algorithm=self.client_auth.signer.signature_algorithm,
key_resolver=self.client_auth.signer.key_resolver,
)
HTTPSignatureAuth.verify(request, **verify_args)
if request.body is not None:
request.body = request.body[::-1]
try:
HTTPSignatureAuth.verify(request, **verify_args)
raise Exception("Expected InvalidSignature to be raised")
except InvalidSignature:
pass
response = requests.Response()
response.request = request
response.status_code = requests.codes.ok
response.url = request.url
response.headers["Received-Signature-Input"] = request.headers["Signature-Input"]
response.headers["Received-Signature"] = request.headers["Signature"]
response.raw = io.BytesIO(json.dumps({}).encode())
signer = HTTPMessageSigner(
signature_algorithm=self.client_auth.signer.signature_algorithm,
key_resolver=self.client_auth.signer.key_resolver,
)
hasher = HTTPSignatureAuth._content_digest_hashers["sha-256"]
digest = hasher(response.raw.getvalue()).digest()
response.headers["Content-Digest"] = str(http_sfv.Dictionary({"sha-256": digest}))
signer.sign(
response,
key_id=default_keyid,
covered_component_ids=("@method", "@authority", "content-digest", "@target-uri"),
)
return response
class DigestlessSignatureAuth(HTTPSignatureAuth):
def add_digest(self, request):
pass
class TestRequestsHTTPSignature(unittest.TestCase):
def setUp(self):
logging.basicConfig(level="DEBUG")
self.session = requests.Session()
self.session.mount("http://", TestAdapter(self))
self.auth = HTTPSignatureAuth(key_id=default_keyid, key=hmac_secret, signature_algorithm=algorithms.HMAC_SHA256)
self.session.mount("http://", TestAdapter(self.auth))
self.session.mount("https://", 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"))
url = "http://example.com/path?query#fragment"
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)
res = self.session.post(url, auth=self.auth, data=b"xyz")
verify_args = dict(signature_algorithm=algorithms.HMAC_SHA256, key_resolver=self.auth.signer.key_resolver)
HTTPSignatureAuth.verify(res, **verify_args)
res.headers["Content-Digest"] = res.headers["Content-Digest"][::-1]
with self.assertRaises(InvalidSignature):
HTTPSignatureAuth.verify(res, **verify_args)
del res.headers["Content-Digest"]
with self.assertRaises(InvalidSignature):
HTTPSignatureAuth.verify(res, **verify_args)
res.headers["Signature"] = res.headers["Signature"][::-1]
with self.assertRaises(InvalidSignature):
HTTPSignatureAuth.verify(res, **verify_args)
del res.headers["Signature"]
with self.assertRaises(InvalidSignature):
HTTPSignatureAuth.verify(res, **verify_args)
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)
def test_auto_cover_authorization_header(self):
url = "http://example.com/path?query#fragment"
res = self.session.get(url, auth=self.auth, headers={"Authorization": "Bearer 12345"})
self.assertIn('"authorization"', res.headers["Received-Signature-Input"])
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)
def test_b21(self):
url = "https://example.com/foo?param=Value&Pet=dog"
self.session.post(
url,
json={"hello": "world"},
headers={
"Date": "Tue, 20 Apr 2021 02:07:55 GMT",
"Content-Digest": (
"sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+"
"AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:"
),
},
auth=self.auth,
)
@unittest.skip("TODO")
def test_rsa(self):
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(passphrase)
encryption_algorithm=serialization.BestAvailableEncryption(passphrase),
)
public_key_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
)
url = 'http://example.com/path?query#fragment'
url = "http://example.com/path?query#fragment"
auth = HTTPSignatureAuth(algorithm="rsa-sha256", key=private_key_pem, key_id="sekret", passphrase=passphrase)
self.session.get(url, auth=auth, headers=dict(pubkey=base64.b64encode(public_key_pem)))
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()