├── tests ├── __init__.py ├── constraints │ ├── oldest-cryptography.txt │ └── oldest-pyopenssl.txt ├── conftest.py ├── test_packaging.py ├── typing │ └── api.py ├── test_pyopenssl.py ├── test_cryptography.py ├── certificates.py └── test_hazmat.py ├── .python-version-default ├── src └── service_identity │ ├── py.typed │ ├── __init__.py │ ├── exceptions.py │ ├── pyopenssl.py │ ├── cryptography.py │ └── hazmat.py ├── docs ├── changelog.md ├── implemented-standards.md ├── license.md ├── _static │ └── custom.css ├── index.md ├── pyopenssl_example.py ├── installation.md ├── api.rst ├── conf.py └── Makefile ├── .github ├── FUNDING.yml ├── dependabot.yml ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── workflows │ ├── codeql-analysis.yml │ ├── zizmor.yml │ ├── pypi-package.yml │ └── ci.yml └── CONTRIBUTING.md ├── .git_archival.txt ├── .gitattributes ├── .gitignore ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── tox.ini ├── README.md ├── pyproject.toml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version-default: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /src/service_identity/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | ``` 3 | -------------------------------------------------------------------------------- /tests/constraints/oldest-cryptography.txt: -------------------------------------------------------------------------------- 1 | attrs==19.1.0 2 | cryptography<3 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: hynek 3 | tidelift: "pypi/service-identity" 4 | -------------------------------------------------------------------------------- /tests/constraints/oldest-pyopenssl.txt: -------------------------------------------------------------------------------- 1 | attrs==19.1.0 2 | cryptography<35 3 | pyOpenSSL==17.1.0 4 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 6da81275269aaa67492b69cdbacc4acf14f98582 2 | node-date: 2025-12-01T20:23:24+01:00 3 | describe-name: 24.2.0-16-g6da8127 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force LF line endings for text files 2 | * text=auto eol=lf 3 | 4 | # Needed for hatch-vcs / setuptools-scm-git-archive 5 | .git_archival.txt export-subst 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | dist 3 | build 4 | .coverage* 5 | .venv 6 | htmlcov 7 | _build 8 | *.pyc 9 | .cache 10 | .pytest_cache 11 | .vscode 12 | .envrc 13 | .direnv 14 | -------------------------------------------------------------------------------- /docs/implemented-standards.md: -------------------------------------------------------------------------------- 1 | # Implemented Standards 2 | 3 | - `dNSName` (DNS-ID, aka host names, {rfc}`6125`). 4 | - `iPAddress` ({rfc}`2818`). 5 | - `uniformResourceIdentifier` (URI-ID, {rfc}`6125`). 6 | - SRV-ID ({rfc}`6125`) 7 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License and Credits 2 | 3 | *service-identity* is released under the [MIT](https://github.com/pyca/service-identity/blob/main/LICENSE) license. 4 | 5 | ```{include} ../README.md 6 | :parser: myst_parser.sphinx_ 7 | :start-after: "## Credits" 8 | :end-before: "### *service-identity* for Enterprise" 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/inter/inter.css'); 2 | @import url('https://assets.hynek.me/css/bm.css'); 3 | 4 | 5 | :root { 6 | font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ 7 | } 8 | @supports (font-variation-settings: normal) { 9 | :root { font-family: InterVariable, sans-serif; } 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | cooldown: 9 | # https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns 10 | default-days: 7 11 | groups: 12 | github-actions: 13 | patterns: 14 | - "*" 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-lts-latest 6 | tools: 7 | # Keep version in sync with tox.ini/docs and ci.yml/docs. 8 | python: "3.13" 9 | jobs: 10 | create_environment: 11 | # Need the tags to calculate the version (sometimes). 12 | - git fetch --tags 13 | 14 | - asdf plugin add uv 15 | - asdf install uv latest 16 | - asdf global uv latest 17 | 18 | build: 19 | html: 20 | - uvx --with tox-uv tox run -e docs-build -- $READTHEDOCS_OUTPUT 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import cryptography 2 | 3 | 4 | try: 5 | import OpenSSL 6 | import OpenSSL.SSL 7 | except ImportError: 8 | OpenSSL = None 9 | 10 | 11 | def pytest_report_header(config): 12 | if OpenSSL is not None: 13 | openssl_version = OpenSSL.SSL.SSLeay_version( 14 | OpenSSL.SSL.SSLEAY_VERSION 15 | ).decode("ascii") 16 | pyopenssl_version = OpenSSL.__version__ 17 | else: 18 | openssl_version = "n/a" 19 | pyopenssl_version = "missing" 20 | 21 | return f"""\ 22 | OpenSSL: {openssl_version} 23 | pyOpenSSL: {pyopenssl_version} 24 | cryptography: {cryptography.__version__}""" 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.13.2 5 | hooks: 6 | - id: ruff-check 7 | args: [--fix, --exit-non-zero-on-fix] 8 | - id: ruff-format 9 | 10 | - repo: https://github.com/codespell-project/codespell 11 | rev: v2.4.1 12 | hooks: 13 | - id: codespell 14 | args: [-L, fo] 15 | 16 | - repo: https://github.com/econchick/interrogate 17 | rev: 1.7.0 18 | hooks: 19 | - id: interrogate 20 | args: [tests] 21 | 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v6.0.0 24 | hooks: 25 | - id: trailing-whitespace 26 | - id: end-of-file-fixer 27 | - id: check-toml 28 | - id: check-yaml 29 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | import pytest 4 | 5 | import service_identity 6 | 7 | 8 | class TestLegacyMetadataHack: 9 | def test_version(self): 10 | """ 11 | service_identity.__version__ returns the correct version. 12 | """ 13 | assert ( 14 | metadata.version("service-identity") 15 | == service_identity.__version__ 16 | ) 17 | 18 | def test_does_not_exist(self): 19 | """ 20 | Asking for unsupported dunders raises an AttributeError. 21 | """ 22 | with pytest.raises( 23 | AttributeError, 24 | match="module service_identity has no attribute __yolo__", 25 | ): 26 | service_identity.__yolo__ 27 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We are following [Calendar Versioning](https://calver.org) with generous backwards-compatibility guarantees. 6 | *Therefore we only support the latest version*. 7 | 8 | That said, you shouldn't be afraid to upgrade if you're only using our documented public APIs and pay attention to `DeprecationWarning`s. 9 | Whenever there is a need to break compatibility, it is announced [in the changelog](https://github.com/pyca/service-identity/blob/main/CHANGELOG.md) and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. 10 | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). 15 | Tidelift will coordinate the fix and disclosure. 16 | -------------------------------------------------------------------------------- /src/service_identity/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Verify service identities. 3 | """ 4 | 5 | from . import cryptography, hazmat, pyopenssl 6 | from .exceptions import ( 7 | CertificateError, 8 | SubjectAltNameWarning, 9 | VerificationError, 10 | ) 11 | 12 | 13 | __title__ = "service-identity" 14 | 15 | __author__ = "Hynek Schlawack" 16 | 17 | __license__ = "MIT" 18 | __copyright__ = "Copyright (c) 2014 " + __author__ 19 | 20 | 21 | __all__ = [ 22 | "CertificateError", 23 | "SubjectAltNameWarning", 24 | "VerificationError", 25 | "cryptography", 26 | "hazmat", 27 | "pyopenssl", 28 | ] 29 | 30 | 31 | def __getattr__(name: str) -> str: 32 | if name != "__version__": 33 | msg = f"module {__name__} has no attribute {name}" 34 | raise AttributeError(msg) 35 | 36 | from importlib.metadata import version # noqa: PLC0415 37 | 38 | return version("service-identity") 39 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | While not being a [Python Software Foundation](https://www.python.org/psf-landing/) project, everyone interacting in this project is expected to follow the [PSF Code of Conduct](https://policies.python.org/python.org/code-of-conduct/). 4 | 5 | In general, this means that everyone is expected to be **open**, **considerate**, and **respectful** of others no matter what their position is within the project. 6 | 7 | 8 | ## Enforcement 9 | 10 | We take Code of Conduct violations seriously, and will act to ensure our spaces are welcoming, inclusive, and professional environments to communicate in. 11 | 12 | If you need to raise a Code of Conduct report, you may do so privately by email to [Hynek Schlawack](mailto:hs@ox.cx). 13 | 14 | Reports will be treated confidentially. 15 | 16 | Alternately you can make a [report to the Python Software Foundation](https://policies.python.org/python.org/code-of-conduct/Procedures-for-Reporting-Incidents/). 17 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Service Identity Verification for pyOpenSSL & cryptography 2 | 3 | Release **{sub-ref}`release`** ({doc}`What's new? `) 4 | 5 | ```{include} ../README.md 6 | :start-after: "spiel-begin -->" 7 | :end-before: "## Project Information" 8 | ``` 9 | 10 | ## User's Guide 11 | 12 | ```{toctree} 13 | :maxdepth: 1 14 | 15 | installation 16 | implemented-standards 17 | api 18 | ``` 19 | 20 | ## Indices and tables 21 | 22 | - {ref}`genindex` 23 | - {ref}`search` 24 | 25 | 26 | ## *service-identity* for Enterprise 27 | 28 | ```{include} ../README.md 29 | :start-after: "*service-identity* for Enterprise" 30 | ``` 31 | 32 | 33 | ```{toctree} 34 | :hidden: 35 | :caption: Meta 36 | 37 | license 38 | changelog 39 | PyPI 40 | GitHub 41 | Contributing 42 | Security Policy 43 | Funding 44 | ``` 45 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | on: 5 | schedule: 6 | - cron: "41 3 * * 6" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [python] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Hynek Schlawack and the service-identity contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/woodruffw/zizmor 2 | name: Zizmor 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["*"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | 14 | jobs: 15 | zizmor: 16 | name: Zizmor latest via PyPI 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 23 | with: 24 | persist-credentials: false 25 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 26 | 27 | - name: Run zizmor 🌈 28 | run: uvx zizmor --format sarif . > results.sarif 29 | env: 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Upload SARIF file 33 | uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 34 | with: 35 | # Path to SARIF file relative to the root of the repository 36 | sarif_file: results.sarif 37 | # Optional category for the results 38 | # Used to differentiate multiple results for one commit 39 | category: zizmor 40 | -------------------------------------------------------------------------------- /tests/typing/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is used to test the typing of the public API of service-identity. 3 | 4 | It is NOT intended to be executed. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import socket 10 | 11 | from typing import Sequence 12 | 13 | from cryptography.hazmat.backends import default_backend 14 | from cryptography.x509 import load_pem_x509_certificate 15 | from OpenSSL import SSL 16 | 17 | import service_identity 18 | 19 | 20 | backend = default_backend() 21 | c_cert = load_pem_x509_certificate("foo.pem", backend) 22 | 23 | c_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( 24 | service_identity.cryptography.extract_patterns(c_cert) 25 | ) 26 | service_identity.cryptography.verify_certificate_hostname( 27 | c_cert, "example.com" 28 | ) 29 | service_identity.cryptography.verify_certificate_ip_address( 30 | c_cert, "127.0.0.1" 31 | ) 32 | 33 | 34 | ctx = SSL.Context(SSL.TLSv1_2_METHOD) 35 | conn = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 36 | p_cert = conn.get_peer_certificate() 37 | assert p_cert 38 | 39 | p_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( 40 | service_identity.pyopenssl.extract_patterns(p_cert) 41 | ) 42 | service_identity.pyopenssl.verify_hostname(conn, "example.com") 43 | service_identity.pyopenssl.verify_ip_address(conn, "127.0.0.1") 44 | -------------------------------------------------------------------------------- /docs/pyopenssl_example.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pprint 3 | import socket 4 | 5 | import idna 6 | 7 | from OpenSSL import SSL 8 | 9 | import service_identity 10 | 11 | 12 | parser = argparse.ArgumentParser( 13 | description="Connect to HOST, inspect its certificate " 14 | "and verify if it's valid for its hostname." 15 | ) 16 | parser.add_argument("HOST") 17 | args = parser.parse_args() 18 | hostname = args.HOST 19 | 20 | ctx = SSL.Context(SSL.TLSv1_2_METHOD) 21 | ctx.set_verify(SSL.VERIFY_PEER, lambda conn, cert, errno, depth, ok: bool(ok)) 22 | ctx.set_default_verify_paths() 23 | 24 | conn = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) 25 | conn.set_tlsext_host_name(idna.encode(hostname)) 26 | conn.connect((hostname, 443)) 27 | 28 | try: 29 | conn.do_handshake() 30 | 31 | if cert := conn.get_peer_certificate(): 32 | print("Server certificate is valid for the following patterns:\n") 33 | pprint.pprint(service_identity.pyopenssl.extract_patterns(cert)) 34 | 35 | try: 36 | service_identity.pyopenssl.verify_hostname(conn, hostname) 37 | except service_identity.VerificationError: 38 | print(f"\nPresented certificate is NOT valid for {hostname}.") 39 | finally: 40 | conn.shutdown() 41 | except SSL.Error as e: 42 | print(f"TLS Handshake failed: {e!r}.") 43 | finally: 44 | conn.close() 45 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation and Requirements 2 | 3 | ## Installation 4 | 5 | ```console 6 | $ python -Im pip install service-identity 7 | ``` 8 | 9 | 10 | ## Requirements 11 | 12 | *service-identity* depends on the [cryptography] package. 13 | In addition to the latest release, we're also testing against the following oldest version constraint: 14 | 15 | ```{include} ../tests/constraints/oldest-cryptography.txt 16 | :literal: true 17 | ``` 18 | 19 | If you want to use the [pyOpenSSL] functionality, you have to install it yourself. 20 | In addition to the latest release, we are also testing against the following oldest version constraints 21 | (you have to add the *cryptography* pin yourself, if you want to use an old version of pyOpenSSL): 22 | 23 | ```{include} ../tests/constraints/oldest-pyopenssl.txt 24 | :literal: true 25 | ``` 26 | 27 | 28 | ### International Domain Names 29 | 30 | Optionally, the `idna` extra dependency can be used for [internationalized domain names] (IDN), i.e. non-ASCII domains: 31 | 32 | ```console 33 | $ python -Im pip install service-identity[idna] 34 | ``` 35 | 36 | Unfortunately it's required because Python's IDN support in the standard library is [outdated] even in the latest releases. 37 | 38 | [cryptography]: https://cryptography.io/ 39 | [idna]: https://pypi.org/project/idna/ 40 | [internationalized domain names]: https://en.wikipedia.org/wiki/Internationalized_domain_name 41 | [outdated]: https://github.com/python/cpython/issues/61507 42 | [pyopenssl]: https://pypi.org/project/pyOpenSSL/ 43 | -------------------------------------------------------------------------------- /src/service_identity/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | All exceptions and warnings thrown by ``service-identity``. 3 | 4 | Separated into an own package for nicer tracebacks, you should still import 5 | them from __init__.py. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from typing import TYPE_CHECKING, Sequence 11 | 12 | 13 | if TYPE_CHECKING: 14 | from .hazmat import ServiceID 15 | 16 | import attr 17 | 18 | 19 | class SubjectAltNameWarning(DeprecationWarning): 20 | """ 21 | This warning is not used anymore and will be removed in a future version. 22 | 23 | Formerly: 24 | 25 | Server Certificate does not contain a ``SubjectAltName``. 26 | 27 | Hostname matching is performed on the ``CommonName`` which is deprecated. 28 | 29 | .. deprecated:: 23.1.0 30 | """ 31 | 32 | 33 | @attr.s(slots=True) 34 | class Mismatch: 35 | mismatched_id: ServiceID = attr.ib() 36 | 37 | 38 | class DNSMismatch(Mismatch): 39 | """ 40 | No matching DNSPattern could be found. 41 | """ 42 | 43 | 44 | class SRVMismatch(Mismatch): 45 | """ 46 | No matching SRVPattern could be found. 47 | """ 48 | 49 | 50 | class URIMismatch(Mismatch): 51 | """ 52 | No matching URIPattern could be found. 53 | """ 54 | 55 | 56 | class IPAddressMismatch(Mismatch): 57 | """ 58 | No matching IPAddressPattern could be found. 59 | """ 60 | 61 | 62 | @attr.s(auto_exc=True) 63 | class VerificationError(Exception): 64 | """ 65 | Service identity verification failed. 66 | """ 67 | 68 | errors: Sequence[Mismatch] = attr.ib() 69 | 70 | def __str__(self) -> str: 71 | return self.__repr__() 72 | 73 | 74 | class CertificateError(Exception): 75 | r""" 76 | Certificate contains invalid or unexpected data. 77 | 78 | This includes the case where s certificate contains no 79 | ``subjectAltName``\ s. 80 | """ 81 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | .. note:: 6 | 7 | So far, public high-level APIs are only available for host names (:rfc:`6125`) and IP addresses (:rfc:`2818`). 8 | All IDs specified by :rfc:`6125` are already implemented though. 9 | If you'd like to play with them and provide feedback have a look at the ``verify_service_identity`` function in the `hazmat module `_. 10 | 11 | 12 | PyCA cryptography 13 | ================= 14 | 15 | .. currentmodule:: service_identity.cryptography 16 | 17 | .. autofunction:: verify_certificate_hostname 18 | .. autofunction:: verify_certificate_ip_address 19 | .. autofunction:: extract_patterns 20 | 21 | 22 | pyOpenSSL 23 | ========= 24 | 25 | .. currentmodule:: service_identity.pyopenssl 26 | 27 | .. autofunction:: verify_hostname 28 | 29 | In practice, this may look like the following: 30 | 31 | .. include:: pyopenssl_example.py 32 | :literal: 33 | 34 | .. autofunction:: verify_ip_address 35 | .. autofunction:: extract_patterns 36 | 37 | 38 | Hazardous Materials 39 | =================== 40 | 41 | .. currentmodule:: service_identity.hazmat 42 | 43 | 44 | .. danger:: 45 | 46 | The following APIs require reader's discretion. 47 | They are stable and they've been using internally by *service-identity* for years, but you need to know what you're doing. 48 | 49 | 50 | Pattern Objects 51 | --------------- 52 | 53 | The following are the objects return by the ``extract_patterns`` functions. 54 | They each carry the attributes that are necessary to match an ID of their type. 55 | 56 | 57 | .. autoclass:: CertificatePattern 58 | 59 | It includes all of those that follow now. 60 | 61 | .. autoclass:: DNSPattern 62 | :members: 63 | .. autoclass:: IPAddressPattern 64 | :members: 65 | .. autoclass:: URIPattern 66 | :members: 67 | .. autoclass:: SRVPattern 68 | :members: 69 | 70 | 71 | Universal Errors and Warnings 72 | ============================= 73 | 74 | .. currentmodule:: service_identity 75 | 76 | .. autoexception:: VerificationError 77 | .. autoexception:: CertificateError 78 | .. autoexception:: SubjectAltNameWarning 79 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4 3 | env_list = 4 | lint, 5 | mypy-{api,pkg}, 6 | docs-{build,doctests}, 7 | py3{8,9,10,11,12,13,14}{,-pyopenssl}{,-oldest}{,-idna}, 8 | coverage-report 9 | 10 | 11 | [testenv] 12 | package = wheel 13 | wheel_build_env = .pkg 14 | extras = 15 | tests 16 | idna: idna 17 | deps = 18 | pyopenssl: pyopenssl 19 | pass_env = 20 | FORCE_COLOR 21 | NO_COLOR 22 | set_env = 23 | oldest: PIP_CONSTRAINT = tests/constraints/oldest-cryptography.txt 24 | pyopenssl-oldest: PIP_CONSTRAINT = tests/constraints/oldest-pyopenssl.txt 25 | commands = 26 | coverage run -m pytest {posargs} 27 | py312-pyopenssl-latest-idna: coverage run -m pytest --doctest-modules --doctest-glob='*.rst' {posargs} 28 | 29 | 30 | [testenv:coverage-report] 31 | # keep in-sync with .python-version-default 32 | base_python = py313 33 | deps = coverage 34 | skip_install = true 35 | commands = 36 | coverage combine 37 | coverage report 38 | 39 | 40 | [testenv:lint] 41 | skip_install = true 42 | deps = prek 43 | commands = prek run --all-files {posargs} 44 | 45 | 46 | [testenv:mypy-api] 47 | extras = mypy 48 | commands = mypy tests/typing docs/pyopenssl_example.py 49 | 50 | 51 | [testenv:mypy-pkg] 52 | extras = mypy 53 | commands = mypy src 54 | 55 | 56 | [testenv:docs-{build,doctests,linkcheck}] 57 | # Keep base_python in sync with gh-actions and .readthedocs.yaml. 58 | base_python = py313 59 | extras = docs 60 | commands = 61 | # N.B. doctests is not a nitpicky as build -- we need to run both in CI! 62 | build: sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 63 | doctests: sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 64 | linkcheck: sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/html 65 | 66 | [testenv:docs-watch] 67 | package = editable 68 | base_python = {[testenv:docs-build]base_python} 69 | extras = {[testenv:docs-build]extras} 70 | deps = watchfiles 71 | commands = 72 | watchfiles \ 73 | --ignore-paths docs/_build/ \ 74 | 'sphinx-build -W -n --jobs auto -b html -d {envtmpdir}/doctrees docs docs/_build/html' \ 75 | src \ 76 | docs 77 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & upload PyPI package 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | 14 | jobs: 15 | # Always build & lint package. 16 | build-package: 17 | name: Build & verify package 18 | runs-on: ubuntu-latest 19 | permissions: 20 | attestations: write 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@efb823f52190ad02594531168b7a2d5790e66516 # v2.14.0 30 | with: 31 | attest-build-provenance-github: 'true' 32 | 33 | # Upload to Test PyPI on every commit on main. 34 | release-test-pypi: 35 | name: Publish in-dev package to test.pypi.org 36 | environment: release-test-pypi 37 | if: github.repository_owner == 'pyca' && github.event_name == 'push' && github.ref == 'refs/heads/main' 38 | runs-on: ubuntu-latest 39 | needs: build-package 40 | 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - name: Download packages built by build-and-inspect-python-package 46 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 47 | with: 48 | name: Packages 49 | path: dist 50 | 51 | - name: Upload package to Test PyPI 52 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 53 | with: 54 | repository-url: https://test.pypi.org/legacy/ 55 | 56 | # Upload to real PyPI on GitHub Releases. 57 | release-pypi: 58 | name: Publish released package to pypi.org 59 | environment: release-pypi 60 | if: github.repository_owner == 'pyca' && github.event.action == 'published' 61 | runs-on: ubuntu-latest 62 | needs: build-package 63 | 64 | permissions: 65 | id-token: write 66 | 67 | steps: 68 | - name: Download packages built by build-and-inspect-python-package 69 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 70 | with: 71 | name: Packages 72 | path: dist 73 | 74 | - name: Upload package to PyPI 75 | uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Service Identity Verification 2 | 3 | Documentation 4 | License: MIT 5 | PyPI release 6 | Downloads per month 7 | 8 | PyCA on IRC 9 | 10 | 11 | 12 | Use this package if: 13 | 14 | - you want to **verify** that a [PyCA *cryptography*](https://cryptography.io/) certificate is valid for a certain hostname or IP address, 15 | - or if you use [pyOpenSSL](https://pypi.org/project/pyOpenSSL/) and don’t want to be [**MITM**](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)ed, 16 | - or if you want to **inspect** certificates from either for service IDs. 17 | 18 | *service-identity* aspires to give you all the tools you need for verifying whether a certificate is valid for the intended purposes. 19 | In the simplest case, this means *host name verification*. 20 | However, *service-identity* implements [RFC 6125](https://datatracker.ietf.org/doc/html/rfc6125.html) fully. 21 | 22 | Also check out [*pem*](https://github.com/hynek/pem) that makes loading certificates from all kinds of PEM-encoded files a breeze! 23 | 24 | 25 | ## Project Information 26 | 27 | *service-identity* is released under the [MIT](https://github.com/pyca/service-identity/blob/main/LICENSE) license, its documentation lives at [Read the Docs](https://service-identity.readthedocs.io/), the code on [GitHub](https://github.com/pyca/service-identity), and the latest release on [PyPI](https://pypi.org/project/service-identity/). 28 | 29 | 30 | ### Credits 31 | 32 | *service-identity* is written and maintained by [Hynek Schlawack](https://hynek.me/). 33 | 34 | The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *service-identity*'s [Tidelift subscribers](https://tidelift.com/lifter/search/pypi/service-identity), and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). 35 | 36 | 37 | ### *service-identity* for Enterprise 38 | 39 | Available as part of the [Tidelift Subscription](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek). 40 | 41 | The maintainers of *service-identity* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open-source packages you use to build your applications. 42 | Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. 43 | -------------------------------------------------------------------------------- /src/service_identity/pyopenssl.py: -------------------------------------------------------------------------------- 1 | """ 2 | `pyOpenSSL `_-specific code. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import contextlib 8 | import warnings 9 | 10 | from typing import Sequence 11 | 12 | from .cryptography import extract_patterns as _cryptography_extract_patterns 13 | from .hazmat import ( 14 | DNS_ID, 15 | CertificatePattern, 16 | IPAddress_ID, 17 | verify_service_identity, 18 | ) 19 | 20 | 21 | with contextlib.suppress(ImportError): 22 | # We only use it for docstrings -- `if TYPE_CHECKING`` does not work. 23 | from OpenSSL.crypto import X509 24 | from OpenSSL.SSL import Connection 25 | 26 | 27 | __all__ = ["verify_hostname"] 28 | 29 | 30 | def verify_hostname(connection: Connection, hostname: str) -> None: 31 | r""" 32 | Verify whether the certificate of *connection* is valid for *hostname*. 33 | 34 | Args: 35 | connection: A pyOpenSSL connection object. 36 | 37 | hostname: The hostname that *connection* should be connected to. 38 | 39 | Raises: 40 | service_identity.VerificationError: 41 | If *connection* does not provide a certificate that is valid for 42 | *hostname*. 43 | 44 | service_identity.CertificateError: 45 | If certificate provided by *connection* contains invalid / 46 | unexpected data. This includes the case where the certificate 47 | contains no ``subjectAltName``\ s. 48 | 49 | .. versionchanged:: 24.1.0 50 | :exc:`~service_identity.CertificateError` is raised if the certificate 51 | contains no ``subjectAltName``\ s instead of 52 | :exc:`~service_identity.VerificationError`. 53 | """ 54 | verify_service_identity( 55 | cert_patterns=extract_patterns( 56 | connection.get_peer_certificate() # type:ignore[arg-type] 57 | ), 58 | obligatory_ids=[DNS_ID(hostname)], 59 | optional_ids=[], 60 | ) 61 | 62 | 63 | def verify_ip_address(connection: Connection, ip_address: str) -> None: 64 | r""" 65 | Verify whether the certificate of *connection* is valid for *ip_address*. 66 | 67 | Args: 68 | connection: A pyOpenSSL connection object. 69 | 70 | ip_address: 71 | The IP address that *connection* should be connected to. Can be an 72 | IPv4 or IPv6 address. 73 | 74 | Raises: 75 | service_identity.VerificationError: 76 | If *connection* does not provide a certificate that is valid for 77 | *ip_address*. 78 | 79 | service_identity.CertificateError: 80 | If the certificate chain of *connection* contains a certificate 81 | that contains invalid/unexpected data. 82 | 83 | .. versionadded:: 18.1.0 84 | 85 | .. versionchanged:: 24.1.0 86 | :exc:`~service_identity.CertificateError` is raised if the certificate 87 | contains no ``subjectAltName``\ s instead of 88 | :exc:`~service_identity.VerificationError`. 89 | """ 90 | verify_service_identity( 91 | cert_patterns=extract_patterns( 92 | connection.get_peer_certificate() # type:ignore[arg-type] 93 | ), 94 | obligatory_ids=[IPAddress_ID(ip_address)], 95 | optional_ids=[], 96 | ) 97 | 98 | 99 | def extract_patterns(cert: X509) -> Sequence[CertificatePattern]: 100 | """ 101 | Extract all valid ID patterns from a certificate for service verification. 102 | 103 | Args: 104 | cert: The certificate to be dissected. 105 | 106 | Returns: 107 | List of IDs. 108 | 109 | .. versionchanged:: 23.1.0 110 | ``commonName`` is not used as a fallback anymore. 111 | """ 112 | return _cryptography_extract_patterns(cert.to_cryptography()) 113 | 114 | 115 | def extract_ids(cert: X509) -> Sequence[CertificatePattern]: 116 | """ 117 | Deprecated and never public API. Use :func:`extract_patterns` instead. 118 | 119 | .. deprecated:: 23.1.0 120 | """ 121 | warnings.warn( 122 | category=DeprecationWarning, 123 | message="`extract_ids()` is deprecated, please use `extract_patterns()`.", 124 | stacklevel=2, 125 | ) 126 | return extract_patterns(cert) 127 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | 4 | # Add any Sphinx extension module names here, as strings. They can be 5 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 6 | # ones. 7 | extensions = [ 8 | "sphinx.ext.doctest", 9 | "sphinx.ext.autodoc", 10 | "sphinx.ext.intersphinx", 11 | "sphinx.ext.napoleon", 12 | "myst_parser", 13 | "notfound.extension", 14 | ] 15 | 16 | myst_enable_extensions = [ 17 | "colon_fence", 18 | "smartquotes", 19 | "deflist", 20 | ] 21 | 22 | # Move type hints into the description block, instead of the func definition. 23 | autodoc_typehints = "description" 24 | autodoc_typehints_description_target = "documented" 25 | 26 | # GitHub has rate limits 27 | linkcheck_ignore = [ 28 | r"https://github.com/.*/(issues|pull|compare)/\d+", 29 | r"https://twitter.com/.*", 30 | ] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ["_templates"] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = ".rst" 37 | 38 | # The master toctree document. 39 | master_doc = "index" 40 | 41 | # General information about the project. 42 | project = "service-identity" 43 | year = 2014 44 | copyright = "2014, Hynek Schlawack" 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | 50 | release = metadata.version("service-identity") 51 | # The short X.Y version. 52 | version = release.rsplit(".", 1)[0] 53 | 54 | # Avoid confusing in-dev versions. 55 | if "dev" in release: 56 | release = version = "UNRELEASED" 57 | 58 | # List of patterns, relative to source directory, that match files and 59 | # directories to ignore when looking for source files. 60 | exclude_patterns = ["_build"] 61 | 62 | nitpick_ignore = [ 63 | ("py:class", "cryptography.hazmat.bindings._rust.x509.Certificate"), 64 | ] 65 | 66 | # -- Options for HTML output ---------------------------------------------- 67 | 68 | html_theme = "furo" 69 | html_theme_options = { 70 | "top_of_page_buttons": [], 71 | "light_css_variables": { 72 | "font-stack": "Inter, sans-serif", 73 | "font-stack--monospace": "BerkeleyMono, MonoLisa, ui-monospace, " 74 | "SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace", 75 | }, 76 | } 77 | html_static_path = ["_static"] 78 | html_css_files = ["custom.css"] 79 | 80 | # Output file base name for HTML help builder. 81 | htmlhelp_basename = "service-identitydoc" 82 | 83 | 84 | # -- Options for LaTeX output --------------------------------------------- 85 | 86 | latex_elements = {} 87 | 88 | # Grouping the document tree into LaTeX files. List of tuples 89 | # (source start file, target name, title, 90 | # author, documentclass [howto, manual, or own class]). 91 | latex_documents = [ 92 | ( 93 | "index", 94 | "service-identity.tex", 95 | "service\\_identity Documentation", 96 | "Hynek Schlawack", 97 | "manual", 98 | ) 99 | ] 100 | 101 | # -- Options for manual page output --------------------------------------- 102 | 103 | # One entry per manual page. List of tuples 104 | # (source start file, name, description, authors, manual section). 105 | man_pages = [ 106 | ( 107 | "index", 108 | "service-identity", 109 | "service-identity Documentation", 110 | ["Hynek Schlawack"], 111 | 1, 112 | ) 113 | ] 114 | 115 | # -- Options for Texinfo output ------------------------------------------- 116 | 117 | # Grouping the document tree into Texinfo files. List of tuples 118 | # (source start file, target name, title, author, 119 | # dir menu entry, description, category) 120 | texinfo_documents = [ 121 | ( 122 | "index", 123 | "service-identity", 124 | "service-identity Documentation", 125 | "Hynek Schlawack", 126 | "service-identity", 127 | "Service Identity Verification for pyOpenSSL", 128 | "Miscellaneous", 129 | ) 130 | ] 131 | 132 | 133 | intersphinx_mapping = { 134 | "python": ("https://docs.python.org/3", None), 135 | "pyopenssl": ("https://www.pyopenssl.org/en/stable/", None), 136 | "cryptography": ("https://cryptography.io/en/stable/", None), 137 | } 138 | -------------------------------------------------------------------------------- /tests/test_pyopenssl.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | import pytest 4 | 5 | from service_identity.exceptions import ( 6 | DNSMismatch, 7 | IPAddressMismatch, 8 | VerificationError, 9 | ) 10 | from service_identity.hazmat import ( 11 | DNS_ID, 12 | DNSPattern, 13 | IPAddress_ID, 14 | IPAddressPattern, 15 | URIPattern, 16 | ) 17 | from service_identity.pyopenssl import ( 18 | extract_ids, 19 | extract_patterns, 20 | verify_hostname, 21 | verify_ip_address, 22 | ) 23 | 24 | from .certificates import ( 25 | PEM_CN_ONLY, 26 | PEM_DNS_ONLY, 27 | PEM_EVERYTHING, 28 | PEM_OTHER_NAME, 29 | ) 30 | 31 | 32 | if pytest.importorskip("OpenSSL"): 33 | from OpenSSL.crypto import FILETYPE_PEM, load_certificate 34 | 35 | 36 | CERT_DNS_ONLY = load_certificate(FILETYPE_PEM, PEM_DNS_ONLY) 37 | CERT_CN_ONLY = load_certificate(FILETYPE_PEM, PEM_CN_ONLY) 38 | CERT_OTHER_NAME = load_certificate(FILETYPE_PEM, PEM_OTHER_NAME) 39 | CERT_EVERYTHING = load_certificate(FILETYPE_PEM, PEM_EVERYTHING) 40 | 41 | 42 | class TestPublicAPI: 43 | def test_verify_hostname_ok(self): 44 | """ 45 | verify_hostname succeeds if the hostnames match. 46 | """ 47 | 48 | class FakeConnection: 49 | def get_peer_certificate(self): 50 | return CERT_DNS_ONLY 51 | 52 | verify_hostname(FakeConnection(), "twistedmatrix.com") 53 | 54 | def test_verify_hostname_fail(self): 55 | """ 56 | verify_hostname fails if the hostnames don't match and provides the 57 | user with helpful information. 58 | """ 59 | 60 | class FakeConnection: 61 | def get_peer_certificate(self): 62 | return CERT_DNS_ONLY 63 | 64 | with pytest.raises(VerificationError) as ei: 65 | verify_hostname(FakeConnection(), "google.com") 66 | 67 | assert [ 68 | DNSMismatch(mismatched_id=DNS_ID("google.com")) 69 | ] == ei.value.errors 70 | 71 | @pytest.mark.parametrize("ip", ["1.1.1.1", "::1"]) 72 | def test_verify_ip_address_ok(self, ip): 73 | """ 74 | verify_ip_address succeeds if the addresses match. Works both with IPv4 75 | and IPv6. 76 | """ 77 | 78 | class FakeConnection: 79 | def get_peer_certificate(self): 80 | return CERT_EVERYTHING 81 | 82 | verify_ip_address(FakeConnection(), ip) 83 | 84 | @pytest.mark.parametrize("ip", ["1.1.1.2", "::2"]) 85 | def test_verify_ip_address_fail(self, ip): 86 | """ 87 | verify_ip_address fails if the addresses don't match and provides the 88 | user with helpful information. Works both with IPv4 and IPv6. 89 | """ 90 | 91 | class FakeConnection: 92 | def get_peer_certificate(self): 93 | return CERT_EVERYTHING 94 | 95 | with pytest.raises(VerificationError) as ei: 96 | verify_ip_address(FakeConnection(), ip) 97 | 98 | assert [ 99 | IPAddressMismatch(mismatched_id=IPAddress_ID(ip)) 100 | ] == ei.value.errors 101 | 102 | 103 | class TestExtractPatterns: 104 | def test_dns(self): 105 | """ 106 | Returns the correct DNSPattern from a certificate. 107 | """ 108 | rv = extract_patterns(CERT_DNS_ONLY) 109 | assert [ 110 | DNSPattern.from_bytes(b"www.twistedmatrix.com"), 111 | DNSPattern.from_bytes(b"twistedmatrix.com"), 112 | ] == rv 113 | 114 | def test_cn_ids_are_ignored(self): 115 | """ 116 | commonName is not supported anymore and therefore ignored. 117 | """ 118 | assert [] == extract_patterns(CERT_CN_ONLY) 119 | 120 | def test_uri(self): 121 | """ 122 | Returns the correct URIPattern from a certificate. 123 | """ 124 | rv = extract_patterns(CERT_OTHER_NAME) 125 | assert [URIPattern.from_bytes(b"http://example.com/")] == [ 126 | id for id in rv if isinstance(id, URIPattern) 127 | ] 128 | 129 | def test_ip(self): 130 | """ 131 | Returns IP patterns. 132 | """ 133 | rv = extract_patterns(CERT_EVERYTHING) 134 | 135 | assert [ 136 | DNSPattern.from_bytes(pattern=b"service.identity.invalid"), 137 | DNSPattern.from_bytes( 138 | pattern=b"*.wildcard.service.identity.invalid" 139 | ), 140 | DNSPattern.from_bytes(pattern=b"service.identity.invalid"), 141 | DNSPattern.from_bytes(pattern=b"single.service.identity.invalid"), 142 | IPAddressPattern(pattern=ipaddress.IPv4Address("1.1.1.1")), 143 | IPAddressPattern(pattern=ipaddress.IPv6Address("::1")), 144 | IPAddressPattern(pattern=ipaddress.IPv4Address("2.2.2.2")), 145 | IPAddressPattern(pattern=ipaddress.IPv6Address("2a00:1c38::53")), 146 | ] == rv 147 | 148 | def test_extract_ids_deprecated(self): 149 | """ 150 | `extract_ids` raises a DeprecationWarning with correct stacklevel. 151 | """ 152 | with pytest.deprecated_call() as wr: 153 | extract_ids(CERT_EVERYTHING) 154 | 155 | w = wr.pop() 156 | 157 | assert ( 158 | "`extract_ids()` is deprecated, please use `extract_patterns()`." 159 | == w.message.args[0] 160 | ) 161 | assert __file__ == w.filename 162 | -------------------------------------------------------------------------------- /tests/test_cryptography.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | import pytest 4 | 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.x509 import load_pem_x509_certificate 7 | 8 | from service_identity.cryptography import ( 9 | extract_ids, 10 | extract_patterns, 11 | verify_certificate_hostname, 12 | verify_certificate_ip_address, 13 | ) 14 | from service_identity.exceptions import ( 15 | CertificateError, 16 | DNSMismatch, 17 | IPAddressMismatch, 18 | VerificationError, 19 | ) 20 | from service_identity.hazmat import ( 21 | DNS_ID, 22 | DNSPattern, 23 | IPAddress_ID, 24 | IPAddressPattern, 25 | URIPattern, 26 | ) 27 | 28 | from .certificates import ( 29 | PEM_CN_ONLY, 30 | PEM_DNS_ONLY, 31 | PEM_EVERYTHING, 32 | PEM_OTHER_NAME, 33 | ) 34 | 35 | 36 | backend = default_backend() 37 | X509_DNS_ONLY = load_pem_x509_certificate(PEM_DNS_ONLY, backend) 38 | X509_CN_ONLY = load_pem_x509_certificate(PEM_CN_ONLY, backend) 39 | X509_OTHER_NAME = load_pem_x509_certificate(PEM_OTHER_NAME, backend) 40 | CERT_EVERYTHING = load_pem_x509_certificate(PEM_EVERYTHING, backend) 41 | 42 | 43 | class TestPublicAPI: 44 | def test_no_cert_patterns_hostname(self): 45 | """ 46 | A certificate without subjectAltNames raises a helpful 47 | CertificateError. 48 | """ 49 | with pytest.raises( 50 | CertificateError, 51 | match="Certificate does not contain any `subjectAltName`s", 52 | ): 53 | verify_certificate_hostname(X509_CN_ONLY, "example.com") 54 | 55 | @pytest.mark.parametrize("ip", ["203.0.113.0", "2001:db8::"]) 56 | def test_no_cert_patterns_ip_address(self, ip): 57 | """ 58 | A certificate without subjectAltNames raises a helpful 59 | CertificateError. 60 | """ 61 | with pytest.raises( 62 | CertificateError, 63 | match="Certificate does not contain any `subjectAltName`s", 64 | ): 65 | verify_certificate_ip_address(X509_CN_ONLY, ip) 66 | 67 | def test_certificate_verify_hostname_ok(self): 68 | """ 69 | verify_certificate_hostname succeeds if the hostnames match. 70 | """ 71 | verify_certificate_hostname(X509_DNS_ONLY, "twistedmatrix.com") 72 | 73 | def test_certificate_verify_hostname_fail(self): 74 | """ 75 | verify_certificate_hostname fails if the hostnames don't match and 76 | provides the user with helpful information. 77 | """ 78 | with pytest.raises(VerificationError) as ei: 79 | verify_certificate_hostname(X509_DNS_ONLY, "google.com") 80 | 81 | assert [ 82 | DNSMismatch(mismatched_id=DNS_ID("google.com")) 83 | ] == ei.value.errors 84 | 85 | @pytest.mark.parametrize("ip", ["1.1.1.1", "::1"]) 86 | def test_verify_certificate_ip_address_ok(self, ip): 87 | """ 88 | verify_certificate_ip_address succeeds if the addresses match. Works 89 | both with IPv4 and IPv6. 90 | """ 91 | verify_certificate_ip_address(CERT_EVERYTHING, ip) 92 | 93 | @pytest.mark.parametrize("ip", ["1.1.1.2", "::2"]) 94 | def test_verify_ip_address_fail(self, ip): 95 | """ 96 | verify_ip_address fails if the addresses don't match and provides the 97 | user with helpful information. Works both with IPv4 and IPv6. 98 | """ 99 | with pytest.raises(VerificationError) as ei: 100 | verify_certificate_ip_address(CERT_EVERYTHING, ip) 101 | 102 | assert [ 103 | IPAddressMismatch(mismatched_id=IPAddress_ID(ip)) 104 | ] == ei.value.errors 105 | 106 | 107 | class TestExtractPatterns: 108 | def test_dns(self): 109 | """ 110 | Returns the correct DNSPattern from a certificate. 111 | """ 112 | rv = extract_patterns(X509_DNS_ONLY) 113 | assert [ 114 | DNSPattern.from_bytes(b"www.twistedmatrix.com"), 115 | DNSPattern.from_bytes(b"twistedmatrix.com"), 116 | ] == rv 117 | 118 | def test_cn_ids_are_ignored(self): 119 | """ 120 | commonName is not supported anymore and therefore ignored. 121 | """ 122 | assert [] == extract_patterns(X509_CN_ONLY) 123 | 124 | def test_uri(self): 125 | """ 126 | Returns the correct URIPattern from a certificate. 127 | """ 128 | rv = extract_patterns(X509_OTHER_NAME) 129 | assert [URIPattern.from_bytes(b"http://example.com/")] == [ 130 | id for id in rv if isinstance(id, URIPattern) 131 | ] 132 | 133 | def test_ip(self): 134 | """ 135 | Returns IP patterns. 136 | """ 137 | rv = extract_patterns(CERT_EVERYTHING) 138 | 139 | assert [ 140 | DNSPattern.from_bytes(pattern=b"service.identity.invalid"), 141 | DNSPattern.from_bytes( 142 | pattern=b"*.wildcard.service.identity.invalid" 143 | ), 144 | DNSPattern.from_bytes(pattern=b"service.identity.invalid"), 145 | DNSPattern.from_bytes(pattern=b"single.service.identity.invalid"), 146 | IPAddressPattern(pattern=ipaddress.IPv4Address("1.1.1.1")), 147 | IPAddressPattern(pattern=ipaddress.IPv6Address("::1")), 148 | IPAddressPattern(pattern=ipaddress.IPv4Address("2.2.2.2")), 149 | IPAddressPattern(pattern=ipaddress.IPv6Address("2a00:1c38::53")), 150 | ] == rv 151 | 152 | def test_extract_ids_deprecated(self): 153 | """ 154 | `extract_ids` raises a DeprecationWarning with correct stacklevel. 155 | """ 156 | with pytest.deprecated_call() as wr: 157 | extract_ids(CERT_EVERYTHING) 158 | 159 | w = wr.pop() 160 | 161 | assert ( 162 | "`extract_ids()` is deprecated, please use `extract_patterns()`." 163 | == w.message.args[0] 164 | ) 165 | assert __file__ == w.filename 166 | -------------------------------------------------------------------------------- /src/service_identity/cryptography.py: -------------------------------------------------------------------------------- 1 | """ 2 | `cryptography.x509 `_-specific code. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import warnings 8 | 9 | from typing import Sequence 10 | 11 | from cryptography.x509 import ( 12 | Certificate, 13 | DNSName, 14 | ExtensionOID, 15 | IPAddress, 16 | ObjectIdentifier, 17 | OtherName, 18 | UniformResourceIdentifier, 19 | ) 20 | from cryptography.x509.extensions import ExtensionNotFound 21 | from pyasn1.codec.der.decoder import decode 22 | from pyasn1.type.char import IA5String 23 | 24 | from .exceptions import CertificateError 25 | from .hazmat import ( 26 | DNS_ID, 27 | CertificatePattern, 28 | DNSPattern, 29 | IPAddress_ID, 30 | IPAddressPattern, 31 | SRVPattern, 32 | URIPattern, 33 | verify_service_identity, 34 | ) 35 | 36 | 37 | __all__ = ["verify_certificate_hostname"] 38 | 39 | 40 | def verify_certificate_hostname( 41 | certificate: Certificate, hostname: str 42 | ) -> None: 43 | r""" 44 | Verify whether *certificate* is valid for *hostname*. 45 | 46 | .. note:: 47 | Nothing is verified about the *authority* of the certificate; 48 | the caller must verify that the certificate chains to an appropriate 49 | trust root themselves. 50 | 51 | Args: 52 | certificate: A *cryptography* X509 certificate object. 53 | 54 | hostname: The hostname that *certificate* should be valid for. 55 | 56 | Raises: 57 | service_identity.VerificationError: 58 | If *certificate* is not valid for *hostname*. 59 | 60 | service_identity.CertificateError: 61 | If *certificate* contains invalid / unexpected data. This includes 62 | the case where the certificate contains no `subjectAltName`\ s. 63 | 64 | .. versionchanged:: 24.1.0 65 | :exc:`~service_identity.CertificateError` is raised if the certificate 66 | contains no ``subjectAltName``\ s instead of 67 | :exc:`~service_identity.VerificationError`. 68 | """ 69 | verify_service_identity( 70 | cert_patterns=extract_patterns(certificate), 71 | obligatory_ids=[DNS_ID(hostname)], 72 | optional_ids=[], 73 | ) 74 | 75 | 76 | def verify_certificate_ip_address( 77 | certificate: Certificate, ip_address: str 78 | ) -> None: 79 | r""" 80 | Verify whether *certificate* is valid for *ip_address*. 81 | 82 | .. note:: 83 | Nothing is verified about the *authority* of the certificate; 84 | the caller must verify that the certificate chains to an appropriate 85 | trust root themselves. 86 | 87 | Args: 88 | certificate: A *cryptography* X509 certificate object. 89 | 90 | ip_address: 91 | The IP address that *connection* should be valid for. Can be an 92 | IPv4 or IPv6 address. 93 | 94 | Raises: 95 | service_identity.VerificationError: 96 | If *certificate* is not valid for *ip_address*. 97 | 98 | service_identity.CertificateError: 99 | If *certificate* contains invalid / unexpected data. This includes 100 | the case where the certificate contains no ``subjectAltName``\ s. 101 | 102 | .. versionadded:: 18.1.0 103 | 104 | .. versionchanged:: 24.1.0 105 | :exc:`~service_identity.CertificateError` is raised if the certificate 106 | contains no ``subjectAltName``\ s instead of 107 | :exc:`~service_identity.VerificationError`. 108 | """ 109 | verify_service_identity( 110 | cert_patterns=extract_patterns(certificate), 111 | obligatory_ids=[IPAddress_ID(ip_address)], 112 | optional_ids=[], 113 | ) 114 | 115 | 116 | ID_ON_DNS_SRV = ObjectIdentifier("1.3.6.1.5.5.7.8.7") # id_on_dnsSRV 117 | 118 | 119 | def extract_patterns(cert: Certificate) -> Sequence[CertificatePattern]: 120 | """ 121 | Extract all valid ID patterns from a certificate for service verification. 122 | 123 | Args: 124 | cert: The certificate to be dissected. 125 | 126 | Returns: 127 | List of IDs. 128 | 129 | .. versionchanged:: 23.1.0 130 | ``commonName`` is not used as a fallback anymore. 131 | """ 132 | ids: list[CertificatePattern] = [] 133 | try: 134 | ext = cert.extensions.get_extension_for_oid( 135 | ExtensionOID.SUBJECT_ALTERNATIVE_NAME 136 | ) 137 | except ExtensionNotFound: 138 | pass 139 | else: 140 | ids.extend( 141 | [ 142 | DNSPattern.from_bytes(name.encode("utf-8")) 143 | for name in ext.value.get_values_for_type(DNSName) 144 | ] 145 | ) 146 | ids.extend( 147 | [ 148 | URIPattern.from_bytes(uri.encode("utf-8")) 149 | for uri in ext.value.get_values_for_type( 150 | UniformResourceIdentifier 151 | ) 152 | ] 153 | ) 154 | ids.extend( 155 | [ 156 | IPAddressPattern(ip) 157 | for ip in ext.value.get_values_for_type(IPAddress) 158 | ] 159 | ) 160 | for other in ext.value.get_values_for_type(OtherName): 161 | if other.type_id == ID_ON_DNS_SRV: 162 | srv, _ = decode(other.value) 163 | if isinstance(srv, IA5String): 164 | ids.append(SRVPattern.from_bytes(srv.asOctets())) 165 | else: # pragma: no cover 166 | msg = "Unexpected certificate content." 167 | raise CertificateError(msg) 168 | 169 | return ids 170 | 171 | 172 | def extract_ids(cert: Certificate) -> Sequence[CertificatePattern]: 173 | """ 174 | Deprecated and never public API. Use :func:`extract_patterns` instead. 175 | 176 | .. deprecated:: 23.1.0 177 | """ 178 | warnings.warn( 179 | category=DeprecationWarning, 180 | message="`extract_ids()` is deprecated, please use `extract_patterns()`.", 181 | stacklevel=2, 182 | ) 183 | return extract_patterns(cert) 184 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "service-identity" 7 | authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] 8 | license = "MIT" 9 | requires-python = ">=3.8" 10 | description = "Service identity verification for pyOpenSSL & cryptography." 11 | keywords = ["cryptography", "openssl", "pyopenssl"] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3.14", 23 | "Programming Language :: Python :: Implementation :: CPython", 24 | "Programming Language :: Python :: Implementation :: PyPy", 25 | "Topic :: Security :: Cryptography", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Typing :: Typed", 28 | ] 29 | dependencies = [ 30 | # Keep in-sync with tests/constraints/*. 31 | "attrs>=19.1.0", 32 | "pyasn1-modules", 33 | "pyasn1", 34 | "cryptography", 35 | ] 36 | dynamic = ["version", "readme"] 37 | 38 | [project.optional-dependencies] 39 | idna = ["idna"] 40 | tests = ["coverage[toml]>=5.0.2", "pytest"] 41 | docs = ["sphinx", "furo", "myst-parser", "sphinx-notfound-page", "pyOpenSSL"] 42 | mypy = ["mypy", "types-pyOpenSSL", "idna"] 43 | dev = ["service-identity[tests,mypy,idna]", "pyOpenSSL"] 44 | 45 | [project.urls] 46 | Documentation = "https://service-identity.readthedocs.io/" 47 | Changelog = "https://service-identity.readthedocs.io/en/stable/changelog.html" 48 | GitHub = "https://github.com/pyca/service-identity" 49 | Funding = "https://github.com/sponsors/hynek" 50 | Tidelift = "https://tidelift.com/subscription/pkg/pypi-service-identity?utm_source=pypi-service-identity&utm_medium=pypi" 51 | Mastodon = "https://mastodon.social/@hynek" 52 | Twitter = "https://twitter.com/hynek" 53 | 54 | 55 | [tool.hatch.version] 56 | source = "vcs" 57 | raw-options = { local_scheme = "no-local-version" } 58 | 59 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 60 | content-type = "text/markdown" 61 | 62 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 63 | text = "# Service Identity Verification for pyOpenSSL & *cryptography*\n" 64 | 65 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 66 | path = "README.md" 67 | start-after = "spiel-begin -->\n" 68 | 69 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 70 | text = """ 71 | 72 | 73 | ## Release Information 74 | 75 | """ 76 | 77 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 78 | path = "CHANGELOG.md" 79 | pattern = "\n(###.+?\n)## " 80 | 81 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 82 | text = """ 83 | ---- 84 | 85 | [Complete Changelog →](https://service-identity.readthedocs.io/en/stable/changelog.html) 86 | """ 87 | 88 | 89 | [tool.pytest.ini_options] 90 | addopts = ["-ra", "--strict-markers", "--strict-config"] 91 | xfail_strict = true 92 | testpaths = "tests" 93 | filterwarnings = ["once::Warning"] 94 | norecursedirs = ["tests/typing"] 95 | 96 | 97 | [tool.coverage.run] 98 | parallel = true 99 | branch = true 100 | source = ["service_identity", "tests"] 101 | 102 | [tool.coverage.paths] 103 | source = ["src", ".tox/py*/**/site-packages"] 104 | 105 | [tool.coverage.report] 106 | show_missing = true 107 | skip_covered = true 108 | exclude_also = [ 109 | # allow defensive code 110 | "^\\s*raise AssertionError\\b", 111 | "^\\s*raise NotImplementedError\\b", 112 | "^\\s*return NotImplemented\\b", 113 | "^\\s*raise$", 114 | 115 | # typing-related code 116 | "^if (False|TYPE_CHECKING):", 117 | ": \\.\\.\\.(\\s*#.*)?$", 118 | "^ +\\.\\.\\.$", 119 | "-> ['\"]?NoReturn['\"]?:", 120 | 121 | # Tests 122 | '^if pytest\.importorskip\(', 123 | ] 124 | 125 | 126 | [tool.interrogate] 127 | omit-covered-files = true 128 | verbose = 2 129 | fail-under = 100 130 | whitelist-regex = ["test_.*"] 131 | 132 | 133 | [tool.ruff] 134 | src = ["src", "tests"] 135 | line-length = 79 136 | 137 | [tool.ruff.lint] 138 | select = ["ALL"] 139 | ignore = [ 140 | "A001", # shadowing is fine 141 | "ANN", # Mypy is better at this 142 | "ARG001", # we don't control all args passed in 143 | "ARG005", # we need stub lambdas 144 | "COM", # ruff format takes care of our commas 145 | "D", # We prefer our own docstring style. 146 | "E501", # leave line-length enforcement to ruff format 147 | "FIX", # Yes, we want XXX as a marker. 148 | "INP001", # sometimes we want Python files outside of packages 149 | "ISC001", # conflicts with ruff format 150 | "N801", # some artistic freedom when naming things after RFCs 151 | "N802", # ditto 152 | "PLR2004", # numbers are sometimes fine 153 | "RUF001", # leave my smart characters alone 154 | "SLF001", # private members are accessed by friendly functions 155 | "TCH", # TYPE_CHECKING blocks break autodocs 156 | "TD", # we don't follow other people's todo style 157 | ] 158 | 159 | [tool.ruff.lint.per-file-ignores] 160 | "tests/*" = [ 161 | "B018", # "useless" expressions can be useful in tests 162 | "PLC1901", # empty strings are falsey, but are less specific in tests 163 | "PT005", # we always add underscores and explicit name 164 | "PT011", # broad is fine 165 | "S101", # assert 166 | "S301", # I know pickle is bad, but people use it. 167 | "SIM300", # Yoda rocks in asserts 168 | "TRY301", # tests need to raise exceptions 169 | ] 170 | "docs/pyopenssl_example.py" = [ 171 | "T201", # print is fine in the example 172 | "T203", # pprint is fine in the example 173 | ] 174 | 175 | [tool.ruff.lint.isort] 176 | lines-between-types = 1 177 | lines-after-imports = 2 178 | 179 | 180 | [tool.mypy] 181 | strict = true 182 | pretty = true 183 | 184 | show_error_codes = true 185 | enable_error_code = ["ignore-without-code"] 186 | ignore_missing_imports = true 187 | 188 | [[tool.mypy.overrides]] 189 | module = "tests.*" 190 | ignore_errors = true 191 | 192 | [[tool.mypy.overrides]] 193 | module = "tests.typing.*" 194 | ignore_errors = false 195 | 196 | [[tool.mypy.overrides]] 197 | module = "cryptography.*" 198 | follow_imports = "skip" 199 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/service_identity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/service_identity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/service_identity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/service_identity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | FORCE_COLOR: "1" # Make tools pretty. 12 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 13 | PIP_NO_PYTHON_VERSION_WARNING: "1" 14 | 15 | permissions: {} 16 | 17 | 18 | jobs: 19 | lint: 20 | name: Run pre-commit 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 24 | with: 25 | persist-credentials: false 26 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 27 | with: 28 | python-version-file: .python-version-default 29 | 30 | - uses: tox-dev/action-pre-commit-uv@246b66536e366bb885f52d61983bf32f7c95e8b1 # 1.0.3 31 | 32 | 33 | build-package: 34 | name: Build & verify package 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 39 | with: 40 | fetch-depth: 0 41 | persist-credentials: false 42 | 43 | - uses: hynek/build-and-inspect-python-package@efb823f52190ad02594531168b7a2d5790e66516 # v2.14.0 44 | id: baipp 45 | 46 | outputs: 47 | # Used to define the matrix for tests below. The value is based on 48 | # packaging metadata (trove classifiers). 49 | python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} 50 | 51 | 52 | tests: 53 | name: Tests & Mypy API on ${{ matrix.python-version }} 54 | runs-on: ubuntu-latest 55 | needs: build-package 56 | 57 | strategy: 58 | fail-fast: false 59 | matrix: 60 | # Created by the build-and-inspect-python-package action above. 61 | python-version: ${{ fromJson(needs.build-package.outputs.python-versions) }} 62 | 63 | env: 64 | PYTHON: ${{ matrix.python-version }} 65 | 66 | steps: 67 | - name: Download pre-built packages 68 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 69 | with: 70 | name: Packages 71 | path: dist 72 | - run: | 73 | tar xf dist/*.tar.gz --strip-components=1 74 | rm -rf src 75 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 76 | with: 77 | python-version: ${{ matrix.python-version }} 78 | allow-prereleases: true 79 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 80 | 81 | - name: Run tests 82 | run: > 83 | uvx --with tox-uv tox run 84 | --installpkg dist/*.whl 85 | -f py${PYTHON//./} 86 | 87 | - name: Upload coverage data 88 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 89 | with: 90 | name: coverage-data-${{ matrix.python-version }} 91 | path: .coverage.* 92 | include-hidden-files: true 93 | if-no-files-found: ignore 94 | 95 | - name: Check public API with Mypy 96 | run: > 97 | uvx --with tox-uv tox run 98 | --installpkg dist/*.whl 99 | -e mypy-api 100 | 101 | 102 | coverage: 103 | name: Ensure 100% test coverage 104 | runs-on: ubuntu-latest 105 | needs: tests 106 | if: always() 107 | 108 | steps: 109 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 110 | with: 111 | persist-credentials: false 112 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 113 | with: 114 | python-version-file: .python-version-default 115 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 116 | 117 | - name: Download coverage data 118 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 119 | with: 120 | pattern: coverage-data-* 121 | merge-multiple: true 122 | 123 | - name: Combine coverage and fail if it's <100%. 124 | run: | 125 | uv tool install coverage 126 | 127 | coverage combine 128 | coverage html --skip-covered --skip-empty 129 | 130 | # Report and write to summary. 131 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 132 | 133 | # Report again and fail if under 100%. 134 | coverage report --fail-under=100 135 | 136 | - name: Upload HTML report if check failed. 137 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 138 | with: 139 | name: html-report 140 | path: htmlcov 141 | if: ${{ failure() }} 142 | 143 | 144 | mypy-pkg: 145 | name: Mypy Codebase 146 | runs-on: ubuntu-latest 147 | needs: build-package 148 | 149 | steps: 150 | - name: Download pre-built packages 151 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 152 | with: 153 | name: Packages 154 | path: dist 155 | - run: tar xf dist/*.tar.gz --strip-components=1 156 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 157 | with: 158 | python-version-file: .python-version-default 159 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 160 | 161 | - run: > 162 | uvx --with tox-uv 163 | tox run -e mypy-pkg 164 | 165 | 166 | install-dev: 167 | strategy: 168 | matrix: 169 | os: [ubuntu-latest, windows-latest] 170 | 171 | name: Verify dev env 172 | runs-on: ${{ matrix.os }} 173 | 174 | steps: 175 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 176 | with: 177 | persist-credentials: false 178 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 179 | with: 180 | python-version-file: .python-version-default 181 | 182 | - name: Install in dev mode & import 183 | run: | 184 | python -Im pip install -e .[dev] 185 | python -Ic 'import service_identity; print(service_identity.__version__)' 186 | python -Ic 'import service_identity.pyopenssl' 187 | python -Ic 'import service_identity.cryptography' 188 | 189 | 190 | docs: 191 | name: Build docs & run doctests 192 | needs: build-package 193 | runs-on: ubuntu-latest 194 | 195 | steps: 196 | - name: Download pre-built packages 197 | uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 198 | with: 199 | name: Packages 200 | path: dist 201 | - run: tar xf dist/*.tar.gz --strip-components=1 202 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 203 | with: 204 | # Keep in sync with tox.ini/docs & .readthedocs.yaml 205 | python-version: "3.13" 206 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 207 | 208 | - run: > 209 | uvx --with tox-uv 210 | tox run -e docs-build,docs-doctests 211 | 212 | 213 | required-checks-pass: 214 | name: Ensure everything required is passing for branch protection 215 | if: always() 216 | 217 | needs: 218 | - coverage 219 | - lint 220 | - mypy-pkg 221 | - docs 222 | - install-dev 223 | 224 | runs-on: ubuntu-latest 225 | 226 | steps: 227 | - name: Decide whether the needed jobs succeeded or failed 228 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 229 | with: 230 | jobs: ${{ toJSON(needs) }} 231 | -------------------------------------------------------------------------------- /tests/certificates.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.backends import default_backend 2 | from cryptography.x509 import load_pem_x509_certificate 3 | 4 | from service_identity.cryptography import extract_patterns 5 | 6 | 7 | # Test certificates 8 | 9 | PEM_DNS_ONLY = b"""\ 10 | -----BEGIN CERTIFICATE----- 11 | MIIGbjCCBVagAwIBAgIDCesrMA0GCSqGSIb3DQEBBQUAMIGMMQswCQYDVQQGEwJJ 12 | TDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0 13 | YWwgQ2VydGlmaWNhdGUgU2lnbmluZzE4MDYGA1UEAxMvU3RhcnRDb20gQ2xhc3Mg 14 | MSBQcmltYXJ5IEludGVybWVkaWF0ZSBTZXJ2ZXIgQ0EwHhcNMTMwNDEwMTk1ODA5 15 | WhcNMTQwNDExMTkyODAwWjB1MRkwFwYDVQQNExBTN2xiQ3Q3TjJSNHQ5bzhKMQsw 16 | CQYDVQQGEwJVUzEeMBwGA1UEAxMVd3d3LnR3aXN0ZWRtYXRyaXguY29tMSswKQYJ 17 | KoZIhvcNAQkBFhxwb3N0bWFzdGVyQHR3aXN0ZWRtYXRyaXguY29tMIIBIjANBgkq 18 | hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxUH8iDxIEiDcMQb8kr/JTYXDGuE8ISQA 19 | uw/gBqpvHIvCgPBkZpvjQLA23rnUZm1S3VG5MIq6gZVdtl9LFIfokMPGgY9EZng8 20 | BaI+6Y36cMtubnzW53OZb7yLQQyg+rjuwjvJOY33ZulEthxhdB3km1Leb67iE9v7 21 | dpyKeJ/8m2IWD37HCtXIEnp9ZqWOZkAPzlzDt6oNxj0s/l3z23+XqZdr+kmlh9U+ 22 | VWBTPppO4AJNwSqbBd0PgIozbYsp6urxSr40YQkIYFOOZQNs7HETJE71Ia7DQcUD 23 | kUF1jZSYZnhVQwGPisqQLGodt9q9p2BhpSf0cUm02uKKzYi5A2h7UQIDAQABo4IC 24 | 7TCCAukwCQYDVR0TBAIwADALBgNVHQ8EBAMCA6gwEwYDVR0lBAwwCgYIKwYBBQUH 25 | AwEwHQYDVR0OBBYEFGeuUvDrFHkl7Krl/+rlv1FsnsU6MB8GA1UdIwQYMBaAFOtC 26 | NNCYsKuf9BtrCPfMZC7vDixFMDMGA1UdEQQsMCqCFXd3dy50d2lzdGVkbWF0cml4 27 | LmNvbYIRdHdpc3RlZG1hdHJpeC5jb20wggFWBgNVHSAEggFNMIIBSTAIBgZngQwB 28 | AgEwggE7BgsrBgEEAYG1NwECAzCCASowLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cu 29 | c3RhcnRzc2wuY29tL3BvbGljeS5wZGYwgfcGCCsGAQUFBwICMIHqMCcWIFN0YXJ0 30 | Q29tIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MAMCAQEagb5UaGlzIGNlcnRpZmlj 31 | YXRlIHdhcyBpc3N1ZWQgYWNjb3JkaW5nIHRvIHRoZSBDbGFzcyAxIFZhbGlkYXRp 32 | b24gcmVxdWlyZW1lbnRzIG9mIHRoZSBTdGFydENvbSBDQSBwb2xpY3ksIHJlbGlh 33 | bmNlIG9ubHkgZm9yIHRoZSBpbnRlbmRlZCBwdXJwb3NlIGluIGNvbXBsaWFuY2Ug 34 | b2YgdGhlIHJlbHlpbmcgcGFydHkgb2JsaWdhdGlvbnMuMDUGA1UdHwQuMCwwKqAo 35 | oCaGJGh0dHA6Ly9jcmwuc3RhcnRzc2wuY29tL2NydDEtY3JsLmNybDCBjgYIKwYB 36 | BQUHAQEEgYEwfzA5BggrBgEFBQcwAYYtaHR0cDovL29jc3Auc3RhcnRzc2wuY29t 37 | L3N1Yi9jbGFzczEvc2VydmVyL2NhMEIGCCsGAQUFBzAChjZodHRwOi8vYWlhLnN0 38 | YXJ0c3NsLmNvbS9jZXJ0cy9zdWIuY2xhc3MxLnNlcnZlci5jYS5jcnQwIwYDVR0S 39 | BBwwGoYYaHR0cDovL3d3dy5zdGFydHNzbC5jb20vMA0GCSqGSIb3DQEBBQUAA4IB 40 | AQCN85dUStYjHmWdXthpAqJcS3KD2JP6N9egOz7FTcToXLW8Kl5a2SUVaJv8Fzs+ 41 | wtbPJQSm0LyGtfdrR6iKFPf28Vm/VkYXPiOV08GD9B7yl1SjktXOsGMPlOHU8YQZ 42 | DEsHOrRvaZBSA1VtBQjYnoO0pDVu9QwDLAPLFvFice2PN803HuMFIwcuQSIrh4nq 43 | PqwitBZ6nPPHz7aSiAut/+txK3EZll0d+hl0H3Phd+ICeITYhNkLe90k7l1IFpET 44 | fJiBDvG/iDAJISgkrR1heuX/e+yWfx7RvqGlMLIE35d+0MhWy92Jzejbl8fJdr4C 45 | Kulh/pV07MWAUZxscUPtWmPo 46 | -----END CERTIFICATE-----""" 47 | 48 | DNS_IDS = extract_patterns( 49 | load_pem_x509_certificate(PEM_DNS_ONLY, default_backend()) 50 | ) 51 | 52 | PEM_CN_ONLY = b"""\ 53 | -----BEGIN CERTIFICATE----- 54 | MIIDlzCCAn+gAwIBAgIUNS9qfJRgoLrr68mAfmhzBior0JAwDQYJKoZIhvcNAQEL 55 | BQAwWzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 56 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAwwLZXhhbXBsZS5jb20w 57 | HhcNMjMwNjA4MDkwNDAxWhcNNDkwMTI3MDkwNDAxWjBbMQswCQYDVQQGEwJBVTET 58 | MBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQ 59 | dHkgTHRkMRQwEgYDVQQDDAtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD 60 | ggEPADCCAQoCggEBALltBNRAyeFczK2kdKTkW6E3/+rptXBcaw3SyltY/Ft8DPe3 61 | lW/GWbbgAU7ceYC0LkYxOEnyQTlXhTyLHSk+PCQLi2ESgwWGnGY5Eusk1DRJ162q 62 | hLUkmz25KdTILnh402fgoziF2RU6PnA7iIwAVntT5uh4S1yPW7TWkdPsE0tc1yBx 63 | 3SAHTXDkoahjKSot35Q87Jw92fxUd7WqVmDrgcLMvp8rXNjdg+HG4fQGoMcSQq2Z 64 | nVPIoWSTOGdL6SzSs6Wvllz3n7YWShTqp9CmGctCGubt02fHF+8G/dMTTukntG4q 65 | EjBtVfeDFx8yXUdOgN4egFxZGYATAUsLcyzNhQcCAwEAAaNTMFEwHQYDVR0OBBYE 66 | FDkuqwU8KxIvwAwMqVpNFYlgd6WbMB8GA1UdIwQYMBaAFDkuqwU8KxIvwAwMqVpN 67 | FYlgd6WbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJ9Nb/C0 68 | 2gkxhWm3S2/9onPMhFOxDLsYxBvZtJMeOvRRrJaxN2m0aXkm0Ww8Bq0qEegZE51m 69 | 0T57IOS256rQlxEnZNeAWT2tf8FEqQGjbI1c/prj420e6afn18x8CSQgiOG4PJ+S 70 | YfufRjSHAYwiiNi+tiKBQ8jOuayFhih9/GsDJvAMW0HjaYZyG6jlA9p4bL1rSqD3 71 | gE+upYhkpVN1lFRZAhxRZOVIqJV/ppHp9RAuawsUdSNKm+rnlaExJKoVys+tZL9+ 72 | djWWQVqnttYocQQ0umX4ydCTr1RM+7ziwWTR5kpOESA/gZePZQqeH9k+XrPEQtLn 73 | 6QBxk+Ft53ZPVso= 74 | -----END CERTIFICATE----- 75 | """ 76 | 77 | 78 | PEM_OTHER_NAME = b"""\ 79 | -----BEGIN CERTIFICATE----- 80 | MIID/DCCAuSgAwIBAgIJAIS0TSddIw6cMA0GCSqGSIb3DQEBBQUAMGwxFDASBgNV 81 | BAMTC2V4YW1wbGUuY29tMSAwHgYJKoZIhvcNAQkBFhFib2d1c0BleGFtcGxlLmNv 82 | bTEUMBIGA1UEChMLRXhhbXBsZSBJbmMxDzANBgNVBAcTBkJlcmxpbjELMAkGA1UE 83 | BhMCREUwHhcNMTQwMzA2MTYyNTA5WhcNMTUwMzA2MTYyNTA5WjBsMRQwEgYDVQQD 84 | EwtleGFtcGxlLmNvbTEgMB4GCSqGSIb3DQEJARYRYm9ndXNAZXhhbXBsZS5jb20x 85 | FDASBgNVBAoTC0V4YW1wbGUgSW5jMQ8wDQYDVQQHEwZCZXJsaW4xCzAJBgNVBAYT 86 | AkRFMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxGQUcOc8cAdzSJbk 87 | 0eCHA1qBY2XwRG8YQzihgQS8Ey+3j69Xf0mtWOlL6v23v8J1ilA7ERs87Y4nbV/9 88 | GJVhC/jTMZmrC6ogwtVIl1wL8sTiHaQZ/4pbpx57YW3qCdefLQrZqAMUgAe20z0G 89 | YVU97u5EGXHYahG4TnB3xN6Qd3BGKP7K69Lb7ZOES2Esq533AZxZShseYR4JNYAc 90 | 2anag2/DpHw6k8ZaxtWHR4SmxlkCoW5IPK0YypeUY91PFY+dxJQEewtisfALKltE 91 | SYnOTWkc0K9YuLuYVogx0K285wX4/Yha2wyo6KSAm0txJayOhcrEP2/34aWCl62m 92 | xOtPbQIDAQABo4GgMIGdMIGaBgNVHREEgZIwgY+CDSouZXhhbXBsZS5uZXSCC2V4 93 | YW1wbGUuY29thwTAqAABhxAAEwAAAAAAAAAAAAAAAAAXhhNodHRwOi8vZXhhbXBs 94 | ZS5jb20voCYGCCsGAQUFBwgHoBoWGF94bXBwLWNsaWVudC5leGFtcGxlLm5ldKAc 95 | BggrBgEFBQcIBaAQDA5pbS5leGFtcGxlLmNvbTANBgkqhkiG9w0BAQUFAAOCAQEA 96 | ACVQcgEKzXEw0M9mmVFFXL2SyDk/4oaDFZbnNfyUp+H7bnxdVBG2M3DzQQLw5yH5 97 | k4GNPvHOKshBbaFcZWiG1sdrfQJy/UjIWnaC5410npfBv7kJWafKKxZzMq3gp4rd 98 | jPO2LxuWcYVOnUtA3CBe12tRV7ynGU8KmKOsU9bOWhUKo8DJ4a6XHB+YwXeOTPyU 99 | mG7XBpQebT01I3OijFJ+apKR2ubjwZE8l1+BAlTzHyUmmcTTWTQk8FTFcP3nZuIr 100 | VyudDBMASs4yVGHzQxmMalYYzd7ZDzM1NrgfG1KyKWqZEA0MzUxiYdUbZN79xL52 101 | EyKUOXPHw78G6zsVmAE1Aw== 102 | -----END CERTIFICATE-----""" 103 | 104 | 105 | PEM_EVERYTHING = b"""\ 106 | -----BEGIN CERTIFICATE----- 107 | MIIGdTCCBF2gAwIBAgIUSxHQCcw8po0mpISRHmijCA7HF9YwDQYJKoZIhvcNAQEL 108 | BQAwEzERMA8GA1UEAxMIY2Eudm0uYWcwHhcNMTgwMjExMTMxOTI0WhcNMTgwMjEx 109 | MTMyMDQyWjAjMSEwHwYDVQQDExhzZXJ2aWNlLmlkZW50aXR5LmludmFsaWQwggIi 110 | MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzYDZKBb91iX9Ct8gFig//2UtA 111 | fRiDdiViimnAuLJ3f4Q5rM2Xs4BQpXEGgf4tBeZ03lFIga+W7nzsNZnooN6ocLwB 112 | z3jb3K4xxy1RRzv0iKLhFdtQwfwS6Xz6usaySGWW5Hpn3Yqwd9qAho/MIFfDruuL 113 | kEInjhtJGta/uT3fZ9BiLsDl1zyZefvhLblpujww5Ex3eGHgZlLixfuQj+vZbQ99 114 | 2xMHRIh6PRVsnVJ7GaSxxIwAdXcVZRuB4he3aIIn8OMCf+1V5aUTfC5vWVrSFfJb 115 | B1V9uw4DB0Uf/bn8bkm4ncr11kjiOUoNahXwPanHVFkTyr2hDU/SguIPRBGFBFCC 116 | RRUbsEhpJrtKy4mc1RQzof+fMJqmTjvRGoIYISfpuL3B84UBuXB6bWoKqsIrsX+Z 117 | Ww3bO7/ncpgko7zQSpjPUxAJQ2z/u+aCh/v++UudMGtYtQlBNTtkQsIAAaho/vHF 118 | ALjusQKj8J6LLJXWrNW0MzidgookHBu3cjE++ymK9bKsgbUFH+T1hf9WIaFR0ldY 119 | uCyOiDx7wxqV8KS3/FXAFU5ra6HtNVy67umcL+e8frBFABxdHu0SWNnXRN5qF233 120 | WQ/0ds0KjjPC19+fH/KlwVuK4u725dtbeKmbbfeqrUhCoDVLG2xfIEPDrwfNiuRx 121 | n//9JahPtu53aRN7NwIDAQABo4IBrzCCAaswDgYDVR0PAQH/BAQDAgOoMB0GA1Ud 122 | JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4EFgQUkrzVHzWYgC6iuhYm 123 | G91tbFg175YwHwYDVR0jBBgwFoAUZDPQAysTTYandW8IzZhkZMamUs0wRQYIKwYB 124 | BQUHAQEEOTA3MDUGCCsGAQUFBzAChilodHRwczovL2NhLnZtLmFnOjgyMDAvdjEv 125 | dm1jYV9pbnRtX3BraS9jYTCBtQYDVR0RBIGtMIGqghhzZXJ2aWNlLmlkZW50aXR5 126 | LmludmFsaWSCIyoud2lsZGNhcmQuc2VydmljZS5pZGVudGl0eS5pbnZhbGlkghhz 127 | ZXJ2aWNlLmlkZW50aXR5LmludmFsaWSCH3NpbmdsZS5zZXJ2aWNlLmlkZW50aXR5 128 | LmludmFsaWSHBAEBAQGHEAAAAAAAAAAAAAAAAAAAAAGHBAICAgKHECoAHDgAAAAA 129 | AAAAAAAAAFMwOwYDVR0fBDQwMjAwoC6gLIYqaHR0cHM6Ly9jYS52bS5hZzo4MjAw 130 | L3YxL3ZtY2FfaW50bV9wa2kvY3JsMA0GCSqGSIb3DQEBCwUAA4ICAQA6fR0V39IN 131 | zqFkJFUFyt/uX7aMnMbe2DKxXmhJns6VwN+nhzB4CNK5rSJ0y0telN5CL2Oe+pS/ 132 | Vfinw15GrdB01r9mV/og0aFMyXFUjmDa4heNKvbuspj+hHjXj2JvETk9pHKURmQe 133 | kd0IffkoDaSFIwjI0rOdDdo+5WcFpjx8lq8IZeBcPdVhqlzIaNa/PgezUg69HQF/ 134 | FEqBkaq4sto8/yXrD6Pp5NszRJvBtEnlq+WSYzvVSH6E48KD1sJr2DTGWs8pi9ml 135 | 7exq1yRSlmz5bgOvl7AVGrl+icOuCpDcuVgE2MbzKm/VKQ01ypUPvnUcDZJC8iDC 136 | 6JNT152YuLY/rgq+XJMeLb/FtDKmav8oOWqeoD72baMub9iVlZ4kaMzjtMFlXVha 137 | 6MQiV36QG99q8KPdxeRxuef4p3NRFa8AlFGbOa/ALxksN9rr8fPxAaNrHBzYsCgN 138 | DZoyYaYe6aIx8wVtpbucdinDSyn7aJy66RHUnKNwW/tJm3WXCI492dEX+s7PGVXA 139 | F4B0w+r2LTELSYJ6Mh+tVleuJZ6Yw947E4iAyc/u7ck6qWRex230hnHZqgRiexP2 140 | 4ZueMI+SnpWqL7rOgLD6VuyemZ18on2VJcgvZiVkYMfZf2330ZlRxtyU2AvKRXc3 141 | 3HotzNMgpPpx8C2KKLKKaiIGRY0pg/WC6w== 142 | -----END CERTIFICATE-----""" 143 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) and this project adheres to [*Calendar Versioning*](https://calver.org/). 6 | 7 | The **first number** of the version is the year. 8 | The **second number** is incremented with each release, starting at 1 for each year. 9 | The **third number** is for emergencies when we need to start branches for older releases. 10 | 11 | You can find out backwards-compatibility policy [here](https://github.com/pyca/service-identity/blob/main/.github/SECURITY.md). 12 | 13 | 14 | 15 | 16 | ## [Unreleased](https://github.com/pyca/service-identity/compare/24.2.0...HEAD) 17 | 18 | ### Added 19 | 20 | - Python 3.14 is now officially supported. 21 | [#85](https://github.com/pyca/service-identity/pull/85) 22 | 23 | 24 | ## [24.2.0](https://github.com/pyca/service-identity/compare/24.1.0...24.2.0) - 2024-10-26 25 | 26 | ### Added 27 | 28 | - Python 3.13 is now officially supported. 29 | [#74](https://github.com/pyca/service-identity/pull/74) 30 | 31 | 32 | ### Changed 33 | 34 | - pyOpenSSL's identity extraction has been reimplemented using *cryptography*'s primitives instead of deprecated pyOpenSSL APIs. 35 | As a result, the oldest supported pyOpenSSL version is now 17.1.0. 36 | [#70](https://github.com/pyca/service-identity/pull/70) 37 | 38 | 39 | ## [24.1.0](https://github.com/pyca/service-identity/compare/23.1.0...24.1.0) - 2024-01-14 40 | 41 | ### Changed 42 | 43 | - If a certificate doesn't contain any `subjectAltName`s, we now raise `service_identity.CertificateError` instead of `service_identity.VerificationError` to make the problem easier to debug. 44 | [#67](https://github.com/pyca/service-identity/pull/67) 45 | 46 | 47 | ## [23.1.0](https://github.com/pyca/service-identity/compare/21.1.0...23.1.0) - 2023-06-14 48 | 49 | ### Removed 50 | 51 | - All Python versions up to and including 3.7 have been dropped. 52 | - Support for `commonName` in certificates has been dropped. 53 | It has been deprecated since 2017 and isn't supported by any major browser. 54 | - The oldest supported pyOpenSSL version (when using the `pyopenssl` backend) is now 17.0.0. 55 | When using such an old pyOpenSSL version, you have to pin *cryptography* yourself to ensure compatibility between them. 56 | Please check out [`constraints/oldest-pyopenssl.txt`](https://github.com/pyca/service-identity/blob/main/tests/constraints/oldest-pyopenssl.txt) to verify what we are testing against. 57 | 58 | 59 | ### Deprecated 60 | 61 | - If you've used `service_identity.(cryptography|pyopenssl).extract_ids()`, please switch to the new names `extract_patterns()`. 62 | [#56](https://github.com/pyca/service-identity/pull/56) 63 | 64 | 65 | ### Added 66 | 67 | - `service_identity.(cryptography|pyopenssl).extract_patterns()` are now public APIs (FKA `extract_ids()`). 68 | You can use them to extract the patterns from a certificate without verifying anything. 69 | [#55](https://github.com/pyca/service-identity/pull/55) 70 | - *service-identity* is now fully typed. 71 | [#57](https://github.com/pyca/service-identity/pull/57) 72 | 73 | 74 | ## [21.1.0](https://github.com/pyca/service-identity/compare/18.1.0...21.1.0) - 2021-05-09 75 | 76 | ### Removed 77 | 78 | - Python 3.4 is not supported anymore. 79 | It has been unsupported by the Python core team for a while now, its PyPI downloads are negligible, and our CI provider removed it as a supported option. 80 | 81 | It's very unlikely that `service-identity` will break under 3.4 anytime soon, which is why we do *not* block its installation on Python 3.4. 82 | But we don't test it anymore and will block it once someone reports breakage. 83 | 84 | 85 | ### Fixed 86 | 87 | - `service_identity.exceptions.VerificationError` can now be pickled and is overall more well-behaved as an exception. 88 | This raises the requirement of `attrs` to 19.1.0. 89 | 90 | 91 | ## [18.1.0](https://github.com/pyca/service-identity/compare/17.0.0...18.1.0) - 2018-12-05 92 | 93 | ### Added 94 | 95 | - pyOpenSSL is optional now if you use `service_identity.cryptography.*` only. 96 | - Added support for `iPAddress` `subjectAltName`s. 97 | You can now verify whether a connection or a certificate is valid for an IP address using `service_identity.pyopenssl.verify_ip_address()` and `service_identity.cryptography.verify_certificate_ip_address()`. 98 | [#12](https://github.com/pyca/service-identity/pull/12) 99 | 100 | 101 | ## [17.0.0](https://github.com/pyca/service-identity/compare/16.0.0...17.0.0) - 2017-05-23 102 | 103 | ### Deprecated 104 | 105 | - Since Chrome 58 and Firefox 48 both don't accept certificates that contain only a Common Name, its usage is hereby deprecated in `service-identity` too. 106 | We have been raising a warning since 16.0.0 and the support will be removed in mid-2018 for good. 107 | 108 | ### Added 109 | 110 | - When `service_identity.SubjectAltNameWarning` is raised, the Common Name of the certificate is now included in the warning message. 111 | [#17](https://github.com/pyca/service-identity/pull/17) 112 | - Added `cryptography.x509` backend for verifying certificates. 113 | [#18](https://github.com/pyca/service-identity/pull/18) 114 | 115 | 116 | ### Changed 117 | 118 | - Wildcards (`*`) are now only allowed if they are the leftmost label in a certificate. 119 | This is common practice by all major browsers. 120 | [#19](https://github.com/pyca/service-identity/pull/19) 121 | 122 | 123 | ## [16.0.0](https://github.com/pyca/service-identity/compare/14.0.0...16.0.0) - 2016-02-18 124 | 125 | ### Removed 126 | 127 | - Python 3.3 and 2.6 aren't supported anymore. 128 | They may work by chance but any effort to keep them working has ceased. 129 | 130 | The last Python 2.6 release was on October 29, 2013 and isn't supported by the CPython core team anymore. 131 | Major Python packages like Django and Twisted dropped Python 2.6 a while ago already. 132 | 133 | Python 3.3 never had a significant user base and wasn't part of any distribution's LTS release. 134 | 135 | - pyOpenSSL versions older than 0.14 are not tested anymore. 136 | They don't even build on recent OpenSSL versions. 137 | Please note that its support may break without further notice. 138 | 139 | 140 | ### Added 141 | 142 | - Officially support Python 3.5. 143 | - A `__str__` method to `VerificationError`. 144 | 145 | 146 | ### Changed 147 | 148 | - `service_identity.SubjectAltNameWarning` is now raised if the server certificate lacks a proper `SubjectAltName`. 149 | [#9](https://github.com/pyca/service-identity/issues/9) 150 | - Port from `characteristic` to its spiritual successor [attrs](https://www.attrs.org/). 151 | 152 | 153 | ## [14.0.0](https://github.com/pyca/service-identity/compare/1.0.0...14.0.0) - 2014-08-22 154 | 155 | ### Changed 156 | 157 | - Switch to year-based version numbers. 158 | - Port to `characteristic` 14.0 (get rid of deprecation warnings). 159 | - Package docs with source distributions. 160 | 161 | 162 | ## [1.0.0](https://github.com/pyca/service-identity/compare/0.2.0...1.0.0) - 2014-06-15 163 | 164 | ### Removed 165 | 166 | - Drop support for Python 3.2. 167 | There is no justification to add complexity and unnecessary function calls for a Python version that [nobody uses](https://alexgaynor.net/2014/jan/03/pypi-download-statistics/). 168 | 169 | ### Changed 170 | 171 | - Move into the [Python Cryptography Authority’s GitHub account](https://github.com/pyca/). 172 | - Move exceptions into `service_identity.exceptions` so tracebacks don’t contain private module names. 173 | - Promoting to stable since Twisted 14.0 is optionally depending on `service-identity` now. 174 | - Use [characteristic](https://characteristic.readthedocs.io/) instead of a home-grown solution. 175 | 176 | 177 | ### Fixed 178 | 179 | - `idna` 0.6 did some backward-incompatible fixes that broke Python 3 support. 180 | This has been fixed now therefore *service-identity* only works with `idna` 0.6 and later. 181 | Unfortunately since `idna` doesn’t offer version introspection, *service-identity* can’t warn about it. 182 | 183 | 184 | ## [0.2.0](https://github.com/pyca/service-identity/compare/0.1.0...0.2.0) - 2014-04-06 185 | 186 | ### Added 187 | 188 | - Official support for Python 3.4. 189 | 190 | 191 | ### Changed 192 | 193 | - Refactor into a multi-module package. 194 | Most notably, `verify_hostname` and `extract_ids` live in the `service_identity.pyopenssl` module now. 195 | - `verify_hostname` now takes an `OpenSSL.SSL.Connection` for the first argument. 196 | - More strict checks for URI_IDs. 197 | 198 | 199 | ### Fixed 200 | 201 | - Less false positives in IP address detection. 202 | 203 | 204 | ## [0.1.0](https://github.com/pyca/service-identity/tree/0.1.0) - 2014-03-03 205 | 206 | Initial release. 207 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | Thank you for considering contributing to *service-identity*! 4 | It's people like *you* who make it such a great tool for everyone. 5 | 6 | This document intends to make contribution more accessible by codifying tribal knowledge and expectations. 7 | Don't be afraid to open half-finished PRs, and ask questions if something is unclear! 8 | 9 | Please note that this project is released with a Contributor [Code of Conduct](https://github.com/pyca/service-identity/blob/main/.github/CODE_OF_CONDUCT.md). 10 | By participating in this project you agree to abide by its terms. 11 | Please report any harm to [Hynek Schlawack] in any way you find appropriate. 12 | 13 | 14 | ## Workflow 15 | 16 | - No contribution is too small! 17 | Please submit as many fixes for typos and grammar bloopers as you can! 18 | - Try to limit each pull request to *one* change only. 19 | - Since we squash on merge, it's up to you how you handle updates to the `main` branch. 20 | Whether you prefer to rebase on `main` or merge `main` into your branch, do whatever is more comfortable for you. 21 | - *Always* add tests and docs for your code. 22 | This is a hard rule; patches with missing tests or documentation won't be merged. 23 | - Make sure your changes pass our [CI]. 24 | You won't get any feedback until it's green unless you ask for it. 25 | For the CI to pass, the coverage must be 100%. 26 | If you have problems to test something, open anyway and ask for advice. 27 | In some situations, we may agree to add an `# pragma: no cover`. 28 | - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. 29 | - Don’t break backwards-compatibility. 30 | 31 | 32 | ## Local Development Environment 33 | 34 | You can (and should) run our test suite using [*tox*]. 35 | However, you’ll probably want a more traditional environment as well. 36 | 37 | First, create a virtual environment so you don't break your system-wide Python installation. 38 | We recommend using the Python version from the `.python-version-default` file in project's root directory. 39 | 40 | If you're using [*direnv*](https://direnv.net), you can automate the creation of a virtual environment with the correct Python version by adding the following `.envrc` to the project root after cloning the repository: 41 | 42 | ```bash 43 | layout python python$(cat .python-version-default) 44 | ``` 45 | 46 | If you're using tools that understand `.python-version` files like [*pyenv*](https://github.com/pyenv/pyenv) does, you can make it a link to the `.python-version-default` file. 47 | 48 | --- 49 | 50 | [Create a fork](https://github.com/pyca/service-identity/fork) of the *service-identity* repository and clone it: 51 | 52 | ```console 53 | $ git clone git@github.com:YOU/service-identity.git 54 | ``` 55 | 56 | Or if you prefer to use Git via HTTPS: 57 | 58 | ```console 59 | $ git clone https://github.com/YOU/service-identity.git 60 | ``` 61 | 62 | > **Warning** 63 | > - **Before** you start working on a new pull request, use the "*Sync fork*" button in GitHub's web UI to ensure your fork is up to date. 64 | > - **Always create a new branch off `main` for each new pull request.** 65 | > Yes, you can work on `main` in your fork and submit pull requests. 66 | > But this will *inevitably* lead to you not being able to synchronize your fork with upstream and having to start over. 67 | 68 | Change into the newly created directory and after activating a virtual environment, install an editable version of *service-identity* along with its tests requirements: 69 | 70 | ```console 71 | $ cd service-identity 72 | $ python -Im pip install --upgrade pip wheel # PLEASE don't skip this step 73 | $ python -Im pip install -e '.[dev]' 74 | ``` 75 | 76 | At this point, 77 | 78 | ```console 79 | $ python -Im pytest 80 | ``` 81 | 82 | When working on the documentation, use: 83 | 84 | ```console 85 | $ tox run -e docs-watch 86 | ``` 87 | 88 | ... to watch your files and automatically rebuild when a file changes. 89 | And use: 90 | 91 | ```console 92 | $ tox run -e docs 93 | ``` 94 | 95 | ... to build it once and run our doctests. 96 | 97 | The built documentation can then be found in `docs/_build/html/`. 98 | 99 | --- 100 | 101 | To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] and its hooks: 102 | 103 | ```console 104 | $ pre-commit install 105 | ``` 106 | 107 | This is not strictly necessary, because our [*tox*] file contains an environment that runs: 108 | 109 | ```console 110 | $ pre-commit run --all-files 111 | ``` 112 | 113 | But it's way more comfortable to run it locally and catch avoidable errors before pushing them to GitHub. 114 | 115 | 116 | ## Code 117 | 118 | - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). 119 | We use the `"""`-on-separate-lines style for docstrings with [Napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html)-style API documentation: 120 | 121 | ```python 122 | def func(x: str, y: int) -> str: 123 | """ 124 | Do something. 125 | 126 | Args: 127 | x: A very important parameter. 128 | 129 | y: 130 | Another important parameter but one that doesn't fit a line so 131 | it already starts on the next one. 132 | 133 | Raises: 134 | Exception: When Something goes wrong. 135 | 136 | Returns: 137 | A very important return value. 138 | """ 139 | ``` 140 | 141 | - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. 142 | 143 | - We follow [PEP 8](https://peps.python.org/pep-0008/) as enforced by [Ruff](https://ruff.rs/) with a line length of 79 characters. 144 | As long as you run our full [*tox*] suite before committing, or install our [*pre-commit*] hooks (ideally you'll do both – see [*Local Development Environment*](#local-development-environment) above), you won't have to spend any time on formatting your code at all. 145 | If you don't, [CI] will catch it for you – but that seems like a waste of your time! 146 | 147 | 148 | ## Tests 149 | 150 | - Write your asserts as `expected == actual` to line them up nicely: 151 | 152 | ```python 153 | x = f() 154 | 155 | assert 42 == x.some_attribute 156 | assert "foo" == x._a_private_attribute 157 | ``` 158 | 159 | - To run the test suite, all you need is a recent [*tox*]. 160 | It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. 161 | - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). 162 | - If you've changed or added public APIs, please update our type stubs (files ending in `.pyi`). 163 | 164 | 165 | ## Documentation 166 | 167 | - We use [Markdown] everywhere except in `docs/api.rst` and docstrings. 168 | 169 | - Use [semantic newlines] in [*reStructuredText*] and [Markdown] files (files ending in `.rst` and `.md`): 170 | 171 | ```markdown 172 | This is a sentence. 173 | This is another sentence. 174 | ``` 175 | 176 | - If you start a new section, add two blank lines before and one blank line after the header, except if two headers follow immediately after each other: 177 | 178 | ```markdown 179 | Last line of previous section. 180 | 181 | 182 | ## Header of New Top Section 183 | 184 | ### Header of New Section 185 | 186 | First line of new section. 187 | ``` 188 | 189 | 190 | ### Changelog 191 | 192 | If your change is noteworthy, there needs to be a changelog entry in [`CHANGELOG.md`](https://github.com/pyca/service-identity/blob/main/CHANGELOG.md), so our users can learn about it! 193 | 194 | - The changelog follows the [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) standard. 195 | Please add the best-fitting section if it's missing for the current release. 196 | We use the following order: `Security`, `Removed`, `Deprecated`, `Added`, `Changed`, `Fixed`. 197 | - As with other docs, please use [semantic newlines] in the changelog. 198 | - Make the last line a link to your pull request. 199 | You probably have to open it first to know the number. 200 | - Wrap symbols like modules, functions, or classes into backticks so they are rendered in a `monospace font`. 201 | - Wrap arguments into asterisks like in docstrings: 202 | `Added new argument *an_argument*.` 203 | - If you mention functions or other callables, add parentheses at the end of their names: 204 | `service-identity.func()` or `service-identity.Class.method()`. 205 | This makes the changelog a lot more readable. 206 | - Prefer simple past tense or constructions with "now". 207 | For example: 208 | 209 | * Added `service-identity.func()`. 210 | * `service-identity.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 211 | 212 | 213 | #### Example entries 214 | 215 | ```markdown 216 | - Added `service-identity.func()`. 217 | The feature really *is* awesome. 218 | [#1](https://github.com/pyca/service-identity/pull/1) 219 | ``` 220 | 221 | or: 222 | 223 | ```markdown 224 | - `service-identity.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 225 | The bug really *was* nasty. 226 | [#1](https://github.com/pyca/service-identity/pull/1) 227 | ``` 228 | 229 | 230 | [CI]: https://github.com/pyca/service-identity/actions 231 | [Hynek Schlawack]: https://hynek.me/about/ 232 | [*pre-commit*]: https://pre-commit.com/ 233 | [*tox*]: https://tox.wiki/ 234 | [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ 235 | [*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html 236 | [Markdown]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax 237 | -------------------------------------------------------------------------------- /src/service_identity/hazmat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common verification code. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import ipaddress 8 | import re 9 | 10 | from typing import Protocol, Sequence, Union, runtime_checkable 11 | 12 | import attr 13 | 14 | from .exceptions import ( 15 | CertificateError, 16 | DNSMismatch, 17 | IPAddressMismatch, 18 | Mismatch, 19 | SRVMismatch, 20 | URIMismatch, 21 | VerificationError, 22 | ) 23 | 24 | 25 | try: 26 | import idna 27 | except ImportError: 28 | idna = None # type: ignore[assignment] 29 | 30 | 31 | @attr.s(slots=True) 32 | class ServiceMatch: 33 | """ 34 | A match of a service id and a certificate pattern. 35 | """ 36 | 37 | service_id: ServiceID = attr.ib() 38 | cert_pattern: CertificatePattern = attr.ib() 39 | 40 | 41 | def verify_service_identity( 42 | cert_patterns: Sequence[CertificatePattern], 43 | obligatory_ids: Sequence[ServiceID], 44 | optional_ids: Sequence[ServiceID], 45 | ) -> list[ServiceMatch]: 46 | """ 47 | Verify whether *cert_patterns* are valid for *obligatory_ids* and 48 | *optional_ids*. 49 | 50 | *obligatory_ids* must be both present and match. *optional_ids* must match 51 | if a pattern of the respective type is present. 52 | """ 53 | if not cert_patterns: 54 | msg = "Certificate does not contain any `subjectAltName`s." 55 | raise CertificateError(msg) 56 | 57 | errors = [] 58 | matches = _find_matches(cert_patterns, obligatory_ids) + _find_matches( 59 | cert_patterns, optional_ids 60 | ) 61 | 62 | matched_ids = [match.service_id for match in matches] 63 | for i in obligatory_ids: 64 | if i not in matched_ids: 65 | errors.append( # noqa: PERF401 66 | i.error_on_mismatch(mismatched_id=i) 67 | ) 68 | 69 | for i in optional_ids: 70 | # If an optional ID is not matched by a certificate pattern *but* there 71 | # is a pattern of the same type , it is an error and the verification 72 | # fails. Example: the user passes a SRV-ID for "_mail.domain.com" but 73 | # the certificate contains an SRV-Pattern for "_xmpp.domain.com". 74 | if i not in matched_ids and _contains_instance_of( 75 | cert_patterns, i.pattern_class 76 | ): 77 | errors.append( # noqa: PERF401 78 | i.error_on_mismatch(mismatched_id=i) 79 | ) 80 | 81 | if errors: 82 | raise VerificationError(errors=errors) 83 | 84 | return matches 85 | 86 | 87 | def _find_matches( 88 | cert_patterns: Sequence[CertificatePattern], 89 | service_ids: Sequence[ServiceID], 90 | ) -> list[ServiceMatch]: 91 | """ 92 | Search for matching certificate patterns and service_ids. 93 | 94 | Args: 95 | service_ids: List of service IDs like DNS_ID. 96 | """ 97 | matches = [] 98 | for sid in service_ids: 99 | for cid in cert_patterns: 100 | if sid.verify(cid): 101 | matches.append( # noqa: PERF401 102 | ServiceMatch(cert_pattern=cid, service_id=sid) 103 | ) 104 | 105 | return matches 106 | 107 | 108 | def _contains_instance_of(seq: Sequence[object], cl: type) -> bool: 109 | return any(isinstance(e, cl) for e in seq) 110 | 111 | 112 | def _is_ip_address(pattern: str | bytes) -> bool: 113 | """ 114 | Check whether *pattern* could be/match an IP address. 115 | 116 | Args: 117 | pattern: A pattern for a host name. 118 | 119 | Returns: 120 | `True` if *pattern* could be an IP address, else `False`. 121 | """ 122 | if isinstance(pattern, bytes): 123 | try: 124 | pattern = pattern.decode("ascii") 125 | except UnicodeError: 126 | return False 127 | 128 | try: 129 | int(pattern) 130 | except ValueError: 131 | pass 132 | else: 133 | return True 134 | 135 | try: 136 | ipaddress.ip_address(pattern.replace("*", "1")) 137 | except ValueError: 138 | return False 139 | 140 | return True 141 | 142 | 143 | @attr.s(slots=True) 144 | class DNSPattern: 145 | """ 146 | A DNS pattern as extracted from certificates. 147 | """ 148 | 149 | #: The pattern. 150 | pattern: bytes = attr.ib() 151 | 152 | _RE_LEGAL_CHARS = re.compile(rb"^[a-z0-9\-_.]+$") 153 | 154 | @classmethod 155 | def from_bytes(cls, pattern: bytes) -> DNSPattern: 156 | if not isinstance(pattern, bytes): 157 | msg = "The DNS pattern must be a bytes string." 158 | raise TypeError(msg) 159 | 160 | pattern = pattern.strip() 161 | 162 | if pattern == b"" or _is_ip_address(pattern) or b"\0" in pattern: 163 | msg = f"Invalid DNS pattern {pattern!r}." 164 | raise CertificateError(msg) 165 | 166 | pattern = pattern.translate(_TRANS_TO_LOWER) 167 | if b"*" in pattern: 168 | _validate_pattern(pattern) 169 | 170 | return cls(pattern=pattern) 171 | 172 | 173 | @attr.s(slots=True) 174 | class IPAddressPattern: 175 | """ 176 | An IP address pattern as extracted from certificates. 177 | """ 178 | 179 | #: The pattern. 180 | pattern: ipaddress.IPv4Address | ipaddress.IPv6Address = attr.ib() 181 | 182 | @classmethod 183 | def from_bytes(cls, bs: bytes) -> IPAddressPattern: 184 | try: 185 | return cls(pattern=ipaddress.ip_address(bs)) 186 | except ValueError: 187 | msg = f"Invalid IP address pattern {bs!r}." 188 | raise CertificateError(msg) from None 189 | 190 | 191 | @attr.s(slots=True) 192 | class URIPattern: 193 | """ 194 | An URI pattern as extracted from certificates. 195 | """ 196 | 197 | #: The pattern for the protocol part. 198 | protocol_pattern: bytes = attr.ib() 199 | #: The pattern for the DNS part. 200 | dns_pattern: DNSPattern = attr.ib() 201 | 202 | @classmethod 203 | def from_bytes(cls, pattern: bytes) -> URIPattern: 204 | if not isinstance(pattern, bytes): 205 | msg = "The URI pattern must be a bytes string." 206 | raise TypeError(msg) 207 | 208 | pattern = pattern.strip().translate(_TRANS_TO_LOWER) 209 | 210 | if b":" not in pattern or b"*" in pattern or _is_ip_address(pattern): 211 | msg = f"Invalid URI pattern {pattern!r}." 212 | raise CertificateError(msg) 213 | 214 | protocol_pattern, hostname = pattern.split(b":") 215 | 216 | return cls( 217 | protocol_pattern=protocol_pattern, 218 | dns_pattern=DNSPattern.from_bytes(hostname), 219 | ) 220 | 221 | 222 | @attr.s(slots=True) 223 | class SRVPattern: 224 | """ 225 | An SRV pattern as extracted from certificates. 226 | """ 227 | 228 | #: The pattern for the name part. 229 | name_pattern: bytes = attr.ib() 230 | #: The pattern for the DNS part. 231 | dns_pattern: DNSPattern = attr.ib() 232 | 233 | @classmethod 234 | def from_bytes(cls, pattern: bytes) -> SRVPattern: 235 | if not isinstance(pattern, bytes): 236 | msg = "The SRV pattern must be a bytes string." 237 | raise TypeError(msg) 238 | 239 | pattern = pattern.strip().translate(_TRANS_TO_LOWER) 240 | 241 | if ( 242 | pattern[0] != b"_"[0] 243 | or b"." not in pattern 244 | or b"*" in pattern 245 | or _is_ip_address(pattern) 246 | ): 247 | msg = f"Invalid SRV pattern {pattern!r}." 248 | raise CertificateError(msg) 249 | 250 | name, hostname = pattern.split(b".", 1) 251 | return cls( 252 | name_pattern=name[1:], dns_pattern=DNSPattern.from_bytes(hostname) 253 | ) 254 | 255 | 256 | CertificatePattern = Union[ 257 | SRVPattern, URIPattern, DNSPattern, IPAddressPattern 258 | ] 259 | """ 260 | A :class:`Union` of all possible patterns that can be extracted from a 261 | certificate. 262 | """ 263 | 264 | 265 | @runtime_checkable 266 | class ServiceID(Protocol): 267 | @property 268 | def pattern_class(self) -> type[CertificatePattern]: ... 269 | 270 | @property 271 | def error_on_mismatch(self) -> type[Mismatch]: ... 272 | 273 | def verify(self, pattern: CertificatePattern) -> bool: ... 274 | 275 | 276 | @attr.s(init=False, slots=True) 277 | class DNS_ID: 278 | """ 279 | A DNS service ID, aka hostname. 280 | """ 281 | 282 | hostname: bytes = attr.ib() 283 | 284 | # characters that are legal in a normalized hostname 285 | _RE_LEGAL_CHARS = re.compile(rb"^[a-z0-9\-_.]+$") 286 | pattern_class = DNSPattern 287 | error_on_mismatch = DNSMismatch 288 | 289 | def __init__(self, hostname: str): 290 | if not isinstance(hostname, str): 291 | msg = "DNS-ID must be a text string." 292 | raise TypeError(msg) 293 | 294 | hostname = hostname.strip() 295 | if not hostname or _is_ip_address(hostname): 296 | msg = "Invalid DNS-ID." 297 | raise ValueError(msg) 298 | 299 | if any(ord(c) > 127 for c in hostname): 300 | if idna: 301 | ascii_id = idna.encode(hostname) 302 | else: 303 | msg = "idna library is required for non-ASCII IDs." 304 | raise ImportError(msg) 305 | else: 306 | ascii_id = hostname.encode("ascii") 307 | 308 | self.hostname = ascii_id.translate(_TRANS_TO_LOWER) 309 | if self._RE_LEGAL_CHARS.match(self.hostname) is None: 310 | msg = "Invalid DNS-ID." 311 | raise ValueError(msg) 312 | 313 | def verify(self, pattern: CertificatePattern) -> bool: 314 | """ 315 | https://tools.ietf.org/search/rfc6125#section-6.4 316 | """ 317 | if isinstance(pattern, self.pattern_class): 318 | return _hostname_matches(pattern.pattern, self.hostname) 319 | 320 | return False 321 | 322 | 323 | @attr.s(slots=True) 324 | class IPAddress_ID: 325 | """ 326 | An IP address service ID. 327 | """ 328 | 329 | ip: ipaddress.IPv4Address | ipaddress.IPv6Address = attr.ib( 330 | converter=ipaddress.ip_address 331 | ) 332 | 333 | pattern_class = IPAddressPattern 334 | error_on_mismatch = IPAddressMismatch 335 | 336 | def verify(self, pattern: CertificatePattern) -> bool: 337 | """ 338 | https://tools.ietf.org/search/rfc2818#section-3.1 339 | """ 340 | if isinstance(pattern, self.pattern_class): 341 | return self.ip == pattern.pattern 342 | 343 | return False 344 | 345 | 346 | @attr.s(init=False, slots=True) 347 | class URI_ID: 348 | """ 349 | An URI service ID. 350 | """ 351 | 352 | protocol: bytes = attr.ib() 353 | dns_id: DNS_ID = attr.ib() 354 | 355 | pattern_class = URIPattern 356 | error_on_mismatch = URIMismatch 357 | 358 | def __init__(self, uri: str): 359 | if not isinstance(uri, str): 360 | msg = "URI-ID must be a text string." 361 | raise TypeError(msg) 362 | 363 | uri = uri.strip() 364 | if ":" not in uri or _is_ip_address(uri): 365 | msg = "Invalid URI-ID." 366 | raise ValueError(msg) 367 | 368 | prot, hostname = uri.split(":") 369 | 370 | self.protocol = prot.encode("ascii").translate(_TRANS_TO_LOWER) 371 | self.dns_id = DNS_ID(hostname.strip("/")) 372 | 373 | def verify(self, pattern: CertificatePattern) -> bool: 374 | """ 375 | https://tools.ietf.org/search/rfc6125#section-6.5.2 376 | """ 377 | if isinstance(pattern, self.pattern_class): 378 | return ( 379 | pattern.protocol_pattern == self.protocol 380 | and self.dns_id.verify(pattern.dns_pattern) 381 | ) 382 | 383 | return False 384 | 385 | 386 | @attr.s(init=False, slots=True) 387 | class SRV_ID: 388 | """ 389 | An SRV service ID. 390 | """ 391 | 392 | name: bytes = attr.ib() 393 | dns_id: DNS_ID = attr.ib() 394 | 395 | pattern_class = SRVPattern 396 | error_on_mismatch = SRVMismatch 397 | 398 | def __init__(self, srv: str): 399 | if not isinstance(srv, str): 400 | msg = "SRV-ID must be a text string." 401 | raise TypeError(msg) 402 | 403 | srv = srv.strip() 404 | if "." not in srv or _is_ip_address(srv) or srv[0] != "_": 405 | msg = "Invalid SRV-ID." 406 | raise ValueError(msg) 407 | 408 | name, hostname = srv.split(".", 1) 409 | 410 | self.name = name[1:].encode("ascii").translate(_TRANS_TO_LOWER) 411 | self.dns_id = DNS_ID(hostname) 412 | 413 | def verify(self, pattern: CertificatePattern) -> bool: 414 | """ 415 | https://tools.ietf.org/search/rfc6125#section-6.5.1 416 | """ 417 | if isinstance(pattern, self.pattern_class): 418 | return self.name == pattern.name_pattern and self.dns_id.verify( 419 | pattern.dns_pattern 420 | ) 421 | 422 | return False 423 | 424 | 425 | def _hostname_matches(cert_pattern: bytes, actual_hostname: bytes) -> bool: 426 | """ 427 | :return: `True` if *cert_pattern* matches *actual_hostname*, else `False`. 428 | """ 429 | if b"*" in cert_pattern: 430 | cert_head, cert_tail = cert_pattern.split(b".", 1) 431 | actual_head, actual_tail = actual_hostname.split(b".", 1) 432 | if cert_tail != actual_tail: 433 | return False 434 | # No patterns for IDNA 435 | if actual_head.startswith(b"xn--"): 436 | return False 437 | 438 | return cert_head in (b"*", actual_head) 439 | 440 | return cert_pattern == actual_hostname 441 | 442 | 443 | def _validate_pattern(cert_pattern: bytes) -> None: 444 | """ 445 | Check whether the usage of wildcards within *cert_pattern* conforms with 446 | our expectations. 447 | """ 448 | cnt = cert_pattern.count(b"*") 449 | if cnt > 1: 450 | msg = f"Certificate's DNS-ID {cert_pattern!r} contains too many wildcards." 451 | raise CertificateError(msg) 452 | parts = cert_pattern.split(b".") 453 | if len(parts) < 3: 454 | msg = f"Certificate's DNS-ID {cert_pattern!r} has too few host components for wildcard usage." 455 | raise CertificateError(msg) 456 | # We assume there will always be only one wildcard allowed. 457 | if b"*" not in parts[0]: 458 | msg = f"Certificate's DNS-ID {cert_pattern!r} has a wildcard outside the left-most part." 459 | raise CertificateError(msg) 460 | if any(not len(p) for p in parts): 461 | msg = f"Certificate's DNS-ID {cert_pattern!r} contains empty parts." 462 | raise CertificateError(msg) 463 | 464 | 465 | # Ensure no locale magic interferes. 466 | _TRANS_TO_LOWER = bytes.maketrans( 467 | b"ABCDEFGHIJKLMNOPQRSTUVWXYZ", b"abcdefghijklmnopqrstuvwxyz" 468 | ) 469 | -------------------------------------------------------------------------------- /tests/test_hazmat.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import pickle 3 | 4 | import pytest 5 | 6 | import service_identity.hazmat 7 | 8 | from service_identity.cryptography import extract_patterns 9 | from service_identity.exceptions import ( 10 | CertificateError, 11 | DNSMismatch, 12 | SRVMismatch, 13 | VerificationError, 14 | ) 15 | from service_identity.hazmat import ( 16 | DNS_ID, 17 | SRV_ID, 18 | URI_ID, 19 | DNSPattern, 20 | IPAddress_ID, 21 | IPAddressPattern, 22 | ServiceMatch, 23 | SRVPattern, 24 | URIPattern, 25 | _contains_instance_of, 26 | _find_matches, 27 | _hostname_matches, 28 | _is_ip_address, 29 | _validate_pattern, 30 | verify_service_identity, 31 | ) 32 | 33 | from .certificates import DNS_IDS 34 | from .test_cryptography import CERT_EVERYTHING 35 | 36 | 37 | try: 38 | import idna 39 | except ImportError: 40 | idna = None 41 | 42 | 43 | class TestVerifyServiceIdentity: 44 | """ 45 | Simple integration tests for verify_service_identity. 46 | """ 47 | 48 | def test_no_cert_patterns(self): 49 | """ 50 | Empty cert patterns raise a helpful CertificateError. 51 | """ 52 | with pytest.raises( 53 | CertificateError, 54 | match="Certificate does not contain any `subjectAltName`s", 55 | ): 56 | verify_service_identity( 57 | cert_patterns=[], obligatory_ids=[], optional_ids=[] 58 | ) 59 | 60 | def test_dns_id_success(self): 61 | """ 62 | Return pairs of certificate ids and service ids on matches. 63 | """ 64 | rv = verify_service_identity( 65 | DNS_IDS, [DNS_ID("twistedmatrix.com")], [] 66 | ) 67 | assert [ 68 | ServiceMatch( 69 | cert_pattern=DNSPattern.from_bytes(b"twistedmatrix.com"), 70 | service_id=DNS_ID("twistedmatrix.com"), 71 | ) 72 | ] == rv 73 | 74 | def test_integration_dns_id_fail(self): 75 | """ 76 | Raise VerificationError if no certificate id matches the supplied 77 | service ids. 78 | """ 79 | i = DNS_ID("wrong.host") 80 | with pytest.raises(VerificationError) as e: 81 | verify_service_identity( 82 | DNS_IDS, obligatory_ids=[i], optional_ids=[] 83 | ) 84 | assert [DNSMismatch(mismatched_id=i)] == e.value.errors 85 | 86 | def test_ip_address_success(self): 87 | """ 88 | IP addresses patterns are matched against IP address IDs. 89 | """ 90 | ip4 = ipaddress.ip_address("2.2.2.2") 91 | ip6 = ipaddress.ip_address("2a00:1c38::53") 92 | id4 = IPAddress_ID(str(ip4)) 93 | id6 = IPAddress_ID(str(ip6)) 94 | rv = verify_service_identity( 95 | extract_patterns(CERT_EVERYTHING), [id4, id6], [] 96 | ) 97 | 98 | assert [ 99 | ServiceMatch(id4, IPAddressPattern(ip4)), 100 | ServiceMatch(id6, IPAddressPattern(ip6)), 101 | ] == rv 102 | 103 | def test_obligatory_missing(self): 104 | """ 105 | Raise if everything matches but one of the obligatory IDs is missing. 106 | """ 107 | i = DNS_ID("example.net") 108 | with pytest.raises(VerificationError) as e: 109 | verify_service_identity( 110 | [SRVPattern.from_bytes(b"_mail.example.net")], 111 | obligatory_ids=[SRV_ID("_mail.example.net"), i], 112 | optional_ids=[], 113 | ) 114 | assert [DNSMismatch(mismatched_id=i)] == e.value.errors 115 | 116 | def test_obligatory_mismatch(self): 117 | """ 118 | Raise if one of the obligatory IDs doesn't match. 119 | """ 120 | i = DNS_ID("example.net") 121 | with pytest.raises(VerificationError) as e: 122 | verify_service_identity( 123 | [ 124 | SRVPattern.from_bytes(b"_mail.example.net"), 125 | DNSPattern.from_bytes(b"example.com"), 126 | ], 127 | obligatory_ids=[SRV_ID("_mail.example.net"), i], 128 | optional_ids=[], 129 | ) 130 | assert [DNSMismatch(mismatched_id=i)] == e.value.errors 131 | 132 | def test_optional_missing(self): 133 | """ 134 | Optional IDs may miss as long as they don't conflict with an existing 135 | pattern. 136 | """ 137 | p = DNSPattern.from_bytes(b"mail.foo.com") 138 | i = DNS_ID("mail.foo.com") 139 | rv = verify_service_identity( 140 | [p], obligatory_ids=[i], optional_ids=[SRV_ID("_mail.foo.com")] 141 | ) 142 | assert [ServiceMatch(cert_pattern=p, service_id=i)] == rv 143 | 144 | def test_optional_mismatch(self): 145 | """ 146 | Raise VerificationError if an ID from optional_ids does not match 147 | a pattern of respective type even if obligatory IDs match. 148 | """ 149 | i = SRV_ID("_xmpp.example.com") 150 | with pytest.raises(VerificationError) as e: 151 | verify_service_identity( 152 | [ 153 | DNSPattern.from_bytes(b"example.net"), 154 | SRVPattern.from_bytes(b"_mail.example.com"), 155 | ], 156 | obligatory_ids=[DNS_ID("example.net")], 157 | optional_ids=[i], 158 | ) 159 | assert [SRVMismatch(mismatched_id=i)] == e.value.errors 160 | 161 | def test_contains_optional_and_matches(self): 162 | """ 163 | If an optional ID is found, return the match within the returned 164 | list and don't raise an error. 165 | """ 166 | p = SRVPattern.from_bytes(b"_mail.example.net") 167 | i = SRV_ID("_mail.example.net") 168 | rv = verify_service_identity( 169 | [DNSPattern.from_bytes(b"example.net"), p], 170 | obligatory_ids=[DNS_ID("example.net")], 171 | optional_ids=[i], 172 | ) 173 | assert ServiceMatch(cert_pattern=p, service_id=i) == rv[1] 174 | 175 | 176 | class TestContainsInstance: 177 | def test_positive(self): 178 | """ 179 | If the list contains an object of the type, return True. 180 | """ 181 | assert _contains_instance_of([object(), (), object()], tuple) 182 | 183 | def test_negative(self): 184 | """ 185 | If the list does not contain an object of the type, return False. 186 | """ 187 | assert not _contains_instance_of([object(), [], {}], tuple) 188 | 189 | 190 | class TestDNS_ID: 191 | def test_enforces_unicode(self): 192 | """ 193 | Raise TypeError if pass DNS-ID is not unicode. 194 | """ 195 | with pytest.raises(TypeError): 196 | DNS_ID(b"foo.com") 197 | 198 | def test_handles_missing_idna(self, monkeypatch): 199 | """ 200 | Raise ImportError if idna is missing and a non-ASCII DNS-ID is passed. 201 | """ 202 | monkeypatch.setattr(service_identity.hazmat, "idna", None) 203 | with pytest.raises(ImportError): 204 | DNS_ID("f\xf8\xf8.com") 205 | 206 | def test_ascii_works_without_idna(self, monkeypatch): 207 | """ 208 | 7bit-ASCII DNS-IDs work no matter whether idna is present or not. 209 | """ 210 | monkeypatch.setattr(service_identity.hazmat, "idna", None) 211 | dns = DNS_ID("foo.com") 212 | assert b"foo.com" == dns.hostname 213 | 214 | @pytest.mark.skipif(idna is None, reason="idna not installed") 215 | def test_idna_used_if_available_on_non_ascii(self): 216 | """ 217 | If idna is installed and a non-ASCII DNS-ID is passed, encode it to 218 | ASCII. 219 | """ 220 | dns = DNS_ID("f\xf8\xf8.com") 221 | assert b"xn--f-5gaa.com" == dns.hostname 222 | 223 | @pytest.mark.parametrize( 224 | "invalid_id", 225 | [ 226 | " ", 227 | "", # empty strings 228 | "host,name", # invalid chars 229 | "192.168.0.0", 230 | "::1", 231 | "1234", # IP addresses 232 | ], 233 | ) 234 | def test_catches_invalid_dns_ids(self, invalid_id): 235 | """ 236 | Raise ValueError on invalid DNS-IDs. 237 | """ 238 | with pytest.raises(ValueError): 239 | DNS_ID(invalid_id) 240 | 241 | def test_lowercases(self): 242 | """ 243 | The hostname is lowercased so it can be compared case-insensitively. 244 | """ 245 | dns_id = DNS_ID("hOsTnAmE") 246 | assert b"hostname" == dns_id.hostname 247 | 248 | def test_verifies_only_dns(self): 249 | """ 250 | If anything else than DNSPattern is passed to verify, return False. 251 | """ 252 | assert not DNS_ID("foo.com").verify(object()) 253 | 254 | def test_simple_match(self): 255 | """ 256 | Simple integration test with _hostname_matches with a match. 257 | """ 258 | assert DNS_ID("foo.com").verify(DNSPattern.from_bytes(b"foo.com")) 259 | 260 | def test_simple_mismatch(self): 261 | """ 262 | Simple integration test with _hostname_matches with a mismatch. 263 | """ 264 | assert not DNS_ID("foo.com").verify(DNSPattern.from_bytes(b"bar.com")) 265 | 266 | def test_matches(self): 267 | """ 268 | Valid matches return `True`. 269 | """ 270 | for cert, actual in [ 271 | (b"www.example.com", b"www.example.com"), 272 | (b"*.example.com", b"www.example.com"), 273 | ]: 274 | assert _hostname_matches(cert, actual) 275 | 276 | def test_mismatches(self): 277 | """ 278 | Invalid matches return `False`. 279 | """ 280 | for cert, actual in [ 281 | (b"xxx.example.com", b"www.example.com"), 282 | (b"*.example.com", b"baa.foo.example.com"), 283 | (b"f*.example.com", b"baa.example.com"), 284 | (b"*.bar.com", b"foo.baz.com"), 285 | (b"*.bar.com", b"bar.com"), 286 | (b"x*.example.com", b"xn--gtter-jua.example.com"), 287 | (b"xxx*.example.com", b"xxxwww.example.com"), 288 | (b"f*.example.com", b"foo.example.com"), 289 | (b"*oo.bar.com", b"foo.bar.com"), 290 | (b"fo*oo.bar.com", b"fooooo.bar.com"), 291 | ]: 292 | assert not _hostname_matches(cert, actual) 293 | 294 | 295 | class TestURI_ID: 296 | def test_enforces_unicode(self): 297 | """ 298 | Raise TypeError if pass URI-ID is not unicode. 299 | """ 300 | with pytest.raises(TypeError): 301 | URI_ID(b"sip:foo.com") 302 | 303 | def test_create_DNS_ID(self): 304 | """ 305 | The hostname is converted into a DNS_ID object. 306 | """ 307 | uri_id = URI_ID("sip:foo.com") 308 | assert DNS_ID("foo.com") == uri_id.dns_id 309 | assert b"sip" == uri_id.protocol 310 | 311 | def test_lowercases(self): 312 | """ 313 | The protocol is lowercased so it can be compared case-insensitively. 314 | """ 315 | uri_id = URI_ID("sIp:foo.com") 316 | assert b"sip" == uri_id.protocol 317 | 318 | def test_catches_missing_colon(self): 319 | """ 320 | Raise ValueError if there's no colon within a URI-ID. 321 | """ 322 | with pytest.raises(ValueError): 323 | URI_ID("sip;foo.com") 324 | 325 | def test_is_only_valid_for_uri(self): 326 | """ 327 | If anything else than an URIPattern is passed to verify, return 328 | False. 329 | """ 330 | assert not URI_ID("sip:foo.com").verify(object()) 331 | 332 | def test_protocol_mismatch(self): 333 | """ 334 | If protocol doesn't match, verify returns False. 335 | """ 336 | assert not URI_ID("sip:foo.com").verify( 337 | URIPattern.from_bytes(b"xmpp:foo.com") 338 | ) 339 | 340 | def test_dns_mismatch(self): 341 | """ 342 | If the hostname doesn't match, verify returns False. 343 | """ 344 | assert not URI_ID("sip:bar.com").verify( 345 | URIPattern.from_bytes(b"sip:foo.com") 346 | ) 347 | 348 | def test_match(self): 349 | """ 350 | Accept legal matches. 351 | """ 352 | assert URI_ID("sip:foo.com").verify( 353 | URIPattern.from_bytes(b"sip:foo.com") 354 | ) 355 | 356 | 357 | class TestSRV_ID: 358 | def test_enforces_unicode(self): 359 | """ 360 | Raise TypeError if pass srv-ID is not unicode. 361 | """ 362 | with pytest.raises(TypeError): 363 | SRV_ID(b"_mail.example.com") 364 | 365 | def test_create_DNS_ID(self): 366 | """ 367 | The hostname is converted into a DNS_ID object. 368 | """ 369 | srv_id = SRV_ID("_mail.example.com") 370 | assert DNS_ID("example.com") == srv_id.dns_id 371 | 372 | def test_lowercases(self): 373 | """ 374 | The service name is lowercased so it can be compared 375 | case-insensitively. 376 | """ 377 | srv_id = SRV_ID("_MaIl.foo.com") 378 | assert b"mail" == srv_id.name 379 | 380 | def test_catches_missing_dot(self): 381 | """ 382 | Raise ValueError if there's no dot within a SRV-ID. 383 | """ 384 | with pytest.raises(ValueError): 385 | SRV_ID("_imapsfoocom") 386 | 387 | def test_catches_missing_underscore(self): 388 | """ 389 | Raise ValueError if the service is doesn't start with an underscore. 390 | """ 391 | with pytest.raises(ValueError): 392 | SRV_ID("imaps.foo.com") 393 | 394 | def test_is_only_valid_for_SRV(self): 395 | """ 396 | If anything else than an SRVPattern is passed to verify, return False. 397 | """ 398 | assert not SRV_ID("_mail.foo.com").verify(object()) 399 | 400 | def test_match(self): 401 | """ 402 | Accept legal matches. 403 | """ 404 | assert SRV_ID("_mail.foo.com").verify( 405 | SRVPattern.from_bytes(b"_mail.foo.com") 406 | ) 407 | 408 | @pytest.mark.skipif(idna is None, reason="idna not installed") 409 | def test_match_idna(self): 410 | """ 411 | IDNAs are handled properly. 412 | """ 413 | assert SRV_ID("_mail.f\xf8\xf8.com").verify( 414 | SRVPattern.from_bytes(b"_mail.xn--f-5gaa.com") 415 | ) 416 | 417 | def test_mismatch_service_name(self): 418 | """ 419 | If the service name doesn't match, verify returns False. 420 | """ 421 | assert not ( 422 | SRV_ID("_mail.foo.com").verify( 423 | SRVPattern.from_bytes(b"_xmpp.foo.com") 424 | ) 425 | ) 426 | 427 | def test_mismatch_dns(self): 428 | """ 429 | If the dns_id doesn't match, verify returns False. 430 | """ 431 | assert not ( 432 | SRV_ID("_mail.foo.com").verify( 433 | SRVPattern.from_bytes(b"_mail.bar.com") 434 | ) 435 | ) 436 | 437 | 438 | class TestDNSPattern: 439 | def test_enforces_bytes(self): 440 | """ 441 | Raise TypeError if unicode is passed. 442 | """ 443 | with pytest.raises(TypeError): 444 | DNSPattern.from_bytes("foo.com") 445 | 446 | def test_catches_empty(self): 447 | """ 448 | Empty DNS-IDs raise a :class:`CertificateError`. 449 | """ 450 | with pytest.raises(CertificateError): 451 | DNSPattern.from_bytes(b" ") 452 | 453 | def test_catches_NULL_bytes(self): 454 | """ 455 | Raise :class:`CertificateError` if a NULL byte is in the hostname. 456 | """ 457 | with pytest.raises(CertificateError): 458 | DNSPattern.from_bytes(b"www.google.com\0nasty.h4x0r.com") 459 | 460 | def test_catches_ip_address(self): 461 | """ 462 | IP addresses are invalid and raise a :class:`CertificateError`. 463 | """ 464 | with pytest.raises(CertificateError): 465 | DNSPattern.from_bytes(b"192.168.0.0") 466 | 467 | def test_invalid_wildcard(self): 468 | """ 469 | Integration test with _validate_pattern: catches double wildcards thus 470 | is used if an wildward is present. 471 | """ 472 | with pytest.raises(CertificateError): 473 | DNSPattern.from_bytes(b"*.foo.*") 474 | 475 | 476 | class TestURIPattern: 477 | def test_enforces_bytes(self): 478 | """ 479 | Raise TypeError if unicode is passed. 480 | """ 481 | with pytest.raises(TypeError): 482 | URIPattern.from_bytes("sip:foo.com") 483 | 484 | def test_catches_missing_colon(self): 485 | """ 486 | Raise CertificateError if URI doesn't contain a `:`. 487 | """ 488 | with pytest.raises(CertificateError): 489 | URIPattern.from_bytes(b"sip;foo.com") 490 | 491 | def test_catches_wildcards(self): 492 | """ 493 | Raise CertificateError if URI contains a *. 494 | """ 495 | with pytest.raises(CertificateError): 496 | URIPattern.from_bytes(b"sip:*.foo.com") 497 | 498 | 499 | class TestSRVPattern: 500 | def test_enforces_bytes(self): 501 | """ 502 | Raise TypeError if unicode is passed. 503 | """ 504 | with pytest.raises(TypeError): 505 | SRVPattern.from_bytes("_mail.example.com") 506 | 507 | def test_catches_missing_underscore(self): 508 | """ 509 | Raise CertificateError if SRV doesn't start with a `_`. 510 | """ 511 | with pytest.raises(CertificateError): 512 | SRVPattern.from_bytes(b"foo.com") 513 | 514 | def test_catches_wildcards(self): 515 | """ 516 | Raise CertificateError if SRV contains a *. 517 | """ 518 | with pytest.raises(CertificateError): 519 | SRVPattern.from_bytes(b"sip:*.foo.com") 520 | 521 | 522 | class TestValidateDNSWildcardPattern: 523 | def test_allows_only_one_wildcard(self): 524 | """ 525 | Raise CertificateError on multiple wildcards. 526 | """ 527 | with pytest.raises(CertificateError): 528 | _validate_pattern(b"*.*.com") 529 | 530 | def test_wildcard_must_be_left_most(self): 531 | """ 532 | Raise CertificateError if wildcard is not in the left-most part. 533 | """ 534 | for hn in [b"foo.b*r.com", b"foo.bar.c*m", b"foo.*", b"foo.*.com"]: 535 | with pytest.raises(CertificateError): 536 | _validate_pattern(hn) 537 | 538 | def test_must_have_at_least_three_parts(self): 539 | """ 540 | Raise CertificateError if host consists of less than three parts. 541 | """ 542 | for hn in [ 543 | b"*", 544 | b"*.com", 545 | b"*fail.com", 546 | b"*foo", 547 | b"foo*", 548 | b"f*o", 549 | b"*.example.", 550 | ]: 551 | with pytest.raises(CertificateError): 552 | _validate_pattern(hn) 553 | 554 | def test_valid_patterns(self): 555 | """ 556 | Does not throw CertificateError on valid patterns. 557 | """ 558 | for pattern in [ 559 | b"*.bar.com", 560 | b"*oo.bar.com", 561 | b"f*.bar.com", 562 | b"f*o.bar.com", 563 | ]: 564 | _validate_pattern(pattern) 565 | 566 | 567 | class TestIPAddressPattern: 568 | def test_invalid_ip(self): 569 | """ 570 | Raises CertificateError on invalid IP addresses. 571 | """ 572 | with pytest.raises(CertificateError): 573 | IPAddressPattern.from_bytes(b"127.o.o.1") 574 | 575 | @pytest.mark.parametrize("ip_s", ["1.1.1.1", "::1"]) 576 | def test_verify_equal(self, ip_s): 577 | """ 578 | Return True if IP addresses are identical. 579 | """ 580 | ip = ipaddress.ip_address(ip_s) 581 | 582 | assert IPAddress_ID(ip).verify(IPAddressPattern(ip)) is True 583 | 584 | 585 | class FakeCertID: 586 | pass 587 | 588 | 589 | class Fake_ID: 590 | """ 591 | An ID that accepts exactly on object as pattern. 592 | """ 593 | 594 | def __init__(self, pattern): 595 | self._pattern = pattern 596 | 597 | def verify(self, other): 598 | """ 599 | True iff other is the same object as pattern. 600 | """ 601 | return other is self._pattern 602 | 603 | 604 | class TestFindMatches: 605 | def test_one_match(self): 606 | """ 607 | If there's a match, return a tuple of the certificate id and the 608 | service id. 609 | """ 610 | valid_cert_id = FakeCertID() 611 | valid_id = Fake_ID(valid_cert_id) 612 | rv = _find_matches( 613 | [FakeCertID(), valid_cert_id, FakeCertID()], [valid_id] 614 | ) 615 | 616 | assert [ 617 | ServiceMatch(cert_pattern=valid_cert_id, service_id=valid_id) 618 | ] == rv 619 | 620 | def test_no_match(self): 621 | """ 622 | If no valid certificate ids are found, return an empty list. 623 | """ 624 | rv = _find_matches( 625 | [FakeCertID(), FakeCertID(), FakeCertID()], [Fake_ID(object())] 626 | ) 627 | 628 | assert [] == rv 629 | 630 | def test_multiple_matches(self): 631 | """ 632 | Return all matches. 633 | """ 634 | valid_cert_id_1 = FakeCertID() 635 | valid_cert_id_2 = FakeCertID() 636 | valid_cert_id_3 = FakeCertID() 637 | valid_id_1 = Fake_ID(valid_cert_id_1) 638 | valid_id_2 = Fake_ID(valid_cert_id_2) 639 | valid_id_3 = Fake_ID(valid_cert_id_3) 640 | rv = _find_matches( 641 | [ 642 | FakeCertID(), 643 | valid_cert_id_1, 644 | FakeCertID(), 645 | valid_cert_id_3, 646 | FakeCertID(), 647 | valid_cert_id_2, 648 | ], 649 | [valid_id_1, valid_id_2, valid_id_3], 650 | ) 651 | 652 | assert [ 653 | ServiceMatch(cert_pattern=valid_cert_id_1, service_id=valid_id_1), 654 | ServiceMatch(cert_pattern=valid_cert_id_2, service_id=valid_id_2), 655 | ServiceMatch(cert_pattern=valid_cert_id_3, service_id=valid_id_3), 656 | ] == rv 657 | 658 | 659 | class TestIsIPAddress: 660 | @pytest.mark.parametrize( 661 | "ip", 662 | [ 663 | b"127.0.0.1", 664 | "127.0.0.1", 665 | "172.16.254.12", 666 | "*.0.0.1", 667 | "::1", 668 | "*::1", 669 | "2001:0db8:0000:0000:0000:ff00:0042:8329", 670 | "2001:0db8::ff00:0042:8329", 671 | ], 672 | ) 673 | def test_ips(self, ip): 674 | """ 675 | Returns True for patterns and hosts that could match IP addresses. 676 | """ 677 | assert _is_ip_address(ip) is True 678 | 679 | @pytest.mark.parametrize( 680 | "not_ip", 681 | [ 682 | b"*.twistedmatrix.com", 683 | b"twistedmatrix.com", 684 | b"mail.google.com", 685 | b"omega7.de", 686 | b"omega7", 687 | b"127.\xff.0.1", 688 | ], 689 | ) 690 | def test_not_ips(self, not_ip): 691 | """ 692 | Return False for patterns and hosts that aren't IP addresses. 693 | """ 694 | assert _is_ip_address(not_ip) is False 695 | 696 | 697 | class TestVerificationError: 698 | def test_repr_str(self): 699 | """ 700 | The __str__ and __repr__ methods return something helpful. 701 | """ 702 | with pytest.raises(VerificationError) as ei: 703 | raise VerificationError(errors=["foo"]) 704 | 705 | assert repr(ei.value) == str(ei.value) 706 | assert str(ei.value) != "" 707 | 708 | @pytest.mark.parametrize("proto", range(pickle.HIGHEST_PROTOCOL + 1)) 709 | @pytest.mark.parametrize( 710 | "exc", 711 | [ 712 | VerificationError(errors=[]), 713 | VerificationError(errors=[DNSMismatch("example.com")]), 714 | VerificationError([]), 715 | VerificationError([DNSMismatch("example.com")]), 716 | ], 717 | ) 718 | def test_pickle(self, exc, proto): 719 | """ 720 | Exceptions can be pickled and unpickled. 721 | """ 722 | new_exc = pickle.loads(pickle.dumps(exc, proto)) 723 | 724 | # Exceptions can't be compared. 725 | assert exc.__class__ == new_exc.__class__ 726 | assert exc.__dict__ == new_exc.__dict__ 727 | --------------------------------------------------------------------------------