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 (LOTL → national TL) as a verifier X.509 trust-anchor source (eIDAS 2.0 / EUDI, ETSI TS 119 612).

A Trusted List is, for a verifier, a source of EU-recognised X.509 anchors. :func:walk_lotl turns the Commission's List of Trusted Lists and the national lists it points at into a :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. Parsing is hardened stdlib XML (no DTD/XXE, bounded); XML-signature verification is an injected callback (the [trustlist] extra ships a reference XAdES one). See docs/adr/ADR-0003-eu-trusted-lists.md.

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)."""

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
28
29
30
31
32
33
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 XML signature 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
class TrustListSignatureError(TrustListError):
    """The Trusted List's XML signature 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."""

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))

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}")