Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ the git history for the fine print.

## Unreleased

- **New: `inventory()` and `python -m httpx_pki inventory` — find the
identities in a directory of certificate exports.** The folder a CA hands
over mixes PKCS#12 bundles, extracted PEM halves, chain bundles, and
issuance artifacts, under extensions that promise nothing. `inventory`
classifies every file by content, pairs private keys with certificates
across files by public key, and reports the constructor call each pairing
amounts to — the work otherwise done by opening files one at a time in an
editor. Where a file holds both halves it is named on its own, rather than
paired with a loose copy of the same key elsewhere in the folder.

It takes several passwords, not one, because such folders span several;
the report refers to them by position (`password #2`) and never repeats a
value. A file no password opens is reported as `LOCKED` rather than
skipped — silence about a file is the failure mode this exists to remove —
and so is a file that opened in part and kept a key shut, the shape
`openssl pkcs12 -out client.pem` writes. Certificate-only files are
`UNPAIRED`, annotated when they hold an identity's issuer (an alternative
`chain=`/`verify=` source); CSRs and human-readable text dumps are labeled
by which encoded file they describe, via public-key and fingerprint
matching. The same leaf reachable two ways (a `.p12` and its extracted
halves) is presented as one certificate with two routes. Filenames are
treated as untrusted, like the names inside the certificates: control
characters are stripped from the report, and the suggested call quotes the
name as a Python literal.

Inventory classifies and pairs; it does not audit — `explain()` is the next
step for a source it names — and it deliberately never *builds* a session:
these folders routinely hold several identities and expired renewals, so
choosing one silently is the mistake the report exists to prevent. Top
level only; subdirectories are counted, not descended into. A symlink to a
file is followed and named as it appears here — somebody linked it in on
purpose — while anything that is not a regular file (a device, a socket, a
link pointing nowhere) is named without being read. The CLI
prompts once per still-locked file (skippable), takes repeatable
`--password-env` (no `--password`, same reasoning as `explain`), and exits
non-zero only when nothing loadable was found.

- **Improved: `from_key_pair` names a swapped certificate and private key.**
Handing the private key as `certificate=` (or vice versa) used to fail with
a generic "could not parse" error, which reads as a broken file. When a
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,43 @@ PKIClient.from_env()

→ [Loading certificates](https://httpx-pki.readthedocs.io/en/stable/guide/loading-certificates.html)

## Handed a whole folder?

A CA rarely sends one file. `inventory` reads the folder, says what each file
actually is, pairs the keys with their certificates, and prints the call each
pairing amounts to:

```console
$ python -m httpx_pki inventory ./corp-export
```

```text
INVENTORY corp-export — 7 files, 2 identities

IDENTITY svc-client RSA-2048 expires 2027-01-15
bundle corp.p12 (password #1)
chain corp-issuing-ca.crt
→ PKIClient("corp.p12", password=..., chain="corp-issuing-ca.crt")

IDENTITY svc-client RSA-2048 expires 2027-01-15
certificate svc-client.pem
private key svc-client.key (encrypted — opened with password #2)
chain corp-issuing-ca.crt
same certificate as corp.p12
→ from_key_pair(certificate="svc-client.pem", private_key="svc-client.key", password=..., chain="corp-issuing-ca.crt")

LOCKED old-2025.pem — 1 certificate, plus 1 encrypted private key none of the given passwords open

NOTES cert-details.txt — human-readable dump; fingerprint matches corp.p12 (not loadable)
svc-client.csr — certificate request for the key of corp.p12 (issuance artifact, not loadable)
```

Nothing is skipped: a file no password opens is reported as locked, not
dropped. It classifies and pairs — it never builds a session for you, because a
folder like that usually holds more than one answer.

→ [Taking inventory of a folder](https://httpx-pki.readthedocs.io/en/stable/guide/taking-inventory.html)

## Async

```python
Expand Down
3 changes: 2 additions & 1 deletion docs/about/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ For anyone reading the source:
| `_ssl` | Building the `ssl.SSLContext`, staging, and `verify=` |
| `_audit` | Finding trust anchors and chain certificates that cannot do their job |
| `_explain` | Laying that out as a report — `explain()` and `client.explain()` |
| `__main__` | `python -m httpx_pki explain` |
| `_inventory` | Classifying a directory of files and pairing them — `inventory()` |
| `__main__` | `python -m httpx_pki explain` and `inventory` |
| `_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 |
Expand Down
4 changes: 3 additions & 1 deletion docs/about/non-goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ policy. Beyond that, revocation is out of scope. See
## Fetching anything over the network

httpx-pki never makes a request of its own. Reading a certificate does not
cause one, and neither does [`explain()`](../guide/inspecting-a-certificate.md).
cause one, and neither does [`explain()`](../guide/inspecting-a-certificate.md)
or [`inventory()`](../guide/taking-inventory.md) — the latter reads the one
directory you name, top level only, and nothing else.

This is a security boundary, not an omission. When a chain is incomplete,
`explain()` reports the URL the certificate names for its issuer — its
Expand Down
5 changes: 5 additions & 0 deletions docs/about/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ 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).

Reports never carry a password value either.
[`inventory()`](../guide/taking-inventory.md) takes several passwords and
refers to them by position — `password #2` — precisely because its output is
meant to be pasted into a ticket or a CI log.

## `SSLKEYLOGFILE` decrypts your traffic

Contexts httpx-pki builds honor the standard `SSLKEYLOGFILE` variable, writing
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ instead — and if you have an error in hand,

Where your credential lives, and how to point httpx-pki at it.

- [](taking-inventory.md) — before you know which file to point at: what a
folder of exports holds, which files pair up, and how to load each pairing
- [](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
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/inspecting-a-certificate.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ For a file you have not written any code for yet:
$ python -m httpx_pki explain corp.p12
```

(For a whole *folder* you have not written any code for yet, the command is
[`inventory`](taking-inventory.md) — it names the files, and `explain` takes it
from there.)

It takes the same selectors the library does, so a bundle holding several
identities can be listed and then inspected:

Expand Down Expand Up @@ -314,6 +318,8 @@ To make expiry a hard failure on every request instead, use

## Next steps

- [](taking-inventory.md) — the step before this one, when what you have is a
folder rather than a file
- [](choosing-a-certificate.md) — inspecting a file that holds several
identities
- [](expiry-and-rotation.md) — acting on what you find as certificates age
Expand Down
208 changes: 208 additions & 0 deletions docs/guide/taking-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Taking inventory of a folder

Certificates rarely arrive as one file. What usually lands in your hands is a folder —
a `.p12` next to the PEM halves somebody extracted from it, a chain bundle, a
CSR nobody deleted, a text dump of a certificate that may or may not be one of
the others, and last year's renewal. The extensions promise nothing, several
of the files want passwords, and you do not yet know which two of them go
together.

`inventory()` reads the folder and tells you: what each file is, which files
pair into a usable identity, and the constructor call each pairing amounts to.

```console
$ python -m httpx_pki inventory ./corp-export
```

```python
import httpx_pki

print(httpx_pki.inventory("./corp-export"))
```

## What the report says

With no passwords yet, nothing has opened — and the report is already useful:

```text
INVENTORY corp-export — 7 files, 0 identities

LOCKED corp.p12 — encrypted PKCS#12; none of the given passwords open it
old-2025.pem — 1 certificate, plus 1 encrypted private key none of the given passwords open
svc-client.key — 1 encrypted private key; none of the given passwords open it

UNPAIRED corp-issuing-ca.crt — 2 certificates with no matching key here (all CA certificates — possibly a verify= trust bundle)
svc-client.pem — 1 certificate with no matching key here

NOTES cert-details.txt — human-readable dump; fingerprint matches svc-client.pem (not loadable)
svc-client.csr — certificate request matching nothing here (issuance artifact, not loadable)

1 subdirectory not inventoried — point inventory() at them directly
```

Every file in the folder appears exactly once. Nothing was skipped for being
unrecognizable, unreadable, or shut — a file the report says nothing about is
the failure this exists to remove, so there is no such file.

Note what it already knows without a single password: `corp-issuing-ca.crt`
holds only CA certificates and is probably a trust bundle; `cert-details.txt`
is prose *about* `svc-client.pem` rather than anything loadable; the CSR is an
issuance artifact. Classification is done on content, never on the extension.

## Passwords, plural

A folder accumulated over time spans several passwords — the export password
and the passphrase on the key somebody extracted from it are routinely
different. So `inventory()` takes a list, and tries each against each
encrypted file:

```python
print(httpx_pki.inventory("./corp-export", passwords=[p12_password, key_password]))
```

```text
INVENTORY corp-export — 7 files, 2 identities

IDENTITY svc-client RSA-2048 expires 2027-01-15
bundle corp.p12 (password #1)
chain corp-issuing-ca.crt
→ PKIClient("corp.p12", password=..., chain="corp-issuing-ca.crt")

IDENTITY svc-client RSA-2048 expires 2027-01-15
certificate svc-client.pem
private key svc-client.key (encrypted — opened with password #2)
chain corp-issuing-ca.crt
same certificate as corp.p12
→ from_key_pair(certificate="svc-client.pem", private_key="svc-client.key", password=..., chain="corp-issuing-ca.crt")

LOCKED old-2025.pem — 1 certificate, plus 1 encrypted private key none of the given passwords open

NOTES cert-details.txt — human-readable dump; fingerprint matches corp.p12 (not loadable)
svc-client.csr — certificate request for the key of corp.p12 (issuance artifact, not loadable)

1 subdirectory not inventoried — point inventory() at them directly
```

The folder holds **one** certificate reachable **two** ways, and the report
says so rather than presenting two mysteries: the second identity is marked
`same certificate as corp.p12`. Either call works; the `.p12` is one file
instead of two.

:::{important}
The report names passwords **by position** — `password #2` — and never repeats
a value. That is deliberate.
:::

`old-2025.pem` stays locked, and that is the last year's renewal nobody could
open. It is still named, still counted, and still on the list of things to ask
somebody about.

## What each section means

**`IDENTITY`** — a private key and its certificate, matched by public key, the
same rule the loaders use. One heading line (subject, key type, expiry), the
files it is made of, and the call that loads it. An expired certificate says
`EXPIRED 2026-01-15` in place of `expires`.

**`LOCKED`** — a file holding key material that none of your passwords opened.
A file that opened *in part* — a PEM whose certificate is readable and whose
key is not, which is what `openssl pkcs12 -out client.pem` writes — appears
here too, because the shut key is the part worth another password.

**`UNPAIRED`** — a certificate with no matching key in this folder, or a key
with no matching certificate. Not always a mystery: a file holding an
identity's issuer is annotated as an alternative `chain=` or `verify=` source,
and a file of nothing but CA certificates is called out as a probable trust
bundle.

**`NOTES`** — everything that is not loadable and not a half: CSRs, text
dumps, unrecognizable files, files too large to be certificate material, and
files that could not be read. CSRs and dumps are matched back to the file they
describe, by public key and by fingerprint respectively.

## From a shell

```console
$ python -m httpx_pki inventory ./corp-export
$ python -m httpx_pki inventory # the current directory
```

Passwords come from the environment, repeatably:

```console
$ python -m httpx_pki inventory ./corp-export \
--password-env P12_PASSWORD --password-env KEY_PASSWORD
```

Anything still locked after that is prompted for, one file at a time, and each
prompt can be skipped with a blank line. A password typed for one file is
tried against all of them, since a folder's `.p12` and its extracted key
routinely share one.

There is deliberately no `--password` flag, for the same reason
[`explain`](inspecting-a-certificate.md#from-a-shell) does not have one: an
argument lands in shell history and in every process listing on the machine.

The command exits non-zero only when the folder yields **nothing loadable**.
Locked and unpaired files are the normal lint of such a folder, not a failure
of the inventory, so a directory with one usable identity and four mysteries
exits `0`.

## What it will not do

**It will not build a session.** A folder like this routinely holds several
identities, expired renewals, and a stray trust bundle, so silently choosing
one is precisely the mistake the report exists to prevent. It hands you the
call and lets you make it.

**It will not descend into subdirectories.** A CA export is flat. Whatever
else a subtree holds, crawling it uninvited is not this function's job — so
subdirectories are counted and named, and you point the tool at them yourself.
Symlinks to files *are* followed, under the name they wear in this folder:
somebody linked it in on purpose.

**It will not audit.** `inventory()` classifies and pairs. Once it names a
source, [`explain()`](inspecting-a-certificate.md#explaining-a-whole-configuration)
is the tool for what would stop that source working — validity, chain,
trust, key usage, and the problems that only show up on a handshake:

```console
$ python -m httpx_pki inventory ./corp-export # which file, and how
$ python -m httpx_pki explain corp-export/corp.p12 # and what is wrong with it
```

**It will not touch the network**, or anything outside the directory you name.

## In code

`inventory()` returns a {class}`~httpx_pki.DirectoryInventory`. `print()` gives the report above; the attributes give the same thing as data:

```python
report = httpx_pki.inventory("./corp-export", passwords=[p12_password])

if not report.usable:
raise SystemExit("nothing loadable in that folder")

for identity in report.identities:
print(identity.info.common_name, identity.info.not_valid_after)
print(" ", identity.suggestion)

for entry in report.locked:
print("still need a password for", entry.name)
```

`identities` holds {class}`~httpx_pki.InventoryIdentity` objects — `info` is
the usual {class}`~httpx_pki.CertInfo`, and `bundle_file` or the
`certificate_file`/`key_file` pair names what to load. `files` carries every
file the inventory saw as an {class}`~httpx_pki.InventoryEntry`, whatever
became of it; `locked`, `unpaired`, and `notes` are the report's other
sections.

## Next steps

- [](loading-certificates.md) — making the call the report suggested
- [](inspecting-a-certificate.md) — `explain()`, for a source the inventory
has named
- [](choosing-a-certificate.md) — when one of those files holds several
identities
- [](server-trust.md) — what to do with the trust bundle it found
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ troubleshooting

guide/index
guide/backends
guide/taking-inventory
guide/loading-certificates
guide/choosing-a-certificate
guide/inspecting-a-certificate
Expand Down
5 changes: 5 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ 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.

Not sure which of those lines applies to what you were sent? If it arrived as a
folder, `python -m httpx_pki inventory ./that-folder` will tell you — it names
every file and prints the call each usable pairing amounts to. See
[](guide/taking-inventory.md).

:::{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
Expand Down
Loading