service_identity-26.1.0/.git_archival.txt0000644000000000000000000000014615206547410015357 0ustar00node: $Format:%H$ node-date: $Format:%cI$ describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ service_identity-26.1.0/.gitattributes0000644000000000000000000000021715206547410014776 0ustar00# Force LF line endings for text files * text=auto eol=lf # Needed for hatch-vcs / setuptools-scm-git-archive .git_archival.txt export-subst service_identity-26.1.0/.pre-commit-config.yaml0000644000000000000000000000117315206547410016366 0ustar00--- repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.15 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell args: [-L, fo] - repo: https://github.com/econchick/interrogate rev: 1.7.0 hooks: - id: interrogate args: [tests] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - id: check-yaml service_identity-26.1.0/.python-version-default0000644000000000000000000000000515206547410016525 0ustar003.14 service_identity-26.1.0/.readthedocs.yaml0000644000000000000000000000067015206547410015335 0ustar00--- version: 2 build: os: ubuntu-lts-latest tools: # Keep version in sync with tox.ini/docs and ci.yml/docs. python: "3.14" jobs: create_environment: # Need the tags to calculate the version (sometimes). - git fetch --tags - asdf plugin add uv - asdf install uv latest - asdf global uv latest build: html: - uvx --with tox-uv tox run -e docs-build -- $READTHEDOCS_OUTPUT service_identity-26.1.0/CHANGELOG.md0000644000000000000000000002065215206547410013721 0ustar00# Changelog All notable changes to this project will be documented in this file. 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/). The **first number** of the version is the year. The **second number** is incremented with each release, starting at 1 for each year. The **third number** is for emergencies when we need to start branches for older releases. You can find out backwards-compatibility policy [here](https://github.com/pyca/service-identity/blob/main/.github/SECURITY.md). ## [26.1.0](https://github.com/pyca/service-identity/compare/24.2.0...26.1.0) - 2026-05-30 ### Added - Python 3.14 and 3.15 are now officially supported. [#85](https://github.com/pyca/service-identity/pull/85) [#93](https://github.com/pyca/service-identity/pull/93) ### Changed - *service-identity* now uses *cryptography*'s Rust-based ASN.1 decoder and doesn't depend on *pyasn1* and *pyasn1-modules* anymore. As a result, the oldest supported pyOpenSSL backend combination is now *pyOpenSSL* 26.1.0 with *cryptography* 47.0.0. [#95](https://github.com/pyca/service-identity/pull/95) ### Fixed - Verifying a single-label hostname (e.g. `localhost`) against a wildcard certificate pattern now raises `VerificationError` cleanly instead of crashing with an opaque `ValueError`. [#92](https://github.com/pyca/service-identity/pull/92) ## [24.2.0](https://github.com/pyca/service-identity/compare/24.1.0...24.2.0) - 2024-10-26 ### Added - Python 3.13 is now officially supported. [#74](https://github.com/pyca/service-identity/pull/74) ### Changed - pyOpenSSL's identity extraction has been reimplemented using *cryptography*'s primitives instead of deprecated pyOpenSSL APIs. As a result, the oldest supported pyOpenSSL version is now 17.1.0. [#70](https://github.com/pyca/service-identity/pull/70) ## [24.1.0](https://github.com/pyca/service-identity/compare/23.1.0...24.1.0) - 2024-01-14 ### Changed - 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. [#67](https://github.com/pyca/service-identity/pull/67) ## [23.1.0](https://github.com/pyca/service-identity/compare/21.1.0...23.1.0) - 2023-06-14 ### Removed - All Python versions up to and including 3.7 have been dropped. - Support for `commonName` in certificates has been dropped. It has been deprecated since 2017 and isn't supported by any major browser. - The oldest supported pyOpenSSL version (when using the `pyopenssl` backend) is now 17.0.0. When using such an old pyOpenSSL version, you have to pin *cryptography* yourself to ensure compatibility between them. 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. ### Deprecated - If you've used `service_identity.(cryptography|pyopenssl).extract_ids()`, please switch to the new names `extract_patterns()`. [#56](https://github.com/pyca/service-identity/pull/56) ### Added - `service_identity.(cryptography|pyopenssl).extract_patterns()` are now public APIs (FKA `extract_ids()`). You can use them to extract the patterns from a certificate without verifying anything. [#55](https://github.com/pyca/service-identity/pull/55) - *service-identity* is now fully typed. [#57](https://github.com/pyca/service-identity/pull/57) ## [21.1.0](https://github.com/pyca/service-identity/compare/18.1.0...21.1.0) - 2021-05-09 ### Removed - Python 3.4 is not supported anymore. 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. 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. But we don't test it anymore and will block it once someone reports breakage. ### Fixed - `service_identity.exceptions.VerificationError` can now be pickled and is overall more well-behaved as an exception. This raises the requirement of `attrs` to 19.1.0. ## [18.1.0](https://github.com/pyca/service-identity/compare/17.0.0...18.1.0) - 2018-12-05 ### Added - pyOpenSSL is optional now if you use `service_identity.cryptography.*` only. - Added support for `iPAddress` `subjectAltName`s. 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()`. [#12](https://github.com/pyca/service-identity/pull/12) ## [17.0.0](https://github.com/pyca/service-identity/compare/16.0.0...17.0.0) - 2017-05-23 ### Deprecated - 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. We have been raising a warning since 16.0.0 and the support will be removed in mid-2018 for good. ### Added - When `service_identity.SubjectAltNameWarning` is raised, the Common Name of the certificate is now included in the warning message. [#17](https://github.com/pyca/service-identity/pull/17) - Added `cryptography.x509` backend for verifying certificates. [#18](https://github.com/pyca/service-identity/pull/18) ### Changed - Wildcards (`*`) are now only allowed if they are the leftmost label in a certificate. This is common practice by all major browsers. [#19](https://github.com/pyca/service-identity/pull/19) ## [16.0.0](https://github.com/pyca/service-identity/compare/14.0.0...16.0.0) - 2016-02-18 ### Removed - Python 3.3 and 2.6 aren't supported anymore. They may work by chance but any effort to keep them working has ceased. The last Python 2.6 release was on October 29, 2013 and isn't supported by the CPython core team anymore. Major Python packages like Django and Twisted dropped Python 2.6 a while ago already. Python 3.3 never had a significant user base and wasn't part of any distribution's LTS release. - pyOpenSSL versions older than 0.14 are not tested anymore. They don't even build on recent OpenSSL versions. Please note that its support may break without further notice. ### Added - Officially support Python 3.5. - A `__str__` method to `VerificationError`. ### Changed - `service_identity.SubjectAltNameWarning` is now raised if the server certificate lacks a proper `SubjectAltName`. [#9](https://github.com/pyca/service-identity/issues/9) - Port from `characteristic` to its spiritual successor [attrs](https://www.attrs.org/). ## [14.0.0](https://github.com/pyca/service-identity/compare/1.0.0...14.0.0) - 2014-08-22 ### Changed - Switch to year-based version numbers. - Port to `characteristic` 14.0 (get rid of deprecation warnings). - Package docs with source distributions. ## [1.0.0](https://github.com/pyca/service-identity/compare/0.2.0...1.0.0) - 2014-06-15 ### Removed - Drop support for Python 3.2. 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/). ### Changed - Move into the [Python Cryptography Authority’s GitHub account](https://github.com/pyca/). - Move exceptions into `service_identity.exceptions` so tracebacks don’t contain private module names. - Promoting to stable since Twisted 14.0 is optionally depending on `service-identity` now. - Use [characteristic](https://characteristic.readthedocs.io/) instead of a home-grown solution. ### Fixed - `idna` 0.6 did some backward-incompatible fixes that broke Python 3 support. This has been fixed now therefore *service-identity* only works with `idna` 0.6 and later. Unfortunately since `idna` doesn’t offer version introspection, *service-identity* can’t warn about it. ## [0.2.0](https://github.com/pyca/service-identity/compare/0.1.0...0.2.0) - 2014-04-06 ### Added - Official support for Python 3.4. ### Changed - Refactor into a multi-module package. Most notably, `verify_hostname` and `extract_ids` live in the `service_identity.pyopenssl` module now. - `verify_hostname` now takes an `OpenSSL.SSL.Connection` for the first argument. - More strict checks for URI_IDs. ### Fixed - Less false positives in IP address detection. ## [0.1.0](https://github.com/pyca/service-identity/tree/0.1.0) - 2014-03-03 Initial release. service_identity-26.1.0/README.md0000644000000000000000000000631415206547410013366 0ustar00# Service Identity Verification Documentation License: MIT PyPI release Downloads per month No AI slop inside. PyCA on IRC Use this package if: - you want to **verify** that a [PyCA *cryptography*](https://cryptography.io/) certificate is valid for a certain hostname or IP address, - 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, - or if you want to **inspect** certificates from either for service IDs. *service-identity* aspires to give you all the tools you need for verifying whether a certificate is valid for the intended purposes. In the simplest case, this means *host name verification*. However, *service-identity* implements [RFC 6125](https://datatracker.ietf.org/doc/html/rfc6125.html) fully. Also check out [*pem*](https://github.com/hynek/pem) that makes loading certificates from all kinds of PEM-encoded files a breeze! ## Project Information *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/). ### Credits *service-identity* is written and maintained by [Hynek Schlawack](https://hynek.me/). 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). ### *service-identity* for Enterprise Available as part of the [Tidelift Subscription](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek). 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. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. service_identity-26.1.0/tox.ini0000644000000000000000000000455515206547410013427 0ustar00[tox] min_version = 4.22 env_list = lint, mypy-{api,pkg}, docs-{build,doctests}, py3{8-15}{,-pyopenssl}{,-oldest}{,-idna}, coverage-{combine,report} [testenv] package = wheel wheel_build_env = .pkg dependency_groups = tests extras = idna: idna deps = pyopenssl: pyopenssl pass_env = FORCE_COLOR NO_COLOR set_env = oldest: PIP_CONSTRAINT = tests/constraints/oldest-cryptography.txt oldest: UV_CONSTRAINT = tests/constraints/oldest-cryptography.txt pyopenssl-oldest: PIP_CONSTRAINT = tests/constraints/oldest-pyopenssl.txt pyopenssl-oldest: UV_CONSTRAINT = tests/constraints/oldest-pyopenssl.txt commands = coverage run -m pytest {posargs} py312-pyopenssl-latest-idna: coverage run -m pytest --doctest-modules --doctest-glob='*.rst' {posargs} # Split combine/report in 2 to avoid excessive "Combined data file ..." output. [testenv:coverage-combine] # keep in-sync with .python-version-default base_python = py314 depends = py3* skip_install = true deps = coverage commands = coverage combine [testenv:coverage-report] # keep in-sync with .python-version-default base_python = py314 depends = coverage-combine parallel_show_output = true skip_install = true deps = coverage commands = coverage report [testenv:lint] skip_install = true deps = prek commands = prek run --all-files {posargs} [testenv:mypy-api] dependency_groups = mypy commands = mypy tests/typing docs/pyopenssl_example.py [testenv:mypy-pkg] dependency_groups = mypy commands = mypy src [testenv:docs-{build,doctests,linkcheck}] # Keep base_python in sync with gh-actions and .readthedocs.yaml. base_python = py314 dependency_groups = docs commands = # N.B. doctests is not a nitpicky as build -- we need to run both in CI! build: sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html doctests: sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html linkcheck: sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/html [testenv:docs-watch] package = editable base_python = {[testenv:docs-build]base_python} dependency_groups = {[testenv:docs-build]dependency_groups} deps = watchfiles commands = watchfiles \ --ignore-paths docs/_build/ \ 'sphinx-build -W -n --jobs auto -b html -d {envtmpdir}/doctrees docs docs/_build/html' \ src \ docs service_identity-26.1.0/.github/AI_POLICY.md0000644000000000000000000001011715206547410015335 0ustar00# Generative AI / LLM Policy We appreciate that we can't realistically police how you author your pull requests, which includes whether you employ large-language model (LLM)-based development tools. So, we don't. However, due to both legal and human reasons, we have to establish boundaries. > [!CAUTION] > **TL;DR:** > - We take the responsibility for this project very seriously and we expect you to take your responsibility for your contributions seriously, too. > This used to be a given, but it changed now that a pull request is just one prompt away. > > - Every contribution has to be backed by a human who unequivocally owns the copyright for all changes. > No LLM bots in `Co-authored-by:`s. > > - DoS-by-slop leads to a permanent ban. > > - Absolutely **no** unsupervised agentic tools like OpenClaw. > > --- > > By submitting a pull request, you certify that: > > - You are the author of the contribution or have the legal right to submit it. > - You either hold the copyright to the changes or have explicit legal authorization to contribute them under this project's license. > - You understand the code. > - You accept full responsibility for it. ## Legal There is ongoing legal uncertainty regarding the copyright status of LLM-generated works and their provenance. Since we do not have a formal [Contributor License Agreement](https://en.wikipedia.org/wiki/Contributor_license_agreement) (CLA), you retain your copyright to your changes to this project. Therefore, allowing contributions by LLMs has unpredictable consequences for the copyright status of this project – even when leaving aside possible copyright violations due to plagiarism. ## Human As the makers of software that is used by millions of people worldwide and with a reputation for high-quality maintenance, we take our responsibility to our users very seriously. No matter what LLM vendors or boosters on LinkedIn tell you, we have to manually review every change before merging, because it's **our responsibility** to keep the project stable. Please understand that by opening low-quality pull requests you're not helping anyone. Worse, you're [poisoning the open source ecosystem](https://lwn.net/Articles/1058266/) that was precarious even before the arrival of LLM tools. Having to wade through plausible-looking-but-low-quality pull requests and trying to determine which ones are legit is extremely demoralizing and has already burned out many good maintainers. Put bluntly, we have no time or interest to become part of your vibe coding loop where you drop LLM slop at our door, we spend time and energy to review it, and you just feed it back into the LLM for another iteration. This dynamic is especially pernicious because it poisons the well for mentoring new contributors which we are committed to. ## Summary In practice, this means: - Pull requests that have an LLM product listed as co-author can't be merged and will be closed without further discussion. We cannot risk the copyright status of this project. If you used LLM tools during development, you may still submit – but you must remove any LLM co-author tags and take full ownership of every line. - By submitting a pull request, **you** take full **technical and legal** responsibility for the contents of the pull request and promise that **you** hold the copyright for the changes submitted. "An LLM wrote it" is **not** an acceptable response to questions or critique. **If you cannot explain and defend the changes you submit, do not submit them** and open a high-quality bug report/feature request instead. - Accounts that exercise bot-like behavior – like automated mass pull requests – will be permanently banned, whether they belong to a human or not. - Do **not** post LLM-generated review comments – we can prompt LLMs ourselves should we desire their wisdom. Do **not** post summaries unless you've fact-checked them and take responsibility for 100% of their content. Remember that *all* LLM output *looks* **plausible**. When using these tools, it's **your** responsibility to ensure that it's also **correct** and has a reasonable signal-to-noise ratio. service_identity-26.1.0/.github/CODE_OF_CONDUCT.md0000644000000000000000000000162615206547410016247 0ustar00# Code of Conduct 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/). 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. ## Enforcement We take Code of Conduct violations seriously, and will act to ensure our spaces are welcoming, inclusive, and professional environments to communicate in. If you need to raise a Code of Conduct report, you may do so privately by email to [Hynek Schlawack](mailto:hs@ox.cx). Reports will be treated confidentially. Alternately you can make a [report to the Python Software Foundation](https://policies.python.org/python.org/code-of-conduct/Procedures-for-Reporting-Incidents/). service_identity-26.1.0/.github/CONTRIBUTING.md0000644000000000000000000002314415206547410015700 0ustar00# How To Contribute > [!IMPORTANT] > - This document is mainly to help you to get started by codifying tribal knowledge and expectations and make it more accessible to everyone. > But don't be afraid to open half-finished PRs and ask questions if something is unclear! > > - If you use LLM / "AI" tools for your contributions, please read and follow our [_Generative AI / LLM Policy_][llm]. 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). By participating in this project you agree to abide by its terms. Please report any harm to [Hynek Schlawack] in any way you find appropriate. ## Workflow Thank you for considering contributing to *service-identity*! It's people like *you* who make it such a great tool for everyone. - **Only contribute code that you fully understand.** See also our [AI policy][llm]. - No contribution is too small! Please submit as many fixes for typos and grammar bloopers as you can! - Before starting big contributions, **talk to us first**. Don't waste energy / tokens on something that we do not want. Rejecting a huge PR is unpleasant for everybody. - Try to limit each pull request to *one* change only. - Since we squash on merge, it's up to you how you handle updates to the `main` branch. Whether you prefer to rebase on `main` or merge `main` into your branch, do whatever is more comfortable for you. Just remember to [not use your own `main` branch for the pull request](https://hynek.me/articles/pull-requests-branch/). - *Always* add tests and docs for your code. This is a hard rule; patches with missing tests or documentation won't be merged. - Make sure your changes pass our [CI]. You won't get any feedback until it's green unless you ask for it. For the CI to pass, the coverage must be 100%. If you have problems to test something, open anyway and ask for advice. In some situations, we may agree to add an `# pragma: no cover`. - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. - Don’t break backwards-compatibility. ## Local Development Environment First, **fork** the repository on GitHub. Make sure to **uncheck** the `Copy the main branch only` radio button on the `Create a new fork` page. If you don't, our test suite will fail because we use Git tags for packaging. Finally, **clone** it using one of the alternatives that you can copy-paste by pressing the big green button labeled `<> Code`. You can (and should) run our test suite using [*tox*] (and keep in mind that `tox run-parallel` is much faster than `tox run`). However, you'll probably want a more traditional environment as well. We recommend using the Python version from the `.python-version-default` file in the project's root directory, because that's the one that is used in the CI by default, too. If you're using [*direnv*](https://direnv.net), you can automate the creation of the project virtual environment with the correct Python version by adding the following `.envrc` to the project root: ```bash layout python python$(cat .python-version-default) ``` or, if you like [*uv*](https://github.com/astral-sh/uv): ```bash test -d .venv || (uv venv --python $(cat .python-version-default) && uv pip install -e . --group dev) . .venv/bin/activate ``` > [!WARNING] > - **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. > - **Always create a new branch off `main` for each new pull request.** > Yes, you can work on `main` in your fork and submit pull requests. > But this will *inevitably* lead to you not being able to synchronize your fork with upstream and having to start over. > > See also: [Why you shouldn't use your `main` branch for pull requests](https://hynek.me/articles/pull-requests-branch/). Change into the newly created directory and after activating a virtual environment, install an editable version of *service-identity* along with its tests requirements (requires Pip 25.1 or *uv* 0.4.27 or later that added support for [dependency groups](https://peps.python.org/pep-0735/)): ```console $ pip install -e . --group dev # or `uv pip install -e . --group dev` ``` Now you can run the test suite: ```console $ python -Im pytest ``` When working on the documentation, use: ```console $ tox run -e docs-watch ``` This will build the documentation, watch for changes, and rebuild it whenever you save a file. To just build the documentation and exit immediately use: ```console $ tox run -e docs-build ``` You will find the built documentation in `docs/_build/html`. To run doctests: ```console $ tox run -e docs-doctests ``` To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] and its hooks: ```console $ pre-commit install ``` This is not strictly necessary, because our [*tox*] file contains an environment that runs: ```console $ pre-commit run --all-files ``` But it's way more comfortable to run it locally and catch avoidable errors before pushing them to GitHub. ## Code - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). 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: ```python def func(x: str, y: int) -> str: """ Do something. Args: x: A very important parameter. y: Another important parameter but one that doesn't fit a line so it already starts on the next one. Raises: Exception: When Something goes wrong. Returns: A very important return value. """ ``` - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. - We follow [PEP 8](https://peps.python.org/pep-0008/) as enforced by [Ruff](https://ruff.rs/) with a line length of 79 characters. 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. If you don't, [CI] will catch it for you – but that seems like a waste of your time! ## Tests - Write your asserts as `expected == actual` to line them up nicely: ```python x = f() assert 42 == x.some_attribute assert "foo" == x._a_private_attribute ``` - To run the test suite, all you need is a recent [*tox*]. It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). - If you've changed or added public APIs, please update our type stubs (files ending in `.pyi`). ## Documentation - We use [Markdown] everywhere except in `docs/api.rst` and docstrings. - Use [semantic newlines] in [*reStructuredText*] and [Markdown] files (files ending in `.rst` and `.md`): ```markdown This is a sentence. This is another sentence. ``` - 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: ```markdown Last line of previous section. ## Header of New Top Section ### Header of New Section First line of new section. ``` ### Changelog 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! - The changelog follows the [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) standard. Please add the best-fitting section if it's missing for the current release. We use the following order: `Security`, `Removed`, `Deprecated`, `Added`, `Changed`, `Fixed`. - As with other docs, please use [semantic newlines] in the changelog. - Make the last line a link to your pull request. You probably have to open it first to know the number. - Wrap symbols like modules, functions, or classes into backticks so they are rendered in a `monospace font`. - Wrap arguments into asterisks like in docstrings: `Added new argument *an_argument*.` - If you mention functions or other callables, add parentheses at the end of their names: `service-identity.func()` or `service-identity.Class.method()`. This makes the changelog a lot more readable. - Prefer simple past tense or constructions with "now". For example: * Added `service-identity.func()`. * `service-identity.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. #### Example entries ```markdown - Added `service-identity.func()`. The feature really *is* awesome. [#1](https://github.com/pyca/service-identity/pull/1) ``` or: ```markdown - `service-identity.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. The bug really *was* nasty. [#1](https://github.com/pyca/service-identity/pull/1) ``` [CI]: https://github.com/pyca/service-identity/actions [Hynek Schlawack]: https://hynek.me/about/ [*pre-commit*]: https://pre-commit.com/ [*tox*]: https://tox.wiki/ [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ [*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html [Markdown]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax [llm]: AI_POLICY.md service_identity-26.1.0/.github/FUNDING.yml0000644000000000000000000000016015206547410015255 0ustar00--- github: hynek tidelift: "pypi/service-identity" thanks_dev: u/gh/hynek custom: https://hynek.me/say-thanks/ service_identity-26.1.0/.github/PULL_REQUEST_TEMPLATE.md0000644000000000000000000000447715206547410017260 0ustar00# Summary # Pull Request Check List - [ ] I acknowledge this project's [**AI policy**](https://github.com/pyca/service-identity/blob/main/.github/AI_POLICY.md). - [ ] This pull requests is [**not** from my `main` branch](https://hynek.me/articles/pull-requests-branch/). - Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you. - [ ] There's **tests** for all new and changed code. - [ ] **New APIs** are added to our typing tests in [`api.py`](https://github.com/pyca/service-identity/blob/main/tests/typing/api.py). - [ ] Updated **documentation** for changed code. - [ ] New functions/classes have to be added to `docs/api.rst` by hand. - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). - The next version is the second number in the current release + 1. The first number represents the current year. So if the current version on PyPI is 26.1.0, the next version is gonna be 26.2.0. If the next version is the first in the new year, it'll be 27.1.0. - [ ] Documentation in `.rst` and `.md` files is written using [**semantic newlines**](https://rhodesmill.org/brandon/2012/one-sentence-per-line/). - [ ] Changes (and possible deprecations) are documented in the [**changelog**](https://github.com/pyca/service-identity/blob/main/CHANGELOG.md). service_identity-26.1.0/.github/SECURITY.md0000644000000000000000000000140515206547410015234 0ustar00# Security Policy ## Supported Versions We are following [Calendar Versioning](https://calver.org) with generous backwards-compatibility guarantees. *Therefore we only support the latest version*. That said, you shouldn't be afraid to upgrade if you're only using our documented public APIs and pay attention to `DeprecationWarning`s. 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. ## Reporting a Vulnerability To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. service_identity-26.1.0/.github/dependabot.yml0000644000000000000000000000046715206547410016302 0ustar00--- version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: monthly cooldown: # https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns default-days: 7 groups: github-actions: patterns: - "*" service_identity-26.1.0/.github/workflows/ci.yml0000644000000000000000000001511115206547410016615 0ustar00--- name: CI on: push: branches: [main] pull_request: workflow_dispatch: env: FORCE_COLOR: "1" # Make tools pretty. PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_PYTHON_VERSION_WARNING: "1" permissions: {} jobs: lint: name: Run pre-commit runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version-default - uses: tox-dev/action-pre-commit-uv@41a04ab74d5ec7ca33c8db8a59b6e3291d576033 # v1.0.4 build-package: name: Build & verify package runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df # v2.18.0 id: baipp outputs: # Used to define the matrix for tests below. The value is based on # packaging metadata (trove classifiers). python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} tests: name: Tests & Mypy API on ${{ matrix.python-version }} runs-on: ubuntu-latest needs: build-package strategy: fail-fast: false matrix: # Created by the build-and-inspect-python-package action above. python-version: ${{ fromJson(needs.build-package.outputs.python-versions) }} env: PYTHON: ${{ matrix.python-version }} steps: - name: Download pre-built packages uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - run: | tar xf dist/*.tar.gz --strip-components=1 rm -rf src - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Run tests run: > uvx --with tox-uv tox run --installpkg dist/*.whl -f py${PYTHON//./} - name: Upload coverage data uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-data-${{ matrix.python-version }} path: .coverage.* include-hidden-files: true if-no-files-found: ignore - name: Check public API with Mypy run: > uvx --with tox-uv tox run --installpkg dist/*.whl -e mypy-api coverage: name: Ensure 100% test coverage runs-on: ubuntu-latest needs: tests if: always() steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version-default - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Download coverage data uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: coverage-data-* merge-multiple: true - name: Combine coverage and fail if it's <100%. run: | uv tool install coverage coverage combine coverage html --skip-covered --skip-empty # Report and write to summary. coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. coverage report --fail-under=100 - name: Upload HTML report if check failed. uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: html-report path: htmlcov if: ${{ failure() }} mypy-pkg: name: Mypy Codebase runs-on: ubuntu-latest needs: build-package steps: - name: Download pre-built packages uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version-default - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - run: > uvx --with tox-uv tox run -e mypy-pkg install-dev: strategy: matrix: os: [ubuntu-latest, windows-latest] name: Verify dev env runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version-default - name: Install in dev mode & import run: | python -Im pip install --group dev -e . python -Ic 'import service_identity; print(service_identity.__version__)' python -Ic 'import service_identity.pyopenssl' python -Ic 'import service_identity.cryptography' docs: name: Build docs & run doctests needs: build-package runs-on: ubuntu-latest steps: - name: Download pre-built packages uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: # Keep in sync with tox.ini/docs & .readthedocs.yaml python-version: "3.14" - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - run: > uvx --with tox-uv tox run -e docs-build,docs-doctests required-checks-pass: name: Ensure everything required is passing for branch protection if: always() needs: - coverage - lint - mypy-pkg - docs - install-dev runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} service_identity-26.1.0/.github/workflows/codeql-analysis.yml0000644000000000000000000000164515206547410021321 0ustar00--- name: CodeQL on: schedule: - cron: "41 3 * * 6" permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [python] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 service_identity-26.1.0/.github/workflows/pypi-package.yml0000644000000000000000000000414015206547410020574 0ustar00--- name: Build & upload PyPI package on: push: branches: [main] tags: ["*"] release: types: - published workflow_dispatch: jobs: # Always build & lint package. build-package: name: Build & verify package runs-on: ubuntu-latest permissions: attestations: write id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df # v2.18.0 with: attest-build-provenance-github: 'true' # Upload to Test PyPI on every commit on main. release-test-pypi: name: Publish in-dev package to test.pypi.org environment: release-test-pypi if: github.repository_owner == 'pyca' && github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: build-package permissions: id-token: write steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - name: Upload package to Test PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ # Upload to real PyPI on GitHub Releases. release-pypi: name: Publish released package to pypi.org environment: release-pypi if: github.repository_owner == 'pyca' && github.event.action == 'published' runs-on: ubuntu-latest needs: build-package permissions: id-token: write steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 service_identity-26.1.0/.github/workflows/zizmor.yml0000644000000000000000000000207115206547410017555 0ustar00# https://github.com/woodruffw/zizmor name: Zizmor on: push: branches: ["main"] pull_request: branches: ["*"] permissions: contents: read jobs: zizmor: name: Zizmor latest via PyPI runs-on: ubuntu-latest permissions: security-events: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Run zizmor 🌈 run: uvx zizmor --format sarif . > results.sarif env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: # Path to SARIF file relative to the root of the repository sarif_file: results.sarif # Optional category for the results # Used to differentiate multiple results for one commit category: zizmor service_identity-26.1.0/docs/Makefile0000644000000000000000000001522215206547410014475 0ustar00# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(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/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/service_identity.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/service_identity.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/service_identity" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/service_identity" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." service_identity-26.1.0/docs/api.rst0000644000000000000000000000347615206547410014350 0ustar00=== API === .. note:: So far, public high-level APIs are only available for host names (:rfc:`6125`) and IP addresses (:rfc:`2818`). All IDs specified by :rfc:`6125` are already implemented though. If you'd like to play with them and provide feedback have a look at the ``verify_service_identity`` function in the `hazmat module `_. PyCA cryptography ================= .. currentmodule:: service_identity.cryptography .. autofunction:: verify_certificate_hostname .. autofunction:: verify_certificate_ip_address .. autofunction:: extract_patterns pyOpenSSL ========= .. currentmodule:: service_identity.pyopenssl .. autofunction:: verify_hostname In practice, this may look like the following: .. include:: pyopenssl_example.py :literal: .. autofunction:: verify_ip_address .. autofunction:: extract_patterns Hazardous Materials =================== .. currentmodule:: service_identity.hazmat .. danger:: The following APIs require reader's discretion. They are stable and they've been using internally by *service-identity* for years, but you need to know what you're doing. Pattern Objects --------------- The following are the objects return by the ``extract_patterns`` functions. They each carry the attributes that are necessary to match an ID of their type. .. autoclass:: CertificatePattern It includes all of those that follow now. .. autoclass:: DNSPattern :members: .. autoclass:: IPAddressPattern :members: .. autoclass:: URIPattern :members: .. autoclass:: SRVPattern :members: Universal Errors and Warnings ============================= .. currentmodule:: service_identity .. autoexception:: VerificationError .. autoexception:: CertificateError .. autoexception:: SubjectAltNameWarning service_identity-26.1.0/docs/changelog.md0000644000000000000000000000004115206547410015277 0ustar00```{include} ../CHANGELOG.md ``` service_identity-26.1.0/docs/conf.py0000644000000000000000000000720015206547410014331 0ustar00from importlib import metadata # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.doctest", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "myst_parser", "notfound.extension", ] myst_enable_extensions = [ "colon_fence", "smartquotes", "deflist", ] # Move type hints into the description block, instead of the func definition. autodoc_typehints = "description" autodoc_typehints_description_target = "documented" # GitHub has rate limits linkcheck_ignore = [ r"https://github.com/.*/(issues|pull|compare)/\d+", r"https://twitter.com/.*", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. project = "service-identity" year = 2014 copyright = "2014, Hynek Schlawack" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. release = metadata.version("service-identity") # The short X.Y version. version = release.rsplit(".", 1)[0] # Avoid confusing in-dev versions. if "dev" in release: release = version = "UNRELEASED" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] nitpick_ignore = [ ("py:class", "cryptography.hazmat.bindings._rust.x509.Certificate"), ] # -- Options for HTML output ---------------------------------------------- html_theme = "furo" html_theme_options = { "top_of_page_buttons": [], "light_css_variables": { "font-stack": "Inter, sans-serif", "font-stack--monospace": "BerkeleyMono, MonoLisa, ui-monospace, " "SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace", }, } html_static_path = ["_static"] html_css_files = ["custom.css"] # Output file base name for HTML help builder. htmlhelp_basename = "service-identitydoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = {} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( "index", "service-identity.tex", "service\\_identity Documentation", "Hynek Schlawack", "manual", ) ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( "index", "service-identity", "service-identity Documentation", ["Hynek Schlawack"], 1, ) ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "service-identity", "service-identity Documentation", "Hynek Schlawack", "service-identity", "Service Identity Verification for pyOpenSSL", "Miscellaneous", ) ] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "pyopenssl": ("https://www.pyopenssl.org/en/stable/", None), "cryptography": ("https://cryptography.io/en/stable/", None), } service_identity-26.1.0/docs/implemented-standards.md0000644000000000000000000000026515206547410017644 0ustar00# Implemented Standards - `dNSName` (DNS-ID, aka host names, {rfc}`6125`). - `iPAddress` ({rfc}`2818`). - `uniformResourceIdentifier` (URI-ID, {rfc}`6125`). - SRV-ID ({rfc}`6125`) service_identity-26.1.0/docs/index.md0000644000000000000000000000155215206547410014467 0ustar00# Service Identity Verification for pyOpenSSL & cryptography Release **{sub-ref}`release`** ({doc}`What's new? `) ```{include} ../README.md :start-after: "spiel-begin -->" :end-before: "## Project Information" ``` ## User's Guide ```{toctree} :maxdepth: 1 installation implemented-standards api ``` ## Indices and tables - {ref}`genindex` - {ref}`search` ## *service-identity* for Enterprise ```{include} ../README.md :start-after: "*service-identity* for Enterprise" ``` ```{toctree} :hidden: :caption: Meta license changelog PyPI GitHub Contributing Security Policy Funding ``` service_identity-26.1.0/docs/installation.md0000644000000000000000000000251115206547410016055 0ustar00# Installation and Requirements ## Installation ```console $ python -Im pip install service-identity ``` ## Requirements *service-identity* depends on the [cryptography] package. In addition to the latest release, we're also testing against the following oldest version constraint: ```{include} ../tests/constraints/oldest-cryptography.txt :literal: true ``` If you want to use the [pyOpenSSL] functionality, you have to install it yourself. In addition to the latest release, we are also testing against the following oldest version constraints (you have to add the *cryptography* pin yourself, if you want to use an old version of pyOpenSSL): ```{include} ../tests/constraints/oldest-pyopenssl.txt :literal: true ``` ### International Domain Names Optionally, the `idna` extra dependency can be used for [internationalized domain names] (IDN), i.e. non-ASCII domains: ```console $ python -Im pip install service-identity[idna] ``` Unfortunately it's required because Python's IDN support in the standard library is [outdated] even in the latest releases. [cryptography]: https://cryptography.io/ [idna]: https://pypi.org/project/idna/ [internationalized domain names]: https://en.wikipedia.org/wiki/Internationalized_domain_name [outdated]: https://github.com/python/cpython/issues/61507 [pyopenssl]: https://pypi.org/project/pyOpenSSL/ service_identity-26.1.0/docs/license.md0000644000000000000000000000042715206547410015002 0ustar00# License and Credits *service-identity* is released under the [MIT](https://github.com/pyca/service-identity/blob/main/LICENSE) license. ```{include} ../README.md :parser: myst_parser.sphinx_ :start-after: "## Credits" :end-before: "### *service-identity* for Enterprise" ``` service_identity-26.1.0/docs/pyopenssl_example.py0000644000000000000000000000224015206547410017152 0ustar00import argparse import pprint import socket import idna from OpenSSL import SSL import service_identity parser = argparse.ArgumentParser( description="Connect to HOST, inspect its certificate " "and verify if it's valid for its hostname." ) parser.add_argument("HOST") args = parser.parse_args() hostname = args.HOST ctx = SSL.Context(SSL.TLSv1_2_METHOD) ctx.set_verify(SSL.VERIFY_PEER, lambda conn, cert, errno, depth, ok: bool(ok)) ctx.set_default_verify_paths() conn = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) conn.set_tlsext_host_name(idna.encode(hostname)) conn.connect((hostname, 443)) try: conn.do_handshake() if cert := conn.get_peer_certificate(): print("Server certificate is valid for the following patterns:\n") pprint.pprint(service_identity.pyopenssl.extract_patterns(cert)) try: service_identity.pyopenssl.verify_hostname(conn, hostname) except service_identity.VerificationError: print(f"\nPresented certificate is NOT valid for {hostname}.") finally: conn.shutdown() except SSL.Error as e: print(f"TLS Handshake failed: {e!r}.") finally: conn.close() service_identity-26.1.0/docs/_static/custom.css0000644000000000000000000000042515206547410016506 0ustar00@import url('https://rsms.me/inter/inter.css'); @import url('https://assets.hynek.me/css/bm.css'); :root { font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ } @supports (font-variation-settings: normal) { :root { font-family: InterVariable, sans-serif; } } service_identity-26.1.0/src/service_identity/__init__.py0000644000000000000000000000132515206547410020355 0ustar00""" Verify service identities. """ from . import cryptography, hazmat, pyopenssl from .exceptions import ( CertificateError, SubjectAltNameWarning, VerificationError, ) __title__ = "service-identity" __author__ = "Hynek Schlawack" __license__ = "MIT" __copyright__ = "Copyright (c) 2014 " + __author__ __all__ = [ "CertificateError", "SubjectAltNameWarning", "VerificationError", "cryptography", "hazmat", "pyopenssl", ] def __getattr__(name: str) -> str: if name != "__version__": msg = f"module {__name__} has no attribute {name}" raise AttributeError(msg) from importlib.metadata import version # noqa: PLC0415 return version("service-identity") service_identity-26.1.0/src/service_identity/cryptography.py0000644000000000000000000001217715206547410021360 0ustar00""" `cryptography.x509 `_-specific code. """ from __future__ import annotations import warnings from typing import Sequence from cryptography.hazmat import asn1 from cryptography.x509 import ( Certificate, DNSName, ExtensionOID, IPAddress, ObjectIdentifier, OtherName, UniformResourceIdentifier, ) from cryptography.x509.extensions import ExtensionNotFound from .exceptions import CertificateError from .hazmat import ( DNS_ID, CertificatePattern, DNSPattern, IPAddress_ID, IPAddressPattern, SRVPattern, URIPattern, verify_service_identity, ) __all__ = ["verify_certificate_hostname"] def verify_certificate_hostname( certificate: Certificate, hostname: str ) -> None: r""" Verify whether *certificate* is valid for *hostname*. .. note:: Nothing is verified about the *authority* of the certificate; the caller must verify that the certificate chains to an appropriate trust root themselves. Args: certificate: A *cryptography* X509 certificate object. hostname: The hostname that *certificate* should be valid for. Raises: service_identity.VerificationError: If *certificate* is not valid for *hostname*. service_identity.CertificateError: If *certificate* contains invalid / unexpected data. This includes the case where the certificate contains no `subjectAltName`\ s. .. versionchanged:: 24.1.0 :exc:`~service_identity.CertificateError` is raised if the certificate contains no ``subjectAltName``\ s instead of :exc:`~service_identity.VerificationError`. """ verify_service_identity( cert_patterns=extract_patterns(certificate), obligatory_ids=[DNS_ID(hostname)], optional_ids=[], ) def verify_certificate_ip_address( certificate: Certificate, ip_address: str ) -> None: r""" Verify whether *certificate* is valid for *ip_address*. .. note:: Nothing is verified about the *authority* of the certificate; the caller must verify that the certificate chains to an appropriate trust root themselves. Args: certificate: A *cryptography* X509 certificate object. ip_address: The IP address that *connection* should be valid for. Can be an IPv4 or IPv6 address. Raises: service_identity.VerificationError: If *certificate* is not valid for *ip_address*. service_identity.CertificateError: If *certificate* contains invalid / unexpected data. This includes the case where the certificate contains no ``subjectAltName``\ s. .. versionadded:: 18.1.0 .. versionchanged:: 24.1.0 :exc:`~service_identity.CertificateError` is raised if the certificate contains no ``subjectAltName``\ s instead of :exc:`~service_identity.VerificationError`. """ verify_service_identity( cert_patterns=extract_patterns(certificate), obligatory_ids=[IPAddress_ID(ip_address)], optional_ids=[], ) ID_ON_DNS_SRV = ObjectIdentifier("1.3.6.1.5.5.7.8.7") # id_on_dnsSRV def extract_patterns(cert: Certificate) -> Sequence[CertificatePattern]: """ Extract all valid ID patterns from a certificate for service verification. Args: cert: The certificate to be dissected. Returns: List of IDs. .. versionchanged:: 23.1.0 ``commonName`` is not used as a fallback anymore. """ ids: list[CertificatePattern] = [] try: ext = cert.extensions.get_extension_for_oid( ExtensionOID.SUBJECT_ALTERNATIVE_NAME ) except ExtensionNotFound: pass else: ids.extend( [ DNSPattern.from_bytes(name.encode("utf-8")) for name in ext.value.get_values_for_type(DNSName) ] ) ids.extend( [ URIPattern.from_bytes(uri.encode("utf-8")) for uri in ext.value.get_values_for_type( UniformResourceIdentifier ) ] ) ids.extend( [ IPAddressPattern(ip) for ip in ext.value.get_values_for_type(IPAddress) ] ) for other in ext.value.get_values_for_type(OtherName): if other.type_id == ID_ON_DNS_SRV: try: srv = asn1.decode_der(asn1.IA5String, other.value) except ValueError as e: msg = "Unexpected certificate content." raise CertificateError(msg) from e ids.append(SRVPattern.from_bytes(srv.as_str().encode("ascii"))) return ids def extract_ids(cert: Certificate) -> Sequence[CertificatePattern]: """ Deprecated and never public API. Use :func:`extract_patterns` instead. .. deprecated:: 23.1.0 """ warnings.warn( category=DeprecationWarning, message="`extract_ids()` is deprecated, please use `extract_patterns()`.", stacklevel=2, ) return extract_patterns(cert) service_identity-26.1.0/src/service_identity/exceptions.py0000644000000000000000000000275315206547410021005 0ustar00""" All exceptions and warnings thrown by ``service-identity``. Separated into an own package for nicer tracebacks, you should still import them from __init__.py. """ from __future__ import annotations from typing import TYPE_CHECKING, Sequence if TYPE_CHECKING: from .hazmat import ServiceID import attr class SubjectAltNameWarning(DeprecationWarning): """ This warning is not used anymore and will be removed in a future version. Formerly: Server Certificate does not contain a ``SubjectAltName``. Hostname matching is performed on the ``CommonName`` which is deprecated. .. deprecated:: 23.1.0 """ @attr.s(slots=True) class Mismatch: mismatched_id: ServiceID = attr.ib() class DNSMismatch(Mismatch): """ No matching DNSPattern could be found. """ class SRVMismatch(Mismatch): """ No matching SRVPattern could be found. """ class URIMismatch(Mismatch): """ No matching URIPattern could be found. """ class IPAddressMismatch(Mismatch): """ No matching IPAddressPattern could be found. """ @attr.s(auto_exc=True) class VerificationError(Exception): """ Service identity verification failed. """ errors: Sequence[Mismatch] = attr.ib() def __str__(self) -> str: return self.__repr__() class CertificateError(Exception): r""" Certificate contains invalid or unexpected data. This includes the case where s certificate contains no ``subjectAltName``\ s. """ service_identity-26.1.0/src/service_identity/hazmat.py0000644000000000000000000003100415206547410020077 0ustar00""" Common verification code. """ from __future__ import annotations import ipaddress import re from typing import Protocol, Sequence, Union, runtime_checkable import attr from .exceptions import ( CertificateError, DNSMismatch, IPAddressMismatch, Mismatch, SRVMismatch, URIMismatch, VerificationError, ) try: import idna except ImportError: idna = None # type: ignore[assignment] @attr.s(slots=True) class ServiceMatch: """ A match of a service id and a certificate pattern. """ service_id: ServiceID = attr.ib() cert_pattern: CertificatePattern = attr.ib() def verify_service_identity( cert_patterns: Sequence[CertificatePattern], obligatory_ids: Sequence[ServiceID], optional_ids: Sequence[ServiceID], ) -> list[ServiceMatch]: """ Verify whether *cert_patterns* are valid for *obligatory_ids* and *optional_ids*. *obligatory_ids* must be both present and match. *optional_ids* must match if a pattern of the respective type is present. """ if not cert_patterns: msg = "Certificate does not contain any `subjectAltName`s." raise CertificateError(msg) errors = [] matches = _find_matches(cert_patterns, obligatory_ids) + _find_matches( cert_patterns, optional_ids ) matched_ids = [match.service_id for match in matches] for i in obligatory_ids: if i not in matched_ids: errors.append( # noqa: PERF401 i.error_on_mismatch(mismatched_id=i) ) for i in optional_ids: # If an optional ID is not matched by a certificate pattern *but* there # is a pattern of the same type , it is an error and the verification # fails. Example: the user passes a SRV-ID for "_mail.domain.com" but # the certificate contains an SRV-Pattern for "_xmpp.domain.com". if i not in matched_ids and _contains_instance_of( cert_patterns, i.pattern_class ): errors.append( # noqa: PERF401 i.error_on_mismatch(mismatched_id=i) ) if errors: raise VerificationError(errors=errors) return matches def _find_matches( cert_patterns: Sequence[CertificatePattern], service_ids: Sequence[ServiceID], ) -> list[ServiceMatch]: """ Search for matching certificate patterns and service_ids. Args: service_ids: List of service IDs like DNS_ID. """ matches = [] for sid in service_ids: for cid in cert_patterns: if sid.verify(cid): matches.append( # noqa: PERF401 ServiceMatch(cert_pattern=cid, service_id=sid) ) return matches def _contains_instance_of(seq: Sequence[object], cl: type) -> bool: return any(isinstance(e, cl) for e in seq) def _is_ip_address(pattern: str | bytes) -> bool: """ Check whether *pattern* could be/match an IP address. Args: pattern: A pattern for a host name. Returns: `True` if *pattern* could be an IP address, else `False`. """ if isinstance(pattern, bytes): try: pattern = pattern.decode("ascii") except UnicodeError: return False try: int(pattern) except ValueError: pass else: return True try: ipaddress.ip_address(pattern.replace("*", "1")) except ValueError: return False return True @attr.s(slots=True) class DNSPattern: """ A DNS pattern as extracted from certificates. """ #: The pattern. pattern: bytes = attr.ib() _RE_LEGAL_CHARS = re.compile(rb"^[a-z0-9\-_.]+$") @classmethod def from_bytes(cls, pattern: bytes) -> DNSPattern: if not isinstance(pattern, bytes): msg = "The DNS pattern must be a bytes string." raise TypeError(msg) pattern = pattern.strip() if pattern == b"" or _is_ip_address(pattern) or b"\0" in pattern: msg = f"Invalid DNS pattern {pattern!r}." raise CertificateError(msg) pattern = pattern.translate(_TRANS_TO_LOWER) if b"*" in pattern: _validate_pattern(pattern) return cls(pattern=pattern) @attr.s(slots=True) class IPAddressPattern: """ An IP address pattern as extracted from certificates. """ #: The pattern. pattern: ipaddress.IPv4Address | ipaddress.IPv6Address = attr.ib() @classmethod def from_bytes(cls, bs: bytes) -> IPAddressPattern: try: return cls(pattern=ipaddress.ip_address(bs)) except ValueError: msg = f"Invalid IP address pattern {bs!r}." raise CertificateError(msg) from None @attr.s(slots=True) class URIPattern: """ An URI pattern as extracted from certificates. """ #: The pattern for the protocol part. protocol_pattern: bytes = attr.ib() #: The pattern for the DNS part. dns_pattern: DNSPattern = attr.ib() @classmethod def from_bytes(cls, pattern: bytes) -> URIPattern: if not isinstance(pattern, bytes): msg = "The URI pattern must be a bytes string." raise TypeError(msg) pattern = pattern.strip().translate(_TRANS_TO_LOWER) if b":" not in pattern or b"*" in pattern or _is_ip_address(pattern): msg = f"Invalid URI pattern {pattern!r}." raise CertificateError(msg) protocol_pattern, hostname = pattern.split(b":") return cls( protocol_pattern=protocol_pattern, dns_pattern=DNSPattern.from_bytes(hostname), ) @attr.s(slots=True) class SRVPattern: """ An SRV pattern as extracted from certificates. """ #: The pattern for the name part. name_pattern: bytes = attr.ib() #: The pattern for the DNS part. dns_pattern: DNSPattern = attr.ib() @classmethod def from_bytes(cls, pattern: bytes) -> SRVPattern: if not isinstance(pattern, bytes): msg = "The SRV pattern must be a bytes string." raise TypeError(msg) pattern = pattern.strip().translate(_TRANS_TO_LOWER) if ( pattern[0] != b"_"[0] or b"." not in pattern or b"*" in pattern or _is_ip_address(pattern) ): msg = f"Invalid SRV pattern {pattern!r}." raise CertificateError(msg) name, hostname = pattern.split(b".", 1) return cls( name_pattern=name[1:], dns_pattern=DNSPattern.from_bytes(hostname) ) CertificatePattern = Union[ SRVPattern, URIPattern, DNSPattern, IPAddressPattern ] """ A :class:`Union` of all possible patterns that can be extracted from a certificate. """ @runtime_checkable class ServiceID(Protocol): @property def pattern_class(self) -> type[CertificatePattern]: ... @property def error_on_mismatch(self) -> type[Mismatch]: ... def verify(self, pattern: CertificatePattern) -> bool: ... @attr.s(init=False, slots=True) class DNS_ID: """ A DNS service ID, aka hostname. """ hostname: bytes = attr.ib() # characters that are legal in a normalized hostname _RE_LEGAL_CHARS = re.compile(rb"^[a-z0-9\-_.]+$") pattern_class = DNSPattern error_on_mismatch = DNSMismatch def __init__(self, hostname: str): if not isinstance(hostname, str): msg = "DNS-ID must be a text string." raise TypeError(msg) hostname = hostname.strip() if not hostname or _is_ip_address(hostname): msg = "Invalid DNS-ID." raise ValueError(msg) if any(ord(c) > 127 for c in hostname): if idna: ascii_id = idna.encode(hostname) else: msg = "idna library is required for non-ASCII IDs." raise ImportError(msg) else: ascii_id = hostname.encode("ascii") self.hostname = ascii_id.translate(_TRANS_TO_LOWER) if self._RE_LEGAL_CHARS.match(self.hostname) is None: msg = "Invalid DNS-ID." raise ValueError(msg) def verify(self, pattern: CertificatePattern) -> bool: """ https://tools.ietf.org/search/rfc6125#section-6.4 """ if isinstance(pattern, self.pattern_class): return _hostname_matches(pattern.pattern, self.hostname) return False @attr.s(slots=True) class IPAddress_ID: """ An IP address service ID. """ ip: ipaddress.IPv4Address | ipaddress.IPv6Address = attr.ib( converter=ipaddress.ip_address ) pattern_class = IPAddressPattern error_on_mismatch = IPAddressMismatch def verify(self, pattern: CertificatePattern) -> bool: """ https://tools.ietf.org/search/rfc2818#section-3.1 """ if isinstance(pattern, self.pattern_class): return self.ip == pattern.pattern return False @attr.s(init=False, slots=True) class URI_ID: """ An URI service ID. """ protocol: bytes = attr.ib() dns_id: DNS_ID = attr.ib() pattern_class = URIPattern error_on_mismatch = URIMismatch def __init__(self, uri: str): if not isinstance(uri, str): msg = "URI-ID must be a text string." raise TypeError(msg) uri = uri.strip() if ":" not in uri or _is_ip_address(uri): msg = "Invalid URI-ID." raise ValueError(msg) prot, hostname = uri.split(":") self.protocol = prot.encode("ascii").translate(_TRANS_TO_LOWER) self.dns_id = DNS_ID(hostname.strip("/")) def verify(self, pattern: CertificatePattern) -> bool: """ https://tools.ietf.org/search/rfc6125#section-6.5.2 """ if isinstance(pattern, self.pattern_class): return ( pattern.protocol_pattern == self.protocol and self.dns_id.verify(pattern.dns_pattern) ) return False @attr.s(init=False, slots=True) class SRV_ID: """ An SRV service ID. """ name: bytes = attr.ib() dns_id: DNS_ID = attr.ib() pattern_class = SRVPattern error_on_mismatch = SRVMismatch def __init__(self, srv: str): if not isinstance(srv, str): msg = "SRV-ID must be a text string." raise TypeError(msg) srv = srv.strip() if "." not in srv or _is_ip_address(srv) or srv[0] != "_": msg = "Invalid SRV-ID." raise ValueError(msg) name, hostname = srv.split(".", 1) self.name = name[1:].encode("ascii").translate(_TRANS_TO_LOWER) self.dns_id = DNS_ID(hostname) def verify(self, pattern: CertificatePattern) -> bool: """ https://tools.ietf.org/search/rfc6125#section-6.5.1 """ if isinstance(pattern, self.pattern_class): return self.name == pattern.name_pattern and self.dns_id.verify( pattern.dns_pattern ) return False def _hostname_matches(cert_pattern: bytes, actual_hostname: bytes) -> bool: """ :return: `True` if *cert_pattern* matches *actual_hostname*, else `False`. """ if b"*" in cert_pattern: if b"." not in actual_hostname: return False cert_head, cert_tail = cert_pattern.split(b".", 1) actual_head, actual_tail = actual_hostname.split(b".", 1) if cert_tail != actual_tail: return False # No patterns for IDNA if actual_head.startswith(b"xn--"): return False return cert_head in (b"*", actual_head) return cert_pattern == actual_hostname def _validate_pattern(cert_pattern: bytes) -> None: """ Check whether the usage of wildcards within *cert_pattern* conforms with our expectations. """ cnt = cert_pattern.count(b"*") if cnt > 1: msg = f"Certificate's DNS-ID {cert_pattern!r} contains too many wildcards." raise CertificateError(msg) parts = cert_pattern.split(b".") if len(parts) < 3: msg = f"Certificate's DNS-ID {cert_pattern!r} has too few host components for wildcard usage." raise CertificateError(msg) # We assume there will always be only one wildcard allowed. if b"*" not in parts[0]: msg = f"Certificate's DNS-ID {cert_pattern!r} has a wildcard outside the left-most part." raise CertificateError(msg) if any(not len(p) for p in parts): msg = f"Certificate's DNS-ID {cert_pattern!r} contains empty parts." raise CertificateError(msg) # Ensure no locale magic interferes. _TRANS_TO_LOWER = bytes.maketrans( b"ABCDEFGHIJKLMNOPQRSTUVWXYZ", b"abcdefghijklmnopqrstuvwxyz" ) service_identity-26.1.0/src/service_identity/py.typed0000644000000000000000000000000015206547410017730 0ustar00service_identity-26.1.0/src/service_identity/pyopenssl.py0000644000000000000000000000714315206547410020656 0ustar00""" `pyOpenSSL `_-specific code. """ from __future__ import annotations import contextlib import warnings from typing import Sequence from .cryptography import extract_patterns as _cryptography_extract_patterns from .hazmat import ( DNS_ID, CertificatePattern, IPAddress_ID, verify_service_identity, ) with contextlib.suppress(ImportError): # We only use it for docstrings -- `if TYPE_CHECKING`` does not work. from OpenSSL.crypto import X509 from OpenSSL.SSL import Connection __all__ = ["verify_hostname"] def verify_hostname(connection: Connection, hostname: str) -> None: r""" Verify whether the certificate of *connection* is valid for *hostname*. Args: connection: A pyOpenSSL connection object. hostname: The hostname that *connection* should be connected to. Raises: service_identity.VerificationError: If *connection* does not provide a certificate that is valid for *hostname*. service_identity.CertificateError: If certificate provided by *connection* contains invalid / unexpected data. This includes the case where the certificate contains no ``subjectAltName``\ s. .. versionchanged:: 24.1.0 :exc:`~service_identity.CertificateError` is raised if the certificate contains no ``subjectAltName``\ s instead of :exc:`~service_identity.VerificationError`. """ verify_service_identity( cert_patterns=extract_patterns( connection.get_peer_certificate() # type:ignore[arg-type] ), obligatory_ids=[DNS_ID(hostname)], optional_ids=[], ) def verify_ip_address(connection: Connection, ip_address: str) -> None: r""" Verify whether the certificate of *connection* is valid for *ip_address*. Args: connection: A pyOpenSSL connection object. ip_address: The IP address that *connection* should be connected to. Can be an IPv4 or IPv6 address. Raises: service_identity.VerificationError: If *connection* does not provide a certificate that is valid for *ip_address*. service_identity.CertificateError: If the certificate chain of *connection* contains a certificate that contains invalid/unexpected data. .. versionadded:: 18.1.0 .. versionchanged:: 24.1.0 :exc:`~service_identity.CertificateError` is raised if the certificate contains no ``subjectAltName``\ s instead of :exc:`~service_identity.VerificationError`. """ verify_service_identity( cert_patterns=extract_patterns( connection.get_peer_certificate() # type:ignore[arg-type] ), obligatory_ids=[IPAddress_ID(ip_address)], optional_ids=[], ) def extract_patterns(cert: X509) -> Sequence[CertificatePattern]: """ Extract all valid ID patterns from a certificate for service verification. Args: cert: The certificate to be dissected. Returns: List of IDs. .. versionchanged:: 23.1.0 ``commonName`` is not used as a fallback anymore. """ return _cryptography_extract_patterns(cert.to_cryptography()) def extract_ids(cert: X509) -> Sequence[CertificatePattern]: """ Deprecated and never public API. Use :func:`extract_patterns` instead. .. deprecated:: 23.1.0 """ warnings.warn( category=DeprecationWarning, message="`extract_ids()` is deprecated, please use `extract_patterns()`.", stacklevel=2, ) return extract_patterns(cert) service_identity-26.1.0/tests/__init__.py0000644000000000000000000000000015206547410015344 0ustar00service_identity-26.1.0/tests/certificates.py0000644000000000000000000001706715206547410016277 0ustar00from cryptography.hazmat.backends import default_backend from cryptography.x509 import load_pem_x509_certificate from service_identity.cryptography import extract_patterns # Test certificates PEM_DNS_ONLY = b"""\ -----BEGIN CERTIFICATE----- MIIGbjCCBVagAwIBAgIDCesrMA0GCSqGSIb3DQEBBQUAMIGMMQswCQYDVQQGEwJJ TDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0 YWwgQ2VydGlmaWNhdGUgU2lnbmluZzE4MDYGA1UEAxMvU3RhcnRDb20gQ2xhc3Mg MSBQcmltYXJ5IEludGVybWVkaWF0ZSBTZXJ2ZXIgQ0EwHhcNMTMwNDEwMTk1ODA5 WhcNMTQwNDExMTkyODAwWjB1MRkwFwYDVQQNExBTN2xiQ3Q3TjJSNHQ5bzhKMQsw CQYDVQQGEwJVUzEeMBwGA1UEAxMVd3d3LnR3aXN0ZWRtYXRyaXguY29tMSswKQYJ KoZIhvcNAQkBFhxwb3N0bWFzdGVyQHR3aXN0ZWRtYXRyaXguY29tMIIBIjANBgkq hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxUH8iDxIEiDcMQb8kr/JTYXDGuE8ISQA uw/gBqpvHIvCgPBkZpvjQLA23rnUZm1S3VG5MIq6gZVdtl9LFIfokMPGgY9EZng8 BaI+6Y36cMtubnzW53OZb7yLQQyg+rjuwjvJOY33ZulEthxhdB3km1Leb67iE9v7 dpyKeJ/8m2IWD37HCtXIEnp9ZqWOZkAPzlzDt6oNxj0s/l3z23+XqZdr+kmlh9U+ VWBTPppO4AJNwSqbBd0PgIozbYsp6urxSr40YQkIYFOOZQNs7HETJE71Ia7DQcUD kUF1jZSYZnhVQwGPisqQLGodt9q9p2BhpSf0cUm02uKKzYi5A2h7UQIDAQABo4IC 7TCCAukwCQYDVR0TBAIwADALBgNVHQ8EBAMCA6gwEwYDVR0lBAwwCgYIKwYBBQUH AwEwHQYDVR0OBBYEFGeuUvDrFHkl7Krl/+rlv1FsnsU6MB8GA1UdIwQYMBaAFOtC NNCYsKuf9BtrCPfMZC7vDixFMDMGA1UdEQQsMCqCFXd3dy50d2lzdGVkbWF0cml4 LmNvbYIRdHdpc3RlZG1hdHJpeC5jb20wggFWBgNVHSAEggFNMIIBSTAIBgZngQwB AgEwggE7BgsrBgEEAYG1NwECAzCCASowLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cu c3RhcnRzc2wuY29tL3BvbGljeS5wZGYwgfcGCCsGAQUFBwICMIHqMCcWIFN0YXJ0 Q29tIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MAMCAQEagb5UaGlzIGNlcnRpZmlj YXRlIHdhcyBpc3N1ZWQgYWNjb3JkaW5nIHRvIHRoZSBDbGFzcyAxIFZhbGlkYXRp b24gcmVxdWlyZW1lbnRzIG9mIHRoZSBTdGFydENvbSBDQSBwb2xpY3ksIHJlbGlh bmNlIG9ubHkgZm9yIHRoZSBpbnRlbmRlZCBwdXJwb3NlIGluIGNvbXBsaWFuY2Ug b2YgdGhlIHJlbHlpbmcgcGFydHkgb2JsaWdhdGlvbnMuMDUGA1UdHwQuMCwwKqAo oCaGJGh0dHA6Ly9jcmwuc3RhcnRzc2wuY29tL2NydDEtY3JsLmNybDCBjgYIKwYB BQUHAQEEgYEwfzA5BggrBgEFBQcwAYYtaHR0cDovL29jc3Auc3RhcnRzc2wuY29t L3N1Yi9jbGFzczEvc2VydmVyL2NhMEIGCCsGAQUFBzAChjZodHRwOi8vYWlhLnN0 YXJ0c3NsLmNvbS9jZXJ0cy9zdWIuY2xhc3MxLnNlcnZlci5jYS5jcnQwIwYDVR0S BBwwGoYYaHR0cDovL3d3dy5zdGFydHNzbC5jb20vMA0GCSqGSIb3DQEBBQUAA4IB AQCN85dUStYjHmWdXthpAqJcS3KD2JP6N9egOz7FTcToXLW8Kl5a2SUVaJv8Fzs+ wtbPJQSm0LyGtfdrR6iKFPf28Vm/VkYXPiOV08GD9B7yl1SjktXOsGMPlOHU8YQZ DEsHOrRvaZBSA1VtBQjYnoO0pDVu9QwDLAPLFvFice2PN803HuMFIwcuQSIrh4nq PqwitBZ6nPPHz7aSiAut/+txK3EZll0d+hl0H3Phd+ICeITYhNkLe90k7l1IFpET fJiBDvG/iDAJISgkrR1heuX/e+yWfx7RvqGlMLIE35d+0MhWy92Jzejbl8fJdr4C Kulh/pV07MWAUZxscUPtWmPo -----END CERTIFICATE-----""" DNS_IDS = extract_patterns( load_pem_x509_certificate(PEM_DNS_ONLY, default_backend()) ) PEM_CN_ONLY = b"""\ -----BEGIN CERTIFICATE----- MIIDlzCCAn+gAwIBAgIUNS9qfJRgoLrr68mAfmhzBior0JAwDQYJKoZIhvcNAQEL BQAwWzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAwwLZXhhbXBsZS5jb20w HhcNMjMwNjA4MDkwNDAxWhcNNDkwMTI3MDkwNDAxWjBbMQswCQYDVQQGEwJBVTET MBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQ dHkgTHRkMRQwEgYDVQQDDAtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBALltBNRAyeFczK2kdKTkW6E3/+rptXBcaw3SyltY/Ft8DPe3 lW/GWbbgAU7ceYC0LkYxOEnyQTlXhTyLHSk+PCQLi2ESgwWGnGY5Eusk1DRJ162q hLUkmz25KdTILnh402fgoziF2RU6PnA7iIwAVntT5uh4S1yPW7TWkdPsE0tc1yBx 3SAHTXDkoahjKSot35Q87Jw92fxUd7WqVmDrgcLMvp8rXNjdg+HG4fQGoMcSQq2Z nVPIoWSTOGdL6SzSs6Wvllz3n7YWShTqp9CmGctCGubt02fHF+8G/dMTTukntG4q EjBtVfeDFx8yXUdOgN4egFxZGYATAUsLcyzNhQcCAwEAAaNTMFEwHQYDVR0OBBYE FDkuqwU8KxIvwAwMqVpNFYlgd6WbMB8GA1UdIwQYMBaAFDkuqwU8KxIvwAwMqVpN FYlgd6WbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJ9Nb/C0 2gkxhWm3S2/9onPMhFOxDLsYxBvZtJMeOvRRrJaxN2m0aXkm0Ww8Bq0qEegZE51m 0T57IOS256rQlxEnZNeAWT2tf8FEqQGjbI1c/prj420e6afn18x8CSQgiOG4PJ+S YfufRjSHAYwiiNi+tiKBQ8jOuayFhih9/GsDJvAMW0HjaYZyG6jlA9p4bL1rSqD3 gE+upYhkpVN1lFRZAhxRZOVIqJV/ppHp9RAuawsUdSNKm+rnlaExJKoVys+tZL9+ djWWQVqnttYocQQ0umX4ydCTr1RM+7ziwWTR5kpOESA/gZePZQqeH9k+XrPEQtLn 6QBxk+Ft53ZPVso= -----END CERTIFICATE----- """ PEM_OTHER_NAME = b"""\ -----BEGIN CERTIFICATE----- MIID/DCCAuSgAwIBAgIJAIS0TSddIw6cMA0GCSqGSIb3DQEBBQUAMGwxFDASBgNV BAMTC2V4YW1wbGUuY29tMSAwHgYJKoZIhvcNAQkBFhFib2d1c0BleGFtcGxlLmNv bTEUMBIGA1UEChMLRXhhbXBsZSBJbmMxDzANBgNVBAcTBkJlcmxpbjELMAkGA1UE BhMCREUwHhcNMTQwMzA2MTYyNTA5WhcNMTUwMzA2MTYyNTA5WjBsMRQwEgYDVQQD EwtleGFtcGxlLmNvbTEgMB4GCSqGSIb3DQEJARYRYm9ndXNAZXhhbXBsZS5jb20x FDASBgNVBAoTC0V4YW1wbGUgSW5jMQ8wDQYDVQQHEwZCZXJsaW4xCzAJBgNVBAYT AkRFMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxGQUcOc8cAdzSJbk 0eCHA1qBY2XwRG8YQzihgQS8Ey+3j69Xf0mtWOlL6v23v8J1ilA7ERs87Y4nbV/9 GJVhC/jTMZmrC6ogwtVIl1wL8sTiHaQZ/4pbpx57YW3qCdefLQrZqAMUgAe20z0G YVU97u5EGXHYahG4TnB3xN6Qd3BGKP7K69Lb7ZOES2Esq533AZxZShseYR4JNYAc 2anag2/DpHw6k8ZaxtWHR4SmxlkCoW5IPK0YypeUY91PFY+dxJQEewtisfALKltE SYnOTWkc0K9YuLuYVogx0K285wX4/Yha2wyo6KSAm0txJayOhcrEP2/34aWCl62m xOtPbQIDAQABo4GgMIGdMIGaBgNVHREEgZIwgY+CDSouZXhhbXBsZS5uZXSCC2V4 YW1wbGUuY29thwTAqAABhxAAEwAAAAAAAAAAAAAAAAAXhhNodHRwOi8vZXhhbXBs ZS5jb20voCYGCCsGAQUFBwgHoBoWGF94bXBwLWNsaWVudC5leGFtcGxlLm5ldKAc BggrBgEFBQcIBaAQDA5pbS5leGFtcGxlLmNvbTANBgkqhkiG9w0BAQUFAAOCAQEA ACVQcgEKzXEw0M9mmVFFXL2SyDk/4oaDFZbnNfyUp+H7bnxdVBG2M3DzQQLw5yH5 k4GNPvHOKshBbaFcZWiG1sdrfQJy/UjIWnaC5410npfBv7kJWafKKxZzMq3gp4rd jPO2LxuWcYVOnUtA3CBe12tRV7ynGU8KmKOsU9bOWhUKo8DJ4a6XHB+YwXeOTPyU mG7XBpQebT01I3OijFJ+apKR2ubjwZE8l1+BAlTzHyUmmcTTWTQk8FTFcP3nZuIr VyudDBMASs4yVGHzQxmMalYYzd7ZDzM1NrgfG1KyKWqZEA0MzUxiYdUbZN79xL52 EyKUOXPHw78G6zsVmAE1Aw== -----END CERTIFICATE-----""" PEM_EVERYTHING = b"""\ -----BEGIN CERTIFICATE----- MIIGdTCCBF2gAwIBAgIUSxHQCcw8po0mpISRHmijCA7HF9YwDQYJKoZIhvcNAQEL BQAwEzERMA8GA1UEAxMIY2Eudm0uYWcwHhcNMTgwMjExMTMxOTI0WhcNMTgwMjEx MTMyMDQyWjAjMSEwHwYDVQQDExhzZXJ2aWNlLmlkZW50aXR5LmludmFsaWQwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzYDZKBb91iX9Ct8gFig//2UtA fRiDdiViimnAuLJ3f4Q5rM2Xs4BQpXEGgf4tBeZ03lFIga+W7nzsNZnooN6ocLwB z3jb3K4xxy1RRzv0iKLhFdtQwfwS6Xz6usaySGWW5Hpn3Yqwd9qAho/MIFfDruuL kEInjhtJGta/uT3fZ9BiLsDl1zyZefvhLblpujww5Ex3eGHgZlLixfuQj+vZbQ99 2xMHRIh6PRVsnVJ7GaSxxIwAdXcVZRuB4he3aIIn8OMCf+1V5aUTfC5vWVrSFfJb B1V9uw4DB0Uf/bn8bkm4ncr11kjiOUoNahXwPanHVFkTyr2hDU/SguIPRBGFBFCC RRUbsEhpJrtKy4mc1RQzof+fMJqmTjvRGoIYISfpuL3B84UBuXB6bWoKqsIrsX+Z Ww3bO7/ncpgko7zQSpjPUxAJQ2z/u+aCh/v++UudMGtYtQlBNTtkQsIAAaho/vHF ALjusQKj8J6LLJXWrNW0MzidgookHBu3cjE++ymK9bKsgbUFH+T1hf9WIaFR0ldY uCyOiDx7wxqV8KS3/FXAFU5ra6HtNVy67umcL+e8frBFABxdHu0SWNnXRN5qF233 WQ/0ds0KjjPC19+fH/KlwVuK4u725dtbeKmbbfeqrUhCoDVLG2xfIEPDrwfNiuRx n//9JahPtu53aRN7NwIDAQABo4IBrzCCAaswDgYDVR0PAQH/BAQDAgOoMB0GA1Ud JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4EFgQUkrzVHzWYgC6iuhYm G91tbFg175YwHwYDVR0jBBgwFoAUZDPQAysTTYandW8IzZhkZMamUs0wRQYIKwYB BQUHAQEEOTA3MDUGCCsGAQUFBzAChilodHRwczovL2NhLnZtLmFnOjgyMDAvdjEv dm1jYV9pbnRtX3BraS9jYTCBtQYDVR0RBIGtMIGqghhzZXJ2aWNlLmlkZW50aXR5 LmludmFsaWSCIyoud2lsZGNhcmQuc2VydmljZS5pZGVudGl0eS5pbnZhbGlkghhz ZXJ2aWNlLmlkZW50aXR5LmludmFsaWSCH3NpbmdsZS5zZXJ2aWNlLmlkZW50aXR5 LmludmFsaWSHBAEBAQGHEAAAAAAAAAAAAAAAAAAAAAGHBAICAgKHECoAHDgAAAAA AAAAAAAAAFMwOwYDVR0fBDQwMjAwoC6gLIYqaHR0cHM6Ly9jYS52bS5hZzo4MjAw L3YxL3ZtY2FfaW50bV9wa2kvY3JsMA0GCSqGSIb3DQEBCwUAA4ICAQA6fR0V39IN zqFkJFUFyt/uX7aMnMbe2DKxXmhJns6VwN+nhzB4CNK5rSJ0y0telN5CL2Oe+pS/ Vfinw15GrdB01r9mV/og0aFMyXFUjmDa4heNKvbuspj+hHjXj2JvETk9pHKURmQe kd0IffkoDaSFIwjI0rOdDdo+5WcFpjx8lq8IZeBcPdVhqlzIaNa/PgezUg69HQF/ FEqBkaq4sto8/yXrD6Pp5NszRJvBtEnlq+WSYzvVSH6E48KD1sJr2DTGWs8pi9ml 7exq1yRSlmz5bgOvl7AVGrl+icOuCpDcuVgE2MbzKm/VKQ01ypUPvnUcDZJC8iDC 6JNT152YuLY/rgq+XJMeLb/FtDKmav8oOWqeoD72baMub9iVlZ4kaMzjtMFlXVha 6MQiV36QG99q8KPdxeRxuef4p3NRFa8AlFGbOa/ALxksN9rr8fPxAaNrHBzYsCgN DZoyYaYe6aIx8wVtpbucdinDSyn7aJy66RHUnKNwW/tJm3WXCI492dEX+s7PGVXA F4B0w+r2LTELSYJ6Mh+tVleuJZ6Yw947E4iAyc/u7ck6qWRex230hnHZqgRiexP2 4ZueMI+SnpWqL7rOgLD6VuyemZ18on2VJcgvZiVkYMfZf2330ZlRxtyU2AvKRXc3 3HotzNMgpPpx8C2KKLKKaiIGRY0pg/WC6w== -----END CERTIFICATE-----""" service_identity-26.1.0/tests/conftest.py0000644000000000000000000000103315206547410015441 0ustar00import cryptography try: import OpenSSL import OpenSSL.SSL except ImportError: OpenSSL = None def pytest_report_header(config): if OpenSSL is not None: openssl_version = OpenSSL.SSL.SSLeay_version( OpenSSL.SSL.SSLEAY_VERSION ).decode("ascii") pyopenssl_version = OpenSSL.__version__ else: openssl_version = "n/a" pyopenssl_version = "missing" return f"""\ OpenSSL: {openssl_version} pyOpenSSL: {pyopenssl_version} cryptography: {cryptography.__version__}""" service_identity-26.1.0/tests/test_cryptography.py0000644000000000000000000001466415206547410017424 0ustar00import ipaddress import pytest from cryptography.hazmat.backends import default_backend from cryptography.x509 import ( ExtensionOID, OtherName, SubjectAlternativeName, load_pem_x509_certificate, ) from service_identity.cryptography import ( ID_ON_DNS_SRV, extract_ids, extract_patterns, verify_certificate_hostname, verify_certificate_ip_address, ) from service_identity.exceptions import ( CertificateError, DNSMismatch, IPAddressMismatch, VerificationError, ) from service_identity.hazmat import ( DNS_ID, DNSPattern, IPAddress_ID, IPAddressPattern, SRVPattern, URIPattern, ) from .certificates import ( PEM_CN_ONLY, PEM_DNS_ONLY, PEM_EVERYTHING, PEM_OTHER_NAME, ) backend = default_backend() X509_DNS_ONLY = load_pem_x509_certificate(PEM_DNS_ONLY, backend) X509_CN_ONLY = load_pem_x509_certificate(PEM_CN_ONLY, backend) X509_OTHER_NAME = load_pem_x509_certificate(PEM_OTHER_NAME, backend) CERT_EVERYTHING = load_pem_x509_certificate(PEM_EVERYTHING, backend) class TestPublicAPI: def test_no_cert_patterns_hostname(self): """ A certificate without subjectAltNames raises a helpful CertificateError. """ with pytest.raises( CertificateError, match="Certificate does not contain any `subjectAltName`s", ): verify_certificate_hostname(X509_CN_ONLY, "example.com") @pytest.mark.parametrize("ip", ["203.0.113.0", "2001:db8::"]) def test_no_cert_patterns_ip_address(self, ip): """ A certificate without subjectAltNames raises a helpful CertificateError. """ with pytest.raises( CertificateError, match="Certificate does not contain any `subjectAltName`s", ): verify_certificate_ip_address(X509_CN_ONLY, ip) def test_certificate_verify_hostname_ok(self): """ verify_certificate_hostname succeeds if the hostnames match. """ verify_certificate_hostname(X509_DNS_ONLY, "twistedmatrix.com") def test_certificate_verify_hostname_fail(self): """ verify_certificate_hostname fails if the hostnames don't match and provides the user with helpful information. """ with pytest.raises(VerificationError) as ei: verify_certificate_hostname(X509_DNS_ONLY, "google.com") assert [ DNSMismatch(mismatched_id=DNS_ID("google.com")) ] == ei.value.errors @pytest.mark.parametrize("ip", ["1.1.1.1", "::1"]) def test_verify_certificate_ip_address_ok(self, ip): """ verify_certificate_ip_address succeeds if the addresses match. Works both with IPv4 and IPv6. """ verify_certificate_ip_address(CERT_EVERYTHING, ip) @pytest.mark.parametrize("ip", ["1.1.1.2", "::2"]) def test_verify_ip_address_fail(self, ip): """ verify_ip_address fails if the addresses don't match and provides the user with helpful information. Works both with IPv4 and IPv6. """ with pytest.raises(VerificationError) as ei: verify_certificate_ip_address(CERT_EVERYTHING, ip) assert [ IPAddressMismatch(mismatched_id=IPAddress_ID(ip)) ] == ei.value.errors class TestExtractPatterns: def test_dns(self): """ Returns the correct DNSPattern from a certificate. """ rv = extract_patterns(X509_DNS_ONLY) assert [ DNSPattern.from_bytes(b"www.twistedmatrix.com"), DNSPattern.from_bytes(b"twistedmatrix.com"), ] == rv def test_cn_ids_are_ignored(self): """ commonName is not supported anymore and therefore ignored. """ assert [] == extract_patterns(X509_CN_ONLY) def test_uri(self): """ Returns the correct URIPattern from a certificate. """ rv = extract_patterns(X509_OTHER_NAME) assert [URIPattern.from_bytes(b"http://example.com/")] == [ id for id in rv if isinstance(id, URIPattern) ] def test_srv(self): """ Returns the correct SRVPattern from a certificate. """ rv = extract_patterns(X509_OTHER_NAME) assert [SRVPattern.from_bytes(b"_xmpp-client.example.net")] == [ id for id in rv if isinstance(id, SRVPattern) ] def test_malformed_srv_other_name(self): """ Malformed DER in a SRV-ID otherName raises a CertificateError. """ class Extension: def __init__(self, value): self.value = value class Extensions: def __init__(self, value): self._value = value def get_extension_for_oid(self, oid): assert oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME return Extension(self._value) class Certificate: def __init__(self, san): self.extensions = Extensions(san) cert = Certificate( SubjectAlternativeName( [OtherName(ID_ON_DNS_SRV, b"\x16\x03abc\x00")] ) ) with pytest.raises( CertificateError, match=r"Unexpected certificate content\." ): extract_patterns(cert) def test_ip(self): """ Returns IP patterns. """ rv = extract_patterns(CERT_EVERYTHING) assert [ DNSPattern.from_bytes(pattern=b"service.identity.invalid"), DNSPattern.from_bytes( pattern=b"*.wildcard.service.identity.invalid" ), DNSPattern.from_bytes(pattern=b"service.identity.invalid"), DNSPattern.from_bytes(pattern=b"single.service.identity.invalid"), IPAddressPattern(pattern=ipaddress.IPv4Address("1.1.1.1")), IPAddressPattern(pattern=ipaddress.IPv6Address("::1")), IPAddressPattern(pattern=ipaddress.IPv4Address("2.2.2.2")), IPAddressPattern(pattern=ipaddress.IPv6Address("2a00:1c38::53")), ] == rv def test_extract_ids_deprecated(self): """ `extract_ids` raises a DeprecationWarning with correct stacklevel. """ with pytest.deprecated_call() as wr: extract_ids(CERT_EVERYTHING) w = wr.pop() assert ( "`extract_ids()` is deprecated, please use `extract_patterns()`." == w.message.args[0] ) assert __file__ == w.filename service_identity-26.1.0/tests/test_hazmat.py0000644000000000000000000005271115206547410016150 0ustar00import ipaddress import pickle import pytest import service_identity.hazmat from service_identity.cryptography import extract_patterns from service_identity.exceptions import ( CertificateError, DNSMismatch, SRVMismatch, VerificationError, ) from service_identity.hazmat import ( DNS_ID, SRV_ID, URI_ID, DNSPattern, IPAddress_ID, IPAddressPattern, ServiceMatch, SRVPattern, URIPattern, _contains_instance_of, _find_matches, _hostname_matches, _is_ip_address, _validate_pattern, verify_service_identity, ) from .certificates import DNS_IDS from .test_cryptography import CERT_EVERYTHING try: import idna except ImportError: idna = None class TestVerifyServiceIdentity: """ Simple integration tests for verify_service_identity. """ def test_no_cert_patterns(self): """ Empty cert patterns raise a helpful CertificateError. """ with pytest.raises( CertificateError, match="Certificate does not contain any `subjectAltName`s", ): verify_service_identity( cert_patterns=[], obligatory_ids=[], optional_ids=[] ) def test_dns_id_success(self): """ Return pairs of certificate ids and service ids on matches. """ rv = verify_service_identity( DNS_IDS, [DNS_ID("twistedmatrix.com")], [] ) assert [ ServiceMatch( cert_pattern=DNSPattern.from_bytes(b"twistedmatrix.com"), service_id=DNS_ID("twistedmatrix.com"), ) ] == rv def test_integration_dns_id_fail(self): """ Raise VerificationError if no certificate id matches the supplied service ids. """ i = DNS_ID("wrong.host") with pytest.raises(VerificationError) as e: verify_service_identity( DNS_IDS, obligatory_ids=[i], optional_ids=[] ) assert [DNSMismatch(mismatched_id=i)] == e.value.errors def test_single_label_hostname_mismatch(self): """ A single-label hostname that does not match a wildcard cert pattern raises VerificationError, not a raw ValueError from the matcher. """ i = DNS_ID("localhost") with pytest.raises(VerificationError) as e: verify_service_identity( [DNSPattern.from_bytes(b"*.example.com")], obligatory_ids=[i], optional_ids=[], ) assert [DNSMismatch(mismatched_id=i)] == e.value.errors def test_ip_address_success(self): """ IP addresses patterns are matched against IP address IDs. """ ip4 = ipaddress.ip_address("2.2.2.2") ip6 = ipaddress.ip_address("2a00:1c38::53") id4 = IPAddress_ID(str(ip4)) id6 = IPAddress_ID(str(ip6)) rv = verify_service_identity( extract_patterns(CERT_EVERYTHING), [id4, id6], [] ) assert [ ServiceMatch(id4, IPAddressPattern(ip4)), ServiceMatch(id6, IPAddressPattern(ip6)), ] == rv def test_obligatory_missing(self): """ Raise if everything matches but one of the obligatory IDs is missing. """ i = DNS_ID("example.net") with pytest.raises(VerificationError) as e: verify_service_identity( [SRVPattern.from_bytes(b"_mail.example.net")], obligatory_ids=[SRV_ID("_mail.example.net"), i], optional_ids=[], ) assert [DNSMismatch(mismatched_id=i)] == e.value.errors def test_obligatory_mismatch(self): """ Raise if one of the obligatory IDs doesn't match. """ i = DNS_ID("example.net") with pytest.raises(VerificationError) as e: verify_service_identity( [ SRVPattern.from_bytes(b"_mail.example.net"), DNSPattern.from_bytes(b"example.com"), ], obligatory_ids=[SRV_ID("_mail.example.net"), i], optional_ids=[], ) assert [DNSMismatch(mismatched_id=i)] == e.value.errors def test_optional_missing(self): """ Optional IDs may miss as long as they don't conflict with an existing pattern. """ p = DNSPattern.from_bytes(b"mail.foo.com") i = DNS_ID("mail.foo.com") rv = verify_service_identity( [p], obligatory_ids=[i], optional_ids=[SRV_ID("_mail.foo.com")] ) assert [ServiceMatch(cert_pattern=p, service_id=i)] == rv def test_optional_mismatch(self): """ Raise VerificationError if an ID from optional_ids does not match a pattern of respective type even if obligatory IDs match. """ i = SRV_ID("_xmpp.example.com") with pytest.raises(VerificationError) as e: verify_service_identity( [ DNSPattern.from_bytes(b"example.net"), SRVPattern.from_bytes(b"_mail.example.com"), ], obligatory_ids=[DNS_ID("example.net")], optional_ids=[i], ) assert [SRVMismatch(mismatched_id=i)] == e.value.errors def test_contains_optional_and_matches(self): """ If an optional ID is found, return the match within the returned list and don't raise an error. """ p = SRVPattern.from_bytes(b"_mail.example.net") i = SRV_ID("_mail.example.net") rv = verify_service_identity( [DNSPattern.from_bytes(b"example.net"), p], obligatory_ids=[DNS_ID("example.net")], optional_ids=[i], ) assert ServiceMatch(cert_pattern=p, service_id=i) == rv[1] class TestContainsInstance: def test_positive(self): """ If the list contains an object of the type, return True. """ assert _contains_instance_of([object(), (), object()], tuple) def test_negative(self): """ If the list does not contain an object of the type, return False. """ assert not _contains_instance_of([object(), [], {}], tuple) class TestDNS_ID: def test_enforces_unicode(self): """ Raise TypeError if pass DNS-ID is not unicode. """ with pytest.raises(TypeError): DNS_ID(b"foo.com") def test_handles_missing_idna(self, monkeypatch): """ Raise ImportError if idna is missing and a non-ASCII DNS-ID is passed. """ monkeypatch.setattr(service_identity.hazmat, "idna", None) with pytest.raises(ImportError): DNS_ID("f\xf8\xf8.com") def test_ascii_works_without_idna(self, monkeypatch): """ 7bit-ASCII DNS-IDs work no matter whether idna is present or not. """ monkeypatch.setattr(service_identity.hazmat, "idna", None) dns = DNS_ID("foo.com") assert b"foo.com" == dns.hostname @pytest.mark.skipif(idna is None, reason="idna not installed") def test_idna_used_if_available_on_non_ascii(self): """ If idna is installed and a non-ASCII DNS-ID is passed, encode it to ASCII. """ dns = DNS_ID("f\xf8\xf8.com") assert b"xn--f-5gaa.com" == dns.hostname @pytest.mark.parametrize( "invalid_id", [ " ", "", # empty strings "host,name", # invalid chars "192.168.0.0", "::1", "1234", # IP addresses ], ) def test_catches_invalid_dns_ids(self, invalid_id): """ Raise ValueError on invalid DNS-IDs. """ with pytest.raises(ValueError): DNS_ID(invalid_id) def test_lowercases(self): """ The hostname is lowercased so it can be compared case-insensitively. """ dns_id = DNS_ID("hOsTnAmE") assert b"hostname" == dns_id.hostname def test_verifies_only_dns(self): """ If anything else than DNSPattern is passed to verify, return False. """ assert not DNS_ID("foo.com").verify(object()) def test_simple_match(self): """ Simple integration test with _hostname_matches with a match. """ assert DNS_ID("foo.com").verify(DNSPattern.from_bytes(b"foo.com")) def test_simple_mismatch(self): """ Simple integration test with _hostname_matches with a mismatch. """ assert not DNS_ID("foo.com").verify(DNSPattern.from_bytes(b"bar.com")) def test_matches(self): """ Valid matches return `True`. """ for cert, actual in [ (b"www.example.com", b"www.example.com"), (b"*.example.com", b"www.example.com"), ]: assert _hostname_matches(cert, actual) def test_mismatches(self): """ Invalid matches return `False`. """ for cert, actual in [ (b"xxx.example.com", b"www.example.com"), (b"*.example.com", b"baa.foo.example.com"), (b"f*.example.com", b"baa.example.com"), (b"*.bar.com", b"foo.baz.com"), (b"*.bar.com", b"bar.com"), (b"x*.example.com", b"xn--gtter-jua.example.com"), (b"xxx*.example.com", b"xxxwww.example.com"), (b"f*.example.com", b"foo.example.com"), (b"*oo.bar.com", b"foo.bar.com"), (b"fo*oo.bar.com", b"fooooo.bar.com"), ]: assert not _hostname_matches(cert, actual) class TestURI_ID: def test_enforces_unicode(self): """ Raise TypeError if pass URI-ID is not unicode. """ with pytest.raises(TypeError): URI_ID(b"sip:foo.com") def test_create_DNS_ID(self): """ The hostname is converted into a DNS_ID object. """ uri_id = URI_ID("sip:foo.com") assert DNS_ID("foo.com") == uri_id.dns_id assert b"sip" == uri_id.protocol def test_lowercases(self): """ The protocol is lowercased so it can be compared case-insensitively. """ uri_id = URI_ID("sIp:foo.com") assert b"sip" == uri_id.protocol def test_catches_missing_colon(self): """ Raise ValueError if there's no colon within a URI-ID. """ with pytest.raises(ValueError): URI_ID("sip;foo.com") def test_is_only_valid_for_uri(self): """ If anything else than an URIPattern is passed to verify, return False. """ assert not URI_ID("sip:foo.com").verify(object()) def test_protocol_mismatch(self): """ If protocol doesn't match, verify returns False. """ assert not URI_ID("sip:foo.com").verify( URIPattern.from_bytes(b"xmpp:foo.com") ) def test_dns_mismatch(self): """ If the hostname doesn't match, verify returns False. """ assert not URI_ID("sip:bar.com").verify( URIPattern.from_bytes(b"sip:foo.com") ) def test_match(self): """ Accept legal matches. """ assert URI_ID("sip:foo.com").verify( URIPattern.from_bytes(b"sip:foo.com") ) class TestSRV_ID: def test_enforces_unicode(self): """ Raise TypeError if pass srv-ID is not unicode. """ with pytest.raises(TypeError): SRV_ID(b"_mail.example.com") def test_create_DNS_ID(self): """ The hostname is converted into a DNS_ID object. """ srv_id = SRV_ID("_mail.example.com") assert DNS_ID("example.com") == srv_id.dns_id def test_lowercases(self): """ The service name is lowercased so it can be compared case-insensitively. """ srv_id = SRV_ID("_MaIl.foo.com") assert b"mail" == srv_id.name def test_catches_missing_dot(self): """ Raise ValueError if there's no dot within a SRV-ID. """ with pytest.raises(ValueError): SRV_ID("_imapsfoocom") def test_catches_missing_underscore(self): """ Raise ValueError if the service is doesn't start with an underscore. """ with pytest.raises(ValueError): SRV_ID("imaps.foo.com") def test_is_only_valid_for_SRV(self): """ If anything else than an SRVPattern is passed to verify, return False. """ assert not SRV_ID("_mail.foo.com").verify(object()) def test_match(self): """ Accept legal matches. """ assert SRV_ID("_mail.foo.com").verify( SRVPattern.from_bytes(b"_mail.foo.com") ) @pytest.mark.skipif(idna is None, reason="idna not installed") def test_match_idna(self): """ IDNAs are handled properly. """ assert SRV_ID("_mail.f\xf8\xf8.com").verify( SRVPattern.from_bytes(b"_mail.xn--f-5gaa.com") ) def test_mismatch_service_name(self): """ If the service name doesn't match, verify returns False. """ assert not ( SRV_ID("_mail.foo.com").verify( SRVPattern.from_bytes(b"_xmpp.foo.com") ) ) def test_mismatch_dns(self): """ If the dns_id doesn't match, verify returns False. """ assert not ( SRV_ID("_mail.foo.com").verify( SRVPattern.from_bytes(b"_mail.bar.com") ) ) class TestDNSPattern: def test_enforces_bytes(self): """ Raise TypeError if unicode is passed. """ with pytest.raises(TypeError): DNSPattern.from_bytes("foo.com") def test_catches_empty(self): """ Empty DNS-IDs raise a :class:`CertificateError`. """ with pytest.raises(CertificateError): DNSPattern.from_bytes(b" ") def test_catches_NULL_bytes(self): """ Raise :class:`CertificateError` if a NULL byte is in the hostname. """ with pytest.raises(CertificateError): DNSPattern.from_bytes(b"www.google.com\0nasty.h4x0r.com") def test_catches_ip_address(self): """ IP addresses are invalid and raise a :class:`CertificateError`. """ with pytest.raises(CertificateError): DNSPattern.from_bytes(b"192.168.0.0") def test_invalid_wildcard(self): """ Integration test with _validate_pattern: catches double wildcards thus is used if an wildward is present. """ with pytest.raises(CertificateError): DNSPattern.from_bytes(b"*.foo.*") class TestURIPattern: def test_enforces_bytes(self): """ Raise TypeError if unicode is passed. """ with pytest.raises(TypeError): URIPattern.from_bytes("sip:foo.com") def test_catches_missing_colon(self): """ Raise CertificateError if URI doesn't contain a `:`. """ with pytest.raises(CertificateError): URIPattern.from_bytes(b"sip;foo.com") def test_catches_wildcards(self): """ Raise CertificateError if URI contains a *. """ with pytest.raises(CertificateError): URIPattern.from_bytes(b"sip:*.foo.com") class TestSRVPattern: def test_enforces_bytes(self): """ Raise TypeError if unicode is passed. """ with pytest.raises(TypeError): SRVPattern.from_bytes("_mail.example.com") def test_catches_missing_underscore(self): """ Raise CertificateError if SRV doesn't start with a `_`. """ with pytest.raises(CertificateError): SRVPattern.from_bytes(b"foo.com") def test_catches_wildcards(self): """ Raise CertificateError if SRV contains a *. """ with pytest.raises(CertificateError): SRVPattern.from_bytes(b"sip:*.foo.com") class TestValidateDNSWildcardPattern: def test_allows_only_one_wildcard(self): """ Raise CertificateError on multiple wildcards. """ with pytest.raises(CertificateError): _validate_pattern(b"*.*.com") def test_wildcard_must_be_left_most(self): """ Raise CertificateError if wildcard is not in the left-most part. """ for hn in [b"foo.b*r.com", b"foo.bar.c*m", b"foo.*", b"foo.*.com"]: with pytest.raises(CertificateError): _validate_pattern(hn) def test_must_have_at_least_three_parts(self): """ Raise CertificateError if host consists of less than three parts. """ for hn in [ b"*", b"*.com", b"*fail.com", b"*foo", b"foo*", b"f*o", b"*.example.", ]: with pytest.raises(CertificateError): _validate_pattern(hn) def test_valid_patterns(self): """ Does not throw CertificateError on valid patterns. """ for pattern in [ b"*.bar.com", b"*oo.bar.com", b"f*.bar.com", b"f*o.bar.com", ]: _validate_pattern(pattern) class TestIPAddressPattern: def test_invalid_ip(self): """ Raises CertificateError on invalid IP addresses. """ with pytest.raises(CertificateError): IPAddressPattern.from_bytes(b"127.o.o.1") @pytest.mark.parametrize("ip_s", ["1.1.1.1", "::1"]) def test_verify_equal(self, ip_s): """ Return True if IP addresses are identical. """ ip = ipaddress.ip_address(ip_s) assert IPAddress_ID(ip).verify(IPAddressPattern(ip)) is True class FakeCertID: pass class Fake_ID: """ An ID that accepts exactly on object as pattern. """ def __init__(self, pattern): self._pattern = pattern def verify(self, other): """ True iff other is the same object as pattern. """ return other is self._pattern class TestFindMatches: def test_one_match(self): """ If there's a match, return a tuple of the certificate id and the service id. """ valid_cert_id = FakeCertID() valid_id = Fake_ID(valid_cert_id) rv = _find_matches( [FakeCertID(), valid_cert_id, FakeCertID()], [valid_id] ) assert [ ServiceMatch(cert_pattern=valid_cert_id, service_id=valid_id) ] == rv def test_no_match(self): """ If no valid certificate ids are found, return an empty list. """ rv = _find_matches( [FakeCertID(), FakeCertID(), FakeCertID()], [Fake_ID(object())] ) assert [] == rv def test_multiple_matches(self): """ Return all matches. """ valid_cert_id_1 = FakeCertID() valid_cert_id_2 = FakeCertID() valid_cert_id_3 = FakeCertID() valid_id_1 = Fake_ID(valid_cert_id_1) valid_id_2 = Fake_ID(valid_cert_id_2) valid_id_3 = Fake_ID(valid_cert_id_3) rv = _find_matches( [ FakeCertID(), valid_cert_id_1, FakeCertID(), valid_cert_id_3, FakeCertID(), valid_cert_id_2, ], [valid_id_1, valid_id_2, valid_id_3], ) assert [ ServiceMatch(cert_pattern=valid_cert_id_1, service_id=valid_id_1), ServiceMatch(cert_pattern=valid_cert_id_2, service_id=valid_id_2), ServiceMatch(cert_pattern=valid_cert_id_3, service_id=valid_id_3), ] == rv class TestIsIPAddress: @pytest.mark.parametrize( "ip", [ b"127.0.0.1", "127.0.0.1", "172.16.254.12", "*.0.0.1", "::1", "*::1", "2001:0db8:0000:0000:0000:ff00:0042:8329", "2001:0db8::ff00:0042:8329", ], ) def test_ips(self, ip): """ Returns True for patterns and hosts that could match IP addresses. """ assert _is_ip_address(ip) is True @pytest.mark.parametrize( "not_ip", [ b"*.twistedmatrix.com", b"twistedmatrix.com", b"mail.google.com", b"omega7.de", b"omega7", b"127.\xff.0.1", ], ) def test_not_ips(self, not_ip): """ Return False for patterns and hosts that aren't IP addresses. """ assert _is_ip_address(not_ip) is False class TestVerificationError: def test_repr_str(self): """ The __str__ and __repr__ methods return something helpful. """ with pytest.raises(VerificationError) as ei: raise VerificationError(errors=["foo"]) assert repr(ei.value) == str(ei.value) assert str(ei.value) != "" @pytest.mark.parametrize("proto", range(pickle.HIGHEST_PROTOCOL + 1)) @pytest.mark.parametrize( "exc", [ VerificationError(errors=[]), VerificationError(errors=[DNSMismatch("example.com")]), VerificationError([]), VerificationError([DNSMismatch("example.com")]), ], ) def test_pickle(self, exc, proto): """ Exceptions can be pickled and unpickled. """ new_exc = pickle.loads(pickle.dumps(exc, proto)) # Exceptions can't be compared. assert exc.__class__ == new_exc.__class__ assert exc.__dict__ == new_exc.__dict__ service_identity-26.1.0/tests/test_packaging.py0000644000000000000000000000120215206547410016575 0ustar00from importlib import metadata import pytest import service_identity class TestLegacyMetadataHack: def test_version(self): """ service_identity.__version__ returns the correct version. """ assert ( metadata.version("service-identity") == service_identity.__version__ ) def test_does_not_exist(self): """ Asking for unsupported dunders raises an AttributeError. """ with pytest.raises( AttributeError, match="module service_identity has no attribute __yolo__", ): service_identity.__yolo__ service_identity-26.1.0/tests/test_pyopenssl.py0000644000000000000000000001112015206547410016705 0ustar00import ipaddress import pytest from service_identity.exceptions import ( DNSMismatch, IPAddressMismatch, VerificationError, ) from service_identity.hazmat import ( DNS_ID, DNSPattern, IPAddress_ID, IPAddressPattern, URIPattern, ) from service_identity.pyopenssl import ( extract_ids, extract_patterns, verify_hostname, verify_ip_address, ) from .certificates import ( PEM_CN_ONLY, PEM_DNS_ONLY, PEM_EVERYTHING, PEM_OTHER_NAME, ) if pytest.importorskip("OpenSSL"): from OpenSSL.crypto import FILETYPE_PEM, load_certificate CERT_DNS_ONLY = load_certificate(FILETYPE_PEM, PEM_DNS_ONLY) CERT_CN_ONLY = load_certificate(FILETYPE_PEM, PEM_CN_ONLY) CERT_OTHER_NAME = load_certificate(FILETYPE_PEM, PEM_OTHER_NAME) CERT_EVERYTHING = load_certificate(FILETYPE_PEM, PEM_EVERYTHING) class TestPublicAPI: def test_verify_hostname_ok(self): """ verify_hostname succeeds if the hostnames match. """ class FakeConnection: def get_peer_certificate(self): return CERT_DNS_ONLY verify_hostname(FakeConnection(), "twistedmatrix.com") def test_verify_hostname_fail(self): """ verify_hostname fails if the hostnames don't match and provides the user with helpful information. """ class FakeConnection: def get_peer_certificate(self): return CERT_DNS_ONLY with pytest.raises(VerificationError) as ei: verify_hostname(FakeConnection(), "google.com") assert [ DNSMismatch(mismatched_id=DNS_ID("google.com")) ] == ei.value.errors @pytest.mark.parametrize("ip", ["1.1.1.1", "::1"]) def test_verify_ip_address_ok(self, ip): """ verify_ip_address succeeds if the addresses match. Works both with IPv4 and IPv6. """ class FakeConnection: def get_peer_certificate(self): return CERT_EVERYTHING verify_ip_address(FakeConnection(), ip) @pytest.mark.parametrize("ip", ["1.1.1.2", "::2"]) def test_verify_ip_address_fail(self, ip): """ verify_ip_address fails if the addresses don't match and provides the user with helpful information. Works both with IPv4 and IPv6. """ class FakeConnection: def get_peer_certificate(self): return CERT_EVERYTHING with pytest.raises(VerificationError) as ei: verify_ip_address(FakeConnection(), ip) assert [ IPAddressMismatch(mismatched_id=IPAddress_ID(ip)) ] == ei.value.errors class TestExtractPatterns: def test_dns(self): """ Returns the correct DNSPattern from a certificate. """ rv = extract_patterns(CERT_DNS_ONLY) assert [ DNSPattern.from_bytes(b"www.twistedmatrix.com"), DNSPattern.from_bytes(b"twistedmatrix.com"), ] == rv def test_cn_ids_are_ignored(self): """ commonName is not supported anymore and therefore ignored. """ assert [] == extract_patterns(CERT_CN_ONLY) def test_uri(self): """ Returns the correct URIPattern from a certificate. """ rv = extract_patterns(CERT_OTHER_NAME) assert [URIPattern.from_bytes(b"http://example.com/")] == [ id for id in rv if isinstance(id, URIPattern) ] def test_ip(self): """ Returns IP patterns. """ rv = extract_patterns(CERT_EVERYTHING) assert [ DNSPattern.from_bytes(pattern=b"service.identity.invalid"), DNSPattern.from_bytes( pattern=b"*.wildcard.service.identity.invalid" ), DNSPattern.from_bytes(pattern=b"service.identity.invalid"), DNSPattern.from_bytes(pattern=b"single.service.identity.invalid"), IPAddressPattern(pattern=ipaddress.IPv4Address("1.1.1.1")), IPAddressPattern(pattern=ipaddress.IPv6Address("::1")), IPAddressPattern(pattern=ipaddress.IPv4Address("2.2.2.2")), IPAddressPattern(pattern=ipaddress.IPv6Address("2a00:1c38::53")), ] == rv def test_extract_ids_deprecated(self): """ `extract_ids` raises a DeprecationWarning with correct stacklevel. """ with pytest.deprecated_call() as wr: extract_ids(CERT_EVERYTHING) w = wr.pop() assert ( "`extract_ids()` is deprecated, please use `extract_patterns()`." == w.message.args[0] ) assert __file__ == w.filename service_identity-26.1.0/tests/constraints/oldest-cryptography.txt0000644000000000000000000000004315206547410022375 0ustar00attrs==19.1.0 cryptography==47.0.0 service_identity-26.1.0/tests/constraints/oldest-pyopenssl.txt0000644000000000000000000000006515206547410021702 0ustar00attrs==19.1.0 cryptography==47.0.0 pyOpenSSL==26.1.0 service_identity-26.1.0/tests/typing/api.py0000644000000000000000000000222215206547410015700 0ustar00""" This module is used to test the typing of the public API of service-identity. It is NOT intended to be executed. """ from __future__ import annotations import socket from typing import Sequence from cryptography.hazmat.backends import default_backend from cryptography.x509 import load_pem_x509_certificate from OpenSSL import SSL import service_identity backend = default_backend() c_cert = load_pem_x509_certificate("foo.pem", backend) c_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( service_identity.cryptography.extract_patterns(c_cert) ) service_identity.cryptography.verify_certificate_hostname( c_cert, "example.com" ) service_identity.cryptography.verify_certificate_ip_address( c_cert, "127.0.0.1" ) ctx = SSL.Context(SSL.TLSv1_2_METHOD) conn = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM)) p_cert = conn.get_peer_certificate() assert p_cert p_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( service_identity.pyopenssl.extract_patterns(p_cert) ) service_identity.pyopenssl.verify_hostname(conn, "example.com") service_identity.pyopenssl.verify_ip_address(conn, "127.0.0.1") service_identity-26.1.0/.gitignore0000644000000000000000000000014215206547410014070 0ustar00.tox dist build .coverage* .venv htmlcov _build *.pyc .cache .pytest_cache .vscode .envrc .direnv service_identity-26.1.0/LICENSE0000644000000000000000000000214015206547410013105 0ustar00The MIT License (MIT) Copyright (c) 2014 Hynek Schlawack and the service-identity contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. service_identity-26.1.0/pyproject.toml0000644000000000000000000001330315206547410015017 0ustar00[build-system] requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [project] name = "service-identity" dynamic = ["version", "readme"] authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.8" description = "Service identity verification for pyOpenSSL & cryptography." keywords = ["cryptography", "openssl", "pyopenssl"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Security :: Cryptography", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] dependencies = [ # Keep in-sync with tests/constraints/*. "attrs>=19.1.0", "cryptography>=47", ] [project.urls] Documentation = "https://service-identity.readthedocs.io/" Changelog = "https://service-identity.readthedocs.io/en/stable/changelog.html" GitHub = "https://github.com/pyca/service-identity" Funding = "https://github.com/sponsors/hynek" Tidelift = "https://tidelift.com/subscription/pkg/pypi-service-identity?utm_source=pypi-service-identity&utm_medium=pypi" Mastodon = "https://mastodon.social/@hynek" Twitter = "https://twitter.com/hynek" [project.optional-dependencies] idna = ["idna"] [dependency-groups] tests = ["coverage[toml]>=5.0.2", "pytest"] docs = ["sphinx", "furo", "myst-parser", "sphinx-notfound-page", "pyOpenSSL"] mypy = ["mypy", "types-pyOpenSSL", "idna"] dev = [ { include-group = "tests" }, { include-group = "mypy" }, "pyOpenSSL", ] [tool.hatch.version] source = "vcs" raw-options = { local_scheme = "no-local-version" } [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = "# Service Identity Verification for pyOpenSSL & *cryptography*\n" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "README.md" start-after = "spiel-begin -->\n" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = """ ## Release Information """ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "CHANGELOG.md" pattern = "\n(###.+?\n)## " [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = """ ---- [Complete Changelog →](https://service-identity.readthedocs.io/en/stable/changelog.html) """ [tool.pytest.ini_options] addopts = ["-ra", "--strict-markers", "--strict-config"] xfail_strict = true testpaths = "tests" filterwarnings = ["once::Warning"] norecursedirs = ["tests/typing"] [tool.coverage.run] parallel = true branch = true source = ["service_identity", "tests"] [tool.coverage.paths] source = ["src", ".tox/py*/**/site-packages"] [tool.coverage.report] show_missing = true skip_covered = true exclude_also = [ # allow defensive code "^\\s*raise AssertionError\\b", "^\\s*raise NotImplementedError\\b", "^\\s*return NotImplemented\\b", "^\\s*raise$", # typing-related code "^if (False|TYPE_CHECKING):", ": \\.\\.\\.(\\s*#.*)?$", "^ +\\.\\.\\.$", "-> ['\"]?NoReturn['\"]?:", # Tests '^if pytest\.importorskip\(', ] [tool.interrogate] omit-covered-files = true verbose = 2 fail-under = 100 whitelist-regex = ["test_.*"] [tool.ruff] src = ["src", "tests"] line-length = 79 [tool.ruff.lint] select = ["ALL"] ignore = [ "A001", # shadowing is fine "ANN", # Mypy is better at this "ARG001", # we don't control all args passed in "ARG005", # we need stub lambdas "COM", # ruff format takes care of our commas "D", # We prefer our own docstring style. "E501", # leave line-length enforcement to ruff format "FIX", # Yes, we want XXX as a marker. "INP001", # sometimes we want Python files outside of packages "ISC001", # conflicts with ruff format "N801", # some artistic freedom when naming things after RFCs "N802", # ditto "PLR2004", # numbers are sometimes fine "RUF001", # leave my smart characters alone "SLF001", # private members are accessed by friendly functions "TCH", # TYPE_CHECKING blocks break autodocs "TD", # we don't follow other people's todo style ] [tool.ruff.lint.per-file-ignores] "tests/*" = [ "B018", # "useless" expressions can be useful in tests "PLC1901", # empty strings are falsey, but are less specific in tests "PT005", # we always add underscores and explicit name "PT011", # broad is fine "S101", # assert "S301", # I know pickle is bad, but people use it. "SIM300", # Yoda rocks in asserts "TRY301", # tests need to raise exceptions ] "docs/pyopenssl_example.py" = [ "T201", # print is fine in the example "T203", # pprint is fine in the example ] [tool.ruff.lint.isort] lines-between-types = 1 lines-after-imports = 2 [[tool.ty.overrides]] include = ["tests"] rules.all = "ignore" [tool.mypy] strict = true pretty = true show_error_codes = true enable_error_code = ["ignore-without-code"] ignore_missing_imports = true [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true [[tool.mypy.overrides]] module = "tests.typing.*" ignore_errors = false [[tool.mypy.overrides]] module = "cryptography.*" follow_imports = "skip" service_identity-26.1.0/PKG-INFO0000644000000000000000000001130715206547410013202 0ustar00Metadata-Version: 2.4 Name: service-identity Version: 26.1.0 Summary: Service identity verification for pyOpenSSL & cryptography. Project-URL: Documentation, https://service-identity.readthedocs.io/ Project-URL: Changelog, https://service-identity.readthedocs.io/en/stable/changelog.html Project-URL: GitHub, https://github.com/pyca/service-identity Project-URL: Funding, https://github.com/sponsors/hynek Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-service-identity?utm_source=pypi-service-identity&utm_medium=pypi Project-URL: Mastodon, https://mastodon.social/@hynek Project-URL: Twitter, https://twitter.com/hynek Author-email: Hynek Schlawack License-Expression: MIT License-File: LICENSE Keywords: cryptography,openssl,pyopenssl Classifier: Development Status :: 5 - Production/Stable Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Security :: Cryptography Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Typing :: Typed Requires-Python: >=3.8 Requires-Dist: attrs>=19.1.0 Requires-Dist: cryptography>=47 Provides-Extra: idna Requires-Dist: idna; extra == 'idna' Description-Content-Type: text/markdown # Service Identity Verification for pyOpenSSL & *cryptography* Use this package if: - you want to **verify** that a [PyCA *cryptography*](https://cryptography.io/) certificate is valid for a certain hostname or IP address, - 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, - or if you want to **inspect** certificates from either for service IDs. *service-identity* aspires to give you all the tools you need for verifying whether a certificate is valid for the intended purposes. In the simplest case, this means *host name verification*. However, *service-identity* implements [RFC 6125](https://datatracker.ietf.org/doc/html/rfc6125.html) fully. Also check out [*pem*](https://github.com/hynek/pem) that makes loading certificates from all kinds of PEM-encoded files a breeze! ## Project Information *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/). ### Credits *service-identity* is written and maintained by [Hynek Schlawack](https://hynek.me/). 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). ### *service-identity* for Enterprise Available as part of the [Tidelift Subscription](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek). 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. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. ## Release Information ### Added - Python 3.14 and 3.15 are now officially supported. [#85](https://github.com/pyca/service-identity/pull/85) [#93](https://github.com/pyca/service-identity/pull/93) ### Changed - *service-identity* now uses *cryptography*'s Rust-based ASN.1 decoder and doesn't depend on *pyasn1* and *pyasn1-modules* anymore. As a result, the oldest supported pyOpenSSL backend combination is now *pyOpenSSL* 26.1.0 with *cryptography* 47.0.0. [#95](https://github.com/pyca/service-identity/pull/95) ### Fixed - Verifying a single-label hostname (e.g. `localhost`) against a wildcard certificate pattern now raises `VerificationError` cleanly instead of crashing with an opaque `ValueError`. [#92](https://github.com/pyca/service-identity/pull/92) ---- [Complete Changelog →](https://service-identity.readthedocs.io/en/stable/changelog.html)