Skip to content

EU Trusted Lists

openvc.trustlist consumes the EU List of Trusted Lists (LOTL) and the national Trusted Lists it points at (eIDAS 2.0 / EUDI, ETSI TS 119 612) as a source of X.509 trust anchors for the verifier. walk_lotl(...) returns a TrustAnchorSet whose .certificates feed the existing X.509 path directly — it adds no verification surface; openvc.x5c stays the path validator.

from openvc import verify_credential
from openvc.trustlist import walk_lotl, verify_xades_enveloped
from openvc.fetch import https_bytes_fetch

anchors = walk_lotl(
    "https://ec.europa.eu/tools/lotl/eu-lotl.xml",
    lotl_signer_certs=[commission_cert],     # caller-pinned root — no implicit trust
    verify_signature=verify_xades_enveloped, # the [trustlist] extra's XAdES verifier
    fetch=https_bytes_fetch)
verify_credential(vc, x5c_trust_anchors=anchors.certificates)

Trust is caller-pinned (the LOTL signer certs), fail-closed (a list that cannot be fetched, verified, or is expired contributes zero anchors and is recorded in problems), and selective (default: granted qualified-CA services). XML parsing is hardened stdlib (no DTD/XXE, bounded). XML-signature (XAdES) verification is an injected callback kept out of core: install openvc-core[trustlist] for the reference verify_xades_enveloped (signxml), or inject your own. See ADR-0003.

openvc.trustlist

openvc.trustlist — consume EU trusted lists as a verifier X.509 trust-anchor source (eIDAS 2.0 / EUDI): one interface, two encodings.

  • ETSI TS 119 612 XML Trusted Lists (LOTL → national TL): :func:walk_lotl.
  • ETSI TS 119 602 JSON Lists of Trusted Entities (LoTE), the successor data model whose EU profiles carry the EUDI wallet anchor lists — Annex F (WRPAC providers) and Annex G (WRPRC providers): :func:walk_lote.

Both distil into the same :class:TrustAnchorSet; its .certificates feed the existing X.509 path directly:

from openvc import verify_credential
from openvc.trustlist import walk_lotl
from openvc.fetch import https_bytes_fetch

anchors = walk_lotl(
    "https://ec.europa.eu/tools/lotl/eu-lotl.xml",
    lotl_signer_certs=[commission_cert],     # caller-pinned root (no implicit trust)
    verify_signature=my_xades_verifier,      # injected, fail-closed
    fetch=https_bytes_fetch)
verify_credential(vc, x5c_trust_anchors=anchors.certificates)

This adds no verification surface — :mod:openvc.x5c remains the path validator; trust lists only tell it which roots are EU-recognised. XML parsing is hardened stdlib (no DTD/XXE, bounded) with XML-signature verification an injected callback (the [trustlist] extra ships a reference XAdES one); the LoTE lane is signed as compact JAdES and verifies on the library's own JOSE primitives, no extra needed. See docs/adr/ADR-0003-eu-trusted-lists.md and :mod:openvc.trustlist.lote.

Select dataclass

A filter over trust services. A None facet matches everything; a set restricts to its members. The default (see :data:DEFAULT_SELECT) keeps granted qualified-CA services — the ones that issue EUDI issuer certs.

Source code in src/openvc/trustlist/consume.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
@dataclass(frozen=True)
class Select:
    """A filter over trust services. A ``None`` facet matches everything; a set
    restricts to its members. The default (see :data:`DEFAULT_SELECT`) keeps
    ``granted`` qualified-CA services — the ones that issue EUDI issuer certs."""
    service_types: frozenset[str] | None = None
    statuses: frozenset[str] | None = None
    territories: frozenset[str] | None = None

    def matches(self, anchor: TrustServiceAnchor) -> bool:
        if self.service_types is not None and anchor.service_type not in self.service_types:
            return False
        if self.statuses is not None and anchor.service_status not in self.statuses:
            return False
        if self.territories is not None and (anchor.territory or "") not in self.territories:
            return False
        return True

ServiceStatus

ETSI TS 119 612 ServiceStatus URIs (the ones a verifier usually gates on).

Source code in src/openvc/trustlist/consume.py
42
43
44
45
46
class ServiceStatus:
    """ETSI TS 119 612 ``ServiceStatus`` URIs (the ones a verifier usually gates on)."""
    GRANTED = f"{_ETSI}/TrustedList/Svcstatus/granted"
    WITHDRAWN = f"{_ETSI}/TrustedList/Svcstatus/withdrawn"
    DEPRECATED_AT_NATIONAL_LEVEL = f"{_ETSI}/TrustedList/Svcstatus/deprecatedatnationallevel"

ServiceType

ETSI TS 119 612 ServiceTypeIdentifier URIs.

A convenience set of the identifiers observed on the live EU Trusted Lists under TLv6 (ETSI TS 119 612 v2.4.1, mandatory since 29 Apr 2026). These are just names — :class:Select matches ServiceTypeIdentifier verbatim, so any URI works, including the EUDI-wallet trust services (issuance of QEAA / EAA / PuB-EAA, qualified electronic ledgers) that v2.4.1 introduces but national lists have not widely populated yet: pass their URI to :class:Select as it rolls out.

Source code in src/openvc/trustlist/consume.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class ServiceType:
    """ETSI TS 119 612 ``ServiceTypeIdentifier`` URIs.

    A convenience set of the identifiers observed on the live EU Trusted Lists under
    **TLv6** (ETSI TS 119 612 v2.4.1, mandatory since 29 Apr 2026). These are just
    names — :class:`Select` matches ``ServiceTypeIdentifier`` verbatim, so **any** URI
    works, including the EUDI-wallet trust services (issuance of QEAA / EAA / PuB-EAA,
    qualified electronic ledgers) that v2.4.1 introduces but national lists have not
    widely populated yet: pass their URI to :class:`Select` as it rolls out.
    """
    CA_QC = f"{_ETSI}/Svctype/CA/QC"                 # CA issuing qualified certificates
    CA_PKC = f"{_ETSI}/Svctype/CA/PKC"              # CA issuing public-key certificates
    NATIONAL_ROOT_CA_QC = f"{_ETSI}/Svctype/NationalRootCA-QC"
    OCSP_QC = f"{_ETSI}/Svctype/Certstatus/OCSP/QC"
    OCSP = f"{_ETSI}/Svctype/Certstatus/OCSP"        # non-qualified OCSP
    CRL_QC = f"{_ETSI}/Svctype/Certstatus/CRL/QC"
    TSA_QTST = f"{_ETSI}/Svctype/TSA/QTST"           # qualified timestamping
    TSA = f"{_ETSI}/Svctype/TSA"                     # non-qualified timestamping
    # Other qualified eIDAS trust services carried on TLv6 national lists:
    EDS_Q = f"{_ETSI}/Svctype/EDS/Q"                 # qualified electronic delivery
    EDS_REM_Q = f"{_ETSI}/Svctype/EDS/REM/Q"         # qualified registered e-mail delivery
    PSES_Q = f"{_ETSI}/Svctype/PSES/Q"               # qualified preservation of e-signatures
    QES_VALIDATION_Q = f"{_ETSI}/Svctype/QESValidation/Q"          # qualified QES validation
    REMOTE_QSIGCD_MANAGEMENT_Q = f"{_ETSI}/Svctype/RemoteQSigCDManagement/Q"
    REMOTE_QSEALCD_MANAGEMENT_Q = f"{_ETSI}/Svctype/RemoteQSealCDManagement/Q"
    ARCHIVING = f"{_ETSI}/Svctype/Archiv"            # archiving

TrustListError

Bases: OpenvcError

Base class for every Trusted List failure.

Source code in src/openvc/trustlist/errors.py
7
8
class TrustListError(OpenvcError):
    """Base class for every Trusted List failure."""

TrustListParseError

Bases: TrustListError

The Trusted List XML is malformed, oversize, or carries a forbidden construct (a DTD/DOCTYPE — an XXE / entity-expansion vector).

Source code in src/openvc/trustlist/errors.py
11
12
13
class TrustListParseError(TrustListError):
    """The Trusted List XML is malformed, oversize, or carries a forbidden
    construct (a DTD/DOCTYPE — an XXE / entity-expansion vector)."""

TrustListProfileError

Bases: TrustListError

A verified, well-formed LoTE does not conform to the requested profile (ETSI TS 119 602 clause 4.7 — e.g. the Annex F/G EU WRPAC/WRPRC providers lists): wrong LoTEType, a forbidden component present, a service type outside the profile's exclusive set, or an update window over the ceiling. Fail-closed: a non-conformant list contributes no anchors.

Source code in src/openvc/trustlist/errors.py
29
30
31
32
33
34
class TrustListProfileError(TrustListError):
    """A verified, well-formed LoTE does not conform to the requested profile
    (ETSI TS 119 602 clause 4.7 — e.g. the Annex F/G EU WRPAC/WRPRC providers
    lists): wrong ``LoTEType``, a forbidden component present, a service type
    outside the profile's exclusive set, or an update window over the ceiling.
    Fail-closed: a non-conformant list contributes no anchors."""

TrustListSignatureBackendUnavailable

Bases: TrustListSignatureUnavailable

XAdES signature verification was requested (via the reference :func:openvc.trustlist.verify_xades_enveloped) but the [trustlist] extra (signxml) is not installed (pip install openvc-core[trustlist]). A subclass of :class:TrustListSignatureUnavailable: no verifier is available, so a list is still never trusted unverified.

Source code in src/openvc/trustlist/errors.py
37
38
39
40
41
42
class TrustListSignatureBackendUnavailable(TrustListSignatureUnavailable):
    """XAdES signature verification was requested (via the reference
    :func:`openvc.trustlist.verify_xades_enveloped`) but the ``[trustlist]`` extra
    (``signxml``) is not installed (``pip install openvc-core[trustlist]``). A
    subclass of :class:`TrustListSignatureUnavailable`: no verifier is available, so
    a list is still never trusted unverified."""

TrustListSignatureError

Bases: TrustListError

The Trusted List's signature (XML XAdES, or a LoTE's compact JAdES) did not verify against the expected signer certificate(s) — the list is not authentic.

Source code in src/openvc/trustlist/errors.py
23
24
25
26
class TrustListSignatureError(TrustListError):
    """The Trusted List's signature (XML XAdES, or a LoTE's compact JAdES) did
    not verify against the expected signer certificate(s) — the list is not
    authentic."""

TrustListSignatureUnavailable

Bases: TrustListError

A Trusted List had to be verified but no verify_signature callback was supplied (fail-closed — a list is never trusted unverified). Pass the reference :func:openvc.trustlist.verify_xades_enveloped (pip install openvc-core[trustlist]), or inject your own.

Source code in src/openvc/trustlist/errors.py
16
17
18
19
20
class TrustListSignatureUnavailable(TrustListError):
    """A Trusted List had to be verified but no ``verify_signature`` callback was
    supplied (fail-closed — a list is never trusted unverified). Pass the reference
    :func:`openvc.trustlist.verify_xades_enveloped` (``pip install
    openvc-core[trustlist]``), or inject your own."""

LoteProfile dataclass

A LoTE profile (TS 119 602 clause 4.7): scheme-defined constraints a specific list must satisfy on top of the general data model. Checking a list against a profile is a conformance gate — every mismatch fails closed as :class:~openvc.trustlist.errors.TrustListProfileError.

service_types is the profile's exclusive set (every service in the list must use one of them); anchor_service_types is the least-privilege subset a profiled :func:walk_lote keeps by default — the issuance services. Under Annex F/G both an issuance and a revocation service become list entries, but only the issuance certificates should anchor credential verification (the adversarial review's M1: without the split, a registrar's revocation-service key would validate WRPRC chains).

Source code in src/openvc/trustlist/lote.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@dataclass(frozen=True)
class LoteProfile:
    """A LoTE profile (TS 119 602 clause 4.7): scheme-defined constraints a
    specific list must satisfy on top of the general data model. Checking a list
    against a profile is a conformance gate — every mismatch fails closed as
    :class:`~openvc.trustlist.errors.TrustListProfileError`.

    ``service_types`` is the profile's *exclusive* set (every service in the
    list must use one of them); ``anchor_service_types`` is the least-privilege
    subset a profiled :func:`walk_lote` keeps by default — the **issuance**
    services. Under Annex F/G both an issuance and a revocation service become
    list entries, but only the issuance certificates should anchor credential
    verification (the adversarial review's M1: without the split, a registrar's
    *revocation*-service key would validate WRPRC chains)."""
    name: str
    lote_type: str                          # required LoTEType (Table x.1)
    status_determination: tuple[str, ...]   # accepted StatusDeterminationApproach spellings
    scheme_rules: str                       # required SchemeTypeCommunityRules URI
    territory: str                          # required SchemeTerritory
    service_types: frozenset[str]           # the exclusive ServiceTypeIdentifier set
    anchor_service_types: frozenset[str]    # the default anchors a profiled walk keeps
    max_update_months: int = 6              # NextUpdate - ListIssueDateTime ceiling

LoteServiceType

The ServiceTypeIdentifier URIs of the WRPAC / WRPRC providers-list profiles (TS 119 602 Tables F.3 / G.3) — each profile uses its pair "to the exclusion of any other".

Source code in src/openvc/trustlist/lote.py
100
101
102
103
104
105
106
107
class LoteServiceType:
    """The ``ServiceTypeIdentifier`` URIs of the WRPAC / WRPRC providers-list
    profiles (TS 119 602 Tables F.3 / G.3) — each profile uses its pair "to the
    exclusion of any other"."""
    WRPAC_ISSUANCE = f"{_URI_19602}/SvcType/WRPAC/Issuance"
    WRPAC_REVOCATION = f"{_URI_19602}/SvcType/WRPAC/Revocation"
    WRPRC_ISSUANCE = f"{_URI_19602}/SvcType/WRPRC/Issuance"
    WRPRC_REVOCATION = f"{_URI_19602}/SvcType/WRPRC/Revocation"

LoteType

The EU LoTEType URIs of TS 119 602 clause C.2.1.

Source code in src/openvc/trustlist/lote.py
90
91
92
93
94
95
96
97
class LoteType:
    """The EU ``LoTEType`` URIs of TS 119 602 clause C.2.1."""
    EU_PID_PROVIDERS = f"{_URI_19602}/LoTEType/EUPIDProvidersList"
    EU_WALLET_PROVIDERS = f"{_URI_19602}/LoTEType/EUWalletProvidersList"
    EU_WRPAC_PROVIDERS = f"{_URI_19602}/LoTEType/EUWRPACProvidersList"
    EU_WRPRC_PROVIDERS = f"{_URI_19602}/LoTEType/EUWRPRCProvidersList"
    EU_PUB_EAA_PROVIDERS = f"{_URI_19602}/LoTEType/EUPubEAAProvidersList"
    EU_REGISTRARS_AND_REGISTERS = f"{_URI_19602}/LoTEType/EURegistrarsAndRegistersList"

TrustAnchorSet dataclass

The result of a LOTL→TL walk: the anchors that verified + the problems.

Source code in src/openvc/trustlist/model.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@dataclass(frozen=True)
class TrustAnchorSet:
    """The result of a LOTL→TL walk: the anchors that verified + the problems."""
    anchors: tuple[TrustServiceAnchor, ...]
    problems: tuple[TrustListProblem, ...] = field(default_factory=tuple)

    @property
    def certificates(self) -> list[Any]:
        """The bare ``x509.Certificate`` anchors — pass straight to
        ``verify_credential(..., x5c_trust_anchors=...)``. Deduplicated by DER."""
        seen: set[bytes] = set()
        out: list[Any] = []
        from cryptography.hazmat.primitives.serialization import Encoding
        for a in self.anchors:
            der = a.certificate.public_bytes(Encoding.DER)
            if der not in seen:
                seen.add(der)
                out.append(a.certificate)
        return out

    @property
    def x509_hashes(self) -> set[str]:
        """The HAIP ``x509_hash`` (hex SHA-256) of every anchor certificate."""
        return {a.sha256 for a in self.anchors}

certificates property

The bare x509.Certificate anchors — pass straight to verify_credential(..., x5c_trust_anchors=...). Deduplicated by DER.

x509_hashes property

The HAIP x509_hash (hex SHA-256) of every anchor certificate.

TrustList dataclass

A parsed ETSI TS 119 612 Trusted List — the LOTL (pointers) or a national TL (providers).

Source code in src/openvc/trustlist/model.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@dataclass(frozen=True)
class TrustList:
    """A parsed ETSI TS 119 612 Trusted List — the LOTL (``pointers``) or a national
    TL (``providers``)."""
    tsl_type: str | None
    scheme_operator: str | None
    territory: str | None
    sequence_number: int | None
    issue_datetime: str | None
    next_update: datetime | None
    pointers: tuple[TslPointer, ...] = ()          # LOTL: pointers to national TLs
    providers: tuple[TrustServiceProvider, ...] = ()  # national TL: the TSP list
    version: int | None = None                     # TSLVersionIdentifier (6 = TLv6)

    @property
    def is_lotl(self) -> bool:
        """Whether this is the List of Trusted Lists (by ``TSLType``)."""
        return self.tsl_type is not None and self.tsl_type.endswith("EUlistofthelists")

is_lotl property

Whether this is the List of Trusted Lists (by TSLType).

TrustListProblem dataclass

Why a TL (or the LOTL) contributed no anchors — surfaced, never silent.

Source code in src/openvc/trustlist/model.py
77
78
79
80
81
82
@dataclass(frozen=True)
class TrustListProblem:
    """Why a TL (or the LOTL) contributed no anchors — surfaced, never silent."""
    location: str                          # the TL URL (or "<lotl>")
    stage: str                             # "fetch" | "signature" | "parse" | "expired"
    detail: str

TrustServiceAnchor dataclass

One trust-service X.509 certificate with the metadata that lets a verifier decide whether to trust it (service type, status, provider, territory).

Source code in src/openvc/trustlist/model.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@dataclass(frozen=True)
class TrustServiceAnchor:
    """One trust-service X.509 certificate with the metadata that lets a verifier
    decide whether to trust it (service type, status, provider, territory)."""
    certificate: Any                       # an x509.Certificate
    service_type: str                      # ServiceTypeIdentifier URI
    service_status: str                    # ServiceStatus URI
    tsp_name: str | None = None            # the Trust Service Provider name
    service_name: str | None = None
    territory: str | None = None           # the TL's SchemeTerritory

    @property
    def sha256(self) -> str:
        """The hex SHA-256 of the certificate DER — HAIP ``x509_hash`` for this anchor."""
        from cryptography.hazmat.primitives.serialization import Encoding
        return hashlib.sha256(self.certificate.public_bytes(Encoding.DER)).hexdigest()

sha256 property

The hex SHA-256 of the certificate DER — HAIP x509_hash for this anchor.

TrustServiceProvider dataclass

A Trust Service Provider and its services (national TL entry).

Source code in src/openvc/trustlist/model.py
50
51
52
53
54
@dataclass(frozen=True)
class TrustServiceProvider:
    """A Trust Service Provider and its services (national TL entry)."""
    name: str | None
    services: tuple[TrustServiceAnchor, ...]

TslPointer dataclass

One OtherTSLPointer in the LOTL: where a national TL lives and the certificate(s) that TL's XML signature must verify against (the LOTL vouches for these — ADR-0003 D5).

Source code in src/openvc/trustlist/model.py
20
21
22
23
24
25
26
27
28
29
@dataclass(frozen=True)
class TslPointer:
    """One ``OtherTSLPointer`` in the LOTL: where a national TL lives and the
    certificate(s) that TL's XML signature must verify against (the LOTL vouches
    for these — ADR-0003 D5)."""
    location: str                          # TSLLocation (the national TL URL)
    signer_certs: tuple[Any, ...]          # x509.Certificate objects (DigitalId X509Certificate)
    territory: str | None = None           # SchemeTerritory in the pointer's AdditionalInformation
    tsl_type: str | None = None            # pointed list's TSLType (EUgeneric / EUlistofthelists)
    mime_type: str | None = None

consume_trust_list(xml, *, verify_signature, expected_signer_certs, max_bytes=DEFAULT_MAX_BYTES)

Verify a TL's XML signature (fail-closed) then parse it into a :class:TrustList.

verify_signature is handed the raw bytes and expected_signer_certs and must raise on any failure; if it is None this raises :class:TrustListSignatureUnavailable — a list is never parsed-and-trusted unverified. Signature verification runs before parsing so an unauthentic list is rejected outright.

Source code in src/openvc/trustlist/consume.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def consume_trust_list(
    xml: bytes, *,
    verify_signature: VerifySignature | None,
    expected_signer_certs: Sequence[Any],
    max_bytes: int = DEFAULT_MAX_BYTES,
) -> TrustList:
    """Verify a TL's XML signature (fail-closed) **then** parse it into a
    :class:`TrustList`.

    *verify_signature* is handed the raw bytes and *expected_signer_certs* and must
    raise on any failure; if it is ``None`` this raises
    :class:`TrustListSignatureUnavailable` — a list is never parsed-and-trusted
    unverified. Signature verification runs before parsing so an unauthentic list is
    rejected outright."""
    if verify_signature is None:
        raise TrustListSignatureUnavailable(
            "no verify_signature callback given; a trust list is never trusted "
            "unverified (pass openvc.trustlist.verify_xades_enveloped from the "
            "[trustlist] extra, or inject your own)")
    try:
        verify_signature(bytes(xml), tuple(expected_signer_certs))
    except TrustListError:
        raise
    except Exception as exc:                    # any raise from the callback = not authentic
        raise TrustListSignatureError(
            f"trust list signature verification failed: {exc}") from exc
    return parse_trust_list(xml, max_bytes=max_bytes)

default_trust_list_fetch(url)

The blessed SSRF-guarded TL fetch: :func:openvc.fetch.https_bytes_fetch with a TL-sized byte cap (national TLs run to a few MB).

Source code in src/openvc/trustlist/consume.py
102
103
104
105
106
def default_trust_list_fetch(url: str) -> bytes:
    """The blessed SSRF-guarded TL fetch: :func:`openvc.fetch.https_bytes_fetch` with a
    TL-sized byte cap (national TLs run to a few MB)."""
    from ..fetch import https_bytes_fetch
    return https_bytes_fetch(url, max_bytes=DEFAULT_MAX_BYTES)

walk_lotl(lotl_url, *, lotl_signer_certs, verify_signature, fetch=default_trust_list_fetch, select=DEFAULT_SELECT, now=None, max_bytes=DEFAULT_MAX_BYTES)

Walk the LOTL at lotl_url down to each national TL and return the selected X.509 trust anchors.

Trust is rooted in lotl_signer_certs (the caller-pinned Commission keys). Each TL's XML signature is verified via verify_signature (fail-closed). select filters the trust services (default: granted qualified-CA — pass None for all); fetch performs the SSRF-guarded GETs. A TL that cannot be fetched / verified / is expired contributes no anchors and is recorded in the result's problems — never silently trusted (ADR-0003 D6). Pass now to pin the expiry evaluation instant.

Source code in src/openvc/trustlist/consume.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def walk_lotl(
    lotl_url: str, *,
    lotl_signer_certs: Sequence[Any],
    verify_signature: VerifySignature | None,
    fetch: FetchTrustList = default_trust_list_fetch,
    select: Select | None = DEFAULT_SELECT,
    now: datetime | None = None,
    max_bytes: int = DEFAULT_MAX_BYTES,
) -> TrustAnchorSet:
    """Walk the LOTL at *lotl_url* down to each national TL and return the selected
    X.509 trust anchors.

    Trust is rooted in *lotl_signer_certs* (the caller-pinned Commission keys). Each
    TL's XML signature is verified via *verify_signature* (fail-closed). *select*
    filters the trust services (default: ``granted`` qualified-CA — pass ``None`` for
    all); *fetch* performs the SSRF-guarded GETs. A TL that cannot be fetched /
    verified / is expired contributes no anchors and is recorded in the result's
    ``problems`` — never silently trusted (ADR-0003 D6). Pass *now* to pin the
    expiry evaluation instant."""
    instant = _utc(now) if now is not None else datetime.now(timezone.utc)
    problems: list[TrustListProblem] = []

    try:
        lotl_bytes = fetch(lotl_url)
    except Exception as exc:                    # LOTL unreachable -> no anchors at all
        return TrustAnchorSet(
            anchors=(), problems=(TrustListProblem(lotl_url, "fetch", str(exc)),))
    try:
        lotl = consume_trust_list(
            lotl_bytes, verify_signature=verify_signature,
            expected_signer_certs=lotl_signer_certs, max_bytes=max_bytes)
    except TrustListError as exc:
        return TrustAnchorSet(
            anchors=(), problems=(TrustListProblem(lotl_url, _stage(exc), str(exc)),))
    if _expired(lotl, instant):
        return TrustAnchorSet(anchors=(), problems=(
            TrustListProblem(lotl_url, "expired",
                             f"LOTL NextUpdate {lotl.next_update} is in the past"),))

    anchors: list[TrustServiceAnchor] = []
    for pointer in lotl.pointers:
        # a pointer to another LOTL (a pivot) yields no service anchors — skip it
        if pointer.tsl_type and pointer.tsl_type.endswith("EUlistofthelists"):
            continue
        if (select is not None and select.territories is not None
                and (pointer.territory or "") not in select.territories):
            continue
        try:
            tl_bytes = fetch(pointer.location)
        except Exception as exc:
            problems.append(TrustListProblem(pointer.location, "fetch", str(exc)))
            continue
        try:
            tl = consume_trust_list(
                tl_bytes, verify_signature=verify_signature,
                expected_signer_certs=pointer.signer_certs, max_bytes=max_bytes)
        except TrustListError as exc:
            problems.append(TrustListProblem(pointer.location, _stage(exc), str(exc)))
            continue
        if _expired(tl, instant):
            problems.append(TrustListProblem(
                pointer.location, "expired",
                f"NextUpdate {tl.next_update} is in the past"))
            continue
        for provider in tl.providers:
            for svc in provider.services:
                if select is None or select.matches(svc):
                    anchors.append(svc)

    return TrustAnchorSet(anchors=tuple(anchors), problems=tuple(problems))

consume_lote(token, *, expected_signer_certs, profile=None, now=None, max_bytes=DEFAULT_MAX_BYTES)

Verify a signed LoTE (compact JAdES baseline B) then parse it.

In order: the compact-JWS envelope (the {ES256, ES384, EdDSA, Ed25519} allow-list applied before any crypto; a fail-closed allow-listed crit; x5c required); the signer authenticated against expected_signer_certs — the leaf matching a pinned certificate byte-for-byte (within that certificate's own validity window), or path-validating to the pinned set as anchors; the signature against the leaf key; the strict payload parse; clause 6.8's DN binding (signing-certificate organizationName must be a SchemeOperatorName value, countryName the SchemeTerritory when the list carries one); and, when profile is given, the profile's conformance gate (:class:TrustListProfileError on any mismatch).

There is no implicit trust root: expected_signer_certs are cryptography x509.Certificate objects the caller pins (or, on a pointer walk, the certificates the pointing list vouched for).

consume_lote establishes authenticity and conformance — not freshness: staging an expired or closed list (NextUpdate in the past, or null per clause 6.3.15) is :func:walk_lote's job, mirroring the 119 612 lane's consume_trust_list/walk_lotl split. A caller using consume_lote directly must check next_update itself.

Source code in src/openvc/trustlist/lote.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
def consume_lote(
    token: str | bytes, *,
    expected_signer_certs: Sequence[Any],
    profile: LoteProfile | None = None,
    now: datetime | None = None,
    max_bytes: int = DEFAULT_MAX_BYTES,
) -> TrustList:
    """Verify a signed LoTE (compact JAdES baseline B) **then** parse it.

    In order: the compact-JWS envelope (the ``{ES256, ES384, EdDSA, Ed25519}``
    allow-list applied before any crypto; a fail-closed allow-listed ``crit``;
    ``x5c`` required); the signer authenticated against *expected_signer_certs*
    — the leaf matching a pinned certificate byte-for-byte (within that
    certificate's own validity window), or path-validating to the pinned set as
    anchors; the **signature** against the leaf key; the strict payload parse;
    clause 6.8's DN binding (signing-certificate ``organizationName`` must be a
    ``SchemeOperatorName`` value, ``countryName`` the ``SchemeTerritory`` when
    the list carries one); and, when *profile* is given, the profile's
    conformance gate (:class:`TrustListProfileError` on any mismatch).

    There is no implicit trust root: *expected_signer_certs* are
    ``cryptography`` ``x509.Certificate`` objects the caller pins (or, on a
    pointer walk, the certificates the pointing list vouched for).

    ``consume_lote`` establishes authenticity and conformance — **not
    freshness**: staging an expired or **closed** list (``NextUpdate`` in the
    past, or null per clause 6.3.15) is :func:`walk_lote`'s job, mirroring the
    119 612 lane's ``consume_trust_list``/``walk_lotl`` split. A caller using
    ``consume_lote`` directly must check ``next_update`` itself."""
    if isinstance(token, (bytes, bytearray)):
        try:
            token = bytes(token).decode("ascii")
        except UnicodeDecodeError as exc:
            raise TrustListParseError("a compact JAdES LoTE must be ASCII") from exc
    if not isinstance(token, str):
        raise TrustListParseError("LoTE token must be a compact-JWS string or bytes")
    if len(token) > max_bytes:
        raise TrustListParseError(
            f"LoTE token is {len(token)} bytes, over the {max_bytes}-byte cap")

    from ..proof._jws import parse_compact
    from ..proof.errors import ProofError
    from ..proof.vc_jwt import ALLOWED_ALGS

    try:
        header, payload, signing_input, signature = parse_compact(token)
    except ProofError as exc:
        raise TrustListParseError(f"LoTE is not a valid compact JWS: {exc}") from exc

    alg = header.get("alg")
    if not isinstance(alg, str) or alg not in ALLOWED_ALGS:
        raise TrustListSignatureError(
            f"LoTE alg {alg!r} is not permitted (need one of {sorted(ALLOWED_ALGS)})")
    _reject_unknown_crit(header)

    leaf, chain = _signer_chain(header)
    _authenticate_signer(leaf, chain, expected_signer_certs, now=now)

    from ..keys import KeyBackendError, verify_signature
    from ..x5c import X5cError, leaf_public_jwk
    try:
        public_jwk = leaf_public_jwk(leaf)
    except X5cError as exc:
        raise TrustListSignatureError(f"LoTE signing certificate: {exc}") from exc
    try:
        ok = verify_signature(alg=alg, public_jwk=public_jwk,
                              signing_input=signing_input, signature=signature)
    except KeyBackendError as exc:
        raise TrustListSignatureError(f"LoTE signature could not be checked: {exc}") from exc
    if not ok:
        raise TrustListSignatureError("LoTE signature verification failed")

    trust_list = parse_lote(payload, max_bytes=max_bytes)
    lote_obj = payload["LoTE"]
    _check_signer_dn(leaf, lote_obj["ListAndSchemeInformation"], territory=trust_list.territory)
    if profile is not None:
        _check_profile(trust_list, lote_obj, profile)
    return trust_list

default_lote_fetch(url)

The blessed SSRF-guarded LoTE fetch: :func:openvc.fetch.https_bytes_fetch with the trust-list byte cap.

Source code in src/openvc/trustlist/lote.py
163
164
165
166
167
def default_lote_fetch(url: str) -> bytes:
    """The blessed SSRF-guarded LoTE fetch: :func:`openvc.fetch.https_bytes_fetch`
    with the trust-list byte cap."""
    from ..fetch import https_bytes_fetch
    return https_bytes_fetch(url, max_bytes=DEFAULT_MAX_BYTES)

parse_lote(payload, *, max_bytes=DEFAULT_MAX_BYTES)

Parse a TS 119 602 JSON LoTE document into a :class:TrustList — strictly, fail-closed, WITHOUT any signature verification (use :func:consume_lote, which verifies first).

Accepts the decoded top-level object ({"LoTE": …}) or raw JSON bytes. Raises :class:TrustListParseError on any structural violation.

Source code in src/openvc/trustlist/lote.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def parse_lote(
    payload: Mapping[str, Any] | bytes, *, max_bytes: int = DEFAULT_MAX_BYTES,
) -> TrustList:
    """Parse a TS 119 602 JSON LoTE document into a :class:`TrustList` —
    strictly, fail-closed, WITHOUT any signature verification (use
    :func:`consume_lote`, which verifies first).

    Accepts the decoded top-level object (``{"LoTE": …}``) or raw JSON bytes.
    Raises :class:`TrustListParseError` on any structural violation."""
    if isinstance(payload, (bytes, bytearray)):
        if len(payload) > max_bytes:
            raise TrustListParseError(
                f"LoTE is {len(payload)} bytes, over the {max_bytes}-byte cap")
        try:
            decoded = json.loads(payload)
        except (ValueError, RecursionError) as exc:
            raise TrustListParseError(f"LoTE is not valid JSON: {exc}") from exc
    else:
        decoded = payload
    root = _require_mapping(decoded, "LoTE document")
    _check_keys(root, allowed=frozenset({"LoTE"}), required=frozenset({"LoTE"}),
                ctx="LoTE document")
    lote = _require_mapping(root["LoTE"], "LoTE")
    _check_keys(lote, allowed=frozenset({"ListAndSchemeInformation", "TrustedEntitiesList"}),
                required=frozenset({"ListAndSchemeInformation"}), ctx="LoTE")

    scheme = _require_mapping(lote["ListAndSchemeInformation"], "ListAndSchemeInformation")
    _check_keys(scheme, allowed=_SCHEME_KEYS, required=_SCHEME_REQUIRED,
                ctx="ListAndSchemeInformation")

    version = _require_int(scheme["LoTEVersionIdentifier"], "LoTEVersionIdentifier")
    sequence = _require_int(scheme["LoTESequenceNumber"], "LoTESequenceNumber")
    operator_names = _ml_strings(scheme["SchemeOperatorName"], "SchemeOperatorName")
    lote_type = (_require_str(scheme["LoTEType"], "LoTEType")
                 if "LoTEType" in scheme else None)
    territory = (_require_str(scheme["SchemeTerritory"], "SchemeTerritory")
                 if "SchemeTerritory" in scheme else None)
    if "HistoricalInformationPeriod" in scheme:
        _require_int(scheme["HistoricalInformationPeriod"], "HistoricalInformationPeriod")
    if "StatusDeterminationApproach" in scheme:
        _require_str(scheme["StatusDeterminationApproach"], "StatusDeterminationApproach")
    if "SchemeTypeCommunityRules" in scheme:
        _ml_uris(scheme["SchemeTypeCommunityRules"], "SchemeTypeCommunityRules")
    if "SchemeName" in scheme:
        _ml_strings(scheme["SchemeName"], "SchemeName")
    if "SchemeInformationURI" in scheme:
        _ml_uris(scheme["SchemeInformationURI"], "SchemeInformationURI")
    if "SchemeOperatorAddress" in scheme:
        _require_mapping(scheme["SchemeOperatorAddress"], "SchemeOperatorAddress")
    if "PolicyOrLegalNotice" in scheme:
        _require_list(scheme["PolicyOrLegalNotice"], "PolicyOrLegalNotice")
    if "DistributionPoints" in scheme:
        points = _require_list(scheme["DistributionPoints"], "DistributionPoints")
        if not points:
            raise TrustListParseError("DistributionPoints must not be empty")
        for i, p in enumerate(points):
            _require_str(p, f"DistributionPoints[{i}]")
    if "SchemeExtensions" in scheme:
        _reject_critical_extensions(scheme["SchemeExtensions"], "SchemeExtensions")

    issue = _datetime_z(scheme["ListIssueDateTime"], "ListIssueDateTime")
    # Clause 6.3.15: NextUpdate is null for a **closed** LoTE (scheme ceased);
    # a closed list contributes no anchors — the walk stages it as expired.
    next_update = (None if scheme["NextUpdate"] is None
                   else _datetime_z(scheme["NextUpdate"], "NextUpdate"))

    pointers: list[TslPointer] = []
    if "PointersToOtherLoTE" in scheme:
        raw_ptrs = _require_list(scheme["PointersToOtherLoTE"], "PointersToOtherLoTE")
        if not raw_ptrs:
            raise TrustListParseError("PointersToOtherLoTE must not be empty")
        for i, p in enumerate(raw_ptrs):
            pointers.append(_parse_pointer(p, f"PointersToOtherLoTE[{i}]"))

    providers: list[TrustServiceProvider] = []
    if "TrustedEntitiesList" in lote:
        entities = _require_list(lote["TrustedEntitiesList"], "TrustedEntitiesList")
        if not entities:
            raise TrustListParseError("TrustedEntitiesList must not be empty")
        for i, e in enumerate(entities):
            providers.append(_parse_entity(e, f"TrustedEntitiesList[{i}]", territory))

    return TrustList(
        tsl_type=lote_type, scheme_operator=operator_names[0], territory=territory,
        sequence_number=sequence, issue_datetime=issue.isoformat(), next_update=next_update,
        pointers=tuple(pointers), providers=tuple(providers), version=version)

walk_lote(lote_url, *, lote_signer_certs, profile=None, fetch=default_lote_fetch, select=_DERIVED_SELECT, now=None, max_bytes=DEFAULT_MAX_BYTES, max_lists=8)

Fetch, verify and distil the LoTE at lote_url (plus one hop of pointed lists) into a :class:TrustAnchorSet.

Trust is rooted in lote_signer_certs (caller-pinned — for the EU lists, the Commission's published list-signing certificates). The root list is verified with :func:consume_lote under profile. Pointed lists follow the clause 6.3.13 vouching model (one hop, like :func:~openvc.trustlist.walk_lotl): each is verified against the certificates its pointer vouched for — and, in a profiled walk, a pointer is only followed when its qualifier LoTEType matches the profile, and the pointed list must conform to the same profile, so a foreign list type can never leak anchors into a profiled walk. A list that cannot be fetched, verified, parsed, is expired, or is closed (NextUpdate null, clause 6.3.15) contributes zero anchors and is recorded in problems — never silently trusted, never aborting the walk.

select defaults to the profile's anchor service types — the issuance services only (least privilege: under Annex F/G a provider's revocation service is also listed, but its certificates must not anchor credential verification). With no profile, the default keeps everything (the EU profiles forbid ServiceStatus, so the 119 612 lane's granted-status default would drop every anchor). Pass an explicit :class:Select to filter differently, or select=None for every admitted anchor. max_lists caps the total lists consumed (root + pointed).

Source code in src/openvc/trustlist/lote.py
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
def walk_lote(
    lote_url: str, *,
    lote_signer_certs: Sequence[Any],
    profile: LoteProfile | None = None,
    fetch: FetchLote = default_lote_fetch,
    select: Select | None = _DERIVED_SELECT,
    now: datetime | None = None,
    max_bytes: int = DEFAULT_MAX_BYTES,
    max_lists: int = 8,
) -> TrustAnchorSet:
    """Fetch, verify and distil the LoTE at *lote_url* (plus one hop of pointed
    lists) into a :class:`TrustAnchorSet`.

    Trust is rooted in *lote_signer_certs* (caller-pinned — for the EU lists,
    the Commission's published list-signing certificates). The root list is
    verified with :func:`consume_lote` under *profile*. Pointed lists follow
    the clause 6.3.13 vouching model (one hop, like
    :func:`~openvc.trustlist.walk_lotl`): each is verified against the
    certificates its pointer vouched for — and, in a **profiled** walk, a
    pointer is only followed when its qualifier ``LoTEType`` matches the
    profile, and the pointed list must conform to the **same** profile, so a
    foreign list type can never leak anchors into a profiled walk. A list that
    cannot be fetched, verified, parsed, is expired, or is **closed**
    (``NextUpdate`` null, clause 6.3.15) contributes zero anchors and is
    recorded in ``problems`` — never silently trusted, never aborting the walk.

    *select* defaults to the **profile's anchor service types** — the issuance
    services only (least privilege: under Annex F/G a provider's *revocation*
    service is also listed, but its certificates must not anchor credential
    verification). With no profile, the default keeps everything (the EU
    profiles forbid ``ServiceStatus``, so the 119 612 lane's granted-status
    default would drop every anchor). Pass an explicit :class:`Select` to
    filter differently, or ``select=None`` for every admitted anchor.
    ``max_lists`` caps the total lists consumed (root + pointed)."""
    if select is _DERIVED_SELECT:
        select = (Select(service_types=profile.anchor_service_types)
                  if profile is not None else None)
    instant = now if now is not None else datetime.now(timezone.utc)
    if instant.tzinfo is None:
        instant = instant.replace(tzinfo=timezone.utc)
    problems: list[TrustListProblem] = []

    try:
        root_bytes = fetch(lote_url)
    except Exception as exc:                # root unreachable -> no anchors at all
        return TrustAnchorSet(
            anchors=(), problems=(TrustListProblem(lote_url, "fetch", str(exc)),))
    try:
        root = consume_lote(
            root_bytes if isinstance(root_bytes, str) else bytes(root_bytes),
            expected_signer_certs=lote_signer_certs, profile=profile,
            now=instant, max_bytes=max_bytes)
    except TrustListError as exc:
        return TrustAnchorSet(
            anchors=(), problems=(TrustListProblem(lote_url, _stage(exc), str(exc)),))
    stale = _staleness(root, instant)
    if stale:
        return TrustAnchorSet(
            anchors=(), problems=(TrustListProblem(lote_url, "expired", stale),))

    anchors: list[TrustServiceAnchor] = []
    for provider in root.providers:
        for svc in provider.services:
            if select is None or select.matches(svc):
                anchors.append(svc)

    visited = {lote_url}
    consumed = 1
    for pointer in root.pointers:
        if pointer.location in visited:
            continue                        # the EU profiles' self-pointer, or a repeat
        if profile is not None and pointer.tsl_type != profile.lote_type:
            problems.append(TrustListProblem(
                pointer.location, "profile",
                f"not followed: pointer LoTEType {pointer.tsl_type!r} is outside "
                f"this profiled walk ({profile.lote_type!r})"))
            continue
        if (select is not None and select.territories is not None
                and (pointer.territory or "") not in select.territories):
            continue
        if consumed >= max_lists:
            problems.append(TrustListProblem(
                pointer.location, "consume",
                f"not followed: the {max_lists}-list cap was reached"))
            continue
        visited.add(pointer.location)
        consumed += 1
        try:
            pointed_bytes = fetch(pointer.location)
        except Exception as exc:
            problems.append(TrustListProblem(pointer.location, "fetch", str(exc)))
            continue
        try:
            pointed = consume_lote(
                pointed_bytes if isinstance(pointed_bytes, str) else bytes(pointed_bytes),
                expected_signer_certs=pointer.signer_certs, profile=profile,
                now=instant, max_bytes=max_bytes)
        except TrustListError as exc:
            problems.append(TrustListProblem(pointer.location, _stage(exc), str(exc)))
            continue
        stale = _staleness(pointed, instant)
        if stale:
            problems.append(TrustListProblem(pointer.location, "expired", stale))
            continue
        for provider in pointed.providers:
            for svc in provider.services:
                if select is None or select.matches(svc):
                    anchors.append(svc)

    return TrustAnchorSet(anchors=tuple(anchors), problems=tuple(problems))

parse_trust_list(xml, *, max_bytes=DEFAULT_MAX_BYTES)

Parse a Trusted List (or the LOTL) XML document into a :class:TrustList.

Hardened against XXE / entity-expansion (no DTD) and oversize input. Raises :class:TrustListParseError on malformed, oversize, or DTD-bearing XML.

Source code in src/openvc/trustlist/parse.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def parse_trust_list(xml: bytes, *, max_bytes: int = DEFAULT_MAX_BYTES) -> TrustList:
    """Parse a Trusted List (or the LOTL) XML document into a :class:`TrustList`.

    Hardened against XXE / entity-expansion (no DTD) and oversize input. Raises
    :class:`TrustListParseError` on malformed, oversize, or DTD-bearing XML."""
    root = _hardened_parse(xml, max_bytes=max_bytes)
    if root.tag != _q("TrustServiceStatusList"):
        raise TrustListParseError(
            f"root element is {root.tag!r}, not a TrustServiceStatusList")

    scheme = root.find(_q("SchemeInformation"))
    tsl_type = _text(scheme, _q("TSLType")) if scheme is not None else None
    operator = None
    territory = None
    seq = None
    version = None
    issue = None
    next_update = None
    pointers: list[TslPointer] = []
    if scheme is not None:
        operator = _localized_name(scheme.find(_q("SchemeOperatorName")))
        territory = _text(scheme, _q("SchemeTerritory"))
        seq = _int(_text(scheme, _q("TSLSequenceNumber")))
        version = _int(_text(scheme, _q("TSLVersionIdentifier")))
        issue = _text(scheme, _q("ListIssueDateTime"))
        nu = scheme.find(_q("NextUpdate"))
        next_update = _parse_datetime(_text(nu, _q("dateTime"))) if nu is not None else None
        ptrs = scheme.find(_q("PointersToOtherTSL"))
        if ptrs is not None:
            for op in ptrs.findall(_q("OtherTSLPointer")):
                pointer = _parse_pointer(op)
                if pointer is not None:
                    pointers.append(pointer)

    providers: list[TrustServiceProvider] = []
    tsp_list = root.find(_q("TrustServiceProviderList"))
    if tsp_list is not None:
        for tsp in tsp_list.findall(_q("TrustServiceProvider")):
            providers.append(_parse_provider(tsp, territory))

    return TrustList(
        tsl_type=tsl_type, scheme_operator=operator, territory=territory,
        sequence_number=seq, issue_datetime=issue, next_update=next_update,
        pointers=tuple(pointers), providers=tuple(providers), version=version)

verify_xades_enveloped(xml, signer_certs, *, max_bytes=DEFAULT_MAX_BYTES)

Verify a Trusted List's enveloped XAdES / XML-DSig signature against signer_certs, returning None on success and raising on any failure — the exact shape :func:openvc.trustlist.walk_lotl's verify_signature expects.

The signature must verify against one of signer_certs (the certificates the parent list vouched for); each is tried in turn and the first that verifies wins. Accepted signature shapes: plain enveloped XML-DSig (one Reference over the document) and XAdES-BASELINE (document + the signature's own SignedProperties, optionally a co-signed ds:KeyInfo) — the shape real EU trusted lists carry. Raises :class:TrustListSignatureError on a bad/absent signature, tampered content, a DTD-bearing document, oversize input, unexpected signed references, or no matching signer; :class:TrustListSignatureBackendUnavailable if the [trustlist] extra (signxml) is not installed.

Source code in src/openvc/trustlist/xades.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def verify_xades_enveloped(
    xml: bytes,
    signer_certs: Sequence[Any],
    *,
    max_bytes: int = DEFAULT_MAX_BYTES,
) -> None:
    """Verify a Trusted List's enveloped XAdES / XML-DSig signature against
    *signer_certs*, returning ``None`` on success and raising on any failure — the
    exact shape :func:`openvc.trustlist.walk_lotl`'s ``verify_signature`` expects.

    The signature must verify against **one of** *signer_certs* (the certificates the
    parent list vouched for); each is tried in turn and the first that verifies wins.
    Accepted signature shapes: plain enveloped XML-DSig (one Reference over the
    document) and XAdES-BASELINE (document + the signature's own ``SignedProperties``,
    optionally a co-signed ``ds:KeyInfo``) — the shape real EU trusted lists carry.
    Raises :class:`TrustListSignatureError` on a bad/absent signature, tampered
    content, a DTD-bearing document, oversize input, unexpected signed references,
    or no matching signer; :class:`TrustListSignatureBackendUnavailable` if the
    ``[trustlist]`` extra (``signxml``) is not installed."""
    try:
        from cryptography.hazmat.primitives.serialization import Encoding
        from lxml import etree
        from signxml import (
            DigestAlgorithm,
            InvalidCertificate,
            InvalidDigest,
            InvalidInput,
            InvalidSignature,
            SignatureConfiguration,
            SignatureMethod,
            XMLVerifier,
        )
    except ImportError as exc:
        raise TrustListSignatureBackendUnavailable(
            "XAdES verification needs the trustlist extra: "
            "pip install openvc-core[trustlist]") from exc

    # Pin the XAdES-BASELINE-B algorithm profile: RSA / ECDSA (incl. RSA-PSS) over
    # SHA-256/384/512. This rejects HMAC, DSA, SHA-1/224 and SHA-3. The Reference COUNT is
    # deliberately not pinned here: every real XAdES-BASELINE signature carries the enveloped
    # document plus its own SignedProperties (the hard 1-reference pin shipped in v1.20.0
    # rejected the actual EU LOTL). Reference coverage is enforced structurally below in
    # _check_signed_references, which keeps the anti-wrapping posture.
    config = SignatureConfiguration(
        signature_methods=frozenset({
            SignatureMethod.RSA_SHA256, SignatureMethod.RSA_SHA384, SignatureMethod.RSA_SHA512,
            SignatureMethod.ECDSA_SHA256, SignatureMethod.ECDSA_SHA384,
            SignatureMethod.ECDSA_SHA512, SignatureMethod.SHA256_RSA_MGF1,
            SignatureMethod.SHA384_RSA_MGF1, SignatureMethod.SHA512_RSA_MGF1,
        }),
        digest_algorithms=frozenset({
            DigestAlgorithm.SHA256, DigestAlgorithm.SHA384, DigestAlgorithm.SHA512}),
        expect_references=True,     # count/coverage enforced in _check_signed_references
    )

    if not isinstance(xml, (bytes, bytearray)):
        raise TrustListSignatureError(
            f"trust list must be bytes, got {type(xml).__name__}")
    if len(xml) > max_bytes:
        raise TrustListSignatureError(
            f"trust list is {len(xml)} bytes, over the {max_bytes}-byte cap")
    certs = list(signer_certs)
    if not certs:
        raise TrustListSignatureError("no expected signer certificates to verify against")

    data = bytes(xml)
    # The document root tag, parsed with entities/DTD/network off, to assert the signature
    # covers the WHOLE document (below) — signxml already rejects DTDs, this is defence in depth.
    try:
        root_tag = etree.fromstring(
            data, etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)).tag
    except etree.XMLSyntaxError as exc:
        raise TrustListSignatureError(f"trust list is not well-formed XML: {exc}") from exc

    signxml_errors = (InvalidSignature, InvalidCertificate, InvalidDigest, InvalidInput)
    last_err: Exception | None = None
    for cert in certs:
        try:
            pem = cert.public_bytes(Encoding.PEM).decode("ascii")
        except Exception as exc:               # not a usable x509.Certificate
            last_err = exc
            continue
        try:
            result = XMLVerifier().verify(data, x509_cert=pem, expect_config=config)
        except signxml_errors as exc:
            last_err = exc
            continue
        # XSW guard: the whole document must be signed via the enveloped URI="" reference
        # (so no signed subtree can be relocated under an attacker root while unsigned nodes
        # — extra TrustServiceProviders / certs — are consumed by the parser). Correlate each
        # verified reference with its SignedInfo URI (same order) and enforce that structurally.
        results = result if isinstance(result, list) else [result]
        uris = _reference_uris(results[0].signature_xml)
        _check_signed_references(results, uris, root_tag)
        return                                 # authentic + signed by a vouched cert + full scope
    raise TrustListSignatureError(
        f"trust list signature did not verify against any of the {len(certs)} "
        f"expected signer certificate(s): {last_err}")