Skip to content

feat(seal): report the level a seal actually came back at - #292

Merged
LKSNDRTMLKV merged 2 commits into
mainfrom
feat/seal-evidenced-level
Sep 13, 2026
Merged

LKSNDRTMLKV merged 2 commits into
mainfrom
feat/seal-evidenced-level

Conversation

@LKSNDRTMLKV

Copy link
Copy Markdown
Member

Closes #290. Closes #291.

The seal request names a conformance level. The boot gate checks that level against the backend's advertised capabilities. Then the returned bytes were taken on trust, and nothing ever looked at them again.

A provider answering CAdES_BASELINE_T to a request for LT — a client enabled for the wrong profile, a plan change, a provider-side default — produced a seal that was correct in every record this node kept. It stops verifying when the signing certificate expires, years after the passport was retention-locked and long after anything could be done about it. That is precisely the failure SealConformanceLevel describes as uncorrectable, and nothing was watching for it.

What this adds

dpp_seal::cades::evidenced_level reads the returned CMS and reports the highest baseline level whose distinguishing material is present: a signature-time-stamp for T, long-term revocation material for LT, an archival timestamp for LTA.

The drain calls it on every seal, logging at error when something bought as long-term carries no long-term material, and counting it in a new seal_downgraded_total.

Two properties chosen deliberately, both tested:

  • It warns; it never fails the row. The seal exists and has been billed. Backing off would buy the same weaker seal again next pass — paying twice to record the problem twice. Same reasoning as the produced-but-unrecorded path above it, arrived at from the other side.
  • It is a floor, not a verdict. A level is reported only when the material for it and every level below it is present, so it can under-report and cannot over-report. Under-reporting prompts a look at a seal that may be fine; over-reporting would hide the downgrade the check exists to catch.

Nothing here is validated, in keeping with the rest of that module: a timestamp token is counted because it is in the bytes, not because anyone confirmed it timestamps this signature or that its TSA is trusted. Establishing that remains an AdES validator's job.

seal_downgraded_total is its own counter rather than another seal_total{outcome=…}. That label is a partition — a row is sealed or retried or exhausted — and a downgraded row is a sealed row too, so putting it there would make sum(seal_total) exceed the rows drained and quietly corrupt every ratio built on it.

The part that is easy to get wrong

The two standards in play disagree about where an LT seal's revocation material lives.

  • ETSI EN 319 122-1 puts it in SignedData.crls, and marks the revocation-values attribute shall not be present at that level.
  • ETSI TS 103 173 — the older profile, and the one Commission Implementing Decision (EU) 2015/1506 actually names for cross-border recognition — carries it in that very attribute.

So a seal built to the profile the law cites carries exactly what the modern standard forbids at the same level. Both homes are accepted.

This is not theoretical: with only the modern home checked, the legacy fixture reports BaselineT and the drain raises a downgrade alarm against a provider that did nothing wrong. That failure was reproduced before the test was trusted — either_home_of_the_revocation_material_evidences_baseline_lt pins it.

Related trap, in the code comments so it survives: the legacy certificate-values / revocation-values attributes are forbidden at B-LT under EN 319 122-1, so looking for them as evidence of LT finds the opposite of what it means under that standard. And SignedData.certificates is shall be present at every level including B-B, so it does not discriminate at all.

Also fixed (#291)

  • sealedAt read as evidence and was not. Documented as "When the QTSP produced it"; it is this node's clock when the backend answered, and for the local backend there is no QTSP at all. A seal carries an attested signing time only from T upward. Handler doc and published schema; no behaviour change.
  • LocalIdentity::generated_at returned Utc::now() under a doc comment claiming it returned the certificate's generation time. No callers, so nothing was wrong today — anything wiring it in would have got a plausible-looking wrong answer. Removed.

Tests

dpp-seal 55 → 61, dpp-node's drain suite gains one. New: unreadable bytes evidence no level; the local backend's own seal evidences BaselineB (which also pins its advertisement against its bytes); a timestamp alone evidences T; both homes evidence LT; both archival timestamp generations evidence LTA; material for a high level without the levels beneath it reports the floor; and a seal below the requested level is still recorded rather than retried.

The fixtures decorate genuine bytes from the local backend rather than assembling CMS from nothing — a hand-rolled structure would only prove the reader agrees with the fixture.

just check green.

Not in this PR

Persisting the level that was requested, so the record and the bytes can be compared, needs a dpp-core field that is merged but unreleased. Tracked in #289, with the release-and-repin sequence and the [patch] trap that sits in the middle of it.

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.

sealedAt reads as evidence and is not; generated_at() is dead and wrong Nothing checks the conformance level a seal actually came back at

1 participant