From bda81b3cb6306dec19a6e60113e21b2933d0950c Mon Sep 17 00:00:00 2001 From: Hoylen Sue Date: Wed, 3 Jun 2020 13:01:25 +1000 Subject: OAuth 1.0a signature methods: RSA-SHA256, RSA-SHA512 and HMAC-SHA512 (#723) * Adding support for RSA-SHA256. * Added support for HMAC-SHA512, RSA-SHA256 and RSA-SHA512 signature methods. * Made version dependencies consistent. * Updated OAuth1 signature tests. * Fixed parsing of netloc/host. Deprecated old functions. * Refactored and expanded tests to include signature validate. * Update docs for HMAC-SHA512, RSA-SHA256 and RSA-SHA512 signature methods. * Updated code comments in oauth1 signatures module. * Updated changelog. * Update docs/feature_matrix.rst Co-Authored-By: Omer Katz * Used parenthesis instead of backslash to break lines. * Fixed typo Co-authored-by: Omer Katz Co-authored-by: Omer Katz --- CHANGELOG.rst | 4 + docs/faq.rst | 2 +- docs/feature_matrix.rst | 101 ++- docs/installation.rst | 120 ++- docs/oauth2/endpoints/metadata.rst | 2 +- oauthlib/oauth1/__init__.py | 33 +- oauthlib/oauth1/rfc5849/__init__.py | 50 +- oauthlib/oauth1/rfc5849/endpoints/base.py | 61 +- oauthlib/oauth1/rfc5849/signature.py | 874 ++++++++++--------- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 8 +- setup.py | 6 +- tests/oauth1/rfc5849/test_signatures.py | 1145 ++++++++++++++++++------- 12 files changed, 1604 insertions(+), 802 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c42df83..478c2ee 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -47,6 +47,10 @@ OAuth1.0 Client * #669: Add case-insensitive headers to oauth1 `BaseEndpoint` +OAuth1.0 + + * #722: Added support for HMAC-SHA512, RSA-SHA256 and RSA-SHA512 signature methods. + 3.0.2 (2019-07-04) ------------------ * #650: Fixed space encoding in base string URI used in the signature base string. diff --git a/docs/faq.rst b/docs/faq.rst index d9cd5c6..4814dcd 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -12,7 +12,7 @@ What parts of OAuth 1 & 2 are supported? See :doc:`feature_matrix`. OAuth 1 with RSA-SHA1 signatures says "could not import cryptography". What should I do? ----------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- Install oauthlib with rsa flag or install cryptography manually via pip. diff --git a/docs/feature_matrix.rst b/docs/feature_matrix.rst index df8cb0e..56d0cf3 100644 --- a/docs/feature_matrix.rst +++ b/docs/feature_matrix.rst @@ -1,33 +1,56 @@ Supported features and platforms ================================ -OAuth 1 is fully supported per the RFC for both clients and providers. -Extensions and variations that are outside the spec are not supported. +Features +-------- -- HMAC-SHA1, RSA-SHA1 and plaintext signatures. -- Signature placement in header, url or body. +OAuth 1.0a +.......... + +OAuth 1.0a is fully supported for both clients and providers. + +All standard *signature methods* defined in `RFC 5849`_ *The OAuth 1.0 +Protocol* are supported: + +- HMAC-SHA1 +- RSA-SHA1 +- PLAINTEXT + +Non-standard *signature methods* that replaces SHA-1 with stronger +digest algorithms are also supported: + +- HMAC-SHA256 +- HMAC-SHA512 +- RSA-SHA256 +- RSA-SHA512 + +The OAuth 1.0a signature can be placed in the header, URL or body of +the request. + +OAuth 2.0 +......... OAuth 2.0 client and provider support for: -- `RFC6749#section-4.1`_: Authorization Code Grant -- `RFC6749#section-4.2`_: Implicit Grant -- `RFC6749#section-4.3`_: Resource Owner Password Credentials Grant -- `RFC6749#section-4.4`_: Client Credentials Grant -- `RFC6749#section-6`_: Refresh Tokens -- `RFC6750`_: Bearer Tokens -- `RFC7009`_: Token Revocation -- `RFC Draft MAC tokens`_ +- `RFC 6749 section-4.1`_: Authorization Code Grant +- `RFC 6749 section-4.2`_: Implicit Grant +- `RFC 6749 section-4.3`_: Resource Owner Password Credentials Grant +- `RFC 6749 section-4.4`_: Client Credentials Grant +- `RFC 6749 section-6`_: Refresh Tokens +- `RFC 6750`_: Bearer Tokens +- `RFC 7009`_: Token Revocation +- `RFC Draft`_ Message Authentication Code (MAC) Tokens - OAuth2.0 Provider: `OpenID Connect Core`_ -- OAuth2.0 Provider: `RFC7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) -- OAuth2.0 Provider: `RFC7662`_: Token Introspection -- OAuth2.0 Provider: `RFC8414`_: Authorization Server Metadata +- OAuth2.0 Provider: `RFC 7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) +- OAuth2.0 Provider: `RFC 7662`_: Token Introspection +- OAuth2.0 Provider: `RFC 8414`_: Authorization Server Metadata Features to be implemented (any help/PR are welcomed): - OAuth2.0 **Client**: `OpenID Connect Core`_ -- OAuth2.0 **Client**: `RFC7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) -- OAuth2.0 **Client**: `RFC7662`_: Token Introspection -- OAuth2.0 **Client**: `RFC8414`_: Authorization Server Metadata +- OAuth2.0 **Client**: `RFC 7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) +- OAuth2.0 **Client**: `RFC 7662`_: Token Introspection +- OAuth2.0 **Client**: `RFC 8414`_: Authorization Server Metadata - SAML2 - Bearer JWT as Client Authentication - Dynamic client registration @@ -35,24 +58,32 @@ Features to be implemented (any help/PR are welcomed): - OpenID Session Management - ...and more -Supported platforms -------------------- +Platforms +--------- + +OAuthLib is mainly developed and tested on 64-bit Linux. It works on +Unix and Unix-like operating systems (including macOS), as well as +Microsoft Windows. + +It should work on any platform that supports Python, if features +requiring RSA public-key cryptography is not used. -OAuthLib is mainly developed/tested on 64 bit Linux but works on Unix (incl. OS -X) and Windows as well. Unless you are using the RSA features of OAuth 1 you -should be able to use OAuthLib on any platform that supports Python. If you use -RSA you are limited to the platforms supported by `cryptography`_. +If features requiring RSA public-key cryptography is used (e.g +RSA-SHA1 and RS256), it should work on any platform supported by +PyCA's `cryptography`_ package. RSA features require installing +additional packages: see the installation instructions for details. .. _`cryptography`: https://cryptography.io/en/latest/installation/ -.. _`RFC6749#section-4.1`: https://tools.ietf.org/html/rfc6749#section-4.1 -.. _`RFC6749#section-4.2`: https://tools.ietf.org/html/rfc6749#section-4.2 -.. _`RFC6749#section-4.3`: https://tools.ietf.org/html/rfc6749#section-4.3 -.. _`RFC6749#section-4.4`: https://tools.ietf.org/html/rfc6749#section-4.4 -.. _`RFC6749#section-6`: https://tools.ietf.org/html/rfc6749#section-6 -.. _`RFC6750`: https://tools.ietf.org/html/rfc6750 -.. _`RFC Draft MAC tokens`: https://tools.ietf.org/id/draft-ietf-oauth-v2-http-mac-02.html -.. _`RFC7009`: https://tools.ietf.org/html/rfc7009 -.. _`RFC7662`: https://tools.ietf.org/html/rfc7662 -.. _`RFC7636`: https://tools.ietf.org/html/rfc7636 +.. _`RFC 5849`: https://tools.ietf.org/html/rfc5849 +.. _`RFC 6749 section-4.1`: https://tools.ietf.org/html/rfc6749#section-4.1 +.. _`RFC 6749 section-4.2`: https://tools.ietf.org/html/rfc6749#section-4.2 +.. _`RFC 6749 section-4.3`: https://tools.ietf.org/html/rfc6749#section-4.3 +.. _`RFC 6749 section-4.4`: https://tools.ietf.org/html/rfc6749#section-4.4 +.. _`RFC 6749 section-6`: https://tools.ietf.org/html/rfc6749#section-6 +.. _`RFC 6750`: https://tools.ietf.org/html/rfc6750 +.. _`RFC Draft`: https://tools.ietf.org/id/draft-ietf-oauth-v2-http-mac-02.html +.. _`RFC 7009`: https://tools.ietf.org/html/rfc7009 +.. _`RFC 7662`: https://tools.ietf.org/html/rfc7662 +.. _`RFC 7636`: https://tools.ietf.org/html/rfc7636 .. _`OpenID Connect Core`: https://openid.net/specs/openid-connect-core-1_0.html -.. _`RFC8414`: https://tools.ietf.org/html/rfc8414 +.. _`RFC 8414`: https://tools.ietf.org/html/rfc8414 diff --git a/docs/installation.rst b/docs/installation.rst index 72d7b08..0e00e39 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,71 +1,147 @@ Installing OAuthLib =================== -The recommended way to install OAuthLib is from PyPI but if you are running -into a bug or want to try out recently implemented features you will want to -try installing directly from the GitHub master branch. -For various reasons you may wish to install using your OS packaging system and -install instructions for a few are shown below. Please send a PR to add a -missing one. +Install from PyPI +----------------- + +The recommended way to install OAuthLib is from PyPI using the *pip* +program. Either just the *standard install* by itself or *with extras +for RSA*. -Latest release on PyPI ----------------------- +Standard install +^^^^^^^^^^^^^^^^ +A standard installation contains the core features of OAuthLib. It can +be installed by running: .. code-block:: bash pip install oauthlib -Bleeding edge from GitHub master --------------------------------- +To reduce its requirements, the Python packages needed for RSA +public-key cryptography are not included in the standard installation. + + +With extras for RSA +^^^^^^^^^^^^^^^^^^^ + +To support features that use RSA public-key cryptography, PyCA's +`cryptography`_ package and the `PyJWT`_ package must also be +installed. This can be done by installing the core features of +OAuthLib along with the "signedtoken" extras. .. code-block:: bash - pip install -e git+https://github.com/oauthlib/oauthlib.git#egg=oauthlib + pip install 'oauthlib[signedtoken]' + +Note: the quotes may be required, since shells can interpret the +square brackets as special characters. + +Alternatively, those two Python packages can be installed manually by +running ``pip install cryptography`` and ``pip install pyjwt``, either +before or after installing the standard installation of OAuthLib. +PyJWT depends on cryptography, so just installing *pyjwt* should +automatically also install *cryptography*. But *cryptography* has +dependencies that can cause its installation to fail, so it can be +better to get it installed before installing PyJWT. + +Install from operating system distribution +------------------------------------------ + +Alternatively, install it from the operating system distribution's +packaging system, if OAuthLib is available as a distribution package. +Install instructions for some distributions are shown below. + +The distribution packages usually only contain the standard install of +OAuthLib. To enable support for RSA, the *cryptography* and *pyjwt* +Python packages also need to be installed: either from the +distribution packages (if available) or from PyPI. Debian and derivatives like Ubuntu, Mint, etc. ---------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: bash - apt-get install python-oauthlib apt-get install python3-oauthlib -Redhat and Fedora ------------------ +The Python2 package is called "python-oauthlib". + +RHEL, CentOS and Fedora +^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: bash - yum install python-oauthlib yum install python3-oauthlib +The Python2 package is called "python2-oauthlib", and is available on +some distributions (e.g.Fedora 31 and CentOS 7) but not available on +others (e.g. CentOS 8). + +For CentOS, the Python3 package is only available on CentOS 8 and +higher. + openSUSE --------- +^^^^^^^^ .. code-block:: bash - zypper in python-oauthlib zypper in python3-oauthlib +The Python2 package is called "python-oauthlib". + Gentoo ------- +^^^^^^ .. code-block:: bash emerge oauthlib Arch ----- +^^^^ .. code-block:: bash pacman -S python-oauthlib - pacman -S python2-oauthlib + +The Python2 package is called "python2-oauthlib". FreeBSD -------- +^^^^^^^ .. code-block:: bash pkg_add -r security/py-oauthlib/ + + +Install from GitHub +------------------- + +Alternatively, install it directly from the source repository on +GitHub. This is the "bleading edge" version, but it may be useful for +accessing bug fixes and/or new features that have not been released. + +Standard install +^^^^^^^^^^^^^^^^ + +The standard installation contains the core features of OAuthLib. + +.. code-block:: bash + + pip install -e git+https://github.com/oauthlib/oauthlib.git#egg=oauthlib + +With extras for RSA +^^^^^^^^^^^^^^^^^^^ + +To support features that use RSA public-key cryptography, install the +core features of OAuthLib along with the "signedtoken" extras. + +.. code-block:: bash + + pip install -e 'git+https://github.com/oauthlib/oauthlib.git#egg=oauthlib[signedtoken]' + +Note: the quotes may be required, since shells can interpret the +square brackets as special characters. + +.. _`cryptography`: https://cryptography.io/ +.. _`PyJWT`: https://pyjwt.readthedocs.io/ diff --git a/docs/oauth2/endpoints/metadata.rst b/docs/oauth2/endpoints/metadata.rst index d44e8b7..a879765 100644 --- a/docs/oauth2/endpoints/metadata.rst +++ b/docs/oauth2/endpoints/metadata.rst @@ -2,7 +2,7 @@ Metadata endpoint =================== -OAuth2.0 Authorization Server Metadata (`RFC8414`_) endpoint provide the metadata of your authorization server. Since the metadata results can be a combination of OAuthlib's Endpoint (see :doc:`preconfigured_servers`), the MetadataEndpoint's class takes a list of Endpoints in parameter, and aggregate the metadata in the response. +OAuth2.0 Authorization Server Metadata (`RFC8414`_) endpoint provide the metadata of your authorization server. Since the metadata results can be a combination of OAuthlib's Endpoint (see :doc:`/oauth2/preconfigured_servers`), the MetadataEndpoint's class takes a list of Endpoints in parameter, and aggregate the metadata in the response. See below an example of usage with `bottle-oauthlib`_ when using a `LegacyApplicationServer` (password grant) endpoint: diff --git a/oauthlib/oauth1/__init__.py b/oauthlib/oauth1/__init__.py index 224fecf..07ef422 100644 --- a/oauthlib/oauth1/__init__.py +++ b/oauthlib/oauth1/__init__.py @@ -5,17 +5,24 @@ oauthlib.oauth1 This module is a wrapper for the most recent implementation of OAuth 1.0 Client and Server classes. """ -from .rfc5849 import ( - SIGNATURE_HMAC, SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, - SIGNATURE_PLAINTEXT, SIGNATURE_RSA, SIGNATURE_TYPE_AUTH_HEADER, - SIGNATURE_TYPE_BODY, SIGNATURE_TYPE_QUERY, Client, -) -from .rfc5849.endpoints import ( - AccessTokenEndpoint, AuthorizationEndpoint, RequestTokenEndpoint, - ResourceEndpoint, SignatureOnlyEndpoint, WebApplicationServer, -) -from .rfc5849.errors import ( - InsecureTransportError, InvalidClientError, InvalidRequestError, - InvalidSignatureMethodError, OAuth1Error, -) +from .rfc5849 import Client +from .rfc5849 import (SIGNATURE_HMAC, + SIGNATURE_HMAC_SHA1, + SIGNATURE_HMAC_SHA256, + SIGNATURE_HMAC_SHA512, + SIGNATURE_RSA, + SIGNATURE_RSA_SHA1, + SIGNATURE_RSA_SHA256, + SIGNATURE_RSA_SHA512, + SIGNATURE_PLAINTEXT) +from .rfc5849 import SIGNATURE_TYPE_AUTH_HEADER, SIGNATURE_TYPE_QUERY +from .rfc5849 import SIGNATURE_TYPE_BODY from .rfc5849.request_validator import RequestValidator +from .rfc5849.endpoints import RequestTokenEndpoint, AuthorizationEndpoint +from .rfc5849.endpoints import AccessTokenEndpoint, ResourceEndpoint +from .rfc5849.endpoints import SignatureOnlyEndpoint, WebApplicationServer +from .rfc5849.errors import (InsecureTransportError, + InvalidClientError, + InvalidRequestError, + InvalidSignatureMethodError, + OAuth1Error) diff --git a/oauthlib/oauth1/rfc5849/__init__.py b/oauthlib/oauth1/rfc5849/__init__.py index f7cd3f3..c559251 100644 --- a/oauthlib/oauth1/rfc5849/__init__.py +++ b/oauthlib/oauth1/rfc5849/__init__.py @@ -4,6 +4,19 @@ oauthlib.oauth1.rfc5849 This module is an implementation of various logic needed for signing and checking OAuth 1.0 RFC 5849 requests. + +It supports all three standard signature methods defined in RFC 5849: + +- HMAC-SHA1 +- RSA-SHA1 +- PLAINTEXT + +It also supports signature methods that are not defined in RFC 5849. These are +based on the standard ones but replace SHA-1 with the more secure SHA-256: + +- HMAC-SHA256 +- RSA-SHA256 + """ import base64 import hashlib @@ -18,14 +31,38 @@ from . import parameters, signature log = logging.getLogger(__name__) - +# Available signature methods +# +# Note: SIGNATURE_HMAC and SIGNATURE_RSA are kept for backward compatibility +# with previous versions of this library, when it the only HMAC-based and +# RSA-based signature methods were HMAC-SHA1 and RSA-SHA1. But now that it +# supports other hashing algorithms besides SHA1, explicitly identifying which +# hashing algorithm is being used is recommended. +# +# Note: if additional values are defined here, don't forget to update the +# imports in "../__init__.py" so they are available outside this module. SIGNATURE_HMAC_SHA1 = "HMAC-SHA1" SIGNATURE_HMAC_SHA256 = "HMAC-SHA256" -SIGNATURE_HMAC = SIGNATURE_HMAC_SHA1 -SIGNATURE_RSA = "RSA-SHA1" +SIGNATURE_HMAC_SHA512 = "HMAC-SHA512" +SIGNATURE_HMAC = SIGNATURE_HMAC_SHA1 # deprecated variable for HMAC-SHA1 + +SIGNATURE_RSA_SHA1 = "RSA-SHA1" +SIGNATURE_RSA_SHA256 = "RSA-SHA256" +SIGNATURE_RSA_SHA512 = "RSA-SHA512" +SIGNATURE_RSA = SIGNATURE_RSA_SHA1 # deprecated variable for RSA-SHA1 + SIGNATURE_PLAINTEXT = "PLAINTEXT" -SIGNATURE_METHODS = (SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, SIGNATURE_RSA, SIGNATURE_PLAINTEXT) + +SIGNATURE_METHODS = ( + SIGNATURE_HMAC_SHA1, + SIGNATURE_HMAC_SHA256, + SIGNATURE_HMAC_SHA512, + SIGNATURE_RSA_SHA1, + SIGNATURE_RSA_SHA256, + SIGNATURE_RSA_SHA512, + SIGNATURE_PLAINTEXT +) SIGNATURE_TYPE_AUTH_HEADER = 'AUTH_HEADER' SIGNATURE_TYPE_QUERY = 'QUERY' @@ -40,7 +77,10 @@ class Client: SIGNATURE_METHODS = { SIGNATURE_HMAC_SHA1: signature.sign_hmac_sha1_with_client, SIGNATURE_HMAC_SHA256: signature.sign_hmac_sha256_with_client, - SIGNATURE_RSA: signature.sign_rsa_sha1_with_client, + SIGNATURE_HMAC_SHA512: signature.sign_hmac_sha512_with_client, + SIGNATURE_RSA_SHA1: signature.sign_rsa_sha1_with_client, + SIGNATURE_RSA_SHA256: signature.sign_rsa_sha256_with_client, + SIGNATURE_RSA_SHA512: signature.sign_rsa_sha512_with_client, SIGNATURE_PLAINTEXT: signature.sign_plaintext_with_client } diff --git a/oauthlib/oauth1/rfc5849/endpoints/base.py b/oauthlib/oauth1/rfc5849/endpoints/base.py index 8103606..3a8c267 100644 --- a/oauthlib/oauth1/rfc5849/endpoints/base.py +++ b/oauthlib/oauth1/rfc5849/endpoints/base.py @@ -11,10 +11,12 @@ import time from oauthlib.common import CaseInsensitiveDict, Request, generate_token from .. import ( - CONTENT_TYPE_FORM_URLENCODED, SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, - SIGNATURE_RSA, SIGNATURE_TYPE_AUTH_HEADER, SIGNATURE_TYPE_BODY, - SIGNATURE_TYPE_QUERY, errors, signature, utils, -) + CONTENT_TYPE_FORM_URLENCODED, + SIGNATURE_HMAC_SHA1, SIGNATURE_HMAC_SHA256, SIGNATURE_HMAC_SHA512, + SIGNATURE_RSA_SHA1, SIGNATURE_RSA_SHA256, SIGNATURE_RSA_SHA512, + SIGNATURE_PLAINTEXT, + SIGNATURE_TYPE_AUTH_HEADER, SIGNATURE_TYPE_BODY, + SIGNATURE_TYPE_QUERY, errors, signature, utils) class BaseEndpoint: @@ -179,38 +181,65 @@ class BaseEndpoint: def _check_signature(self, request, is_token_request=False): # ---- RSA Signature verification ---- - if request.signature_method == SIGNATURE_RSA: + if request.signature_method == SIGNATURE_RSA_SHA1 or \ + request.signature_method == SIGNATURE_RSA_SHA256 or \ + request.signature_method == SIGNATURE_RSA_SHA512: + # RSA-based signature method + # The server verifies the signature per `[RFC3447] section 8.2.2`_ # .. _`[RFC3447] section 8.2.2`: https://tools.ietf.org/html/rfc3447#section-8.2.1 + rsa_key = self.request_validator.get_rsa_key( request.client_key, request) - valid_signature = signature.verify_rsa_sha1(request, rsa_key) + + if request.signature_method == SIGNATURE_RSA_SHA1: + valid_signature = signature.verify_rsa_sha1(request, rsa_key) + elif request.signature_method == SIGNATURE_RSA_SHA256: + valid_signature = signature.verify_rsa_sha256(request, rsa_key) + elif request.signature_method == SIGNATURE_RSA_SHA512: + valid_signature = signature.verify_rsa_sha512(request, rsa_key) + else: + valid_signature = False # ---- HMAC or Plaintext Signature verification ---- else: + # Non-RSA based signature method + # Servers receiving an authenticated request MUST validate it by: # Recalculating the request signature independently as described in # `Section 3.4`_ and comparing it to the value received from the # client via the "oauth_signature" parameter. # .. _`Section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + client_secret = self.request_validator.get_client_secret( request.client_key, request) + resource_owner_secret = None if request.resource_owner_key: if is_token_request: - resource_owner_secret = self.request_validator.get_request_token_secret( - request.client_key, request.resource_owner_key, request) + resource_owner_secret = \ + self.request_validator.get_request_token_secret( + request.client_key, request.resource_owner_key, + request) else: - resource_owner_secret = self.request_validator.get_access_token_secret( - request.client_key, request.resource_owner_key, request) + resource_owner_secret = \ + self.request_validator.get_access_token_secret( + request.client_key, request.resource_owner_key, + request) if request.signature_method == SIGNATURE_HMAC_SHA1: - valid_signature = signature.verify_hmac_sha1(request, - client_secret, resource_owner_secret) + valid_signature = signature.verify_hmac_sha1( + request, client_secret, resource_owner_secret) elif request.signature_method == SIGNATURE_HMAC_SHA256: - valid_signature = signature.verify_hmac_sha256(request, - client_secret, resource_owner_secret) + valid_signature = signature.verify_hmac_sha256( + request, client_secret, resource_owner_secret) + elif request.signature_method == SIGNATURE_HMAC_SHA512: + valid_signature = signature.verify_hmac_sha512( + request, client_secret, resource_owner_secret) + elif request.signature_method == SIGNATURE_PLAINTEXT: + valid_signature = signature.verify_plaintext( + request, client_secret, resource_owner_secret) else: - valid_signature = signature.verify_plaintext(request, - client_secret, resource_owner_secret) + valid_signature = False + return valid_signature diff --git a/oauthlib/oauth1/rfc5849/signature.py b/oauthlib/oauth1/rfc5849/signature.py index 0c22ef6..a370ccd 100644 --- a/oauthlib/oauth1/rfc5849/signature.py +++ b/oauthlib/oauth1/rfc5849/signature.py @@ -1,66 +1,70 @@ """ -oauthlib.oauth1.rfc5849.signature -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This module is an implementation of `section 3.4`_ of RFC 5849. -This module represents a direct implementation of `section 3.4`_ of the spec. - -Terminology: - * Client: software interfacing with an OAuth API - * Server: the API provider - * Resource Owner: the user who is granting authorization to the client +**Usage** Steps for signing a request: -1. Collect parameters from the uri query, auth header, & body -2. Normalize those parameters -3. Normalize the uri -4. Pass the normalized uri, normalized parameters, and http method to - construct the base string -5. Pass the base string and any keys needed to a signing function +1. Collect parameters from the request using ``collect_parameters``. +2. Normalize those parameters using ``normalize_parameters``. +3. Create the *base string URI* using ``base_string_uri``. +4. Create the *signature base string* from the above three components + using ``signature_base_string``. +5. Pass the *signature base string* and the client credentials to one of the + sign-with-client functions. The HMAC-based signing functions needs + client credentials with secrets. The RSA-based signing functions needs + client credentials with an RSA private key. + +To verify a request, pass the request and credentials to one of the verify +functions. The HMAC-based signing functions needs the shared secrets. The +RSA-based verify functions needs the RSA public key. + +**Scope** + +All of the functions in this module should be considered internal to OAuthLib, +since they are not imported into the "oauthlib.oauth1" module. Programs using +OAuthLib should not use directly invoke any of the functions in this module. + +**Deprecated functions** + +The "sign_" methods that are not "_with_client" have been deprecated. They may +be removed in a future release. Since they are all internal functions, this +should have no impact on properly behaving programs. .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 """ + import binascii import hashlib import hmac import logging -import urllib.parse as urlparse +import warnings from oauthlib.common import extract_params, safe_string_equals, urldecode +import urllib.parse as urlparse from . import utils -log = logging.getLogger(__name__) - -def signature_base_string(http_method, base_str_uri, - normalized_encoded_request_parameters): - """**Construct the signature base string.** - Per `section 3.4.1.1`_ of the spec. +log = logging.getLogger(__name__) - For example, the HTTP request:: - POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 - Host: example.com - Content-Type: application/x-www-form-urlencoded - Authorization: OAuth realm="Example", - oauth_consumer_key="9djdj82h48djs9d2", - oauth_token="kkk9d7dh3k39sjv7", - oauth_signature_method="HMAC-SHA1", - oauth_timestamp="137131201", - oauth_nonce="7d8f3e4a", - oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" +# ==== Common functions ========================================== - c2&a3=2+q +def signature_base_string( + http_method: str, + base_str_uri: str, + normalized_encoded_request_parameters: str) -> str: + """ + Construct the signature base string. - is represented by the following signature base string (line breaks - are for display purposes only):: + The *signature base string* is the value that is calculated and signed by + the client. It is also independently calculated by the server to verify + the signature, and therefore must produce the exact same value at both + ends or the signature won't verify. - POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q - %26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_ - key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m - ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk - 9d7dh3k39sjv7 + The rules for calculating the *signature base string* are defined in + section 3.4.1.1`_ of RFC 5849. .. _`section 3.4.1.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.1 """ @@ -91,37 +95,40 @@ def signature_base_string(http_method, base_str_uri, # 5. The request parameters as normalized in `Section 3.4.1.3.2`_, after # being encoded (`Section 3.6`). # - # .. _`Section 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + # .. _`Sec 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 base_string += utils.escape(normalized_encoded_request_parameters) return base_string -def base_string_uri(uri, host=None): - """**Base String URI** - Per `section 3.4.1.2`_ of RFC 5849. - - For example, the HTTP request:: - - GET /r%20v/X?id=123 HTTP/1.1 - Host: EXAMPLE.COM:80 - - is represented by the base string URI: "http://example.com/r%20v/X". +def base_string_uri(uri: str, host: str = None) -> str: + """ + Calculates the _base string URI_. - In another example, the HTTPS request:: + The *base string URI* is one of the components that make up the + *signature base string*. - GET /?q=1 HTTP/1.1 - Host: www.example.net:8080 + The ``host`` is optional. If provided, it is used to override any host and + port values in the ``uri``. The value for ``host`` is usually extracted from + the "Host" request header from the HTTP request. Its value may be just the + hostname, or the hostname followed by a colon and a TCP/IP port number + (hostname:port). If a value for the``host`` is provided but it does not + contain a port number, the default port number is used (i.e. if the ``uri`` + contained a port number, it will be discarded). - is represented by the base string URI: "https://www.example.net:8080/". + The rules for calculating the *base string URI* are defined in + section 3.4.1.2`_ of RFC 5849. .. _`section 3.4.1.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.2 - The host argument overrides the netloc part of the uri argument. + :param uri: URI + :param host: hostname with optional port number, separated by a colon + :return: base string URI """ + if not isinstance(uri, str): - raise ValueError('uri must be a unicode object.') + raise ValueError('uri must be a string.') # FIXME: urlparse does not support unicode scheme, netloc, path, params, query, fragment = urlparse.urlparse(uri) @@ -132,26 +139,27 @@ def base_string_uri(uri, host=None): # # .. _`RFC3986`: https://tools.ietf.org/html/rfc3986 - if not scheme or not netloc: - raise ValueError('uri must include a scheme and netloc') + if not scheme: + raise ValueError('missing scheme') # Per `RFC 2616 section 5.1.2`_: # # Note that the absolute path cannot be empty; if none is present in # the original URI, it MUST be given as "/" (the server root). # - # .. _`RFC 2616 section 5.1.2`: https://tools.ietf.org/html/rfc2616#section-5.1.2 + # .. _`RFC 2616 5.1.2`: https://tools.ietf.org/html/rfc2616#section-5.1.2 if not path: path = '/' # 1. The scheme and host MUST be in lowercase. scheme = scheme.lower() netloc = netloc.lower() + # Note: if ``host`` is used, it will be converted to lowercase below # 2. The host and port values MUST match the content of the HTTP # request "Host" header field. if host is not None: - netloc = host.lower() + netloc = host.lower() # override value in uri with provided host # 3. The port MUST be included if it is not the default port for the # scheme, and MUST be excluded if it is the default. Specifically, @@ -161,14 +169,34 @@ def base_string_uri(uri, host=None): # # .. _`RFC2616`: https://tools.ietf.org/html/rfc2616 # .. _`RFC2818`: https://tools.ietf.org/html/rfc2818 - default_ports = ( - ('http', '80'), - ('https', '443'), - ) + if ':' in netloc: - host, port = netloc.split(':', 1) - if (scheme, port) in default_ports: - netloc = host + # Contains a colon ":", so try to parse as "host:port" + + hostname, port_str = netloc.split(':', 1) + + if len(hostname) == 0: + raise ValueError('missing host') # error: netloc was ":port" or ":" + + if len(port_str) == 0: + netloc = hostname # was "host:", so just use the host part + else: + try: + port_num = int(port_str) # try to parse into an integer number + except ValueError: + raise ValueError('port is not an integer') + + if port_num <= 0 or 65535 < port_num: + raise ValueError('port out of range') # 16-bit unsigned ints + if (scheme, port_num) in (('http', 80), ('https', 443)): + netloc = hostname # default port for scheme: exclude port num + else: + netloc = hostname + ':' + str(port_num) # use hostname:port + else: + # Does not contain a colon, so entire value must be the hostname + + if len(netloc) == 0: + raise ValueError('missing host') # error: netloc was empty string v = urlparse.urlunparse((scheme, netloc, path, params, '', '')) @@ -197,77 +225,29 @@ def base_string_uri(uri, host=None): return v.replace(' ', '%20') -# ** Request Parameters ** -# -# Per `section 3.4.1.3`_ of the spec. -# -# In order to guarantee a consistent and reproducible representation of -# the request parameters, the parameters are collected and decoded to -# their original decoded form. They are then sorted and encoded in a -# particular manner that is often different from their original -# encoding scheme, and concatenated into a single string. -# -# .. _`section 3.4.1.3`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3 - -def collect_parameters(uri_query='', body=[], headers=None, +def collect_parameters(uri_query='', body=None, headers=None, exclude_oauth_signature=True, with_realm=False): - """**Parameter Sources** + """ + Gather the request parameters from all the parameter sources. + + This function is used to extract all the parameters, which are then passed + to ``normalize_parameters`` to produce one of the components that make up + the *signature base string*. Parameters starting with `oauth_` will be unescaped. Body parameters must be supplied as a dict, a list of 2-tuples, or a - formencoded query string. + form encoded query string. Headers must be supplied as a dict. - Per `section 3.4.1.3.1`_ of the spec. - - For example, the HTTP request:: - - POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 - Host: example.com - Content-Type: application/x-www-form-urlencoded - Authorization: OAuth realm="Example", - oauth_consumer_key="9djdj82h48djs9d2", - oauth_token="kkk9d7dh3k39sjv7", - oauth_signature_method="HMAC-SHA1", - oauth_timestamp="137131201", - oauth_nonce="7d8f3e4a", - oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D" - - c2&a3=2+q - - contains the following (fully decoded) parameters used in the - signature base sting:: - - +------------------------+------------------+ - | Name | Value | - +------------------------+------------------+ - | b5 | =%3D | - | a3 | a | - | c@ | | - | a2 | r b | - | oauth_consumer_key | 9djdj82h48djs9d2 | - | oauth_token | kkk9d7dh3k39sjv7 | - | oauth_signature_method | HMAC-SHA1 | - | oauth_timestamp | 137131201 | - | oauth_nonce | 7d8f3e4a | - | c2 | | - | a3 | 2 q | - +------------------------+------------------+ - - Note that the value of "b5" is "=%3D" and not "==". Both "c@" and - "c2" have empty values. While the encoding rules specified in this - specification for the purpose of constructing the signature base - string exclude the use of a "+" character (ASCII code 43) to - represent an encoded space character (ASCII code 32), this practice - is widely used in "application/x-www-form-urlencoded" encoded values, - and MUST be properly decoded, as demonstrated by one of the "a3" - parameter instances (the "a3" parameter is used twice in this - request). - - .. _`section 3.4.1.3.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + The rules where the parameters must be sourced from are defined in + `section 3.4.1.3.1`_ of RFC 5849. + + .. _`Sec 3.4.1.3.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 """ + if body is None: + body = [] headers = headers or {} params = [] @@ -278,11 +258,11 @@ def collect_parameters(uri_query='', body=[], headers=None, # `RFC3986, Section 3.4`_. The query component is parsed into a list # of name/value pairs by treating it as an # "application/x-www-form-urlencoded" string, separating the names - # and values and decoding them as defined by - # `W3C.REC-html40-19980424`_, Section 17.13.4. + # and values and decoding them as defined by W3C.REC-html40-19980424 + # `W3C-HTML-4.0`_, Section 17.13.4. # - # .. _`RFC3986, Section 3.4`: https://tools.ietf.org/html/rfc3986#section-3.4 - # .. _`W3C.REC-html40-19980424`: https://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + # .. _`RFC3986, Sec 3.4`: https://tools.ietf.org/html/rfc3986#section-3.4 + # .. _`W3C-HTML-4.0`: https://www.w3.org/TR/1998/REC-html40-19980424/ if uri_query: params.extend(urldecode(uri_query)) @@ -305,12 +285,12 @@ def collect_parameters(uri_query='', body=[], headers=None, # # * The entity-body follows the encoding requirements of the # "application/x-www-form-urlencoded" content-type as defined by - # `W3C.REC-html40-19980424`_. + # W3C.REC-html40-19980424 `W3C-HTML-4.0`_. # * The HTTP request entity-header includes the "Content-Type" # header field set to "application/x-www-form-urlencoded". # - # .._`W3C.REC-html40-19980424`: https://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + # .. _`W3C-HTML-4.0`: https://www.w3.org/TR/1998/REC-html40-19980424/ # TODO: enforce header param inclusion conditions bodyparams = extract_params(body) or [] @@ -332,75 +312,17 @@ def collect_parameters(uri_query='', body=[], headers=None, return unescaped_params -def normalize_parameters(params): - """**Parameters Normalization** - Per `section 3.4.1.3.2`_ of the spec. - - For example, the list of parameters from the previous section would - be normalized as follows: - - Encoded:: - - +------------------------+------------------+ - | Name | Value | - +------------------------+------------------+ - | b5 | %3D%253D | - | a3 | a | - | c%40 | | - | a2 | r%20b | - | oauth_consumer_key | 9djdj82h48djs9d2 | - | oauth_token | kkk9d7dh3k39sjv7 | - | oauth_signature_method | HMAC-SHA1 | - | oauth_timestamp | 137131201 | - | oauth_nonce | 7d8f3e4a | - | c2 | | - | a3 | 2%20q | - +------------------------+------------------+ - - Sorted:: - - +------------------------+------------------+ - | Name | Value | - +------------------------+------------------+ - | a2 | r%20b | - | a3 | 2%20q | - | a3 | a | - | b5 | %3D%253D | - | c%40 | | - | c2 | | - | oauth_consumer_key | 9djdj82h48djs9d2 | - | oauth_nonce | 7d8f3e4a | - | oauth_signature_method | HMAC-SHA1 | - | oauth_timestamp | 137131201 | - | oauth_token | kkk9d7dh3k39sjv7 | - +------------------------+------------------+ - - Concatenated Pairs:: - - +-------------------------------------+ - | Name=Value | - +-------------------------------------+ - | a2=r%20b | - | a3=2%20q | - | a3=a | - | b5=%3D%253D | - | c%40= | - | c2= | - | oauth_consumer_key=9djdj82h48djs9d2 | - | oauth_nonce=7d8f3e4a | - | oauth_signature_method=HMAC-SHA1 | - | oauth_timestamp=137131201 | - | oauth_token=kkk9d7dh3k39sjv7 | - +-------------------------------------+ - - and concatenated together into a single string (line breaks are for - display purposes only):: - - a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj - dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1 - &oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7 - - .. _`section 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 +def normalize_parameters(params) -> str: + """ + Calculate the normalized request parameters. + + The *normalized request parameters* is one of the components that make up + the *signature base string*. + + The rules for parameter normalization are defined in `section 3.4.1.3.2`_ of + RFC 5849. + + .. _`Sec 3.4.1.3.2`: https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 """ # The parameters collected in `Section 3.4.1.3`_ are normalized into a @@ -430,34 +352,33 @@ def normalize_parameters(params): return '&'.join(parameter_parts) -def sign_hmac_sha1_with_client(base_string, client): - return sign_hmac_sha1(base_string, - client.client_secret, - client.resource_owner_secret - ) +# ==== Common functions for HMAC-based signature methods ========= +def _sign_hmac(hash_algorithm_name: str, + sig_base_str: str, + client_secret: str, + resource_owner_secret: str): + """ + **HMAC-SHA256** -def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): - """**HMAC-SHA1** - - The "HMAC-SHA1" signature method uses the HMAC-SHA1 signature - algorithm as defined in `RFC2104`_:: + The "HMAC-SHA256" signature method uses the HMAC-SHA256 signature + algorithm as defined in `RFC4634`_:: - digest = HMAC-SHA1 (key, text) + digest = HMAC-SHA256 (key, text) Per `section 3.4.2`_ of the spec. - .. _`RFC2104`: https://tools.ietf.org/html/rfc2104 + .. _`RFC4634`: https://tools.ietf.org/html/rfc4634 .. _`section 3.4.2`: https://tools.ietf.org/html/rfc5849#section-3.4.2 """ - # The HMAC-SHA1 function variables are used in following way: + # The HMAC-SHA256 function variables are used in following way: # text is set to the value of the signature base string from # `Section 3.4.1.1`_. # # .. _`Section 3.4.1.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.1 - text = base_string + text = sig_base_str # key is set to the concatenated values of: # 1. The client shared-secret, after being encoded (`Section 3.6`_). @@ -474,251 +395,438 @@ def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 key += utils.escape(resource_owner_secret or '') + # Get the hashing algorithm to use + + m = { + 'SHA-1': hashlib.sha1, + 'SHA-256': hashlib.sha256, + 'SHA-512': hashlib.sha512, + } + hash_alg = m[hash_algorithm_name] + + # Calculate the signature + # FIXME: HMAC does not support unicode! key_utf8 = key.encode('utf-8') text_utf8 = text.encode('utf-8') - signature = hmac.new(key_utf8, text_utf8, hashlib.sha1) + signature = hmac.new(key_utf8, text_utf8, hash_alg) # digest is used to set the value of the "oauth_signature" protocol # parameter, after the result octet string is base64-encoded # per `RFC2045, Section 6.8`. # - # .. _`RFC2045, Section 6.8`: https://tools.ietf.org/html/rfc2045#section-6.8 + # .. _`RFC2045, Sec 6.8`: https://tools.ietf.org/html/rfc2045#section-6.8 return binascii.b2a_base64(signature.digest())[:-1].decode('utf-8') -def sign_hmac_sha256_with_client(base_string, client): - return sign_hmac_sha256(base_string, - client.client_secret, - client.resource_owner_secret - ) +def _verify_hmac(hash_algorithm_name: str, + request, + client_secret=None, + resource_owner_secret=None): + """Verify a HMAC-SHA1 signature. + + Per `section 3.4`_ of the spec. + + .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + + .. _`RFC2616 section 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 + + """ + norm_params = normalize_parameters(request.params) + bs_uri = base_string_uri(request.uri) + sig_base_str = signature_base_string(request.http_method, bs_uri, + norm_params) + signature = _sign_hmac(hash_algorithm_name, sig_base_str, + client_secret, resource_owner_secret) + match = safe_string_equals(signature, request.signature) + if not match: + log.debug('Verify HMAC failed: signature base string: %s', sig_base_str) + return match + + +# ==== HMAC-SHA1 ================================================= + +def sign_hmac_sha1_with_client(sig_base_str, client): + return _sign_hmac('SHA-1', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha1(request, client_secret=None, resource_owner_secret=None): + return _verify_hmac('SHA-1', request, client_secret, resource_owner_secret) + + +def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): + """ + Deprecated function for calculating a HMAC-SHA1 signature. + + This function has been replaced by invoking ``sign_hmac`` with "SHA-1" + as the hash algorithm name. + + This function was invoked by sign_hmac_sha1_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. + """ + warnings.warn('use sign_hmac_sha1_with_client instead of sign_hmac_sha1', + DeprecationWarning) + + # For some unknown reason, the original implementation assumed base_string + # could either be bytes or str. The signature base string calculating + # function always returned a str, so the new ``sign_rsa`` only expects that. + + base_string = base_string.decode('ascii') \ + if isinstance(base_string, bytes) else base_string + + return _sign_hmac('SHA-1', base_string, + client_secret, resource_owner_secret) + + +# ==== HMAC-SHA256 =============================================== + +def sign_hmac_sha256_with_client(sig_base_str, client): + return _sign_hmac('SHA-256', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha256(request, client_secret=None, resource_owner_secret=None): + return _verify_hmac('SHA-256', request, + client_secret, resource_owner_secret) def sign_hmac_sha256(base_string, client_secret, resource_owner_secret): - """**HMAC-SHA256** + """ + Deprecated function for calculating a HMAC-SHA256 signature. - The "HMAC-SHA256" signature method uses the HMAC-SHA256 signature - algorithm as defined in `RFC4634`_:: + This function has been replaced by invoking ``sign_hmac`` with "SHA-256" + as the hash algorithm name. - digest = HMAC-SHA256 (key, text) + This function was invoked by sign_hmac_sha256_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. + """ + warnings.warn( + 'use sign_hmac_sha256_with_client instead of sign_hmac_sha256', + DeprecationWarning) - Per `section 3.4.2`_ of the spec. + # For some unknown reason, the original implementation assumed base_string + # could either be bytes or str. The signature base string calculating + # function always returned a str, so the new ``sign_rsa`` only expects that. - .. _`RFC4634`: https://tools.ietf.org/html/rfc4634 - .. _`section 3.4.2`: https://tools.ietf.org/html/rfc5849#section-3.4.2 + base_string = base_string.decode('ascii') \ + if isinstance(base_string, bytes) else base_string + + return _sign_hmac('SHA-256', base_string, + client_secret, resource_owner_secret) + + +# ==== HMAC-SHA512 =============================================== + +def sign_hmac_sha512_with_client(sig_base_str: str, + client): + return _sign_hmac('SHA-512', sig_base_str, + client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha512(request, + client_secret: str = None, + resource_owner_secret: str = None): + return _verify_hmac('SHA-512', request, + client_secret, resource_owner_secret) + + +# ==== Common functions for RSA-based signature methods ========== + +_jwt_rsa = {} # cache of RSA-hash implementations from PyJWT jwt.algorithms + + +def _get_jwt_rsa_algorithm(hash_algorithm_name: str): """ + Obtains an RSAAlgorithm object that implements RSA with the hash algorithm. - # The HMAC-SHA256 function variables are used in following way: + This method maintains the ``_jwt_rsa`` cache. - # text is set to the value of the signature base string from - # `Section 3.4.1.1`_. - # - # .. _`Section 3.4.1.1`: https://tools.ietf.org/html/rfc5849#section-3.4.1.1 - text = base_string + Returns a jwt.algorithm.RSAAlgorithm. + """ + if hash_algorithm_name in _jwt_rsa: + # Found in cache: return it + return _jwt_rsa[hash_algorithm_name] + else: + # Not in cache: instantiate a new RSAAlgorithm - # key is set to the concatenated values of: - # 1. The client shared-secret, after being encoded (`Section 3.6`_). - # - # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 - key = utils.escape(client_secret or '') + # PyJWT has some nice pycrypto/cryptography abstractions + import jwt.algorithms as jwt_algorithms + m = { + 'SHA-1': jwt_algorithms.hashes.SHA1, + 'SHA-256': jwt_algorithms.hashes.SHA256, + 'SHA-512': jwt_algorithms.hashes.SHA512, + } + v = jwt_algorithms.RSAAlgorithm(m[hash_algorithm_name]) - # 2. An "&" character (ASCII code 38), which MUST be included - # even when either secret is empty. - key += '&' + _jwt_rsa[hash_algorithm_name] = v # populate cache - # 3. The token shared-secret, after being encoded (`Section 3.6`_). - # - # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 - key += utils.escape(resource_owner_secret or '') + return v - # FIXME: HMAC does not support unicode! - key_utf8 = key.encode('utf-8') - text_utf8 = text.encode('utf-8') - signature = hmac.new(key_utf8, text_utf8, hashlib.sha256) - # digest is used to set the value of the "oauth_signature" protocol - # parameter, after the result octet string is base64-encoded - # per `RFC2045, Section 6.8`. - # - # .. _`RFC2045, Section 6.8`: https://tools.ietf.org/html/rfc2045#section-6.8 - return binascii.b2a_base64(signature.digest())[:-1].decode('utf-8') +def _prepare_key_plus(alg, keystr): + """ + Prepare a PEM encoded key (public or private), by invoking the `prepare_key` + method on alg with the keystr. + + The keystr should be a string or bytes. If the keystr is bytes, it is + decoded as UTF-8 before being passed to prepare_key. Otherwise, it + is passed directly. + """ + if isinstance(keystr, bytes): + keystr = keystr.decode('utf-8') + return alg.prepare_key(keystr) -_jwtrs1 = None -#jwt has some nice pycrypto/cryptography abstractions -def _jwt_rs1_signing_algorithm(): - global _jwtrs1 - if _jwtrs1 is None: - import jwt.algorithms as jwtalgo - _jwtrs1 = jwtalgo.RSAAlgorithm(jwtalgo.hashes.SHA1) - return _jwtrs1 +def _sign_rsa(hash_algorithm_name: str, + sig_base_str: str, + rsa_private_key: str): + """ + Calculate the signature for an RSA-based signature method. -def sign_rsa_sha1(base_string, rsa_private_key): - """**RSA-SHA1** + The ``alg`` is used to calculate the digest over the signature base string. + For the "RSA_SHA1" signature method, the alg must be SHA-1. While OAuth 1.0a + only defines the RSA-SHA1 signature method, this function can be used for + other non-standard signature methods that only differ from RSA-SHA1 by the + digest algorithm. - Per `section 3.4.3`_ of the spec. + Signing for the RSA-SHA1 signature method is defined in + `section 3.4.3`_ of RFC 5849. - The "RSA-SHA1" signature method uses the RSASSA-PKCS1-v1_5 signature - algorithm as defined in `RFC3447, Section 8.2`_ (also known as - PKCS#1), using SHA-1 as the hash function for EMSA-PKCS1-v1_5. To + The RSASSA-PKCS1-v1_5 signature algorithm used defined by + `RFC3447, Section 8.2`_ (also known as PKCS#1), with the `alg` as the + hash function for EMSA-PKCS1-v1_5. To use this method, the client MUST have established client credentials with the server that included its RSA public key (in a manner that is beyond the scope of this specification). .. _`section 3.4.3`: https://tools.ietf.org/html/rfc5849#section-3.4.3 .. _`RFC3447, Section 8.2`: https://tools.ietf.org/html/rfc3447#section-8.2 - """ - if isinstance(base_string, str): - base_string = base_string.encode('utf-8') - # TODO: finish RSA documentation - alg = _jwt_rs1_signing_algorithm() + + # Get the implementation of RSA-hash + + alg = _get_jwt_rsa_algorithm(hash_algorithm_name) + + # Check private key + + if not rsa_private_key: + raise ValueError('rsa_private_key required for RSA with ' + + alg.hash_alg.name + ' signature method') + + # Convert the "signature base string" into a sequence of bytes (M) + # + # The signature base string, by definition, only contain printable US-ASCII + # characters. So encoding it as 'ascii' will always work. It will raise a + # ``UnicodeError`` if it can't encode the value, which will never happen + # if the signature base string was created correctly. Therefore, using + # 'ascii' encoding provides an extra level of error checking. + + m = sig_base_str.encode('ascii') + + # Perform signing: S = RSASSA-PKCS1-V1_5-SIGN (K, M) + key = _prepare_key_plus(alg, rsa_private_key) - s=alg.sign(base_string, key) - return binascii.b2a_base64(s)[:-1].decode('utf-8') + s = alg.sign(m, key) + + # base64-encoded per RFC2045 section 6.8. + # + # 1. While b2a_base64 implements base64 defined by RFC 3548. As used here, + # it is the same as base64 defined by RFC 2045. + # 2. b2a_base64 includes a "\n" at the end of its result ([:-1] removes it) + # 3. b2a_base64 produces a binary string. Use decode to produce a str. + # It should only contain only printable US-ASCII characters. + return binascii.b2a_base64(s)[:-1].decode('ascii') -def sign_rsa_sha1_with_client(base_string, client): - if not client.rsa_key: - raise ValueError('rsa_key is required when using RSA signature method.') - return sign_rsa_sha1(base_string, client.rsa_key) +def _verify_rsa(hash_algorithm_name: str, + request, + rsa_public_key: str): + """ + Verify a base64 encoded signature for a RSA-based signature method. -def sign_plaintext(client_secret, resource_owner_secret): - """Sign a request using plaintext. + The ``alg`` is used to calculate the digest over the signature base string. + For the "RSA_SHA1" signature method, the alg must be SHA-1. While OAuth 1.0a + only defines the RSA-SHA1 signature method, this function can be used for + other non-standard signature methods that only differ from RSA-SHA1 by the + digest algorithm. - Per `section 3.4.4`_ of the spec. + Verification for the RSA-SHA1 signature method is defined in + `section 3.4.3`_ of RFC 5849. - The "PLAINTEXT" method does not employ a signature algorithm. It - MUST be used with a transport-layer mechanism such as TLS or SSL (or - sent over a secure channel with equivalent protections). It does not - utilize the signature base string or the "oauth_timestamp" and - "oauth_nonce" parameters. + .. _`section 3.4.3`: https://tools.ietf.org/html/rfc5849#section-3.4.3 - .. _`section 3.4.4`: https://tools.ietf.org/html/rfc5849#section-3.4.4 + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + .. _`RFC2616 Sec 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 """ - # The "oauth_signature" protocol parameter is set to the concatenated - # value of: + try: + # Calculate the *signature base string* of the actual received request - # 1. The client shared-secret, after being encoded (`Section 3.6`_). - # - # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 - signature = utils.escape(client_secret or '') + norm_params = normalize_parameters(request.params) + bs_uri = base_string_uri(request.uri) + sig_base_str = signature_base_string( + request.http_method, bs_uri, norm_params) - # 2. An "&" character (ASCII code 38), which MUST be included even - # when either secret is empty. - signature += '&' + # Obtain the signature that was received in the request - # 3. The token shared-secret, after being encoded (`Section 3.6`_). - # - # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 - signature += utils.escape(resource_owner_secret or '') + sig = binascii.a2b_base64(request.signature.encode('ascii')) - return signature + # Get the implementation of RSA-with-hash algorithm to use + alg = _get_jwt_rsa_algorithm(hash_algorithm_name) -def sign_plaintext_with_client(base_string, client): - return sign_plaintext(client.client_secret, client.resource_owner_secret) + # Verify the received signature was produced by the private key + # corresponding to the `rsa_public_key`, signing exact same + # *signature base string*. + # + # RSASSA-PKCS1-V1_5-VERIFY ((n, e), M, S) + key = _prepare_key_plus(alg, rsa_public_key) -def verify_hmac_sha1(request, client_secret=None, - resource_owner_secret=None): - """Verify a HMAC-SHA1 signature. + # The signature base string only contain printable US-ASCII characters. + # The ``encode`` method with the default "strict" error handling will + # raise a ``UnicodeError`` if it can't encode the value. So using + # "ascii" will always work. - Per `section 3.4`_ of the spec. + verify_ok = alg.verify(sig_base_str.encode('ascii'), key, sig) - .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 + if not verify_ok: + log.debug('Verify failed: RSA with ' + alg.hash_alg.name + + ': signature base string=%s' + sig_base_str) + return verify_ok - To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri - attribute MUST be an absolute URI whose netloc part identifies the - origin server or gateway on which the resource resides. Any Host - item of the request argument's headers dict attribute will be - ignored. + except UnicodeError: + # A properly encoded signature will only contain printable US-ASCII + # characters. The ``encode`` method with the default "strict" error + # handling will raise a ``UnicodeError`` if it can't decode the value. + # So using "ascii" will work with all valid signatures. But an + # incorrectly or maliciously produced signature could contain other + # bytes. + # + # This implementation treats that situation as equivalent to the + # signature verification having failed. + # + # Note: simply changing the encode to use 'utf-8' will not remove this + # case, since an incorrect or malicious request can contain bytes which + # are invalid as UTF-8. + return False - .. _`RFC2616 section 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 - """ - norm_params = normalize_parameters(request.params) - bs_uri = base_string_uri(request.uri) - sig_base_str = signature_base_string(request.http_method, bs_uri, - norm_params) - signature = sign_hmac_sha1(sig_base_str, client_secret, - resource_owner_secret) - match = safe_string_equals(signature, request.signature) - if not match: - log.debug('Verify HMAC-SHA1 failed: signature base string: %s', - sig_base_str) - return match +# ==== RSA-SHA1 ================================================== +def sign_rsa_sha1_with_client(sig_base_str, client): + # For some reason, this function originally accepts both str and bytes. + # This behaviour is preserved here. But won't be done for the newer + # sign_rsa_sha256_with_client and sign_rsa_sha512_with_client functions, + # which will only accept strings. The function to calculate a + # "signature base string" always produces a string, so it is not clear + # why support for bytes would ever be needed. + sig_base_str = sig_base_str.decode('ascii')\ + if isinstance(sig_base_str, bytes) else sig_base_str -def verify_hmac_sha256(request, client_secret=None, - resource_owner_secret=None): - """Verify a HMAC-SHA256 signature. + return _sign_rsa('SHA-1', sig_base_str, client.rsa_key) - Per `section 3.4`_ of the spec. - .. _`section 3.4`: https://tools.ietf.org/html/rfc5849#section-3.4 +def verify_rsa_sha1(request, rsa_public_key: str): + return _verify_rsa('SHA-1', request, rsa_public_key) - To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri - attribute MUST be an absolute URI whose netloc part identifies the - origin server or gateway on which the resource resides. Any Host - item of the request argument's headers dict attribute will be - ignored. - .. _`RFC2616 section 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 +def sign_rsa_sha1(base_string, rsa_private_key): + """ + Deprecated function for calculating a RSA-SHA1 signature. + + This function has been replaced by invoking ``sign_rsa`` with "SHA-1" + as the hash algorithm name. + This function was invoked by sign_rsa_sha1_with_client and + test_signatures.py, but does any application invoke it directly? If not, + it can be removed. """ - norm_params = normalize_parameters(request.params) - bs_uri = base_string_uri(request.uri) - sig_base_str = signature_base_string(request.http_method, bs_uri, - norm_params) - signature = sign_hmac_sha256(sig_base_str, client_secret, - resource_owner_secret) - match = safe_string_equals(signature, request.signature) - if not match: - log.debug('Verify HMAC-SHA256 failed: signature base string: %s', - sig_base_str) - return match + warnings.warn('use _sign_rsa("SHA-1", ...) instead of sign_rsa_sha1', + DeprecationWarning) + if isinstance(base_string, bytes): + base_string = base_string.decode('ascii') -def _prepare_key_plus(alg, keystr): - if isinstance(keystr, bytes): - keystr = keystr.decode('utf-8') - return alg.prepare_key(keystr) + return _sign_rsa('SHA-1', base_string, rsa_private_key) -def verify_rsa_sha1(request, rsa_public_key): - """Verify a RSASSA-PKCS #1 v1.5 base64 encoded signature. - Per `section 3.4.3`_ of the spec. +# ==== RSA-SHA256 ================================================ - Note this method requires the jwt and cryptography libraries. +def sign_rsa_sha256_with_client(sig_base_str: str, client): + return _sign_rsa('SHA-256', sig_base_str, client.rsa_key) - .. _`section 3.4.3`: https://tools.ietf.org/html/rfc5849#section-3.4.3 - To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri - attribute MUST be an absolute URI whose netloc part identifies the - origin server or gateway on which the resource resides. Any Host - item of the request argument's headers dict attribute will be - ignored. +def verify_rsa_sha256(request, rsa_public_key: str): + return _verify_rsa('SHA-256', request, rsa_public_key) + + +# ==== RSA-SHA512 ================================================ + +def sign_rsa_sha512_with_client(sig_base_str: str, client): + return _sign_rsa('SHA-512', sig_base_str, client.rsa_key) + + +def verify_rsa_sha512(request, rsa_public_key: str): + return _verify_rsa('SHA-512', request, rsa_public_key) + + +# ==== PLAINTEXT ================================================= + +def sign_plaintext_with_client(_signature_base_string, client): + # _signature_base_string is not used because the signature with PLAINTEXT + # is just the secret: it isn't a real signature. + return sign_plaintext(client.client_secret, client.resource_owner_secret) + + +def sign_plaintext(client_secret, resource_owner_secret): + """Sign a request using plaintext. + + Per `section 3.4.4`_ of the spec. + + The "PLAINTEXT" method does not employ a signature algorithm. It + MUST be used with a transport-layer mechanism such as TLS or SSL (or + sent over a secure channel with equivalent protections). It does not + utilize the signature base string or the "oauth_timestamp" and + "oauth_nonce" parameters. + + .. _`section 3.4.4`: https://tools.ietf.org/html/rfc5849#section-3.4.4 - .. _`RFC2616 section 5.2`: https://tools.ietf.org/html/rfc2616#section-5.2 """ - norm_params = normalize_parameters(request.params) - bs_uri = base_string_uri(request.uri) - sig_base_str = signature_base_string(request.http_method, bs_uri, - norm_params).encode('utf-8') - sig = binascii.a2b_base64(request.signature.encode('utf-8')) - alg = _jwt_rs1_signing_algorithm() - key = _prepare_key_plus(alg, rsa_public_key) + # The "oauth_signature" protocol parameter is set to the concatenated + # value of: + + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + signature = utils.escape(client_secret or '') + + # 2. An "&" character (ASCII code 38), which MUST be included even + # when either secret is empty. + signature += '&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: https://tools.ietf.org/html/rfc5849#section-3.6 + signature += utils.escape(resource_owner_secret or '') - verify_ok = alg.verify(sig_base_str, key, sig) - if not verify_ok: - log.debug('Verify RSA-SHA1 failed: signature base string: %s', - sig_base_str) - return verify_ok + return signature def verify_plaintext(request, client_secret=None, resource_owner_secret=None): diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index d30bfd7..81ee1de 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -161,10 +161,10 @@ class MetadataEndpoint(BaseEndpoint): response_types_supported REQUIRED. - * Other OPTIONAL fields: - jwks_uri - registration_endpoint - response_modes_supported + Other OPTIONAL fields: + jwks_uri, + registration_endpoint, + response_modes_supported grant_types_supported OPTIONAL. JSON array containing a list of the OAuth 2.0 grant diff --git a/setup.py b/setup.py index 6fada45..0babb45 100755 --- a/setup.py +++ b/setup.py @@ -16,9 +16,9 @@ def fread(fn): return f.read() -rsa_require = ['cryptography'] -signedtoken_require = ['cryptography', 'pyjwt>=1.0.0'] -signals_require = ['blinker'] +rsa_require = ['cryptography>=1.4.0'] +signedtoken_require = ['cryptography>=1.4.0', 'pyjwt>=1.6.0'] +signals_require = ['blinker>=1.4.0'] setup( name='oauthlib', diff --git a/tests/oauth1/rfc5849/test_signatures.py b/tests/oauth1/rfc5849/test_signatures.py index 2de4e8a..3e84f24 100644 --- a/tests/oauth1/rfc5849/test_signatures.py +++ b/tests/oauth1/rfc5849/test_signatures.py @@ -1,301 +1,588 @@ # -*- coding: utf-8 -*- -from urllib.parse import quote - from oauthlib.oauth1.rfc5849.signature import ( - base_string_uri, collect_parameters, normalize_parameters, sign_hmac_sha1, - sign_hmac_sha1_with_client, sign_plaintext, sign_plaintext_with_client, - sign_rsa_sha1, sign_rsa_sha1_with_client, signature_base_string, + collect_parameters, + signature_base_string, + base_string_uri, + normalize_parameters, + sign_hmac_sha1_with_client, + sign_hmac_sha256_with_client, + sign_hmac_sha512_with_client, + sign_rsa_sha1_with_client, + sign_rsa_sha256_with_client, + sign_rsa_sha512_with_client, + sign_plaintext_with_client, + verify_hmac_sha1, + verify_hmac_sha256, + verify_hmac_sha512, + verify_rsa_sha1, + verify_rsa_sha256, + verify_rsa_sha512, + verify_plaintext ) - from tests.unittest import TestCase +# ################################################################ + +class MockRequest: + """ + Mock of a request used by the verify_* functions. + """ + + def __init__(self, + method: str, + uri_str: str, + params: list, + signature: str): + """ + The params is a list of (name, value) tuples. It is not a dictionary, + because there can be multiple parameters with the same name. + """ + self.uri = uri_str + self.http_method = method + self.params = params + self.signature = signature + + +# ################################################################ + +class MockClient: + """ + Mock of client credentials used by the sign_*_with_client functions. + + For HMAC, set the client_secret and resource_owner_secret. + + For RSA, set the rsa_key to either a PEM formatted PKCS #1 public key or + PEM formatted PKCS #1 private key. + """ + def __init__(self, + client_secret: str = None, + resource_owner_secret: str = None, + rsa_key: str = None): + self.client_secret = client_secret + self.resource_owner_secret = resource_owner_secret + self.rsa_key = rsa_key # used for private or public key: a poor design! + + +# ################################################################ + class SignatureTests(TestCase): - class MockClient(dict): - def __getattr__(self, name): - return self[name] - - def __setattr__(self, name, value): - self[name] = value - - def decode(self): - for k, v in self.items(): - self[k] = v.decode('utf-8') - - uri_query = "b5=%3D%253D&a3=a&c%40=&a2=r%20b" - authorization_header = """OAuth realm="Example", - oauth_consumer_key="9djdj82h48djs9d2", - oauth_token="kkk9d7dh3k39sjv7", - oauth_signature_method="HMAC-SHA1", - oauth_timestamp="137131201", - oauth_nonce="7d8f3e4a", - oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D" """.strip() - body = "c2&a3=2+q" - http_method = b"post" - base_string_url = ( - "http://example.com/request?{}".format(uri_query)).encode('utf-8') - unnormalized_request_parameters =[ - ('OAuth realm',"Example"), - ('oauth_consumer_key',"9djdj82h48djs9d2"), - ('oauth_token',"kkk9d7dh3k39sjv7"), - ('oauth_signature_method',"HMAC-SHA1"), - ('oauth_timestamp',"137131201"), - ('oauth_nonce',"7d8f3e4a"), - ('oauth_signature',"bYT5CMsGcbgUdFHObYMEfcx6bsw%3D") + """ + Unit tests for the oauthlib/oauth1/rfc5849/signature.py module. + + The tests in this class are organised into sections, to test the + functions relating to: + + - Signature base string calculation + - HMAC-based signature methods + - RSA-based signature methods + - PLAINTEXT signature method + + Each section is separated by a comment beginning with "====". + + Those comments have been formatted to remain visible when the code is + collapsed using PyCharm's code folding feature. That is, those section + heading comments do not have any other comment lines around it, so they + don't get collapsed when the contents of the class is collapsed. While + there is a "Sequential comments" option in the code folding configuration, + by default they are folded. + + They all use some/all of the example test vector, defined in the first + section below. + """ + + # ==== Example test vector ======================================= + + eg_signature_base_string =\ + 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' \ + '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' \ + 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' \ + 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' \ + '9d7dh3k39sjv7' + + # The _signature base string_ above is copied from the end of + # RFC 5849 section 3.4.1.1. + # + # It corresponds to the three values below. + # + # The _normalized parameters_ below is copied from the end of + # RFC 5849 section 3.4.1.3.2. + + eg_http_method = 'POST' + + eg_base_string_uri = 'http://example.com/request' + + eg_normalized_parameters =\ + 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' \ + 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' \ + '&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7' + + # The above _normalized parameters_ corresponds to the parameters below. + # + # The parameters below is copied from the table at the end of + # RFC 5849 section 3.4.1.3.1. + + eg_params = [ + ('b5', '=%3D'), + ('a3', 'a'), + ('c@', ''), + ('a2', 'r b'), + ('oauth_consumer_key', '9djdj82h48djs9d2'), + ('oauth_token', 'kkk9d7dh3k39sjv7'), + ('oauth_signature_method', 'HMAC-SHA1'), + ('oauth_timestamp', '137131201'), + ('oauth_nonce', '7d8f3e4a'), + ('c2', ''), + ('a3', '2 q'), ] - normalized_encoded_request_params = sorted( - [(quote(k), quote(v)) for k, v in unnormalized_request_parameters - if k.lower() != "oauth realm"]) - client_secret = b"ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ" - resource_owner_secret = b"just-a-string asdasd" - control_base_string = ( - "POST&http%3A%2F%2Fexample.com%2Frequest&" - "a2%3Dr%2520b%26" - "a3%3D2%2520q%26" - "a3%3Da%26" - "b5%3D%253D%25253D%26" - "c%2540%3D%26" - "c2%3D%26" - "oauth_consumer_key%3D9djdj82h48djs9d2%26" - "oauth_nonce%3D7d8f3e4a%26" - "oauth_signature_method%3DHMAC-SHA1%26" - "oauth_timestamp%3D137131201%26" - "oauth_token%3Dkkk9d7dh3k39sjv7" - ) - - def setUp(self): - self.client = self.MockClient( - client_secret = self.client_secret, - resource_owner_secret = self.resource_owner_secret - ) + + # The above parameters correspond to parameters from the three values below. + # + # These come from RFC 5849 section 3.4.1.3.1. + + eg_uri_query = 'b5=%3D%253D&a3=a&c%40=&a2=r%20b' + + eg_body = 'c2&a3=2+q' + + eg_authorization_header =\ + 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' \ + ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' \ + ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' \ + ' oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D"' + + # ==== Signature base string calculating function tests ========== def test_signature_base_string(self): """ - Example text to be turned into a base string:: - - POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 - Host: example.com - Content-Type: application/x-www-form-urlencoded - Authorization: OAuth realm="Example", - oauth_consumer_key="9djdj82h48djs9d2", - oauth_token="kkk9d7dh3k39sjv7", - oauth_signature_method="HMAC-SHA1", - oauth_timestamp="137131201", - oauth_nonce="7d8f3e4a", - oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" - c2&a3=2+q - - Sample Base string generated and tested against:: - POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q - %26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_ - key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m - ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk - 9d7dh3k39sjv7 + Test the ``signature_base_string`` function. """ - self.assertRaises(ValueError, base_string_uri, self.base_string_url) - base_string_url = base_string_uri(self.base_string_url.decode('utf-8')) - base_string_url = base_string_url.encode('utf-8') - querystring = self.base_string_url.split(b'?', 1)[1] - query_params = collect_parameters(querystring.decode('utf-8'), - body=self.body) - normalized_encoded_query_params = sorted( - [(quote(k), quote(v)) for k, v in query_params]) - normalized_request_string = "&".join(sorted( - ['='.join((k, v)) for k, v in ( - self.normalized_encoded_request_params + - normalized_encoded_query_params) - if k.lower() != 'oauth_signature'])) - self.assertRaises(ValueError, signature_base_string, - self.http_method, - base_string_url, - normalized_request_string) - self.assertRaises(ValueError, signature_base_string, - self.http_method.decode('utf-8'), - base_string_url, - normalized_request_string) - - base_string = signature_base_string( - self.http_method.decode('utf-8'), - base_string_url.decode('utf-8'), - normalized_request_string - ) - - self.assertEqual(self.control_base_string, base_string) + # Example from RFC 5849 - def test_base_string_uri(self): - """ - Example text to be turned into a normalized base string uri:: + self.assertEqual( + self.eg_signature_base_string, + signature_base_string( + self.eg_http_method, + self.eg_base_string_uri, + self.eg_normalized_parameters)) - GET /?q=1 HTTP/1.1 - Host: www.example.net:8080 + # Test method is always uppercase in the signature base string - Sample string generated:: + for test_method in ['POST', 'Post', 'pOST', 'poST', 'posT', 'post']: + self.assertEqual( + self.eg_signature_base_string, + signature_base_string( + test_method, + self.eg_base_string_uri, + self.eg_normalized_parameters)) - https://www.example.net:8080/ + def test_base_string_uri(self): + """ + Test the ``base_string_uri`` function. """ - # test first example from RFC 5849 section 3.4.1.2. + # ---------------- + # Examples from the OAuth 1.0a specification: RFC 5849. + + # First example from RFC 5849 section 3.4.1.2. + # + # GET /r%20v/X?id=123 HTTP/1.1 + # Host: EXAMPLE.COM:80 + # # Note: there is a space between "r" and "v" - uri = 'http://EXAMPLE.COM:80/r v/X?id=123' - self.assertEqual(base_string_uri(uri), - 'http://example.com/r%20v/X') - - # test second example from RFC 5849 section 3.4.1.2. - uri = 'https://www.example.net:8080/?q=1' - self.assertEqual(base_string_uri(uri), - 'https://www.example.net:8080/') - - # test for unicode failure - uri = b"www.example.com:8080" - self.assertRaises(ValueError, base_string_uri, uri) - - # test for missing scheme - uri = "www.example.com:8080" - self.assertRaises(ValueError, base_string_uri, uri) - - # test a URI with the default port - uri = "http://www.example.com:80/" - self.assertEqual(base_string_uri(uri), - "http://www.example.com/") - - # test a URI missing a path - uri = "http://www.example.com" - self.assertEqual(base_string_uri(uri), - "http://www.example.com/") - - # test a relative URI - uri = "/a-host-relative-uri" - host = "www.example.com" - self.assertRaises(ValueError, base_string_uri, (uri, host)) - - # test overriding the URI's netloc with a host argument - uri = "http://www.example.com/a-path" - host = "alternatehost.example.com" - self.assertEqual(base_string_uri(uri, host), - "http://alternatehost.example.com/a-path") + + self.assertEqual( + 'http://example.com/r%20v/X', + base_string_uri('http://EXAMPLE.COM:80/r v/X?id=123')) + + # Second example from RFC 5849 section 3.4.1.2. + # + # GET /?q=1 HTTP/1.1 + # Host: www.example.net:8080 + + self.assertEqual( + 'https://www.example.net:8080/', + base_string_uri('https://www.example.net:8080/?q=1')) + + # ---------------- + # Scheme: will always be in lowercase + + for uri in [ + 'foobar://www.example.com', + 'FOOBAR://www.example.com', + 'Foobar://www.example.com', + 'FooBar://www.example.com', + 'fOObAR://www.example.com', + ]: + self.assertEqual('foobar://www.example.com/', base_string_uri(uri)) + + # ---------------- + # Host: will always be in lowercase + + for uri in [ + 'http://www.example.com', + 'http://WWW.EXAMPLE.COM', + 'http://www.EXAMPLE.com', + 'http://wWW.eXAMPLE.cOM', + ]: + self.assertEqual('http://www.example.com/', base_string_uri(uri)) + + # base_string_uri has an optional host parameter that can be used to + # override the URI's netloc (or used as the host if there is no netloc) + # The "netloc" refers to the "hostname[:port]" part of the URI. + + self.assertEqual( + 'http://actual.example.com/', + base_string_uri('http://IGNORE.example.com', 'ACTUAL.example.com')) + + self.assertEqual( + 'http://override.example.com/path', + base_string_uri('http:///path', 'OVERRIDE.example.com')) + + # ---------------- + # Port: default ports always excluded; non-default ports always included + + self.assertEqual( + "http://www.example.com/", + base_string_uri("http://www.example.com:80/")) # default port + + self.assertEqual( + "https://www.example.com/", + base_string_uri("https://www.example.com:443/")) # default port + + self.assertEqual( + "https://www.example.com:999/", + base_string_uri("https://www.example.com:999/")) # non-default port + + self.assertEqual( + "http://www.example.com:443/", + base_string_uri("HTTP://www.example.com:443/")) # non-default port + + self.assertEqual( + "https://www.example.com:80/", + base_string_uri("HTTPS://www.example.com:80/")) # non-default port + + self.assertEqual( + "http://www.example.com/", + base_string_uri("http://www.example.com:/")) # colon but no number + + # ---------------- + # Paths + + self.assertEqual( + 'http://www.example.com/', + base_string_uri('http://www.example.com')) # no slash + + self.assertEqual( + 'http://www.example.com/', + base_string_uri('http://www.example.com/')) # with slash + + self.assertEqual( + 'http://www.example.com:8080/', + base_string_uri('http://www.example.com:8080')) # no slash + + self.assertEqual( + 'http://www.example.com:8080/', + base_string_uri('http://www.example.com:8080/')) # with slash + + self.assertEqual( + 'http://www.example.com/foo/bar', + base_string_uri('http://www.example.com/foo/bar')) # no slash + self.assertEqual( + 'http://www.example.com/foo/bar/', + base_string_uri('http://www.example.com/foo/bar/')) # with slash + + # ---------------- + # Query parameters & fragment IDs do not appear in the base string URI + + self.assertEqual( + 'https://www.example.com/path', + base_string_uri('https://www.example.com/path?foo=bar')) + + self.assertEqual( + 'https://www.example.com/path', + base_string_uri('https://www.example.com/path#fragment')) + + # ---------------- + # Percent encoding + # + # RFC 5849 does not specify what characters are percent encoded, but in + # one of its examples it shows spaces being percent encoded. + # So it is assumed that spaces must be encoded, but we don't know what + # other characters are encoded or not. + + self.assertEqual( + 'https://www.example.com/hello%20world', + base_string_uri('https://www.example.com/hello world')) + + self.assertEqual( + 'https://www.hello%20world.com/', + base_string_uri('https://www.hello world.com/')) + + # ---------------- + # Errors detected + + # base_string_uri expects a string + self.assertRaises(ValueError, base_string_uri, None) + self.assertRaises(ValueError, base_string_uri, 42) + self.assertRaises(ValueError, base_string_uri, b'http://example.com') + + # Missing scheme is an error + self.assertRaises(ValueError, base_string_uri, '') + self.assertRaises(ValueError, base_string_uri, ' ') # single space + self.assertRaises(ValueError, base_string_uri, 'http') + self.assertRaises(ValueError, base_string_uri, 'example.com') + + # Missing host is an error + self.assertRaises(ValueError, base_string_uri, 'http:') + self.assertRaises(ValueError, base_string_uri, 'http://') + self.assertRaises(ValueError, base_string_uri, 'http://:8080') + + # Port is not a valid TCP/IP port number + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:0') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:-1') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:65536') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:3.14') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:BAD') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:NaN') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com: ') + self.assertRaises(ValueError, base_string_uri, 'http://eg.com:42:42') def test_collect_parameters(self): - """We check against parameters multiple times in case things change - after more parameters are added. """ - self.assertEqual(collect_parameters(), []) - - # Check against uri_query - parameters = collect_parameters(uri_query=self.uri_query) - correct_parameters = [('b5', '=%3D'), - ('a3', 'a'), - ('c@', ''), - ('a2', 'r b')] - self.assertEqual(sorted(parameters), sorted(correct_parameters)) - - headers = {'Authorization': self.authorization_header} - # check against authorization header as well - parameters = collect_parameters( - uri_query=self.uri_query, headers=headers) - parameters_with_realm = collect_parameters( - uri_query=self.uri_query, headers=headers, with_realm=True) - # Redo the checks against all the parameters. Duplicated code but - # better safety - correct_parameters += [ - ('oauth_nonce', '7d8f3e4a'), - ('oauth_timestamp', '137131201'), - ('oauth_consumer_key', '9djdj82h48djs9d2'), - ('oauth_signature_method', 'HMAC-SHA1'), - ('oauth_token', 'kkk9d7dh3k39sjv7')] - correct_parameters_with_realm = ( - correct_parameters + [('realm', 'Example')]) - self.assertEqual(sorted(parameters), sorted(correct_parameters)) - self.assertEqual(sorted(parameters_with_realm), - sorted(correct_parameters_with_realm)) - - # Add in the body. - # Redo again the checks against all the parameters. Duplicated code - # but better safety - parameters = collect_parameters( - uri_query=self.uri_query, body=self.body, headers=headers) - correct_parameters += [ - ('c2', ''), - ('a3', '2 q') - ] - self.assertEqual(sorted(parameters), sorted(correct_parameters)) + Test the ``collect_parameters`` function. + """ + + # ---------------- + # Examples from the OAuth 1.0a specification: RFC 5849. + + params = collect_parameters( + self.eg_uri_query, + self.eg_body, + {'Authorization': self.eg_authorization_header}) + + # Check params contains the same pairs as control_params, ignoring order + self.assertEqual(sorted(self.eg_params), sorted(params)) + + # ---------------- + # Examples with no parameters + + self.assertEqual([], collect_parameters('', '', {})) + + self.assertEqual([], collect_parameters(None, None, None)) + + self.assertEqual([], collect_parameters()) + + self.assertEqual([], collect_parameters(headers={'foo': 'bar'})) + + # ---------------- + # Test effect of exclude_oauth_signature" + + no_sig = collect_parameters( + headers={'authorization': self.eg_authorization_header}) + with_sig = collect_parameters( + headers={'authorization': self.eg_authorization_header}, + exclude_oauth_signature=False) + + self.assertEqual(sorted(no_sig + [('oauth_signature', + 'djosJKDKJSD8743243/jdk33klY=')]), + sorted(with_sig)) + + # ---------------- + # Test effect of "with_realm" as well as header name case insensitivity + + no_realm = collect_parameters( + headers={'authorization': self.eg_authorization_header}, + with_realm=False) + with_realm = collect_parameters( + headers={'AUTHORIZATION': self.eg_authorization_header}, + with_realm=True) + + self.assertEqual(sorted(no_realm + [('realm', 'Example')]), + sorted(with_realm)) def test_normalize_parameters(self): - """ We copy some of the variables from the test method above.""" - - headers = {'Authorization': self.authorization_header} - parameters = collect_parameters( - uri_query=self.uri_query, body=self.body, headers=headers) - normalized = normalize_parameters(parameters) - - # Unicode everywhere and always - self.assertIsInstance(normalized, str) - - # Lets see if things are in order - # check to see that querystring keys come in alphanumeric order: - querystring_keys = ['a2', 'a3', 'b5', 'oauth_consumer_key', - 'oauth_nonce', 'oauth_signature_method', - 'oauth_timestamp', 'oauth_token'] - index = -1 # start at -1 because the 'a2' key starts at index 0 - for key in querystring_keys: - self.assertGreater(normalized.index(key), index) - index = normalized.index(key) - - # Control signature created using openssl: - # echo -n $(cat ) | openssl dgst -binary -hmac | base64 - control_signature = "mwd09YMxVd2XJ1gudNaBuAuKKuY=" - control_signature_s = "wsdNmjGB7lvis0UJuPAmjvX/PXw=" - - def test_sign_hmac_sha1(self): - """Verifying HMAC-SHA1 signature against one created by OpenSSL.""" - - self.assertRaises(ValueError, sign_hmac_sha1, self.control_base_string, - self.client_secret, self.resource_owner_secret) - - sign = sign_hmac_sha1(self.control_base_string, - self.client_secret.decode('utf-8'), - b'') - self.assertEqual(len(sign), 28) - self.assertEqual(sign, self.control_signature) - - def test_sign_hmac_sha1_with_secret(self): - """Verifying HMAC-SHA1 signature against one created by OpenSSL.""" - - self.assertRaises(ValueError, sign_hmac_sha1, self.control_base_string, - self.client_secret, self.resource_owner_secret) - - sign = sign_hmac_sha1(self.control_base_string, - self.client_secret.decode('utf-8'), - self.resource_owner_secret.decode('utf-8')) - self.assertEqual(len(sign), 28) - self.assertEqual(sign, self.control_signature_s) + """ + Test the ``normalize_parameters`` function. + """ + + # headers = {'Authorization': self.authorization_header} + # parameters = collect_parameters( + # uri_query=self.uri_query, body=self.body, headers=headers) + # normalized = normalize_parameters(parameters) + # + # # Unicode everywhere and always + # self.assertIsInstance(normalized, str) + # + # # Lets see if things are in order + # # check to see that querystring keys come in alphanumeric order: + # querystring_keys = ['a2', 'a3', 'b5', 'oauth_consumer_key', + # 'oauth_nonce', 'oauth_signature_method', + # 'oauth_timestamp', 'oauth_token'] + # index = -1 # start at -1 because the 'a2' key starts at index 0 + # for key in querystring_keys: + # self.assertGreater(normalized.index(key), index) + # index = normalized.index(key) + + # ---------------- + # Example from the OAuth 1.0a specification: RFC 5849. + # Params from end of section 3.4.1.3.1. and the expected + # normalized parameters from the end of section 3.4.1.3.2. + + self.assertEqual(self.eg_normalized_parameters, + normalize_parameters(self.eg_params)) + + # ==== HMAC-based signature method tests ========================= + + hmac_client = MockClient( + client_secret='ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ', + resource_owner_secret='just-a-string asdasd') + + # The following expected signatures were calculated by putting the value of + # the eg_signature_base_string in a file ("base-str.txt") and running: + # + # echo -n `cat base-str.txt` | openssl dgst -hmac KEY -sha1 -binary| base64 + # + # Where the KEY is the concatenation of the client_secret, an ampersand and + # the resource_owner_secret. But those values need to be encoded properly, + # so the spaces in the resource_owner_secret must be represented as '%20'. + # + # Note: the "echo -n" is needed to remove the last newline character, which + # most text editors will add. + + expected_signature_hmac_sha1 = \ + 'wsdNmjGB7lvis0UJuPAmjvX/PXw=' + + expected_signature_hmac_sha256 = \ + 'wdfdHUKXHbOnOGZP8WFAWMSAmWzN3EVBWWgXGlC/Eo4=' + + expected_signature_hmac_sha512 = \ + 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' \ + '1fGONffMpdZxUkUTW3k1kg==' def test_sign_hmac_sha1_with_client(self): - self.assertRaises(ValueError, - sign_hmac_sha1_with_client, - self.control_base_string, - self.client) - - self.client.decode() - sign = sign_hmac_sha1_with_client( - self.control_base_string, self.client) - - self.assertEqual(len(sign), 28) - self.assertEqual(sign, self.control_signature_s) - - - control_base_string_rsa_sha1 = ( - b"POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q" - b"%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_" - b"key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m" - b"ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk" - b"9d7dh3k39sjv7" - ) - - # Generated using: $ openssl genrsa -out .pem 1024 - # PEM encoding requires the key to be concatenated with - # linebreaks. - rsa_private_key = b"""-----BEGIN RSA PRIVATE KEY----- + """ + Test sign and verify with HMAC-SHA1. + """ + self.assertEqual( + self.expected_signature_hmac_sha1, + sign_hmac_sha1_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha1( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha1), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_sign_hmac_sha256_with_client(self): + """ + Test sign and verify with HMAC-SHA256. + """ + self.assertEqual( + self.expected_signature_hmac_sha256, + sign_hmac_sha256_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha256( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha256), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_sign_hmac_sha512_with_client(self): + """ + Test sign and verify with HMAC-SHA512. + """ + self.assertEqual( + self.expected_signature_hmac_sha512, + sign_hmac_sha512_with_client(self.eg_signature_base_string, + self.hmac_client)) + self.assertTrue(verify_hmac_sha512( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_hmac_sha512), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + def test_hmac_false_positives(self): + """ + Test verify_hmac-* functions will correctly detect invalid signatures. + """ + + _ros = self.hmac_client.resource_owner_secret + + for functions in [ + (sign_hmac_sha1_with_client, verify_hmac_sha1), + (sign_hmac_sha256_with_client, verify_hmac_sha256), + (sign_hmac_sha512_with_client, verify_hmac_sha512), + ]: + signing_function = functions[0] + verify_function = functions[1] + + good_signature = \ + signing_function( + self.eg_signature_base_string, + self.hmac_client) + + bad_signature_on_different_value = \ + signing_function( + 'not the signature base string', + self.hmac_client) + + bad_signature_produced_by_different_client_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret='wrong-secret', + resource_owner_secret=_ros)) + bad_signature_produced_by_different_resource_owner_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret=self.hmac_client.client_secret, + resource_owner_secret='wrong-secret')) + + bad_signature_produced_with_no_resource_owner_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(client_secret=self.hmac_client.client_secret)) + bad_signature_produced_with_no_client_secret = \ + signing_function( + self.eg_signature_base_string, + MockClient(resource_owner_secret=_ros)) + + self.assertTrue(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_on_different_value, + bad_signature_produced_by_different_client_secret, + bad_signature_produced_by_different_resource_owner_secret, + bad_signature_produced_with_no_resource_owner_secret, + bad_signature_produced_with_no_client_secret, + ]: + self.assertFalse(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.hmac_client.client_secret, + self.hmac_client.resource_owner_secret)) + + # ==== RSA-based signature methods tests ========================= + + rsa_private_client = MockClient(rsa_key=''' +-----BEGIN RSA PRIVATE KEY----- MIICXgIBAAKBgQDk1/bxyS8Q8jiheHeYYp/4rEKJopeQRRKKpZI4s5i+UPwVpupG AlwXWfzXwSMaKPAoKJNdu7tqKRniqst5uoHXw98gj0x7zamu0Ck1LtQ4c7pFMVah 5IYGhBi2E9ycNS329W27nJPWNCbESTu7snVlG8V8mfvGGg3xNjTMO7IdrwIDAQAB @@ -310,71 +597,291 @@ kmaMg2PNrjUR51F0zOEFycaaqXbGcFwe1/xx9zLmHzMDXd4bsnwt9kk+fe0hQzVS JzatanQit3+feev1PN3QewJAWv4RZeavEUhKv+kLe95Yd0su7lTLVduVgh4v5yLT Ga6FHdjGPcfajt+nrpB1n8UQBEH9ZxniokR/IPvdMlxqXA== -----END RSA PRIVATE KEY----- -""" - @property - def control_signature_rsa_sha1(self): - # Base string saved in "". Signature obtained using: - # $ echo -n $(cat ) | openssl dgst -sha1 -sign .pem | base64 - # where echo -n suppresses the last linebreak. - return ( - "mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738" - "HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q" - "yTR4TWCODlEfN7Zfgy8+pf+TjiXfIwRC1jEWbuL1E=" - - ) - - def test_sign_rsa_sha1(self): - """Verify RSA-SHA1 signature against one created by OpenSSL.""" - base_string = self.control_base_string_rsa_sha1 +''') + + rsa_public_client = MockClient(rsa_key=''' +-----BEGIN RSA PUBLIC KEY----- +MIGJAoGBAOTX9vHJLxDyOKF4d5hin/isQomil5BFEoqlkjizmL5Q/BWm6kYCXBdZ +/NfBIxoo8Cgok127u2opGeKqy3m6gdfD3yCPTHvNqa7QKTUu1DhzukUxVqHkhgaE +GLYT3Jw1Lfb1bbuck9Y0JsRJO7uydWUbxXyZ+8YaDfE2NMw7sh2vAgMBAAE= +-----END RSA PUBLIC KEY----- +''') + + # The above private key was generated using: + # $ openssl genrsa -out example.pvt 1024 + # $ chmod 600 example.pvt + # Public key was extract from it using: + # $ ssh-keygen -e -m pem -f example.pvt + # PEM encoding requires the key to be concatenated with linebreaks. + + # The following expected signatures were calculated by putting the private + # key in a file (test.pvt) and the value of sig_base_str_rsa in another file + # ("base-str.txt") and running: + # + # echo -n `cat base-str.txt` | openssl dgst -sha1 -sign test.pvt| base64 + # + # Note: the "echo -n" is needed to remove the last newline character, which + # most text editors will add. + + expected_signature_rsa_sha1 = \ + 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' \ + 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' \ + 'yTR4TWCODlEfN7Zfgy8+pf+TjiXfIwRC1jEWbuL1E=' + + expected_signature_rsa_sha256 = \ + 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' \ + 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' \ + '/N7s9M1WMnfZZ+1dQbbwtTs1TG1+iexUcV7r3M7Heec=' + + expected_signature_rsa_sha512 = \ + 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' \ + 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' \ + 'g4JXdWCGXG8YRvRJTg+QKhXuXwS6TcMNakrgzgFIVhA=' - private_key = self.rsa_private_key + def test_sign_rsa_sha1_with_client(self): + """ + Test sign and verify with RSA-SHA1. + """ + self.assertEqual( + self.expected_signature_rsa_sha1, + sign_rsa_sha1_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha1( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha1), + self.rsa_public_client.rsa_key)) + + def test_sign_rsa_sha256_with_client(self): + """ + Test sign and verify with RSA-SHA256. + """ + self.assertEqual( + self.expected_signature_rsa_sha256, + sign_rsa_sha256_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha256( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha256), + self.rsa_public_client.rsa_key)) + + def test_sign_rsa_sha512_with_client(self): + """ + Test sign and verify with RSA-SHA512. + """ + self.assertEqual( + self.expected_signature_rsa_sha512, + sign_rsa_sha512_with_client(self.eg_signature_base_string, + self.rsa_private_client)) + self.assertTrue(verify_rsa_sha512( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha512), + self.rsa_public_client.rsa_key)) + + def test_rsa_false_positives(self): + """ + Test verify_rsa-* functions will correctly detect invalid signatures. + """ - control_signature = self.control_signature_rsa_sha1 + another_client = MockClient(rsa_key=''' +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDZcD/1OZNJJ6Y3QZM16Z+O7fkD9kTIQuT2BfpAOUvDfxzYhVC9 +TNmSDHCQhr+ClutyolBk5jTE1/FXFUuHoPsTrkI7KQFXPP834D4gnSY9jrAiUJHe +DVF6wXNuS7H4Ueh16YPjUxgLLRh/nn/JSEj98gsw+7DP01OWMfWS99S7eQIDAQAB +AoGBALsQZRXVyK7BG7CiC8HwEcNnXDpaXmZjlpNKJTenk1THQMvONd4GBZAuf5D3 +PD9fE4R1u/ByVKecmBaxTV+L0TRQfD8K/nbQe0SKRQIkLI2ymLJKC/eyw5iTKT0E ++BS6wYpVd+mfcqgvpHOYpUmz9X8k/eOa7uslFmvt+sDb5ZcBAkEA+++SRqqUxFEG +s/ZWAKw9p5YgkeVUOYVUwyAeZ97heySrjVzg1nZ6v6kv7iOPi9KOEpaIGPW7x1K/ +uQuSt4YEqQJBANzyNqZTTPpv7b/R8ABFy0YMwPVNt3b1GOU1Xxl6iuhH2WcHuueo +UB13JHoZCMZ7hsEqieEz6uteUjdRzRPKclECQFNhVK4iop3emzNQYeJTHwyp+RmQ +JrHq2MTDioyiDUouNsDQbnFMQQ/RtNVB265Q/0hTnbN1ELLFRkK9+87VghECQQC9 +hacLFPk6+TffCp3sHfI3rEj4Iin1iFhKhHWGzW7JwJfjoOXaQK44GDLZ6Q918g+t +MmgDHR2tt8KeYTSgfU+BAkBcaVF91EQ7VXhvyABNYjeYP7lU7orOgdWMa/zbLXSU +4vLsK1WOmwPY9zsXpPkilqszqcru4gzlG462cSbEdAW9 +-----END RSA PRIVATE KEY----- +''') + + for functions in [ + (sign_rsa_sha1_with_client, verify_rsa_sha1), + (sign_rsa_sha256_with_client, verify_rsa_sha256), + (sign_rsa_sha512_with_client, verify_rsa_sha512), + ]: + signing_function = functions[0] + verify_function = functions[1] + + good_signature = \ + signing_function(self.eg_signature_base_string, + self.rsa_private_client) + + bad_signature_on_different_value = \ + signing_function('wrong value signed', self.rsa_private_client) + + bad_signature_produced_by_different_private_key = \ + signing_function(self.eg_signature_base_string, another_client) + + self.assertTrue(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.rsa_public_client.rsa_key)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_on_different_value, + bad_signature_produced_by_different_private_key, + ]: + self.assertFalse(verify_function( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.rsa_public_client.rsa_key)) + + def test_rsa_bad_keys(self): + """ + Testing RSA sign and verify with bad key values produces errors. - sign = sign_rsa_sha1(base_string, private_key) - self.assertEqual(sign, control_signature) - sign = sign_rsa_sha1(base_string.decode('utf-8'), private_key) - self.assertEqual(sign, control_signature) + This test is useful for coverage tests, since it runs the code branches + that deal with error situations. + """ + # Signing needs a private key - def test_sign_rsa_sha1_with_client(self): - base_string = self.control_base_string_rsa_sha1 + for bad_value in [None, '', 'foobar']: + self.assertRaises(ValueError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + MockClient(rsa_key=bad_value)) - self.client.rsa_key = self.rsa_private_key + self.assertRaises(AttributeError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + self.rsa_public_client) # public key doesn't sign - control_signature = self.control_signature_rsa_sha1 + # Verify needs a public key - sign = sign_rsa_sha1_with_client(base_string, self.client) + for bad_value in [None, '', 'foobar', self.rsa_private_client.rsa_key]: + self.assertRaises(TypeError, + verify_rsa_sha1, + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + self.expected_signature_rsa_sha1), + MockClient(rsa_key=bad_value)) - self.assertEqual(sign, control_signature) + # For completeness, this text could repeat the above for RSA-SHA256 and + # RSA-SHA512 signing and verification functions. - self.client.decode() ## Decode `rsa_private_key` from UTF-8 + def test_rsa_jwt_algorithm_cache(self): + # Tests cache of RSAAlgorithm objects is implemented correctly. - sign = sign_rsa_sha1_with_client(base_string, self.client) + # This is difficult to test, since the cache is internal. + # + # Running this test with coverage will show the cache-hit branch of code + # being executed by two signing operations with the same hash algorithm. - self.assertEqual(sign, control_signature) + self.test_sign_rsa_sha1_with_client() # creates cache entry + self.test_sign_rsa_sha1_with_client() # reuses cache entry + # Some possible bugs will be detected if multiple signing operations + # with different hash algorithms produce the wrong results (e.g. if the + # cache incorrectly returned the previously used algorithm, instead + # of the one that is needed). - control_signature_plaintext = ( - "ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ&" - "just-a-string%20%20%20%20asdasd") + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha1_with_client() + self.test_sign_rsa_sha256_with_client() + self.test_sign_rsa_sha512_with_client() - def test_sign_plaintext(self): - """ """ + # ==== PLAINTEXT signature method tests ========================== - self.assertRaises(ValueError, sign_plaintext, self.client_secret, - self.resource_owner_secret) - sign = sign_plaintext(self.client_secret.decode('utf-8'), - self.resource_owner_secret.decode('utf-8')) - self.assertEqual(sign, self.control_signature_plaintext) + plaintext_client = hmac_client # for convenience, use the same HMAC secrets + expected_signature_plaintext = ( + 'ECrDNoq1VYzzzzzzzzzyAK7TwZNtPnkqatqZZZZ' + '&' + 'just-a-string%20%20%20%20asdasd') def test_sign_plaintext_with_client(self): - self.assertRaises(ValueError, sign_plaintext_with_client, - None, self.client) - - self.client.decode() - - sign = sign_plaintext_with_client(None, self.client) + # With PLAINTEXT, the "signature" is always the same: regardless of the + # contents of the request. It is the concatenation of the encoded + # client_secret, an ampersand, and the encoded resource_owner_secret. + # + # That is why the spaces in the resource owner secret are "%20". + + self.assertEqual(self.expected_signature_plaintext, + sign_plaintext_with_client(None, # request is ignored + self.plaintext_client)) + self.assertTrue(verify_plaintext( + MockRequest('PUT', + 'http://example.com/some-other-path', + [('description', 'request is ignored in PLAINTEXT')], + self.expected_signature_plaintext), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) + + def test_plaintext_false_positives(self): + """ + Test verify_plaintext function will correctly detect invalid signatures. + """ - self.assertEqual(sign, self.control_signature_plaintext) + _ros = self.plaintext_client.resource_owner_secret + + good_signature = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + self.plaintext_client) + + bad_signature_produced_by_different_client_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret='wrong-secret', + resource_owner_secret=_ros)) + bad_signature_produced_by_different_resource_owner_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret=self.plaintext_client.client_secret, + resource_owner_secret='wrong-secret')) + + bad_signature_produced_with_no_resource_owner_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(client_secret=self.plaintext_client.client_secret)) + bad_signature_produced_with_no_client_secret = \ + sign_plaintext_with_client( + self.eg_signature_base_string, + MockClient(resource_owner_secret=_ros)) + + self.assertTrue(verify_plaintext( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + good_signature), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) + + for bad_signature in [ + '', + 'ZG9uJ3QgdHJ1c3QgbWUK', # random base64 encoded value + 'altérer', # value with a non-ASCII character in it + bad_signature_produced_by_different_client_secret, + bad_signature_produced_by_different_resource_owner_secret, + bad_signature_produced_with_no_resource_owner_secret, + bad_signature_produced_with_no_client_secret, + ]: + self.assertFalse(verify_plaintext( + MockRequest('POST', + 'http://example.com/request', + self.eg_params, + bad_signature), + self.plaintext_client.client_secret, + self.plaintext_client.resource_owner_secret)) -- cgit v1.2.1