├── 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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------