Skip to content

feat(seal): read and verify EU Trusted Lists - #316

Merged
LKSNDRTMLKV merged 10 commits into
mainfrom
feat/trusted-list-revived
Sep 16, 2026
Merged

LKSNDRTMLKV merged 10 commits into
mainfrom
feat/trusted-list-revived

Conversation

@LKSNDRTMLKV

@LKSNDRTMLKV LKSNDRTMLKV commented Sep 15, 2026

Copy link
Copy Markdown
Member

Revives #294, which GitHub auto-closed when its base branch feat/seal-evidenced-level merged as #292. The work was finished and pushed; nothing was wrong with it except that its PR closed underneath it.

just check is green (1138/1138). #312 has merged, so this targets main directly.

Why this is stacked rather than independent

It needs dpp_domain::trusted_list and SealedEnvelope::conformance_level, both new in dpp-core 0.20.0, so it does not build against the pin on main. That is the dependency the original note recorded as "blocked on the core repin", and #312 clears it.

What it adds

dpp_seal::trustlistverify_lotl for the List of Trusted Lists, verify_trusted_list for a Member State's, plus parsers, fetcher and typed rejections. Nothing calls it yet. This is the reader, not a policy.

The trust anchor is an Official Journal notice, not a certificate authority. The LOTL's signing certificates chain to no commercial root — they are published in the OJ C series, which the LOTL names through its own SchemeInformationURI. There is nothing to walk up to, so the anchor is six SHA-256 digests taken from notice 52026XC01944, compiled in with the location, CELEX and pin date beside them. Compiled in and never configuration: an operator who can repoint the anchor can make any list verify.

Those six digests are the entire pinned surface. No Member State's certificates live in the repository — they arrive inside a document that has already been verified, so a country joining or rotating needs no code change and no release.

authorises is a precondition, not a verdict: anyone can copy the genuine certificate into a forgery and pass it, and only the signature separates them. The converse is the sharper half — Finland's list is genuine and correctly signed by Finland, which does not sign the LOTL, and it verifies cleanly with the anchor check disabled. That pair is why both halves exist, and there is a test for each.

What reviewers should look at hardest

The recurring operational obligation. The pin must be refreshed when the Commission republishes the notice, and the current signer expires 2027-11-17 — a calendar date, not a discovery. A stale pin fails closed and reads as an outage. The reader carries the early signal (the LOTL's first SchemeInformationURI entry is the current notice), but nothing acts on it yet.

The xml-sec fork. Italy (2.86 MB) and France (2.55 MB) exceed a compile-time node-set ceiling in the published crate and cannot be verified at any configuration — the constant is pub(crate) and the policy knob that looks like it raises the limit is validated against the same number. They miss by four and five nodes, and trusted lists only grow. The fork changes that one constant and nothing else, pinned by rev rather than vendored because the crate is ~66k LOC.

Changes made during the revival

A deny.toml source allowance. The sources policy denies git dependencies deliberately — "a git dependency is how unreviewed code enters a build" — so the fork needs an explicit entry. Added with the justification and the exit condition: when structured-world/xml-sec#158 resolves, the [patch.crates-io] stanza and the deny.toml entry go together. The rule still stands for everything else; this one is allowed because the review is a single constant, not because forks are fine.

A CHANGELOG entry. The work had none.

One conflict that was a real disagreement, not a textual one. This branch recorded a seal's conformance_level as what the bytes carry; #312 records what was requested. Core settles it — the field's doc says it is "a record of what was asked for, not proof of what arrived", and the conformance kit's seal.misrecorded_level rule fails an envelope whose stored level disagrees with the request. I kept the request.

The concern behind the other version was legitimate and is preserved in the comment: echoing a request could launder a broken can_produce guard. What actually catches that is cades::evidenced_level, read independently by the drain, which alarms on the disagreement rather than letting either side vouch for itself.

Folded in from #317

The TS 119 612 signature profile (#297) is asserted here. 3e03920 was cherry-picked onto this branch as 504b33f, and #317 is closed.

It was folded rather than merged behind this one because splitting them would put a verifier on main that accepts an arbitrary transform chain, for as long as it took to merge the second PR. Transforms decide what a signature covers, and xml-sec's TransformPolicy::allowed_algorithms defaults to None — every implemented algorithm, XPath and XPath Filter 2.0 included. A gap that narrow is not worth the seam.

The anchor check is now bound to the key that verifies

chain_tests::each_signature_names_exactly_one_certificate already recorded this hazard: this module reads a certificate out of the document and checks it against the anchor, while xml-sec verifies the signature with a certificate it selects — select_x509_signing_certificate resolves the leaf of the embedded chain and only falls back to document order when that finds nothing. VerifyResult exposes no certificate, so a disagreement cannot be caught afterwards.

That test guards two committed fixtures. The documents that matter arrive at runtime from 43 URLs, so it could detect the problem and never prevent it.

The sharp part, which the test showed rather than confirmed: ds:KeyInfo sits inside ds:Signature, which the mandated enveloped-signature transform removes from the digest input. A certificate can therefore be added to a genuine, correctly signed trusted list without disturbing its signature — injecting one into the published LOTL comes back AmbiguousSigningCertificate, not SignatureInvalid. Reading "the first certificate" would have taken the real anchored one, passed the anchor, and gone on to verify. What actually binds the certificate is XAdES SigningCertificate in the signed properties, which this module does not read.

Two constraints make the divergence impossible instead of merely visible:

  • the read is scoped to ds:KeyInfo, the only place xml-sec resolves keys from — a ds:Object carrying XAdES CertificateValues holds certificates too;
  • ds:KeyInfo must carry exactly one certificate, so every selection rule resolves to the same bytes.

New AmbiguousSigningCertificate on both enums. Refusing is free today — every published list checked carries exactly one — and when that stops being true the fix is to resolve the leaf the way xml-sec does, not to relax the check.

Two defects fixed from #317's branch

  • TrustedListRejected::NonConformantProfile rendered a literal \n plus seventeen spaces where its LotlRejected twin used a line continuation. Neither rustfmt nor clippy reads inside a string literal, and no test rendered that variant. no_rejection_message_carries_a_stray_line_break now covers all fifteen variants across both enums.
  • the two transform constants were inserted between signing_certificate's doc comment and the function, silently reattaching that doc to ENVELOPED_SIGNATURE.

just check green (1138/1138), and just lint-integration — the tier check omits, per #332 — is clean.

@LKSNDRTMLKV

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Head commit changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@LKSNDRTMLKV
LKSNDRTMLKV changed the base branch from chore/repin-core-0-20-0 to main September 15, 2026 05:05
@LKSNDRTMLKV
LKSNDRTMLKV force-pushed the feat/trusted-list-revived branch from 79d0082 to 7503b7e Compare September 15, 2026 05:06
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/trusted-list-revived

Comment @coderabbitai help to get the list of available commands.

@LKSNDRTMLKV
LKSNDRTMLKV merged commit 9efa7a4 into main Sep 16, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-ready Opt this PR into a CodeRabbit review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant