Compare commits
4 Commits
755d9325b8
...
ca2b461a87
Author | SHA1 | Date |
---|---|---|
Andrey Kislyuk | ca2b461a87 | |
Andrey Kislyuk | 7324cb2f03 | |
Andrey Kislyuk | 99dc5f76fb | |
Andrey Kislyuk | fc73c56301 |
|
@ -1,3 +1,10 @@
|
||||||
|
Changes for v0.6.0 (2022-04-12)
|
||||||
|
===============================
|
||||||
|
|
||||||
|
- Support verifying responses
|
||||||
|
|
||||||
|
- Documentation improvements
|
||||||
|
|
||||||
Changes for v0.5.0 (2022-04-10)
|
Changes for v0.5.0 (2022-04-10)
|
||||||
===============================
|
===============================
|
||||||
|
|
||||||
|
|
29
README.rst
29
README.rst
|
@ -33,13 +33,12 @@ requests with bodies (such as POST), the ``Content-Digest`` header is set to the
|
||||||
format described in the
|
format described in the
|
||||||
`IETF Digest Fields draft RFC <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers>`_ and signed.
|
`IETF Digest Fields draft RFC <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers>`_ and signed.
|
||||||
To add other headers to the signature, pass an array of header names in the ``covered_component_ids`` keyword argument.
|
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/#id1>`_ for the full list of options and
|
See the `API documentation <https://pyauth.github.io/requests-http-signature/#id3>`_ for the full list of options and
|
||||||
details.
|
details.
|
||||||
|
|
||||||
Verifying messages
|
Verifying responses
|
||||||
~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~
|
||||||
In addition to signing messages in the client, the class method ``HTTPSignatureAuth.verify()`` can be used to verify
|
The class method ``HTTPSignatureAuth.verify()`` can be used to verify responses received back from the server:
|
||||||
incoming requests:
|
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
@ -48,12 +47,24 @@ incoming requests:
|
||||||
assert key_id == 'squirrel'
|
assert key_id == 'squirrel'
|
||||||
return 'monorail_cat'
|
return 'monorail_cat'
|
||||||
|
|
||||||
request = requests.Request(...) # Reconstruct the incoming request using the Requests API
|
response = requests.get(url, auth=auth)
|
||||||
request = request.prepare()
|
HTTPSignatureAuth.verify(response,
|
||||||
HTTPSignatureAuth.verify(request,
|
|
||||||
signature_algorithm=algorithms.HMAC_SHA256,
|
signature_algorithm=algorithms.HMAC_SHA256,
|
||||||
key_resolver=key_resolver)
|
key_resolver=key_resolver)
|
||||||
|
|
||||||
|
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
|
.. 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
|
It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP message
|
||||||
|
@ -67,7 +78,7 @@ incoming requests:
|
||||||
|
|
||||||
verify_result = HTTPSignatureAuth.verify(request, ...)
|
verify_result = HTTPSignatureAuth.verify(request, ...)
|
||||||
|
|
||||||
See the `API documentation <https://pyauth.github.io/requests-http-signature/#id1>`_ for full details.
|
See the `API documentation <https://pyauth.github.io/requests-http-signature/#id3>`_ for full details.
|
||||||
|
|
||||||
Asymmetric key algorithms
|
Asymmetric key algorithms
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -2,7 +2,7 @@ import datetime
|
||||||
import email.utils
|
import email.utils
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from typing import List
|
from typing import List, Union
|
||||||
|
|
||||||
import http_sfv
|
import http_sfv
|
||||||
import requests
|
import requests
|
||||||
|
@ -148,7 +148,13 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify(cls, request: requests.PreparedRequest, *,
|
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: List[str] = ("@method", "@authority", "@target-uri"),
|
require_components: List[str] = ("@method", "@authority", "@target-uri"),
|
||||||
signature_algorithm: HTTPSignatureAlgorithm,
|
signature_algorithm: HTTPSignatureAlgorithm,
|
||||||
key_resolver: HTTPSignatureKeyResolver):
|
key_resolver: HTTPSignatureKeyResolver):
|
||||||
|
@ -166,23 +172,23 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
|
||||||
You can ensure that the information signed is what you expect to be signed by only trusting the *VerifyResult*
|
You can ensure that the information signed is what you expect to be signed by only trusting the *VerifyResult*
|
||||||
tuple returned by ``verify()``.
|
tuple returned by ``verify()``.
|
||||||
|
|
||||||
:param request:
|
:param message:
|
||||||
The HTTP request to verify. You can reconstruct an incoming request using the
|
The HTTP response or request to verify. You can either pass a received response, or reconstruct an arbitrary
|
||||||
`Requests API <https://docs.python-requests.org/en/latest/api/#requests.Request>`_ as follows::
|
request using the `Requests API <https://docs.python-requests.org/en/latest/api/#requests.Request>`_::
|
||||||
|
|
||||||
request = requests.Request(...)
|
request = requests.Request(...)
|
||||||
request = request.prepare()
|
prepared_request = request.prepare()
|
||||||
HTTPSignatureAuth.verify(request, ...)
|
HTTPSignatureAuth.verify(prepared_request, ...)
|
||||||
|
|
||||||
:param require_components:
|
:param require_components:
|
||||||
A list of lowercased header names or derived component IDs ("@method", "@target-uri", "@authority",
|
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
|
"@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
|
specified in the standard) to require to be covered by the signature. If the "content-digest" header field
|
||||||
is specified here (recommended for requests that have a body), it will be verified by matching it against
|
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 request (expected to be bytes).
|
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")
|
If this parameter is not specified, ``verify()`` will set it to ("@method", "@authority", "@target-uri")
|
||||||
for requests without a body, and ("@method", "@authority", "@target-uri", "content-digest") for requests
|
for messages without a body, and ("@method", "@authority", "@target-uri", "content-digest") for messages
|
||||||
with a body.
|
with a body.
|
||||||
:param signature_algorithm:
|
:param signature_algorithm:
|
||||||
The algorithm expected to be used by the signature. Any signature not using the expected algorithm will
|
The algorithm expected to be used by the signature. Any signature not using the expected algorithm will
|
||||||
|
@ -203,20 +209,23 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
|
||||||
* ``label`` (str): The label for the signature
|
* ``label`` (str): The label for the signature
|
||||||
* ``algorithm``: (same as ``signature_algorithm`` above)
|
* ``algorithm``: (same as ``signature_algorithm`` above)
|
||||||
* ``covered_components``: A mapping of component names to their values, as covered by the signature
|
* ``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
|
* ``parameters``: A mapping of signature parameters to their values, as covered by the signature, including
|
||||||
* ``body``: The message body for requests that have a body and pass validation of the covered
|
"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.
|
content-digest; ``None`` otherwise.
|
||||||
|
|
||||||
:raises: ``InvalidSignature`` - raised whenever signature validation fails for any reason.
|
:raises: ``InvalidSignature`` - raised whenever signature validation fails for any reason.
|
||||||
"""
|
"""
|
||||||
if request.body is not None:
|
body = cls.get_body(message)
|
||||||
|
if body is not None:
|
||||||
if "content-digest" not in require_components and '"content-digest"' not in require_components:
|
if "content-digest" not in require_components and '"content-digest"' not in require_components:
|
||||||
require_components = list(require_components) + ["content-digest"]
|
require_components = list(require_components) + ["content-digest"]
|
||||||
|
|
||||||
verifier = HTTPMessageVerifier(signature_algorithm=signature_algorithm,
|
verifier = HTTPMessageVerifier(signature_algorithm=signature_algorithm,
|
||||||
key_resolver=key_resolver,
|
key_resolver=key_resolver,
|
||||||
component_resolver_class=cls.component_resolver_class)
|
component_resolver_class=cls.component_resolver_class)
|
||||||
verify_results = verifier.verify(request)
|
verify_results = verifier.verify(message)
|
||||||
if len(verify_results) != 1:
|
if len(verify_results) != 1:
|
||||||
raise InvalidSignature("Multiple signatures are not supported.")
|
raise InvalidSignature("Multiple signatures are not supported.")
|
||||||
verify_result = verify_results[0]
|
verify_result = verify_results[0]
|
||||||
|
@ -227,8 +236,8 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
|
||||||
if component_key not in verify_result.covered_components:
|
if component_key not in verify_result.covered_components:
|
||||||
raise InvalidSignature(f"A required component, {component_key}, was not covered by the signature.")
|
raise InvalidSignature(f"A required component, {component_key}, was not covered by the signature.")
|
||||||
if component_key == '"content-digest"':
|
if component_key == '"content-digest"':
|
||||||
if request.body is None:
|
if body is None:
|
||||||
raise InvalidSignature("Found a content-digest header in a request with no body")
|
raise InvalidSignature("Found a content-digest header in a message with no body")
|
||||||
digest = http_sfv.Dictionary()
|
digest = http_sfv.Dictionary()
|
||||||
digest.parse(verify_result.covered_components[component_key].encode())
|
digest.parse(verify_result.covered_components[component_key].encode())
|
||||||
if len(digest) < 1:
|
if len(digest) < 1:
|
||||||
|
@ -238,8 +247,8 @@ class HTTPSignatureAuth(requests.auth.AuthBase):
|
||||||
raise InvalidSignature(f'Unsupported digest algorithm "{k}"')
|
raise InvalidSignature(f'Unsupported digest algorithm "{k}"')
|
||||||
raw_digest = v.value
|
raw_digest = v.value
|
||||||
hasher = cls._digest_hashers[k]
|
hasher = cls._digest_hashers[k]
|
||||||
expect_digest = hasher(request.body).digest()
|
expect_digest = hasher(body).digest()
|
||||||
if raw_digest != expect_digest:
|
if raw_digest != expect_digest:
|
||||||
raise InvalidSignature("The content-digest header does not match the request body")
|
raise InvalidSignature("The content-digest header does not match the message body")
|
||||||
verify_result = verify_result._replace(body=request.body)
|
verify_result = verify_result._replace(body=body)
|
||||||
return verify_result
|
return verify_result
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -16,7 +16,7 @@ setup(
|
||||||
},
|
},
|
||||||
setup_requires=['setuptools_scm >= 3.4.3'],
|
setup_requires=['setuptools_scm >= 3.4.3'],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"http-message-signatures >= 0.2.2",
|
"http-message-signatures >= 0.2.3",
|
||||||
"http-sfv >= 0.9.3",
|
"http-sfv >= 0.9.3",
|
||||||
"requests >= 2.27.1"
|
"requests >= 2.27.1"
|
||||||
],
|
],
|
||||||
|
|
31
test/test.py
31
test/test.py
|
@ -1,14 +1,15 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
import os, sys, unittest, logging, base64
|
import os, sys, unittest, logging, base64, io, json
|
||||||
|
|
||||||
|
import http_sfv
|
||||||
import requests
|
import requests
|
||||||
from requests.adapters import HTTPAdapter
|
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 requests_http_signature import algorithms, HTTPSignatureAuth # noqa: E402
|
||||||
from http_message_signatures import InvalidSignature # noqa: E402
|
from http_message_signatures import HTTPMessageSigner, InvalidSignature # noqa: E402
|
||||||
|
|
||||||
logging.basicConfig(level="DEBUG")
|
logging.basicConfig(level="DEBUG")
|
||||||
|
|
||||||
|
@ -34,10 +35,20 @@ class TestAdapter(HTTPAdapter):
|
||||||
except InvalidSignature:
|
except InvalidSignature:
|
||||||
pass
|
pass
|
||||||
response = requests.Response()
|
response = requests.Response()
|
||||||
|
response.request = request
|
||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
response.url = request.url
|
response.url = request.url
|
||||||
response.headers["Received-Signature-Input"] = request.headers["Signature-Input"]
|
response.headers["Received-Signature-Input"] = request.headers["Signature-Input"]
|
||||||
response.headers["Received-Signature"] = request.headers["Signature"]
|
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._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
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -61,7 +72,21 @@ class TestRequestsHTTPSignature(unittest.TestCase):
|
||||||
self.session.get(url, auth=self.auth)
|
self.session.get(url, auth=self.auth)
|
||||||
self.auth.signer.key_resolver.resolve_private_key = lambda k: b"abc"
|
self.auth.signer.key_resolver.resolve_private_key = lambda k: b"abc"
|
||||||
self.session.get(url, auth=self.auth)
|
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):
|
def test_expired_signature(self):
|
||||||
"TODO"
|
"TODO"
|
||||||
|
|
Loading…
Reference in New Issue