Skip to content

PqChain Validator

Reports the post-quantum posture of every certificate in the presented chain. During the staged PQ migration the leaf, intermediates, and root rotate independently, so a single yes/no for the whole chain hides the information operators actually need. This validator gives a per-certificate view plus a role-level summary.

A certificate counts as PQ when either its public key algorithm or its signature algorithm is post-quantum (standalone ML-DSA/SLH-DSA or a composite). The signature is the issuing CA's choice rather than the operator's, so both are tracked separately per link.

By default is_valid: true means the leaf certificate's key is post-quantum (the part the operator controls). Pass require_full_chain: true via validator args to require that every presented certificate has a PQ key or a PQ signature. This does not require both on each certificate or verify the signatures.

The server may omit the root

This report covers the presented chain, not a path built against a trust store. A missing root produces root_pq: null. A classical root, when present, is a separate migration finding; it does not change the default leaf-key policy.

Opt-in

Registered but disabled by default (not in DEFAULT_VALIDATORS):

from certmonitor import CertMonitor

with CertMonitor("example.com", enabled_validators=["pq_chain"]) as m:
    print(m.validate()["pq_chain"])

# strict mode:
#   m.validate(validator_args={"pq_chain": {"require_full_chain": True}})

Chain retrieval uses the same APIs and fallbacks as the Chain validator. If the server's chain cannot be retrieved or parsed, you get a structured error instead of the report below. Missing issuers are not downloaded.

How it decides

Each link is classified independently, then the verdict keys off the leaf key by default (or the whole chain with require_full_chain).

flowchart TD
    A[validate called] --> B{Chain available?}
    B -- No --> Z["structured error"]
    B -- Yes --> C[For each certificate:<br/>is_pq = key_is_pq OR signature_is_pq]
    C --> D[Summarize by role:<br/>leaf_pq / intermediate_pq / root_pq]
    D --> E{require_full_chain?}
    E -- "false (default)" --> F{Leaf key<br/>post-quantum?}
    F -- Yes --> G["is_valid: true"]
    F -- No --> H["is_valid: false"]
    E -- true --> I{Every certificate<br/>is_pq?}
    I -- Yes --> G
    I -- No --> H

Example output

A post-quantum leaf on a classical chain (one possible migration shape):

These examples show selected fields from illustrative scans. validate() also adds status and code, described in the result contract.

{
    "chain_length": 3,
    "certs": [
        {"position": 0, "role": "leaf", "subject": {"commonName": "example.com"}, "key_algorithm": "ml-dsa-65",
         "key_is_pq": true, "signature_algorithm_oid": "1.2.840.113549.1.1.11",
         "signature_is_pq": false, "is_pq": true},
        {"position": 1, "role": "intermediate", "subject": {"commonName": "Intermediate CA"}, "key_algorithm": "rsaEncryption",
         "key_is_pq": false, "signature_is_pq": false, "is_pq": false},
        {"position": 2, "role": "root", "subject": {"commonName": "Root CA"}, "key_algorithm": "rsaEncryption",
         "key_is_pq": false, "signature_is_pq": false, "is_pq": false}
    ],
    "summary": {"leaf_pq": true, "intermediate_pq": false, "root_pq": false},
    "is_valid": true
}

summary values are null for roles with no certificate in the chain (e.g. a single self-signed cert has no intermediates).

Reference

certmonitor.validators.pq_chain.PqChainValidator

Bases: BaseCertValidator

Report the post-quantum posture of every certificate in the chain.

During the staged PQ migration, the leaf, intermediates, and root rotate independently, a post-quantum leaf will routinely chain up to classical intermediates and roots for years. This validator walks the chain the server presented and reports, per certificate, whether the public key and the signature use post-quantum algorithms, plus a role-level summary.

A certificate counts as PQ when either its key algorithm or its signature algorithm is post-quantum (standalone or composite), both are meaningful migration signals, and the signature is the issuing CA's choice rather than the operator's.

By default is_valid is True when the leaf certificate's key is post-quantum, the part the operator controls. Pass require_full_chain=True to demand that every certificate in the chain is PQ.

Note: chains that terminate at public trust anchors will report a classical root for the foreseeable future. This is expected, not a bug, root CAs migrate last.

Opt-in: registered in VALIDATORS but not in DEFAULT_VALIDATORS. Chain retrieval requires Python 3.10+ (same constraint as the chain validator); on older interpreters this validator reports a structured error.

Attributes:

Name Type Description
name str

The name of the validator.

name class-attribute instance-attribute

name: str = 'pq_chain'

requires class-attribute instance-attribute

requires: ClassVar = ('cert_data',)

validate

validate(cert: dict[str, Any], host: str, port: int, *, require_full_chain: bool = False) -> PqChainResult

Walk the presented chain and report per-certificate PQ posture.

Parameters:

Name Type Description Default
cert dict[str, Any]

The cert data dict built by CertMonitor; expected to contain chain_analysis (and/or chain_error).

required
host str

The hostname (unused; dispatcher compatibility).

required
port int

The port (unused; dispatcher compatibility).

required
require_full_chain bool

When True, is_valid requires every certificate in the chain to be post-quantum. Default False: the leaf's key decides.

False

Returns:

Name Type Description
dict PqChainResult

{chain_length, certs, summary, is_valid} where each

PqChainResult

entry is `{position, role, subject, key_algorithm, key_is_pq,

PqChainResult

signature_algorithm_oid, signature_is_pq, is_pq}` and the

PqChainResult

summary is {leaf_pq, intermediate_pq, root_pq}

PqChainResult

(None when the chain has no certificate in that role).

Examples:

Example output (post-quantum leaf on a classical chain):

{
    "chain_length": 3,
    "certs": [
        {"position": 0, "role": "leaf", "key_algorithm": "ml-dsa-65", "key_is_pq": true, "is_pq": true},
        {"position": 1, "role": "intermediate", "key_algorithm": "rsaEncryption", "key_is_pq": false, "is_pq": false},
        {"position": 2, "role": "root", "key_algorithm": "rsaEncryption", "key_is_pq": false, "is_pq": false}
    ],
    "summary": {"leaf_pq": true, "intermediate_pq": false, "root_pq": false},
    "is_valid": true
}
(Per-cert fields abbreviated; each entry also carries subject, signature_algorithm_oid, and signature_is_pq.)

Source code in certmonitor/validators/pq_chain.py
def validate(
    self,
    cert: dict[str, Any],
    host: str,
    port: int,
    *,
    require_full_chain: bool = False,
) -> PqChainResult:
    """Walk the presented chain and report per-certificate PQ posture.

    Args:
        cert: The cert data dict built by `CertMonitor`; expected to
            contain `chain_analysis` (and/or `chain_error`).
        host: The hostname (unused; dispatcher compatibility).
        port: The port (unused; dispatcher compatibility).
        require_full_chain: When `True`, `is_valid` requires every
            certificate in the chain to be post-quantum. Default
            `False`: the leaf's key decides.

    Returns:
        dict: `{chain_length, certs, summary, is_valid}` where each
        entry is `{position, role, subject, key_algorithm, key_is_pq,
        signature_algorithm_oid, signature_is_pq, is_pq}` and the
        summary is `{leaf_pq, intermediate_pq, root_pq}`
        (`None` when the chain has no certificate in that role).

    Examples:
        Example output (post-quantum leaf on a classical chain):
            ```json
            {
                "chain_length": 3,
                "certs": [
                    {"position": 0, "role": "leaf", "key_algorithm": "ml-dsa-65", "key_is_pq": true, "is_pq": true},
                    {"position": 1, "role": "intermediate", "key_algorithm": "rsaEncryption", "key_is_pq": false, "is_pq": false},
                    {"position": 2, "role": "root", "key_algorithm": "rsaEncryption", "key_is_pq": false, "is_pq": false}
                ],
                "summary": {"leaf_pq": true, "intermediate_pq": false, "root_pq": false},
                "is_valid": true
            }
            ```
            (Per-cert fields abbreviated; each entry also carries
            `subject`, `signature_algorithm_oid`, and
            `signature_is_pq`.)
    """
    chain_error = cert.get("chain_error")
    if chain_error:
        return self._error_result(chain_error)

    analysis = cert.get("chain_analysis")
    if analysis is None:
        return self._error_result(
            "Certificate chain was not fetched. This typically means the "
            "Python interpreter is older than 3.10 or the SSL handler did "
            "not populate the chain."
        )
    if isinstance(analysis, dict) and "error" in analysis:
        return self._error_result(analysis["error"])

    raw_certs: list[dict[str, Any]] = list(analysis.get("certs", []))
    if not raw_certs:
        return self._error_result("Certificate chain is empty.")

    certs: list[dict[str, Any]] = []
    for idx, raw in enumerate(raw_certs):
        key_algorithm = raw.get("public_key_info", {}).get("algorithm", "unknown")
        sig_oid = raw.get("signature_algorithm_oid", "")
        key_is_pq = key_algorithm in _PQ_KEY_NAMES
        signature_is_pq = sig_oid in _PQ_SIG_OIDS

        if idx == 0:
            role = "leaf"
        elif raw.get("is_self_signed", False):
            role = "root"
        else:
            role = "intermediate"

        certs.append(
            {
                "position": idx,
                "role": role,
                "subject": raw.get("subject", {}),
                "key_algorithm": key_algorithm,
                "key_is_pq": key_is_pq,
                "signature_algorithm_oid": sig_oid,
                "signature_is_pq": signature_is_pq,
                "is_pq": key_is_pq or signature_is_pq,
            }
        )

    summary = {
        "leaf_pq": certs[0]["key_is_pq"],
        "intermediate_pq": self._role_all_pq(certs, "intermediate"),
        "root_pq": self._role_all_pq(certs, "root"),
    }

    if require_full_chain:
        is_valid = all(entry["is_pq"] for entry in certs)
    else:
        is_valid = bool(summary["leaf_pq"])

    result: PqChainResult = {
        "chain_length": len(certs),
        "certs": certs,
        "summary": summary,
        "is_valid": is_valid,
    }
    if not is_valid:
        result["reason"] = (
            "Not every certificate in the chain uses a post-quantum algorithm."
            if require_full_chain
            else f"Leaf key algorithm ({certs[0]['key_algorithm']}) is not post-quantum."
        )
    return result