Compare commits

...

51 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
12 changed files with 458 additions and 139 deletions

View File

@ -13,9 +13,8 @@ jobs:
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
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install build dependencies
@ -24,3 +23,11 @@ jobs:
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,3 +1,34 @@
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)
===============================

View File

@ -2,6 +2,7 @@ SHELL=/bin/bash
lint:
flake8
mypy --check-untyped-defs requests_http_signature
test: lint
python ./test/test.py -v

View File

@ -2,7 +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 Message Signatures draft RFC <https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/>`_.
the `IETF HTTP Message Signatures draft standard <https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/>`_.
Installation
------------
@ -20,37 +20,71 @@ Usage
preshared_key_id = 'squirrel'
preshared_secret = b'monorail_cat'
url = 'http://example.com/path'
url = 'https://example.com/'
auth = HTTPSignatureAuth(key=preshared_secret, key_id=preshared_key_id, signature_algorithm=algorithms.HMAC_SHA256)
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.
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.
In addition to signing messages in the client, the class method ``HTTPSignatureAuth.verify()`` can be used to verify
incoming requests:
Verifying responses
~~~~~~~~~~~~~~~~~~~
The class method ``HTTPSignatureAuth.verify()`` can be used to verify responses received back from the server:
.. code-block:: python
class key_resolver:
class MyKeyResolver:
def resolve_public_key(self, key_id):
assert key_id == 'squirrel'
return 'monorail_cat'
HTTPSignatureAuth.verify(request, signature_algorithm=algorithms.HMAC_SHA256, key_resolver=key_resolver)
response = requests.get(url, auth=auth)
verify_result = HTTPSignatureAuth.verify(response,
signature_algorithm=algorithms.HMAC_SHA256,
key_resolver=MyKeyResolver())
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``. Note that signing with rsa-pss-sha512 is not currently supported due to a limitation of
the cryptography library.
``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:
@ -58,7 +92,9 @@ constructor as bytes in the PEM format, or configure the key resolver as follows
.. code-block:: python
with open('key.pem', 'rb') as fh:
auth = HTTPSignatureAuth(algorithm=algorithms.RSA_V1_5_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)
class MyKeyResolver:
@ -68,17 +104,28 @@ constructor as bytes in the PEM format, or configure the key resolver as follows
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())
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://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 documentation <https://pyauth.github.io/requests-http-signature/>`_
* `Package distribution (PyPI) <https://pypi.python.org/pypi/requests-http-signature>`_
* `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
~~~~

View File

@ -17,13 +17,8 @@ release:
@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 gh; then echo "Please install gh"; 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)
TAG_MSG=$$(mktemp); \
@ -35,15 +30,23 @@ release:
git commit -m ${TAG}; \
git tag --sign --annotate --file $$TAG_MSG ${TAG}
git push --follow-tags
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 --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
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
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,5 +1,10 @@
import os
import sys
import guzzle_sphinx_theme
sys.path.insert(0, os.path.abspath(".."))
project = "requests-http-signature"
copyright = "Andrey Kislyuk"
author = "Andrey Kislyuk"
@ -24,6 +29,6 @@ html_sidebars = {
"logo-text.html",
# "globaltoc.html",
"localtoc.html",
"searchbox.html"
"searchbox.html",
]
}

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

@ -2,15 +2,21 @@ import datetime
import email.utils
import hashlib
import secrets
from typing import List
from typing import Sequence, Type, Union
import http_sfv
import requests
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
from http_message_signatures import (algorithms, HTTPSignatureComponentResolver, HTTPSignatureKeyResolver, # noqa: F401
HTTPMessageSigner, HTTPMessageVerifier, HTTPSignatureAlgorithm, InvalidSignature)
from http_message_signatures.structures import CaseInsensitiveDict
class RequestsHttpSignatureException(RequestException):
@ -32,20 +38,70 @@ class SingleKeyResolver(HTTPSignatureKeyResolver):
class HTTPSignatureAuth(requests.auth.AuthBase):
hasher_name = "sha-256"
hasher_constructor = hashlib.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/>`_.
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):
: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:
@ -59,10 +115,11 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
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)
self.signer = HTTPMessageSigner(
signature_algorithm=signature_algorithm,
key_resolver=key_resolver,
component_resolver_class=self.component_resolver_class,
)
def add_date(self, request, timestamp):
if "Date" not in request.headers:
@ -71,12 +128,12 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
def add_digest(self, request):
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 "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()
digest_node = http_sfv.Dictionary({self.hasher_name: digest})
request.headers["Content-Digest"] = str(digest_node)
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)
def get_nonce(self, request):
if self.use_nonce:
@ -85,46 +142,148 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
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 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_digest(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)
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 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")
def get_body(cls, message):
if isinstance(message, requests.Response):
return message.content
return message.body
@classmethod
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,25 +1,20 @@
#!/usr/bin/env python
from setuptools import setup, find_packages
from setuptools import find_packages, setup
setup(
name='requests-http-signature',
version='0.2.0',
url='https://github.com/pyauth/requests-http-signature',
license='Apache Software License',
author='Andrey Kislyuk',
author_email='kislyuk@gmail.com',
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(),
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"
],
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={
"tests": [
"flake8",
@ -27,22 +22,26 @@ setup(
"build",
"wheel",
"mypy",
"types-requests",
]
},
packages=find_packages(exclude=['test']),
packages=find_packages(exclude=["test"]),
include_package_data=True,
platforms=['MacOS X', 'Posix'],
test_suite='test',
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'
"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,15 +1,24 @@
#!/usr/bin/env python
import os, sys, unittest, 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__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from requests_http_signature import algorithms, HTTPSignatureAuth # noqa: E402
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"
@ -23,12 +32,37 @@ class TestAdapter(HTTPAdapter):
self.client_auth = auth
def send(self, request, *args, **kwargs):
HTTPSignatureAuth.verify(request,
signature_algorithm=self.client_auth.signer.signature_algorithm,
key_resolver=self.client_auth.signer.key_resolver)
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
@ -42,43 +76,71 @@ class TestRequestsHTTPSignature(unittest.TestCase):
self.session = requests.Session()
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'
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)
self.session.post(url, auth=self.auth, data=b"xyz")
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_expired_signature(self):
"TODO"
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"])
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()