diff --git a/CHANGELOG.md b/CHANGELOG.md index fa83b972..77a1d1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [3.0.3.dev0](https://github.com/httpie/httpie/compare/3.0.2...HEAD) (Unreleased) +- Added support for specifying certificate private key passphrases through `--cert-key-pass` and prompts. ([#946](https://github.com/httpie/httpie/issues/946)) - Fixed escaping of integer indexes with multiple backslashes in the nested JSON builder. ([#1285](https://github.com/httpie/httpie/issues/1285)) - Fixed displaying of status code without a status message on non-`auto` themes. ([#1300](https://github.com/httpie/httpie/issues/1300)) - Fixed redundant issuance of stdin detection warnings on some rare cases due to underlying implementation. ([#1303](https://github.com/httpie/httpie/pull/1303)) diff --git a/docs/README.md b/docs/README.md index b5d139aa..00aff4b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1489,6 +1489,21 @@ path of the key file with `--cert-key`: $ http --cert=client.crt --cert-key=client.key https://example.org ``` +If the given private key requires a passphrase, HTTPie will automatically detect it +and ask it through a prompt: + +```bash +$ http --cert=client.pem --cert-key=client.key https://example.org +http: passphrase for client.key: **** +``` + +If you don't want to see a prompt, you can supply the passphrase with the `--cert-key-pass` +argument: + +```bash +$ http --cert=client.pem --cert-key=client.key --cert-key-pass=my_password https://example.org +``` + ### SSL version Use the `--ssl=` option to specify the desired protocol version to use. diff --git a/httpie/cli/argparser.py b/httpie/cli/argparser.py index b1ab8de1..f9d6674b 100644 --- a/httpie/cli/argparser.py +++ b/httpie/cli/argparser.py @@ -10,7 +10,8 @@ from urllib.parse import urlsplit from requests.utils import get_netrc_auth from .argtypes import ( - AuthCredentials, KeyValueArgType, PARSED_DEFAULT_FORMAT_OPTIONS, + AuthCredentials, SSLCredentials, KeyValueArgType, + PARSED_DEFAULT_FORMAT_OPTIONS, parse_auth, parse_format_options, ) @@ -148,6 +149,7 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser): self._parse_items() self._process_url() self._process_auth() + self._process_ssl_cert() if self.args.raw is not None: self._body_from_input(self.args.raw) @@ -236,6 +238,19 @@ class HTTPieArgumentParser(BaseHTTPieArgumentParser): self.env.stdout = self.env.devnull self.env.apply_warnings_filter() + def _process_ssl_cert(self): + from httpie.ssl_ import _is_key_file_encrypted + + if self.args.cert_key_pass is None: + self.args.cert_key_pass = SSLCredentials(None) + + if ( + self.args.cert_key is not None + and self.args.cert_key_pass.value is None + and _is_key_file_encrypted(self.args.cert_key) + ): + self.args.cert_key_pass.prompt_password(self.args.cert_key) + def _process_auth(self): # TODO: refactor & simplify this method. self.args.auth_plugin = None diff --git a/httpie/cli/argtypes.py b/httpie/cli/argtypes.py index 7bc487ea..8f19c3c5 100644 --- a/httpie/cli/argtypes.py +++ b/httpie/cli/argtypes.py @@ -130,16 +130,11 @@ class KeyValueArgType: return tokens -class AuthCredentials(KeyValueArg): - """Represents parsed credentials.""" - - def has_password(self) -> bool: - return self.value is not None - - def prompt_password(self, host: str): - prompt_text = f'http: password for {self.key}@{host}: ' +class PromptMixin: + def _prompt_password(self, prompt: str) -> str: + prompt_text = f'http: {prompt}: ' try: - self.value = self._getpass(prompt_text) + return self._getpass(prompt_text) except (EOFError, KeyboardInterrupt): sys.stderr.write('\n') sys.exit(0) @@ -150,6 +145,26 @@ class AuthCredentials(KeyValueArg): return getpass.getpass(str(prompt)) +class SSLCredentials(PromptMixin): + """Represents the passphrase for the certificate's key.""" + + def __init__(self, value: Optional[str]) -> None: + self.value = value + + def prompt_password(self, key_file: str) -> None: + self.value = self._prompt_password(f'passphrase for {key_file}') + + +class AuthCredentials(KeyValueArg, PromptMixin): + """Represents parsed credentials.""" + + def has_password(self) -> bool: + return self.value is not None + + def prompt_password(self, host: str) -> None: + self.value = self._prompt_password(f'password for {self.key}@{host}:') + + class AuthCredentialsArgType(KeyValueArgType): """A key-value arg type that parses credentials.""" diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 5ccc3a16..0a0efafa 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -8,7 +8,7 @@ from textwrap import dedent, wrap from .. import __doc__, __version__ from .argparser import HTTPieArgumentParser from .argtypes import ( - KeyValueArgType, SessionNameValidator, + KeyValueArgType, SessionNameValidator, SSLCredentials, readable_file_arg, response_charset_type, response_mime_type, ) from .constants import ( @@ -803,6 +803,17 @@ ssl.add_argument( ''' ) +ssl.add_argument( + '--cert-key-pass', + default=None, + type=SSLCredentials, + help=''' + The passphrase to be used to with the given private key. Only needed if --cert-key + is given and the key file requires a passphrase. + + ''' +) + ####################################################################### # Troubleshooting ####################################################################### diff --git a/httpie/client.py b/httpie/client.py index 06235d24..1984537c 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -19,7 +19,7 @@ from .encoding import UTF8 from .models import RequestsMessage from .plugins.registry import plugin_manager from .sessions import get_httpie_session -from .ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieHTTPSAdapter +from .ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, HTTPieCertificate, HTTPieHTTPSAdapter from .uploads import ( compress_request, prepare_request_body, get_multipart_data_and_content_type, @@ -262,7 +262,14 @@ def make_send_kwargs_mergeable_from_env(args: argparse.Namespace) -> dict: if args.cert: cert = args.cert if args.cert_key: - cert = cert, args.cert_key + # Having a client certificate key passphrase is not supported + # by requests. So we are using our own transportation structure + # which is compatible with their format (a tuple of minimum two + # items). + # + # See: https://github.com/psf/requests/issues/2519 + cert = HTTPieCertificate(cert, args.cert_key, args.cert_key_pass.value) + return { 'proxies': {p.key: p.value for p in args.proxy}, 'stream': True, diff --git a/httpie/ssl_.py b/httpie/ssl_.py index cdec18f8..b9438543 100644 --- a/httpie/ssl_.py +++ b/httpie/ssl_.py @@ -1,4 +1,5 @@ import ssl +from typing import NamedTuple, Optional from httpie.adapters import HTTPAdapter # noinspection PyPackageRequirements @@ -24,6 +25,17 @@ AVAILABLE_SSL_VERSION_ARG_MAPPING = { } +class HTTPieCertificate(NamedTuple): + cert_file: Optional[str] = None + key_file: Optional[str] = None + key_password: Optional[str] = None + + def to_raw_cert(self): + """Synthesize a requests-compatible (2-item tuple of cert and key file) + object from HTTPie's internal representation of a certificate.""" + return (self.cert_file, self.key_file) + + class HTTPieHTTPSAdapter(HTTPAdapter): def __init__( self, @@ -47,6 +59,13 @@ class HTTPieHTTPSAdapter(HTTPAdapter): kwargs['ssl_context'] = self._ssl_context return super().proxy_manager_for(*args, **kwargs) + def cert_verify(self, conn, url, verify, cert): + if isinstance(cert, HTTPieCertificate): + conn.key_password = cert.key_password + cert = cert.to_raw_cert() + + return super().cert_verify(conn, url, verify, cert) + @staticmethod def _create_ssl_context( verify: bool, @@ -61,3 +80,17 @@ class HTTPieHTTPSAdapter(HTTPAdapter): # in `super().cert_verify()`. cert_reqs=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE ) + + +def _is_key_file_encrypted(key_file): + """Detects if a key file is encrypted or not. + + Copy of the internal urllib function (urllib3.util.ssl_)""" + + with open(key_file, "r") as f: + for line in f: + # Look for Proc-Type: 4,ENCRYPTED + if "ENCRYPTED" in line: + return True + + return False diff --git a/tests/client_certs/password_protected/client.key b/tests/client_certs/password_protected/client.key new file mode 100644 index 00000000..1634352f --- /dev/null +++ b/tests/client_certs/password_protected/client.key @@ -0,0 +1,42 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,93DA845817852FB576163AA829C232E9 + +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 +-----END RSA PRIVATE KEY----- diff --git a/tests/client_certs/password_protected/client.pem b/tests/client_certs/password_protected/client.pem new file mode 100644 index 00000000..08ee6522 --- /dev/null +++ b/tests/client_certs/password_protected/client.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEazCCAtOgAwIBAgIUIWojJySwCenyvcJrZY1aKOMQfTUwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjAzMDExMzM5MzRaFw00OTA3 +MTcxMzM5MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEB +AQUAA4IBjwAwggGKAoIBgQC0+8GOIhknLgLmWEQGgdDJffntbDfGdtG7JFUFfvuN +sibTHL4RPNhe+UrT/LR+JBfIfmdeb+NlmKzsTeR0+8PkX5ZjXMShf5icghVukK7G +OoQS7olQqlGzpIX76VqktVr4tFNXmMJeBO0NIaXHm0ASsoz3fIfDx9ttJETPs6lm +Wv/PUPemvtUgcbAb+kjz9QqcUV8B1xcCvQma6NSpxcmJHqAuI6HkdbDzyedKuuSi +M6yNFjh3EJjsufReQgkcfDsqh+RA3zQoIyPXLNqjzGD71z24jUtvIxb5ZNEtv0xp +5zCOCavuRNNyKGvvnlIeyup7bMe0QIds566miG49osVpPVvVmg+q+w2YYAE+7svb +nJp7NYn2tryRqsmvnASLVQD6T9wTWUa8w/tT1+ltnhfqbwDcVACzsw/U4FFwcfWw +5BnUcJacoDkj/3TCqgkA8XFe1/DVU8XCcsvEaoLzwHhHu2+QDpqal8rNouyTpFGA +/wioVBQGpksPZjl8lumsz3kCAwEAAaNTMFEwHQYDVR0OBBYEFGJhl1BPOXCVqRo3 +U/ruuedvlDqsMB8GA1UdIwQYMBaAFGJhl1BPOXCVqRo3U/ruuedvlDqsMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAE9NtrE5qSHlK9DXdH6bPW6z +GO9uWBl3rJVtqVvPoH8RxJG/jtaD/Pnc3MkIxMoliCleNK6BdYVGV9u8x9W1jQo8 +H+mnH3/Ise8ZY1zpblZJF0z9xs5sWW7qO8U06GmJWRSPn3LKEZjLsNmThhUW09wN +8EZX914zCWtzCrUTNg8Au1Dz9zA9ScfpCVPhKORTCnrpoTL6iXsPxmCx+5awmNLE +uh9kw4NScEyq33RTPosMpwSMlXGRuASltx/J7Rn0DNR0r1p0XzDS4CG1iDwXHlEF +MwsOvSahNyz5RInrU3cgN70tafoRIHScLYycnRml8dydxrDoFgdJk5sI4zgq24Sg +TktTq9ShrT4yQX+lrGS6eZQK/YZEBPD7BdTLYp3vlfYQMJ4Jz9SyQ8b9/9jIFVFS +dFfWiCqEuhTvGfptAzYX+K9OaegZnIk3X7R6O+YQ3oHCbLbnV3bpKlgNnOKBwa2X +kJ5GRp+rZOJ97yjrspKjpR5tNCiJnp7NnnA5VA6mfw== +-----END CERTIFICATE----- diff --git a/tests/test_ssl.py b/tests/test_ssl.py index f930bf28..fc587064 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -6,6 +6,8 @@ import pytest_httpbin.certs import requests.exceptions import urllib3 +from unittest import mock + from httpie.ssl_ import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS from httpie.status import ExitStatus @@ -32,6 +34,15 @@ CLIENT_CERT = str(CERTS_ROOT / 'client.crt') CLIENT_KEY = str(CERTS_ROOT / 'client.key') CLIENT_PEM = str(CERTS_ROOT / 'client.pem') +# In case of a regeneration, use the following commands +# in the PWD_TESTS_ROOT: +# $ openssl genrsa -aes128 -passout pass:password 3072 > client.pem +# $ openssl req -new -x509 -nodes -days 10000 -key client.pem > client.pem +PWD_TESTS_ROOT = CERTS_ROOT / 'password_protected' +PWD_CLIENT_PEM = str(PWD_TESTS_ROOT / 'client.pem') +PWD_CLIENT_KEY = str(PWD_TESTS_ROOT / 'client.key') +PWD_CLIENT_PASS = 'password' +PWD_CLIENT_INVALID_PASS = PWD_CLIENT_PASS + 'invalid' # We test against a local httpbin instance which uses a self-signed cert. # Requests without --verify= will fail with a verification error. @@ -165,3 +176,37 @@ def test_pyopenssl_presence(): else: assert urllib3.util.ssl_.IS_PYOPENSSL assert urllib3.util.IS_PYOPENSSL + + +@mock.patch('httpie.cli.argtypes.SSLCredentials._prompt_password', + new=lambda self, prompt: PWD_CLIENT_PASS) +def test_password_protected_cert_prompt(httpbin_secure): + r = http(httpbin_secure + '/get', + '--cert', PWD_CLIENT_PEM, + '--cert-key', PWD_CLIENT_KEY) + assert HTTP_OK in r + + +@mock.patch('httpie.cli.argtypes.SSLCredentials._prompt_password', + new=lambda self, prompt: PWD_CLIENT_INVALID_PASS) +def test_password_protected_cert_prompt_invalid(httpbin_secure): + with pytest.raises(ssl_errors): + http(httpbin_secure + '/get', + '--cert', PWD_CLIENT_PEM, + '--cert-key', PWD_CLIENT_KEY) + + +def test_password_protected_cert_cli_arg(httpbin_secure): + r = http(httpbin_secure + '/get', + '--cert', PWD_CLIENT_PEM, + '--cert-key', PWD_CLIENT_KEY, + '--cert-key-pass', PWD_CLIENT_PASS) + assert HTTP_OK in r + + +def test_password_protected_cert_cli_arg_invalid(httpbin_secure): + with pytest.raises(ssl_errors): + http(httpbin_secure + '/get', + '--cert', PWD_CLIENT_PEM, + '--cert-key', PWD_CLIENT_KEY, + '--cert-key-pass', PWD_CLIENT_INVALID_PASS)