├── newsfragments └── .gitkeep ├── src └── trustme │ ├── py.typed │ ├── _version.py │ ├── __main__.py │ ├── _cli.py │ └── __init__.py ├── docs ├── source │ ├── _static │ │ └── .gitkeep │ ├── _templates │ │ └── need-help.html │ ├── trustme-trio-example.py │ ├── conf.py │ └── index.rst ├── Makefile └── make.bat ├── docs-requirements.in ├── test-requirements.in ├── lint-requirements.in ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release.yml │ └── ci.yml ├── .readthedocs.yml ├── noxfile.py ├── .gitignore ├── LICENSE.MIT ├── test-requirements.txt ├── docs-requirements.txt ├── lint-requirements.txt ├── pyproject.toml ├── tests ├── test_cli.py └── test_trustme.py ├── README.rst └── LICENSE.APACHE2 /newsfragments/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/trustme/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/trustme/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.1+dev" 2 | -------------------------------------------------------------------------------- /src/trustme/__main__.py: -------------------------------------------------------------------------------- 1 | from ._cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /docs-requirements.in: -------------------------------------------------------------------------------- 1 | sphinxcontrib_trio 2 | cryptography 3 | idna 4 | -------------------------------------------------------------------------------- /test-requirements.in: -------------------------------------------------------------------------------- 1 | pytest>=6.2 2 | coverage[toml] 3 | PyOpenSSL 4 | service-identity 5 | cryptography 6 | idna 7 | -------------------------------------------------------------------------------- /lint-requirements.in: -------------------------------------------------------------------------------- 1 | mypy 2 | cryptography>=35.0.0 3 | types-pyopenssl>=20.0.4 4 | pytest>=6.2 5 | idna>=3.2 6 | black 7 | isort 8 | nox 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | The Trio code of conduct applies to this project. See: 2 | https://trio.readthedocs.io/en/latest/code-of-conduct.html 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This is an official Trio project. For the Trio contributing guide, 2 | see: 3 | https://trio.readthedocs.io/en/latest/contributing.html 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is made available under the terms of *either* of the 2 | licenses found in LICENSE.APACHE2 or LICENSE.MIT. Contributions to 3 | are made under the terms of *both* these licenses. 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE LICENSE.MIT LICENSE.APACHE2 2 | include README.rst CODE_OF_CONDUCT.md 3 | include test-requirements.txt 4 | recursive-include docs * 5 | recursive-include tests * 6 | prune docs/build 7 | -------------------------------------------------------------------------------- /docs/source/_templates/need-help.html: -------------------------------------------------------------------------------- 1 |
2 |

Need help?

3 | Try chat or StackOverflow. 5 |
-------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | versioning-strategy: lockfile-only 9 | allow: 10 | - dependency-type: direct 11 | - dependency-type: indirect 12 | groups: 13 | dependencies: 14 | patterns: 15 | - "*" 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/latest/config-file/index.html 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3" 8 | 9 | python: 10 | install: 11 | - requirements: docs-requirements.txt 12 | - method: pip 13 | path: . 14 | 15 | sphinx: 16 | fail_on_warning: true 17 | configuration: docs/source/conf.py -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "dependabot/**" 7 | pull_request: 8 | 9 | env: 10 | FORCE_COLOR: "1" 11 | 12 | jobs: 13 | Lint: 14 | name: 'Lint' 15 | timeout-minutes: 10 16 | runs-on: 'ubuntu-latest' 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Setup python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.x' 24 | - name: Run lint 25 | run: | 26 | python -m pip install --upgrade nox 27 | nox -s lint 28 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = trustme 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=trustme 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import nox 4 | 5 | 6 | @nox.session() 7 | def lint(session: nox.Session) -> None: 8 | session.install("-r", "lint-requirements.txt") 9 | LINT_PATHS = ("src/trustme", "tests", "noxfile.py") 10 | session.run("black", *LINT_PATHS) 11 | session.run("isort", "--profile", "black", *LINT_PATHS) 12 | session.run("mypy", *LINT_PATHS) 13 | 14 | 15 | @nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "pypy3"]) 16 | def test(session: nox.Session) -> None: 17 | session.install(".", "-r", "test-requirements.txt") 18 | session.run( 19 | "coverage", 20 | "run", 21 | "--parallel-mode", 22 | "-m", 23 | "pytest", 24 | "-W", 25 | "error", 26 | "-ra", 27 | "-s", 28 | *(session.posargs or ("tests/",)), 29 | ) 30 | if os.environ.get("CI") != "true": 31 | session.run("coverage", "combine") 32 | session.run("coverage", "report", "-m") 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project-specific generated files 2 | docs/build/ 3 | 4 | bench/results/ 5 | bench/env/ 6 | bench/trio/ 7 | 8 | # Byte-compiled / optimized / DLL files / editor temp files 9 | __pycache__/ 10 | *.py[cod] 11 | *~ 12 | \#* 13 | .#* 14 | *.swp 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | /build/ 22 | /develop-eggs/ 23 | /dist/ 24 | /eggs/ 25 | /lib/ 26 | /lib64/ 27 | /parts/ 28 | /sdist/ 29 | /var/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | /.pybuild 34 | 35 | # Installer logs 36 | pip-log.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .venv/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | .pytest_cache/ 46 | nosetests.xml 47 | coverage.xml 48 | 49 | # Translations 50 | *.mo 51 | 52 | # Mr Developer 53 | .mr.developer.cfg 54 | .project 55 | .pydevproject 56 | 57 | # Rope 58 | .ropeproject 59 | 60 | # Django stuff: 61 | *.log 62 | *.pot 63 | 64 | # Sphinx documentation 65 | doc/_build/ 66 | 67 | # pyenv 68 | .python-version 69 | -------------------------------------------------------------------------------- /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile test-requirements.in 6 | # 7 | attrs==25.4.0 8 | # via service-identity 9 | cffi==2.0.0 10 | # via cryptography 11 | coverage[toml]==7.12.0 12 | # via -r test-requirements.in 13 | cryptography==46.0.3 14 | # via 15 | # -r test-requirements.in 16 | # pyopenssl 17 | # service-identity 18 | exceptiongroup==1.3.1 19 | # via pytest 20 | idna==3.11 21 | # via -r test-requirements.in 22 | iniconfig==2.3.0 23 | # via pytest 24 | packaging==25.0 25 | # via pytest 26 | pluggy==1.6.0 27 | # via pytest 28 | pyasn1==0.6.1 29 | # via 30 | # pyasn1-modules 31 | # service-identity 32 | pyasn1-modules==0.4.2 33 | # via service-identity 34 | pycparser==2.23 35 | # via cffi 36 | pygments==2.19.2 37 | # via pytest 38 | pyopenssl==25.3.0 39 | # via -r test-requirements.in 40 | pytest==9.0.2 41 | # via -r test-requirements.in 42 | service-identity==24.2.0 43 | # via -r test-requirements.in 44 | tomli==2.3.0 45 | # via 46 | # coverage 47 | # pytest 48 | typing-extensions==4.15.0 49 | # via 50 | # cryptography 51 | # exceptiongroup 52 | # pyopenssl 53 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --resolver=backtracking docs-requirements.in 6 | # 7 | alabaster==0.7.16 8 | # via sphinx 9 | babel==2.17.0 10 | # via sphinx 11 | certifi==2025.11.12 12 | # via requests 13 | cffi==2.0.0 14 | # via cryptography 15 | charset-normalizer==3.4.4 16 | # via requests 17 | cryptography==46.0.3 18 | # via -r docs-requirements.in 19 | docutils==0.21.2 20 | # via sphinx 21 | idna==3.11 22 | # via 23 | # -r docs-requirements.in 24 | # requests 25 | imagesize==1.4.1 26 | # via sphinx 27 | jinja2==3.1.6 28 | # via sphinx 29 | markupsafe==3.0.3 30 | # via jinja2 31 | packaging==25.0 32 | # via sphinx 33 | pycparser==2.23 34 | # via cffi 35 | pygments==2.19.2 36 | # via sphinx 37 | requests==2.32.5 38 | # via sphinx 39 | snowballstemmer==3.0.1 40 | # via sphinx 41 | sphinx==8.1.3 42 | # via sphinxcontrib-trio 43 | sphinxcontrib-applehelp==2.0.0 44 | # via sphinx 45 | sphinxcontrib-devhelp==2.0.0 46 | # via sphinx 47 | sphinxcontrib-htmlhelp==2.1.0 48 | # via sphinx 49 | sphinxcontrib-jsmath==1.0.1 50 | # via sphinx 51 | sphinxcontrib-qthelp==2.0.0 52 | # via sphinx 53 | sphinxcontrib-serializinghtml==2.0.0 54 | # via sphinx 55 | sphinxcontrib-trio==1.1.2 56 | # via -r docs-requirements.in 57 | tomli==2.3.0 58 | # via sphinx 59 | typing-extensions==4.15.0 60 | # via cryptography 61 | urllib3==2.6.0 62 | # via requests 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | name: Build dists 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 19 | - name: Setup python 20 | uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 21 | with: 22 | python-version: 3.x 23 | - name: Install dependencies 24 | run: python -m pip install build 25 | - name: Build dists 26 | run: python -m build 27 | - name: Upload dists 28 | uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 29 | with: 30 | name: "dist" 31 | path: "dist/" 32 | if-no-files-found: error 33 | retention-days: 5 34 | 35 | pypi-publish: 36 | name: Upload release to PyPI 37 | if: startsWith(github.ref, 'refs/tags/') 38 | needs: [build] 39 | runs-on: ubuntu-latest 40 | environment: release 41 | permissions: 42 | id-token: write # Needed for trusted publishing to PyPI 43 | 44 | steps: 45 | - name: Download dists 46 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 47 | with: 48 | name: "dist" 49 | path: "dist/" 50 | - name: Publish package distributions to PyPI 51 | uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3 52 | -------------------------------------------------------------------------------- /lint-requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --universal lint-requirements.in 3 | argcomplete==3.6.3 4 | # via nox 5 | attrs==25.4.0 6 | # via nox 7 | black==25.9.0 8 | # via -r lint-requirements.in 9 | cffi==2.0.0 10 | # via cryptography 11 | click==8.3.1 12 | # via black 13 | colorlog==6.10.1 14 | # via nox 15 | cryptography==46.0.3 16 | # via 17 | # -r lint-requirements.in 18 | # types-pyopenssl 19 | dependency-groups==1.3.1 20 | # via nox 21 | distlib==0.4.0 22 | # via virtualenv 23 | exceptiongroup==1.3.1 24 | # via pytest 25 | filelock==3.20.0 26 | # via virtualenv 27 | humanize==4.14.0 28 | # via nox 29 | idna==3.11 30 | # via -r lint-requirements.in 31 | iniconfig==2.3.0 32 | # via pytest 33 | isort==7.0.0 34 | # via -r lint-requirements.in 35 | librt==0.7.3 36 | # via mypy 37 | mypy==1.19.0 38 | # via -r lint-requirements.in 39 | mypy-extensions==1.1.0 40 | # via 41 | # black 42 | # mypy 43 | nox==2025.11.12 44 | # via -r lint-requirements.in 45 | packaging==25.0 46 | # via 47 | # black 48 | # dependency-groups 49 | # nox 50 | # pytest 51 | pathspec==0.12.1 52 | # via 53 | # black 54 | # mypy 55 | platformdirs==4.5.1 56 | # via 57 | # black 58 | # virtualenv 59 | pluggy==1.6.0 60 | # via pytest 61 | pycparser==2.23 62 | # via cffi 63 | pygments==2.19.2 64 | # via pytest 65 | pytest==9.0.2 66 | # via -r lint-requirements.in 67 | pytokens==0.3.0 68 | # via black 69 | tomli==2.3.0 70 | # via 71 | # black 72 | # dependency-groups 73 | # mypy 74 | # nox 75 | # pytest 76 | types-cffi==1.17.0.20250915 77 | # via types-pyopenssl 78 | types-pyopenssl==24.1.0.20240722 79 | # via -r lint-requirements.in 80 | types-setuptools==80.9.0.20250822 81 | # via types-cffi 82 | typing-extensions==4.15.0 83 | # via 84 | # black 85 | # cryptography 86 | # exceptiongroup 87 | # mypy 88 | # virtualenv 89 | virtualenv==20.35.4 90 | # via nox 91 | -------------------------------------------------------------------------------- /docs/source/trustme-trio-example.py: -------------------------------------------------------------------------------- 1 | # trustme-trio-example.py 2 | 3 | import trustme 4 | import trio 5 | import ssl 6 | 7 | # Create our fake certificates 8 | ca = trustme.CA() 9 | server_cert = ca.issue_cert("test-host.example.org") 10 | client_cert = ca.issue_cert("client@example.org") 11 | 12 | 13 | async def demo_server(server_raw_stream): 14 | server_ssl_context = ssl.create_default_context( 15 | ssl.Purpose.CLIENT_AUTH) 16 | 17 | # Set up the server's SSLContext to use our fake server cert 18 | server_cert.configure_cert(server_ssl_context) 19 | 20 | # Set up the server's SSLContext to trust our fake CA, that signed 21 | # our client cert, so that it can validate client's cert. 22 | ca.configure_trust(server_ssl_context) 23 | 24 | # Verify that client sent us their TLS cert signed by a trusted CA 25 | server_ssl_context.verify_mode = ssl.CERT_REQUIRED 26 | 27 | server_ssl_stream = trio.SSLStream( 28 | server_raw_stream, 29 | server_ssl_context, 30 | server_side=True, 31 | ) 32 | 33 | # Send some data to check that the connection is really working 34 | await server_ssl_stream.send_all(b"x") 35 | print("Server successfully sent data over the encrypted channel!") 36 | print("Client cert looks like:", server_ssl_stream.getpeercert()) 37 | 38 | 39 | async def demo_client(client_raw_stream): 40 | client_ssl_context = ssl.create_default_context() 41 | 42 | # Set up the client's SSLContext to trust our fake CA, that signed 43 | # our server cert, so that it can validate server's cert. 44 | ca.configure_trust(client_ssl_context) 45 | 46 | # Set up the client's SSLContext to use our fake client cert 47 | client_cert.configure_cert(client_ssl_context) 48 | 49 | client_ssl_stream = trio.SSLStream( 50 | client_raw_stream, 51 | client_ssl_context, 52 | # Tell the client that it's looking for a trusted cert for this 53 | # particular hostname (must match what we passed to issue_cert) 54 | server_hostname="test-host.example.org", 55 | ) 56 | 57 | assert await client_ssl_stream.receive_some(1) == b"x" 58 | print("Client successfully received data over the encrypted channel!") 59 | print("Server cert looks like:", client_ssl_stream.getpeercert()) 60 | 61 | 62 | async def main(): 63 | from trio.testing import memory_stream_pair 64 | server_raw_stream, client_raw_stream = memory_stream_pair() 65 | 66 | async with trio.open_nursery() as nursery: 67 | nursery.start_soon(demo_server, server_raw_stream) 68 | nursery.start_soon(demo_client, client_raw_stream) 69 | 70 | 71 | trio.run(main) 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "dependabot/**" 7 | pull_request: 8 | 9 | env: 10 | FORCE_COLOR: "1" 11 | 12 | jobs: 13 | test: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15'] 18 | os: 19 | - macos-latest 20 | - windows-latest 21 | - ubuntu-latest 22 | nox-session: [''] 23 | include: 24 | - python-version: pypy3.11 25 | os: ubuntu-latest 26 | nox-session: test-pypy3 27 | name: ${{ fromJson('{"macos-latest":"macOS","windows-latest":"Windows","ubuntu-latest":"Ubuntu"}')[matrix.os] }} (${{ matrix.python-version }}) 28 | timeout-minutes: 20 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v5 33 | - name: Setup Python ${{ matrix.python-version }}' 34 | uses: actions/setup-python@v6 35 | with: 36 | python-version: '${{ matrix.python-version }}' 37 | allow-prereleases: true 38 | - name: Run tests 39 | run: | 40 | python -m pip install --upgrade nox 41 | nox -s ${NOX_SESSION:-test-$PYTHON_VERSION} 42 | shell: bash 43 | env: 44 | PYTHON_VERSION: ${{ matrix.python-version }} 45 | NOX_SESSION: ${{ matrix.nox-session }} 46 | - name: "Upload coverage data" 47 | uses: "actions/upload-artifact@v4" 48 | with: 49 | name: coverage-data-${{ matrix.os }}-${{ matrix.python-version }} 50 | path: .coverage.* 51 | include-hidden-files: true 52 | if-no-files-found: ignore 53 | 54 | 55 | coverage: 56 | name: Combine & check coverage 57 | if: always() 58 | runs-on: "ubuntu-latest" 59 | needs: test 60 | 61 | steps: 62 | - uses: actions/checkout@v5 63 | - name: "Use latest Python so it understands all syntax" 64 | uses: actions/setup-python@v6 65 | with: 66 | python-version: "3.x" 67 | 68 | - uses: actions/download-artifact@v5 69 | with: 70 | pattern: coverage-data-* 71 | merge-multiple: true 72 | 73 | - name: Combine coverage & fail if it's <100% 74 | run: | 75 | python -Im pip install --upgrade coverage[toml] 76 | 77 | python -Im coverage combine 78 | python -Im coverage html --skip-covered --skip-empty 79 | 80 | # Report and write to summary. 81 | python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 82 | 83 | # Report again and fail if under 100%. 84 | python -Im coverage report --fail-under=100 85 | 86 | - name: Upload HTML report if check failed 87 | uses: actions/upload-artifact@v4 88 | with: 89 | name: html-report 90 | path: htmlcov 91 | if: ${{ failure() }} 92 | -------------------------------------------------------------------------------- /src/trustme/_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | from datetime import datetime 5 | from typing import List, Optional 6 | 7 | import trustme 8 | 9 | # ISO 8601 10 | DATE_FORMAT = "%Y-%m-%d" 11 | 12 | 13 | def main(argv: Optional[List[str]] = None) -> None: 14 | if argv is None: 15 | argv = sys.argv[1:] # pragma: no cover (used in tests) 16 | 17 | parser = argparse.ArgumentParser(prog="trustme") 18 | parser.add_argument( 19 | "-d", 20 | "--dir", 21 | default=os.getcwd(), 22 | help="Directory where certificates and keys are written to. Defaults to cwd.", 23 | ) 24 | parser.add_argument( 25 | "-i", 26 | "--identities", 27 | nargs="*", 28 | default=("localhost", "127.0.0.1", "::1"), 29 | help="Identities for the certificate. Defaults to 'localhost 127.0.0.1 ::1'.", 30 | ) 31 | parser.add_argument( 32 | "--common-name", 33 | nargs=1, 34 | default=None, 35 | help="Also sets the deprecated 'commonName' field (only for the first identity passed).", 36 | ) 37 | parser.add_argument( 38 | "-x", 39 | "--expires-on", 40 | default=None, 41 | help="Set the date the certificate will expire on (in YYYY-MM-DD format).", 42 | metavar="YYYY-MM-DD", 43 | ) 44 | parser.add_argument( 45 | "-q", 46 | "--quiet", 47 | action="store_true", 48 | help="Doesn't print out helpful information for humans.", 49 | ) 50 | parser.add_argument( 51 | "-k", 52 | "--key-type", 53 | choices=list(t.name for t in trustme.KeyType), 54 | default="ECDSA", 55 | ) 56 | 57 | args = parser.parse_args(argv) 58 | cert_dir = args.dir 59 | identities = [str(identity) for identity in args.identities] 60 | common_name = str(args.common_name[0]) if args.common_name else None 61 | expires_on = ( 62 | None 63 | if args.expires_on is None 64 | else datetime.strptime(args.expires_on, DATE_FORMAT) 65 | ) 66 | quiet = args.quiet 67 | key_type = trustme.KeyType[args.key_type] 68 | 69 | if not os.path.isdir(cert_dir): 70 | raise ValueError(f"--dir={cert_dir} is not a directory") 71 | if len(identities) < 1: 72 | raise ValueError("Must include at least one identity") 73 | 74 | # Generate the CA certificate 75 | ca = trustme.CA(key_type=key_type) 76 | cert = ca.issue_cert( 77 | *identities, common_name=common_name, not_after=expires_on, key_type=key_type 78 | ) 79 | 80 | # Write the certificate and private key the server should use 81 | server_key = os.path.join(cert_dir, "server.key") 82 | server_cert = os.path.join(cert_dir, "server.pem") 83 | cert.private_key_pem.write_to_path(path=server_key) 84 | with open(server_cert, mode="w") as f: 85 | f.truncate() 86 | for blob in cert.cert_chain_pems: 87 | blob.write_to_path(path=server_cert, append=True) 88 | 89 | # Write the certificate the client should trust 90 | client_cert = os.path.join(cert_dir, "client.pem") 91 | ca.cert_pem.write_to_path(path=client_cert) 92 | 93 | if not quiet: 94 | idents = "', '".join(identities) 95 | print(f"Generated a certificate for '{idents}'") 96 | print("Configure your server to use the following files:") 97 | print(f" cert={server_cert}") 98 | print(f" key={server_key}") 99 | print("Configure your client to use the following files:") 100 | print(f" cert={client_cert}") 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "trustme" 7 | dynamic = ["version"] 8 | description = "#1 quality TLS certs while you wait, for the discerning tester" 9 | readme = "README.rst" 10 | license = {text = "MIT OR Apache-2.0"} 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Nathaniel J. Smith", email = "njs@pobox.com" }, 14 | ] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: Apache Software License", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: Implementation :: CPython", 21 | "Programming Language :: Python :: Implementation :: PyPy", 22 | "Programming Language :: Python :: Free Threading :: 2 - Beta", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Topic :: Security :: Cryptography", 31 | "Topic :: Software Development :: Testing", 32 | "Topic :: System :: Networking", 33 | ] 34 | dependencies = [ 35 | "cryptography>=3.1", 36 | "idna>=2.0", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/python-trio/trustme" 41 | 42 | [project.scripts] 43 | trustme = "trustme._cli:main" 44 | 45 | [tool.hatch.version] 46 | path = "src/trustme/_version.py" 47 | 48 | 49 | [tool.hatch.build.targets.sdist] 50 | include = [ 51 | "/docs", 52 | "/src", 53 | "/tests", 54 | "/test-requirements.txt", 55 | "/README.rst", 56 | "/LICENSE", 57 | "/LICENSE.APACHE2", 58 | "/LICENSE.MIT", 59 | ] 60 | 61 | 62 | 63 | [tool.towncrier] 64 | # Usage: 65 | # - PRs should drop a file like "issuenumber.feature" in newsfragments 66 | # (or "bugfix", "doc", "removal", "misc"; misc gets no text, we can 67 | # customize this) 68 | # - At release time after bumping version number, run: towncrier 69 | # (or towncrier --draft) 70 | # - Make sure to use a version with the PRs mentioned below merged. 71 | # You probably want https://github.com/hawkowl/towncrier/pull/69 too. 72 | # Right now on my laptop it's 73 | # PYTHONPATH=~/src/towncrier/src ~/src/towncrier/bin/towncrier 74 | # with the merge-64-66-69 branch checked out. 75 | package = "trustme" 76 | package_dir = "src" 77 | filename = "docs/source/index.rst" 78 | directory = "newsfragments" 79 | # Requires https://github.com/hawkowl/towncrier/pull/64 80 | underlines = ["-", "~", "^"] 81 | # Requires https://github.com/hawkowl/towncrier/pull/66 82 | issue_format = "`#{issue} `__" 83 | 84 | [tool.mypy] 85 | check_untyped_defs = true 86 | disallow_any_generics = true 87 | disallow_incomplete_defs = true 88 | disallow_subclassing_any = true 89 | disallow_untyped_calls = true 90 | disallow_untyped_decorators = true 91 | disallow_untyped_defs = true 92 | no_implicit_optional = true 93 | no_implicit_reexport = true 94 | show_error_codes = true 95 | strict_equality = true 96 | warn_redundant_casts = true 97 | warn_return_any = true 98 | warn_unreachable = true 99 | warn_unused_configs = true 100 | warn_unused_ignores = true 101 | 102 | [tool.coverage.run] 103 | branch = true 104 | omit = ["*/trustme/__main__.py"] 105 | source = ["trustme"] 106 | 107 | [tool.coverage.paths] 108 | source = ["src/trustme", "*/trustme", "*\\trustme"] 109 | 110 | [tool.coverage.setup] 111 | precision = 1 112 | exclude_lines = [ 113 | "pragma: no cover.*", 114 | "if TYPE_CHECKING:" 115 | ] 116 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from trustme._cli import main 9 | 10 | 11 | def test_trustme_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 12 | monkeypatch.chdir(tmp_path) 13 | 14 | main(argv=[]) 15 | 16 | assert tmp_path.joinpath("server.key").exists() 17 | assert tmp_path.joinpath("server.pem").exists() 18 | assert tmp_path.joinpath("client.pem").exists() 19 | 20 | 21 | def test_trustme_cli_e2e(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 22 | monkeypatch.chdir(tmp_path) 23 | 24 | rv = subprocess.call([sys.executable, "-m", "trustme"]) 25 | assert rv == 0 26 | 27 | assert tmp_path.joinpath("server.key").exists() 28 | assert tmp_path.joinpath("server.pem").exists() 29 | assert tmp_path.joinpath("client.pem").exists() 30 | 31 | 32 | def test_trustme_cli_directory(tmp_path: Path) -> None: 33 | subdir = tmp_path.joinpath("sub") 34 | subdir.mkdir() 35 | main(argv=["-d", str(subdir)]) 36 | 37 | assert subdir.joinpath("server.key").exists() 38 | assert subdir.joinpath("server.pem").exists() 39 | assert subdir.joinpath("client.pem").exists() 40 | 41 | 42 | def test_trustme_cli_directory_does_not_exist(tmp_path: Path) -> None: 43 | notdir = tmp_path.joinpath("notdir") 44 | with pytest.raises(ValueError, match="is not a directory"): 45 | main(argv=["-d", str(notdir)]) 46 | 47 | 48 | def test_trustme_cli_identities( 49 | tmp_path: Path, monkeypatch: pytest.MonkeyPatch 50 | ) -> None: 51 | monkeypatch.chdir(tmp_path) 52 | 53 | main(argv=["-i", "example.org", "www.example.org"]) 54 | 55 | assert tmp_path.joinpath("server.key").exists() 56 | assert tmp_path.joinpath("server.pem").exists() 57 | assert tmp_path.joinpath("client.pem").exists() 58 | 59 | 60 | def test_trustme_cli_identities_empty(tmp_path: Path) -> None: 61 | with pytest.raises(ValueError, match="at least one identity"): 62 | main(argv=["-i"]) 63 | 64 | 65 | def test_trustme_cli_common_name( 66 | tmp_path: Path, monkeypatch: pytest.MonkeyPatch 67 | ) -> None: 68 | monkeypatch.chdir(tmp_path) 69 | 70 | main(argv=["--common-name", "localhost"]) 71 | 72 | assert tmp_path.joinpath("server.key").exists() 73 | assert tmp_path.joinpath("server.pem").exists() 74 | assert tmp_path.joinpath("client.pem").exists() 75 | 76 | 77 | def test_trustme_cli_expires_on( 78 | tmp_path: Path, monkeypatch: pytest.MonkeyPatch 79 | ) -> None: 80 | monkeypatch.chdir(tmp_path) 81 | 82 | main(argv=["--expires-on", "2035-03-01"]) 83 | 84 | assert tmp_path.joinpath("server.key").exists() 85 | assert tmp_path.joinpath("server.pem").exists() 86 | assert tmp_path.joinpath("client.pem").exists() 87 | 88 | 89 | def test_trustme_cli_invalid_expires_on( 90 | tmp_path: Path, monkeypatch: pytest.MonkeyPatch 91 | ) -> None: 92 | monkeypatch.chdir(tmp_path) 93 | 94 | with pytest.raises(ValueError, match="does not match format"): 95 | main(argv=["--expires-on", "foobar"]) 96 | 97 | assert not tmp_path.joinpath("server.key").exists() 98 | assert not tmp_path.joinpath("server.pem").exists() 99 | assert not tmp_path.joinpath("client.pem").exists() 100 | 101 | 102 | def test_trustme_cli_quiet( 103 | capsys: pytest.CaptureFixture[str], 104 | tmp_path: Path, 105 | monkeypatch: pytest.MonkeyPatch, 106 | ) -> None: 107 | monkeypatch.chdir(tmp_path) 108 | 109 | main(argv=["-q"]) 110 | 111 | assert tmp_path.joinpath("server.key").exists() 112 | assert tmp_path.joinpath("server.pem").exists() 113 | assert tmp_path.joinpath("client.pem").exists() 114 | 115 | captured = capsys.readouterr() 116 | assert not captured.out 117 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. note that this README gets 'include'ed into the main documentation 2 | 3 | ============================================== 4 | trustme: #1 quality TLS certs while you wait 5 | ============================================== 6 | 7 | .. image:: https://vignette2.wikia.nocookie.net/jadensadventures/images/1/1e/Kaa%27s_hypnotic_eyes.jpg/revision/latest?cb=20140310173415 8 | :width: 200px 9 | :align: right 10 | 11 | You wrote a cool network client or server. It encrypts connections 12 | using `TLS 13 | `__. Your test 14 | suite needs to make TLS connections to itself. 15 | 16 | Uh oh. Your test suite *probably* doesn't have a valid TLS 17 | certificate. Now what? 18 | 19 | ``trustme`` is a tiny Python package that does one thing: it gives you 20 | a `fake `__ 21 | certificate authority (CA) that you can use to generate fake TLS certs 22 | to use in your tests. Well, technically they're real certs, they're 23 | just signed by your CA, which nobody trusts. But you can trust 24 | it. Trust me. 25 | 26 | 27 | Vital statistics 28 | ================ 29 | 30 | **Install:** ``pip install -U trustme`` 31 | 32 | **Documentation:** https://trustme.readthedocs.io 33 | 34 | **Bug tracker and source code:** https://github.com/python-trio/trustme 35 | 36 | **Tested on:** Python 3.10+, CPython and PyPy 37 | 38 | **License:** MIT or Apache 2, your choice. 39 | 40 | **Code of conduct:** Contributors are requested to follow our `code of 41 | conduct 42 | `__ 43 | in all project spaces. 44 | 45 | 46 | Cheat sheet 47 | =========== 48 | 49 | Programmatic usage: 50 | 51 | .. code-block:: python 52 | 53 | import trustme 54 | 55 | # ----- Creating certs ----- 56 | 57 | # Look, you just created your certificate authority! 58 | ca = trustme.CA() 59 | 60 | # And now you issued a cert signed by this fake CA 61 | # https://en.wikipedia.org/wiki/Example.org 62 | server_cert = ca.issue_cert("test-host.example.org") 63 | 64 | # That's it! 65 | 66 | # ----- Using your shiny new certs ----- 67 | 68 | # You can configure SSL context objects to trust this CA: 69 | ca.configure_trust(ssl_context) 70 | # Or configure them to present the server certificate 71 | server_cert.configure_cert(ssl_context) 72 | # You can use standard library or PyOpenSSL context objects here, 73 | # trustme is happy either way. 74 | 75 | # ----- or ----- 76 | 77 | # Save the PEM-encoded data to a file to use in non-Python test 78 | # suites: 79 | ca.cert_pem.write_to_path("ca.pem") 80 | server_cert.private_key_and_cert_chain_pem.write_to_path("server.pem") 81 | 82 | # ----- or ----- 83 | 84 | # Put the PEM-encoded data in a temporary file, for libraries that 85 | # insist on that: 86 | with ca.cert_pem.tempfile() as ca_temp_path: 87 | requests.get("https://...", verify=ca_temp_path) 88 | 89 | Command line usage: 90 | 91 | .. code-block:: console 92 | 93 | $ # Certs may be generated from anywhere. Here's where we are: 94 | $ pwd 95 | /tmp 96 | $ # ----- Creating certs ----- 97 | $ python -m trustme 98 | Generated a certificate for 'localhost', '127.0.0.1', '::1' 99 | Configure your server to use the following files: 100 | cert=/tmp/server.pem 101 | key=/tmp/server.key 102 | Configure your client to use the following files: 103 | cert=/tmp/client.pem 104 | $ # ----- Using certs ----- 105 | $ gunicorn --keyfile server.key --certfile server.pem app:app 106 | $ curl --cacert client.pem https://localhost:8000/ 107 | Hello, world! 108 | 109 | 110 | FAQ 111 | === 112 | 113 | **Should I use these certs for anything real?** Certainly not. 114 | 115 | **Why not just use self-signed certificates?** These are more 116 | realistic. You don't have to disable your certificate validation code 117 | in your test suite, which is good because you want to test what you 118 | run in production, and you would *never* disable your certificate 119 | validation code in production, right? Plus, they're just as easy to 120 | work with. Actually easier, in many cases. 121 | 122 | **What if I want to test how my code handles some bizarre TLS 123 | configuration?** We think trustme hits a sweet spot of ease-of-use 124 | and generality as it is. The defaults are carefully chosen to work 125 | on all major operating systems and be as fast as possible. We don't 126 | want to turn trustme into a second-rate re-export of everything in 127 | `cryptography `__. If you have more complex 128 | needs, consider using them directly, possibly starting from the 129 | trustme code. 130 | 131 | **Will you automate installing CA cert into system trust store?** No. 132 | `mkcert `__ already does this 133 | well, and we would not have anything to add. 134 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # trustme documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 18 01:46:01 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | sys.path.insert(0, os.path.abspath('../..')) 22 | 23 | # Warn about all references to unknown targets 24 | nitpicky = True 25 | 26 | html_sidebars = { 27 | "**": [ 28 | "localtoc.html", "relations.html", "searchbox.html", "need-help.html", 29 | ], 30 | } 31 | 32 | # -- General configuration ------------------------------------------------ 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.intersphinx', 44 | #'sphinx.ext.viewcode', 45 | 'sphinx.ext.napoleon', 46 | 'sphinxcontrib_trio', 47 | ] 48 | 49 | intersphinx_mapping = { 50 | "python": ('https://docs.python.org/3', None), 51 | # https://github.com/pyca/pyopenssl/issues/1046 52 | "pyopenssl": ('https://www.pyopenssl.org/en/20.0.1/', None), 53 | "trio": ('https://trio.readthedocs.io/en/latest/', None), 54 | } 55 | 56 | autodoc_member_order = "bysource" 57 | autodoc_typehints = "both" 58 | autodoc_typehints_description_target = "documented" 59 | 60 | # Tell sphinx to treat bare backticks like `foo` as :py:obj:`foo` 61 | default_role = 'py:obj' 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = ['_templates'] 65 | 66 | # The suffix(es) of source filenames. 67 | # You can specify multiple suffix as a list of string: 68 | # 69 | # source_suffix = ['.rst', '.md'] 70 | source_suffix = '.rst' 71 | 72 | # The master toctree document. 73 | master_doc = 'index' 74 | 75 | # General information about the project. 76 | project = 'trustme' 77 | copyright = '2017, Nathaniel J. Smith' 78 | author = 'Nathaniel J. Smith' 79 | 80 | # The version info for the project you're documenting, acts as replacement for 81 | # |version| and |release|, also used in various other places throughout the 82 | # built documents. 83 | # 84 | import trustme 85 | # The short X.Y version. 86 | version = trustme.__version__ 87 | # The full version, including alpha/beta/rc tags. 88 | release = version 89 | 90 | # The language for content autogenerated by Sphinx. Refer to documentation 91 | # for a list of supported languages. 92 | # 93 | # This is also used if you do content translation via gettext catalogs. 94 | # Usually you set "language" from the command line for these cases. 95 | language = "en" 96 | 97 | # List of patterns, relative to source directory, that match files and 98 | # directories to ignore when looking for source files. 99 | # This patterns also effect to html_static_path and html_extra_path 100 | exclude_patterns = [] 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | highlight_language = 'python3' 106 | 107 | 108 | # -- Options for HTML output ---------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | # 113 | html_theme = 'alabaster' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | # 119 | # html_theme_options = {} 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | 127 | # -- Options for HTMLHelp output ------------------------------------------ 128 | 129 | # Output file base name for HTML help builder. 130 | htmlhelp_basename = 'trustmedoc' 131 | 132 | 133 | # -- Options for LaTeX output --------------------------------------------- 134 | 135 | latex_elements = { 136 | # The paper size ('letterpaper' or 'a4paper'). 137 | # 138 | # 'papersize': 'letterpaper', 139 | 140 | # The font size ('10pt', '11pt' or '12pt'). 141 | # 142 | # 'pointsize': '10pt', 143 | 144 | # Additional stuff for the LaTeX preamble. 145 | # 146 | # 'preamble': '', 147 | 148 | # Latex figure (float) alignment 149 | # 150 | # 'figure_align': 'htbp', 151 | } 152 | 153 | # Grouping the document tree into LaTeX files. List of tuples 154 | # (source start file, target name, title, 155 | # author, documentclass [howto, manual, or own class]). 156 | latex_documents = [ 157 | (master_doc, 'trustme.tex', 'trustme Documentation', 158 | 'Nathaniel J. Smith', 'manual'), 159 | ] 160 | 161 | 162 | # -- Options for manual page output --------------------------------------- 163 | 164 | # One entry per manual page. List of tuples 165 | # (source start file, name, description, authors, manual section). 166 | man_pages = [ 167 | (master_doc, 'trustme', 'trustme Documentation', 168 | [author], 1) 169 | ] 170 | 171 | 172 | # -- Options for Texinfo output ------------------------------------------- 173 | 174 | # Grouping the document tree into Texinfo files. List of tuples 175 | # (source start file, target name, title, author, 176 | # dir menu entry, description, category) 177 | texinfo_documents = [ 178 | (master_doc, 'trustme', 'trustme Documentation', 179 | author, 'trustme', 'One line description of project.', 180 | 'Miscellaneous'), 181 | ] 182 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. module:: trustme 2 | 3 | .. include:: ../../README.rst 4 | 5 | 6 | Full working example 7 | ==================== 8 | 9 | Here's a fully working example you can run to see how :mod:`trustme` 10 | works. It demonstrates a simple TLS server and client that connect to 11 | each other using :mod:`trustme`\-generated certs. 12 | 13 | This example requires `Trio `__ (``pip 14 | install -upgrade trio``) and Python 3.10+. Note that while :mod:`trustme` is 15 | maintained by the Trio project, :mod:`trustme` is happy to work with 16 | any networking library. 17 | 18 | The key lines are the calls to :meth:`~CA.configure_trust`, 19 | :meth:`~LeafCert.configure_cert` – try commenting them out one at a 20 | time to see what happens! Also notice that the hostname 21 | ``test-host.example.org`` appears twice – try changing one of the 22 | strings so that the two copies no longer match, and see what happens 23 | then! 24 | 25 | .. literalinclude:: trustme-trio-example.py 26 | 27 | 28 | CLI reference 29 | ============= 30 | 31 | **All options:** 32 | 33 | .. code-block:: console 34 | 35 | $ python -m trustme --help 36 | usage: trustme [-h] [-d DIR] [-i [IDENTITIES [IDENTITIES ...]]] 37 | [--common-name COMMON_NAME] [-q] 38 | 39 | optional arguments: 40 | -h, --help Show this help message and exit. 41 | -d DIR, --dir DIR Directory where certificates and keys are written to. 42 | Defaults to cwd. 43 | -i [IDENTITIES [IDENTITIES ...]], --identities [IDENTITIES [IDENTITIES ...]] 44 | Identities for the certificate. Defaults to 'localhost 45 | 127.0.0.1 ::1'. 46 | --common-name COMMON_NAME 47 | Also sets the deprecated 'commonName' field. 48 | -q, --quiet Doesn't print out helpful information for humans. 49 | 50 | **Default configuration:** 51 | 52 | .. code-block:: console 53 | 54 | $ cd /tmp/ 55 | $ python -m trustme 56 | Generated a certificate for 'localhost', '127.0.0.1', '::1' 57 | Configure your server to use the following files: 58 | cert=/tmp/server.pem 59 | key=/tmp/server.key 60 | Configure your client to use the following files: 61 | cert=/tmp/client.pem 62 | 63 | **Designate different identities:** 64 | 65 | .. code-block:: console 66 | 67 | $ python -m trustme -i www.example.org example.org 68 | Generated a certificate for 'www.example.org', 'example.org' 69 | Configure your server to use the following files: 70 | cert=/tmp/server.pem 71 | key=/tmp/server.key 72 | Configure your client to use the following files: 73 | cert=/tmp/client.pem 74 | 75 | **Generate files into a directory:** 76 | 77 | .. code-block:: console 78 | 79 | $ mkdir /tmp/a 80 | $ python -m trustme -d /tmp/a 81 | Generated a certificate for 'localhost', '127.0.0.1', '::1' 82 | Configure your server to use the following files: 83 | cert=/tmp/a/server.pem 84 | key=/tmp/a/server.key 85 | Configure your client to use the following files: 86 | cert=/tmp/a/client.pem 87 | 88 | **Configure certs for server/client:** 89 | 90 | .. code-block:: console 91 | 92 | $ gunicorn --keyfile /tmp/a/server.key --certfile /tmp/a/server.pem app:app 93 | $ curl --cacert /tmp/a/client.pem https://localhost:8000 94 | Hello, world! 95 | 96 | 97 | API reference 98 | ============= 99 | 100 | .. autoclass:: CA 101 | :members: 102 | :exclude-members: issue_server_cert 103 | 104 | .. autoclass:: LeafCert() 105 | :members: 106 | 107 | .. autoclass:: Blob() 108 | 109 | .. automethod:: bytes 110 | 111 | .. automethod:: tempfile 112 | :with: path 113 | 114 | .. automethod:: write_to_path 115 | 116 | .. autoclass:: KeyType 117 | :members: RSA, ECDSA 118 | :undoc-members: 119 | 120 | Change history 121 | ============== 122 | 123 | .. towncrier release notes start 124 | 125 | Trustme 1.2.1 (2025-01-02) 126 | -------------------------- 127 | 128 | Bugfixes 129 | ~~~~~~~~ 130 | 131 | - Update from deprecated pyOpenSSL APIs to non-deprecated cryptography APIs. (`#670 `__) 132 | 133 | 134 | Trustme 1.2.0 (2024-10-07) 135 | -------------------------- 136 | 137 | Features 138 | ~~~~~~~~ 139 | 140 | - Add support for Python 3.13. (`#664 `__) 141 | - Allow setting of cert's notBefore attribute (`#628 `__) 142 | 143 | 144 | Bugfixes 145 | ~~~~~~~~ 146 | 147 | - Add the Authority Key Identifier extension to child CA certificates. (`#642 `__) 148 | 149 | 150 | Deprecations and Removals 151 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 152 | 153 | - Remove support for Python 3.8 and PyPy 3.9. (`#664 `__) 154 | 155 | 156 | Trustme 1.1.0 (2023-07-10) 157 | -------------------------- 158 | 159 | Features 160 | ~~~~~~~~ 161 | 162 | - Allow `os.PathLike` in typing of `Blob.write_to_path`. (`#606 `__) 163 | - Add support for PyPy 3.10 and Python 3.12. (`#609 `__) 164 | 165 | 166 | Deprecations and Removals 167 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 168 | 169 | - Remove support for Python 3.7. (`#609 `__) 170 | 171 | 172 | Trustme 1.0.0 (2023-04-24) 173 | ------------------------------ 174 | 175 | Features 176 | ~~~~~~~~ 177 | 178 | - Support for ECDSA keys in certificates and use them by default. The type of key used for certificates can be controlled by the ``key_type`` parameter on the multiple methods that generate certificates. ECDSA certificates as they can be generated significantly faster. (`#559 `__) 179 | - Support for Python 3.10 and 3.11 (`#372 `__, `574 `__) 180 | 181 | 182 | 183 | Deprecations and Removals 184 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 185 | 186 | - Remove support for Python 2. trustme now requires Python>=3.7 (CPython or PyPy). (`#346 `__) 187 | 188 | 189 | Trustme 0.9.0 (2021-08-12) 190 | -------------------------- 191 | 192 | Features 193 | ~~~~~~~~ 194 | 195 | - The package is now type annotated. If you use mypy on code which uses ``trustme``, you should be able to remove any exclusions. (`#339 `__) 196 | 197 | 198 | Trustme 0.8.0 (2021-06-08) 199 | -------------------------- 200 | 201 | Features 202 | ~~~~~~~~ 203 | 204 | - It's now possible to set an expiry date on server certificates, either with ``--expires-on`` in the CLI or with ``not_after`` in `trustme.CA.issue_cert`. (`#293 `__) 205 | - Support Python 3.10 (`#327 `__) 206 | - Set correct KeyUsage and ExtendedKeyUsage extensions, per CA/B Forum baseline requirements. (`#328 `__) 207 | 208 | 209 | Trustme 0.7.0 (2021-02-10) 210 | ------------------------------ 211 | 212 | Features 213 | ~~~~~~~~ 214 | 215 | - trustme can now be used a command line interface with ``python -m 216 | trustme``. Get the help with ``python -m trustme --help``. (`#265 `__) 217 | 218 | 219 | Trustme 0.6.0 (2019-12-19) 220 | -------------------------- 221 | 222 | Features 223 | ~~~~~~~~ 224 | 225 | - Allow specifying organization and organization unit in CA and issued certs. (`#126 `__) 226 | 227 | 228 | Trustme 0.5.3 (2019-10-31) 229 | -------------------------- 230 | 231 | Features 232 | ~~~~~~~~ 233 | 234 | - Added :attr:`CA.from_pem` to import an existing certificate authority; this allows migrating to trustme step-by-step. (`#107 `__) 235 | 236 | 237 | Trustme 0.5.2 (2019-06-03) 238 | -------------------------- 239 | 240 | Bugfixes 241 | ~~~~~~~~ 242 | 243 | - Update to avoid a deprecation warning on cryptography 2.7. (`#47 `__) 244 | 245 | 246 | Trustme 0.5.1 (2019-04-15) 247 | -------------------------- 248 | 249 | Bugfixes 250 | ~~~~~~~~ 251 | 252 | - Update key size to 2048 bits, as required by recent Debian. (`#45 `__) 253 | 254 | 255 | Trustme 0.5.0 (2019-01-21) 256 | -------------------------- 257 | 258 | Features 259 | ~~~~~~~~ 260 | 261 | - Added :meth:`CA.create_child_ca` to allow for certificate chains (`#3 `__) 262 | - Added :attr:`CA.private_key_pem` to export CA private keys; this allows signing other certs with the same CA outside of trustme. (`#27 `__) 263 | - CAs now include the KeyUsage and ExtendedKeyUsage extensions configured for SSL certificates. (`#30 `__) 264 | - `CA.issue_cert` now accepts email addresses as a valid form of identity. (`#33 `__) 265 | - It's now possible to set the "common name" of generated certs; see `CA.issue_cert` for details. (`#34 `__) 266 | - ``CA.issue_server_cert`` has been renamed to `CA.issue_cert`, since it supports both server and client certs. To preserve backwards compatibility, the old name is retained as an undocumented alias. (`#35 `__) 267 | 268 | 269 | Bugfixes 270 | ~~~~~~~~ 271 | 272 | - Make sure cert expiration dates don't exceed 2038-01-01, to avoid 273 | issues on some 32-bit platforms that suffer from the `Y2038 problem 274 | `__. (`#41 `__) 275 | 276 | 277 | Trustme 0.4.0 (2017-08-06) 278 | -------------------------- 279 | 280 | Features 281 | ~~~~~~~~ 282 | 283 | - :meth:`CA.issue_cert` now accepts IP addresses and IP networks. 284 | (`#19 `__) 285 | 286 | 287 | Bugfixes 288 | ~~~~~~~~ 289 | 290 | - Start doing our own handling of Unicode hostname (IDNs), instead of relying 291 | on cryptography to do it; this allows us to correctly handle a broader range 292 | of cases, and avoids relying on soon-to-be-deprecated behavior (`#17 293 | `__) 294 | - Generated certs no longer contain a subject:commonName field, to better match 295 | CABF guidelines (`#18 `__) 296 | 297 | 298 | Trustme 0.3.0 (2017-08-03) 299 | -------------------------- 300 | 301 | Bugfixes 302 | ~~~~~~~~ 303 | 304 | - Don't crash on Windows (`#10 305 | `__) 306 | 307 | 308 | Misc 309 | ~~~~ 310 | 311 | - `#11 `__, `#12 312 | `__ 313 | 314 | 315 | Trustme 0.2.0 (2017-08-02) 316 | -------------------------- 317 | 318 | - Broke and re-did almost the entire public API. Sorry! Let's just 319 | pretend v0.1.0 never happened. 320 | 321 | - Hey there are docs now though, that should be worth something right? 322 | 323 | 324 | Trustme 0.1.0 (2017-07-18) 325 | -------------------------- 326 | 327 | - Initial release 328 | 329 | 330 | Acknowledgements 331 | ================ 332 | 333 | This is basically just a trivial wrapper around the awesome Python 334 | `cryptography `__ library. Also, `Glyph 335 | `__ originally wrote most of the 336 | tricky bits. I got tired of never being able to remember how this 337 | works or find the magic snippets to copy/paste, so I stole the code 338 | out of `Twisted `__ and wrapped it in a 339 | bow. 340 | -------------------------------------------------------------------------------- /LICENSE.APACHE2: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /tests/test_trustme.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import socket 3 | import ssl 4 | import sys 5 | from concurrent.futures import ThreadPoolExecutor 6 | from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network 7 | from pathlib import Path 8 | from typing import Callable, Optional, Union 9 | 10 | import OpenSSL.SSL 11 | import pytest 12 | import service_identity.pyopenssl # type: ignore[import-not-found] 13 | from cryptography import x509 14 | from cryptography.hazmat.primitives.serialization import ( 15 | Encoding, 16 | PublicFormat, 17 | load_pem_private_key, 18 | ) 19 | 20 | import trustme 21 | from trustme import CA, KeyType, LeafCert 22 | 23 | SslSocket = Union[ssl.SSLSocket, OpenSSL.SSL.Connection] 24 | 25 | 26 | def _path_length(ca_cert: x509.Certificate) -> Optional[int]: 27 | bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) 28 | return bc.value.path_length 29 | 30 | 31 | def assert_is_ca(ca_cert: x509.Certificate) -> None: 32 | bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) 33 | assert bc.value.ca is True 34 | assert bc.critical is True 35 | 36 | ku = ca_cert.extensions.get_extension_for_class(x509.KeyUsage) 37 | assert ku.value.key_cert_sign is True 38 | assert ku.value.crl_sign is True 39 | assert ku.critical is True 40 | 41 | with pytest.raises(x509.ExtensionNotFound): 42 | ca_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) 43 | 44 | 45 | def assert_is_leaf(leaf_cert: x509.Certificate) -> None: 46 | bc = leaf_cert.extensions.get_extension_for_class(x509.BasicConstraints) 47 | assert bc.value.ca is False 48 | assert bc.critical is True 49 | 50 | ku = leaf_cert.extensions.get_extension_for_class(x509.KeyUsage) 51 | assert ku.value.digital_signature is True 52 | assert ku.value.key_encipherment is True 53 | assert ku.value.key_cert_sign is False 54 | assert ku.value.crl_sign is False 55 | assert ku.critical is True 56 | 57 | eku = leaf_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) 58 | assert eku.value == x509.ExtendedKeyUsage( 59 | [ 60 | x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH, 61 | x509.oid.ExtendedKeyUsageOID.SERVER_AUTH, 62 | x509.oid.ExtendedKeyUsageOID.CODE_SIGNING, 63 | ] 64 | ) 65 | assert eku.critical is True 66 | 67 | 68 | @pytest.mark.parametrize( 69 | "key_type,expected_key_header", [(KeyType.RSA, b"RSA"), (KeyType.ECDSA, b"EC")] 70 | ) 71 | def test_basics(key_type: KeyType, expected_key_header: bytes) -> None: 72 | ca = CA(key_type=key_type) 73 | 74 | today = datetime.datetime.now(datetime.timezone.utc) 75 | 76 | assert ( 77 | b"BEGIN " + expected_key_header + b" PRIVATE KEY" in ca.private_key_pem.bytes() 78 | ) 79 | assert b"BEGIN CERTIFICATE" in ca.cert_pem.bytes() 80 | 81 | private_key = load_pem_private_key(ca.private_key_pem.bytes(), password=None) 82 | 83 | ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.bytes()) 84 | assert ca_cert.not_valid_before_utc <= today <= ca_cert.not_valid_after_utc 85 | 86 | public_key1 = private_key.public_key().public_bytes( 87 | Encoding.PEM, PublicFormat.SubjectPublicKeyInfo 88 | ) 89 | public_key2 = ca_cert.public_key().public_bytes( 90 | Encoding.PEM, PublicFormat.SubjectPublicKeyInfo 91 | ) 92 | assert public_key1 == public_key2 93 | 94 | assert ca_cert.issuer == ca_cert.subject 95 | assert_is_ca(ca_cert) 96 | 97 | with pytest.raises(ValueError): 98 | ca.issue_cert() 99 | 100 | server = ca.issue_cert( 101 | "test-1.example.org", "test-2.example.org", key_type=key_type 102 | ) 103 | 104 | assert b"PRIVATE KEY" in server.private_key_pem.bytes() 105 | assert b"BEGIN CERTIFICATE" in server.cert_chain_pems[0].bytes() 106 | assert len(server.cert_chain_pems) == 1 107 | assert ( 108 | server.private_key_pem.bytes() in server.private_key_and_cert_chain_pem.bytes() 109 | ) 110 | for blob in server.cert_chain_pems: 111 | assert blob.bytes() in server.private_key_and_cert_chain_pem.bytes() 112 | 113 | server_cert = x509.load_pem_x509_certificate(server.cert_chain_pems[0].bytes()) 114 | 115 | assert server_cert.not_valid_before_utc <= today <= server_cert.not_valid_after_utc 116 | assert server_cert.issuer == ca_cert.subject 117 | assert_is_leaf(server_cert) 118 | 119 | san = server_cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) 120 | hostnames = san.value.get_values_for_type(x509.DNSName) 121 | assert hostnames == ["test-1.example.org", "test-2.example.org"] 122 | 123 | 124 | def test_ca_custom_names() -> None: 125 | ca = CA( 126 | organization_name="python-trio", 127 | organization_unit_name="trustme", 128 | ) 129 | 130 | ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.bytes()) 131 | 132 | assert { 133 | "O=python-trio", 134 | "OU=trustme", 135 | }.issubset({rdn.rfc4514_string() for rdn in ca_cert.subject.rdns}) 136 | 137 | 138 | def test_issue_cert_custom_names() -> None: 139 | ca = CA() 140 | leaf_cert = ca.issue_cert( 141 | "example.org", 142 | organization_name="python-trio", 143 | organization_unit_name="trustme", 144 | ) 145 | 146 | cert = x509.load_pem_x509_certificate(leaf_cert.cert_chain_pems[0].bytes()) 147 | 148 | assert { 149 | "O=python-trio", 150 | "OU=trustme", 151 | }.issubset({rdn.rfc4514_string() for rdn in cert.subject.rdns}) 152 | 153 | 154 | def test_issue_cert_custom_not_after() -> None: 155 | now = datetime.datetime.now() 156 | expires = datetime.datetime(2025, 12, 1, 8, 10, 10) 157 | ca = CA() 158 | 159 | leaf_cert = ca.issue_cert( 160 | "example.org", 161 | organization_name="python-trio", 162 | organization_unit_name="trustme", 163 | not_after=expires, 164 | ) 165 | 166 | cert = x509.load_pem_x509_certificate(leaf_cert.cert_chain_pems[0].bytes()) 167 | 168 | for t in ["year", "month", "day", "hour", "minute", "second"]: 169 | assert getattr(cert.not_valid_after_utc, t) == getattr(expires, t) 170 | 171 | 172 | def test_issue_cert_custom_not_before() -> None: 173 | not_before = datetime.datetime(2027, 7, 5, 17, 15, 30) 174 | ca = CA() 175 | 176 | leaf_cert = ca.issue_cert( 177 | "example.org", 178 | organization_name="python-trio", 179 | organization_unit_name="trustme", 180 | not_before=not_before, 181 | ) 182 | 183 | cert = x509.load_pem_x509_certificate(leaf_cert.cert_chain_pems[0].bytes()) 184 | 185 | for t in ["year", "month", "day", "hour", "minute", "second"]: 186 | assert getattr(cert.not_valid_before_utc, t) == getattr(not_before, t) 187 | 188 | 189 | def test_intermediate() -> None: 190 | ca = CA() 191 | ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.bytes()) 192 | assert_is_ca(ca_cert) 193 | assert ca_cert.issuer == ca_cert.subject 194 | assert _path_length(ca_cert) == 9 195 | 196 | child_ca = ca.create_child_ca() 197 | child_ca_cert = x509.load_pem_x509_certificate(child_ca.cert_pem.bytes()) 198 | assert_is_ca(child_ca_cert) 199 | assert child_ca_cert.issuer == ca_cert.subject 200 | assert _path_length(child_ca_cert) == 8 201 | aki = child_ca_cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) 202 | assert aki.critical is False 203 | expected_aki_key_id = ca_cert.extensions.get_extension_for_class( 204 | x509.SubjectKeyIdentifier 205 | ).value.digest 206 | assert aki.value.key_identifier == expected_aki_key_id 207 | 208 | child_server = child_ca.issue_cert("test-host.example.org") 209 | assert len(child_server.cert_chain_pems) == 2 210 | child_server_cert = x509.load_pem_x509_certificate( 211 | child_server.cert_chain_pems[0].bytes() 212 | ) 213 | assert child_server_cert.issuer == child_ca_cert.subject 214 | assert_is_leaf(child_server_cert) 215 | 216 | 217 | def test_path_length() -> None: 218 | ca = CA() 219 | ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.bytes()) 220 | assert _path_length(ca_cert) == 9 221 | 222 | child_ca = ca 223 | for i in range(9): 224 | child_ca = child_ca.create_child_ca() 225 | 226 | # Can't create new child CAs anymore 227 | child_ca_cert = x509.load_pem_x509_certificate(child_ca.cert_pem.bytes()) 228 | assert _path_length(child_ca_cert) == 0 229 | with pytest.raises(ValueError): 230 | child_ca.create_child_ca() 231 | 232 | 233 | def test_unrecognized_context_type() -> None: 234 | ca = CA() 235 | server = ca.issue_cert("test-1.example.org") 236 | 237 | with pytest.raises(TypeError): 238 | ca.configure_trust(None) # type: ignore[arg-type] 239 | 240 | with pytest.raises(TypeError): 241 | server.configure_cert(None) # type: ignore[arg-type] 242 | 243 | 244 | def test_blob(tmp_path: Path) -> None: 245 | test_data = b"xyzzy" 246 | b = trustme.Blob(test_data) 247 | 248 | # bytes 249 | 250 | assert b.bytes() == test_data 251 | 252 | # write_to_path 253 | 254 | b.write_to_path(str(tmp_path / "test1")) 255 | with (tmp_path / "test1").open("rb") as f: 256 | assert f.read() == test_data 257 | 258 | # append=False overwrites 259 | with (tmp_path / "test2").open("wb") as f: 260 | f.write(b"asdf") 261 | b.write_to_path(str(tmp_path / "test2")) 262 | with (tmp_path / "test2").open("rb") as f: 263 | assert f.read() == test_data 264 | 265 | # append=True appends 266 | with (tmp_path / "test2").open("wb") as f: 267 | f.write(b"asdf") 268 | b.write_to_path(str(tmp_path / "test2"), append=True) 269 | with (tmp_path / "test2").open("rb") as f: 270 | assert f.read() == b"asdf" + test_data 271 | 272 | # tempfile 273 | with b.tempfile(dir=str(tmp_path)) as path: 274 | assert path.startswith(str(tmp_path)) 275 | assert path.endswith(".pem") 276 | with open(path, "rb") as f: 277 | assert f.read() == test_data 278 | 279 | 280 | def test_ca_from_pem(tmp_path: Path) -> None: 281 | ca1 = trustme.CA() 282 | ca2 = trustme.CA.from_pem(ca1.cert_pem.bytes(), ca1.private_key_pem.bytes()) 283 | assert ca1._certificate == ca2._certificate 284 | assert ca1.private_key_pem.bytes() == ca2.private_key_pem.bytes() 285 | 286 | 287 | def check_connection_end_to_end( 288 | wrap_client: Callable[[CA, socket.socket, str], SslSocket], 289 | wrap_server: Callable[[LeafCert, socket.socket], SslSocket], 290 | key_type: KeyType, 291 | ) -> None: 292 | # Client side 293 | def fake_ssl_client(ca: CA, raw_client_sock: socket.socket, hostname: str) -> None: 294 | try: 295 | wrapped_client_sock = wrap_client(ca, raw_client_sock, hostname) 296 | # Send and receive some data to prove the connection is good 297 | wrapped_client_sock.send(b"x") 298 | assert wrapped_client_sock.recv(1) == b"y" 299 | wrapped_client_sock.close() 300 | except: # pragma: no cover 301 | sys.excepthook(*sys.exc_info()) 302 | raise 303 | finally: 304 | raw_client_sock.close() 305 | 306 | # Server side 307 | def fake_ssl_server(server_cert: LeafCert, raw_server_sock: socket.socket) -> None: 308 | try: 309 | wrapped_server_sock = wrap_server(server_cert, raw_server_sock) 310 | # Prove that we're connected 311 | assert wrapped_server_sock.recv(1) == b"x" 312 | wrapped_server_sock.send(b"y") 313 | wrapped_server_sock.close() 314 | except: # pragma: no cover 315 | sys.excepthook(*sys.exc_info()) 316 | raise 317 | finally: 318 | raw_server_sock.close() 319 | 320 | def doit(ca: CA, hostname: str, server_cert: LeafCert) -> None: 321 | # socketpair and ssl don't work together on py2, because... reasons. 322 | # So we need to do this the hard way. 323 | listener = socket.socket() 324 | listener.bind(("127.0.0.1", 0)) 325 | listener.listen(1) 326 | raw_client_sock = socket.socket() 327 | raw_client_sock.connect(listener.getsockname()) 328 | raw_server_sock, _ = listener.accept() 329 | listener.close() 330 | with ThreadPoolExecutor(2) as tpe: 331 | f1 = tpe.submit(fake_ssl_client, ca, raw_client_sock, hostname) 332 | f2 = tpe.submit(fake_ssl_server, server_cert, raw_server_sock) 333 | f1.result() 334 | f2.result() 335 | 336 | ca = CA(key_type=key_type) 337 | intermediate_ca = ca.create_child_ca(key_type=key_type) 338 | hostname = "my-test-host.example.org" 339 | 340 | # Should work 341 | doit(ca, hostname, ca.issue_cert(hostname, key_type=key_type)) 342 | 343 | # Should work 344 | doit(ca, hostname, intermediate_ca.issue_cert(hostname, key_type=key_type)) 345 | 346 | # To make sure that the above success actually required that the 347 | # CA and cert logic is all working, make sure that the same code 348 | # fails if the certs or CA aren't right: 349 | 350 | # Bad hostname fails 351 | with pytest.raises(Exception): 352 | doit(ca, "asdf.example.org", ca.issue_cert(hostname, key_type=key_type)) 353 | 354 | # Bad CA fails 355 | bad_ca = CA() 356 | with pytest.raises(Exception): 357 | doit(bad_ca, hostname, ca.issue_cert(hostname, key_type=key_type)) 358 | 359 | 360 | @pytest.mark.parametrize("key_type", [KeyType.RSA, KeyType.ECDSA]) 361 | def test_stdlib_end_to_end(key_type: KeyType) -> None: 362 | def wrap_client( 363 | ca: CA, raw_client_sock: socket.socket, hostname: str 364 | ) -> ssl.SSLSocket: 365 | ctx = ssl.create_default_context() 366 | ca.configure_trust(ctx) 367 | wrapped_client_sock = ctx.wrap_socket(raw_client_sock, server_hostname=hostname) 368 | print("Client got server cert:", wrapped_client_sock.getpeercert()) 369 | peercert = wrapped_client_sock.getpeercert() 370 | assert peercert is not None 371 | san = peercert["subjectAltName"] 372 | assert san == (("DNS", "my-test-host.example.org"),) 373 | return wrapped_client_sock 374 | 375 | def wrap_server( 376 | server_cert: LeafCert, raw_server_sock: socket.socket 377 | ) -> ssl.SSLSocket: 378 | ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 379 | server_cert.configure_cert(ctx) 380 | wrapped_server_sock = ctx.wrap_socket(raw_server_sock, server_side=True) 381 | print("server encrypted with:", wrapped_server_sock.cipher()) 382 | return wrapped_server_sock 383 | 384 | check_connection_end_to_end(wrap_client, wrap_server, key_type) 385 | 386 | 387 | @pytest.mark.parametrize("key_type", [KeyType.RSA, KeyType.ECDSA]) 388 | def test_pyopenssl_end_to_end(key_type: KeyType) -> None: 389 | def wrap_client( 390 | ca: CA, raw_client_sock: socket.socket, hostname: str 391 | ) -> OpenSSL.SSL.Connection: 392 | # Cribbed from example at 393 | # https://service-identity.readthedocs.io/en/stable/api.html#service_identity.pyopenssl.verify_hostname 394 | ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) 395 | ctx.set_verify( 396 | OpenSSL.SSL.VERIFY_PEER, lambda conn, cert, errno, depth, ok: bool(ok) 397 | ) 398 | ca.configure_trust(ctx) 399 | conn = OpenSSL.SSL.Connection(ctx, raw_client_sock) 400 | conn.set_connect_state() 401 | conn.do_handshake() 402 | service_identity.pyopenssl.verify_hostname(conn, hostname) 403 | return conn 404 | 405 | def wrap_server( 406 | server_cert: LeafCert, raw_server_sock: socket.socket 407 | ) -> OpenSSL.SSL.Connection: 408 | ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) 409 | server_cert.configure_cert(ctx) 410 | 411 | conn = OpenSSL.SSL.Connection(ctx, raw_server_sock) 412 | conn.set_accept_state() 413 | conn.do_handshake() 414 | return conn 415 | 416 | check_connection_end_to_end(wrap_client, wrap_server, key_type) 417 | 418 | 419 | def test_identity_variants() -> None: 420 | ca = CA() 421 | 422 | for bad in [b"example.org", bytearray(b"example.org"), 123]: 423 | with pytest.raises(TypeError): 424 | ca.issue_cert(bad) # type: ignore[arg-type] 425 | 426 | cases = { 427 | # Traditional ascii hostname 428 | "example.org": x509.DNSName("example.org"), 429 | # Wildcard 430 | "*.example.org": x509.DNSName("*.example.org"), 431 | # IDN 432 | "éxamplë.org": x509.DNSName("xn--xampl-9rat.org"), 433 | "xn--xampl-9rat.org": x509.DNSName("xn--xampl-9rat.org"), 434 | # IDN + wildcard 435 | "*.éxamplë.org": x509.DNSName("*.xn--xampl-9rat.org"), 436 | "*.xn--xampl-9rat.org": x509.DNSName("*.xn--xampl-9rat.org"), 437 | # IDN that acts differently in IDNA-2003 vs IDNA-2008 438 | "faß.de": x509.DNSName("xn--fa-hia.de"), 439 | "xn--fa-hia.de": x509.DNSName("xn--fa-hia.de"), 440 | # IDN with non-permissable character (uppercase K) 441 | # (example taken from idna package docs) 442 | "Königsgäßchen.de": x509.DNSName("xn--knigsgchen-b4a3dun.de"), 443 | # IP addresses 444 | "127.0.0.1": x509.IPAddress(IPv4Address("127.0.0.1")), 445 | "::1": x509.IPAddress(IPv6Address("::1")), 446 | # Check normalization 447 | "0000::1": x509.IPAddress(IPv6Address("::1")), 448 | # IP networks 449 | "127.0.0.0/24": x509.IPAddress(IPv4Network("127.0.0.0/24")), 450 | "2001::/16": x509.IPAddress(IPv6Network("2001::/16")), 451 | # Check normalization 452 | "2001:0000::/16": x509.IPAddress(IPv6Network("2001::/16")), 453 | # Email address 454 | "example@example.com": x509.RFC822Name("example@example.com"), 455 | } 456 | 457 | for hostname, expected in cases.items(): 458 | # Can't repr the got or expected values here, at least until 459 | # cryptography v2.1 is out, because in v2.0 on py2, DNSName.__repr__ 460 | # blows up on IDNs. 461 | print(f"testing: {hostname!r}") 462 | pem = ca.issue_cert(hostname).cert_chain_pems[0].bytes() 463 | cert = x509.load_pem_x509_certificate(pem) 464 | san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) 465 | assert_is_leaf(cert) 466 | got = list(san.value)[0] 467 | assert got == expected 468 | 469 | 470 | def test_backcompat() -> None: 471 | ca = CA() 472 | # We can still use the old name 473 | ca.issue_server_cert("example.com") 474 | 475 | 476 | def test_CN() -> None: 477 | ca = CA() 478 | 479 | # Must be str 480 | with pytest.raises(TypeError): 481 | ca.issue_cert(common_name=b"bad kwarg value") # type: ignore[arg-type] 482 | 483 | # Default is no common name 484 | pem = ca.issue_cert("example.com").cert_chain_pems[0].bytes() 485 | cert = x509.load_pem_x509_certificate(pem) 486 | common_names = cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) 487 | assert common_names == [] 488 | 489 | # Common name on its own is valid 490 | pem = ca.issue_cert(common_name="woo").cert_chain_pems[0].bytes() 491 | cert = x509.load_pem_x509_certificate(pem) 492 | common_names = cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) 493 | assert common_names[0].value == "woo" 494 | 495 | # Common name + SAN 496 | pem = ca.issue_cert("example.com", common_name="woo").cert_chain_pems[0].bytes() 497 | cert = x509.load_pem_x509_certificate(pem) 498 | san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) 499 | assert list(san.value)[0] == x509.DNSName("example.com") 500 | common_names = cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME) 501 | assert common_names[0].value == "woo" 502 | -------------------------------------------------------------------------------- /src/trustme/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import ipaddress 5 | import os 6 | import ssl 7 | from base64 import urlsafe_b64encode 8 | from contextlib import contextmanager 9 | from enum import Enum 10 | from tempfile import NamedTemporaryFile 11 | from typing import TYPE_CHECKING, Generator, List, Optional, Union 12 | 13 | import idna 14 | from cryptography import x509 15 | from cryptography.hazmat.primitives import hashes 16 | from cryptography.hazmat.primitives.asymmetric import ec, rsa 17 | from cryptography.hazmat.primitives.serialization import ( 18 | Encoding, 19 | NoEncryption, 20 | PrivateFormat, 21 | load_pem_private_key, 22 | ) 23 | from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID 24 | 25 | from ._version import __version__ 26 | 27 | if TYPE_CHECKING: # pragma: no cover 28 | import OpenSSL.SSL 29 | 30 | CERTIFICATE_PUBLIC_KEY_TYPES = Union[rsa.RSAPublicKey, ec.EllipticCurvePublicKey] 31 | CERTIFICATE_PRIVATE_KEY_TYPES = Union[rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey] 32 | 33 | __all__ = ["CA"] 34 | 35 | # Default certificate expiry date: 36 | # OpenSSL on Windows fails if you try to give it a date after 37 | # ~3001-01-19: 38 | # https://github.com/pyca/cryptography/issues/3194 39 | DEFAULT_EXPIRY = datetime.datetime(3000, 1, 1) 40 | DEFAULT_NOT_BEFORE = datetime.datetime(2000, 1, 1) 41 | 42 | 43 | def _name( 44 | name: str, 45 | organization_name: Optional[str] = None, 46 | common_name: Optional[str] = None, 47 | ) -> x509.Name: 48 | name_pieces = [ 49 | x509.NameAttribute( 50 | NameOID.ORGANIZATION_NAME, 51 | organization_name or f"trustme v{__version__}", 52 | ), 53 | x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, name), 54 | ] 55 | if common_name is not None: 56 | name_pieces.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name)) 57 | return x509.Name(name_pieces) 58 | 59 | 60 | def random_text() -> str: 61 | return urlsafe_b64encode(os.urandom(12)).decode("ascii") 62 | 63 | 64 | def _smells_like_pyopenssl(ctx: object) -> bool: 65 | return getattr(ctx, "__module__", "").startswith("OpenSSL") 66 | 67 | 68 | def _cert_builder_common( 69 | subject: x509.Name, 70 | issuer: x509.Name, 71 | public_key: CERTIFICATE_PUBLIC_KEY_TYPES, 72 | not_after: Optional[datetime.datetime] = None, 73 | not_before: Optional[datetime.datetime] = None, 74 | ) -> x509.CertificateBuilder: 75 | not_after = not_after if not_after else DEFAULT_EXPIRY 76 | not_before = not_before if not_before else DEFAULT_NOT_BEFORE 77 | return ( 78 | x509.CertificateBuilder() 79 | .subject_name(subject) 80 | .issuer_name(issuer) 81 | .public_key(public_key) 82 | .not_valid_before(not_before) 83 | .not_valid_after(not_after) 84 | .serial_number(x509.random_serial_number()) 85 | .add_extension( 86 | x509.SubjectKeyIdentifier.from_public_key(public_key), 87 | critical=False, 88 | ) 89 | ) 90 | 91 | 92 | def _identity_string_to_x509(identity: str) -> x509.GeneralName: 93 | # Because we are a DWIM library for lazy slackers, we cheerfully pervert 94 | # the cryptography library's carefully type-safe API, and silently DTRT 95 | # for any of the following identity types: 96 | # 97 | # - "example.org" 98 | # - "example.org" 99 | # - "éxamplë.org" 100 | # - "xn--xampl-9rat.org" 101 | # - "xn--xampl-9rat.org" 102 | # - "127.0.0.1" 103 | # - "::1" 104 | # - "10.0.0.0/8" 105 | # - "2001::/16" 106 | # - "example@example.org" 107 | # 108 | # plus wildcard variants of the identities. 109 | if not isinstance(identity, str): 110 | raise TypeError("identities must be str") 111 | 112 | if "@" in identity: 113 | return x509.RFC822Name(identity) 114 | 115 | # Have to try ip_address first, because ip_network("127.0.0.1") is 116 | # interpreted as being the network 127.0.0.1/32. Which I guess would be 117 | # fine, actually, but why risk it. 118 | try: 119 | return x509.IPAddress(ipaddress.ip_address(identity)) 120 | except ValueError: 121 | try: 122 | return x509.IPAddress(ipaddress.ip_network(identity)) 123 | except ValueError: 124 | pass 125 | 126 | # Encode to an A-label, like cryptography wants 127 | if identity.startswith("*."): 128 | alabel_bytes = b"*." + idna.encode(identity[2:], uts46=True) 129 | else: 130 | alabel_bytes = idna.encode(identity, uts46=True) 131 | # Then back to text, which is mandatory on cryptography 2.0 and earlier, 132 | # and may or may not be deprecated in cryptography 2.1. 133 | alabel = alabel_bytes.decode("ascii") 134 | return x509.DNSName(alabel) 135 | 136 | 137 | class Blob: 138 | """A convenience wrapper for a blob of bytes. 139 | 140 | This type has no public constructor. They're used to provide a handy 141 | interface to the PEM-encoded data generated by `trustme`. For example, see 142 | `CA.cert_pem` or `LeafCert.private_key_and_cert_chain_pem`. 143 | 144 | """ 145 | 146 | def __init__(self, data: bytes) -> None: 147 | self._data = data 148 | 149 | def bytes(self) -> bytes: 150 | """Returns the data as a `bytes` object.""" 151 | return self._data 152 | 153 | def write_to_path( 154 | self, path: Union[str, "os.PathLike[str]"], append: bool = False 155 | ) -> None: 156 | """Writes the data to the file at the given path. 157 | 158 | Args: 159 | path: The path to write to. 160 | append: If False (the default), replace any existing file 161 | with the given name. If True, append to any existing file. 162 | 163 | """ 164 | if append: 165 | mode = "ab" 166 | else: 167 | mode = "wb" 168 | with open(path, mode) as f: 169 | f.write(self._data) 170 | 171 | @contextmanager 172 | def tempfile(self, dir: Optional[str] = None) -> Generator[str, None, None]: 173 | """Context manager for writing data to a temporary file. 174 | 175 | The file is created when you enter the context manager, and 176 | automatically deleted when the context manager exits. 177 | 178 | Many libraries have annoying APIs which require that certificates be 179 | specified as filesystem paths, so even if you have already the data in 180 | memory, you have to write it out to disk and then let them read it 181 | back in again. If you encounter such a library, you should probably 182 | file a bug. But in the mean time, this context manager makes it easy 183 | to give them what they want. 184 | 185 | Example: 186 | 187 | Here's how to get requests to use a trustme CA (`see also 188 | `__):: 189 | 190 | ca = trustme.CA() 191 | with ca.cert_pem.tempfile() as ca_cert_path: 192 | requests.get("https://localhost/...", verify=ca_cert_path) 193 | 194 | Args: 195 | dir: Passed to `tempfile.NamedTemporaryFile`. 196 | 197 | """ 198 | # On Windows, you can't re-open a NamedTemporaryFile that's still 199 | # open. Which seems like it completely defeats the purpose of having a 200 | # NamedTemporaryFile? Oh well... 201 | # https://bugs.python.org/issue14243 202 | f = NamedTemporaryFile(suffix=".pem", dir=dir, delete=False) 203 | try: 204 | f.write(self._data) 205 | f.close() 206 | yield f.name 207 | finally: 208 | f.close() # in case write() raised an error 209 | os.unlink(f.name) 210 | 211 | 212 | class KeyType(Enum): 213 | """Type of the key used to generate a certificate""" 214 | 215 | RSA = 0 216 | ECDSA = 1 217 | 218 | def _generate_key(self) -> CERTIFICATE_PRIVATE_KEY_TYPES: 219 | if self is KeyType.RSA: 220 | # key_size needs to be a least 2048 to be accepted 221 | # on Debian and pressumably other OSes 222 | 223 | return rsa.generate_private_key(public_exponent=65537, key_size=2048) 224 | elif self is KeyType.ECDSA: 225 | return ec.generate_private_key(ec.SECP256R1()) 226 | else: # pragma: no cover 227 | raise ValueError("Unknown key type") 228 | 229 | 230 | class CA: 231 | """A certificate authority.""" 232 | 233 | _certificate: x509.Certificate 234 | 235 | def __init__( 236 | self, 237 | parent_cert: Optional[CA] = None, 238 | path_length: int = 9, 239 | organization_name: Optional[str] = None, 240 | organization_unit_name: Optional[str] = None, 241 | key_type: KeyType = KeyType.ECDSA, 242 | ) -> None: 243 | self.parent_cert = parent_cert 244 | self._private_key = key_type._generate_key() 245 | self._path_length = path_length 246 | 247 | name = _name( 248 | organization_unit_name or "Testing CA #" + random_text(), 249 | organization_name=organization_name, 250 | ) 251 | issuer = name 252 | sign_key = self._private_key 253 | aki: Optional[x509.AuthorityKeyIdentifier] 254 | if parent_cert is not None: 255 | sign_key = parent_cert._private_key 256 | parent_certificate = parent_cert._certificate 257 | issuer = parent_certificate.subject 258 | ski_ext = parent_certificate.extensions.get_extension_for_class( 259 | x509.SubjectKeyIdentifier 260 | ) 261 | aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( 262 | ski_ext.value 263 | ) 264 | else: 265 | aki = None 266 | cert_builder = _cert_builder_common( 267 | name, issuer, self._private_key.public_key() 268 | ).add_extension( 269 | x509.BasicConstraints(ca=True, path_length=path_length), 270 | critical=True, 271 | ) 272 | if aki: 273 | cert_builder = cert_builder.add_extension(aki, critical=False) 274 | self._certificate = cert_builder.add_extension( 275 | x509.KeyUsage( 276 | digital_signature=True, # OCSP 277 | content_commitment=False, 278 | key_encipherment=False, 279 | data_encipherment=False, 280 | key_agreement=False, 281 | key_cert_sign=True, # sign certs 282 | crl_sign=True, # sign revocation lists 283 | encipher_only=False, 284 | decipher_only=False, 285 | ), 286 | critical=True, 287 | ).sign( 288 | private_key=sign_key, 289 | algorithm=hashes.SHA256(), 290 | ) 291 | 292 | @property 293 | def cert_pem(self) -> Blob: 294 | """`Blob`: The PEM-encoded certificate for this CA. Add this to your 295 | trust store to trust this CA.""" 296 | return Blob(self._certificate.public_bytes(Encoding.PEM)) 297 | 298 | @property 299 | def private_key_pem(self) -> Blob: 300 | """`Blob`: The PEM-encoded private key for this CA. Use this to sign 301 | other certificates from this CA.""" 302 | return Blob( 303 | self._private_key.private_bytes( 304 | Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption() 305 | ) 306 | ) 307 | 308 | def create_child_ca(self, key_type: KeyType = KeyType.ECDSA) -> "CA": 309 | """Creates a child certificate authority 310 | 311 | Returns: 312 | CA: the newly-generated certificate authority 313 | 314 | Raises: 315 | ValueError: if the CA path length is 0 316 | """ 317 | if self._path_length == 0: 318 | raise ValueError("Can't create child CA: path length is 0") 319 | 320 | path_length = self._path_length - 1 321 | return CA(parent_cert=self, path_length=path_length, key_type=key_type) 322 | 323 | def issue_cert( 324 | self, 325 | *identities: str, 326 | common_name: Optional[str] = None, 327 | organization_name: Optional[str] = None, 328 | organization_unit_name: Optional[str] = None, 329 | not_before: Optional[datetime.datetime] = None, 330 | not_after: Optional[datetime.datetime] = None, 331 | key_type: KeyType = KeyType.ECDSA, 332 | ) -> "LeafCert": 333 | """Issues a certificate. The certificate can be used for either servers 334 | or clients. 335 | 336 | Args: 337 | identities: The identities that this certificate will be valid for. 338 | Most commonly, these are just hostnames, but we accept any of the 339 | following forms: 340 | 341 | - Regular hostname: ``example.com`` 342 | - Wildcard hostname: ``*.example.com`` 343 | - International Domain Name (IDN): ``café.example.com`` 344 | - IDN in A-label form: ``xn--caf-dma.example.com`` 345 | - IPv4 address: ``127.0.0.1`` 346 | - IPv6 address: ``::1`` 347 | - IPv4 network: ``10.0.0.0/8`` 348 | - IPv6 network: ``2001::/16`` 349 | - Email address: ``example@example.com`` 350 | 351 | These ultimately end up as "Subject Alternative Names", which are 352 | what modern programs are supposed to use when checking identity. 353 | 354 | common_name: Sets the "Common Name" of the certificate. This is a 355 | legacy field that used to be used to check identity. It's an 356 | arbitrary string with poorly-defined semantics, so `modern 357 | programs are supposed to ignore it 358 | `__. 359 | But it might be useful if you need to test how your software 360 | handles legacy or buggy certificates. 361 | 362 | organization_name: Sets the "Organization Name" (O) attribute on the 363 | certificate. By default, it will be "trustme" suffixed with a 364 | version number. 365 | 366 | organization_unit_name: Sets the "Organization Unit Name" (OU) 367 | attribute on the certificate. By default, a random one will be 368 | generated. 369 | 370 | not_before: Set the validity start date (notBefore) of the certificate. 371 | This argument type is `datetime.datetime`. 372 | 373 | not_after: Set the expiry date (notAfter) of the certificate. This 374 | argument type is `datetime.datetime`. 375 | 376 | key_type: Set the type of key that is used for the certificate. By default this is an ECDSA based key. 377 | 378 | Returns: 379 | LeafCert: the newly-generated certificate. 380 | 381 | """ 382 | if not identities and common_name is None: 383 | raise ValueError("Must specify at least one identity or common name") 384 | 385 | key = key_type._generate_key() 386 | 387 | ski_ext = self._certificate.extensions.get_extension_for_class( 388 | x509.SubjectKeyIdentifier 389 | ) 390 | aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( 391 | ski_ext.value 392 | ) 393 | 394 | cert = ( 395 | _cert_builder_common( 396 | _name( 397 | organization_unit_name or "Testing cert #" + random_text(), 398 | organization_name=organization_name, 399 | common_name=common_name, 400 | ), 401 | self._certificate.subject, 402 | key.public_key(), 403 | not_before=not_before, 404 | not_after=not_after, 405 | ) 406 | .add_extension( 407 | x509.BasicConstraints(ca=False, path_length=None), 408 | critical=True, 409 | ) 410 | .add_extension(aki, critical=False) 411 | .add_extension( 412 | x509.SubjectAlternativeName( 413 | [_identity_string_to_x509(ident) for ident in identities] 414 | ), 415 | critical=True, 416 | ) 417 | .add_extension( 418 | x509.KeyUsage( 419 | digital_signature=True, 420 | content_commitment=False, 421 | key_encipherment=True, 422 | data_encipherment=False, 423 | key_agreement=False, 424 | key_cert_sign=False, 425 | crl_sign=False, 426 | encipher_only=False, 427 | decipher_only=False, 428 | ), 429 | critical=True, 430 | ) 431 | .add_extension( 432 | x509.ExtendedKeyUsage( 433 | [ 434 | ExtendedKeyUsageOID.CLIENT_AUTH, 435 | ExtendedKeyUsageOID.SERVER_AUTH, 436 | ExtendedKeyUsageOID.CODE_SIGNING, 437 | ] 438 | ), 439 | critical=True, 440 | ) 441 | .sign( 442 | private_key=self._private_key, 443 | algorithm=hashes.SHA256(), 444 | ) 445 | ) 446 | 447 | chain_to_ca = [] 448 | ca = self 449 | while ca.parent_cert is not None: 450 | chain_to_ca.append(ca._certificate.public_bytes(Encoding.PEM)) 451 | ca = ca.parent_cert 452 | 453 | return LeafCert( 454 | key.private_bytes( 455 | Encoding.PEM, 456 | PrivateFormat.TraditionalOpenSSL, 457 | NoEncryption(), 458 | ), 459 | cert.public_bytes(Encoding.PEM), 460 | chain_to_ca, 461 | ) 462 | 463 | # For backwards compatibility 464 | issue_server_cert = issue_cert 465 | 466 | def configure_trust(self, ctx: Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None: 467 | """Configure the given context object to trust certificates signed by 468 | this CA. 469 | 470 | Args: 471 | ctx: The SSL context to be modified. 472 | 473 | """ 474 | if isinstance(ctx, ssl.SSLContext): 475 | ctx.load_verify_locations(cadata=self.cert_pem.bytes().decode("ascii")) 476 | elif _smells_like_pyopenssl(ctx): 477 | from OpenSSL import crypto 478 | 479 | cert = crypto.load_certificate(crypto.FILETYPE_PEM, self.cert_pem.bytes()) 480 | store = ctx.get_cert_store() 481 | store.add_cert(cert) 482 | else: 483 | raise TypeError( 484 | "unrecognized context type {!r}".format(ctx.__class__.__name__) 485 | ) 486 | 487 | @classmethod 488 | def from_pem(cls, cert_bytes: bytes, private_key_bytes: bytes) -> "CA": 489 | """Build a CA from existing cert and private key. 490 | 491 | This is useful if your test suite has an existing certificate authority and 492 | you're not ready to switch completely to trustme just yet. 493 | 494 | Args: 495 | cert_bytes: The bytes of the certificate in PEM format 496 | private_key_bytes: The bytes of the private key in PEM format 497 | """ 498 | ca = cls() 499 | ca.parent_cert = None 500 | ca._certificate = x509.load_pem_x509_certificate(cert_bytes) 501 | ca._private_key = load_pem_private_key(private_key_bytes, password=None) # type: ignore[assignment] 502 | 503 | return ca 504 | 505 | 506 | class LeafCert: 507 | """A server or client certificate. 508 | 509 | This type has no public constructor; you get one by calling 510 | `CA.issue_cert` or similar. 511 | 512 | Attributes: 513 | private_key_pem (`Blob`): The PEM-encoded private key corresponding to 514 | this certificate. 515 | 516 | cert_chain_pems (list of `Blob` objects): The zeroth entry in this list 517 | is the actual PEM-encoded certificate, and any entries after that 518 | are the rest of the certificate chain needed to reach the root CA. 519 | 520 | private_key_and_cert_chain_pem (`Blob`): A single `Blob` containing the 521 | concatenation of the PEM-encoded private key and the PEM-encoded 522 | cert chain. 523 | 524 | """ 525 | 526 | def __init__( 527 | self, private_key_pem: bytes, server_cert_pem: bytes, chain_to_ca: List[bytes] 528 | ) -> None: 529 | self.private_key_pem = Blob(private_key_pem) 530 | self.cert_chain_pems = [Blob(pem) for pem in [server_cert_pem] + chain_to_ca] 531 | self.private_key_and_cert_chain_pem = Blob( 532 | private_key_pem + server_cert_pem + b"".join(chain_to_ca) 533 | ) 534 | 535 | def configure_cert(self, ctx: Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None: 536 | """Configure the given context object to present this certificate. 537 | 538 | Args: 539 | ctx: The SSL context to be modified. 540 | 541 | """ 542 | if isinstance(ctx, ssl.SSLContext): 543 | # Currently need a temporary file for this, see: 544 | # https://bugs.python.org/issue16487 545 | with self.private_key_and_cert_chain_pem.tempfile() as path: 546 | ctx.load_cert_chain(path) 547 | elif _smells_like_pyopenssl(ctx): 548 | key = load_pem_private_key(self.private_key_pem.bytes(), None) 549 | ctx.use_privatekey(key) # type: ignore[arg-type] 550 | cert = x509.load_pem_x509_certificate(self.cert_chain_pems[0].bytes()) 551 | ctx.use_certificate(cert) # type: ignore[arg-type] 552 | for pem in self.cert_chain_pems[1:]: 553 | cert = x509.load_pem_x509_certificate(pem.bytes()) 554 | ctx.add_extra_chain_cert(cert) # type: ignore[arg-type] 555 | else: 556 | raise TypeError( 557 | "unrecognized context type {!r}".format(ctx.__class__.__name__) 558 | ) 559 | --------------------------------------------------------------------------------