diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74a9166..f2f1615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,30 @@ jobs: files: ./coverage.xml fail_ci_if_error: false + # Read the Docs builds from its own webhook after merge, so nothing else + # would catch a broken cross-reference or a malformed docstring until the + # docs were already live. This leg runs the same build RTD runs -- same + # -W/nitpicky settings via .readthedocs.yaml's fail_on_warning -- on every + # PR. autodoc imports httpx_pki, so the package is installed, not just the + # docs toolchain. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + - name: Build (warnings are errors) + run: python -m sphinx -b html -W --keep-going docs docs/_build/html + - name: Check external links + # Informational: an upstream site being down should not redden a PR. + continue-on-error: true + run: python -m sphinx -b linkcheck docs docs/_build/linkcheck + # The httpx fallback leg. The main matrix runs on httpx2 (the required # dependency; the dev extra also installs httpx, exercising the # both-installed preference). This leg covers the remaining environment the diff --git a/.gitignore b/.gitignore index 6753729..6c9d7dd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ htmlcov/ *.pem *.key .idea* +docs/_build/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..adbe688 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,31 @@ +# Read the Docs build configuration. +# +# RTD builds on its own webhook, not from a GitHub Action: a push to main +# rebuilds `latest`, and pushing a vX.Y.Z tag builds that version and moves +# `stable` onto it. Since publish.yml fires on a published release -- which +# implies the tag -- the docs and the PyPI release track the same tag without +# either workflow knowing about the other. +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + # A broken cross-reference or an orphaned page fails the build rather than + # shipping quietly. Paired with nitpicky = True in conf.py. + fail_on_warning: true + +python: + install: + # autodoc imports httpx_pki, so the package and its runtime dependencies + # have to be installed, not just the docs toolchain. + - method: pip + path: . + extra_requirements: + - docs + +formats: + - htmlzip diff --git a/CHANGELOG.md b/CHANGELOG.md index fc61b02..a7a3127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,81 @@ the git history for the fine print. `verify="certifi"` (added in 0.7) pins the certifi bundle for callers who want the old behavior. `SSLKEYLOGFILE` is honored by every context either way. +- **Breaking: `from_key_pair`'s `key_password=` is now `password=`**, the same + keyword every other constructor and `reload()` already used. Rename the + argument at call sites: `from_key_pair(cert, key, key_password=...)` becomes + `from_key_pair(cert, key, password=...)`. +- **Breaking: `CertInfo.not_before` / `not_after` are now `not_valid_before` / + `not_valid_after`**, matching the client properties of the same name (and + `cryptography`'s own vocabulary) so the two objects no longer spell the same + instant two ways. `httpx_pki.testing.make_client_cert()` takes the renamed + keywords to match, keeping mint-and-read-back symmetric. +- **Breaking: the platform stores' `predicate=` is now `identity=`**, the same + keyword PKCS#12 and PEM bundles already used, on + `from_windows_cert_store`, `from_macos_keychain`, `build_windows_ssl_context`, + `build_macos_ssl_context`, `select_windows_certificate`, and + `select_macos_certificate`. `identity` is the library's noun everywhere else + (`P12Identity`, `list_identities()`, `HTTPX_PKI_IDENTITY`), and having one + spelling for bundles and another for stores meant `currently_valid` had to be + documented twice in different vocabulary. It now reads + `identity=currently_valid` everywhere. + + On the stores `identity=` accepts everything a bundle's does *except* an + integer position: a store has no stable enumeration order, so a position + would select a different certificate from one run to the next, and it raises + `TypeError` rather than silently indexing. A string is a name substring or an + exact SHA-1/SHA-256 fingerprint, matching the bundle rule. `name=` and + `thumbprint=` are unchanged and remain the unambiguous spellings. +- **New: the `_init_state()` subclass hook** — the documented seam for + subclasses that take constructor keywords of their own. It runs exactly once + on every construction path (`__init__`, every `from_*` alternate + constructor, and unpickling — the latter two never call `__init__`, so + extending `__init__` alone was not enough), receiving the extra-keyword dict + before it is forwarded to httpx. Pop your keywords, set your attributes; + what remains must be valid httpx keywords, so unclaimed arguments still fail + loudly. State set in the hook survives a pickle round trip automatically, + and `reload()`/`auto_reload` leave it untouched. See the subclassing section + of the advanced-usage guide. +- **Bug fix: `warn_if_expires_within` now survives `reload()` and pickling.** + The window was applied once at construction and then forgotten, so the + early-expiry warning went permanently quiet after the first rotation — and + after any pickle round trip — which silently disabled the one signal the + documented `auto_reload` + `warn_if_expires_within` pairing exists to give a + long-lived service. It is now retained on the client and re-applied to the + *freshly loaded* certificate on every reload: a rotation onto another + short-lived certificate warns again, one onto a healthy certificate goes + quiet, and a client that never asked for the warning still never gets one. + The two unconditional warnings (expired, not-yet-valid) already fired on + reload and are unchanged. +- **Bug fix: `reload(password=...)` no longer silently discards the password** + for sources that supply their own. A client built by `from_env()` reads + `{prefix}PASSWORD` itself, and the Windows store and macOS keychain export + under an internally generated single-use password — for all three the + argument had nothing to decrypt and was dropped without a word, so removing + a password from the environment and passing it to `reload()` instead failed + with a bare "wrong password" from a caller who had supplied one. It now + raises `TypeError` naming which case you are in and where the password + belongs, matching how `auto_reload` already rejects a source it cannot + watch. Reloads that pass no password are unaffected. +- **Breaking: the certificate-source argument is now `source=` everywhere.** + `PKIClient(...)` / `AsyncPKIClient(...)`, `from_pkcs12`, and + `build_ssl_context` called it `cert=` while `from_pem`, `list_identities`, + and `list_pkcs12_identities` already called it `source=`; the parameter is + typed `CertSource` (a path, `bytes`, or `Path`, and for a bundle it holds a + key and chain as well as a certificate), so `source` describes it and now + names it everywhere. Callers passing it positionally — every example in the + docs — are unaffected. + + This also fixes a real defect: because the constructor's first parameter was + named `cert`, httpx's deprecated `cert=` keyword bound to it instead of + reaching the guard, so `PKIClient(bundle, cert=...)` raised a bare + `_PKIMixin.__init__() got multiple values for argument 'cert'` — leaking a + private class name and explaining nothing — where every `from_*` constructor + gave a pointed message. The guard now fires uniformly. + + `from_key_pair(certificate=..., private_key=...)` is unchanged: there + `certificate` really is the certificate, distinct from the key. So is + `cert_info(cert_pem)`, which takes PEM bytes rather than a source. - **truststore is now a direct required dependency** (it also arrives transitively with httpx2, but httpx-pki calls it directly). The `[system]` and `[httpx2]` extras still install but are no-ops; they are kept so diff --git a/README.md b/README.md index 9acd160..aaade31 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![codecov](https://img.shields.io/codecov/c/github/ccbest/httpx-pki?branch=main)](https://codecov.io/gh/ccbest/httpx-pki) [![PyPI](https://img.shields.io/pypi/v/httpx-pki)](https://pypi.org/project/httpx-pki/) [![Python versions](https://img.shields.io/pypi/pyversions/httpx-pki)](https://pypi.org/project/httpx-pki/) +[![Docs](https://img.shields.io/readthedocs/httpx-pki)](https://httpx-pki.readthedocs.io/) [![License: MIT](https://img.shields.io/pypi/l/httpx-pki)](https://github.com/ccbest/httpx-pki/blob/main/LICENSE) [![Checked with mypy](https://img.shields.io/badge/mypy-checked-2a6db2)](https://mypy-lang.org/) @@ -22,6 +23,8 @@ with PKIClient("client.p12", password="secret") as client: print(resp.status_code) ``` +📖 **[Full documentation](https://httpx-pki.readthedocs.io/)** + #### Purpose httpx deprecated its `cert=` argument in 0.28 — a design httpx2 keeps — in @@ -34,363 +37,52 @@ from PKCS#12 or in-memory bytes. `httpx-pki` is that missing piece. pip install httpx-pki ``` -Requires Python 3.10+, `httpx2>=2.9`, and `cryptography>=44` (`truststore` and -`certifi`, which back server verification, come along as dependencies). - -### httpx2 or httpx? - -Both. httpx development continues under pydantic's stewardship as -[httpx2](https://github.com/pydantic/httpx2), and `httpx-pki` works with either: -when httpx2 is importable it is preferred (the session classes subclass -`httpx2.Client`), otherwise `httpx-pki` binds to httpx. `httpx_pki.HTTP_BACKEND` -reports which backend won, and setting `HTTPX_PKI_BACKEND=httpx` (or `httpx2`) -in the environment forces the choice — the escape hatch if httpx2 arrives in -your environment as a transitive dependency of something else but your code -still expects `PKIClient` to subclass `httpx.Client`. Install the httpx2 -backend with `pip install httpx-pki[httpx2]`. - -In the upcoming 0.8 release, [httpx2](https://github.com/pydantic/httpx2) — httpx's continuation -under pydantic's stewardship — will become the required dependency, and the session -classes will subclass `httpx2.Client` / `httpx2.AsyncClient`. The original httpx -will remain fully supported as a fallback: with `httpx>=0.28` installed, -`httpx-pki` binds to httpx whenever httpx2 is absent, and setting -`HTTPX_PKI_BACKEND=httpx` in the environment forces it even when httpx2 is -installed — the escape hatch for code that expects `PKIClient` to subclass -the original `httpx.Client`. `httpx_pki.HTTP_BACKEND` reports which backend was -resolved. - -In 0.8, the `verify=True` default will move from certifi to the OS -trust store (see [Server trust](#server-trust-verify)); pass `verify="certifi"` -to keep the old bundle. The `[system]` and `[httpx2]` extras still install but -are no-ops — truststore and httpx2 are required dependencies now. - -One caveat with both packages installed: `isinstance(client, httpx.Client)` -against the *original* httpx is False once the sessions subclass httpx2. Either -force the backend as above, or migrate the check (calling -[`httpx2.alias_httpx()`](https://github.com/pydantic/httpx2) in your application -makes `import httpx` resolve to httpx2 everywhere, which keeps such checks -consistent). - -## Supported formats - -Certificate files come with many extensions (`.p12`, `.pfx`, `.pem`, `.crt`, -`.key`, `.tls`, `.ukey`, ...), but an extension is just a name — what matters is -the **encoding of the bytes**. `httpx-pki` detects that from the content, so the -extension never matters: - -| Input | Constructor | Notes | -| --- | --- | --- | -| **PKCS#12** (`.p12`, `.pfx`, binary) | `PKIClient(...)` or `from_pkcs12(...)` | key + cert + chain in one password-protected blob; may hold [several identities](#when-one-bundle-holds-several-identities) | -| **PEM bundle** (key + cert(s) in one file) | `PKIClient(...)` or `from_pem(...)` | any block order; PKCS#1/PKCS#8/EC/encrypted keys; may hold [several identities](#when-one-bundle-holds-several-identities) too | -| **Separate cert + key** (PEM *or* DER) | `from_key_pair(...)` | optional `chain=` intermediates | -| **PKCS#7 / `.p7b`** (certs only, DER or PEM) | `certificate=`/`chain=` in `from_key_pair`, or a `verify=` CA bundle | holds no private key — pairs with a separate key | -| **Windows cert store** | `from_windows_cert_store(...)` | Windows only; see below | -| **macOS keychain** | `from_macos_keychain(...)` | macOS only; see below | - -`PKIClient(source, password=...)` auto-detects PKCS#12 vs PEM, so you can point -it at whatever you were handed. Use the explicit `from_pkcs12` / `from_pem` -constructors when you want to force one interpretation. - -## Usage - -### From a PKCS#12 bundle (`.p12` / `.pfx`) - -A path (`str` or `pathlib.Path`) or raw `bytes` both work: - -```python -from pathlib import Path -from httpx_pki import PKIClient - -PKIClient("client.p12", password="secret") # path -PKIClient(Path("client.pfx"), password="secret") # pathlib.Path -PKIClient(p12_bytes, password=b"secret") # bytes; password may be bytes -``` - -### When one bundle holds several identities - -A bundle — PKCS#12 or PEM alike — can carry more than one **identity**: a -private key with its certificate. Two identities for the same subject is routine wherever a CA -archives the key that *decrypts* data, so encrypted mail and files survive a -lost laptop, but never the key that *signs*, which would defeat -non-repudiation: Entrust dual key pairs, PIV/CAC, S/MIME key archival, national -eID schemes. The two certificates differ in their key usage, and that is -usually all that tells them apart. - -Which bits exactly depends on the algorithm and the scheme: - -| Half | Typical key usage | -| --- | --- | -| encryption | `key_encipherment` (RSA) or `key_agreement` (ECDH) | -| signing | `digital_signature`, and/or `content_commitment` — the bit most CAs still call *nonRepudiation*, which `key_usage=` accepts as a spelling | - -**For mTLS you almost always want the signing half.** TLS 1.3, and every ECDHE -suite before it, has the client sign the handshake; an encryption-only -certificate cannot complete one. - -Some schemes split three ways rather than two — a PIV card carries -authentication, signature, and key-management certificates, and the first two -both assert `digital_signature`. There the extended key usage is the -discriminator (`client_auth` versus `email_protection`), which -`extended_key_usage=` selects on. - -Loading such a file without saying which one you want raises rather than -presenting whichever the file happens to store first: - -```python ->>> PKIClient("corp.p12", password="secret") -AmbiguousCertificateError: this PKCS#12 data holds 2 identities: - [0] corp-user (Signature) key_usage=digital_signature expires=2027-07-30 8F78A78195… - [1] corp-user (Encryption) key_usage=key_encipherment expires=2027-07-30 6E88063681… -Pick one with identity= (index, name, or fingerprint), key_usage=, or extended_key_usage=. -``` - -See what a file holds with `list_identities` — it detects PKCS#12 vs PEM from -the content, exactly like the constructors, and never returns the private keys -(`list_pkcs12_identities` is the sibling for when only PKCS#12 should be -accepted): - -```python -from httpx_pki import list_identities - -for identity in list_identities("corp.p12", password="secret"): - print(identity.index, identity.friendly_name, - sorted(identity.info.key_usage), identity.info.extended_key_usage) -``` - -Then select one. Every bundle entry point — `PKIClient(...)`, -`from_pkcs12(...)`, `from_pem(...)`, `AsyncPKIClient`, and -`build_ssl_context` — takes the same three selectors, and they intersect if -you pass more than one: - -```python -# by key usage: the usual discriminator for a dual key pair -PKIClient("corp.p12", password="secret", key_usage="digital_signature") - -# by extended key usage, when both certs share their key-usage bits -PKIClient("corp.p12", password="secret", extended_key_usage="client_auth") - -# by name: a case-insensitive substring of the friendly name, common name, -# or full subject -PKIClient("corp.p12", password="secret", identity="Signature") - -# by exact SHA-1 or SHA-256 fingerprint (colons and case are ignored) -PKIClient("corp.p12", password="secret", identity="9F:86:D0:81…") - -# by file position, or by any predicate over the identity -PKIClient("corp.p12", password="secret", identity=0) -PKIClient( - "corp.p12", - password="secret", - identity=lambda i: i.info.serial_number == 4242 -) -``` - -A selector that matches nothing raises `CertificateNotFoundError`; one that -matches several raises `AmbiguousCertificateError`. Usage names are spelled as -`CertInfo` reports them (`digital_signature`, `client_auth`), and `keyUsage` -camelCase and dotted OIDs are accepted too. - -The same applies when a file carries a **renewed certificate next to the one it -replaces** — two certificates over one key pair, which is what renewing rather -than rekeying produces. Those are two identities as well, and since only the -validity window separates them, the ready-made `currently_valid` selector is -the way to pick: - -```python -from httpx_pki import PKIClient, currently_valid - -PKIClient("corp.p12", password="secret", identity=currently_valid) -``` - -Not-yet-valid and expired identities never match it. During the renewal -*overlap*, when the old certificate has not expired yet, the tie resolves to -the later validity window — but only between certificates that are otherwise -interchangeable (same subject and usages). It never picks between the halves -of a dual key pair: freshness cannot tell a signing certificate from an -encryption one, so combine it with `key_usage=` there. - -**PEM bundles get the same treatment.** A `.pem` concatenating two key+cert -pairs — or one key followed by its old and renewed certificates — holds -several identities, chosen with the same selectors. Keys are paired to -certificates by public key, in any block order; a key matching no certificate -at all still means the bundle was assembled from the wrong pieces, and is -rejected. - -The other identities' certificates are **not** presented as chain certificates -— they are leaf certificates of their own, and a strict server can reject a -chain carrying them. Only real chain certificates are sent. - -The selection is remembered: `reload()` and `auto_reload` re-select the same -identity after a rotation, even if the new file lists the identities in a -different order, and it survives pickling. - -### From a PEM file (key + cert in one blob) - -```python -from httpx_pki import PKIClient - -PKIClient("client.pem") # auto-detected -PKIClient.from_pem("client.pem") # explicit -PKIClient.from_pem(pem_bytes, password="..") # if the key block is encrypted -``` - -A PEM bundle holding more than one key+cert pair takes the same `identity=` / -`key_usage=` / `extended_key_usage=` selectors as PKCS#12 — see -[several identities](#when-one-bundle-holds-several-identities). - -### From a separate certificate and key - -```python -from httpx_pki import PKIClient - -client = PKIClient.from_key_pair( - certificate="client.crt", - private_key="client.key", - key_password="secret", # if the key is encrypted - chain="intermediate.crt", # optional: intermediates to present; one - # path/bytes (may concatenate several) or a list -) -``` +Requires Python 3.10+. httpx2 comes with it, along with `cryptography`, +`truststore`, and `certifi`. -If `certificate` is itself a bundle (leaf plus intermediates in one PEM file), -the leaf is identified by matching the private key — in any block order — and -the other certificates are presented as chain automatically. Both `certificate` -and `chain` also accept certs-only **PKCS#7** bundles (`.p7b`/`.p7c`, DER or -PEM) — the format Windows CAs commonly export chains in. +Prefer the original httpx? It stays fully supported — install with `--no-deps` +so httpx2 isn't pulled in. See +[Install](https://httpx-pki.readthedocs.io/en/stable/install.html) and +[Backends](https://httpx-pki.readthedocs.io/en/stable/guide/backends.html). -### From the Windows certificate store (Windows only) +## Whatever you were handed, there's a one-liner for it -Pull an **exportable** client certificate (key included) straight out of the -user's personal store, selecting by a case-insensitive substring of the subject -common name or the Windows "friendly name": +Certificate files come with all sorts of extensions — `.p12`, `.pfx`, `.pem`, +`.crt`, `.tls` — but an extension is just a name. `httpx-pki` detects the +encoding from the **bytes**, so you can point it at whatever your PKI team sent +you: ```python from httpx_pki import PKIClient -with PKIClient.from_windows_cert_store(name="ACME Client") as client: - client.get("https://mtls.example.com/") -``` - -If several certificates match you'll get an `AmbiguousCertificateError` listing -the candidates with their key usages and expiry; narrow it with any combination -of selectors — **every one you pass must match**: - -```python -PKIClient.from_windows_cert_store(thumbprint="A1:B2:C3:...") -PKIClient.from_windows_cert_store(predicate=lambda c: c.friendly_name == "prod") -PKIClient.from_windows_cert_store(name="ACME", location="LocalMachine") - -# A dual key pair — what AD key archival provisions — puts both halves in the -# store under one subject. The key usage is what separates them: -PKIClient.from_windows_cert_store(name="ACME", key_usage="digital_signature") -PKIClient.from_windows_cert_store(name="ACME", extended_key_usage="client_auth") -``` - -To see what's in the store before selecting, `list_windows_certificates()` -returns a `WinCert` for each certificate — metadata only, no key is exported: - -```python -from httpx_pki import list_windows_certificates - -for c in list_windows_certificates(): # location="LocalMachine" for the machine store - print(c.friendly_name, c.subject_cn, c.thumbprint, sorted(c.key_usage)) -``` - -Each `WinCert` also carries the parsed `certificate` and its `info` -(a [`CertInfo`](#inspecting-the-certificate)), so a predicate can select on -anything a certificate holds — including skipping the expired copy a store -tends to keep after a renewal, which the ready-made `currently_valid` -selector does for you: - -```python -from httpx_pki import currently_valid - -PKIClient.from_windows_cert_store(name="ACME", predicate=currently_valid) -``` - -Notes: - -- **Windows only** — calling it elsewhere raises `UnsupportedPlatformError`. -- The certificate's private key must have been imported as **exportable** — - otherwise the export fails with a `CertificateLoadError`. -- No password is involved: the cert is exported under a random, single-use - password that never leaves the library. -- `AsyncPKIClient.from_windows_cert_store(...)` is the async equivalent. - -### From the macOS keychain (macOS only) - -The macOS sibling of the Windows store: pull an **exportable** identity -(certificate + private key) out of the default keychain search list, selecting -by a case-insensitive substring of the subject common name or the keychain -label: - -```python -from httpx_pki import PKIClient - -with PKIClient.from_macos_keychain(name="ACME Client") as client: - client.get("https://mtls.example.com/") -``` - -Selection works exactly like the Windows store — `AmbiguousCertificateError` -lists the candidates, and every selector you pass must match: - -```python -PKIClient.from_macos_keychain(thumbprint="A1:B2:C3:...") -PKIClient.from_macos_keychain(predicate=lambda c: c.label == "prod") - -# Both halves of a dual key pair in one keychain, told apart by usage: -PKIClient.from_macos_keychain(name="ACME", key_usage="digital_signature") -PKIClient.from_macos_keychain(name="ACME", extended_key_usage="email_protection") +# PKCS#12 bundle — key + cert + chain in one blob +PKIClient("client.p12", password="secret") -# The renewed identity rather than the expired one kept alongside it: -PKIClient.from_macos_keychain(name="ACME", predicate=currently_valid) -``` - -`list_macos_certificates()` returns a `MacCert` per identity — subject CN, -keychain label, SHA-1 thumbprint, plus the parsed `certificate`, its `info`, -and `key_usage` / `extended_key_usage`; metadata only, no key is exported. -`build_macos_ssl_context(...)` is the session-less seam, mirroring -`build_windows_ssl_context`. +# PEM bundle — key + cert(s) in one file, any block order +PKIClient("client.pem") -Notes: +# Raw bytes you already have in hand +PKIClient(p12_bytes, password=b"secret") -- **macOS only** — calling it elsewhere raises `UnsupportedPlatformError`. -- The private key must be exportable, and the keychain may require **user - consent** for the export. A headless session cannot grant consent — for - unattended use, import the certificate with access pre-granted - (`security import client.p12 -k login.keychain -A`) or click "Always Allow" - once in the consent dialog. -- No password is involved: the identity is exported under a random, - single-use password that never leaves the library. -- `reload()` re-exports from the keychain with the same selector; there is no - file to watch, so `auto_reload` is not available. -- `AsyncPKIClient.from_macos_keychain(...)` is the async equivalent. +# Separate certificate and key, PEM or DER +PKIClient.from_key_pair("client.crt", "client.key") -### From environment variables +# ...with intermediates, as PEM or PKCS#7 +PKIClient.from_key_pair("client.crt", "client.key", chain="chain.p7b") -For containerized / 12-factor deployments, configure the certificate out of band: +# The Windows certificate store (Windows only) +PKIClient.from_windows_cert_store(name="Acme Corp") -```python -from httpx_pki import PKIClient +# The macOS keychain (macOS only) +PKIClient.from_macos_keychain(name="Acme Corp") -with PKIClient.from_env() as client: # reads HTTPX_PKI_* by default - client.get("https://mtls.example.com/") +# Configured entirely by environment variables +PKIClient.from_env() ``` -| Variable | Meaning | -| --- | --- | -| `HTTPX_PKI_CERT` | path to a PKCS#12 or PEM source (**required**) | -| `HTTPX_PKI_PASSWORD` | password for the cert / key (optional) | -| `HTTPX_PKI_KEY` | path to a separate private key; switches to cert+key mode | -| `HTTPX_PKI_CHAIN` | intermediates to present, in addition to any carried by `CERT` | -| `HTTPX_PKI_CA` | CA bundle for **server** trust (`verify=`), or the literal `system` for the OS trust store / `certifi` for the certifi bundle | -| `HTTPX_PKI_IDENTITY` | which identity to present when `CERT` holds several: a file position, a name substring, a fingerprint, or the literal `currently_valid` | -| `HTTPX_PKI_KEY_USAGE` | identity selector by key usage, comma-separated (e.g. `digital_signature`) | -| `HTTPX_PKI_EXT_KEY_USAGE` | identity selector by extended key usage, comma-separated (e.g. `client_auth`) | - -Pass a different `prefix=` to namespace per service (`PKIClient.from_env("MYAPP_")`). +→ [Loading certificates](https://httpx-pki.readthedocs.io/en/stable/guide/loading-certificates.html) -### Async +## Async ```python from httpx_pki import AsyncPKIClient @@ -399,206 +91,74 @@ async with AsyncPKIClient("client.p12", password="secret") as client: resp = await client.get("https://mtls.example.com/") ``` -### Passing httpx options +## One file, several certificates -Any extra keyword arguments flow straight through to the underlying httpx client: +A PKCS#12 or PEM bundle can hold more than one identity — a dual key pair from +AD key archival, or a renewed certificate kept beside the one it replaces. +`cryptography` can't express that: it returns the first key and leaves the other +identity's certificate looking like a chain certificate. `httpx-pki` reads the +structure itself, so you can inspect and select: ```python -PKIClient("client.p12", base_url="https://api.example.com", - headers={"User-Agent": "me"}, timeout=10.0, http2=True) -``` +from httpx_pki import PKIClient, list_identities, currently_valid -### Server trust (`verify`) +list_identities("corp.p12", password="secret") # see what's in there -Mounting *your* client certificate and verifying the *server's* certificate are -independent. `verify` behaves just like httpx2 — `True` (default, the -operating-system trust store), `False` to disable (with a warning), a path to -a CA bundle, or a ready-made `ssl.SSLContext` — plus two httpx-pki literals: -`"system"` (a synonym of `True`, kept from when the OS store was opt-in) and -`"certifi"` to pin the certifi CA bundle by name: - -```python -PKIClient("client.p12", verify="/etc/ssl/custom-ca.pem") +PKIClient("corp.p12", password="secret", key_usage="digital_signature") +PKIClient("corp.p12", password="secret", identity="Signature") +PKIClient("corp.p12", password="secret", identity=currently_valid) ``` -The CA-bundle path may be PEM or a certs-only **PKCS#7** bundle (`.p7b`, DER or -PEM) — handy when the private CA was exported from a Windows CA, which OpenSSL -itself can't read as a `cafile`. +Loading a multi-identity bundle without a selector raises rather than guessing. -#### The default: the OS trust store +→ [Choosing the right certificate](https://httpx-pki.readthedocs.io/en/stable/guide/choosing-a-certificate.html) -Since 0.8, `verify=True` verifies the server against the **operating-system -trust store** (Windows CryptoAPI / macOS Security framework / OpenSSL's system -CA paths on Linux), via the same -[truststore](https://truststore.readthedocs.io/) machinery httpx2 and pip use -by default. That's where private CAs distributed through your OS live (group -policy, MDM, a TLS-inspecting proxy) — the ones behind the classic -`CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate` right -after your client certificate loaded fine, which certifi has never heard of. -`verify="system"` remains as an explicit synonym from when the OS store was -opt-in; both spellings survive pickling, unlike a custom `ssl.SSLContext`. -(A CA-bundle *file* literally named `system` can still be passed as -`Path("system")`.) +## Server trust -#### Pinning certifi: `verify="certifi"` - -The certifi bundle — the default through 0.7, and still what the original -httpx uses for `verify=True` — remains available by name, for callers who want -exactly the bundled public CAs regardless of what the OS store holds: +Your client certificate and the server's are independent. `verify=True` (the +default) uses the **OS trust store**, so corporate CAs distributed by group +policy or MDM work out of the box: ```python +PKIClient("client.p12", password="secret", verify="/etc/ssl/internal-ca.pem") PKIClient("client.p12", password="secret", verify="certifi") ``` -Works with every constructor and `build_ssl_context`; `HTTPX_PKI_CA=certifi` -(or `system`) selects the corresponding trust for `from_env`. - -> **Passing your own `ssl.SSLContext`?** `httpx-pki` loads the client certificate -> into that exact object (it can't be copied), so don't reuse a shared context -> across clients — each load would overwrite the previous cert. You'll get a -> warning. Pass `verify=True` or a CA-bundle path to let `httpx-pki` build a -> dedicated context instead. +→ [Server trust](https://httpx-pki.readthedocs.io/en/stable/guide/server-trust.html) -Like httpx, contexts built by `httpx-pki` honor the `SSLKEYLOGFILE` environment -variable, logging TLS session keys to that file so a capture tool (e.g. -Wireshark) can decrypt the handshake — invaluable when debugging mTLS failures. -A context you pass in yourself is left untouched. +## Expiry and rotation -### Subclassing - -```python -class MyServiceSession(PKIClient): - def __init__(self, p12, **kwargs): - super().__init__(p12, base_url="https://service.internal", **kwargs) - - def health(self): - return self.get("/health").json() -``` - -### Inspecting the certificate - -```python -info = client.cert_info() -print(info.common_name, info.not_after, info.subject_alt_names) -print(info.dns_names) # just the dNSName SANs, for hostname checks -print(info.issuer_common_name) # who signed it (issuer_distinguished_name for the full DN) -print(info.serial_number_hex) # audit logging (serial_number for the raw int) -print(info.fingerprint_sha256) # uppercase hex, no separators -``` - -`subject_alt_names` lists every SAN entry as a string (DNS names, IP addresses, -email addresses, URIs); `dns_names` is the dNSName subset. - -`fingerprint_sha1` is also available, in the same format the platform stores -use for thumbprints — so it can be compared against -`list_windows_certificates()` / `list_macos_certificates()` output or passed -straight to a `thumbprint=` selector. - -### Expiry awareness - -An expired (or not-yet-valid) client certificate is the most common silent mTLS -failure. Loading one **warns** immediately, and the session exposes its validity -window so you can check before you depend on it: - -```python -client.is_expired # bool -client.is_not_yet_valid # bool -client.expires_in # timedelta (negative once expired) -client.not_valid_after # datetime (UTC) -``` - -Pass `warn_if_expires_within=` (accepted by every constructor, `from_*` -included) to be told about a cert that's about to roll over, and call -`check_validity()` to turn "not currently usable" into a hard error: +Certificates keep getting shorter-lived. Warn early, reload automatically, or +fail loudly: ```python from datetime import timedelta -from httpx_pki import PKIClient, CertificateExpiredError - -client = PKIClient("client.p12", password="secret", - warn_if_expires_within=timedelta(days=14)) - -client.check_validity() # raises if expired / not yet valid -client.check_validity(within=timedelta(days=7)) # also raises if it expires soon -``` - -(`check_validity` raises `CertificateExpiredError` or `CertificateNotYetValidError`.) -### Filtering warnings - -Every warning `httpx-pki` emits carries a filterable category, all subclasses of -`PKIWarning` (itself a `UserWarning`): `CertificateValidityWarning` (expired / -not yet valid / expiring soon), `TLSConfigWarning` (a TLS configuration that -likely doesn't do what was intended, e.g. a custom transport that drops the -client cert, or `verify=False`), and `PicklingWarning` (configuration dropped -during pickling). Silence one concern without hiding the others: - -```python -import warnings -from httpx_pki import CertificateValidityWarning - -warnings.filterwarnings("ignore", category=CertificateValidityWarning) +PKIClient( + "/etc/certs/client.pem", + auto_reload=True, # pick up cert-manager rotations + strict_validity=True, # fail clearly, not at handshake + warn_if_expires_within=timedelta(days=7), +) ``` -### Certificate rotation (hot reload) - -Client certificates keep getting shorter-lived — cert-manager renews a mounted -Secret at two-thirds of its lifetime, Vault PKI issues certs measured in hours — -but a session snapshots its certificate at construction. Without rotation -support, a long-running process presents the stale cert until handshakes start -failing, and the only fix is a restart. +→ [Expiry and rotation](https://httpx-pki.readthedocs.io/en/stable/guide/expiry-and-rotation.html) -`reload()` re-reads the certificate source (file, `from_env` variables, or the -Windows store) and swaps the fresh certificate into the mounted SSL context -**in place**, so new handshakes — on every transport sharing the context — -present it immediately: +## Inspecting what's mounted ```python -client = PKIClient("/etc/certs/client.pem") -# ... /etc/certs/client.pem is rotated by cert-manager ... -client.reload() +client.cn # 'corp-user' +client.not_valid_after # datetime (UTC) +client.is_expired # bool +client.cert_info() # CertInfo: subject, issuer, fingerprints, usages, SANs ``` -Or let the session watch for you — `auto_reload` stats the source files before -a request (throttled, default at most once per second) and reloads when they -change: - -```python -from datetime import timedelta +→ [Inspecting a certificate](https://httpx-pki.readthedocs.io/en/stable/guide/inspecting-a-certificate.html) -client = PKIClient("/etc/certs/client.pem", auto_reload=True) -client = PKIClient("/etc/certs/client.pem", auto_reload=timedelta(seconds=30)) -``` +## Just the SSL context -`strict_validity=True` completes the picture: every request is preceded by -`check_validity()`, so a certificate that expired anyway fails with a clear -`CertificateExpiredError` *before* the connection is attempted, instead of an -opaque OpenSSL handshake error. - -Semantics worth knowing: - -- The swap is atomic: if the rotated file is unreadable or garbage, `reload()` - raises `CertificateLoadError` and the previous certificate keeps serving. - With `auto_reload` the error surfaces on the triggering request and is - retried on the next one. -- Connections already established keep the certificate they handshook with - until they close (TLS has no mid-connection re-authentication); only new - connections present the rotated cert. -- Rotation tooling should replace files atomically (write-then-rename), which - kubelet and cert-manager already do. -- `auto_reload` requires a filesystem source to watch — construction from - in-memory bytes or the Windows store raises `TypeError` (the store can still - be re-exported with a manual `reload()`). -- If the source is password-protected, enabling `auto_reload` retains the - password on the session so unattended reloads can decrypt it (see the - security note below). Without `auto_reload` no password is retained; pass - one explicitly to a manual reload: `client.reload(password="secret")`. - -### Just the SSL context - -Don't want the session wrapper? `build_ssl_context` gives you the hard part — a -ready `ssl.SSLContext` with the client certificate mounted — to use with a plain -`httpx.Client`, an httpx transport, or anything else that accepts a context: +Don't want the client wrapper? `build_ssl_context()` gives you the hard part, +ready for a plain `httpx.Client` or a custom transport: ```python import httpx @@ -608,195 +168,75 @@ ctx = build_ssl_context("client.p12", password="secret") client = httpx.Client(verify=ctx) ``` -`build_windows_ssl_context` is the same seam for the Windows store — it selects a -certificate exactly like `from_windows_cert_store` (`name` / `thumbprint` / -`predicate`) but hands back the `ssl.SSLContext` instead of a session, so you can -mount a store cert on your own transport without building a client first: - -```python -from httpx_pki import build_windows_ssl_context - -ctx = build_windows_ssl_context(predicate=lambda c: c.friendly_name == "prod") -``` - -### Custom transports (e.g. `httpx-retries`) - -`httpx-pki` is fully compatible with libraries that supply a custom transport, -such as [`httpx-retries`](https://github.com/will-ockmore/httpx-retries) — but -there is one **httpx rule** to know, and it is not specific to this library: - -> Whenever you pass a custom `transport=` (or `mounts=`) to an httpx client, httpx -> uses that transport **as-is** and ignores the client-level `verify=`/`cert=`. -> The TLS configuration — including your client certificate — must live on the -> transport itself. - -So the client certificate has to be mounted on the **inner** transport that the -retry transport wraps. `build_ssl_context()` is exactly that seam: - -```python -import httpx -from httpx_pki import build_ssl_context -from httpx_retries import RetryTransport, Retry - -# ✅ WORKS — the cert lives on the inner transport the retry layer wraps -ctx = build_ssl_context("client.p12", password="secret", verify="/etc/ssl/ca.pem") -transport = RetryTransport(transport=httpx.HTTPTransport(verify=ctx), - retry=Retry(total=5)) -client = httpx.Client(transport=transport) # mTLS + retries -resp = client.get("https://mtls.example.com/") -``` +⚠️ Passing a custom `transport=` makes httpx ignore `verify=` — put the context +on the **inner** transport, not the client. -```python -# ❌ DOES NOT mount the cert — the custom transport makes httpx ignore verify=, -# so no client certificate is presented and the handshake fails. -from httpx_pki import PKIClient -from httpx_retries import RetryTransport +→ [Advanced usage](https://httpx-pki.readthedocs.io/en/stable/guide/advanced.html) -client = PKIClient("client.p12", password="secret", - transport=RetryTransport()) # cert silently dropped! -``` +## Testing helpers -If you specifically want your `PKIClient` *subclass* (its methods, `base_url`, -`cert_info()`, ...) **and** retries, give that subclass the same inner transport. -Its own `verify=` is ignored (the transport wins), but the rest of its behavior -is preserved: +`httpx_pki.testing` mints throwaway certificates, including multi-identity +bundles that nothing else readily produces: ```python -ctx = build_ssl_context("client.p12", password="secret") -inner = httpx.HTTPTransport(verify=ctx) -client = PKIClient("client.p12", password="secret", - transport=RetryTransport(transport=inner, retry=Retry(total=5))) -``` - -The same rule applies to any custom-transport library and to hand-built -`mounts=` — put the TLS config on the transport, not on the client. - -### Mismatched key / cert - -When you build from a separate key and certificate (`from_key_pair` or a PEM -bundle), `httpx-pki` checks that the private key actually matches the certificate -and raises `CertificateLoadError` up front, instead of letting it surface later as -an opaque OpenSSL handshake error. - -### Testing helpers - -`httpx_pki.testing` mints throwaway certificates so your own test suites don't -have to re-derive the `cryptography` boilerplate: - -```python -from httpx_pki import PKIClient from httpx_pki.testing import make_ca, make_client_cert ca = make_ca() bundle = make_client_cert("svc-client", ca=ca, dns_names=["svc.internal"]) - -with PKIClient(bundle.pkcs12(), password=b"") as client: - assert client.cn == "svc-client" - -expired = make_client_cert("old", ca=ca, expired=True) # for expiry tests +expired = make_client_cert("old", ca=ca, expired=True) ``` -Minted certificates carry the extensions a real CA would issue — a -`digitalSignature`/`keyEncipherment` KeyUsage and a `clientAuth` ExtendedKeyUsage — -so servers that enforce EKU accept them. Override either with `key_usage=` / -`extended_key_usage=`. - -`make_pkcs12` writes several identities into one bundle, which nothing else can -do — `cryptography` and the `openssl` command line both keep a single key — so -you can test how your code handles a dual key pair: - -```python -from httpx_pki.testing import make_ca, make_client_cert, make_pkcs12 - -ca = make_ca() -signing = make_client_cert("me", ca=ca, key_usage=["digital_signature"]) -encryption = make_client_cert("me", ca=ca, key_usage=["key_encipherment"]) - -blob = make_pkcs12( - [(signing, "Signature"), (encryption, "Encryption")], password="secret" -) -``` - -The bundle is laid out the way OpenSSL and Windows write one (certificates in a -PBES2-encrypted block, each key individually shrouded, an HMAC over the whole -file); pass `encrypt_certs=False`, `mac=False`, or an empty password for the -plainer variants. +→ [Testing helpers](https://httpx-pki.readthedocs.io/en/stable/guide/testing.html) ## ⚠️ Security note on pickling -To support pickling, the session stores its certificate material and -reconstructs the live SSL context on unpickle. **The pickle therefore contains -the decrypted private key in cleartext.** Treat a pickled session as a secret: -do not write it to untrusted storage or transmit it over untrusted channels. -`repr()` never reveals key material. +To support pickling, a client stores its certificate material and rebuilds the +SSL context on unpickle. **The pickle therefore contains the decrypted private +key in cleartext** — treat it as a secret. `repr()` never reveals key material. -The source password is never retained — with one exception: enabling -`auto_reload` keeps it on the session (and in its pickles, which already carry -the decrypted key) so unattended reloads can decrypt the rotated source. +Passwords are not retained, with one exception: enabling `auto_reload` keeps the +password on the client so unattended reloads can decrypt the rotated source. -A custom `ssl.SSLContext` passed as `verify=` cannot be pickled; an unpickled -session falls back to default server verification (with a warning). A -certificate source that cannot be pickled (e.g. a Windows-store `predicate` -lambda) is dropped with a warning — the unpickled session works but cannot -`reload()`. +→ [Security notes](https://httpx-pki.readthedocs.io/en/stable/about/security.html) +· [SECURITY.md](https://github.com/ccbest/httpx-pki/blob/main/SECURITY.md) ## How it works -Python's stdlib `ssl` cannot load PKCS#12 or in-memory key material — only cert -chains from file paths. So `httpx-pki` uses +Stdlib `ssl` can't load PKCS#12 or in-memory key material, so `httpx-pki` uses [`cryptography`](https://cryptography.io/) to extract the key and certificates, -stages them somewhere OpenSSL can read, and passes the resulting -`ssl.SSLContext` to httpx via `verify=` (the recommended path since httpx 0.28). - -**On Linux, the decrypted key never touches disk**: the material is staged in -an anonymous in-memory file (`memfd_create`) that OpenSSL reads via -`/proc/self/fd`, and that ceases to exist the moment it's closed — nothing to -unlink, nothing for a crash to leave behind, nothing for a temp-directory -sweeper to catch. This matters most with `auto_reload`, where the key is -re-staged on every certificate rotation. On other platforms — or in a rare -Linux sandbox where memfd or procfs is unavailable — the material lands in a -`0600` temporary PEM file just long enough for OpenSSL to read it, then is -deleted. +stages them where OpenSSL can read them, and passes the resulting +`ssl.SSLContext` to httpx via `verify=`. + +**On Linux the decrypted key never touches disk** — it's staged in an anonymous +`memfd` that OpenSSL reads through `/proc/self/fd` and that ceases to exist when +closed. Elsewhere it's a `0600` temp file, deleted immediately after loading. + +→ [How it works](https://httpx-pki.readthedocs.io/en/stable/about/how-it-works.html) ## Non-goals -`httpx-pki` is scoped to credentials whose private key can be exported into -memory. Some adjacent things it deliberately does **not** do: - -- **PKCS#11, smartcards, HSMs, TPMs, and other non-exportable keys** - (YubiKeys, CAC/PIV cards, Windows keys marked non-exportable, Secure - Enclave). These are fundamentally incompatible with Python's `ssl` module, - which must hold the raw key bytes and offers no way to delegate the - handshake signature to external hardware. No library built on stdlib `ssl` - can support them; you need an OpenSSL PKCS#11 provider configured outside - Python. -- **Java keystores (JKS/JCEKS).** Java itself moved to PKCS#12 as its default - keystore format (Java 9+). Convert once, then use the result directly: - `keytool -importkeystore -srckeystore client.jks -destkeystore client.p12 - -deststoretype PKCS12` -- **Workload-identity protocol clients (SPIFFE/SPIRE, Vault agent, - cert-manager).** All of these can materialize rotating PEM or PKCS#12 files, - which [`auto_reload`](#certificate-rotation-hot-reload) already handles — a - protocol integration would add heavy dependencies for no new capability. -- **OCSP / CRL revocation checking.** Stdlib `ssl` provides nothing to build - on. `verify="system"` delegates verification to the OS on Windows and macOS, - where the platform verifier applies its own revocation policy; beyond that, - revocation is out of scope. +Scoped to credentials whose private key can be exported into memory. Not +supported: **PKCS#11 / smartcards / HSMs / TPMs** (incompatible with stdlib +`ssl`, which needs the raw key bytes), **Java keystores** (convert to PKCS#12 +with `keytool`), **workload-identity protocol clients** (point `auto_reload` at +the files they write), and **OCSP / CRL revocation** (nothing in stdlib `ssl` to +build on). + +→ [Non-goals](https://httpx-pki.readthedocs.io/en/stable/about/non-goals.html) ## Supply chain -A library that handles client private keys deserves scrutiny of how it is -built and shipped. Releases are published to PyPI exclusively from GitHub -Actions via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) -(OIDC — no long-lived PyPI tokens) with [PEP 740](https://peps.python.org/pep-0740/) -attestations, from a tagged commit whose version is verified against the -package's `__version__` at build time. The runtime dependency footprint is -limited to `httpx`, `cryptography`, and `certifi`. As with any -security-sensitive dependency, install via a lockfile that records hashes -(uv, poetry, or `pip-tools` with `pip install --require-hashes`). - -See [SECURITY.md](https://github.com/ccbest/httpx-pki/blob/main/SECURITY.md) -for the full policy and how to report a vulnerability. +Released to PyPI exclusively from GitHub Actions via +[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no +long-lived tokens) with [PEP 740](https://peps.python.org/pep-0740/) +attestations, from a tagged commit whose version is verified against +`__version__` at build time. All Actions are pinned to full commit SHAs. + +Install via a lockfile that records hashes, as with any security-sensitive +dependency. + +→ [Supply chain](https://httpx-pki.readthedocs.io/en/stable/about/supply-chain.html) +· [Changelog](https://github.com/ccbest/httpx-pki/blob/main/CHANGELOG.md) ## License diff --git a/SECURITY.md b/SECURITY.md index ec2fe95..ddb31ff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,10 +27,12 @@ Reports especially welcome (non-exhaustive): - Supply-chain issues with the release pipeline described below. Already-documented behavior — e.g. that a pickled session contains the -decrypted private key (see the README's security note) — is not a +decrypted private key (see the [security notes][sec]) — is not a vulnerability by itself, but ways to *exploit* such behavior beyond what is documented are in scope. +[sec]: https://httpx-pki.readthedocs.io/en/stable/about/security.html + ## Supported versions Only the **latest release** receives security fixes. There are no maintenance @@ -49,8 +51,8 @@ How releases are produced, so you can decide what to trust: `__version__` before building, so a release is auditable to one commit. - All GitHub Actions used by CI and release workflows are pinned to full commit SHAs. -- Runtime dependencies are limited to `httpx`, `cryptography`, and `certifi` - (plus the optional `truststore` extra). +- Runtime dependencies are limited to `httpx2`, `cryptography`, `truststore`, + and `certifi`. There are no optional runtime dependencies. As a consumer, install with a lockfile that records hashes (uv, poetry, or `pip-tools` + `pip install --require-hashes`) — as you would for any diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/about/changelog.md b/docs/about/changelog.md new file mode 100644 index 0000000..3139cd4 --- /dev/null +++ b/docs/about/changelog.md @@ -0,0 +1,2 @@ +```{include} ../../CHANGELOG.md +``` diff --git a/docs/about/how-it-works.md b/docs/about/how-it-works.md new file mode 100644 index 0000000..892d53d --- /dev/null +++ b/docs/about/how-it-works.md @@ -0,0 +1,78 @@ +# How it works + +Python's standard-library `ssl` module cannot load PKCS#12, and cannot load key +material from memory at all — it reads certificate chains from file paths and +nothing else. Everything httpx-pki does follows from working around that one +limitation without ever writing your private key somewhere it can linger. + +## The path from bundle to handshake + +1. **Parse.** [`cryptography`](https://cryptography.io/en/latest/) extracts the private + key, the leaf certificate, and any chain certificates from whatever you + supplied — PKCS#12, PEM, separate files, or an export from an OS store. +2. **Select.** If the source holds several identities, the requested one is + chosen and the rest are discarded rather than mistaken for chain + certificates. See [](../guide/choosing-a-certificate.md#why-httpx-pki-parses-these-itself). +3. **Stage.** The material is put somewhere OpenSSL can read it — see below. +4. **Load.** An `ssl.SSLContext` is built, server trust configured per + `verify=`, and the client certificate loaded into it. +5. **Mount.** That context is handed to httpx as `verify=`, the supported path + since httpx 0.28. + +The client keeps the context, which is what makes +[hot reload](../guide/expiry-and-rotation.md) possible: a rotated certificate is +swapped into the same object in place, so every transport already holding it +picks up the change. + +## Staging: the key never touches disk on Linux + +Step 3 is the interesting one, because OpenSSL insists on a file path. + +**On Linux**, the material is staged in an anonymous in-memory file created +with `memfd_create`, which OpenSSL reads through `/proc/self/fd`. That file has +no name in any directory and ceases to exist the moment it is closed — nothing +to unlink, nothing for a crash to leave behind, nothing for a temp-directory +sweeper to find. + +This matters most with `auto_reload`, where the key is re-staged on every +rotation. Over a long-lived process that is a lot of opportunities to leave key +material lying around, and none of them do. + +**On other platforms** — or in a rare Linux sandbox where `memfd` or `procfs` +is unavailable — the material lands in a `0600` temporary PEM file just long +enough for OpenSSL to read it, and is then deleted. + +## Why PKCS#12 parsing is hand-rolled + +`cryptography` returns a single private key from a PKCS#12 file, so a bundle +holding two identities loses one of them and leaves its certificate looking like +a chain certificate. httpx-pki reads the key bags itself and pairs keys to +certificates by public key. The full explanation, with what `cryptography` +actually returns, is in +[](../guide/choosing-a-certificate.md#why-httpx-pki-parses-these-itself). + +`cryptography` still does all the cryptographic work — decrypting the file's +encrypted portions, parsing certificates, deserializing keys. What httpx-pki +adds is the structural read that tells one identity from another. + +## The modules + +For anyone reading the source: + +| Module | | +| --- | --- | +| `_compat` | Resolves the httpx2 / httpx backend at import | +| `_material` | The canonical `Material` — key, leaf, chain — and PEM handling | +| `_pkcs12` | Reading identities out of a PKCS#12 bundle | +| `_select` | The identity selectors shared by files and both OS stores | +| `_ssl` | Building the `ssl.SSLContext`, staging, and `verify=` | +| `_mixin` | Everything the client classes share: constructors, reload, validity | +| `_client` | The two public classes, binding the mixin to its httpx base | +| `_winstore` / `_keychain` | The OS certificate stores | +| `_env` | Reading configuration out of the environment | + +## Next steps + +- [](security.md) — what all this means for your key material +- [](non-goals.md) — what deliberately does not work this way +- [](../guide/index.md) — the user guide diff --git a/docs/about/non-goals.md b/docs/about/non-goals.md new file mode 100644 index 0000000..33daf2a --- /dev/null +++ b/docs/about/non-goals.md @@ -0,0 +1,58 @@ +# Non-goals + +httpx-pki is scoped to credentials whose private key **can be exported into +memory**. That is a real boundary, not a roadmap gap, and this page exists so +you can rule the library out quickly. + +## Non-exportable keys: PKCS#11, smartcards, HSMs, TPMs + +YubiKeys, CAC/PIV cards, Windows keys marked non-exportable, the Secure +Enclave — none of these work, and none can be made to. + +These are fundamentally incompatible with Python's `ssl` module, which must +hold the raw key bytes and offers no way to delegate the handshake signature to +external hardware. That is a limitation of the standard library, not of this +library: **no package built on stdlib `ssl` can support them.** + +**What to use instead:** an OpenSSL PKCS#11 provider configured outside Python, +so the handshake signature happens in the hardware. + +## Java keystores (JKS / JCEKS) + +Not supported, because Java itself moved on — PKCS#12 has been the default +keystore format since Java 9. + +**What to use instead:** convert once, then use the result directly. + +```console +$ keytool -importkeystore -srckeystore client.jks \ + -destkeystore client.p12 -deststoretype PKCS12 +``` + +## Workload-identity protocol clients + +No SPIFFE/SPIRE, Vault agent, or cert-manager integration. + +All of these already materialize rotating PEM or PKCS#12 files, which +[`auto_reload`](../guide/expiry-and-rotation.md) handles. A protocol +integration would add heavy dependencies for no new capability. + +**What to use instead:** point httpx-pki at the file the agent writes. + +```python +PKIClient("/var/run/secrets/workload/client.pem", auto_reload=True) +``` + +## OCSP / CRL revocation checking + +Stdlib `ssl` provides nothing to build on, so httpx-pki does not attempt it. + +**Partial exception:** `verify=True` delegates verification to the OS on +Windows and macOS, where the platform verifier applies its own revocation +policy. Beyond that, revocation is out of scope. See +[](../guide/server-trust.md). + +## Next steps + +- [](how-it-works.md) — why these boundaries fall where they do +- [](../guide/index.md) — what httpx-pki *does* do diff --git a/docs/about/security.md b/docs/about/security.md new file mode 100644 index 0000000..d2f314b --- /dev/null +++ b/docs/about/security.md @@ -0,0 +1,105 @@ +# Security notes + +httpx-pki handles client private keys and the passwords protecting them. This +page collects everything about where that material lives and how long it stays +there, so nothing here should be a surprise later. + +To report a vulnerability, see +[SECURITY.md](https://github.com/ccbest/httpx-pki/blob/main/SECURITY.md) — +please **do not open a public issue** for anything security-sensitive. + +## The key stays in memory + +The decrypted private key is never written to a file you can find. On Linux it +is staged in an anonymous `memfd` that ceases to exist when closed; elsewhere it +is a `0600` temporary file that exists only for as long as OpenSSL needs to read +it. See [](how-it-works.md#staging-the-key-never-touches-disk-on-linux). + +`repr()` never reveals key material. + +## Pickling a client embeds the decrypted key + +:::{danger} +To support pickling, a client stores its certificate material and rebuilds the +SSL context on unpickle. **The pickle therefore contains the decrypted private +key in cleartext.** + +Treat a pickled client exactly as you would treat the key itself: never write +one to untrusted storage, send it over an untrusted channel, cache it, or log +it. +::: + +Two things are *not* carried across, both with a `PicklingWarning`: + +- a custom `ssl.SSLContext` passed as `verify=` — the unpickled client falls + back to default server verification +- an unpicklable certificate source, such as a Windows-store `identity` + lambda — the unpickled client works but cannot `reload()` + +The second is a quiet weakening if you pickled the client precisely to carry a +restrictive trust configuration into a worker process. See +[](../guide/server-trust.md#pickling-drops-a-custom-context). + +## Passwords are not retained — with one exception + +The source password is discarded after the material is loaded. Reloading a +password-protected source requires passing it again: + +```python +client.reload(password="secret") +``` + +**Enabling `auto_reload` changes that.** An unattended reload has no other way +to decrypt a rotated source, so the password is retained on the client for its +lifetime — and appears in its pickles, which already carry the decrypted key. + +That is a deliberate trade, not an oversight. If it is not one you want, reload +manually and pass the password each time. See +[](../guide/expiry-and-rotation.md#passwords-and-unattended-reloads). + +## `SSLKEYLOGFILE` decrypts your traffic + +Contexts httpx-pki builds honor the standard `SSLKEYLOGFILE` variable, writing +TLS session keys where a capture tool can use them to decrypt the handshake. +That is exactly what it is for when debugging — and exactly why it must never +be set in production. A context you passed in yourself is left untouched. + +See [](../guide/server-trust.md#debugging-with-sslkeylogfile). + +## Configurations that look safe and are not + +Three warnings describe setups that run without error while failing to do what +they appear to do. They are worth reading rather than silencing: + +- **`verify=False`** — no server verification, so a client certificate is + presented to an endpoint whose identity was never established +- **A shared `ssl.SSLContext`** — two clients silently end up presenting the + same identity, which + [changes who a request authenticates as](../guide/server-trust.md#sharing-a-context-swaps-the-identity-on-the-wire) +- **A custom `transport=`** — httpx ignores `verify=`, so no client certificate + is presented at all + +Promoting `TLSConfigWarning` to an error in CI catches all three. See +[](../reference/exceptions.md#making-them-fatal). + +## What is in scope for a report + +Reports are especially welcome for: + +- private-key or password material leaking anywhere unintended — disk, logs, + `repr()`, warnings, exception messages, or living longer than documented +- server-verification bypasses: any way a certificate is accepted that + `verify=`'s documented semantics say should be rejected +- flaws in the platform-store integrations +- supply-chain issues with the release pipeline — see [](supply-chain.md) + +Already-documented behavior, such as a pickled client containing the decrypted +key, is not a vulnerability by itself. Ways to *exploit* such behavior beyond +what is documented are in scope. + +Only the **latest release** receives security fixes. + +## Next steps + +- [](supply-chain.md) — how releases are built and verified +- [](how-it-works.md) — where key material actually goes diff --git a/docs/about/supply-chain.md b/docs/about/supply-chain.md new file mode 100644 index 0000000..7538f9b --- /dev/null +++ b/docs/about/supply-chain.md @@ -0,0 +1,73 @@ +# Supply chain + +A library that handles client private keys deserves scrutiny of how it is built +and shipped. This page describes how releases are produced, so you can decide +what to trust. + +## How a release is made + +- **Published exclusively from GitHub Actions**, from a tagged commit in the + repository, via [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) + (OIDC). **No long-lived PyPI tokens exist**, so there is no publishing + credential to steal. +- **[PEP 740](https://peps.python.org/pep-0740/) digital attestations** are + generated for every artifact. Provenance is shown per file at + [pypi.org/project/httpx-pki](https://pypi.org/project/httpx-pki/#files). +- **The tag is verified against the code.** The workflow checks that the release + tag matches the package's `__version__` before building, so every release is + auditable to exactly one commit. +- **All GitHub Actions are pinned to full commit SHAs**, not tags. Tags are + mutable and have been repointed at malicious commits in real supply-chain + attacks. Dependabot keeps the pins current. + +## Runtime dependencies + +The footprint is deliberately small: + +| Package | Why | +| --- | --- | +| [httpx2](https://github.com/pydantic/httpx2) | The HTTP client being extended | +| [cryptography](https://cryptography.io/en/latest/) | Parsing and decrypting certificate material | +| [truststore](https://truststore.readthedocs.io/en/latest/) | The OS trust store behind `verify=True` | +| [certifi](https://github.com/certifi/python-certifi) | The bundle behind `verify="certifi"` | + +There are no optional runtime dependencies. See [](../install.md). + +## What you should do + +Install with a lockfile that records hashes — as you would for any +security-sensitive dependency: + +::::{tab-set} + +:::{tab-item} uv +```console +$ uv lock +$ uv sync --locked +``` +::: + +:::{tab-item} Poetry +```console +$ poetry lock +$ poetry install +``` +::: + +:::{tab-item} pip-tools +```console +$ pip-compile --generate-hashes +$ pip install --require-hashes -r requirements.txt +``` +::: + +:::: + +To verify a release yourself, check the attestations on the PyPI file listing +against the tagged commit in the repository. + +## Next steps + +- [](security.md) — how key material is handled, and how to report an issue +- [SECURITY.md](https://github.com/ccbest/httpx-pki/blob/main/SECURITY.md) — + the full policy diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..c4f5459 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,112 @@ +"""Sphinx configuration for the httpx-pki documentation. + +The docs are authored in Markdown (MyST) but the package docstrings are +reStructuredText -- ``:class:``/``:meth:`` roles and ``::`` literal blocks -- +so autodoc reads them natively and intersphinx turns the references to +``ssl``, ``cryptography`` and the standard library into working links. +""" + +from __future__ import annotations + +from httpx_pki import __version__ + +# -- Project ---------------------------------------------------------------- + +project = "httpx-pki" +author = "Carl Best" +copyright = "2026, Carl Best" # noqa: A001 + +# Single-sourced from httpx_pki/__init__.py, the same place pyproject's +# dynamic version and the publish.yml tag check read it from. +release = __version__ +version = ".".join(__version__.split(".")[:2]) + +# -- General ---------------------------------------------------------------- + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx_copybutton", + "sphinx_design", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# Warn about cross-references that do not resolve. Paired with +# fail_on_warning in .readthedocs.yaml, a typo'd :class: role fails the build +# instead of silently rendering as plain text. +nitpicky = True + +# Everything below is unresolvable for a structural reason, not a typo. Keep +# these patterns tight: the point of nitpicky is that a genuine broken +# reference still fails the build. +nitpick_ignore_regex = [ + # Neither httpx2 nor httpx publishes an objects.inv, so their types cannot + # be linked. Drop this entry (and add an intersphinx mapping) if that + # changes -- these are the annotations users most want to follow. + (r"py:.*", r"^httpx2?\..*"), + # Private names that leak into public signatures: the _S TypeVar the + # from_* classmethods return, _PKIMixin, _CertDetails, and friends. They + # are intentionally undocumented. + (r"py:.*", r"^_[A-Za-z_]*$"), + (r"py:.*", r"^httpx_pki\..*\._[A-Za-z_]*$"), + # cryptography annotates with the public x509.Certificate but the runtime + # class lives in the Rust bindings, which its objects.inv does not carry. + (r"py:.*", r"^cryptography\.hazmat\.bindings\._rust\..*"), +] + +# -- MyST ------------------------------------------------------------------- + +myst_enable_extensions = [ + "colon_fence", # ::: fences, so directives nest inside Markdown + "deflist", # definition lists for option/flag tables + "attrs_inline", # {.class} attributes on inline elements + "substitution", # |version|-style substitutions + "linkify", # bare URLs become links +] +myst_heading_anchors = 3 # #anchor links for h1-h3, so deep links survive +myst_substitutions = {"version": release} + +# -- autodoc ---------------------------------------------------------------- + +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_typehints_description_target = "documented_params" +autodoc_class_signature = "separated" +autodoc_preserve_defaults = True +autodoc_default_options = { + "members": True, + "show-inheritance": True, +} + +# -- intersphinx ------------------------------------------------------------ + +# httpx2 is deliberately absent: it does not publish an objects.inv, so a +# mapping entry would only produce fetch warnings. httpx.* references are +# suppressed via nitpick_ignore above; add a mapping here if that changes. +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "cryptography": ("https://cryptography.io/en/latest/", None), +} + +# -- linkcheck -------------------------------------------------------------- + +# PyPI renders the per-file listing client-side, so linkcheck cannot see the +# #files anchor even though the link is good. Check the page, not the anchor. +linkcheck_anchors_ignore_for_url = [ + r"https://pypi\.org/project/httpx-pki/", +] + +# -- HTML output ------------------------------------------------------------ + +html_theme = "furo" +html_static_path = ["_static"] +html_title = f"httpx-pki {release}" +html_theme_options = { + "source_repository": "https://github.com/ccbest/httpx-pki/", + "source_branch": "main", + "source_directory": "docs/", +} diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md new file mode 100644 index 0000000..7f3db0e --- /dev/null +++ b/docs/guide/advanced.md @@ -0,0 +1,231 @@ +# Advanced usage + +## Subclassing + +`PKIClient` and `AsyncPKIClient` are ordinary httpx clients, so wrapping your +service's conventions around one works exactly as you would expect: + +```python +from httpx_pki import PKIClient + + +class MyServiceSession(PKIClient): + def __init__(self, p12, **kwargs): + super().__init__(p12, base_url="https://service.internal", **kwargs) + + def health(self): + return self.get("/health").json() +``` + +Everything the base class offers — `cert_info()`, `reload()`, the validity +properties, context-manager support, pickling — is inherited. + +### Extra constructor keywords: `_init_state()` + +Extending `__init__` as above is fine for baking in fixed httpx settings, but +it is **not** enough when your subclass takes keywords of its own, because two +paths build a session without ever calling `__init__`: the `from_*` alternate +constructors (which return your subclass — `MyServiceSession.from_env()` types +and behaves as a `MyServiceSession`) and unpickling. State set only in +`__init__` would be missing on both. + +The supported seam is the `_init_state()` hook. It runs exactly once on +**every** construction path, receiving the constructor's extra keyword dict +before it is forwarded to httpx. Pop your keywords out and set your +attributes; pop with a default so the attributes exist even when the caller +passed nothing: + +```python +from httpx_pki import PKIClient + + +class ProxiedSession(PKIClient): + def _init_state(self, kwargs): + self.proxy_url = kwargs.pop("proxy_url", None) + self.do_not_proxy = kwargs.pop("do_not_proxy", ()) + + +ProxiedSession("client.p12", password="secret", proxy_url="http://proxy:3128") +ProxiedSession.from_env() # hook still runs; defaults apply +ProxiedSession.from_pkcs12( # extras pass through any from_* + "client.p12", "secret", proxy_url="http://proxy:3128" +) +``` + +Rules of the road: + +- **Anything you leave in the dict goes to httpx**, so an unclaimed keyword + still fails loudly with a `TypeError` — you only bypass httpx's checking for + the keywords you pop. +- **Pickling is automatic.** The keyword set is snapshotted before the hook + pops it, and unpickling re-runs the hook with the original keywords, so + state set here survives a pickle round trip — as long as the values are + picklable. (Like the rest of the pickle behavior, this restores the session + *as constructed*; later mutations of those attributes are not captured.) +- **`reload()` and `auto_reload` do not re-run the hook** — rotation swaps + certificate material in place and leaves your state alone. +- **Do not touch other session state in the hook.** It runs mid-construction, + before the httpx base class is initialized. +- **Chain in grandchildren** with `super()._init_state(kwargs)`. + +## Just the SSL context + +If you do not want the client wrapper, `build_ssl_context()` gives you the hard +part on its own: a ready {py:class}`ssl.SSLContext` with the client certificate +mounted, for a plain `httpx.Client`, a transport, or anything else that accepts +a context. + +```python +import httpx +from httpx_pki import build_ssl_context + +ctx = build_ssl_context("client.p12", password="secret") +client = httpx.Client(verify=ctx) +``` + +It takes the same `verify=` values and the same identity selectors as the +constructors: + +```python +ctx = build_ssl_context( + "corp.p12", + password="secret", + verify="/etc/ssl/ca.pem", + key_usage="digital_signature", +) +``` + +`build_windows_ssl_context()` and `build_macos_ssl_context()` are the same seam +for the OS stores, selecting exactly as their `from_*` constructors do: + +```python +from httpx_pki import build_windows_ssl_context + +ctx = build_windows_ssl_context(identity=lambda c: c.friendly_name == "prod") +``` + +:::{warning} +A context built this way is yours alone — do not reuse one across several +clients. See +[](server-trust.md#sharing-a-context-swaps-the-identity-on-the-wire). +::: + +(custom-transports)= +## Custom transports + +httpx-pki works with libraries that supply their own transport — retries, +caching, instrumentation — but there is one **httpx rule** to know first, and it +is not specific to this library: + +:::{important} +When you pass a custom `transport=` (or `mounts=`) to an httpx client, httpx +uses that transport **as-is** and ignores the client-level `verify=` / `cert=`. +The TLS configuration — including your client certificate — must live on the +transport itself. +::: + +So passing a custom transport to `PKIClient` silently drops the certificate. +httpx-pki notices and warns: + +```python +# ❌ The certificate is NOT mounted +from httpx_pki import PKIClient +from httpx_retries import RetryTransport + +client = PKIClient("client.p12", password="secret", transport=RetryTransport()) +``` + +```text +TLSConfigWarning: a custom transport=/mounts= makes httpx ignore verify=, so +the client certificate is NOT mounted on this session. Build the context with +build_ssl_context() and put it on the inner transport instead, e.g. +httpx.HTTPTransport(verify=ctx). +``` + +The request then fails at the handshake, because the server asked for a +certificate that was never presented: + +```text +ReadError: [SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate required +``` + +### Putting the certificate on the inner transport + +`build_ssl_context()` is exactly the seam for this. Mount the context on the +**inner** transport that the custom one wraps: + +```python +# ✅ The certificate lives on the inner transport +import httpx +from httpx_pki import build_ssl_context +from httpx_retries import RetryTransport, Retry + +ctx = build_ssl_context("client.p12", password="secret", verify="/etc/ssl/ca.pem") + +transport = RetryTransport( + transport=httpx.HTTPTransport(verify=ctx), + retry=Retry(total=5), +) +client = httpx.Client(transport=transport) # mTLS and retries + +resp = client.get("https://mtls.example.com/") +``` + +### Keeping `PKIClient` as well + +If you want your `PKIClient` subclass — its methods, `base_url`, +`cert_info()` — **and** a custom transport, give it the same inner transport. +Its own `verify=` is ignored, since the transport wins, but everything else is +preserved: + +```python +ctx = build_ssl_context("client.p12", password="secret") +inner = httpx.HTTPTransport(verify=ctx) + +client = PKIClient( + "client.p12", + password="secret", + transport=RetryTransport(transport=inner, retry=Retry(total=5)), +) + +client.cert_info() # still works +client.cn # still works +``` + +You will still get the `TLSConfigWarning` — httpx-pki cannot tell that you +mounted the certificate on the inner transport yourself. Silence it once you +have checked the wiring: + +```python +warnings.filterwarnings("ignore", category=TLSConfigWarning) +``` + +The same rule applies to any custom-transport library and to hand-built +`mounts=`: put the TLS configuration on the transport, not on the client. + +:::{note} +httpx2 deprecates `verify=` on **its own** clients and transports, so use +`httpx.HTTPTransport(verify=ctx)` with a real context rather than a path. +httpx-pki's own `verify=` is unaffected — it accepts paths and literals and +builds the context for you. +::: + +## Rotation without a client + +`reload()` belongs to the client, so a bare context does not rotate. Rebuild +the context and remount it, or keep a `PKIClient` for the lifecycle and take +`client.ssl_context` when you need the raw object: + +```python +client = PKIClient("/etc/certs/client.pem", auto_reload=True) +ctx = client.ssl_context # reloads swap the certificate into this object +``` + +Because reloads mutate the context **in place**, a transport holding that same +object keeps working across a rotation. See [](expiry-and-rotation.md). + +## Next steps + +- [](server-trust.md) — everything `verify=` accepts +- [](testing.md) — throwaway certificates for exercising all of this +- [](../reference/api.md) — the full API surface diff --git a/docs/guide/backends.md b/docs/guide/backends.md new file mode 100644 index 0000000..742dbb1 --- /dev/null +++ b/docs/guide/backends.md @@ -0,0 +1,127 @@ +# Backends: httpx2 and httpx + +httpx development continues under pydantic's stewardship as +[httpx2](https://github.com/pydantic/httpx2), which is API-compatible with +httpx. Since 0.8, **httpx2 is httpx-pki's required dependency** and the session +classes subclass `httpx2.Client` / `httpx2.AsyncClient`. + +The original httpx remains fully supported as a fallback. Nothing in +httpx-pki's own API changes between the two: the same constructors, the same +keyword arguments, the same behavior. What changes is which library +`PKIClient` inherits from — which matters in exactly one place, covered under +[](#the-isinstance-caveat) below. + +## How the backend is chosen + +Resolution happens **once, at first import** of `httpx_pki`, and applies to the +whole process. Every internal module binds to the same resolved backend. + +1. If `HTTPX_PKI_BACKEND` is set to `httpx` or `httpx2`, that backend is used. +2. Otherwise httpx2 is used when it can be imported. +3. Otherwise httpx-pki falls back to httpx. + +So the ordinary install resolves to httpx2, and an environment with httpx and +no httpx2 resolves to httpx — with no configuration in either case. See +[](../install.md#using-httpx-instead-of-httpx2) for how to build the latter. + +:::{note} +httpx-pki never touches `sys.modules`. Your own `import httpx` is never +redirected, whichever backend httpx-pki resolved. +::: + +## Checking which backend you have + +```python +import httpx_pki + +print(httpx_pki.HTTP_BACKEND) # 'httpx2' or 'httpx' +``` + +Useful in a startup log line or a test assertion when you care which one you +are on. + +## Forcing the choice + +Set `HTTPX_PKI_BACKEND` before the process starts: + +```console +$ HTTPX_PKI_BACKEND=httpx python -m myapp +``` + +The main reason to reach for this is httpx2 arriving in your environment as +some *other* package's transitive dependency, while your own code still expects +`PKIClient` to subclass the original `httpx.Client`. + +Because the variable is read at import time, setting it from inside your +program only works before the first `import httpx_pki` — set it in the +environment, not in `main()`. + +Two ways it fails loudly rather than silently: + +```text +# HTTPX_PKI_BACKEND=foo +ImportError: HTTPX_PKI_BACKEND='foo' is not a supported backend; set it to +"httpx" or "httpx2", or unset it to prefer httpx2 when installed +``` + +```text +# HTTPX_PKI_BACKEND=httpx2, but httpx2 is not installed +ModuleNotFoundError: No module named 'httpx2' +``` + +Forcing a backend never falls back — if you asked for one, you get it or an +error. + +(the-isinstance-caveat)= +## The isinstance caveat + +This is the one behavior difference worth knowing about. With both packages +installed, httpx-pki resolves to httpx2, so a client is **not** an instance of +the *original* `httpx.Client`: + +```python +import httpx, httpx2 +from httpx_pki import PKIClient + +client = PKIClient("client.p12", password="secret") + +isinstance(client, httpx.Client) # False +isinstance(client, httpx2.Client) # True +``` + +Runtime type checks, `@singledispatch` registrations, and Pydantic models +annotated against `httpx.Client` will all be affected. Duck-typed code is not — +the two classes have the same interface. + +There are two fixes. + +**Force the backend**, if you want to keep using the original httpx: + +```console +$ HTTPX_PKI_BACKEND=httpx python -m myapp +``` + +**Or alias httpx to httpx2 application-wide**, which makes `import httpx` +resolve to httpx2 everywhere and keeps such checks consistent: + +```python +import httpx2 + +httpx2.alias_httpx() # before anything imports httpx + +import httpx +from httpx_pki import PKIClient + +httpx is httpx2 # True +isinstance(client, httpx.Client) # True +``` + +Calling `alias_httpx()` is your application's decision — a library should not +make it for you, so httpx-pki does not. + +## Type annotations + +For type checkers, httpx-pki always annotates against httpx2: it is the +required dependency, so it resolves in every environment that has httpx-pki +installed. httpx is typed API-compatibly, so the annotations stay correct on +the fallback too — mypy will not complain either way. diff --git a/docs/guide/choosing-a-certificate.md b/docs/guide/choosing-a-certificate.md new file mode 100644 index 0000000..8531802 --- /dev/null +++ b/docs/guide/choosing-a-certificate.md @@ -0,0 +1,228 @@ +# Choosing the right certificate + +A single PKCS#12 or PEM file often carries more than one private key and +certificate. httpx-pki calls each key-and-certificate pair an **identity**, and +when a source holds several it will not guess which one you meant: + +```text +AmbiguousCertificateError: this PKCS#12 data holds 2 identities: + [0] corp-user (Signature) key_usage=digital_signature expires=2027-07-30 8F78A78195… + [1] corp-user (Encryption) key_usage=key_encipherment expires=2027-07-30 6E88063681… +Pick one with identity= (index, name, or fingerprint), key_usage=, or extended_key_usage=. +``` + +This page is about resolving that. + +## Why one file holds two certificates + +Two identities for the same subject is routine wherever a CA archives the key +that *decrypts* data — so encrypted mail and files survive a lost laptop — but +never the key that *signs*, which would defeat non-repudiation. Entrust dual +key pairs, PIV/CAC, S/MIME key archival, and national eID schemes all work this +way. The two certificates usually differ only in their key usage: + +| Half | Typical key usage | +| --- | --- | +| encryption | `key_encipherment` (RSA) or `key_agreement` (ECDH) | +| signing | `digital_signature`, and/or `content_commitment` — the bit most CAs still call *nonRepudiation* | + +:::{important} +**For mTLS you almost always want the signing half.** TLS 1.3, and every ECDHE +suite before it, has the client sign the handshake; an encryption-only +certificate cannot complete one. +::: + +Some schemes split three ways instead of two. A PIV card carries +authentication, signature, and key-management certificates, and the first two +*both* assert `digital_signature` — there the extended key usage +(`client_auth` versus `email_protection`) is what tells them apart. + +The other common case is a **renewed certificate stored beside the one it +replaces**: two certificates over one key pair, which is what renewing rather +than rekeying produces. Only the validity window separates those — see +[](#picking-the-current-one). + +## Why httpx-pki parses these itself + +`cryptography` cannot express a multi-identity bundle. +`pkcs12.load_key_and_certificates()` returns the **first** private key, pairs +it with its certificate, and discards every other key — leaving the other +identities' certificates lumped in with the genuine CA chain: + +```python +key, cert, additional = pkcs12.load_key_and_certificates(raw, b"secret") + +cert # corp-user / digital_signature — whichever happened to be first +additional # [corp-user / key_encipherment, ← another identity's LEAF + # Acme Issuing CA] ← a real chain certificate +``` + +Nothing in that return value distinguishes the two, and the second identity's +private key is simply gone. A client built naively from it presents another +leaf certificate as though it were a chain certificate, which a strict server +can reject — and gives you no way to select the identity you actually wanted. + +So httpx-pki reads the key bags itself, pairs keys to certificates by public +key, and exposes each pair as an identity you can inspect and select. +`cryptography` still does the decryption and certificate parsing. A file whose +layout it cannot read that way falls back to `cryptography`'s single-identity +view. + +## Look before you choose + +`list_identities` shows what a file holds. It detects PKCS#12 versus PEM from +the content, exactly like the constructors, and never returns private keys: + +```python +from httpx_pki import list_identities + +for identity in list_identities("corp.p12", password="secret"): + print( + identity.index, + identity.friendly_name, + sorted(identity.info.key_usage), + identity.info.extended_key_usage + ) +``` + +```text +0 Signature ['digital_signature'] ['client_auth'] +1 Encryption ['key_encipherment'] ['email_protection'] +``` + +Each entry is a {py:class}`~httpx_pki.P12Identity`, whose `info` is a +{py:class}`~httpx_pki.CertInfo` carrying the subject, validity window, +fingerprints, and usage bits. `list_pkcs12_identities` is the stricter sibling +for when only PKCS#12 should be accepted — it rejects PEM rather than falling +back to it. + +## The selectors + +Every bundle entry point takes the same three selectors — `PKIClient(...)`, +`from_pkcs12(...)`, `from_pem(...)`, `AsyncPKIClient`, and +`build_ssl_context`: + +```python +# By key usage — the usual discriminator for a dual key pair +PKIClient("corp.p12", password="secret", key_usage="digital_signature") + +# By extended key usage — when both certs share their key-usage bits +PKIClient("corp.p12", password="secret", extended_key_usage="client_auth") + +# By name — case-insensitive substring of the friendly name, common name, +# or full subject +PKIClient("corp.p12", password="secret", identity="Signature") + +# By exact SHA-1 or SHA-256 fingerprint (colons and case are ignored) +PKIClient("corp.p12", password="secret", identity="9F:86:D0:81…") + +# By position in the file +PKIClient("corp.p12", password="secret", identity=0) + +# By any predicate over the identity +PKIClient( + "corp.p12", + password="secret", + identity=lambda i: i.info.serial_number == 4242 +) + +# Multiple kwargs are ANDed together +PKIClient( + "corp.p12", + password="secret", + key_usage="digital_signature", + extended_key_usage="client_auth" +) +``` + +### How usage names are spelled + +Usage names are spelled as `CertInfo` reports them — `digital_signature`, +`client_auth` — but camelCase and dotted OIDs are accepted too, so you can +paste whatever your CA's documentation uses: + +```python +key_usage="digital_signature" # as CertInfo reports it +key_usage="digitalSignature" # camelCase +key_usage="nonRepudiation" # accepted spelling of content_commitment +extended_key_usage="1.3.6.1.5.5.7.3.2" # dotted OID +``` + +(picking-the-current-one)= +## Picking the current one + +When a file carries a renewed certificate next to the one it replaces, only the +validity window separates them. The ready-made `currently_valid` selector picks +on exactly that: + +```python +from httpx_pki import PKIClient, currently_valid + +PKIClient("corp.p12", password="secret", identity=currently_valid) +``` + +Expired and not-yet-valid identities never match it. During a renewal +*overlap*, when the old certificate has not expired yet, the tie resolves to +the later validity window — but only between certificates that are otherwise +interchangeable, meaning the same subject and usages. + +:::{warning} +`currently_valid` never picks between the halves of a dual key pair. Freshness +cannot tell a signing certificate from an encryption one, so both remain +matched and the load is still ambiguous. Combine it with `key_usage=` there: + +```python +PKIClient( + "corp.p12", + password="secret", + identity=currently_valid, + key_usage="digital_signature" +) +``` +::: + +## When a selector does not resolve to one identity + +Matching nothing and matching several are different errors, and both name what +the file actually holds so you can correct the selector: + +```text +CertificateNotFoundError: key_usage='crl_sign' matched no identity in the +PKCS#12 data, which holds: ... +``` + +```text +AmbiguousCertificateError: identity=httpx_pki.currently_valid matched +2 identities: ... +``` + +## PEM bundles work the same way + +A `.pem` concatenating two key-and-certificate pairs — or one key followed by +its old and renewed certificates — holds several identities, chosen with the +same selectors: + +```python +PKIClient("corp.pem", key_usage="digital_signature") +``` + +Keys are paired to certificates by public key, in any block order. A key +matching no certificate at all means the bundle was assembled from the wrong +pieces, and is rejected. + +## What happens to the identities you did not pick + +They are **not** presented as chain certificates. They are leaf certificates in +their own right, and a strict server can reject a chain carrying them — only +real chain certificates are sent. + +The selection is also remembered. `reload()` and `auto_reload` re-select the +same identity after a rotation, even if the new file lists them in a different +order, and it survives pickling. + +## Next steps + +- [](server-trust.md) — how the server gets verified +- [](expiry-and-rotation.md) — reload, and warnings as a certificate ages +- [](windows-store.md) and [](macos-keychain.md) — the same problem in the OS + stores, which have their own selectors diff --git a/docs/guide/environment.md b/docs/guide/environment.md new file mode 100644 index 0000000..7253e71 --- /dev/null +++ b/docs/guide/environment.md @@ -0,0 +1,179 @@ +# From environment variables + +Containerized and 12-factor deployments configure the client certificate +through the environment rather than in code. `from_env()` reads a set of +`HTTPX_PKI_*` variables and builds the session from them: + +```python +from httpx_pki import PKIClient + +client = PKIClient.from_env() +``` + +```console +$ export HTTPX_PKI_CERT=/etc/pki/client.p12 +$ export HTTPX_PKI_PASSWORD=secret +$ python -m myapp +``` + +Nothing about the source is decided in code — the same image runs against a +PKCS#12 bundle in one environment and a separate cert and key in another. + +## The variables + +Only `CERT` is required. Every variable takes the prefix `HTTPX_PKI_` by +default; see [](#using-a-different-prefix) to change it. + +| Variable | Purpose | +| --- |-------------------------------------------------------------------------------------------------------------------------------------------| +| `HTTPX_PKI_CERT` | **Required.** Path to a PKCS#12 or PEM source. The encoding is detected from the bytes. | +| `HTTPX_PKI_PASSWORD` | Password for the certificate or key. Omit for unencrypted material. | +| `HTTPX_PKI_KEY` | Path to a separate private key. Switches to the cert-and-key path (`PKIClient.from_key_pair( ... )`), with `HTTPX_PKI_CERT` as the certificate. | +| `HTTPX_PKI_CHAIN` | Path to intermediate certificates to present, in addition to any `CERT` already carries. | +| `HTTPX_PKI_CA` | Server trust: a CA bundle path, or the literal `system` or `certifi`. Absent means the default. | +| `HTTPX_PKI_IDENTITY` | Which identity to use when the source holds several: a position (`0`), a name substring, a fingerprint, or the literal `currently_valid`. | +| `HTTPX_PKI_KEY_USAGE` | Select an identity by key usage. Comma-separated, e.g. `digital_signature`. | +| `HTTPX_PKI_EXT_KEY_USAGE` | Select an identity by extended key usage. Comma-separated, e.g. `client_auth`. | + +## Common shapes + +A PKCS#12 bundle: + +```console +$ export HTTPX_PKI_CERT=/etc/pki/client.p12 +$ export HTTPX_PKI_PASSWORD=secret +``` + +A separate certificate and key — setting `HTTPX_PKI_KEY` is what switches modes: + +```console +$ export HTTPX_PKI_CERT=/etc/pki/client.crt +$ export HTTPX_PKI_KEY=/etc/pki/client.key +``` + +A PEM bundle needing no password at all: + +```console +$ export HTTPX_PKI_CERT=/etc/pki/client.pem +``` + +## Server trust + +`HTTPX_PKI_CA` sets `verify`. It takes a path to a CA bundle, or one of two +literals: + +```console +$ export HTTPX_PKI_CA=/etc/pki/internal-ca.pem # a private CA +$ export HTTPX_PKI_CA=system # the OS trust store (default when unset) +$ export HTTPX_PKI_CA=certifi # the certifi bundle +``` + +Leaving it unset gives the default, which is the OS trust store — see +[](server-trust.md). + +An explicit `verify` argument wins over `HTTPX_PKI_CA`, so code can override +the environment when it needs to: + +```python +PKIClient.from_env(verify="certifi") # ignores HTTPX_PKI_CA +``` + +## Selecting an identity + +When `CERT` points at a bundle holding several identities, the same +discriminators available in code are available here. Without one, httpx-pki +refuses to guess and raises `AmbiguousCertificateError`: + +```console +$ export HTTPX_PKI_IDENTITY=0 # by position +$ export HTTPX_PKI_IDENTITY="Acme Corp" # by name substring +$ export HTTPX_PKI_IDENTITY=A1:B2:C3:... # by fingerprint +$ export HTTPX_PKI_KEY_USAGE=digital_signature # by key usage +$ export HTTPX_PKI_EXT_KEY_USAGE=client_auth # by extended key usage +``` + +`HTTPX_PKI_IDENTITY=currently_valid` is the environment spelling of the +{py:func}`~httpx_pki.currently_valid` selector. + +:::{note} +`currently_valid` discards identities outside their validity window. During a +renewal *overlap*, when old and new are both valid, it resolves to the later +window — but only between certificates that are otherwise interchangeable +(same subject and usages). The halves of a dual key pair stay ambiguous, so +combine it with `HTTPX_PKI_KEY_USAGE` there. +::: + +Full detail on all of these: [](choosing-a-certificate.md). + +:::{important} +The identity selectors describe a position *inside a bundle*, so they cannot be +combined with `HTTPX_PKI_KEY`, which points at a separate key file. Setting +both raises `CertificateLoadError`: + +``` +HTTPX_PKI_IDENTITY / HTTPX_PKI_KEY_USAGE / HTTPX_PKI_EXT_KEY_USAGE select an +identity inside a PKCS#12 or PEM bundle, but HTTPX_PKI_KEY points at a +separate private key; drop one or the other +``` +::: + +(using-a-different-prefix)= +## Using a different prefix + +Pass one to `from_env()` if `HTTPX_PKI_` collides with something, or if you +would rather namespace the variables to your own application: + +```python +PKIClient.from_env("MYAPP_") +``` + +```console +$ export MYAPP_CERT=/etc/pki/client.p12 +$ export MYAPP_PASSWORD=secret +``` + +The prefix applies to every variable in the table above. + +## Reloading + +`reload()` re-reads the environment, so a redeployed pod picks up whatever the +variables now point at. With `auto_reload`, httpx-pki watches the files the +variables named when the session was built: + +```python +client = PKIClient.from_env(auto_reload=True) +``` + +Because the password comes from `HTTPX_PKI_PASSWORD` along with everything +else, `reload()` takes no `password=` here — passing one raises rather than +being silently ignored: + +```text +TypeError: reload(password=...) does not apply to a from_env() client: the +password is read from HTTPX_PKI_PASSWORD along with the rest of the +configuration. Set that variable instead of passing one here. +``` + +See [](expiry-and-rotation.md). + +## When something is missing + +A missing `CERT` fails immediately and by name, rather than at handshake time: + +``` +CertificateLoadError: environment variable HTTPX_PKI_CERT is not set +``` + +## Other variables httpx-pki reads + +Two more environment variables affect behavior, neither of them tied to +`from_env()`: + +`HTTPX_PKI_BACKEND` +: Forces the HTTP backend to `httpx` or `httpx2`. Read once, at import. + See [](backends.md). + +`SSLKEYLOGFILE` +: Standard across the Python TLS ecosystem — when set, TLS session keys are + written to that path, which decrypts your traffic by design. Honored by + every context httpx-pki builds. See [](../about/security.md). diff --git a/docs/guide/expiry-and-rotation.md b/docs/guide/expiry-and-rotation.md new file mode 100644 index 0000000..68e7f5b --- /dev/null +++ b/docs/guide/expiry-and-rotation.md @@ -0,0 +1,210 @@ +# Expiry and rotation + +A client presenting an expired certificate is the most common silent mTLS +failure, and certificates keep getting shorter-lived: cert-manager renews a +mounted Secret at two-thirds of its lifetime, Vault PKI issues certificates +measured in hours. A client snapshots its certificate at construction, so a +long-running process needs a plan for what happens next. + +httpx-pki gives you three, in increasing order of automation: + +- **[Warn early](#warning-before-it-expires)** — know a rollover is coming +- **[Reload](#reloading-a-rotated-certificate)** — pick up the new file, + manually or automatically +- **[Fail loudly](#strict-validity)** — turn an expired certificate into a + clear error instead of a handshake failure + +## Warning before it expires + +Loading an already-expired or not-yet-valid certificate warns immediately: + +```text +CertificateValidityWarning: client certificate expired on 2026-08-01; +mTLS handshakes will fail. +``` + +To hear about one that is merely *about* to roll over, pass +`warn_if_expires_within` — accepted by every constructor, `from_*` included: + +```python +from datetime import timedelta +from httpx_pki import PKIClient + +client = PKIClient( + "client.p12", + password="secret", + warn_if_expires_within=timedelta(days=14), +) +``` + +```text +CertificateValidityWarning: client certificate expires on 2026-08-07 +(in 4 day(s)). +``` + +The window is kept on the client, so it keeps applying for the client's +lifetime: every [reload](#reloading-a-rotated-certificate) re-checks the +*freshly loaded* certificate against it, and it survives pickling. A rotation +that lands another short-lived certificate warns again; one that lands a +healthy certificate goes quiet. That is what makes it useful next to +`auto_reload` — see [](#strict-validity). + +To check on demand rather than be warned, the validity properties and +`check_validity()` are covered in [](inspecting-a-certificate.md). + +## Reloading a rotated certificate + +`reload()` re-reads the source — file, `from_env` variables, Windows store, or +macOS keychain — and swaps the fresh certificate into the mounted SSL context +**in place**, so new handshakes present it immediately: + +```python +client = PKIClient("/etc/certs/client.pem") + +# ... cert-manager rotates /etc/certs/client.pem ... + +client.reload() +``` + +### Automatically + +`auto_reload` stats the source files before each request and reloads when they +change, throttled to at most once per second by default: + +```python +from datetime import timedelta + +PKIClient("/etc/certs/client.pem", auto_reload=True) +PKIClient("/etc/certs/client.pem", auto_reload=timedelta(seconds=30)) +``` + +Nothing else in your code changes — the next request after a rotation simply +presents the new certificate. + +### What to expect + +**The swap is atomic.** If the rotated file is unreadable or garbage, +`reload()` raises `CertificateLoadError` and the *previous* certificate keeps +serving: + +```text +CertificateLoadError: invalid PKCS#12 data or wrong password +``` + +With `auto_reload` that error surfaces on the triggering request and is retried +on the next one. You never end up with a client that has no certificate. + +**Established connections keep their certificate.** TLS has no mid-connection +re-authentication, so only new connections present the rotated certificate. +Existing ones carry on until they close. + +**Rotation tooling should replace files atomically** — write-then-rename, which +kubelet and cert-manager already do. A reload that catches a half-written file +raises rather than mounting garbage, but atomic replacement avoids the churn. + +:::{note} +`auto_reload` needs a filesystem path to watch. Constructing from in-memory +bytes, the Windows store, or the macOS keychain raises: + +```text +TypeError: auto_reload requires a filesystem-path certificate source to watch +``` + +The stores can still be re-exported with a manual `reload()` — see +[](windows-store.md#reloading) and [](macos-keychain.md#reloading). +::: + +### Passwords and unattended reloads + +Reloading a password-protected source needs the password again. httpx-pki does +not retain it by default: + +```python +client = PKIClient("client.p12", password="secret") + +client.reload() # CertificateLoadError +client.reload(password="secret") # works +``` + +Enabling `auto_reload` **does** retain the password on the client, since an +unattended reload has no other way to decrypt the source: + +```python +client = PKIClient("client.p12", password="secret", auto_reload=True) +client.reload() # works — password retained +``` + +That is a deliberate trade: it keeps the password in memory for the client's +lifetime. See [](../about/security.md). + +:::{note} +`password=` applies only to sources httpx-pki decrypts on your behalf — a +PKCS#12 or PEM bundle, or a separate key file. Three sources supply their own, +and passing one to them raises rather than being quietly discarded: + +```text +TypeError: reload(password=...) does not apply to a from_env() client: the +password is read from HTTPX_PKI_PASSWORD along with the rest of the +configuration. Set that variable instead of passing one here. + +TypeError: reload(password=...) does not apply to a client built from the +Windows certificate store: the certificate is exported under an internally +generated single-use password, so there is none to supply. Drop the argument. +``` + +The macOS keychain says the same as the Windows store. +::: + +(strict-validity)= +## Strict validity + +`strict_validity=True` runs `check_validity()` before every request, so a +certificate that expired anyway fails clearly *before* the connection is +attempted: + +```python +client = PKIClient("client.p12", password="secret", strict_validity=True) +client.get("https://mtls.example.com/") +``` + +```text +CertificateExpiredError: client certificate expired on 2026-08-01 18:02 UTC +``` + +Without it you get an opaque OpenSSL handshake error from the far side instead +— which is the failure this whole page exists to prevent. + +Combining it with `auto_reload` is the belt-and-braces setup for a long-lived +service: pick up rotations automatically, and fail legibly if one is ever +missed. + +```python +PKIClient( + "/etc/certs/client.pem", + auto_reload=True, + strict_validity=True, + warn_if_expires_within=timedelta(days=7), +) +``` + +## Silencing the validity warnings + +The warnings on this page are all `CertificateValidityWarning`, so a single +filter quiets them without touching anything else httpx-pki reports: + +```python +import warnings +from httpx_pki import CertificateValidityWarning + +warnings.filterwarnings("ignore", category=CertificateValidityWarning) +``` + +See [](../reference/exceptions.md) for the other categories and for making +warnings fatal. + +## Next steps + +- [](inspecting-a-certificate.md) — checking validity on demand +- [](../reference/exceptions.md) — every error and warning, and how to filter them +- [](server-trust.md) — the other half of the connection +- [](advanced.md) — rotation when you are using the SSL context directly diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..0939814 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,55 @@ +# User guide + +Everything httpx-pki does, grouped by the question you arrived with. If you +just want a working request, start with the [quickstart](../quickstart.md) +instead — and if you have an error in hand, +[troubleshooting](../troubleshooting.md) is organized by symptom. + +## Getting a certificate loaded + +Where your credential lives, and how to point httpx-pki at it. + +- [](loading-certificates.md) — PKCS#12, PEM, separate key and certificate, + PKCS#7 chains, and why the file extension never matters +- [](windows-store.md) — pulling an exportable certificate out of the Windows + certificate store +- [](macos-keychain.md) — the same on macOS, including the consent prompt that + catches out headless deployments +- [](environment.md) — configuring the whole thing from `HTTPX_PKI_*` + variables, for containers and 12-factor deployments + +## When one source holds several certificates + +Common wherever a CA archives the encryption key but not the signing key, and +after any renewal that leaves the old certificate in place. + +- [](choosing-a-certificate.md) — key usage, extended key usage, name, + fingerprint, position, and arbitrary predicates +- [](inspecting-a-certificate.md) — what a loaded certificate says about + itself: subject, issuer, validity, fingerprints, usages + +## Getting the connection right + +- [](server-trust.md) — `verify=`, the OS trust store, private CAs, and the + ways a TLS setup can look fine while doing nothing +- [](backends.md) — httpx2, httpx, how the binding is resolved, and the one + behavior difference between them + +## Keeping it working + +- [](expiry-and-rotation.md) — warn early, reload automatically, or fail + loudly when a certificate rolls over +- [](../reference/exceptions.md) — every error and warning httpx-pki produces, + what each message means, and how to filter them + +## Going further + +- [](advanced.md) — subclassing, using the bare `ssl.SSLContext`, and the + custom-transport rule that silently drops your certificate +- [](testing.md) — minting throwaway certificates, including multi-identity + bundles nothing else readily produces + +--- + +Looking for a specific class or function? The +[API reference](../reference/api.md) is generated from the source. diff --git a/docs/guide/inspecting-a-certificate.md b/docs/guide/inspecting-a-certificate.md new file mode 100644 index 0000000..46656d5 --- /dev/null +++ b/docs/guide/inspecting-a-certificate.md @@ -0,0 +1,134 @@ +# Inspecting a certificate + +Once a certificate is mounted, the client exposes what it is presenting — +useful for a startup log line, a health check, a support diagnostic, or an +assertion in a test. + +```python +from httpx_pki import PKIClient + +client = PKIClient("client.p12", password="secret") + +client.cn # 'corp-user' +client.not_valid_after # datetime(2027, 8, 2, 17, 31, 31, tzinfo=utc) +client.is_expired # False +``` + +## Quick properties + +The common questions have direct properties, so you rarely need the full +detail: + +| Property | Type | | +| --- | --- | --- | +| `cn` | `str` | Subject common name | +| `dn` | `str` | Full subject distinguished name | +| `not_valid_before` | `datetime` | Start of the validity window | +| `not_valid_after` | `datetime` | End of the validity window | +| `is_expired` | `bool` | Past `not_valid_after` | +| `is_not_yet_valid` | `bool` | Before `not_valid_before` | +| `expires_in` | `timedelta` | Time left until `not_valid_after` | +| `certificate` | `x509.Certificate` | The parsed certificate itself | +| `ssl_context` | `ssl.SSLContext` | The context the client uses | + +All datetimes are timezone-aware and in UTC. + +```python +if client.expires_in < datetime.timedelta(days=14): + log.warning("client cert %s expires %s", client.cn, client.not_valid_after) +``` + +## The full picture: `cert_info()` + +`client.cert_info()` returns a {py:class}`~httpx_pki.CertInfo` — a frozen +dataclass with everything httpx-pki reads off the certificate: + +```python +info = client.cert_info() +``` + +| Field | Example | +| --- | --- | +| `common_name` | `'corp-user'` | +| `distinguished_name` | `'CN=corp-user'` | +| `issuer_common_name` | `'Acme Issuing CA'` | +| `issuer_distinguished_name` | `'CN=Acme Issuing CA,O=Acme'` | +| `serial_number` | `137979069391421544275421516091962167926830026919` | +| `not_valid_before` | `datetime(2026, 8, 1, 17, 31, 31, tzinfo=utc)` | +| `not_valid_after` | `datetime(2027, 8, 2, 17, 31, 31, tzinfo=utc)` | +| `fingerprint_sha256` | `'A92ABBA4A948400B6F791A49226192FD…'` | +| `fingerprint_sha1` | `'B8A2152EC0FC713231352786123249F35FC66B5A'` | +| `subject_alt_names` | `['client.example.com']` | +| `dns_names` | `['client.example.com']` | +| `key_usage` | `frozenset({'digital_signature'})` | +| `extended_key_usage` | `['client_auth']` | + +Fingerprints are uppercase hex without separators — the same form +`identity=` accepts when [choosing a certificate](choosing-a-certificate.md), +which also tolerates colons and lowercase. + +`key_usage` is a `frozenset` because order is meaningless; `extended_key_usage` +is a list. Both use the readable names shown here rather than raw OIDs. + +:::{note} +`CertInfo` describes the **leaf certificate only** — the one being presented. +It says nothing about the chain, and nothing about server trust. +::: + +## Inspecting without building a client + +The module-level `cert_info()` reads a certificate straight from PEM bytes, +with no client and no private key involved: + +```python +from httpx_pki import cert_info + +info = cert_info(pem_bytes) +print(info.common_name, info.not_valid_after) +``` + +To inspect what a *file* holds — including a bundle with several identities, +and without touching the private keys — use `list_identities` instead. See +[](choosing-a-certificate.md#look-before-you-choose). + +## Asserting validity + +`check_validity()` raises rather than returning a boolean, so it reads well in +a startup check: + +```python +client.check_validity() # raises if expired or not yet valid +``` + +- `CertificateExpiredError` — past `not_valid_after` +- `CertificateNotYetValidError` — before `not_valid_before` + +Pass `within=` to treat an imminent expiry as a failure too: + +```python +client.check_validity(within=datetime.timedelta(days=30)) +``` + +```text +CertificateExpiredError: client certificate expires on 2027-08-02 17:31 UTC, +within 30 days +``` + +Loading an already-expired certificate does **not** raise by default — it +warns, so a diagnostic tool can still inspect it: + +```text +CertificateValidityWarning: client certificate expired on 2026-08-01; +mTLS handshakes will fail. +``` + +To make expiry a hard failure on every request instead, use +`strict_validity=True`. That and the rollover warning are covered in +[](expiry-and-rotation.md). + +## Next steps + +- [](choosing-a-certificate.md) — inspecting a file that holds several + identities +- [](expiry-and-rotation.md) — acting on what you find as certificates age +- [](server-trust.md) — the other half of the connection diff --git a/docs/guide/loading-certificates.md b/docs/guide/loading-certificates.md new file mode 100644 index 0000000..5ede23b --- /dev/null +++ b/docs/guide/loading-certificates.md @@ -0,0 +1,172 @@ +# Loading certificates + +Client certificates arrive in whatever shape your PKI team, cloud provider, or +corporate CA happened to produce. This page covers every file-based form +httpx-pki accepts. The two OS certificate stores have their own pages +([Windows](windows-store.md), [macOS](macos-keychain.md)), as does +[configuration from the environment](environment.md). + +:::{important} +Whatever the shape, mTLS needs **a certificate and the private key that matches +it** — the certificate states who you are, the key proves you are entitled to +it, and the handshake uses both. Some of the formats below carry only +certificates: a `.crt` or `.cer` is a single certificate, and a PKCS#7 `.p7b` +cannot hold a key at all. If yours has no key in it, see +[](../troubleshooting.md#do-you-have-both-halves). +::: + +## The extension does not matter + +Certificate files come with a lot of names — `.p12`, `.pfx`, `.pem`, `.crt`, +`.cer`, `.key`, `.tls`, `.ukey` — but an extension is just a label somebody +chose. What matters is the **encoding of the bytes**, and httpx-pki reads that +from the content: + +```python +PKIClient("whatever-they-sent-me.tls") # works if the bytes are PKCS#12 or PEM +``` + +So you can generally point `PKIClient` at the file you were handed without +first working out what it is. Use the explicit constructors below when you +would rather force one interpretation than rely on detection. + +| Input | Constructor | +| --- | --- | +| **PKCS#12** — `.p12`, `.pfx`, binary | `PKIClient(...)` or `from_pkcs12(...)` | +| **PEM bundle** — key + cert(s) in one file | `PKIClient(...)` or `from_pem(...)` | +| **Separate cert + key** — PEM or DER | `from_key_pair(...)` | +| **PKCS#7** — `.p7b`, `.p7c`, certs only | `certificate=` or `chain=` on `from_key_pair(...)` | + +Every source below accepts a `str` path, a {py:class}`pathlib.Path`, or raw +`bytes`. + +## PKCS#12 bundles + +The usual enterprise hand-off: private key, leaf certificate, and chain in one +password-protected blob. + +```python +from pathlib import Path +from httpx_pki import PKIClient + +PKIClient("client.p12", password="secret") +PKIClient(Path("client.pfx"), password="secret") +PKIClient(p12_bytes, password=b"secret") # the password may be bytes too +PKIClient("client.p12") # no password on the bundle + +PKIClient.from_pkcs12("client.p12", "secret") # explicit; password is positional +``` + +Any chain certificates inside the bundle are presented to the server +automatically. + +## PEM bundles + +A single file holding the private key and its certificate — and possibly +intermediates — as consecutive PEM blocks. + +```python +PKIClient("client.pem") # auto-detected +PKIClient.from_pem("client.pem") # explicit +PKIClient.from_pem(pem_bytes, password="pw") # if the key block is encrypted +``` + +Block order does not matter, and the key may be any of the usual encodings: + +- **PKCS#8** — `-----BEGIN PRIVATE KEY-----` +- **PKCS#1** — `-----BEGIN RSA PRIVATE KEY-----` +- **Encrypted PKCS#8** — `-----BEGIN ENCRYPTED PRIVATE KEY-----`, with `password=` +- **EC keys**, alongside RSA + +## A separate certificate and key + +Two files, the shape most non-Windows tooling produces: + +```python +client = PKIClient.from_key_pair( + certificate="client.crt", + private_key="client.key", + password="secret", # only if the key is encrypted + chain="intermediate.crt", # optional intermediates to present +) +``` + +Both files may be **PEM or DER** — again detected from the bytes, so a DER +certificate with a `.crt` name and a DER key with a `.key` name work as-is. + +### When the certificate file is itself a bundle + +If `certificate` holds several certificates — a leaf plus intermediates — the +leaf is identified by **matching it against the private key**, in any block +order. The remaining certificates become the chain automatically: + +```python +# fullchain.pem contains the CA first, then the leaf. Still correct. +PKIClient.from_key_pair("fullchain.pem", "client.key") +``` + +### Intermediates + +`chain` takes a single source, or a list: + +```python +chain="intermediates.pem" # one file, may concatenate several +chain=["intermediate.crt", "root.crt"] # a list of sources +chain=b"-----BEGIN CERTIFICATE-----\n..." # raw bytes +``` + +## PKCS#7 bundles + +`.p7b` / `.p7c` files hold certificates but **no private key**, which is the +format Windows CAs commonly export chains in. They work anywhere a certificate +source is accepted — DER or PEM — and pair with a separate key: + +```python +# The .p7b holds the leaf and its intermediates; the key comes separately +PKIClient.from_key_pair("issued.p7b", "client.key") + +# Or as the chain alongside a normal leaf certificate +PKIClient.from_key_pair("client.crt", "client.key", chain="chain.p7b") +``` + +A certs-only PKCS#7 is also valid as a `verify=` CA bundle — see +[](server-trust.md). + +## When a source holds several identities + +A PKCS#12 or PEM bundle can hold more than one key-and-certificate pair — a +dual key pair from AD key archival, or a renewed certificate kept alongside the +one it replaces. httpx-pki will not guess which one you meant: + +```text +AmbiguousCertificateError: this PKCS#12 data holds 2 identities: + [0] dual (dual) key_usage=digital_signature expires=... + [1] dual (dual) key_usage=key_encipherment expires=... +``` + +`from_pkcs12` and `from_pem` both take `identity=`, `key_usage=`, and +`extended_key_usage=` to resolve it. That is its own topic: +[](choosing-a-certificate.md). + +## When the key and certificate do not match + +Pairing the wrong two files is a common and confusing mistake, so httpx-pki +checks at load time rather than letting it surface as an opaque handshake +failure: + +```text +CertificateLoadError: private key does not match certificate +(their public keys differ) +``` + +An encrypted key with the wrong password — or none — fails the same way: + +```text +CertificateLoadError: could not parse private key (wrong password?) +``` + +## Next steps + +- [](choosing-a-certificate.md) — picking one identity out of several +- [](server-trust.md) — how the server gets verified +- [](expiry-and-rotation.md) — reloading these sources as they rotate diff --git a/docs/guide/macos-keychain.md b/docs/guide/macos-keychain.md new file mode 100644 index 0000000..45cb535 --- /dev/null +++ b/docs/guide/macos-keychain.md @@ -0,0 +1,187 @@ +# The macOS keychain + +The macOS sibling of [](windows-store.md): pull an **exportable** identity — +certificate plus private key — out of the default keychain search list, with no +file to point at. + +```python +from httpx_pki import PKIClient + +with PKIClient.from_macos_keychain(name="ACME Client") as client: + client.get("https://mtls.example.com/") +``` + +`name` is a case-insensitive substring of either the subject common name or the +keychain label. + +:::{important} +**macOS only.** Calling this anywhere else raises `UnsupportedPlatformError`: + +```text +UnsupportedPlatformError: the macOS keychain is only available on macOS +``` + +`AsyncPKIClient.from_macos_keychain(...)` is the async equivalent. +::: + +## Export requires consent + +The private key must be exportable, and **the keychain may prompt for user +consent** when httpx-pki exports it. That matters most where nobody is there to +click: + +:::{warning} +A headless session — CI, a launch daemon, a container — cannot grant consent, +so the export blocks or fails. For unattended use, grant access up front: + +```console +$ security import client.p12 -k login.keychain -A +``` + +The `-A` flag allows access by any application. Alternatively, click **Always +Allow** once in the consent dialog on an interactive session, which persists +the grant for that application. +::: + +No password is involved: the identity is exported under a random, single-use +password that never leaves the library. + +## Looking before you select + +`list_macos_certificates()` returns a `MacCert` per identity — metadata only, +with no key exported: + +```python +from httpx_pki import list_macos_certificates + +for c in list_macos_certificates(): + print(c.label, c.subject_cn, c.thumbprint, sorted(c.key_usage)) +``` + +| Attribute | | +| --- | --- | +| `subject_cn` | Subject common name | +| `label` | The keychain label | +| `thumbprint` | SHA-1 thumbprint, uppercase hex | +| `certificate` | The parsed `x509.Certificate` | +| `info` | Its {py:class}`~httpx_pki.CertInfo` | +| `key_usage` / `extended_key_usage` | Convenience accessors onto `info` | + +It takes no arguments — the default keychain search list is what gets searched, +which is the macOS equivalent of the Windows store/location pair. + +## Selecting + +Selection works exactly as it does on Windows. More than one match gives an +`AmbiguousCertificateError` naming the candidates: + +```text +name='ACME' matched 2 certificates: + ACME Client key_usage=digital_signature expires=2027-08-02 AA11BB + ACME Client key_usage=key_encipherment expires=2027-08-02 CC22DD +Narrow it with a more specific name, a key usage, or an exact thumbprint. +``` + +Narrow it with any combination: + +```python +# By exact thumbprint — colons and case are ignored +PKIClient.from_macos_keychain(thumbprint="A1:B2:C3:...") + +# By key usage — both halves of a dual key pair in one keychain +PKIClient.from_macos_keychain(name="ACME", key_usage="digital_signature") + +# By extended key usage +PKIClient.from_macos_keychain(name="ACME", extended_key_usage="email_protection") + +# By any predicate over the MacCert +PKIClient.from_macos_keychain(identity=lambda c: c.label == "prod") + +# By identity — the portable spelling: a name substring, an exact +# fingerprint, or a predicate, exactly as a PKCS#12 bundle accepts +PKIClient.from_macos_keychain(identity="ACME") +``` + +`identity=` is the same keyword a bundle and the Windows store take. As there, +an integer position is rejected — a keychain has no stable ordering — and +`name=` / `thumbprint=` remain the unambiguous spellings. + +:::{note} +**Every selector you pass must match** — they intersect rather than falling +back, so a thumbprint from one identity combined with a name from another +matches nothing. +::: + +:::{warning} +**A predicate does not port between the two stores unchanged.** Each record +exposes the platform's own name for its human-readable label — `WinCert` has +`friendly_name` (the Windows friendly name, also what PKCS#12 calls it), +`MacCert` has `label` (the keychain's `kSecAttrLabel`): + +```python +identity=lambda c: c.friendly_name == "prod" # Windows +identity=lambda c: c.label == "prod" # macOS — same idea, other name +``` + +Everything else is shared: `subject_cn`, `thumbprint`, `info`, `key_usage`, +and `extended_key_usage` are spelled the same on both, so a predicate over any +of those *is* portable — as is `name=`, which matches against the label or the +common name on either platform. +::: + +For mTLS you want the signing half of a dual key pair; the background is in +[](choosing-a-certificate.md#why-one-file-holds-two-certificates). + +### Skipping the expired copy + +A keychain tends to keep the old identity after a renewal. The ready-made +`currently_valid` selector filters those out: + +```python +from httpx_pki import currently_valid + +PKIClient.from_macos_keychain(name="ACME", identity=currently_valid) +``` + +## Reloading + +There is no file to watch, so `auto_reload` is not offered here. `reload()` +re-exports from the keychain with the same selector: + +```python +client.reload() +``` + +No password is involved, so `reload()` takes none. Passing one raises rather +than being silently ignored, since the export uses an internal single-use +password: + +```text +TypeError: reload(password=...) does not apply to a client built from the +macOS keychain: the certificate is exported under an internally generated +single-use password, so there is none to supply. Drop the argument. +``` + +Note that a re-export can prompt for consent again unless access was +pre-granted — see [](#export-requires-consent). See also +[](expiry-and-rotation.md). + +## Just the SSL context + +`build_macos_ssl_context()` takes the same selectors and returns the +{py:class}`ssl.SSLContext` alone, mirroring `build_windows_ssl_context`: + +```python +from httpx_pki import build_macos_ssl_context + +ctx = build_macos_ssl_context(name="ACME", key_usage="digital_signature") +``` + +See [](advanced.md). + +## Next steps + +- [](windows-store.md) — the same idea on Windows +- [](inspecting-a-certificate.md) — what the selected certificate says about + itself +- [](server-trust.md) — verifying the server you are connecting to diff --git a/docs/guide/server-trust.md b/docs/guide/server-trust.md new file mode 100644 index 0000000..fe64ae9 --- /dev/null +++ b/docs/guide/server-trust.md @@ -0,0 +1,269 @@ +# Server trust (`verify`) + +Two different certificates are in play on an mTLS connection, and httpx-pki +keeps them separate: + +- the **client certificate** you present, which is everything the rest of this + guide is about +- the **server's** certificate, which `verify` decides how to check + +`verify` behaves just like httpx2, plus two literals of httpx-pki's own. + +| `verify=` | Meaning | +| --- | --- | +| `True` | **Default.** The operating-system trust store | +| `"system"` | Explicit synonym of `True` | +| `"certifi"` | Pin the certifi CA bundle | +| a path | A CA bundle — PEM or certs-only PKCS#7 | +| an `ssl.SSLContext` | Your own — the client certificate is loaded **into it**, [with caveats](#passing-your-own-ssl-context) | +| `False` | No verification, with a warning | + +## The default: the OS trust store + +Since 0.8, `verify=True` verifies the server against the **operating-system +trust store** — Windows CryptoAPI, the macOS Security framework, or OpenSSL's +system CA paths on Linux — via the same +[truststore](https://truststore.readthedocs.io/en/latest/) machinery httpx2 and pip use. + +That is where private CAs distributed through your OS live: group policy, MDM, +or a TLS-inspecting corporate proxy. It is the difference between working and +not for the classic failure where your client certificate loads fine and then: + +```text +CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate +``` + +certifi has never heard of your company's internal CA. The OS store has. + +`verify="system"` is an explicit synonym, kept from when the OS store was +opt-in. Both spellings survive pickling. + +:::{note} +Upgrading from 0.7 or earlier? This is a **behavior change** — `verify=True` +used to mean the certifi bundle. Pass `verify="certifi"` to keep the old +behavior. +::: + +## A private CA from a file + +When the CA is not in the OS store — the usual case for an internal service +whose CA arrived as an email attachment — point `verify` at it: + +```python +PKIClient("client.p12", password="secret", verify="/etc/ssl/custom-ca.pem") +``` + +The bundle may be PEM **or a certs-only PKCS#7** (DER or PEM), which is handy +when the CA was exported from a Windows CA — a format OpenSSL itself cannot +read as a `cafile`. + +### The extension does not matter here either + +Just as with [certificate files](loading-certificates.md#the-extension-does-not-matter), +what counts is the bytes, not the name. CA chains turn up as `.crt` at least as +often as `.pem`, and both work — as does a file with several PEM certificates +concatenated, which is the usual shape of a chain: + +```python +verify="/etc/pki/internal-ca.crt" # PEM content, .crt name +verify="/etc/pki/internal-ca.cer" # PEM content, .cer name +verify="/etc/pki/chain.pem" # several PEM certs concatenated +verify="/etc/pki/chain.p7b" # PKCS#7, DER or PEM +``` + +:::{note} +One genuine limit: a **bare DER certificate** is not accepted as a CA bundle, +even though DER is fine for the client certificate. Convert it to PEM, or wrap +it in a PKCS#7: + +```text +CertificateLoadError: could not load CA bundle 'ca.der': +[X509: NO_CERTIFICATE_OR_CRL_FOUND] no certificate or crl found +``` + +```console +$ openssl x509 -inform der -in ca.der -out ca.pem +``` +::: + +:::{tip} +A CA-bundle file literally named `system` or `certifi` would collide with the +literals. Pass it as a `Path` to disambiguate: + +```python +PKIClient("client.p12", password="secret", verify=Path("system")) +``` +::: + +## Pinning certifi + +The certifi bundle — the default through 0.7, and still what the original httpx +uses for `verify=True` — remains available by name, for callers who want +exactly the bundled public CAs regardless of what the OS store holds: + +```python +PKIClient("client.p12", password="secret", verify="certifi") +``` + +This works with every constructor and with `build_ssl_context`. For +`from_env`, `HTTPX_PKI_CA=certifi` (or `system`) selects the corresponding +trust — see [](environment.md#server-trust). + +## Passing your own SSL context + +You can hand `verify` a ready-made {py:class}`ssl.SSLContext`, but there is a +sharp edge: + +:::{warning} +httpx-pki loads the client certificate **into that exact object** — an +`SSLContext` cannot be copied. Sharing one context across several clients means +each load overwrites the previous certificate. You get a warning: + +```text +TLSConfigWarning: verify= was given a pre-built ssl.SSLContext; httpx-pki +loads the client certificate into it in place. Do not share this context with +other clients -- use verify=True or a CA-bundle path (letting httpx-pki build +a dedicated context) if it must stay cert-free. +``` +::: + +Pass `verify=True` or a CA-bundle path instead and httpx-pki builds a dedicated +context per client. + +### Sharing a context swaps the identity on the wire + +This is not a stylistic warning — the consequence is that a client presents +somebody else's certificate. Building one context and reusing it looks like an +obvious optimization: + +```python +import ssl +from httpx_pki import PKIClient + +# ❌ One context, shared between two identities +shared = ssl.create_default_context(cafile="/etc/pki/internal-ca.pem") + +alice = PKIClient("alice.p12", password="…", verify=shared) +bob = PKIClient("bob.p12", password="…", verify=shared) + +alice.get("https://mtls.example.com/") # presents BOB's certificate +``` + +Against a real server that reports the certificate it received: + +```text +alice-client presents : 39A205B9… ✓ as expected +bob-client presents : B0685021… ✓ as expected +alice-client, again : B0685021… ✗ now presenting bob's certificate +``` + +Constructing `bob` called `load_cert_chain` on the same object, overwriting +alice's certificate. Nothing in `alice` reflects this — +`alice.cert_info()` still reports alice's certificate. Only the server sees the +swap. + +The fix is a context per client. Simplest is to not build one at all: + +```python +CA = "/etc/pki/internal-ca.pem" + +# ✅ httpx-pki builds a dedicated context for each +alice = PKIClient("alice.p12", password="…", verify=CA) +bob = PKIClient("bob.p12", password="…", verify=CA) + +# ✅ Or, if you need to configure it yourself, construct one per client +alice = PKIClient( + "alice.p12", + password="…", + verify=ssl.create_default_context(cafile=CA), +) +bob = PKIClient( + "bob.p12", + password="…", + verify=ssl.create_default_context(cafile=CA), +) +``` + +:::{danger} +**Threads make this a race.** A shared context has no per-client state, so two +threads each constructing a client over it swap identities depending on +ordering — no error, no failed handshake, just requests authenticated as the +wrong principal, intermittently: + +```python +# ❌ Racy: every worker loads its certificate into the same object +shared = ssl.create_default_context(cafile=CA) + +def fetch(p12: str) -> str: + client = PKIClient(p12, password="…", verify=shared) + return client.get("https://mtls.example.com/").text + +with ThreadPoolExecutor() as pool: + list(pool.map(fetch, ["alice.p12", "bob.p12"])) +``` + +Give every client its own context. Passing `verify=True` or a CA-bundle path is +the simplest way, since httpx-pki then builds a dedicated one per client. +::: + +Note that a client itself is fine to use from multiple threads — httpx clients +are thread-safe. The hazard is specifically **sharing one `SSLContext` object +across client constructions**. + +### Pickling drops a custom context + +Pickling matters at **process** boundaries — `multiprocessing`, a +`ProcessPoolExecutor`, a prefork task queue — not between threads, which share +memory and never pickle. + +A custom context does not survive that trip. The unpickled client does not +fail; it quietly falls back to default verification, having warned you: + +```text +PicklingWarning: a custom ssl.SSLContext passed as verify= cannot be pickled; +the unpickled client falls back to default server verification. +``` + +If you pickled the client precisely to carry a restrictive trust configuration +into a worker, that configuration is gone and verification is weaker than you +intended. `verify=True`, `"system"`, `"certifi"`, and CA-bundle paths all +survive pickling — prefer them. + +## Disabling verification + +```python +PKIClient("client.p12", password="secret", verify=False) +``` + +```text +TLSConfigWarning: verify=False disables server certificate verification; +connections are vulnerable to man-in-the-middle attacks. +``` + +Presenting a client certificate to a server you have not authenticated is +worth thinking twice about — it proves who *you* are to an endpoint you have +not established the identity of. Prefer pointing `verify` at the CA bundle, +even in development. + +## Debugging with `SSLKEYLOGFILE` + +Contexts httpx-pki builds honor the standard `SSLKEYLOGFILE` variable, writing +TLS session keys where a capture tool such as Wireshark can use them to decrypt +the handshake — invaluable when an mTLS failure is not saying much: + +```console +$ SSLKEYLOGFILE=/tmp/keys.log python -m myapp +``` + +A context you passed in yourself is left untouched. + +:::{danger} +This decrypts your traffic by design. Never set it in production. See +[](../about/security.md). +::: + +## Next steps + +- [](expiry-and-rotation.md) — keeping a long-lived client working +- [](advanced.md) — building the SSL context yourself +- [](inspecting-a-certificate.md) — what you are presenting diff --git a/docs/guide/testing.md b/docs/guide/testing.md new file mode 100644 index 0000000..468f446 --- /dev/null +++ b/docs/guide/testing.md @@ -0,0 +1,155 @@ +# Testing helpers + +Testing mTLS code means having certificates, and checking key material into a +repository is a bad habit. `httpx_pki.testing` mints throwaway certificates so +your suite does not have to re-derive the `cryptography` boilerplate. + +```python +from httpx_pki import PKIClient +from httpx_pki.testing import make_ca, make_client_cert + +ca = make_ca() +bundle = make_client_cert("svc-client", ca=ca, dns_names=["svc.internal"]) + +with PKIClient(bundle.pkcs12(), password=b"") as client: + assert client.cn == "svc-client" +``` + +The module is not imported by `httpx_pki` itself — import it explicitly. It is +for tests, and it is not a CA. + +## What you get back + +Both `make_ca()` and `make_client_cert()` return a `CertBundle`, which can hand +you the material in whatever shape the code under test wants: + +| Accessor | | +| --- | --- | +| `.pkcs12(password=b"")` | A PKCS#12 blob | +| `.pem` | Key and certificate concatenated, ready for `PKIClient(...)` | +| `.cert_pem` | The certificate alone | +| `.key_pem` | The unencrypted private key alone | +| `.ca_pem` | The issuing CA's certificate — useful as `verify=` | +| `.common_name` | The subject common name | +| `.issuer` | The issuing `CertBundle`, or `None` | + +```python +PKIClient(bundle.pkcs12(), password=b"") # PKCS#12 +PKIClient(bundle.pem) # PEM bundle +PKIClient.from_key_pair(bundle.cert_pem, bundle.key_pem) +``` + +## Realistic extensions by default + +Minted certificates carry what a real CA would issue — a +`digital_signature` / `key_encipherment` KeyUsage and a `client_auth` +ExtendedKeyUsage — so servers that enforce EKU accept them: + +```python +info = client.cert_info() +info.key_usage # frozenset({'digital_signature', 'key_encipherment'}) +info.extended_key_usage # ['client_auth'] +``` + +Override either to test your own selection logic: + +```python +make_client_cert("me", ca=ca, key_usage=["digital_signature"]) +make_client_cert("me", ca=ca, extended_key_usage=["email_protection"]) +``` + +## Exercising the validity paths + +```python +expired = make_client_cert("old", ca=ca, expired=True) + +future = make_client_cert( + "new", + ca=ca, + not_valid_before=datetime.now(timezone.utc) + timedelta(days=5), + not_valid_after=datetime.now(timezone.utc) + timedelta(days=50), +) +``` + +```python +PKIClient(expired.pem).is_expired # True +PKIClient(future.pem).is_not_yet_valid # True +``` + +Both emit a `CertificateValidityWarning` on load, so a test that builds one +deliberately will want to filter it — see +[](../reference/exceptions.md#filtering-warnings). + +## Multi-identity bundles + +`make_pkcs12` writes **several identities into one bundle**, which nothing else +readily does — `cryptography` and the `openssl` command line both keep a single +key. That makes it the only convenient way to test how your code handles a dual +key pair: + +```python +from httpx_pki.testing import make_ca, make_client_cert, make_pkcs12 + +ca = make_ca() +signing = make_client_cert("me", ca=ca, key_usage=["digital_signature"]) +encryption = make_client_cert("me", ca=ca, key_usage=["key_encipherment"]) + +blob = make_pkcs12( + [(signing, "Signature"), (encryption, "Encryption")], + password="secret", +) +``` + +Each entry is a `CertBundle`, or a `(bundle, friendly_name)` tuple when you want +the identity labelled. The result behaves exactly like a real dual key pair: + +```python +PKIClient(blob, password="secret") # AmbiguousCertificateError +PKIClient(blob, password="secret", key_usage="digital_signature") # picks Signature +``` + +See [](choosing-a-certificate.md). + +## Bundle layout + +By default `make_pkcs12` lays the file out the way OpenSSL and Windows write +one: certificates in a PBES2-encrypted block, each key individually shrouded, +and an HMAC over the whole file. Three flags produce the plainer variants, for +testing a parser against the shapes it will meet in the wild: + +```python +make_pkcs12([bundle], password="pw", encrypt_certs=False) +make_pkcs12([bundle], password="pw", mac=False) +make_pkcs12([bundle], password="pw", keys_in_encrypted_safe=True) +make_pkcs12([bundle]) # no password at all +``` + +## A pytest fixture + +```python +import pytest +from httpx_pki import PKIClient +from httpx_pki.testing import make_ca, make_client_cert + + +@pytest.fixture(scope="session") +def ca(): + return make_ca() + + +@pytest.fixture +def client(ca): + bundle = make_client_cert("test-client", ca=ca) + with PKIClient(bundle.pem, verify=False) as session: + yield session +``` + +For a full round trip, point a local TLS server at `ca.cert_pem` as its client +CA and pass `verify=bundle.ca_pem` to the client — the two halves of the same +CA. + +## Next steps + +- [](choosing-a-certificate.md) — what multi-identity bundles are for +- [](expiry-and-rotation.md) — what the expired-certificate paths do +- [](../reference/api.md#testing-helpers) — the generated API reference diff --git a/docs/guide/windows-store.md b/docs/guide/windows-store.md new file mode 100644 index 0000000..71d11ba --- /dev/null +++ b/docs/guide/windows-store.md @@ -0,0 +1,199 @@ +# The Windows certificate store + +On Windows, the certificate you need is often already in the user's personal +store — enrolled by Active Directory, pushed by group policy, or imported by +hand — with no file to point at. `from_windows_cert_store` pulls it out +directly: + +```python +from httpx_pki import PKIClient + +with PKIClient.from_windows_cert_store(name="ACME Client") as client: + client.get("https://mtls.example.com/") +``` + +`name` is a case-insensitive substring of either the subject common name or the +Windows "friendly name", so you rarely need the exact string. + +:::{important} +**Windows only.** Calling this anywhere else raises `UnsupportedPlatformError`: + +```text +UnsupportedPlatformError: the Windows certificate store is only available on Windows +``` + +`AsyncPKIClient.from_windows_cert_store(...)` is the async equivalent. +::: + +## The certificate must be exportable + +httpx-pki needs the private key, so the certificate has to have been imported +with its key marked **exportable**. If it was not, the export fails with +`CertificateLoadError`. + +No password is involved: the certificate is exported under a random, +single-use password that never leaves the library. + +## Looking before you select + +`list_windows_certificates()` returns a `WinCert` per certificate — metadata +only, with no key exported: + +```python +from httpx_pki import list_windows_certificates + +for c in list_windows_certificates(): + print(c.friendly_name, c.subject_cn, c.thumbprint, sorted(c.key_usage)) +``` + +| Attribute | | +| --- | --- | +| `subject_cn` | Subject common name | +| `friendly_name` | The Windows friendly name | +| `thumbprint` | SHA-1 thumbprint, uppercase hex | +| `certificate` | The parsed `x509.Certificate` | +| `info` | Its {py:class}`~httpx_pki.CertInfo` | +| `key_usage` / `extended_key_usage` | Convenience accessors onto `info` | + +Because each record carries the parsed certificate, a predicate can select on +anything a certificate holds. See [](inspecting-a-certificate.md) for what +`CertInfo` exposes. + +## Selecting + +If more than one certificate matches, you get an `AmbiguousCertificateError` +listing the candidates with their usages, expiry, and thumbprints: + +```text +name='ACME' matched 2 certificates: + ACME Client key_usage=digital_signature expires=2027-08-02 AA11BB + ACME Client key_usage=key_encipherment expires=2027-08-02 CC22DD +Narrow it with a more specific name, a key usage, or an exact thumbprint. +``` + +Narrow it with any combination of selectors: + +```python +# By exact thumbprint — colons and case are ignored +PKIClient.from_windows_cert_store(thumbprint="A1:B2:C3:...") + +# By key usage — the usual discriminator for a dual key pair +PKIClient.from_windows_cert_store(name="ACME", key_usage="digital_signature") + +# By extended key usage +PKIClient.from_windows_cert_store(name="ACME", extended_key_usage="client_auth") + +# By any predicate over the WinCert +PKIClient.from_windows_cert_store(identity=lambda c: c.friendly_name == "prod") + +# By identity — the portable spelling: a name substring, an exact +# fingerprint, or a predicate, exactly as a PKCS#12 bundle accepts +PKIClient.from_windows_cert_store(identity="ACME") +``` + +`identity=` is the same keyword a bundle takes, so a selector written for a +`.p12` carries over to the store unchanged. The one form it does *not* accept +here is an integer position — a store has no stable ordering, so a position +would pick a different certificate from one run to the next: + +```text +TypeError: identity= cannot be an integer for a platform certificate store: +a store has no stable ordering... +``` + +`name=` and `thumbprint=` remain the unambiguous spellings, for when you want +to force one interpretation rather than let a bare string be either. + +:::{note} +**Every selector you pass must match.** They intersect rather than falling +back, so a thumbprint from one certificate combined with a name from another +matches nothing: + +```text +CertificateNotFoundError: thumbprint='AA11BB' + name='Encryption' matched no +certificate in the store, which holds: ... +``` +::: + +### Dual key pairs + +AD key archival provisions both halves of a dual key pair into the store under +one subject, so `name=` alone will not separate them — the key usage is what +does: + +```python +PKIClient.from_windows_cert_store(name="ACME", key_usage="digital_signature") +``` + +For mTLS you want the signing half; the background is in +[](choosing-a-certificate.md#why-one-file-holds-two-certificates). + +### Skipping the expired copy + +A store tends to keep the old certificate after a renewal. The ready-made +`currently_valid` selector filters those out: + +```python +from httpx_pki import currently_valid + +PKIClient.from_windows_cert_store(name="ACME", identity=currently_valid) +``` + +Where two remain valid during a renewal overlap, it resolves to the later +window. + +## Choosing the store + +Both arguments default to the user's personal store: + +```python +PKIClient.from_windows_cert_store(name="ACME", store="MY", location="CurrentUser") +``` + +- `store` — `"MY"` (personal), `"CA"`, `"ROOT"`, or any store name +- `location` — `"CurrentUser"` or `"LocalMachine"` + +`list_windows_certificates(store=..., location=...)` takes the same two. + +## Reloading + +There is no file to watch, so `auto_reload` is not offered here. `reload()` +still works and re-exports from the store on demand, which picks up a +certificate that has been renewed in place: + +```python +client.reload() +``` + +No password is involved, so `reload()` takes none. Passing one raises rather +than being silently ignored, since the export uses an internal single-use +password: + +```text +TypeError: reload(password=...) does not apply to a client built from the +Windows certificate store: the certificate is exported under an internally +generated single-use password, so there is none to supply. Drop the argument. +``` + +See [](expiry-and-rotation.md). + +## Just the SSL context + +`build_windows_ssl_context()` takes the same selectors and returns the +{py:class}`ssl.SSLContext` alone, for mounting on a transport or a routing +layer: + +```python +from httpx_pki import build_windows_ssl_context + +ctx = build_windows_ssl_context(name="ACME", key_usage="digital_signature") +``` + +See [](advanced.md). + +## Next steps + +- [](macos-keychain.md) — the same idea on macOS +- [](inspecting-a-certificate.md) — what the selected certificate says about + itself +- [](server-trust.md) — verifying the server you are connecting to diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8f7328f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,107 @@ +# httpx-pki + +PKCS#12 client-certificate (mTLS) sessions for [httpx2](https://github.com/pydantic/httpx2) +and httpx. + +`httpx-pki` gives you an httpx client that presents a client certificate, loaded +from wherever your certificate actually lives — a `.p12`/`.pfx` bundle, a PEM +file, a separate key and cert, the Windows certificate store, or the macOS +keychain — without hand-rolling an `ssl.SSLContext` or writing the private key +to a temporary file. + +```python +from httpx_pki import PKIClient + +with PKIClient("client.p12", password="secret") as client: + r = client.get("https://mtls.example.com/") +``` + +:::{admonition} Backend +:class: note + +Since 0.8, httpx-pki depends on **httpx2** and `PKIClient` subclasses +`httpx2.Client`. The original httpx remains fully supported as a fallback — +see [](guide/backends.md). +::: + +## Where to start + +:::::{grid} 1 1 2 2 +:gutter: 3 + +::::{grid-item-card} {octicon}`rocket` Quickstart +:link: quickstart +:link-type: doc + +Install, load a certificate, make a request. +:::: + +::::{grid-item-card} {octicon}`book` User guide +:link: guide/index +:link-type: doc + +Every source format, identity selection, server trust, rotation. +:::: + +::::{grid-item-card} {octicon}`code` API reference +:link: reference/index +:link-type: doc + +Every public class, function, exception, and warning. +:::: + +::::{grid-item-card} {octicon}`gear` How it works +:link: about/how-it-works +:link-type: doc + +What happens between your bundle and the TLS handshake. +:::: + +::::: + +```{toctree} +:hidden: +:caption: Getting started + +install +quickstart +troubleshooting +``` + +```{toctree} +:hidden: +:caption: User guide + +guide/index +guide/backends +guide/loading-certificates +guide/choosing-a-certificate +guide/inspecting-a-certificate +guide/windows-store +guide/macos-keychain +guide/environment +guide/server-trust +guide/expiry-and-rotation +guide/advanced +guide/testing +``` + +```{toctree} +:hidden: +:caption: Reference + +reference/index +reference/api +reference/exceptions +``` + +```{toctree} +:hidden: +:caption: About + +about/how-it-works +about/security +about/non-goals +about/supply-chain +about/changelog +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..30d20b8 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,91 @@ +# Install + +httpx-pki supports **Python 3.10 through 3.14** on Linux, macOS, and Windows. + +::::{tab-set} + +:::{tab-item} pip +```console +$ pip install httpx-pki +``` +::: + +:::{tab-item} uv +```console +$ uv add httpx-pki +``` +::: + +:::{tab-item} Poetry +```console +$ poetry add httpx-pki +``` +::: + +:::: + +## Check it worked + +```python +import httpx_pki + +print(httpx_pki.__version__) # 0.8.0 +print(httpx_pki.HTTP_BACKEND) # 'httpx2' +``` + +`HTTP_BACKEND` reports which HTTP library the client classes were built on. On +a normal install it is always `'httpx2'`. + +## Using httpx instead of httpx2 + +httpx-pki still works with the original httpx, and the two are equally +supported — see [](guide/backends.md) for what actually differs. The backend is +chosen at import time by what is installed: **httpx2 absent and `httpx>=0.28` +present means httpx-pki binds to httpx**, with no configuration. + +So the goal is an environment with httpx and no httpx2. Install with +`--no-deps` to stop pip pulling httpx2 in, then install the runtime +dependencies yourself: + +```console +$ pip install "httpx>=0.28" cryptography truststore +$ pip install --no-deps httpx-pki +``` + +Confirm the result: + +```python +import httpx_pki + +print(httpx_pki.HTTP_BACKEND) # 'httpx' +``` + +:::{note} +`pip check` will report `httpx-pki requires httpx2, which is not installed`. +That is expected and harmless — httpx2 is declared as a hard requirement so +the ordinary install needs no extras, and `--no-deps` is the documented way +out of it. Nothing at runtime consults the metadata. +::: + +### If both httpx and httpx2 are installed + +When httpx2 arrives anyway — usually as some other package's transitive +dependency — httpx-pki prefers it. Set `HTTPX_PKI_BACKEND` to force the +choice: + +```console +$ HTTPX_PKI_BACKEND=httpx python -m myapp +``` + +Details in [](guide/backends.md). + +## Development install + +```console +$ git clone https://github.com/ccbest/httpx-pki +$ cd httpx-pki +$ pip install -e ".[dev]" +$ pytest +``` + +`[docs]` installs the Sphinx toolchain for building this site. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..f0cc8be --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,146 @@ +# Quickstart + +Already [installed](install.md)? This page gets you from a certificate file to +an authenticated request. + +## Your first request + +`PKIClient` is an httpx client that presents a client certificate. Point it at +a PKCS#12 bundle and use it exactly like `httpx.Client`: + +```python +from httpx_pki import PKIClient + +with PKIClient("client.p12", password="secret") as client: + resp = client.get("https://mtls.example.com/") + print(resp.status_code) +``` + +There is no `ssl.SSLContext` to build and no key file left behind — the private +key is decrypted into memory and mounted on the connection, never written +anywhere that outlives the load. See [](about/how-it-works.md#staging-the-key-never-touches-disk-on-linux) +for what that means on each platform. + +## Whatever you were handed, there is a one-liner for it + +Client certificates arrive in a lot of shapes. httpx-pki takes all of them: + +```python +from pathlib import Path +from httpx_pki import PKIClient + +# PKCS#12 bundle — key + cert + chain in one blob +PKIClient("client.p12", password="secret") +PKIClient(Path("client.pfx"), password="secret") + +# PEM bundle — key + cert(s) in one file, any block order +PKIClient("client.pem") + +# Raw bytes you already have in hand (the password may be bytes too) +PKIClient(p12_bytes, password=b"secret") + +# Separate certificate and key, PEM or DER +PKIClient.from_key_pair("client.crt", "client.key") + +# ...with intermediates, as PEM or PKCS#7 +PKIClient.from_key_pair("client.crt", "client.key", chain="chain.p7b") + +# The Windows certificate store (Windows only) +PKIClient.from_windows_cert_store(name="Acme Corp") + +# The macOS keychain (macOS only) +PKIClient.from_macos_keychain(name="Acme Corp") + +# Configured entirely by environment variables +PKIClient.from_env() +``` + +That last one is how you keep the source out of your code altogether — set +`HTTPX_PKI_CERT` (and friends) in the environment and the same image runs +anywhere. See [](guide/environment.md). + +Note what is *not* in that list: any step where you tell httpx-pki which format +you have. Certificate files come with all sorts of extensions — `.p12`, `.pfx`, +`.pem`, `.crt`, `.tls`, `.ukey` — but an extension is just a name. httpx-pki +detects the encoding from the **bytes**, so pointing `PKIClient` at whatever +your PKI team sent you generally just works. + +Use the explicit `from_pkcs12` / `from_pem` constructors when you would rather +force one interpretation than rely on detection. + +:::{tip} +If a source holds more than one identity — a dual key pair, or a renewed +certificate kept alongside the one it replaced — httpx-pki refuses to guess and +raises `AmbiguousCertificateError`. See [](guide/choosing-a-certificate.md) for +how to pick one. +::: + +Every form above is covered in full in [](guide/loading-certificates.md). + +## Async + +`AsyncPKIClient` is the `httpx.AsyncClient` equivalent, and takes every +constructor and option the synchronous class does: + +```python +from httpx_pki import AsyncPKIClient + +async with AsyncPKIClient("client.p12", password="secret") as client: + resp = await client.get("https://mtls.example.com/") +``` + +## Passing httpx options + +Any keyword argument httpx-pki does not consume flows straight through to the +underlying httpx client: + +```python +PKIClient( + "client.p12", + password="secret", + base_url="https://api.example.com", + headers={"User-Agent": "me"}, + timeout=10.0, +) +``` + +:::{note} +`http2=True` works too, but — as with plain httpx — it needs the `h2` package: +`pip install h2`. +::: + +## Verifying the server + +The certificate above is what *you* present. `verify` controls how the +**server** is checked, and the two are independent. + +The default, `verify=True`, is your operating system's trust store: Windows +CryptoAPI, the macOS Security framework, or OpenSSL's system CA paths on Linux. +Certificates issued by a corporate CA that is distributed through the OS +therefore verify with no extra configuration. + +When the private CA is *not* in the OS store — the common case for an internal +service whose CA came to you as a file — point `verify` at it: + +```python +# Any PEM CA bundle, or a certs-only PKCS#7 (.p7b) +PKIClient("client.p12", password="secret", verify="/etc/pki/internal-ca.pem") +``` + +To pin the certifi bundle instead: + +```python +PKIClient("client.p12", password="secret", verify="certifi") +``` + +Custom SSL contexts and turning verification off are covered in +[](guide/server-trust.md). + +## Next steps + +- [](guide/loading-certificates.md) — every source format in full +- [](guide/choosing-a-certificate.md) — picking one when a source holds several +- [](guide/expiry-and-rotation.md) — hot reload and expiry warnings for + long-lived clients +- [](guide/testing.md) — throwaway certificates for your test suite +- [](troubleshooting.md) — when the load fails, or the handshake does diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..e37748b --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,99 @@ +# API reference + +Everything exported from `httpx_pki`, grouped by what it is for. Anything not +listed here is private and may change without a major version bump. + +## Clients + +The two session classes. Both take the same alternate constructors and +certificate behavior; they differ only in which httpx client they subclass. + +```{eval-rst} +.. autoclass:: httpx_pki.PKIClient + :members: + :private-members: _init_state + :inherited-members: Client, BaseClient + :show-inheritance: + +.. autoclass:: httpx_pki.AsyncPKIClient + :members: + :private-members: _init_state + :inherited-members: AsyncClient, BaseClient + :show-inheritance: +``` + +## SSL contexts + +For callers who want the configured {py:class}`ssl.SSLContext` without the +client — see [](../guide/advanced.md). + +```{eval-rst} +.. autofunction:: httpx_pki.build_ssl_context + +.. autofunction:: httpx_pki.build_windows_ssl_context + +.. autofunction:: httpx_pki.build_macos_ssl_context +``` + +## Discovery and selection + +Inspect what a source holds before mounting anything — see +[](../guide/choosing-a-certificate.md). + +```{eval-rst} +.. autofunction:: httpx_pki.list_identities + +.. autofunction:: httpx_pki.list_pkcs12_identities + +.. autofunction:: httpx_pki.list_windows_certificates + +.. autofunction:: httpx_pki.select_windows_certificate + +.. autofunction:: httpx_pki.list_macos_certificates + +.. autofunction:: httpx_pki.select_macos_certificate + +.. autofunction:: httpx_pki.currently_valid +``` + +## Data types + +```{eval-rst} +.. autoclass:: httpx_pki.CertInfo + :members: + +.. autoclass:: httpx_pki.Material + :members: + +.. autoclass:: httpx_pki.P12Identity + :members: + +.. autoclass:: httpx_pki.WinCert + :members: + +.. autoclass:: httpx_pki.MacCert + :members: + +.. autofunction:: httpx_pki.cert_info +``` + +## Backend resolution + +```{eval-rst} +.. autodata:: httpx_pki.HTTP_BACKEND +``` + +See [](../guide/backends.md). + +(testing-helpers)= +## Testing helpers + +Throwaway certificate generation for test suites — see +[](../guide/testing.md). This module is not imported by `httpx_pki` itself; +import it explicitly as `httpx_pki.testing`. + +```{eval-rst} +.. automodule:: httpx_pki.testing + :members: + :member-order: bysource +``` diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md new file mode 100644 index 0000000..1bc31ae --- /dev/null +++ b/docs/reference/exceptions.md @@ -0,0 +1,217 @@ +# Exceptions and warnings + +Everything httpx-pki tells you about, in one place. This page is organized by +what httpx-pki raises; if you have an error in hand and want to know what to do +about it — including the handshake failures that come from OpenSSL rather than +from here — start at [](../troubleshooting.md). + +The split is deliberate. httpx-pki **raises** when it cannot do what you asked, +and **warns** when it can proceed but the result is probably not what you +intended — an expired certificate you may be deliberately inspecting, a +`verify=False` in a throwaway script. + +``` +Exception UserWarning +└── PKIError └── PKIWarning + ├── CertificateLoadError ├── CertificateValidityWarning + ├── CertificateExpiredError ├── TLSConfigWarning + ├── CertificateNotYetValidError└── PicklingWarning + ├── CertificateNotFoundError + ├── AmbiguousCertificateError + └── UnsupportedPlatformError +``` + +Both base classes exist so one `except` or one filter reaches everything. +Neither is raised directly. + +## Exceptions + +```{eval-rst} +.. autoexception:: httpx_pki.PKIError + :show-inheritance: + +.. autoexception:: httpx_pki.CertificateLoadError + :show-inheritance: + +.. autoexception:: httpx_pki.CertificateNotFoundError + :show-inheritance: + +.. autoexception:: httpx_pki.AmbiguousCertificateError + :show-inheritance: + +.. autoexception:: httpx_pki.CertificateExpiredError + :show-inheritance: + +.. autoexception:: httpx_pki.CertificateNotYetValidError + :show-inheritance: + +.. autoexception:: httpx_pki.UnsupportedPlatformError + :show-inheritance: +``` + +### What they look like + +| Message | Cause | +| --- | --- | +| `invalid PKCS#12 data or wrong password` | Wrong password, or the bytes are not PKCS#12 | +| `could not parse private key (wrong password?)` | Encrypted key with a wrong or missing `password` | +| `no private key found in PEM data` | The PEM holds certificates only — see [](../troubleshooting.md#no-private-key) | +| `PKCS#12 data contains no private key` | The bundle holds certificates only — see [](../troubleshooting.md#no-private-key) | +| `the data is a DER certificate with no private key; …` | A bare `.crt`/`.cer` passed as the single source; use `from_key_pair` | +| `the data is a certificate-only PKCS#7 bundle with no private key; …` | A `.p7b` passed as the single source; it belongs in `chain=` or `verify=` | +| `no certificate found in PEM data` | The reverse — a key with no certificate | +| `PKCS#12 data contains no certificate` | The reverse — a key with no certificate | +| `private key does not match certificate (their public keys differ)` | The cert and key are not a pair — see [](../guide/loading-certificates.md#when-the-key-and-certificate-do-not-match) | +| `private key does not match any certificate in the PEM data` | Same, within one bundle — it was assembled from the wrong pieces | +| `could not load CA bundle …: [X509: NO_CERTIFICATE_OR_CRL_FOUND]` | A `verify=` bundle that is bare DER — see [](../guide/server-trust.md#the-extension-does-not-matter-here-either) | +| `this PKCS#12 data holds 2 identities: …` | Several identities, no selector — see [](../guide/choosing-a-certificate.md) | +| `key_usage='crl_sign' matched no identity …` | A selector that matched nothing | +| `the Windows certificate store is only available on Windows` | Platform-specific constructor off-platform | +| `client certificate expired on 2026-08-01 18:02 UTC` | From `check_validity()` or `strict_validity=True` | + +:::{note} +Not every failure is a `PKIError`. Asking for `auto_reload` on a source with no +file to watch is a plain `TypeError`, because it is a programming error rather +than a certificate problem: + +```text +TypeError: auto_reload requires a filesystem-path certificate source to watch +``` + +The same goes for `reload(password=...)` on a source that supplies its own — +`from_env`, the Windows store, or the macOS keychain. The password would have +nothing to decrypt, so it is refused rather than quietly discarded. See +[](../guide/expiry-and-rotation.md#passwords-and-unattended-reloads). +::: + +## Warnings + +```{eval-rst} +.. autoexception:: httpx_pki.PKIWarning + :show-inheritance: + +.. autoexception:: httpx_pki.CertificateValidityWarning + :show-inheritance: + +.. autoexception:: httpx_pki.TLSConfigWarning + :show-inheritance: + +.. autoexception:: httpx_pki.PicklingWarning + :show-inheritance: +``` + +### What they look like + +`CertificateValidityWarning` — the certificate cannot be used, or soon will not +be. See [](../guide/expiry-and-rotation.md). + +```text +client certificate expired on 2026-08-01; mTLS handshakes will fail. + +client certificate is not valid until 2026-09-01; mTLS handshakes will fail +until then. + +client certificate expires on 2026-08-07 (in 4 day(s)). +``` + +The third fires only when you asked for it with `warn_if_expires_within=`. The +first two always fire — loading an unusable certificate is never silent. + +`TLSConfigWarning` — a setup that runs but does not do what it looks like it +does. These are worth reading rather than silencing. + +```text +verify=False disables server certificate verification; connections are +vulnerable to man-in-the-middle attacks. + +verify= was given a pre-built ssl.SSLContext; httpx-pki loads the client +certificate into it in place. Do not share this context with other clients -- +use verify=True or a CA-bundle path (letting httpx-pki build a dedicated +context) if it must stay cert-free. + +a custom transport=/mounts= makes httpx ignore verify=, so the client +certificate is NOT mounted on this session. Build the context with +build_ssl_context() and put it on the inner transport instead, e.g. +httpx.HTTPTransport(verify=ctx). +``` + +Between them: no client certificate presented, no server verified, or two +clients quietly sharing one identity. See +[](../guide/server-trust.md#passing-your-own-ssl-context) and +[](../guide/advanced.md). + +`PicklingWarning` — configuration that will not survive `pickle`, which matters +at process boundaries such as `multiprocessing` or a prefork task queue. +Neither is fatal; the unpickled client works with less than you configured. + +```text +a custom ssl.SSLContext passed as verify= cannot be pickled; the unpickled +client falls back to default server verification. + +the certificate source cannot be pickled; the unpickled client will not be +reloadable. +``` + +## Filtering warnings + +Silence one concern without hiding the others: + +```python +import warnings +from httpx_pki import CertificateValidityWarning + +warnings.filterwarnings("ignore", category=CertificateValidityWarning) +``` + +Or reach all of them at once: + +```python +from httpx_pki import PKIWarning + +warnings.filterwarnings("ignore", category=PKIWarning) +``` + +To silence warnings only around a specific call, scope the filter: + +```python +with warnings.catch_warnings(): + warnings.simplefilter("ignore", CertificateValidityWarning) + client = PKIClient("expired.p12", password="secret") +``` + +### Making them fatal + +Turning `PKIWarning` into an error is a cheap way to catch a misconfiguration +before it ships: + +```python +warnings.filterwarnings("error", category=PKIWarning) +``` + +In a pytest suite, via `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error::httpx_pki.PKIWarning", +] +``` + +:::{tip} +`TLSConfigWarning` is the one most worth promoting to an error in CI — every +message under it describes a client that silently fails to do its job. +::: + +### Why you may only see a warning once + +Python deduplicates warnings by default: the same message from the same line is +shown once per process, so constructing ten clients with `verify=False` prints +one warning, not ten. + +```console +$ python -W always myapp.py # show every occurrence +$ python -W error myapp.py # turn them all into errors +``` + +That is a property of Python's warning machinery, not of httpx-pki — worth +knowing when a warning you expected repeatedly appears only once. diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..535f22a --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,14 @@ +# Reference + +Generated from the docstrings in the package itself. + +- [](api.md) — clients, SSL contexts, discovery and selection, data types, + testing helpers +- [](exceptions.md) — every error and warning httpx-pki produces, what each + message means, and how to filter them + +Environment variables are documented in the guide, at +[](../guide/environment.md). + +For task-shaped explanations of the same surface, start at +[](../guide/index.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..a17c6e1 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,307 @@ +# Troubleshooting + +Find your error in the table, follow the link, apply the fix. If you have no +error at all — the request succeeds and the server still treats you as the wrong +principal — that is the [last group](#it-connects-as-the-wrong-identity). + +## Find your error + +Errors from httpx-pki name the problem and usually the fix. Errors from OpenSSL, +which is everything under *at request time*, do not — your certificate loaded +fine and something about the connection was rejected. + +Rows are keyed on the **distinctive phrase**, not the whole message, since most +of these carry a filename or a count as well. Match on that. + +| What you see | What it means | Fix | +| --- | --- | --- | +| **At construction** | | | +| `invalid PKCS#12 data or wrong password` | Usually the password. httpx-pki says this only after ruling out the cert-only cases below | Check the password, then [](guide/loading-certificates.md) | +| `could not parse private key (wrong password?)` | The key is encrypted and `password=` was wrong or missing | [](guide/loading-certificates.md) | +| `…no private key…` (four wordings) | Your source has certificates but no key — you are holding half a credential | [](#no-private-key) | +| `…no certificate…` (two wordings) | The reverse: a key with no certificate | [](#no-certificate) | +| `…does not match…` (two wordings) | The cert and key you paired are not a pair | [](#the-key-and-certificate-do-not-match) | +| `AmbiguousCertificateError` | Several credentials matched and httpx-pki will not guess. The message lists them | [](guide/choosing-a-certificate.md) | +| `CertificateNotFoundError` | Your selector matched nothing. The message lists what was there to match | [](guide/choosing-a-certificate.md) | +| `NO_CERTIFICATE_OR_CRL_FOUND` | A `verify=` bundle in bare DER, the one format not accepted there | [](guide/server-trust.md#the-extension-does-not-matter-here-either) | +| `HTTPX_PKI_CERT is not set` | `from_env()` with nothing to read | [](guide/environment.md) | +| `only available on Windows` / `on macOS` | A platform constructor called off-platform | [](guide/windows-store.md) | +| **At request time** | | | +| `CERTIFICATE_VERIFY_FAILED` | **You** do not trust the **server's** certificate. Nothing to do with your client certificate | [](#you-do-not-trust-the-server) | +| `TLSV1_ALERT_UNKNOWN_CA` | The **server** does not trust **yours** — most often because you are not sending the intermediates | [](#the-server-does-not-trust-you) | +| `TLSV13_ALERT_CERTIFICATE_REQUIRED` | Your certificate never reached the wire | [](#the-server-wanted-a-certificate-and-did-not-get-one) | +| `SSLV3_ALERT_HANDSHAKE_FAILURE` | The same, on a pre-TLS 1.3 connection | [](#the-server-wanted-a-certificate-and-did-not-get-one) | +| `SSLV3_ALERT_CERTIFICATE_EXPIRED` | Expired, and the server checked | [](#your-certificate-has-expired) | +| **No error at all** | | | +| The server authenticates you as the wrong principal | A shared `ssl.SSLContext`, the wrong half of a dual key pair, or a rotation you did not pick up | [](#it-connects-as-the-wrong-identity) | + +:::{tip} +Not finding your message? [](reference/exceptions.md) has the full set with each +message spelled out — including the **warnings**, which fire on setups that run +without any error at all but do not do what they look like they do. +::: + +## Do you have both halves? + +mTLS needs a **certificate and the private key that matches it**. The +certificate is public and states who you are; the private key is what proves you +are entitled to it. The handshake needs both, so any source you hand to +`PKIClient` has to carry both. + +This is the most common thing to get wrong, because several of the file formats +a PKI team hands out contain no private key at all: + +| Format | Private key inside? | +| --- | --- | +| PKCS#12 — `.p12`, `.pfx` | Usually — carrying both is what the format is for | +| PEM — `.pem` | Maybe — it holds whatever blocks were concatenated into it | +| A single certificate — `.crt`, `.cer` | **No.** A certificate is only ever the public half | +| PKCS#7 — `.p7b`, `.p7c` | **No.** Certificates only; the format cannot hold a key | +| A key file — `.key` | The key, but no certificate to go with it | + +Those names are conventions, not guarantees — as everywhere else in httpx-pki, +[what counts is the bytes](guide/loading-certificates.md#the-extension-does-not-matter). +The reliable way to find out what you have is to try to load it: httpx-pki +inspects the content when a load fails and tells you what it actually found. + +### "…no private key…" + +Four messages say this, depending on what httpx-pki found when it looked: + +```text +CertificateLoadError: no private key found in PEM data + +CertificateLoadError: PKCS#12 data contains no private key + +CertificateLoadError: the data is a DER certificate with no private key; +pass it to from_key_pair(certificate=..., private_key=...) + +CertificateLoadError: the data is a certificate-only PKCS#7 bundle with no +private key; use it as chain= in from_key_pair or as a verify= CA bundle +``` + +***There are three ways to land here, and they need different responses.*** + +**1. The key is in a separate file.** The normal shape outside Windows. Use +`from_key_pair` rather than the single-source constructor: + +```python +PKIClient.from_key_pair("client.crt", "client.key") +``` + +**2. You were sent the chain, not your credential.** A `.p7b` from a Windows CA is +frequently the *issuing chain* — useful as `chain=` when you present your +certificate, or as a `verify=` CA bundle for checking the server, but never a +credential on its own. See [](guide/server-trust.md). + +**3. You genuinely do not have the key.** If you were only ever sent a certificate, +mTLS is not possible with it and no library can change that — the key was either +kept by whoever generated the request, or never left the machine that made it. +Go back to your PKI team and ask for a PKCS#12 export, or issue a fresh +certificate from a CSR you generate yourself, so you hold the key from the +start. + +### "…no certificate…" + +The reverse pair reads the same way: + +```text +CertificateLoadError: no certificate found in PEM data +CertificateLoadError: PKCS#12 data contains no certificate +``` + +A key with no certificate is equally unusable — you have the proof but not the +claim. The fix is the same: find the other half. + +## The key and certificate do not match + +Two wordings, depending on whether you paired two files or handed over one +bundle that was assembled wrongly: + +```text +CertificateLoadError: private key does not match certificate +(their public keys differ) + +CertificateLoadError: private key does not match any certificate +in the PEM data +``` + +httpx-pki compares the public key inside the certificate against the public half +of the private key, and refuses the pair when they differ. Usually one of the +two files is from a different issuance — an older certificate, or the key left +over from a CSR that was superseded. + +Catching it here is deliberate. Left alone it surfaces much later as an +unexplained handshake rejection, with nothing pointing at the file pair. + +## It fails when you make a request + +The certificate loaded, so the problem is the connection. httpx surfaces these +as `ConnectError` or — for alerts that arrive after the handshake appears to +finish, which is normal in TLS 1.3 — as `ReadError`. + +The first question is **which side is complaining**, because the two look +similar and have opposite fixes. + +### You do not trust the server + +```text +[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: +unable to get local issuer certificate +``` + +Your side rejected *their* certificate: nothing in your trust store issued it. +Nothing to do with your client certificate. Almost always an internal service +whose CA is not public. + +Point `verify` at the CA that issued the server's certificate: + +```python +PKIClient("client.p12", password="secret", verify="/etc/pki/internal-ca.pem") +``` + +If the CA is distributed through your OS by group policy or MDM, the default +`verify=True` already reads the OS trust store and should find it. Full detail +in [](guide/server-trust.md). + +### The server does not trust you + +```text +[SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca +``` + +The mirror image: the server could not build a path from your certificate to a +CA it trusts. Two causes, in order of likelihood. + +**You are not sending the intermediates.** Your certificate is almost never +signed by the root directly — there is at least one intermediate CA in between. +The server needs that intermediate to connect your certificate to the root it +trusts, and by convention the *client* supplies it. A PKCS#12 bundle normally +carries the chain and httpx-pki presents it automatically, but a bare +`client.crt` and `client.key` carry nothing, so you must supply it: + +```python +PKIClient.from_key_pair("client.crt", "client.key", chain="intermediate.crt") +``` + +`chain=` also takes a list, or a `.p7b`, which is how Windows CAs usually export +one: + +```python +PKIClient.from_key_pair("client.crt", "client.key", chain=["intermediate.crt", "sub-ca.crt"]) +PKIClient.from_key_pair("client.crt", "client.key", chain="chain.p7b") +``` + +Send every intermediate between your certificate and the root. The root itself +is not normally needed — the server already has it, which is the whole point of +trusting it. + +To see what you are currently presenting, `cert_info()` describes the leaf; see +[](guide/inspecting-a-certificate.md). + +**The server really does not trust your CA.** If the chain is complete, your +certificate is from an issuer the server was never configured to accept. That is +a server-side change, not a client one. + +### The server wanted a certificate and did not get one + +```text +[SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate required +[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure +``` + +Your certificate was never put on the wire. If you passed a custom `transport=` +or `mounts=`, that is the cause — httpx uses the transport as-is and ignores the +client-level `verify=`, so the certificate is silently dropped. httpx-pki warns +about this at construction: + +```text +TLSConfigWarning: a custom transport=/mounts= makes httpx ignore verify=, so +the client certificate is NOT mounted on this session. +``` + +The fix is to mount the context on the inner transport — see +[](guide/advanced.md#custom-transports). + +### Your certificate has expired + +```text +[SSL: SSLV3_ALERT_CERTIFICATE_EXPIRED] sslv3 alert certificate expired +``` + +The server checked the validity window and rejected it. httpx-pki warns about +this at load time too, so it may already be in your logs: + +```text +CertificateValidityWarning: client certificate expired on 2026-08-01; +mTLS handshakes will fail. +``` + +To turn expiry into a clear error before the connection is attempted rather than +an alert from the far side, use `strict_validity=True`. To pick up rotations +automatically, use `auto_reload`. Both are in +[](guide/expiry-and-rotation.md). + +## It connects as the wrong identity + +The awkward category: everything succeeds, and the server authenticates you as +somebody or something else. + +**You picked the wrong half of a dual key pair.** A bundle holding a signing and +an encryption certificate needs a selector, and for mTLS you want the signing +half — an encryption-only certificate cannot sign the handshake, so it typically +fails outright rather than misauthenticating: + +```python +PKIClient("corp.p12", password="secret", key_usage="digital_signature") +``` + +Background in [](guide/choosing-a-certificate.md#why-one-file-holds-two-certificates). + +**You shared one `ssl.SSLContext` between clients.** This one genuinely does +misauthenticate, silently: constructing the second client overwrites the first +client's certificate in the shared object, and only the server sees the swap. +`cert_info()` on the first client still reports what you expect. Worked through +in [](guide/server-trust.md#sharing-a-context-swaps-the-identity-on-the-wire). + +**Your certificate rotated underneath you.** An established connection keeps the +certificate it handshook with, so a reload only affects new connections. See +[](guide/expiry-and-rotation.md#what-to-expect). + +## Still stuck + +Make the warnings loud. Most silent-failure modes warn at construction, and +Python shows a given warning [only once per process](reference/exceptions.md#why-you-may-only-see-a-warning-once) +by default: + +```console +$ python -W always myapp.py +``` + +Turning `TLSConfigWarning` into an error is the sharpest version of this — every +message under it describes a client that is not doing what it looks like it is +doing: + +```python +import warnings +from httpx_pki import TLSConfigWarning + +warnings.filterwarnings("error", category=TLSConfigWarning) +``` + +Check what you are actually presenting: + +```python +client = PKIClient("client.p12", password="secret") +print(client.cert_info()) +``` + +And if the handshake still says nothing useful, `SSLKEYLOGFILE` lets Wireshark +decrypt it — see [](guide/server-trust.md#debugging-with-sslkeylogfile), and +note the warning there about never setting it in production. + +## Next steps + +- [](reference/exceptions.md) — every error and warning, and how to filter them +- [](guide/loading-certificates.md) — every source format in full +- [](guide/server-trust.md) — everything `verify=` accepts diff --git a/httpx_pki/_keychain.py b/httpx_pki/_keychain.py index da00449..2ba1979 100644 --- a/httpx_pki/_keychain.py +++ b/httpx_pki/_keychain.py @@ -82,14 +82,14 @@ def select_macos_certificate( # pylint: disable=too-many-arguments *, name: str | None = None, thumbprint: str | None = None, - predicate: MacPredicate | None = None, + identity: str | MacPredicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, ) -> MacCert: """Choose a single certificate from *candidates*. Every selector given must match: an exact ``thumbprint`` (SHA-1; colons, - spaces, and case are ignored), a ``predicate`` callable, a case-insensitive + spaces, and case are ignored), an ``identity``, a case-insensitive ``name`` substring matched against the subject common name and the keychain label, and the ``key_usage`` / ``extended_key_usage`` the certificate must assert -- which is how the two halves of a dual key pair in one keychain @@ -103,7 +103,7 @@ def select_macos_certificate( # pylint: disable=too-many-arguments candidates, name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, aliases=lambda c: (c.subject_cn, c.label), key_usage=key_usage, extended_key_usage=extended_key_usage, @@ -135,7 +135,7 @@ def load_macos_pkcs12( # pylint: disable=too-many-arguments *, name: str | None = None, thumbprint: str | None = None, - predicate: MacPredicate | None = None, + identity: str | MacPredicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, ) -> tuple[bytes, bytes, str]: @@ -156,7 +156,7 @@ def load_macos_pkcs12( # pylint: disable=too-many-arguments candidates, name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, key_usage=key_usage, extended_key_usage=extended_key_usage, ) diff --git a/httpx_pki/_material.py b/httpx_pki/_material.py index 4480201..18917d8 100644 --- a/httpx_pki/_material.py +++ b/httpx_pki/_material.py @@ -75,8 +75,8 @@ class CertInfo: # pylint: disable=too-many-instance-attributes issuer_common_name: str | None issuer_distinguished_name: str serial_number: int - not_before: datetime.datetime - not_after: datetime.datetime + not_valid_before: datetime.datetime + not_valid_after: datetime.datetime fingerprint_sha256: str fingerprint_sha1: str subject_alt_names: list[str] @@ -492,7 +492,7 @@ def load_chain_pems(source: CertSource) -> list[bytes]: def normalize_pem( certificate: CertSource, private_key: CertSource, - key_password: Password = None, + password: Password = None, chain: CertSource | list[CertSource] | None = None, ) -> Material: """Build canonical material from a separate certificate and private key. @@ -503,9 +503,13 @@ def normalize_pem( *chain* carries any further intermediate certificates to present to the server: a single source (which may itself concatenate several PEM certs) or a list of sources. + + *password* decrypts *private_key* only. An X.509 certificate is public data + and is never encrypted in PEM, DER, or certs-only PKCS#7, so there is no + corresponding certificate password anywhere in this path. """ certs = _load_certificates(read_source(certificate)) - key = _load_private_key(read_source(private_key), encode_password(key_password)) + key = _load_private_key(read_source(private_key), encode_password(password)) if len(certs) == 1: leaf = certs[0] _verify_key_matches_cert(key, leaf) @@ -618,8 +622,8 @@ def certificate_info(cert: x509.Certificate) -> CertInfo: issuer_common_name=_name_cn(cert.issuer), issuer_distinguished_name=cert.issuer.rfc4514_string(), serial_number=cert.serial_number, - not_before=cert.not_valid_before_utc, - not_after=cert.not_valid_after_utc, + not_valid_before=cert.not_valid_before_utc, + not_valid_after=cert.not_valid_after_utc, fingerprint_sha256=cert.fingerprint(hashes.SHA256()).hex().upper(), fingerprint_sha1=cert.fingerprint(hashes.SHA1()).hex().upper(), subject_alt_names=sans, diff --git a/httpx_pki/_mixin.py b/httpx_pki/_mixin.py index fa37042..eebcead 100644 --- a/httpx_pki/_mixin.py +++ b/httpx_pki/_mixin.py @@ -72,6 +72,40 @@ def _utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) +# Source kinds whose material is not decrypted with a caller-supplied +# password: ``env`` reads its own password variable along with the rest of the +# configuration, and the platform stores export under an internally generated +# single-use password. Passing one to reload() for these is always a mistake, +# so it is refused rather than silently discarded. +_PASSWORDLESS_SOURCES = ("env", "winstore", "macos_keychain") + + +def _no_password_message(source: SourceRef) -> str: + """Why ``reload(password=...)`` cannot apply to *source*. + + The two cases fail for different reasons and have different fixes, so the + message says which one the caller is in rather than only that the password + was not used. + """ + if source.kind == "env": + prefix = source.args.get("prefix", "HTTPX_PKI_") + return ( + "reload(password=...) does not apply to a from_env() client: the " + f"password is read from {prefix}PASSWORD along with the rest of " + "the configuration. Set that variable instead of passing one here." + ) + store = ( + "the Windows certificate store" + if source.kind == "winstore" + else "the macOS keychain" + ) + return ( + f"reload(password=...) does not apply to a client built from {store}: " + "the certificate is exported under an internally generated single-use " + "password, so there is none to supply. Drop the argument." + ) + + def _mount_shadows_tls(pattern: object) -> bool: """Whether an httpx mount pattern would handle https traffic. @@ -86,6 +120,11 @@ def _mount_shadows_tls(pattern: object) -> bool: class _PKIMixin: # pylint: disable=too-many-instance-attributes _material: Material _verify_policy: VerifyTypes + # Snapshot of the constructor's extra keywords taken BEFORE _init_state + # pops subclass extras -- so it may hold more than httpx keywords. It is + # serialized as-is by __getstate__, and __setstate__ replays it through + # _apply_material (re-running the hook); snapshotting post-pop would + # silently break subclass pickling. _httpx_kwargs: dict[str, Any] # Parsed once from _material.cert_pem in _apply_material. Parsing is pure, so # caching it is invisible (the time-dependent checks recompute "now" @@ -103,6 +142,10 @@ class _PKIMixin: # pylint: disable=too-many-instance-attributes _source: SourceRef | None _auto_reload: datetime.timedelta | None _strict_validity: bool + # The warn_if_expires_within window, retained so a rotated certificate is + # judged against the same threshold the client was built with -- reload() + # re-evaluates it, and it is carried across pickling. None disables it. + _warn_within: datetime.timedelta | None _reload_lock: threading.Lock _watch_paths: list[Path] _watch_sig: WatchSignature @@ -114,7 +157,7 @@ def _httpx_init(self, *, verify: ssl.SSLContext, **kwargs: Any) -> None: def __init__( # pylint: disable=too-many-arguments self, - cert: CertSource, + source: CertSource, password: Password = None, *, verify: VerifyTypes = True, @@ -132,12 +175,12 @@ def __init__( # pylint: disable=too-many-arguments "key_usage": key_usage, "extended_key_usage": extended_key_usage, } - material = load_material(read_source(cert), encoded, **selectors) + material = load_material(read_source(source), encoded, **selectors) self._apply_material( material, verify=verify, warn_if_expires_within=warn_if_expires_within, - source=SourceRef("auto", {"cert": cert, **selectors}, encoded), + source=SourceRef("auto", {"source": source, **selectors}, encoded), auto_reload=auto_reload, strict_validity=strict_validity, **kwargs, @@ -154,12 +197,22 @@ def _apply_material( # pylint: disable=too-many-arguments strict_validity: bool = False, **kwargs: Any, ) -> None: + """The shared in-place constructor body. + + Runs exactly once for every path that builds a client -- ``__init__``, + ``_from_material`` (behind every ``from_*`` alternate constructor), + and ``__setstate__`` -- validating the config, initializing all mixin + state (including the :meth:`_init_state` subclass hook), and forwarding + the leftover *kwargs* to the httpx base class via ``_httpx_init``. + :meth:`reload` never calls this; it swaps certificate material into + the mounted SSL context in place. + """ if "cert" in kwargs: raise TypeError( - "pass the client certificate to the constructor's cert source, " - "not via httpx's cert= keyword: httpx deprecated cert= in 0.28, " - "and it would collide with the SSL context httpx-pki mounts on " - "verify=." + "pass the client certificate as the constructor's source= " + "argument, not via httpx's cert= keyword: httpx deprecated " + "cert= in 0.28, and it would collide with the SSL context " + "httpx-pki mounts on verify=." ) # timedelta(0) means "check on every request", so test identity/type, # not truthiness (bool(timedelta(0)) is False). @@ -188,11 +241,15 @@ def _apply_material( # pylint: disable=too-many-arguments self._material = material self._verify_policy = verify - self._httpx_kwargs = kwargs + # Snapshot BEFORE _init_state pops its extras -- see the _httpx_kwargs + # annotation for why the pre-pop set is the one pickled. + self._httpx_kwargs = dict(kwargs) + self._init_state(kwargs) self._certinfo = cert_info(material.cert_pem) self._source = source self._auto_reload = interval self._strict_validity = strict_validity + self._warn_within = warn_if_expires_within self._reload_lock = threading.Lock() self._watch_paths = watch_paths(source) if source is not None else [] self._watch_sig = stat_signature(self._watch_paths) @@ -202,10 +259,41 @@ def _apply_material( # pylint: disable=too-many-arguments else 0.0 ) self._warn_on_ignored_tls(kwargs) - self._warn_on_validity(warn_if_expires_within) + self._warn_on_validity(self._warn_within) self._ssl_context = _context_from_material(material, verify) self._httpx_init(verify=self._ssl_context, **kwargs) + def _init_state(self, kwargs: dict[str, Any]) -> None: + """Subclass hook: claim constructor keywords and set up extra state. + + Runs exactly once on every path that builds a session -- ``__init__``, + every ``from_*`` alternate constructor, and unpickling -- before the + remaining *kwargs* are forwarded to the httpx base class. ``pop()`` + your subclass's keywords out of *kwargs* (it is mutated in place) and + assign your attributes; anything left over must be a keyword httpx + accepts. Popping with a default keeps the attributes present on every + path, including the constructors a caller passes no extras to:: + + class TracedClient(PKIClient): + def _init_state(self, kwargs): + self.trace_header = kwargs.pop("trace_header", "X-Trace-Id") + + TracedClient("client.p12", trace_header="X-Request-Id") + TracedClient.from_env() # trace_header defaults to "X-Trace-Id" + + The full keyword set is snapshotted for pickling before this hook + runs, and an unpickled client re-runs the hook with the original + keywords -- state set here survives a pickle round trip with no extra + code, as long as the values are themselves picklable. + :meth:`reload` and ``auto_reload`` swap certificate material in place + and do **not** re-run this hook. + + Do not rely on other session state here: the hook runs mid- + construction, before the httpx base class is initialized. When + subclassing a subclass, chain with ``super()._init_state(kwargs)``. + The base implementation does nothing. + """ + @classmethod def _from_material( # pylint: disable=too-many-arguments cls: type[_S], @@ -274,7 +362,7 @@ def from_env( # pylint: disable=too-many-arguments @classmethod def from_pkcs12( # pylint: disable=too-many-arguments cls: type[_S], - cert: CertSource, + source: CertSource, password: Password = None, *, verify: VerifyTypes = True, @@ -316,12 +404,12 @@ def from_pkcs12( # pylint: disable=too-many-arguments "key_usage": key_usage, "extended_key_usage": extended_key_usage, } - material = parse_pkcs12(read_source(cert), encoded, **selectors) + material = parse_pkcs12(read_source(source), encoded, **selectors) return cls._from_material( material, verify=verify, warn_if_expires_within=warn_if_expires_within, - source=SourceRef("pkcs12", {"cert": cert, **selectors}, encoded), + source=SourceRef("pkcs12", {"source": source, **selectors}, encoded), auto_reload=auto_reload, strict_validity=strict_validity, **kwargs, @@ -377,7 +465,7 @@ def from_key_pair( # pylint: disable=too-many-arguments certificate: CertSource, private_key: CertSource, *, - key_password: Password = None, + password: Password = None, chain: CertSource | list[CertSource] | None = None, verify: VerifyTypes = True, warn_if_expires_within: datetime.timedelta | None = None, @@ -390,11 +478,14 @@ def from_key_pair( # pylint: disable=too-many-arguments *certificate* is the client (leaf) certificate. Pass *chain* to present intermediate certificates to the server: a single source (which may concatenate several PEM certs) or a list of sources. + *password* decrypts *private_key* if it is encrypted; certificates are + never encrypted, so it is the same *password* every other constructor + takes. *warn_if_expires_within* warns about a certificate that expires inside that window (see :meth:`check_validity`). """ - encoded = encode_password(key_password) - material = normalize_pem(certificate, private_key, key_password, chain) + encoded = encode_password(password) + material = normalize_pem(certificate, private_key, password, chain) return cls._from_material( material, verify=verify, @@ -419,7 +510,7 @@ def from_macos_keychain( # pylint: disable=too-many-arguments name: str | None = None, *, thumbprint: str | None = None, - predicate: MacPredicate | None = None, + identity: str | MacPredicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, verify: VerifyTypes = True, @@ -431,12 +522,13 @@ def from_macos_keychain( # pylint: disable=too-many-arguments macOS only. Selects the identity from the default keychain search list by ``name`` (case-insensitive substring of the subject common name or - keychain label), ``thumbprint``, a ``predicate`` callable, or the + keychain label), ``thumbprint``, ``identity`` (a name substring, an + exact fingerprint, or a predicate callable), or the ``key_usage`` / ``extended_key_usage`` the certificate must assert; every selector given must match. A keychain holding both halves of a dual key pair needs the usage to choose between them, and one holding a renewed certificate alongside the one it replaces can take - ``predicate=httpx_pki.currently_valid``:: + ``identity=httpx_pki.currently_valid``:: AsyncPKIClient.from_macos_keychain( "corp-user", key_usage="digital_signature" @@ -459,7 +551,7 @@ def from_macos_keychain( # pylint: disable=too-many-arguments selector: dict[str, Any] = { "name": name, "thumbprint": thumbprint, - "predicate": predicate, + "identity": identity, "key_usage": key_usage, "extended_key_usage": extended_key_usage, } @@ -479,7 +571,7 @@ def from_windows_cert_store( # pylint: disable=too-many-arguments,too-many-loca name: str | None = None, *, thumbprint: str | None = None, - predicate: Predicate | None = None, + identity: str | Predicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, store: str = "MY", @@ -493,12 +585,13 @@ def from_windows_cert_store( # pylint: disable=too-many-arguments,too-many-loca Windows only. Selects the certificate by ``name`` (case-insensitive substring of the subject common name or friendly name), ``thumbprint``, - a ``predicate`` callable, or the ``key_usage`` / ``extended_key_usage`` + ``identity`` (a name substring, an exact fingerprint, or a predicate + callable), or the ``key_usage`` / ``extended_key_usage`` the certificate must assert; every selector given must match. A store holding both halves of a dual key pair -- what Active Directory key archival provisions -- needs the usage to choose between them, and one holding a renewed certificate alongside the one it replaces can take - ``predicate=httpx_pki.currently_valid``:: + ``identity=httpx_pki.currently_valid``:: PKIClient.from_windows_cert_store( "corp-user", key_usage="digital_signature" @@ -519,7 +612,7 @@ def from_windows_cert_store( # pylint: disable=too-many-arguments,too-many-loca selector: dict[str, Any] = { "name": name, "thumbprint": thumbprint, - "predicate": predicate, + "identity": identity, "key_usage": key_usage, "extended_key_usage": extended_key_usage, "store": store, @@ -540,12 +633,12 @@ def from_windows_cert_store( # pylint: disable=too-many-arguments,too-many-loca @property def not_valid_before(self) -> datetime.datetime: """Start of the client certificate's validity window (UTC).""" - return self._certinfo.not_before + return self._certinfo.not_valid_before @property def not_valid_after(self) -> datetime.datetime: """End of the client certificate's validity window (UTC).""" - return self._certinfo.not_after + return self._certinfo.not_valid_after @property def is_expired(self) -> bool: @@ -575,17 +668,17 @@ def check_validity( """ info = self._certinfo now = _utcnow() - not_before = f"{info.not_before:%Y-%m-%d %H:%M UTC}" - not_after = f"{info.not_after:%Y-%m-%d %H:%M UTC}" - if now < info.not_before: + not_before = f"{info.not_valid_before:%Y-%m-%d %H:%M UTC}" + not_after = f"{info.not_valid_after:%Y-%m-%d %H:%M UTC}" + if now < info.not_valid_before: raise CertificateNotYetValidError( f"client certificate is not valid until {not_before}" ) - if now > info.not_after: + if now > info.not_valid_after: raise CertificateExpiredError( f"client certificate expired on {not_after}" ) - if within is not None and info.not_after - now <= within: + if within is not None and info.not_valid_after - now <= within: raise CertificateExpiredError( f"client certificate expires on {not_after}, within {within}" ) @@ -604,16 +697,29 @@ def reload(self, *, password: Password = None) -> None: The swap is atomic: if the new material cannot be loaded (:class:`~httpx_pki.CertificateLoadError`), the client keeps serving - the previous certificate. Pass *password* if the source is encrypted - and the client was not built with ``auto_reload`` (which is the only - mode that retains the password). Raises :class:`TypeError` for a - client built from in-memory bytes -- there is no source to re-read. + the previous certificate. Pass *password* if the source is a file or + bundle that is encrypted and the client was not built with + ``auto_reload`` (which is the only mode that retains the password). + + The freshly loaded certificate is put through the same validity checks + the constructor ran, against the ``warn_if_expires_within`` window the + client was built with -- so a rotation that lands another short-lived + certificate warns again, and one that lands a healthy certificate goes + quiet. + + Raises :class:`TypeError` for a client built from in-memory bytes + (there is no source to re-read), and for a *password* passed to a + source that has none to use: ``from_env`` reads ``{prefix}PASSWORD`` + itself, and the Windows store and macOS keychain export under an + internal single-use password. """ if self._source is None or not is_reloadable(self._source): raise TypeError( "this client was built from in-memory bytes; there is no " "certificate source to reload from" ) + if password is not None and self._source.kind in _PASSWORDLESS_SOURCES: + raise TypeError(_no_password_message(self._source)) with self._reload_lock: # Fingerprint the watched files BEFORE reading them: if another # rotation lands between the read and the fingerprint, recording @@ -625,7 +731,7 @@ def reload(self, *, password: Password = None) -> None: self._material = material self._certinfo = cert_info(material.cert_pem) self._watch_sig = sig_before - self._warn_on_validity(None) + self._warn_on_validity(self._warn_within) def _preflight(self) -> None: """Per-request hook run by ``send()``: auto-reload, then validity. @@ -677,27 +783,28 @@ def _warn_on_validity( ) -> None: info = self._certinfo now = _utcnow() - if now > info.not_after: + if now > info.not_valid_after: warnings.warn( - f"client certificate expired on {info.not_after:%Y-%m-%d}; " + f"client certificate expired on {info.not_valid_after:%Y-%m-%d}; " "mTLS handshakes will fail.", CertificateValidityWarning, stacklevel=3, ) - elif now < info.not_before: + elif now < info.not_valid_before: + starts = f"{info.not_valid_before:%Y-%m-%d}" warnings.warn( - f"client certificate is not valid until {info.not_before:%Y-%m-%d}; " + f"client certificate is not valid until {starts}; " "mTLS handshakes will fail until then.", CertificateValidityWarning, stacklevel=3, ) elif ( warn_if_expires_within is not None - and info.not_after - now <= warn_if_expires_within + and info.not_valid_after - now <= warn_if_expires_within ): - days = (info.not_after - now).days + days = (info.not_valid_after - now).days warnings.warn( - f"client certificate expires on {info.not_after:%Y-%m-%d} " + f"client certificate expires on {info.not_valid_after:%Y-%m-%d} " f"(in {days} day(s)).", CertificateValidityWarning, stacklevel=3, @@ -793,6 +900,7 @@ def __getstate__(self) -> dict[str, Any]: "source": source, "auto_reload": auto_reload, "strict_validity": self._strict_validity, + "warn_if_expires_within": self._warn_within, } def __setstate__(self, state: dict[str, Any]) -> None: @@ -803,6 +911,7 @@ def __setstate__(self, state: dict[str, Any]) -> None: source=state.get("source"), auto_reload=state.get("auto_reload", False), strict_validity=state.get("strict_validity", False), + warn_if_expires_within=state.get("warn_if_expires_within"), **state["httpx_kwargs"], ) @@ -811,5 +920,5 @@ def __repr__(self) -> str: return ( f"<{type(self).__name__} " f"cn={info.common_name!r} " - f"expires={info.not_after:%Y-%m-%d}>" + f"expires={info.not_valid_after:%Y-%m-%d}>" ) diff --git a/httpx_pki/_pkcs12.py b/httpx_pki/_pkcs12.py index acbd345..c959388 100644 --- a/httpx_pki/_pkcs12.py +++ b/httpx_pki/_pkcs12.py @@ -604,7 +604,7 @@ def _listing(identities: list[P12Identity]) -> str: if i.friendly_name: parts.append(f"({i.friendly_name})") parts.append(f"key_usage={','.join(sorted(i.info.key_usage)) or ''}") - parts.append(f"expires={i.info.not_after:%Y-%m-%d}") + parts.append(f"expires={i.info.not_valid_after:%Y-%m-%d}") parts.append(i.info.fingerprint_sha1) lines.append(" ".join(parts)) return "\n".join(lines) diff --git a/httpx_pki/_select.py b/httpx_pki/_select.py index ead79fd..d87c852 100644 --- a/httpx_pki/_select.py +++ b/httpx_pki/_select.py @@ -14,7 +14,7 @@ (:func:`~httpx_pki._winstore.select_windows_certificate`, :func:`~httpx_pki._keychain.select_macos_certificate`); * :data:`currently_valid` is the ready-made renewal selector, accepted - anywhere a predicate is. + anywhere an ``identity`` is. Every selector **intersects**: each one given narrows the candidates further, so a name and a key usage together mean "both", never "whichever is more @@ -26,7 +26,7 @@ import datetime from collections.abc import Callable, Iterable, Sequence -from typing import Protocol, TypeVar +from typing import Any, Protocol, TypeVar from cryptography import x509 @@ -71,7 +71,7 @@ class _CertDetails: same whether it is filtering a PKCS#12 file, the Windows store, or the macOS keychain:: - predicate=lambda c: "digital_signature" in c.key_usage + identity=lambda c: "digital_signature" in c.key_usage Both accessors are empty rather than ``None`` when the certificate could not be read, so a predicate never has to guard against it -- such a @@ -110,7 +110,7 @@ def __call__(self, candidate: _StoreCert) -> bool: if info is None: return False now = datetime.datetime.now(datetime.timezone.utc) - return info.not_before <= now <= info.not_after + return info.not_valid_before <= now <= info.not_valid_after @staticmethod def narrow(matches: Sequence[_C]) -> list[_C]: @@ -135,7 +135,7 @@ def narrow(matches: Sequence[_C]) -> list[_C]: if len(profiles) != 1: return list(matches) latest = max( - (m.info.not_after, m.info.not_before) + (m.info.not_valid_after, m.info.not_valid_before) for m in matches if m.info is not None ) @@ -143,7 +143,7 @@ def narrow(matches: Sequence[_C]) -> list[_C]: m for m in matches if m.info is not None - and (m.info.not_after, m.info.not_before) == latest + and (m.info.not_valid_after, m.info.not_valid_before) == latest ] def __repr__(self) -> str: @@ -157,8 +157,8 @@ def __reduce__(self) -> str: currently_valid = _CurrentlyValid() """Selector for the certificate whose validity window contains *now*. -Usable anywhere a predicate is: ``identity=currently_valid`` for PKCS#12 and -PEM bundles, ``predicate=currently_valid`` for the platform stores. Built for +Usable anywhere an ``identity`` is -- ``identity=currently_valid`` for PKCS#12 +and PEM bundles and for the platform stores alike. Built for the renewal case -- a bundle or store holding the renewed certificate alongside the one it replaces:: @@ -288,36 +288,79 @@ def matches_usages( # -- store selection -------------------------------------------------------- +def _matches_identity( + candidate: _StoreCert, + needle: str, + aliases: Callable[[Any], tuple[str | None, ...]], +) -> bool: + """Match a string ``identity=`` against one store candidate. + + Mirrors the bundle rule in :func:`~httpx_pki._pkcs12._matches_name`: a + full-length hex digest is an exact fingerprint comparison, anything else a + case-insensitive substring of the candidate's aliases. Keeping the two + identical is what lets ``identity="ACME"`` mean the same thing whether the + source is a ``.p12`` or the Windows store. + """ + target = normalize_thumbprint(needle) + if len(target) in (40, 64) and all(c in "0123456789ABCDEF" for c in target): + digests = {candidate.thumbprint} + if candidate.info is not None: + digests.add(candidate.info.fingerprint_sha256) + return target in digests + lowered = needle.lower() + return any( + alias is not None and lowered in alias.lower() + for alias in aliases(candidate) + ) + + def select_certificate( # pylint: disable=too-many-arguments candidates: Sequence[_C], *, name: str | None, thumbprint: str | None, - predicate: Callable[[_C], bool] | None, + identity: str | Callable[[_C], bool] | None, aliases: Callable[[_C], tuple[str | None, ...]], key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, ) -> _C: """Choose a single certificate from *candidates*. - Every selector given must match: an exact ``thumbprint`` (compared - normalized -- colons, spaces, and case are ignored), a ``predicate`` - callable, a case-insensitive ``name`` substring matched against the strings + Every selector given must match: an ``identity`` (a name substring, an + exact SHA-1/SHA-256 fingerprint, or a predicate callable), an exact + ``thumbprint`` (compared normalized -- colons, spaces, and case are + ignored), a case-insensitive ``name`` substring matched against the strings *aliases* extracts from each candidate, and the ``key_usage`` / ``extended_key_usage`` the certificate must assert. With no selector, all candidates qualify (handy when the store holds exactly one). - ``predicate=currently_valid`` picks the certificate whose validity window + ``identity=currently_valid`` picks the certificate whose validity window contains now, preferring the renewed one during a renewal overlap. + ``name``/``thumbprint`` are the unambiguous spellings; ``identity`` is the + portable one, accepting exactly what a PKCS#12 or PEM bundle's ``identity`` + does apart from an integer position -- a store has no stable ordering, so + that is rejected rather than silently indexing. + Raises :class:`~httpx_pki.CertificateNotFoundError` if nothing matches and :class:`~httpx_pki.AmbiguousCertificateError` if more than one does. """ + if isinstance(identity, bool) or isinstance(identity, int): + raise TypeError( + "identity= cannot be an integer for a platform certificate store: " + "a store has no stable ordering, so a position would select a " + "different certificate from one run to the next. Use a name, a " + "thumbprint, or a predicate." + ) matches = list(candidates) if thumbprint is not None: target = normalize_thumbprint(thumbprint) matches = [c for c in matches if c.thumbprint == target] - if predicate is not None: - matches = [c for c in matches if predicate(c)] + if identity is not None: + if isinstance(identity, str): + needle = identity + matches = [c for c in matches if _matches_identity(c, needle, aliases)] + else: + matches = [c for c in matches if identity(c)] if name is not None: needle = name.lower() matches = [ @@ -332,11 +375,11 @@ def select_certificate( # pylint: disable=too-many-arguments matches = [ c for c in matches if matches_usages(c, key_usage, extended_key_usage) ] - if predicate is not None: - matches = _narrowed(predicate, matches) + if identity is not None: + matches = _narrowed(identity, matches) selector = _selector_repr( - name, thumbprint, predicate, key_usage, extended_key_usage + name, thumbprint, identity, key_usage, extended_key_usage ) if not matches: raise CertificateNotFoundError( @@ -367,7 +410,7 @@ def _listing(candidates: Sequence[_StoreCert]) -> str: if candidate.key_usage: parts.append(f"key_usage={','.join(sorted(candidate.key_usage))}") if candidate.info is not None: - parts.append(f"expires={candidate.info.not_after:%Y-%m-%d}") + parts.append(f"expires={candidate.info.not_valid_after:%Y-%m-%d}") parts.append(candidate.thumbprint) lines.append(" ".join(parts)) return "\n".join(lines) if lines else " (nothing)" @@ -376,15 +419,15 @@ def _listing(candidates: Sequence[_StoreCert]) -> str: def _selector_repr( # pylint: disable=too-many-arguments name: str | None, thumbprint: str | None, - predicate: object, + identity: object, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, ) -> str: described = [] if thumbprint is not None: described.append(f"thumbprint={thumbprint!r}") - if predicate is not None: - described.append("predicate") + if identity is not None: + described.append(f"identity={identity!r}") if name is not None: described.append(f"name={name!r}") if key_usage is not None: diff --git a/httpx_pki/_source.py b/httpx_pki/_source.py index f31cd41..9b6a266 100644 --- a/httpx_pki/_source.py +++ b/httpx_pki/_source.py @@ -43,7 +43,9 @@ class SourceRef: the encoded source password, retained only when ``auto_reload`` is on. """ - kind: str # "auto" | "pkcs12" | "pem" | "key_pair" | "env" | "winstore" + # "auto" | "pkcs12" | "pem" | "key_pair" | "env" | "winstore" + # | "macos_keychain" + kind: str args: dict[str, Any] password: bytes | None = None @@ -65,16 +67,19 @@ def resolve_source( # pylint: disable=too-many-return-statements ) -> Material: """Load fresh material from *ref*, exactly as the constructor did. - An explicit *password* overrides the one retained on the ref. The ``env`` - kind re-reads the environment (including its password variable); the - ``winstore`` kind re-exports from the Windows store. + An explicit *password* overrides the one retained on the ref. It only ever + reaches a kind that decrypts with one: the ``env`` kind re-reads the + environment (including its own password variable) and the platform stores + re-export under an internal single-use password, so + :meth:`~httpx_pki.PKIClient.reload` refuses a password for those rather + than passing one here to be ignored. """ pw = password if password is not None else ref.password args = ref.args if ref.kind == "auto": - return load_material(read_source(args["cert"]), pw, **_selectors(args)) + return load_material(read_source(args["source"]), pw, **_selectors(args)) if ref.kind == "pkcs12": - return parse_pkcs12(read_source(args["cert"]), pw, **_selectors(args)) + return parse_pkcs12(read_source(args["source"]), pw, **_selectors(args)) if ref.kind == "pem": return parse_pem_bundle( read_source(args["source"]), pw, **_selectors(args) @@ -111,9 +116,7 @@ def watch_paths(ref: SourceRef) -> list[Path]: """ args = ref.args candidates: list[Any] - if ref.kind in ("auto", "pkcs12"): - candidates = [args["cert"]] - elif ref.kind == "pem": + if ref.kind in ("auto", "pkcs12", "pem"): candidates = [args["source"]] elif ref.kind == "key_pair": chain = args["chain"] diff --git a/httpx_pki/_ssl.py b/httpx_pki/_ssl.py index 6a0288c..d8b4f7b 100644 --- a/httpx_pki/_ssl.py +++ b/httpx_pki/_ssl.py @@ -44,7 +44,7 @@ def build_ssl_context( # pylint: disable=too-many-arguments - cert: CertSource, + source: CertSource, password: Password = None, *, verify: VerifyTypes = True, @@ -57,7 +57,7 @@ def build_ssl_context( # pylint: disable=too-many-arguments A convenience for callers who want the SSL context without the :class:`~httpx_pki.PKIClient` wrapper -- to mount on a plain :class:`httpx.Client`, an httpx transport, or any library that accepts an - ``ssl.SSLContext``. *cert* is a PKCS#12 or PEM source (path or bytes; the + ``ssl.SSLContext``. *source* is a PKCS#12 or PEM source (path or bytes; the encoding is detected from the content) and *verify* configures server trust exactly like httpx2 -- ``True``, the default, verifies against the OS trust store -- plus two httpx-pki literals: ``"system"`` (a synonym of ``True``) @@ -71,7 +71,7 @@ def build_ssl_context( # pylint: disable=too-many-arguments :meth:`~httpx_pki.PKIClient.from_pkcs12`. """ material = load_material( - read_source(cert), + read_source(source), encode_password(password), identity=identity, key_usage=key_usage, @@ -84,7 +84,7 @@ def build_windows_ssl_context( # pylint: disable=too-many-arguments name: str | None = None, *, thumbprint: str | None = None, - predicate: Predicate | None = None, + identity: str | Predicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, store: str = "MY", @@ -97,7 +97,8 @@ def build_windows_ssl_context( # pylint: disable=too-many-arguments :meth:`~httpx_pki.PKIClient.from_windows_cert_store`: it selects an exportable certificate from the store -- by ``name`` (case-insensitive substring of the subject common name or friendly name), ``thumbprint``, a - ``predicate`` callable, or the ``key_usage`` / ``extended_key_usage`` it + ``identity`` (name substring, fingerprint, or predicate callable), or the + ``key_usage`` / ``extended_key_usage`` it must assert -- and returns the ``ssl.SSLContext`` presenting it, with server trust configured by *verify* exactly like httpx2 (``True``, the default, is the OS trust store; the literal ``"certifi"`` pins the certifi @@ -106,10 +107,11 @@ def build_windows_ssl_context( # pylint: disable=too-many-arguments Use it to mount a store certificate on a transport or a routing layer without building a whole :class:`~httpx_pki.PKIClient` just to read its ``ssl_context``. Windows only; see - :meth:`~httpx_pki.PKIClient.from_windows_cert_store` for the errors raised. + :meth:`~httpx_pki.PKIClient.from_windows_cert_store` for the errors + raised:: ctx = build_windows_ssl_context( - predicate=lambda c: "Internal" in (c.friendly_name or "") + identity=lambda c: "Internal" in (c.friendly_name or "") ) transport = httpx.HTTPTransport(verify=ctx) """ @@ -118,7 +120,7 @@ def build_windows_ssl_context( # pylint: disable=too-many-arguments pfx, password, chosen = load_windows_pkcs12( name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, key_usage=key_usage, extended_key_usage=extended_key_usage, store=store, @@ -133,7 +135,7 @@ def build_macos_ssl_context( # pylint: disable=too-many-arguments name: str | None = None, *, thumbprint: str | None = None, - predicate: MacPredicate | None = None, + identity: str | MacPredicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, verify: VerifyTypes = True, @@ -144,7 +146,8 @@ def build_macos_ssl_context( # pylint: disable=too-many-arguments :meth:`~httpx_pki.PKIClient.from_macos_keychain`: it selects an exportable identity from the default keychain search list -- by ``name`` (case-insensitive substring of the subject common name or keychain label), - ``thumbprint``, a ``predicate`` callable, or the ``key_usage`` / + ``thumbprint``, an ``identity`` (name substring, fingerprint, or + predicate callable), or the ``key_usage`` / ``extended_key_usage`` it must assert -- and returns the ``ssl.SSLContext`` presenting it, with server trust configured by *verify* exactly like httpx2 (``True``, the default, is the OS trust store; the @@ -161,7 +164,7 @@ def build_macos_ssl_context( # pylint: disable=too-many-arguments pfx, password, chosen = load_macos_pkcs12( name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, key_usage=key_usage, extended_key_usage=extended_key_usage, ) diff --git a/httpx_pki/_winstore.py b/httpx_pki/_winstore.py index 2643255..a6c6d52 100644 --- a/httpx_pki/_winstore.py +++ b/httpx_pki/_winstore.py @@ -70,13 +70,13 @@ def select_windows_certificate( # pylint: disable=too-many-arguments *, name: str | None = None, thumbprint: str | None = None, - predicate: Predicate | None = None, + identity: str | Predicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, ) -> WinCert: """Choose a single certificate from *candidates*. - Every selector given must match: an exact ``thumbprint``, a ``predicate`` + Every selector given must match: an exact ``thumbprint``, an ``identity`` callable, a case-insensitive ``name`` substring matched against the subject common name and the Windows friendly name, and the ``key_usage`` / ``extended_key_usage`` the certificate must assert -- which is how the two @@ -90,7 +90,7 @@ def select_windows_certificate( # pylint: disable=too-many-arguments candidates, name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, aliases=lambda c: (c.subject_cn, c.friendly_name), key_usage=key_usage, extended_key_usage=extended_key_usage, @@ -125,7 +125,7 @@ def load_windows_pkcs12( # pylint: disable=too-many-arguments *, name: str | None = None, thumbprint: str | None = None, - predicate: Predicate | None = None, + identity: str | Predicate | None = None, key_usage: UsageSelector | None = None, extended_key_usage: UsageSelector | None = None, store: str = "MY", @@ -148,7 +148,7 @@ def load_windows_pkcs12( # pylint: disable=too-many-arguments candidates, name=name, thumbprint=thumbprint, - predicate=predicate, + identity=identity, key_usage=key_usage, extended_key_usage=extended_key_usage, ) diff --git a/httpx_pki/testing.py b/httpx_pki/testing.py index 4133d98..71f6a30 100644 --- a/httpx_pki/testing.py +++ b/httpx_pki/testing.py @@ -178,8 +178,8 @@ def make_client_cert( # pylint: disable=too-many-arguments,too-many-locals ca: CertBundle | None = None, dns_names: list[str] | None = None, ip_addresses: list[str] | None = None, - not_before: datetime.datetime | None = None, - not_after: datetime.datetime | None = None, + not_valid_before: datetime.datetime | None = None, + not_valid_after: datetime.datetime | None = None, expired: bool = False, key_usage: Iterable[str] | None = None, extended_key_usage: Iterable[str] | None = None, @@ -188,9 +188,9 @@ def make_client_cert( # pylint: disable=too-many-arguments,too-many-locals Signed by *ca* if given, otherwise self-signed. *dns_names*/*ip_addresses* populate the Subject Alternative Name extension. The validity window - defaults to (yesterday, +365 days); override it with *not_before*/ - *not_after*, or pass ``expired=True`` for a window that has already closed - (handy for exercising :meth:`PKIClient.check_validity`). + defaults to (yesterday, +365 days); override it with *not_valid_before*/ + *not_valid_after*, or pass ``expired=True`` for a window that has already + closed (handy for exercising :meth:`httpx_pki.PKIClient.check_validity`). The certificate carries the extensions a real client certificate would: a KeyUsage of digitalSignature + keyEncipherment and an ExtendedKeyUsage @@ -205,11 +205,11 @@ def make_client_cert( # pylint: disable=too-many-arguments,too-many-locals key = rsa.generate_private_key(public_exponent=65537, key_size=2048) now = _utcnow() if expired: - not_before = not_before or now - datetime.timedelta(days=30) - not_after = not_after or now - datetime.timedelta(days=1) + not_valid_before = not_valid_before or now - datetime.timedelta(days=30) + not_valid_after = not_valid_after or now - datetime.timedelta(days=1) else: - not_before = not_before or now - datetime.timedelta(days=1) - not_after = not_after or now + datetime.timedelta(days=365) + not_valid_before = not_valid_before or now - datetime.timedelta(days=1) + not_valid_after = not_valid_after or now + datetime.timedelta(days=365) issuer = ca.cert.subject if ca is not None else None subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]) @@ -219,8 +219,8 @@ def make_client_cert( # pylint: disable=too-many-arguments,too-many-locals .issuer_name(issuer if issuer is not None else subject) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) + .not_valid_before(not_valid_before) + .not_valid_after(not_valid_after) .add_extension( x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False ) diff --git a/pyproject.toml b/pyproject.toml index bef3060..e9a61fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,18 @@ system = [ httpx2 = [ "httpx2>=2.9", ] +# Sphinx + MyST: the guide is authored in Markdown, but the package docstrings +# are reStructuredText (:class:/:meth: roles, :: literal blocks), so autodoc +# reads them as-is and intersphinx resolves them against the stdlib and +# cryptography docs. Read the Docs installs this via .readthedocs.yaml. +docs = [ + "sphinx>=8.1", + "furo>=2024.8.6", + "myst-parser>=4", + "linkify-it-py>=2", + "sphinx-copybutton>=0.5", + "sphinx-design>=0.6", +] dev = [ "httpx>=0.28", "mypy>=1.11", diff --git a/tests/test_env.py b/tests/test_env.py index bf5b37e..11d6079 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -25,6 +25,38 @@ def test_from_env_pkcs12(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Non assert session.cn == "envclient" +def test_reload_rejects_a_password_and_names_the_variable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # from_env reads its own password variable, so a password passed here + # would be silently discarded. Refuse it, and say where it belongs. + bundle = make_client_cert("envclient", ca=make_ca()) + p12 = tmp_path / "client.p12" + p12.write_bytes(bundle.pkcs12("pw")) + monkeypatch.setenv("HTTPX_PKI_CERT", str(p12)) + monkeypatch.setenv("HTTPX_PKI_PASSWORD", "pw") + with PKIClient.from_env() as session: + with pytest.raises(TypeError, match="HTTPX_PKI_PASSWORD"): + session.reload(password="pw") + # Without one it still reloads from the environment as before. + session.reload() + assert session.cn == "envclient" + + +def test_reload_rejects_a_password_under_a_custom_prefix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The message names the prefix actually in use, not the default. + bundle = make_client_cert("envclient", ca=make_ca()) + p12 = tmp_path / "client.p12" + p12.write_bytes(bundle.pkcs12("pw")) + monkeypatch.setenv("MYAPP_CERT", str(p12)) + monkeypatch.setenv("MYAPP_PASSWORD", "pw") + with PKIClient.from_env("MYAPP_") as session: + with pytest.raises(TypeError, match="MYAPP_PASSWORD"): + session.reload(password="pw") + + def test_from_env_separate_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_formats.py b/tests/test_formats.py index f6d157e..a5228e6 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -55,6 +55,32 @@ def test_encrypted_pem_key_with_password(client: Signed, ca: Signed) -> None: assert session.cn == CLIENT_CN +def test_key_pair_encrypted_key_with_password(client: Signed) -> None: + # from_key_pair takes the same password= as every other constructor: it + # decrypts the key, the only half that can be encrypted at all. + encrypted_key = client.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"keypw"), + ) + with PKIClient.from_key_pair( + certificate=client.cert_pem, private_key=encrypted_key, password="keypw" + ) as session: + assert session.cn == CLIENT_CN + + +def test_key_pair_encrypted_key_wrong_password_raises(client: Signed) -> None: + encrypted_key = client.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"keypw"), + ) + with pytest.raises(CertificateLoadError, match="could not parse private key"): + PKIClient.from_key_pair( + certificate=client.cert_pem, private_key=encrypted_key, password="wrong" + ) + + def test_pem_without_key_raises(client: Signed) -> None: with pytest.raises(CertificateLoadError, match="no private key"): PKIClient(client.cert_pem) diff --git a/tests/test_identity.py b/tests/test_identity.py index 81f0bee..aca49e1 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -554,7 +554,7 @@ def test_renewal_selecting_the_valid_certificate( blob, _expiring, renewed = renewal_p12 now = datetime.datetime.now(datetime.timezone.utc) with PKIClient( - blob, password=P12_PASSWORD, identity=lambda i: i.info.not_after > now + blob, password=P12_PASSWORD, identity=lambda i: i.info.not_valid_after > now ) as session: assert session.certificate.serial_number == renewed.cert.serial_number assert not session.is_expired @@ -610,8 +610,8 @@ def test_currently_valid_skips_the_not_yet_valid_certificate( future = make_client_cert( "future-user", ca=ca_bundle, - not_before=now + datetime.timedelta(days=30), - not_after=now + datetime.timedelta(days=400), + not_valid_before=now + datetime.timedelta(days=30), + not_valid_after=now + datetime.timedelta(days=400), ) blob = make_pkcs12([(current, "now"), (future, "next")], password=P12_PASSWORD) with PKIClient( @@ -628,7 +628,7 @@ def test_currently_valid_prefers_the_renewed_during_overlap( # tie resolves to the later window. now = datetime.datetime.now(datetime.timezone.utc) old = make_client_cert( - "overlap-user", ca=ca_bundle, not_after=now + datetime.timedelta(days=20) + "overlap-user", ca=ca_bundle, not_valid_after=now + datetime.timedelta(days=20) ) new = make_client_cert("overlap-user", ca=ca_bundle) blob = make_pkcs12([(old, "old"), (new, "new")], password=P12_PASSWORD) diff --git a/tests/test_keychain.py b/tests/test_keychain.py index a3ceab7..39ae547 100644 --- a/tests/test_keychain.py +++ b/tests/test_keychain.py @@ -66,9 +66,9 @@ def test_select_by_thumbprint_with_separators() -> None: assert chosen.subject_cn == "ACME Dev Client" -def test_select_by_predicate() -> None: +def test_select_by_identity_predicate() -> None: chosen = select_macos_certificate( - CANDIDATES, predicate=lambda c: c.label == "prod" + CANDIDATES, identity=lambda c: c.label == "prod" ) assert chosen.thumbprint == "AA11BB" diff --git a/tests/test_reload.py b/tests/test_reload.py index 349451e..2fb40ae 100644 --- a/tests/test_reload.py +++ b/tests/test_reload.py @@ -95,6 +95,27 @@ def test_reload_on_bytes_source_raises(client_p12: bytes) -> None: session.reload() +@pytest.mark.parametrize( + ("kind", "expected"), + [ + ("winstore", "Windows certificate store"), + ("macos_keychain", "macOS keychain"), + ], +) +def test_reload_rejects_a_password_for_a_platform_store( + client_p12: bytes, kind: str, expected: str +) -> None: + # A store export uses an internal single-use password, so a caller-supplied + # one has nothing to decrypt. Refuse it instead of discarding it silently. + # The source is faked so this runs off-platform; only the reload argument + # check is under test, and it runs before anything touches the store. + with PKIClient(client_p12, password=P12_PASSWORD) as session: + session._source = SourceRef(kind, {"name": "ACME"}) + with pytest.raises(TypeError, match=expected) as excinfo: + session.reload(password="pw") + assert "single-use" in str(excinfo.value) + + def test_auto_reload_on_bytes_source_rejected_at_construction( client_p12: bytes, ) -> None: diff --git a/tests/test_session.py b/tests/test_session.py index a1524d0..ac3a56c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -130,7 +130,7 @@ def test_cert_info_fields(client: Signed, client_p12: bytes) -> None: info = session.cert_info() assert info.common_name == CLIENT_CN assert info.distinguished_name == f"CN={CLIENT_CN}" - assert info.not_after > info.not_before + assert info.not_valid_after > info.not_valid_before assert "test-client.example.com" in info.subject_alt_names # Audit fields, against the ground-truth cryptography object. assert info.serial_number == client.cert.serial_number @@ -274,6 +274,21 @@ def test_cert_kwarg_rejected(client: Signed) -> None: ) +def test_cert_kwarg_rejected_on_every_bundle_entry_point( + client: Signed, client_p12: bytes +) -> None: + # The source parameter is named source=, not cert=, so httpx's deprecated + # cert= reaches the guard on every constructor rather than binding to the + # first positional and producing a bare arity error naming _PKIMixin. + blob = client.key_pem + client.cert_pem + with pytest.raises(TypeError, match="httpx's cert= keyword"): + PKIClient(blob, cert="ignored.pem") + with pytest.raises(TypeError, match="httpx's cert= keyword"): + PKIClient.from_pkcs12(client_p12, password=P12_PASSWORD, cert="ignored.pem") + with pytest.raises(TypeError, match="httpx's cert= keyword"): + PKIClient.from_pem(blob, cert="ignored.pem") + + def test_warning_hierarchy() -> None: # The categories are public API: importable from the package root, and all # PKIWarning subclasses of UserWarning so generic filters keep matching. diff --git a/tests/test_subclass.py b/tests/test_subclass.py new file mode 100644 index 0000000..395f26d --- /dev/null +++ b/tests/test_subclass.py @@ -0,0 +1,135 @@ +"""Tests for the ``_init_state`` subclass hook. + +The hook is the documented seam for subclasses that take extra constructor +keywords: it must run on every construction path (``__init__``, the ``from_*`` +alternates, unpickling), must not run again on reload, and must leave the +remaining kwargs for httpx. +""" + +from __future__ import annotations + +import pickle +from pathlib import Path +from typing import Any + +import pytest + +from httpx_pki import AsyncPKIClient, PKIClient +from tests.conftest import CLIENT_CN, P12_PASSWORD, Signed + +PROXY = "http://proxy.internal:3128" + + +class ProxiedClient(PKIClient): + """A subclass with extra constructor keywords, claimed via _init_state.""" + + def _init_state(self, kwargs: dict[str, Any]) -> None: + self.proxy_url = kwargs.pop("proxy_url", None) + self.do_not_proxy = kwargs.pop("do_not_proxy", ()) + self.hook_runs = getattr(self, "hook_runs", 0) + 1 + + +class RegionClient(ProxiedClient): + """A grandchild: chains to the parent hook with super().""" + + def _init_state(self, kwargs: dict[str, Any]) -> None: + self.region = kwargs.pop("region", "us-east-1") + super()._init_state(kwargs) + + +class AsyncProxiedClient(AsyncPKIClient): + def _init_state(self, kwargs: dict[str, Any]) -> None: + self.proxy_url = kwargs.pop("proxy_url", None) + + +def test_init_claims_kwargs(client_p12: bytes) -> None: + with ProxiedClient( + client_p12, + password=P12_PASSWORD, + proxy_url=PROXY, + do_not_proxy=("localhost",), + ) as session: + assert session.proxy_url == PROXY + assert session.do_not_proxy == ("localhost",) + assert session.cert_info().common_name == CLIENT_CN + + +def test_init_defaults_when_absent(client_p12: bytes) -> None: + with ProxiedClient(client_p12, password=P12_PASSWORD) as session: + assert session.proxy_url is None + assert session.do_not_proxy == () + + +def test_alternate_constructor_runs_hook(client_p12: bytes) -> None: + # from_* constructors bypass __init__ entirely; the hook must still run. + with ProxiedClient.from_pkcs12( + client_p12, P12_PASSWORD, proxy_url=PROXY + ) as session: + assert isinstance(session, ProxiedClient) + assert session.proxy_url == PROXY + + +def test_alternate_constructor_defaults(client: Signed) -> None: + with ProxiedClient.from_key_pair( + certificate=client.cert_pem, private_key=client.key_pem + ) as session: + assert session.proxy_url is None + assert session.do_not_proxy == () + + +def test_leftover_kwargs_reach_httpx(client_p12: bytes) -> None: + with ProxiedClient( + client_p12, + password=P12_PASSWORD, + proxy_url=PROXY, + base_url="https://service.internal", + ) as session: + assert session.base_url == "https://service.internal" + + +def test_unclaimed_kwarg_still_rejected(client_p12: bytes) -> None: + # A keyword neither the hook nor httpx knows keeps failing loudly. + with pytest.raises(TypeError): + ProxiedClient(client_p12, password=P12_PASSWORD, bogus_option=1) + + +def test_pickle_round_trip_rebuilds_state(client_p12: bytes) -> None: + session = ProxiedClient(client_p12, password=P12_PASSWORD, proxy_url=PROXY) + try: + restored = pickle.loads(pickle.dumps(session)) + finally: + session.close() + try: + assert isinstance(restored, ProxiedClient) + assert restored.proxy_url == PROXY + assert restored.do_not_proxy == () + finally: + restored.close() + + +def test_reload_does_not_rerun_hook(client_p12_file: Path) -> None: + with ProxiedClient( + client_p12_file, password=P12_PASSWORD, proxy_url=PROXY + ) as session: + assert session.hook_runs == 1 + session.reload(password=P12_PASSWORD) + assert session.hook_runs == 1 + assert session.proxy_url == PROXY + + +def test_grandchild_chains_super(client_p12: bytes) -> None: + with RegionClient.from_pkcs12( + client_p12, P12_PASSWORD, proxy_url=PROXY, region="eu-west-1" + ) as session: + assert session.region == "eu-west-1" + assert session.proxy_url == PROXY + assert session.do_not_proxy == () + + +async def test_async_hook(client_p12: bytes) -> None: + async with AsyncProxiedClient( + client_p12, password=P12_PASSWORD, proxy_url=PROXY + ) as session: + assert session.proxy_url == PROXY + async with AsyncProxiedClient.from_pkcs12(client_p12, P12_PASSWORD) as session: + assert session.proxy_url is None diff --git a/tests/test_validity.py b/tests/test_validity.py index 6fd68a3..3149a32 100644 --- a/tests/test_validity.py +++ b/tests/test_validity.py @@ -44,10 +44,13 @@ def test_expired_cert_warns_on_load_and_check_raises() -> None: def test_not_yet_valid_cert_warns_and_check_raises() -> None: - not_before = _now() + datetime.timedelta(days=10) - not_after = _now() + datetime.timedelta(days=40) + not_valid_before = _now() + datetime.timedelta(days=10) + not_valid_after = _now() + datetime.timedelta(days=40) bundle = make_client_cert( - "c", ca=make_ca(), not_before=not_before, not_after=not_after + "c", + ca=make_ca(), + not_valid_before=not_valid_before, + not_valid_after=not_valid_after, ) with pytest.warns(CertificateValidityWarning, match="not valid until"): session = PKIClient(bundle.pkcs12()) @@ -60,8 +63,8 @@ def test_not_yet_valid_cert_warns_and_check_raises() -> None: def test_warn_if_expires_within_fires() -> None: - not_after = _now() + datetime.timedelta(days=5) - bundle = make_client_cert("c", ca=make_ca(), not_after=not_after) + not_valid_after = _now() + datetime.timedelta(days=5) + bundle = make_client_cert("c", ca=make_ca(), not_valid_after=not_valid_after) with pytest.warns(CertificateValidityWarning, match="expires on"): session = PKIClient( bundle.pkcs12(), warn_if_expires_within=datetime.timedelta(days=10) @@ -77,8 +80,8 @@ def test_warn_if_expires_within_on_alternate_constructors( ) -> None: # warn_if_expires_within is an explicit parameter of every alternate # constructor, not something that happens to fall through **kwargs. - not_after = _now() + datetime.timedelta(days=5) - bundle = make_client_cert("c", ca=make_ca(), not_after=not_after) + not_valid_after = _now() + datetime.timedelta(days=5) + bundle = make_client_cert("c", ca=make_ca(), not_valid_after=not_valid_after) within = datetime.timedelta(days=10) with pytest.warns(CertificateValidityWarning, match="expires on"): if constructor == "from_pkcs12": @@ -104,8 +107,8 @@ def test_warn_if_expires_within_on_alternate_constructors( def test_check_validity_within_window_raises() -> None: - not_after = _now() + datetime.timedelta(days=5) - bundle = make_client_cert("c", ca=make_ca(), not_after=not_after) + not_valid_after = _now() + datetime.timedelta(days=5) + bundle = make_client_cert("c", ca=make_ca(), not_valid_after=not_valid_after) with PKIClient(bundle.pkcs12()) as session: session.check_validity() # currently valid -> ok with pytest.raises(CertificateExpiredError, match="within"): @@ -122,3 +125,93 @@ def test_warn_if_expires_within_silent_when_far_off() -> None: bundle.pkcs12(), warn_if_expires_within=datetime.timedelta(days=10) ) session.close() + + +def test_warn_if_expires_within_survives_reload(tmp_path: Path) -> None: + # The window is retained on the client, so a rotated certificate is judged + # against the same threshold the session was built with. Without this the + # warning goes quiet after the first rotation -- exactly when the + # auto_reload + warn_if_expires_within pairing is meant to be watching. + ca = make_ca() + path = tmp_path / "client.pem" + path.write_bytes( + make_client_cert( + "c", ca=ca, not_valid_after=_now() + datetime.timedelta(days=5) + ).pem + ) + within = datetime.timedelta(days=10) + with pytest.warns(CertificateValidityWarning, match="expires on"): + session = PKIClient(path, warn_if_expires_within=within) + try: + # A rotation that lands another short-lived certificate must warn again. + path.write_bytes( + make_client_cert( + "c", ca=ca, not_valid_after=_now() + datetime.timedelta(days=6) + ).pem + ) + with pytest.warns(CertificateValidityWarning, match="expires on"): + session.reload() + finally: + session.close() + + +def test_reload_reevaluates_the_window_against_the_new_cert( + tmp_path: Path, +) -> None: + # The window is re-applied to the fresh certificate, not replayed: rotating + # to a comfortably-valid one must go quiet again. + import warnings + + ca = make_ca() + path = tmp_path / "client.pem" + path.write_bytes( + make_client_cert( + "c", ca=ca, not_valid_after=_now() + datetime.timedelta(days=5) + ).pem + ) + with pytest.warns(CertificateValidityWarning, match="expires on"): + session = PKIClient(path, warn_if_expires_within=datetime.timedelta(days=10)) + try: + path.write_bytes(make_client_cert("c", ca=ca).pem) # ~365 days + with warnings.catch_warnings(): + warnings.simplefilter("error") + session.reload() + finally: + session.close() + + +def test_reload_without_a_window_stays_silent(tmp_path: Path) -> None: + # A client that never asked for the early warning must not start getting + # one from the reload path. + import warnings + + ca = make_ca() + path = tmp_path / "client.pem" + path.write_bytes( + make_client_cert( + "c", ca=ca, not_valid_after=_now() + datetime.timedelta(days=5) + ).pem + ) + with PKIClient(path) as session: + with warnings.catch_warnings(): + warnings.simplefilter("error") + session.reload() + + +def test_warn_if_expires_within_survives_pickle() -> None: + import pickle + + bundle = make_client_cert( + "c", ca=make_ca(), not_valid_after=_now() + datetime.timedelta(days=5) + ) + with pytest.warns(CertificateValidityWarning, match="expires on"): + session = PKIClient( + bundle.pkcs12(), warn_if_expires_within=datetime.timedelta(days=10) + ) + try: + payload = pickle.dumps(session) + finally: + session.close() + with pytest.warns(CertificateValidityWarning, match="expires on"): + restored = pickle.loads(payload) + restored.close() diff --git a/tests/test_winstore.py b/tests/test_winstore.py index 22d9e96..e28fc64 100644 --- a/tests/test_winstore.py +++ b/tests/test_winstore.py @@ -77,13 +77,37 @@ def test_select_by_thumbprint_with_separators() -> None: assert chosen.subject_cn == "ACME Dev Client" -def test_select_by_predicate() -> None: +def test_select_by_identity_predicate() -> None: chosen = select_windows_certificate( - CANDIDATES, predicate=lambda c: c.friendly_name == "prod" + CANDIDATES, identity=lambda c: c.friendly_name == "prod" ) assert chosen.thumbprint == "AA11BB" +def test_select_by_identity_name_substring() -> None: + # identity= takes the same string a bundle's identity= does: a name + # substring, so the spelling ports between a .p12 and the store. + chosen = select_windows_certificate(CANDIDATES, identity="prod") + assert chosen.thumbprint == "AA11BB" + + +def test_select_by_identity_full_thumbprint() -> None: + # A full-length hex digest is an exact fingerprint match, not a substring. + full = "A" * 40 + record = WinCert( + subject_cn="digest-user", friendly_name="digest", thumbprint=full + ) + chosen = select_windows_certificate([record, *CANDIDATES], identity=full) + assert chosen.subject_cn == "digest-user" + + +def test_select_by_identity_rejects_an_integer() -> None: + # A store has no stable ordering, so a positional identity= would select a + # different certificate run to run. It must be refused, not silently used. + with pytest.raises(TypeError, match="no stable ordering"): + select_windows_certificate(CANDIDATES, identity=0) + + def test_select_no_selector_single_candidate() -> None: only = [CANDIDATES[0]] assert select_windows_certificate(only) is only[0] @@ -159,7 +183,7 @@ def fake_export(cert: WinCert) -> tuple[bytes, bytes]: monkeypatch.setattr(winstore, "_export_pfx", fake_export) monkeypatch.setattr(winstore.sys, "platform", "win32") - ctx = build_windows_ssl_context(predicate=lambda c: "internal" in c.friendly_name) + ctx = build_windows_ssl_context(identity=lambda c: "internal" in c.friendly_name) assert isinstance(ctx, ssl.SSLContext) @@ -360,35 +384,35 @@ def _record(bundle: CertBundle, friendly_name: str) -> WinCert: ) -def test_predicate_currently_valid_skips_the_expired_copy() -> None: +def test_identity_currently_valid_skips_the_expired_copy() -> None: # A store keeps the expired certificate alongside its renewal; the # ready-made selector picks the one that works right now. cn = "ACME Renewed User" old = make_client_cert(cn, expired=True) new = make_client_cert(cn) chosen = select_windows_certificate( - [_record(old, "old"), _record(new, "new")], predicate=currently_valid + [_record(old, "old"), _record(new, "new")], identity=currently_valid ) assert chosen.friendly_name == "new" -def test_predicate_currently_valid_prefers_the_later_window() -> None: +def test_identity_currently_valid_prefers_the_later_window() -> None: # Renewal overlap: both are valid and otherwise interchangeable, so the # tie resolves to the later window. now = datetime.datetime.now(datetime.timezone.utc) cn = "ACME Overlap User" - old = make_client_cert(cn, not_after=now + datetime.timedelta(days=20)) + old = make_client_cert(cn, not_valid_after=now + datetime.timedelta(days=20)) new = make_client_cert(cn) chosen = select_windows_certificate( - [_record(old, "old"), _record(new, "new")], predicate=currently_valid + [_record(old, "old"), _record(new, "new")], identity=currently_valid ) assert chosen.friendly_name == "new" -def test_predicate_currently_valid_never_matches_unreadable_records() -> None: +def test_identity_currently_valid_never_matches_unreadable_records() -> None: # A record whose certificate could not be read cannot prove validity. with pytest.raises(CertificateNotFoundError): - select_windows_certificate(CANDIDATES, predicate=currently_valid) + select_windows_certificate(CANDIDATES, identity=currently_valid) # -- CERT_CONTEXT reading (simulated; the real struct is Windows-only) --------