Skip to content

Migrate XML and signature processing to pyuppsala and pybergshamra - #337

Open
kushaldas wants to merge 17 commits into
IdentityPython:masterfrom
SUNET:feat/new_libraries
Open

kushaldas wants to merge 17 commits into
IdentityPython:masterfrom
SUNET:feat/new_libraries

Conversation

@kushaldas

Copy link
Copy Markdown

Summary

This replaces the lxml and pyxmlsecurity/xmlsec backends with the
Rust-backed pyuppsala and pybergshamra libraries.

The migration also addresses several performance, memory, signing, and
PKCS11 issues discovered while testing production-sized aggregates.

Main changes

  • Replace lxml with pyuppsala for parsing, serialization, XPath, XSD,
    and XSLT.
  • Replace pyxmlsecurity/xmlsec signing with pybergshamra.
  • Require pyuppsala 0.11.0 and pybergshamra 0.9.0.
  • Raise the minimum supported Python version to 3.10.
  • Replace document-wide per-entity XPath operations with native subtree
    traversal and bulk operations.
  • Use native document signing and verification for document roots.
  • Keep subtree signing on the serialized XML path so single-entity MDQ
    responses sign the selected entity rather than its owning aggregate.
  • Store detached entity copies and release source documents after
    loading to avoid retaining complete source aggregates.
  • Add an optional native batch fetcher controlled by
    PYFF_USE_NATIVE_FETCHER.

PKCS11 changes

  • Support token label, token serial number, and slot ID selection.
  • Correctly parse absolute PKCS11 module paths.
  • Use the owned XML signing path for PKCS11 operations instead of
    mutating a live pyuppsala document.
  • Cache PKCS11 providers for the lifetime of each worker while creating
    a new session for each signing operation.
  • Serialize provider initialization to prevent concurrent loading of
    vendor modules.

These changes prevent the Luna PKCS11 library from being unloaded while
its background threads are still active and avoid the resulting worker
crashes.

Namespace handling

pyuppsala does not provide lxml's optional cleanup_namespaces API, so
that operation is now capability-guarded.

The QName namespace regression coverage from #333 and #335 has been
extended through finalize, sign, and publish, including the owned
XML signing path used for PKCS11.

Compatibility cleanup

The required pyuppsala and pybergshamra versions provide the native
counting, attribute collection, signing, and verification APIs used by
pyFF. Compatibility fallbacks for older library versions have therefore
been removed.

Verification

PYFF_SKIP_SLOW_TESTS=1 uv run pytest -q

kushaldas and others added 12 commits September 15, 2026 09:01
Replace pyFF's lxml + pyxmlsecurity/xmlsec backend with the pure-Rust
pyuppsala (XML parse/serialize/XPath/XSD/XSLT) and pybergshamra (XML
signing) libraries, and rewrite the entity-processing hot paths that
were quadratic under the old per-entity whole-document XPath model.

Backend swap
- Dependencies: drop lxml and pyxmlsecurity; add pyuppsala>=0.8.0,
  pybergshamra>=0.6.4, and cryptography>=42. Python floor raised to
  3.10 (drops the eval-type-backport 3.9 shim).
- utils.py: import etree/Element/ElementTree from pyuppsala.etree.
  pyuppsala is safe-by-default (entities are never resolved/expanded),
  so the old set_default_parser(resolve_entities=False) guard is gone.
- Schema/validation: pyuppsala's XMLSchema resolves xsd:import /
  xsd:include relative to the schema file's own directory, so the
  lxml ResourceResolver + custom parser plumbing is removed; the
  composite schema is built with XMLSchema(file=..., lenient=True),
  lenient matching libxml2/lxml built-in datatype validation (real
  metadata carries anyURI values with spaces). Validation-error
  filtering is adapted to pyuppsala's ValidationError shape (message
  with line/column, no embedded source document), fixing a filter
  that previously dropped every error.
- Signing: enveloped signing goes through pybergshamra.sign_enveloped
  (with PKCS#11/HSM support via Pkcs11Signer), replacing xmlsec.sign.
  Certificate inspection (CertInfo / cert_info / cert_dict) is
  reimplemented on the cryptography x509 API, replacing xmlsec.crypto.

Performance: quadratic hot paths rewritten to single native passes
- Entity-attribute indexing (_eattribute) and certreport walked the
  whole document with .xpath() once per entity; each XPath evaluation
  rebuilt the document-wide attribute/index tables, so the
  mutate->xpath->mutate loop over N entities was O(N x document) --
  catastrophic at eduGAIN (~10k entities) scale. Both now iterate
  entities directly and walk each (small) entity with native .iter(),
  and certreport dedupes so each distinct certificate URI is
  validated exactly once.

Optional native GIL-free batch fetcher (off by default)
- New config flag use_native_fetcher (PYFF_USE_NATIVE_FETCHER). When
  on, Resource.reload fetches each frontier round's http(s) URLs in a
  single pyuppsala.fetch_many call (Rust, GIL released), served to the
  parser through a requests-compatible response shim (_NativeResponse
  / case-insensitive headers); failures fall back to the existing
  url_get path, preserving retry/local-backup semantics. requests_cache
  is bypassed when on. reload also gains a ThreadPoolExecutor frontier
  so downloads overlap; max_workers defaults to config.worker_pool_size.
- pyuppsala may be built without the net feature (fetch_many absent);
  the import is optional and the flag no-ops in that case.

Tests adjusted for the new backend's error/validation surface
(test_pipeline, test_utils).
The XML/crypto backend migration left several pyFF-side hot paths on the
generic (serialize / iterate-in-Python) routes even though the released
pyuppsala 0.10.0 and pybergshamra 0.8.0 wheels expose native
equivalents. This ports them, each guarded by hasattr() with the
previous behaviour kept as the fallback for older library versions.

- sign: sign the working tree in place via
  pybergshamra.sign_enveloped_document() on the shared native document
  handle instead of serializing the whole aggregate, signing the string
  and re-parsing the result (which doubled peak memory on large
  aggregates).
- check_signature: verify via pybergshamra.verify_document() on the
  native document instead of dumping the tree to a string first.
- stats: use Element.fast_count() for the entity / IdP / SP counts
  instead of materializing a Python proxy per entity.
- has_tag: use fast_has() / fast_count() instead of a Python iter().
- with_entity_attributes: collect (Name, [AttributeValue...]) groups in
  one native pass with fast_collect_grouped_text().
- store.update: store a detached deepcopy of each entity. pyuppsala
  element proxies keep their owning document alive, so storing entities
  straight out of a source feed retained every parsed aggregate and its
  input buffer. The previously stored copy is unindexed before the new
  one is indexed.
- load: release resource.t after reload; the store owns its own copies
  and the resource graph only needs the metadata.
- Raise etree.MAX_XPATH_NODE_VISITS to 50M. The 100k default is an
  anti-DoS cap for untrusted input; pyFF runs XPath over its own
  aggregates, which exceed a million nodes at eduGAIN scale.
The document-native sign and verify shortcuts introduced with the
pyuppsala/pybergshamra fastpaths operated on etree.native_document(elt),
which is the document that OWNS elt. After `first` (an MDQ single-entity
request) the working document is an EntityDescriptor still attached to
the selected aggregate, so the signature was inserted under the
discarded EntitiesDescriptor root and the emitted entity went out
unsigned, without any error. Aggregates, whose root is the document
root, were unaffected.

Gate both shortcuts on the new pyff.utils.is_document_root(), which
checks elt.getroottree().getroot() is elt (a deepcopy'd element has no
parent but is not a document root either). Subtrees fall back to the
serialize/sign/ reparse path, which is cheap for a single entity.

Adds SigningTest::test_signing_single_entity (select one entity -> first
-> finalize -> sign -> publish) asserting the signature is the entity's
first child in both the working document and the published file, and
verifies. Aggregate signing performance is unchanged.
- require pybergshamra 0.8.1 and pyuppsala 0.10.0
- support token label, token serial, and slot ID selectors
- fix absolute PKCS11 module path parsing
- document Luna HA and Chrystoki configuration
- add PKCS11 signing configuration tests
- use the owned XML signing path for PKCS11 keys
- preserve entities in signed and published aggregates
- retain native document signing for software keys
- add regression coverage for HSM signing and aggregate contents
Keep PKCS#11 providers alive for the lifetime of each worker process.
This prevents the Luna vendor library from being unloaded while its
native background threads are still running.

Serialize provider initialization and cache providers by module path and
token or slot selector. Continue creating a new session for each signing
operation.

Add tests for provider reuse, selector isolation, and concurrent
initialization.
Guard cleanup_namespaces calls because pyuppsala does not provide this
optional lxml API. Skip the pruning-specific test when unsupported.

This prevents metadata parsing and watcher callbacks from failing.
Remove compatibility fallbacks for APIs guaranteed by pyuppsala 0.11.0
and pybergshamra 0.9.0.

Extend the QName namespace regression test through finalize, sign, and
publish using the owned-string signing path used by PKCS11.
Error: src/pyff/builtins.py:5:1: I001 Import block is un-sorted or un-formatted
@vladimir-mencl-eresearch

Copy link
Copy Markdown
Contributor

Hi,
@kushaldas

Thanks for this work.

I've now managed to switch my (DEV) metadata pipeline to this version. I have some interesting performance observation: on a simple aggregate-publishing batch run ( load local federation + eduGAIN, publish aggregates for local federation, eduGAIN, plus filtred by IdP/SP), I saw a performance drop that more than doubled the run time ( from 1m11s to 2m50s ).

However, the performance improvements in per-entity publishing (MDQ) far outweight that and I saw the runtime for the whole pipeline drop to less then half (from about 13 minutes to 5m50s).

Thanks a lot for the improvements on the MDQ side - and wondering if there's anything worth addressing on the aggregate side.

Cheers,
Vlad

PS: I'll keep the DEV pipeline running on this and monitor for stability - to confirm this also resolves #315.

@vladimir-mencl-eresearch

Copy link
Copy Markdown
Contributor

Hi @kushaldas ,

I'm running this branch in my DEV environment, so far without a glitches (except for when the incoming eduGAIN feed was invalid in the recent episode, but that tripped up the PROD pipeline on 2.1.7 as well).

And this branch is also running the MDQ publishing in multithreaded mode and so far avoided the race-condition that was happening with lxml (#315 ).

One extra thing I tried is EC certificates - which are just plain unsupported in current pyFF code.

I got Got exception when loading/executing pipe: key error: key type does not match algorithm RsaPkcs1v15(Sha256) when I tried - but not sure if that was intended to support here. Can definitely wait for later.

Cheers,
Vlad

- pybergshamra dependency to 0.9.1
- Require the released pybergshamra 0.9.1 package.
- Refresh the uv lockfile with the new wheel and source distribution.
@vladimir-mencl-eresearch

Copy link
Copy Markdown
Contributor

Thanks @kushaldas - this was definitely a performance improvement, my workflow now runs in 2m19s - over 30s improvement!

@kushaldas

Copy link
Copy Markdown
Author

I got Got exception when loading/executing pipe: key error: key type does not match algorithm RsaPkcs1v15(Sha256) when I tried - but not sure if that was intended to support here. Can definitely wait for later.

I will create a separate PR after this one gets merged. Right now software signing path does not specify signature_method, so pybergshamra defaults to RSA-SHA256. So the output is :

  KeyLoadError: key type does not match algorithm RsaPkcs1v15(Sha256)

- Require pybergshamra 0.9.2 and refresh the lockfile.
- Remove the temporary sibling checkout override.
- Serialize PKCS#11 signing per token and release sessions on errors.
- Add regression tests for session cleanup and concurrent signing.
- Update dependency documentation and record server benchmarks.
@majagw

majagw commented Oct 2, 2026 •

Copy link
Copy Markdown

I have set up the environment with the new pyff 2.1.8.dev0 and HSM as it is used for eduGAIN feed aggregation. It is a simple aggregate/sign/publish batch.
I also see performance issue (as reported by Vlad for such a test).

A small batch (only one small federation)

total size: 28
selected: 28
idps: 12
sps: 16

takes
real 0m20.717s
user 0m1.430s
sys 0m0.374s

Full eduGAIN:

total size: 10737
selected: 10737
idps: 6648
sps: 4109

real 1m0.352s
user 0m37.888s
sys 0m4.730s

what is a bit alarming is the difference between real and user time.

When I'm using key/certificate from local files, processing speed seems normal, as with pyff 2.1.7, it is for small batch
real 0m0.840s
user 0m0.625s
sys 0m0.129s

and for full eduGAIN
real 0m40.869s
user 0m36.738s
sys 0m3.928s

so it seems like a HSM issue? My HSM environment is installed in the same way as inside our docker-luna-client used in eduGAIN production.

With pyff 2.1.7 and HSM via docker-luna-client I'm getting:
for small batch:
real 0m1.978s
user 0m0.636s
sys 0m0.081s

and for full:
real 0m32.286s
user 0m29.418s
sys 0m1.476s

and very similar results when local files are used in place of HSM.

Another cumbersome problem for now is that publisher XSL cannot be used because pyuppsala does not handle it.

Require pyuppsala 0.13.0 and pybergshamra 0.10.0 and re-resolve both in
uv.lock. These releases carry the native DOM validation, the
inherited-namespace import, the fragment-splice enveloped signing, and
the W3C ML-DSA and SLH-DSA identifier support this branch relies on.

filter_invalids_from_document validates the whole document once natively
and only falls back to the per-entity pass, which pinpoints and removes
offenders, when the document as a whole fails. On the eduGAIN aggregate
this replaced about 10,700 serialize-and-validate calls with one
in-place validation.

entitiesdescriptor appends the collected entities with a single extend.
pyuppsala's cross-document append deep-copies each subtree natively,
including inherited namespace declarations, and never mutates the
source, so the former per-entity loop with an explicit deepcopy was a
redundant second clone per entity. Output is byte-identical.

Add utils.collect_attr and utils.collect_text, which use pyuppsala's
fast_collect_attr and fast_collect_text single native passes when
present and fall back to iteration otherwise. guess_entity_software
gathers SingleSignOnService and AssertionConsumerService Location
attributes through collect_attr instead of two ElementPath scans that
materialized every endpoint, and the DomainHint collection uses
collect_text.

Measured on the benchmark host against the previous candidate: the
aggregate job 69.8 s to 46.0 s wall with peak RSS 3.7 GB to 2.7 GB,
load720 7.0 s to 5.9 s, sign720 8.0 s to 6.7 s; all signed outputs
verify with identical entity counts and the unsigned output is
byte-identical apart from the pipeline path.
@vladimir-mencl-eresearch

Copy link
Copy Markdown
Contributor

Thanks for the latest performance improvements @kushaldas !

Just testing that in my (DEV) pipeline.

The aggregate performance has improved to 1m40s. Still somewhat above I get with pyFF 2.1.7 release (still lxml), 1m12s, but significant improvement relative to this branch before the latest commit (my report above from 3 weeks ago says 2m19s).

And my whole pipeline (aggregate + MDQ) runs in 3m26s - that's significant improvement over the lxml implementation (order of magnitude on MDQ) and I'm happy with the performance here.

@kushaldas

Copy link
Copy Markdown
Author

@majagw Thank you for the numbers. The shape of them (about 19 s of extra wall time with
almost no CPU, the same on 28 entities and on all of eduGAIN) points at a
one-off wait when the Luna library initialises, not at signing. I could
reproduce your run exactly on a test host with a Luna 10.9.4 client: a
ServerNameNN entry in Chrystoki.conf that accepts the TCP connection but never
answers NTLS makes C_Initialize wait out ReceiveTimeout = 20000, and the small
batch goes from 1.3 s to 21.5 s wall with 1.1 s user. A server whose packets
are dropped costs about 10 s per entry instead.

With 2.1.7 you did not see this because signing went through pyeleven in the
luna-client container, which initialises the library once at container start.
This branch initialises it inside each pyff run, so the client configuration on
the host that runs pyff now matters directly.

Could you check and post:

  1. The LunaSA Client section of the /etc/Chrystoki.conf that your pyff process
    uses, with every ServerNameNN and ServerPortNN entry, and any VirtualToken or
    HAConfiguration sections. Hostnames are enough. 2. For each listed server, from
    the same host, the output of nc -4 -w 5 -z -v <server> 1792 and the wall time
    of vtl verify (or vtl listSlots). If one entry times out, or vtl itself
    takes around 20 s, that is the whole difference.

  2. Whether that host's cert/server directory still holds a certificate for an
    appliance that is retired or not reachable from this network. The
    docker-luna-client entrypoint registers every *Cert.pem it finds there as a
    server.

  3. The exact key: value you use in the sign pipe (with the label, not the PIN),
    so I know whether it selects a slot or a token label.

If vtl is slow too, removing the unreachable entry should bring the small batch
back to about a second. If vtl is fast and pyff still loses 20 s, please say so
and I will dig further, because then it is something on our side.

@theseal

theseal commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Since Kushal found the performance cause outside of this project I will probably merge this branch in the beginning of next week and start to work on a 3.0.0 release.

Thanks for all your feedback!

@majagw

majagw commented Oct 16, 2026

Copy link
Copy Markdown

First of all - I'm running tests from one server, where I have docker-luna-client running v.10.9.0-65 and new docker image with HSM settings the same as on the docker-luna-client - Chrystoki.conf is the same
I have:
ServerName00 = dcoa-hsm2.sunet.se;
ServerName01 = lla-hsm2.sunet.se;
ServerName03 = tug-hsm2.sunet.se;
ServerName04 = dcob-hsm2.sunet.se;

and in /usr/safenet/lunaclient/cert/server
dcoa-hsm2.sunet.seCert.pem dcob-hsm2.sunet.seCert.pem lla-hsm2.sunet.seCert.pem sthb-hsm2.sunet.seCert.pem tug-hsm2.sunet.seCert.pem
(so sthb... is redundant)

vlt verify takes ~16-17s on both.

nc times also look very similar on both docker instances, the same when nc is run from main host e.g.
nc -4 -w 5 -z -v dcoa-hsm2.sunet.se 1792
Ncat: Version 7.95 ( https://nmap.org/ncat )
Ncat: Connected to 130.242.205.238:1792.
Ncat: 0 bytes sent, 0 bytes received in 0.11 seconds.

I've just checked that aggregation/signing/publishing of the whole eduGAIN takes < 1min
real 0m53.189s
user 0m34.657s
sys 0m4.123s

@kushaldas

kushaldas commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

@majagw Could you time the servers one at a time, without editing the live config:

I found the numbers I needed to debug.

for h in dcoa-hsm2 lla-hsm2 tug-hsm2 dcob-hsm2; do
  d=/tmp/luna-$h; mkdir -p $d
  sed -e '/ServerName0[0-9]/d' -e '/ServerPort0[0-9]/d' -e '/ServerHtl0[0-9]/d' \
      -e "/NetClient = 1;/a\   ServerName00 = $h.sunet.se;\n   ServerPort00 = 1792;\n   ServerHtl00 = 0;" \
      /etc/Chrystoki.conf > $d/Chrystoki.conf
  echo "== $h"; ( time ChrystokiConfigurationPath=$d vtl verify ) 2>&1 | grep -e Serial -e Label -e real -e '^ *[0-9]'
done

@majagw

majagw commented Oct 16, 2026

Copy link
Copy Markdown

My results:
== dcoa-hsm2
Slot Serial # Label
0 1621911929455 edugain
real 0m4.159s
== lla-hsm2
Slot Serial # Label
0 1429933786538 edugain
real 0m3.934s
== tug-hsm2
Slot Serial # Label
0 1429929129937 edugain
real 0m4.942s
== dcob-hsm2
Slot Serial # Label
0 1620838580409 edugain
real 0m4.032s

entitiesdescriptor appended the store's shared entity elements straight
into each request's new EntitiesDescriptor, trusting a comment that
pyuppsala's cross-document append never mutates the source. It does:
like lxml it has move semantics for Python objects. The subtree is
cloned into the target document, but the source's live element proxies
are re-pointed onto the clone and the source node is detached from its
previous parent.

With pyffd serving requests from several threads, request 2 therefore
pulled the entities out of the aggregate request 1 was still finalizing
and signing. The loud symptom in production (md-signer-2, serving
swamid-2.0) was a pyuppsala panic surfacing as "Already borrowed:
PyBorrowMutError" from repoint_subtree. The quiet one was worse: with
the response cache disabled, 36 concurrent full-aggregate requests on a
test host returned 8 empty aggregates and 28 missing entities, all with
HTTP 200.

Restore the deepcopy of every entity before the append when copy=True,
as pyFF did before the backend migration, so each request works on
private elements and the store is never re-pointed or detached. Keep
copy=False as the explicit in-document move for the caller that wants
it. After the change the same 36 concurrent requests all return the
complete aggregate, byte-identical apart from the signature.

Add TestEntitiesDescriptor: building two aggregates from the same entity
list must leave the first intact and the entities unparented (fails on
the old code), and copy=False must still move.
pybergshamra 0.11.0 ships kryptering 0.7.0, which keeps the PKCS#11
module loaded for the life of the process and never unloads it, not even
when C_Initialize fails. That removes the Thales Luna segmentation fault
when a worker dropped its last provider and created another, so the
provider cache in builtins is now a lookup optimisation rather than a
safety requirement.

Refresh uv.lock and state the new minimum in README.rst and docs.

Verified against a Luna network HSM: batch signing with direct-slot and
HA client configs, provider drop-and-recreate in one process (crashed
with 0.10.0), and pyffd serving 36 concurrent uncached aggregate
requests plus 36 single entities with every response complete.
@vladimir-mencl-eresearch

Copy link
Copy Markdown
Contributor

Just confirming the latest update works for me all fine - with an improvement of about 1-2s on the aggregate signing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants