EU trusted lists and qualified signatures
Added in version 0.30.0.
With the optional [etsi] dependency group installed, pyHanko can use
EU trusted lists (as published under eIDAS) as a source of trust, and assess
whether a signature was produced with a qualified certificate.
This page assumes familiarity with the AdES validation engine; trusted-list support slots into the same
SignatureValidationSpec machinery described there.
How trust lists fit in
Under eIDAS, every member state publishes a signed trusted list (TL) enumerating its qualified trust service providers, and the European Commission publishes a list of the lists (LOTL) pointing at all of them. Rather than a flat set of root certificates, a trusted list associates each trust service with rich metadata.
PyHanko models this through a TSPRegistry, which a TSPTrustManager then
exposes to the validation engine as a trust source. Because the trust manager
plugs into the ordinary CertValidationPolicySpec, everything from the
AdES engine guide—revocation gathering, local
knowledge, LTA validation—continues to work unchanged.
Building a registry from a trusted list
In production you will normally bootstrap the registry from the live LOTL (see
below). The underlying operation, however, is to
parse a single signed trusted list into a TSPRegistry, verifying its XML
signature against the scheme operator’s certificate. That is exactly what
trust_list_to_registry()
does, and it works fully offline given a trusted-list document on disk.
from pyhanko.keys import load_cert_from_pemder
from pyhanko.sign.validation.qualified.eutl_parse import (
trust_list_to_registry,
)
# The scheme operator's certificate, used to verify the list signature.
tlso_cert = load_cert_from_pemder('path/to/certfile')
with open('trusted-list.xml', 'r', encoding='utf-8') as tl_file:
registry, recoverable_errors = trust_list_to_registry(
tl_file.read(), tlso_certs=[tlso_cert]
)
# 'recoverable_errors' flags services that could not be parsed; the rest
# of the registry is still usable.
cas = list(registry.known_certificate_authorities)
print("CAs registered:", len(cas))
CAs registered: 1
Validating against the registry
To use the registry, wrap it in a TSPTrustManager and drop that into a
CertValidationPolicySpec exactly where a
SimpleTrustManager would go.
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.sign.validation.ades import ades_basic_validation
from pyhanko.sign.validation.policy_decl import SignatureValidationSpec
from pyhanko.sign.validation.qualified.tsp import TSPTrustManager
from pyhanko_certvalidator.context import CertValidationPolicySpec
from pyhanko_certvalidator.policy_decl import (
CertRevTrustPolicy,
REQUIRE_REVINFO,
)
validation_spec = SignatureValidationSpec(
cert_validation_policy=CertValidationPolicySpec(
trust_manager=TSPTrustManager(registry),
revinfo_policy=CertRevTrustPolicy(REQUIRE_REVINFO),
),
)
async def validate():
with open('document.pdf', 'rb') as doc:
sig = PdfFileReader(doc).embedded_signatures[0]
result = await ades_basic_validation(
sig.signed_data,
validation_spec=validation_spec,
raw_digest=sig.compute_digest(),
)
print(result.ades_subindic)
return result
AdESPassed.OK
Inspecting the qualification verdict
When validation succeeds against a trusted list, the resulting
PdfSignatureStatus carries a
QualificationResult
describing how the certificate qualifies. Its
QualifiedStatus reports
whether the certificate is qualified, the
QcCertType (an e-signature,
e-seal or website-authentication certificate), and the private-key management
type (e.g. whether the key resides in a qualified signature creation device).
async def report_qualification():
with open('document.pdf', 'rb') as doc:
sig = PdfFileReader(doc).embedded_signatures[0]
result = await ades_basic_validation(
sig.signed_data,
validation_spec=validation_spec,
raw_digest=sig.compute_digest(),
)
qualification = result.api_status.qualification_result
status = qualification.status
print("Qualified:", status.qualified)
print("Type:", status.qc_type.name)
Qualified: True
Type: QC_ESEAL
Imposing qualification requirements
Knowing the qualification status is one thing; requiring a particular status
is another. Attach a QualificationRequirements to the
SignatureValidationSpec to make the engine reject signatures that do not meet
your eIDAS policy. A signature that validates cryptographically but fails the
qualification requirements is reported as
SIG_CONSTRAINTS_FAILURE.
from pyhanko.sign.validation.policy_decl import QualificationRequirements
from pyhanko.sign.validation.qualified.tsp import QcCertType
eseal_spec = SignatureValidationSpec(
cert_validation_policy=CertValidationPolicySpec(
trust_manager=TSPTrustManager(registry),
revinfo_policy=CertRevTrustPolicy(REQUIRE_REVINFO),
),
qualification_requirements=QualificationRequirements(
permit_cert_types=frozenset([QcCertType.QC_ESEAL]),
),
)
async def validate_eseal_only():
with open('document.pdf', 'rb') as doc:
sig = PdfFileReader(doc).embedded_signatures[0]
result = await ades_basic_validation(
sig.signed_data,
validation_spec=eseal_spec,
raw_digest=sig.compute_digest(),
)
print(result.ades_subindic)
AdESPassed.OK
By default, QualificationRequirements permits e-signature and e-seal
certificates. Its other knobs let you tighten the policy further:
permit_key_mgmt_typesrestricts whichQcPrivateKeyManagementTypevalues are acceptable—e.g. require a QSCD-resident key;require_service_typedemands that the end-entity certificate be listed directly as a service of a given type. This is the natural way to insist that a timestamp come from a qualified timestamping authority (QTST), by passing the corresponding requirement asts_qualification_requirementson the spec.
Note
Qualification requirements only make sense against a TSPTrustManager;
a plain root-certificate trust manager carries no qualification metadata.
Bootstrapping from the live LOTL
The example above parses a trusted list already on disk. In production you will
instead fetch the whole hierarchy starting from the EU list-of-the-lists, which
pyHanko handles with
lotl_to_registry(). The
default bootstrap keys for the LOTL signature are bundled with the library, and
results are cached on disk so you do not refetch dozens of national lists on
every run.
import aiohttp
from datetime import timedelta
from pyhanko.sign.validation.qualified.eutl_fetch import (
FileSystemTLCache,
lotl_to_registry,
)
async def prepare_registry(cache_dir):
# In production, point the cache at a persistent location such as
# '/var/cache/trust-lists' so national lists are not refetched unnecessarily.
cache = FileSystemTLCache(cache_dir, expire_after=timedelta(days=14))
async with aiohttp.ClientSession() as client:
registry, errors = await lotl_to_registry(
# 'None' bootstraps from the official list-of-the-lists.
lotl_xml=None,
client=client,
cache=cache,
# Limit the member states taken into account; a cold full
# fetch covers the whole EU and can take a while.
only_territories={'be'},
)
return registry
The TSPRegistry it returns is interchangeable with the one built offline
above, so everything else on this page applies verbatim.