diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79689a8..e2b951a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] steps: - name: Checkout uses: actions/checkout@v2 @@ -36,16 +36,13 @@ jobs: strategy: fail-fast: false matrix: - python: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] old_cryptography: [''] extra_name: [''] include: - python: '3.8' old_cryptography: 2.6.1 extra_name: ', cryptography 2.6.1' - - python: pypy2 - old_cryptography: '' - extra_name: ', PyPy 2' - python: pypy3 old_cryptography: '' extra_name: ', PyPy 3' @@ -70,7 +67,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['2.7', '3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10.0-beta.2'] steps: - name: Checkout uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index 7cdbc85..ea0e967 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ Vital statistics **Bug tracker and source code:** https://github.com/python-trio/trustme -**Tested on:** Python 2.7 and Python 3.5+, CPython and PyPy +**Tested on:** Python 3.6+, CPython and PyPy **License:** MIT or Apache 2, your choice. diff --git a/ci.sh b/ci.sh index 4a4dc3e..0edaa83 100755 --- a/ci.sh +++ b/ci.sh @@ -12,11 +12,7 @@ python -m pip install dist/*.zip # Actual tests -if [[ $(python -c 'import sys; print(sys.version_info < (3,))') = 'True' ]]; then - python -m pip install -Ur test-requirements.txt -else - python -m pip install -Ur test-requirements-py3.txt -fi +python -m pip install -Ur test-requirements.txt if [ -n "${OLD_CRYPTOGRAPHY:-}" ]; then python -m pip install cryptography=="${OLD_CRYPTOGRAPHY}" fi diff --git a/docs/source/index.rst b/docs/source/index.rst index e201f48..68acea6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,9 +11,9 @@ works. It demonstrates a simple TLS server and client that connect to each other using :mod:`trustme`\-generated certs. This example requires `Trio `__ (``pip -install -U trio``) and Python 3.5+. Note that while :mod:`trustme` is +install -U trio``) and Python 3.6+. Note that while :mod:`trustme` is maintained by the Trio project, :mod:`trustme` is happy to work with -any networking library, and also supports Python 2. +any networking library. The key lines are the calls to :meth:`~CA.configure_trust`, :meth:`~LeafCert.configure_cert` – try commenting them out one at a diff --git a/lint-requirements.in b/lint-requirements.in index 0a07786..438bf3a 100644 --- a/lint-requirements.in +++ b/lint-requirements.in @@ -1,6 +1,6 @@ -# Typing -mypy[python2]==0.910 +mypy==0.910 # TODO: Switch to cryptography>=35.0.0 once it's released. types-cryptography>=3.3.3 types-pyopenssl>=20.0.4 -py>=1.9.0 +pytest>=6.2 +idna>=3.2 diff --git a/lint-requirements.txt b/lint-requirements.txt index 318506c..20ddc9e 100644 --- a/lint-requirements.txt +++ b/lint-requirements.txt @@ -1,17 +1,37 @@ # -# This file is autogenerated by pip-compile with python 3.9 +# This file is autogenerated by pip-compile with python 3.6 # To update, run: # # pip-compile lint-requirements.in # -mypy[python2]==0.910 +attrs==21.2.0 + # via pytest +idna==3.2 + # via -r lint-requirements.in +importlib-metadata==4.6.4 + # via + # pluggy + # pytest +iniconfig==1.1.1 + # via pytest +mypy==0.910 # via -r lint-requirements.in mypy-extensions==0.4.3 # via mypy +packaging==21.0 + # via pytest +pluggy==0.13.1 + # via pytest py==1.10.0 + # via pytest +pyparsing==2.4.7 + # via packaging +pytest==6.2.4 # via -r lint-requirements.in toml==0.10.2 - # via mypy + # via + # mypy + # pytest typed-ast==1.4.3 # via mypy types-cryptography==3.3.5 @@ -25,4 +45,8 @@ types-ipaddress==0.1.5 types-pyopenssl==20.0.5 # via -r lint-requirements.in typing-extensions==3.10.0.0 - # via mypy + # via + # importlib-metadata + # mypy +zipp==3.5.0 + # via importlib-metadata diff --git a/lint.sh b/lint.sh index dd36cf5..dd62118 100755 --- a/lint.sh +++ b/lint.sh @@ -14,4 +14,3 @@ python -m pip install -Ur lint-requirements.txt # Linting mypy trustme tests -mypy -2 trustme tests diff --git a/newsfragments/346.removal.rst b/newsfragments/346.removal.rst new file mode 100644 index 0000000..ab3a3c3 --- /dev/null +++ b/newsfragments/346.removal.rst @@ -0,0 +1 @@ +Remove support for Python 2. trustme now requires Python>=3.6 (CPython or PyPy). diff --git a/pyproject.toml b/pyproject.toml index c539fb2..18ac678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,4 @@ warn_redundant_casts = true warn_return_any = true warn_unreachable = true warn_unused_configs = true -# Some ignores are only for python2/python3. -# warn_unused_ignores = true - -[[tool.mypy.overrides]] -module = "pytest" -ignore_missing_imports = true +warn_unused_ignores = true diff --git a/setup.py b/setup.py index 3be6929..1a0663a 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,6 @@ # cryptography depends on both of these too, so we should declare our # dependencies to be accurate, but they don't actually cost anything: "idna", - "ipaddress; python_version < '3.3'", ], classifiers=[ "Development Status :: 4 - Beta", @@ -31,9 +30,8 @@ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", diff --git a/test-requirements-py3.txt b/test-requirements-py3.txt deleted file mode 100644 index 60a07c8..0000000 --- a/test-requirements-py3.txt +++ /dev/null @@ -1,63 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file=test-requirements-py3.txt test-requirements.in -# -attrs==21.2.0 - # via - # pytest - # service-identity -cffi==1.14.5 - # via cryptography -coverage==5.5 - # via pytest-cov -cryptography==3.4.7 - # via - # -r test-requirements.in - # pyopenssl - # service-identity -importlib-metadata==4.5.0 - # via - # pluggy - # pytest -iniconfig==1.1.1 - # via pytest -packaging==20.9 - # via pytest -pluggy==0.13.1 - # via pytest -py==1.10.0 - # via pytest -pyasn1-modules==0.2.8 - # via service-identity -pyasn1==0.4.8 - # via - # pyasn1-modules - # service-identity -pycparser==2.20 - # via cffi -pyopenssl==20.0.1 - # via -r test-requirements.in -pyparsing==2.4.7 - # via packaging -pytest-cov==2.12.1 - # via -r test-requirements.in -pytest==6.2.4 - # via - # -r test-requirements.in - # pytest-cov -service-identity==21.1.0 - # via -r test-requirements.in -six==1.16.0 - # via - # pyopenssl - # service-identity -toml==0.10.2 - # via - # pytest - # pytest-cov -typing-extensions==3.10.0.0 - # via importlib-metadata -zipp==3.4.1 - # via importlib-metadata diff --git a/test-requirements.in b/test-requirements.in index 9abd93a..bad44a0 100644 --- a/test-requirements.in +++ b/test-requirements.in @@ -1,15 +1,6 @@ -pytest +pytest>=6.2 pytest-cov PyOpenSSL service-identity cryptography -# Those are the last version with py2 support -# and pip-compile won't let us pin it just on py2, so we have to pin it -# everywhere -more-itertools==5.0.0; python_version < "3" -zipp<2.0; python_version < "3" -idna<3; python_version < "3" -# Really only needed on py2, but again, pip-compile doesn't handle -# environment markers well, so we install it everywhere and on py3 it -# just doesn't get used. -futures; python_version < "3.2" +idna diff --git a/test-requirements.txt b/test-requirements.txt index c1eceb7..74cf2f9 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,97 +1,65 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.6 # To update, run: # # pip-compile test-requirements.in # -atomicwrites==1.4.0 - # via pytest attrs==21.2.0 # via # pytest # service-identity -backports.functools-lru-cache==1.6.4 - # via wcwidth -cffi==1.14.5 +cffi==1.14.6 # via cryptography -configparser==4.0.2 - # via importlib-metadata -contextlib2==0.6.0.post1 - # via - # importlib-metadata - # zipp coverage==5.5 # via pytest-cov -cryptography==2.9.2 +cryptography==3.4.8 # via # -r test-requirements.in # pyopenssl # service-identity -enum34==1.1.10 - # via cryptography -funcsigs==1.0.2 - # via pytest -futures==3.1.1 ; python_version < "3.2" - # via -r test-requirements.in -idna==2.10 ; python_version < "3" +idna==3.2 # via -r test-requirements.in -importlib-metadata==2.0.0 +importlib-metadata==4.6.4 # via # pluggy # pytest -ipaddress==1.0.23 - # via - # cryptography - # service-identity -more-itertools==5.0.0 ; python_version < "3" - # via - # -r test-requirements.in - # pytest -packaging==20.9 +iniconfig==1.1.1 + # via pytest +packaging==21.0 # via pytest -pathlib2==2.3.5 - # via - # importlib-metadata - # pytest pluggy==0.13.1 # via pytest py==1.10.0 # via pytest -pyasn1-modules==0.2.8 - # via service-identity pyasn1==0.4.8 # via # pyasn1-modules # service-identity +pyasn1-modules==0.2.8 + # via service-identity pycparser==2.20 # via cffi -pyopenssl==19.1.0 +pyopenssl==20.0.1 # via -r test-requirements.in pyparsing==2.4.7 # via packaging -pytest-cov==2.12.1 - # via -r test-requirements.in -pytest==4.6.3 +pytest==6.2.4 # via # -r test-requirements.in # pytest-cov -scandir==1.10.0 - # via pathlib2 +pytest-cov==2.12.1 + # via -r test-requirements.in service-identity==21.1.0 # via -r test-requirements.in six==1.16.0 # via - # cryptography - # more-itertools - # pathlib2 # pyopenssl - # pytest # service-identity toml==0.10.2 - # via pytest-cov -wcwidth==0.2.5 - # via pytest -zipp==1.2.0 ; python_version < "3" # via - # -r test-requirements.in - # importlib-metadata + # pytest + # pytest-cov +typing-extensions==3.10.0.0 + # via importlib-metadata +zipp==3.5.0 + # via importlib-metadata diff --git a/tests/test_cli.py b/tests/test_cli.py index 3881dab..86a9222 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import subprocess import sys @@ -8,13 +6,8 @@ from trustme._cli import main -TYPE_CHECKING = False -if TYPE_CHECKING: # pragma: no cover - from typing import Any - -def test_trustme_cli(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): main(argv=[]) @@ -23,8 +16,7 @@ def test_trustme_cli(tmpdir): assert tmpdir.join("client.pem").check(exists=1) -def test_trustme_cli_e2e(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_e2e(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): rv = subprocess.call([sys.executable, "-m", "trustme"]) assert rv == 0 @@ -34,8 +26,7 @@ def test_trustme_cli_e2e(tmpdir): assert tmpdir.join("client.pem").check(exists=1) -def test_trustme_cli_directory(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_directory(tmpdir: py.path.local) -> None: subdir = tmpdir.mkdir("sub") main(argv=["-d", str(subdir)]) @@ -44,15 +35,13 @@ def test_trustme_cli_directory(tmpdir): assert subdir.join("client.pem").check(exists=1) -def test_trustme_cli_directory_does_not_exist(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_directory_does_not_exist(tmpdir: py.path.local) -> None: notdir = tmpdir.join("notdir") with pytest.raises(ValueError, match="is not a directory"): main(argv=["-d", str(notdir)]) -def test_trustme_cli_identities(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_identities(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): main(argv=["-i", "example.org", "www.example.org"]) @@ -61,14 +50,12 @@ def test_trustme_cli_identities(tmpdir): assert tmpdir.join("client.pem").check(exists=1) -def test_trustme_cli_identities_empty(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_identities_empty(tmpdir: py.path.local) -> None: with pytest.raises(ValueError, match="at least one identity"): main(argv=["-i"]) -def test_trustme_cli_common_name(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_common_name(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): main(argv=["--common-name", "localhost"]) @@ -77,8 +64,7 @@ def test_trustme_cli_common_name(tmpdir): assert tmpdir.join("client.pem").check(exists=1) -def test_trustme_cli_expires_on(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_expires_on(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): main(argv=["--expires-on", "2035-03-01"]) @@ -87,8 +73,7 @@ def test_trustme_cli_expires_on(tmpdir): assert tmpdir.join("client.pem").check(exists=1) -def test_trustme_cli_invalid_expires_on(tmpdir): - # type: (py.path.local) -> None +def test_trustme_cli_invalid_expires_on(tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): with pytest.raises(ValueError, match="does not match format"): main(argv=["--expires-on", "foobar"]) @@ -98,8 +83,7 @@ def test_trustme_cli_invalid_expires_on(tmpdir): assert tmpdir.join("client.pem").check(exists=0) -def test_trustme_cli_quiet(capsys, tmpdir): - # type: (Any, py.path.local) -> None +def test_trustme_cli_quiet(capsys: pytest.CaptureFixture[str], tmpdir: py.path.local) -> None: with tmpdir.as_cwd(): main(argv=["-q"]) diff --git a/tests/test_trustme.py b/tests/test_trustme.py index f74b409..c85c42f 100644 --- a/tests/test_trustme.py +++ b/tests/test_trustme.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import py import pytest @@ -8,9 +6,9 @@ import socket import threading import datetime -from concurrent.futures import ThreadPoolExecutor # type: ignore[import] - +from concurrent.futures import ThreadPoolExecutor from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network +from typing import Callable, Optional, Union from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -23,21 +21,16 @@ import trustme from trustme import CA, LeafCert -TYPE_CHECKING = False -if TYPE_CHECKING: # pragma: no cover - from typing import Callable, Optional, Text, Union - SslSocket = Union[ssl.SSLSocket, OpenSSL.SSL.Connection] +SslSocket = Union[ssl.SSLSocket, OpenSSL.SSL.Connection] -def _path_length(ca_cert): - # type: (x509.Certificate) -> Optional[int] +def _path_length(ca_cert: x509.Certificate) -> Optional[int]: bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) return bc.value.path_length -def assert_is_ca(ca_cert): - # type: (x509.Certificate) -> None +def assert_is_ca(ca_cert: x509.Certificate) -> None: bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) assert bc.value.ca is True assert bc.critical is True @@ -51,8 +44,7 @@ def assert_is_ca(ca_cert): ca_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) -def assert_is_leaf(leaf_cert): - # type: (x509.Certificate) -> None +def assert_is_leaf(leaf_cert: x509.Certificate) -> None: bc = leaf_cert.extensions.get_extension_for_class(x509.BasicConstraints) assert bc.value.ca is False assert bc.critical is True @@ -73,8 +65,7 @@ def assert_is_leaf(leaf_cert): assert eku.critical is True -def test_basics(): - # type: () -> None +def test_basics() -> None: ca = CA() today = datetime.datetime.today() @@ -101,7 +92,7 @@ def test_basics(): with pytest.raises(ValueError): ca.issue_cert() - server = ca.issue_cert(u"test-1.example.org", u"test-2.example.org") + server = ca.issue_cert("test-1.example.org", "test-2.example.org") assert b"PRIVATE KEY" in server.private_key_pem.bytes() assert b"BEGIN CERTIFICATE" in server.cert_chain_pems[0].bytes() @@ -119,14 +110,13 @@ def test_basics(): san = server_cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) hostnames = san.value.get_values_for_type(x509.DNSName) - assert hostnames == [u"test-1.example.org", u"test-2.example.org"] + assert hostnames == ["test-1.example.org", "test-2.example.org"] -def test_ca_custom_names(): - # type: () -> None +def test_ca_custom_names() -> None: ca = CA( - organization_name=u'python-trio', - organization_unit_name=u'trustme', + organization_name='python-trio', + organization_unit_name='trustme', ) ca_cert = x509.load_pem_x509_certificate( @@ -143,13 +133,12 @@ def test_ca_custom_names(): }) -def test_issue_cert_custom_names(): - # type: () -> None +def test_issue_cert_custom_names() -> None: ca = CA() leaf_cert = ca.issue_cert( - u'example.org', - organization_name=u'python-trio', - organization_unit_name=u'trustme', + 'example.org', + organization_name='python-trio', + organization_unit_name='trustme', ) cert = x509.load_pem_x509_certificate( @@ -166,16 +155,15 @@ def test_issue_cert_custom_names(): }) -def test_issue_cert_custom_not_after(): - # type: () -> None +def test_issue_cert_custom_not_after() -> None: now = datetime.datetime.now() expires = datetime.datetime(2025, 12, 1, 8, 10, 10) ca = CA() leaf_cert = ca.issue_cert( - u'example.org', - organization_name=u'python-trio', - organization_unit_name=u'trustme', + 'example.org', + organization_name='python-trio', + organization_unit_name='trustme', not_after=expires, ) @@ -188,8 +176,7 @@ def test_issue_cert_custom_not_after(): assert getattr(cert.not_valid_after, t) == getattr(expires, t) -def test_intermediate(): - # type: () -> None +def test_intermediate() -> None: ca = CA() ca_cert = x509.load_pem_x509_certificate( ca.cert_pem.bytes(), default_backend()) @@ -204,7 +191,7 @@ def test_intermediate(): assert child_ca_cert.issuer == ca_cert.subject assert _path_length(child_ca_cert) == 8 - child_server = child_ca.issue_cert(u"test-host.example.org") + child_server = child_ca.issue_cert("test-host.example.org") assert len(child_server.cert_chain_pems) == 2 child_server_cert = x509.load_pem_x509_certificate( child_server.cert_chain_pems[0].bytes(), default_backend()) @@ -212,8 +199,7 @@ def test_intermediate(): assert_is_leaf(child_server_cert) -def test_path_length(): - # type: () -> None +def test_path_length() -> None: ca = CA() ca_cert = x509.load_pem_x509_certificate( ca.cert_pem.bytes(), default_backend()) @@ -231,10 +217,9 @@ def test_path_length(): child_ca.create_child_ca() -def test_unrecognized_context_type(): - # type: () -> None +def test_unrecognized_context_type() -> None: ca = CA() - server = ca.issue_cert(u"test-1.example.org") + server = ca.issue_cert("test-1.example.org") with pytest.raises(TypeError): ca.configure_trust(None) # type: ignore[arg-type] @@ -243,8 +228,7 @@ def test_unrecognized_context_type(): server.configure_cert(None) # type: ignore[arg-type] -def test_blob(tmpdir): - # type: (py.path.local) -> None +def test_blob(tmpdir: py.path.local) -> None: test_data = b"xyzzy" b = trustme.Blob(test_data) @@ -279,19 +263,19 @@ def test_blob(tmpdir): with open(path, "rb") as f: assert f.read() == test_data -def test_ca_from_pem(tmpdir): - # type: (py.path.local) -> None +def test_ca_from_pem(tmpdir: py.path.local) -> None: ca1 = trustme.CA() ca2 = trustme.CA.from_pem(ca1.cert_pem.bytes(), ca1.private_key_pem.bytes()) assert ca1._certificate == ca2._certificate assert ca1.private_key_pem.bytes() == ca2.private_key_pem.bytes() -def check_connection_end_to_end(wrap_client, wrap_server): - # type: (Callable[[CA, socket.socket, Text], SslSocket], Callable[[LeafCert, socket.socket], SslSocket]) -> None +def check_connection_end_to_end( + wrap_client: Callable[[CA, socket.socket, str], SslSocket], + wrap_server: Callable[[LeafCert, socket.socket], SslSocket], +) -> None: # Client side - def fake_ssl_client(ca, raw_client_sock, hostname): - # type: (CA, socket.socket, Text) -> None + def fake_ssl_client(ca: CA, raw_client_sock: socket.socket, hostname: str) -> None: try: wrapped_client_sock = wrap_client(ca, raw_client_sock, hostname) # Send and receive some data to prove the connection is good @@ -305,8 +289,7 @@ def fake_ssl_client(ca, raw_client_sock, hostname): raw_client_sock.close() # Server side - def fake_ssl_server(server_cert, raw_server_sock): - # type: (LeafCert, socket.socket) -> None + def fake_ssl_server(server_cert: LeafCert, raw_server_sock: socket.socket) -> None: try: wrapped_server_sock = wrap_server(server_cert, raw_server_sock) # Prove that we're connected @@ -319,8 +302,7 @@ def fake_ssl_server(server_cert, raw_server_sock): finally: raw_server_sock.close() - def doit(ca, hostname, server_cert): - # type: (CA, Text, LeafCert) -> None + def doit(ca: CA, hostname: str, server_cert: LeafCert) -> None: # socketpair and ssl don't work together on py2, because... reasons. # So we need to do this the hard way. listener = socket.socket() @@ -338,7 +320,7 @@ def doit(ca, hostname, server_cert): ca = CA() intermediate_ca = ca.create_child_ca() - hostname = u"my-test-host.example.org" + hostname = "my-test-host.example.org" # Should work doit(ca, hostname, ca.issue_cert(hostname)) @@ -352,7 +334,7 @@ def doit(ca, hostname, server_cert): # Bad hostname fails with pytest.raises(Exception): - doit(ca, u"asdf.example.org", ca.issue_cert(hostname)) + doit(ca, "asdf.example.org", ca.issue_cert(hostname)) # Bad CA fails bad_ca = CA() @@ -360,15 +342,12 @@ def doit(ca, hostname, server_cert): doit(bad_ca, hostname, ca.issue_cert(hostname)) -def test_stdlib_end_to_end(): - # type: () -> None - def wrap_client(ca, raw_client_sock, hostname): - # type: (CA, socket.socket, Text) -> ssl.SSLSocket +def test_stdlib_end_to_end() -> None: + def wrap_client(ca: CA, raw_client_sock: socket.socket, hostname: str) -> ssl.SSLSocket: ctx = ssl.create_default_context() ca.configure_trust(ctx) - # Type ignore for Python 2: wants str, got unicode, but I guess unicode also works. wrapped_client_sock = ctx.wrap_socket( - raw_client_sock, server_hostname=hostname) # type: ignore[arg-type] + raw_client_sock, server_hostname=hostname) print("Client got server cert:", wrapped_client_sock.getpeercert()) peercert = wrapped_client_sock.getpeercert() assert peercert is not None @@ -376,8 +355,7 @@ def wrap_client(ca, raw_client_sock, hostname): assert san == (("DNS", "my-test-host.example.org"),) return wrapped_client_sock - def wrap_server(server_cert, raw_server_sock): - # type: (LeafCert, socket.socket) -> ssl.SSLSocket + def wrap_server(server_cert: LeafCert, raw_server_sock: socket.socket) -> ssl.SSLSocket: ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) server_cert.configure_cert(ctx) wrapped_server_sock = ctx.wrap_socket( @@ -388,10 +366,8 @@ def wrap_server(server_cert, raw_server_sock): check_connection_end_to_end(wrap_client, wrap_server) -def test_pyopenssl_end_to_end(): - # type: () -> None - def wrap_client(ca, raw_client_sock, hostname): - # type: (CA, socket.socket, Text) -> OpenSSL.SSL.Connection +def test_pyopenssl_end_to_end() -> None: + def wrap_client(ca: CA, raw_client_sock: socket.socket, hostname: str) -> OpenSSL.SSL.Connection: # Cribbed from example at # https://service-identity.readthedocs.io/en/stable/api.html#service_identity.pyopenssl.verify_hostname ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) @@ -404,8 +380,7 @@ def wrap_client(ca, raw_client_sock, hostname): service_identity.pyopenssl.verify_hostname(conn, hostname) return conn - def wrap_server(server_cert, raw_server_sock): - # type: (LeafCert, socket.socket) -> OpenSSL.SSL.Connection + def wrap_server(server_cert: LeafCert, raw_server_sock: socket.socket) -> OpenSSL.SSL.Connection: ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) server_cert.configure_cert(ctx) @@ -417,8 +392,7 @@ def wrap_server(server_cert, raw_server_sock): check_connection_end_to_end(wrap_client, wrap_server) -def test_identity_variants(): - # type: () -> None +def test_identity_variants() -> None: ca = CA() for bad in [b"example.org", bytearray(b"example.org"), 123]: @@ -427,48 +401,48 @@ def test_identity_variants(): cases = { # Traditional ascii hostname - u"example.org": x509.DNSName(u"example.org"), + "example.org": x509.DNSName("example.org"), # Wildcard - u"*.example.org": x509.DNSName(u"*.example.org"), + "*.example.org": x509.DNSName("*.example.org"), # IDN - u"éxamplë.org": x509.DNSName(u"xn--xampl-9rat.org"), - u"xn--xampl-9rat.org": x509.DNSName(u"xn--xampl-9rat.org"), + "éxamplë.org": x509.DNSName("xn--xampl-9rat.org"), + "xn--xampl-9rat.org": x509.DNSName("xn--xampl-9rat.org"), # IDN + wildcard - u"*.éxamplë.org": x509.DNSName(u"*.xn--xampl-9rat.org"), - u"*.xn--xampl-9rat.org": x509.DNSName(u"*.xn--xampl-9rat.org"), + "*.éxamplë.org": x509.DNSName("*.xn--xampl-9rat.org"), + "*.xn--xampl-9rat.org": x509.DNSName("*.xn--xampl-9rat.org"), # IDN that acts differently in IDNA-2003 vs IDNA-2008 - u"faß.de": x509.DNSName(u"xn--fa-hia.de"), - u"xn--fa-hia.de": x509.DNSName(u"xn--fa-hia.de"), + "faß.de": x509.DNSName("xn--fa-hia.de"), + "xn--fa-hia.de": x509.DNSName("xn--fa-hia.de"), # IDN with non-permissable character (uppercase K) # (example taken from idna package docs) - u"Königsgäßchen.de": x509.DNSName(u"xn--knigsgchen-b4a3dun.de"), + "Königsgäßchen.de": x509.DNSName("xn--knigsgchen-b4a3dun.de"), # IP addresses - u"127.0.0.1": x509.IPAddress(IPv4Address(u"127.0.0.1")), - u"::1": x509.IPAddress(IPv6Address(u"::1")), + "127.0.0.1": x509.IPAddress(IPv4Address("127.0.0.1")), + "::1": x509.IPAddress(IPv6Address("::1")), # Check normalization - u"0000::1": x509.IPAddress(IPv6Address(u"::1")), + "0000::1": x509.IPAddress(IPv6Address("::1")), # IP networks - u"127.0.0.0/24": x509.IPAddress(IPv4Network(u"127.0.0.0/24")), - u"2001::/16": x509.IPAddress(IPv6Network(u"2001::/16")), + "127.0.0.0/24": x509.IPAddress(IPv4Network("127.0.0.0/24")), + "2001::/16": x509.IPAddress(IPv6Network("2001::/16")), # Check normalization - u"2001:0000::/16": x509.IPAddress(IPv6Network(u"2001::/16")), + "2001:0000::/16": x509.IPAddress(IPv6Network("2001::/16")), # Email address - u"example@example.com": x509.RFC822Name(u"example@example.com"), + "example@example.com": x509.RFC822Name("example@example.com"), } for hostname, expected in cases.items(): # Can't repr the got or expected values here, at least until # cryptography v2.1 is out, because in v2.0 on py2, DNSName.__repr__ # blows up on IDNs. - print("testing: {!r}".format(hostname)) + print(f"testing: {hostname!r}") pem = ca.issue_cert(hostname).cert_chain_pems[0].bytes() cert = x509.load_pem_x509_certificate(pem, default_backend()) san = cert.extensions.get_extension_for_class( @@ -479,28 +453,21 @@ def test_identity_variants(): assert got == expected -def test_backcompat(): - # type: () -> None +def test_backcompat() -> None: ca = CA() # We can still use the old name - ca.issue_server_cert(u"example.com") + ca.issue_server_cert("example.com") -def test_CN(): - # type: () -> None +def test_CN() -> None: ca = CA() - # Since we have to emulate kwonly args here, I guess we should test the - # emulation logic - with pytest.raises(TypeError): - ca.issue_cert(comon_nam=u"wrong kwarg name") # type: ignore[call-arg] - - # Must be unicode + # Must be str with pytest.raises(TypeError): ca.issue_cert(common_name=b"bad kwarg value") # type: ignore[arg-type] # Default is no common name - pem = ca.issue_cert(u"example.com").cert_chain_pems[0].bytes() + pem = ca.issue_cert("example.com").cert_chain_pems[0].bytes() cert = x509.load_pem_x509_certificate(pem, default_backend()) common_names = cert.subject.get_attributes_for_oid( x509.oid.NameOID.COMMON_NAME @@ -508,21 +475,21 @@ def test_CN(): assert common_names == [] # Common name on its own is valid - pem = ca.issue_cert(common_name=u"woo").cert_chain_pems[0].bytes() + pem = ca.issue_cert(common_name="woo").cert_chain_pems[0].bytes() cert = x509.load_pem_x509_certificate(pem, default_backend()) common_names = cert.subject.get_attributes_for_oid( x509.oid.NameOID.COMMON_NAME ) - assert common_names[0].value == u"woo" + assert common_names[0].value == "woo" # Common name + SAN - pem = ca.issue_cert(u"example.com", common_name=u"woo").cert_chain_pems[0].bytes() + pem = ca.issue_cert("example.com", common_name="woo").cert_chain_pems[0].bytes() cert = x509.load_pem_x509_certificate(pem, default_backend()) san = cert.extensions.get_extension_for_class( x509.SubjectAlternativeName ) - assert list(san.value)[0] == x509.DNSName(u"example.com") + assert list(san.value)[0] == x509.DNSName("example.com") common_names = cert.subject.get_attributes_for_oid( x509.oid.NameOID.COMMON_NAME ) - assert common_names[0].value == u"woo" + assert common_names[0].value == "woo" diff --git a/trustme/__init__.py b/trustme/__init__.py index cc280b0..8bc55a3 100644 --- a/trustme/__init__.py +++ b/trustme/__init__.py @@ -1,14 +1,13 @@ -# -*- coding: utf-8 -*- - import datetime +import ipaddress +import os import ssl from base64 import urlsafe_b64encode -from tempfile import NamedTemporaryFile from contextlib import contextmanager -import os +from tempfile import NamedTemporaryFile +from typing import Generator, List, Optional, Union -import ipaddress -import idna # type: ignore[import] +import idna from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -25,18 +24,10 @@ TYPE_CHECKING = False if TYPE_CHECKING: # pragma: no cover - from typing import Generator, List, Optional, Text, Union - import OpenSSL.SSL __all__ = ["CA"] -# Python 2/3 annoyingness -try: - unicode -except NameError: - unicode = str - # On my laptop, making a CA + server certificate using 2048 bit keys takes ~160 # ms, and using 4096 bit keys takes ~2 seconds. We want tests to run in 160 ms, # not 2 seconds. And we can't go lower, since Debian (and probably others) @@ -52,12 +43,11 @@ # https://github.com/pyca/cryptography/pull/4658 DEFAULT_EXPIRY = datetime.datetime(2038, 1, 1) -def _name(name, organization_name=None, common_name=None): - # type: (Text, Optional[Text], Optional[Text]) -> x509.Name +def _name(name: str, organization_name: Optional[str] = None, common_name: Optional[str] = None) -> x509.Name: name_pieces = [ x509.NameAttribute( NameOID.ORGANIZATION_NAME, - organization_name or u"trustme v{}".format(__version__), + organization_name or f"trustme v{__version__}", ), x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, name), ] @@ -68,18 +58,20 @@ def _name(name, organization_name=None, common_name=None): return x509.Name(name_pieces) -def random_text(): - # type: () -> Text +def random_text() -> str: return urlsafe_b64encode(os.urandom(12)).decode("ascii") -def _smells_like_pyopenssl(ctx): - # type: (object) -> bool +def _smells_like_pyopenssl(ctx: object) -> bool: return getattr(ctx, "__module__", "").startswith("OpenSSL") # type: ignore[no-any-return] -def _cert_builder_common(subject, issuer, public_key, not_after=None): - # type: (x509.Name, x509.Name, rsa.RSAPublicKey, Optional[datetime.datetime]) -> x509.CertificateBuilder +def _cert_builder_common( + subject: x509.Name, + issuer: x509.Name, + public_key: rsa.RSAPublicKey, + not_after: Optional[datetime.datetime] = None, +) -> x509.CertificateBuilder: not_after = not_after if not_after else DEFAULT_EXPIRY return ( x509.CertificateBuilder() @@ -96,8 +88,7 @@ def _cert_builder_common(subject, issuer, public_key, not_after=None): ) -def _identity_string_to_x509(identity): - # type: (Text) -> x509.GeneralName +def _identity_string_to_x509(identity: str) -> x509.GeneralName: # Because we are a DWIM library for lazy slackers, we cheerfully pervert # the cryptography library's carefully type-safe API, and silently DTRT # for any of the following identity types: @@ -114,10 +105,10 @@ def _identity_string_to_x509(identity): # - "example@example.org" # # plus wildcard variants of the identities. - if not isinstance(identity, unicode): - raise TypeError("identities must be text (unicode on py2, str on py3)") + if not isinstance(identity, str): + raise TypeError("identities must be str") - if u"@" in identity: + if "@" in identity: return x509.RFC822Name(identity) # Have to try ip_address first, because ip_network("127.0.0.1") is @@ -142,7 +133,7 @@ def _identity_string_to_x509(identity): return x509.DNSName(alabel) -class Blob(object): +class Blob: """A convenience wrapper for a blob of bytes. This type has no public constructor. They're used to provide a handy @@ -150,19 +141,16 @@ class Blob(object): `CA.cert_pem` or `LeafCert.private_key_and_cert_chain_pem`. """ - def __init__(self, data): - # type: (bytes) -> None + def __init__(self, data: bytes) -> None: self._data = data - def bytes(self): - # type: () -> bytes + def bytes(self) -> bytes: """Returns the data as a `bytes` object. """ return self._data - def write_to_path(self, path, append=False): - # type: (str, bool) -> None + def write_to_path(self, path: str, append: bool = False) -> None: """Writes the data to the file at the given path. Args: @@ -179,8 +167,7 @@ def write_to_path(self, path, append=False): f.write(self._data) @contextmanager - def tempfile(self, dir=None): - # type: (Optional[str]) -> Generator[str, None, None] + def tempfile(self, dir: Optional[str] = None) -> Generator[str, None, None]: """Context manager for writing data to a temporary file. The file is created when you enter the context manager, and @@ -210,9 +197,7 @@ def tempfile(self, dir=None): # open. Which seems like it completely defeats the purpose of having a # NamedTemporaryFile? Oh well... # https://bugs.python.org/issue14243 - # Type ignore temporarily needed for Python 2: - # https://github.com/python/typeshed/pull/5836 - f = NamedTemporaryFile(suffix=".pem", dir=dir, delete=False) # type: ignore[arg-type] + f = NamedTemporaryFile(suffix=".pem", dir=dir, delete=False) try: f.write(self._data) f.close() @@ -222,19 +207,17 @@ def tempfile(self, dir=None): os.unlink(f.name) -class CA(object): +class CA: """A certificate authority.""" - - _certificate = None # type: x509.Certificate + _certificate: x509.Certificate def __init__( self, - parent_cert=None, - path_length=9, - organization_name=None, - organization_unit_name=None, - ): - # type: (Optional[CA], int, Optional[Text], Optional[Text]) -> None + parent_cert: Optional["CA"] = None, + path_length: int = 9, + organization_name: Optional[str] = None, + organization_unit_name: Optional[str] = None, + ) -> None: self.parent_cert = parent_cert self._private_key = rsa.generate_private_key( public_exponent=65537, @@ -244,7 +227,7 @@ def __init__( self._path_length = path_length name = _name( - organization_unit_name or u"Testing CA #" + random_text(), + organization_unit_name or "Testing CA #" + random_text(), organization_name=organization_name, ) issuer = name @@ -281,15 +264,13 @@ def __init__( ) @property - def cert_pem(self): - # type: () -> Blob + def cert_pem(self) -> Blob: """`Blob`: The PEM-encoded certificate for this CA. Add this to your trust store to trust this CA.""" return Blob(self._certificate.public_bytes(Encoding.PEM)) @property - def private_key_pem(self): - # type: () -> Blob + def private_key_pem(self) -> Blob: """`Blob`: The PEM-encoded private key for this CA. Use this to sign other certificates from this CA.""" return Blob( @@ -300,8 +281,7 @@ def private_key_pem(self): ) ) - def create_child_ca(self): - # type: () -> CA + def create_child_ca(self) -> "CA": """Creates a child certificate authority Returns: @@ -316,18 +296,17 @@ def create_child_ca(self): path_length = self._path_length - 1 return CA(parent_cert=self, path_length=path_length) - def issue_cert(self, *identities, **kwargs): - # type: (Text, Optional[Union[Text, datetime.datetime]]) -> LeafCert - # PY3: (str, Optional[str], Optional[str], Optional[str], Optional[datetime.datetime]) -> LeafCert - """issue_cert(*identities, common_name=None, organization_name=None, \ - organization_unit_name=None, not_after=None) - - Issues a certificate. The certificate can be used for either servers + def issue_cert( + self, + *identities: str, + common_name: Optional[str] = None, + organization_name: Optional[str] = None, + organization_unit_name: Optional[str] = None, + not_after: Optional[datetime.datetime] = None, + ) -> "LeafCert": + """Issues a certificate. The certificate can be used for either servers or clients. - All arguments must be text strings (``unicode`` on Python 2, ``str`` - on Python 3). - Args: identities: The identities that this certificate will be valid for. Most commonly, these are just hostnames, but we accept any of the @@ -369,13 +348,6 @@ def issue_cert(self, *identities, **kwargs): LeafCert: the newly-generated certificate. """ - common_name = kwargs.pop("common_name", None) # type: Optional[Text] # type: ignore[assignment] - organization_name = kwargs.pop("organization_name", None) # type: Optional[Text] # type: ignore[assignment] - organization_unit_name = kwargs.pop("organization_unit_name", None) # type: Optional[Text] # type: ignore[assignment] - not_after = kwargs.pop("not_after", None) # type: Optional[datetime.datetime] # type: ignore[assignment] - if kwargs: - raise TypeError("unrecognized keyword arguments {}".format(kwargs)) - if not identities and common_name is None: raise ValueError( "Must specify at least one identity or common name" @@ -402,7 +374,7 @@ def issue_cert(self, *identities, **kwargs): cert = ( _cert_builder_common( _name( - organization_unit_name or u"Testing cert #" + random_text(), + organization_unit_name or "Testing cert #" + random_text(), organization_name=organization_name, common_name=common_name, ), @@ -468,8 +440,7 @@ def issue_cert(self, *identities, **kwargs): # For backwards compatibility issue_server_cert = issue_cert - def configure_trust(self, ctx): - # type: (Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None + def configure_trust(self, ctx: Union[ssl.SSLContext, "OpenSSL.SSL.Context"]) -> None: """Configure the given context object to trust certificates signed by this CA. @@ -493,8 +464,7 @@ def configure_trust(self, ctx): .format(ctx.__class__.__name__)) @classmethod - def from_pem(cls, cert_bytes, private_key_bytes): - # type: (bytes, bytes) -> CA + def from_pem(cls, cert_bytes: bytes, private_key_bytes: bytes) -> "CA": """Build a CA from existing cert and private key. This is useful if your test suite has an existing certificate authority and @@ -513,7 +483,7 @@ def from_pem(cls, cert_bytes, private_key_bytes): return ca -class LeafCert(object): +class LeafCert: """A server or client certificate. This type has no public constructor; you get one by calling @@ -532,16 +502,14 @@ class LeafCert(object): cert chain. """ - def __init__(self, private_key_pem, server_cert_pem, chain_to_ca): - # type: (bytes, bytes, List[bytes]) -> None + def __init__(self, private_key_pem: bytes, server_cert_pem: bytes, chain_to_ca: List[bytes]) -> None: self.private_key_pem = Blob(private_key_pem) self.cert_chain_pems = [ Blob(pem) for pem in [server_cert_pem] + chain_to_ca] self.private_key_and_cert_chain_pem = ( Blob(private_key_pem + server_cert_pem + b''.join(chain_to_ca))) - def configure_cert(self, ctx): - # type: (Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None + def configure_cert(self, ctx: Union[ssl.SSLContext, "OpenSSL.SSL.Context"]) -> None: """Configure the given context object to present this certificate. Args: diff --git a/trustme/__main__.py b/trustme/__main__.py index 3705535..84a45d4 100644 --- a/trustme/__main__.py +++ b/trustme/__main__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from ._cli import main main() diff --git a/trustme/_cli.py b/trustme/_cli.py index 7c08d2c..fc0aea1 100644 --- a/trustme/_cli.py +++ b/trustme/_cli.py @@ -1,27 +1,15 @@ -# -*- coding: utf-8 -*- - import argparse import os import trustme import sys - +from typing import List, Optional from datetime import datetime -TYPE_CHECKING = False -if TYPE_CHECKING: # pragma: no cover - from typing import List, Optional - -# Python 2/3 annoyingness -try: - unicode -except NameError: # pragma: no cover - unicode = str # ISO 8601 DATE_FORMAT = '%Y-%m-%d' -def main(argv=None): - # type: (Optional[List[str]]) -> None +def main(argv: Optional[List[str]] = None) -> None: if argv is None: argv = sys.argv[1:] @@ -61,13 +49,13 @@ def main(argv=None): args = parser.parse_args(argv) cert_dir = args.dir - identities = [unicode(identity) for identity in args.identities] - common_name = unicode(args.common_name[0]) if args.common_name else None + identities = [str(identity) for identity in args.identities] + common_name = str(args.common_name[0]) if args.common_name else None expires_on = None if args.expires_on is None else datetime.strptime(args.expires_on, DATE_FORMAT) quiet = args.quiet if not os.path.isdir(cert_dir): - raise ValueError("--dir={} is not a directory".format(cert_dir)) + raise ValueError(f"--dir={cert_dir} is not a directory") if len(identities) < 1: raise ValueError("Must include at least one identity") @@ -90,9 +78,9 @@ def main(argv=None): if not quiet: idents = "', '".join(identities) - print("Generated a certificate for '{}'".format(idents)) + print(f"Generated a certificate for '{idents}'") print("Configure your server to use the following files:") - print(" cert={}".format(server_cert)) - print(" key={}".format(server_key)) + print(f" cert={server_cert}") + print(f" key={server_key}") print("Configure your client to use the following files:") - print(" cert={}".format(client_cert)) + print(f" cert={client_cert}")