Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4eb3768
feat(seal): read EU Trusted Lists, unverified
LKSNDRTMLKV Sep 11, 2026
c468681
docs(seal): name the XMLDSig candidates rather than imply none
LKSNDRTMLKV Sep 11, 2026
512c205
docs(seal): pin the xml-sec fork for whoever implements verification
LKSNDRTMLKV Sep 11, 2026
e2e50f6
feat(seal): pin the EU trusted list trust anchor
LKSNDRTMLKV Sep 11, 2026
f46b2fd
feat(seal): verify the list of trusted lists against the anchor
LKSNDRTMLKV Sep 11, 2026
41b236d
feat(seal): verify national trusted lists against the LOTL
LKSNDRTMLKV Sep 11, 2026
ad247db
chore(deps): allow the pinned xml-sec fork as a source
LKSNDRTMLKV Sep 15, 2026
9092dda
feat(seal): report a seal's issuer and device leg
LKSNDRTMLKV Sep 15, 2026
3a6bb4b
feat(seal): surface a seal's origin and the sealing tier
LKSNDRTMLKV Sep 15, 2026
8e2ad6e
feat(seal): verify a seal's certificate path
LKSNDRTMLKV Sep 15, 2026
f8bcceb
chore(seal): gate the rsa advisory suppression claim
LKSNDRTMLKV Sep 15, 2026
6f19e4a
fix(seal): derive the QTSP profile from the requested level
LKSNDRTMLKV Sep 15, 2026
483907e
feat(seal): emit a full LTA seal from the local backend
LKSNDRTMLKV Sep 15, 2026
66e6949
feat(seal): prove a seal covers the passport it sits on
LKSNDRTMLKV Sep 15, 2026
11d72e6
feat(vault): state seal coverage in the evidence dossier
LKSNDRTMLKV Sep 15, 2026
908331b
feat(cli): render seal origin, binding and sealing tier
LKSNDRTMLKV Sep 15, 2026
16d287d
feat(seal): serve the requested and evidenced seal levels
LKSNDRTMLKV Sep 15, 2026
868f29f
fix(seal): verify real provider algorithms and refuse misbound seals
LKSNDRTMLKV Sep 15, 2026
5b6d917
feat(vault): check the dossier's seal when verifying it
LKSNDRTMLKV Sep 15, 2026
926060b
feat(vault): name the seal's issuer and level in the dossier
LKSNDRTMLKV Sep 15, 2026
f5f78f7
feat(seal): read the attested sealing time from the token
LKSNDRTMLKV Sep 15, 2026
1981486
fix(cli): sanitise every node-supplied string it renders
LKSNDRTMLKV Sep 15, 2026
9fc673e
feat(seal): report whether archival protection is still live
LKSNDRTMLKV Sep 15, 2026
06f1c40
feat(seal): audit stored seals that no count can see
LKSNDRTMLKV Sep 15, 2026
0b88d10
chore(deps): record the seal-audit test dependencies
LKSNDRTMLKV Sep 15, 2026
c2f8514
fix(seal): gauge broken seals per walk, not per batch
LKSNDRTMLKV Sep 15, 2026
e6eca22
feat(seal): report and name the seals the audit found broken
LKSNDRTMLKV Sep 15, 2026
d780189
fix(seal): name the audit gauges as gauges
LKSNDRTMLKV Sep 15, 2026
df2a601
feat(seal): add an operator-initiated seal repair route
LKSNDRTMLKV Sep 15, 2026
07fdeee
chore(deps): record the repair test's sqlx dependency
LKSNDRTMLKV Sep 15, 2026
fee7503
fix(vault): refuse a seal repair that covers nothing
LKSNDRTMLKV Sep 15, 2026
a287b67
test(vault): pin the seal repair route's refusals
LKSNDRTMLKV Sep 15, 2026
5b13743
test(node): pin the reason on a re-armed seal row
LKSNDRTMLKV Sep 15, 2026
490fd88
feat(seal): let the seal audit survive a restart
LKSNDRTMLKV Sep 15, 2026
6696c41
feat(cli): add odal seal repair
LKSNDRTMLKV Sep 15, 2026
a9de4f5
feat(seal): report a seal in EN 319 102-1 terms
LKSNDRTMLKV Sep 15, 2026
948d418
feat(seal): check the certificate window and revocation
LKSNDRTMLKV Sep 15, 2026
c3d1245
feat(seal): tell a truncated chain from an unlisted issuer
LKSNDRTMLKV Sep 15, 2026
0c018a4
test(seal): pin that both readers use the attested moment
LKSNDRTMLKV Sep 15, 2026
2afbc50
feat(seal): act on the certificate, and check the TSA window
LKSNDRTMLKV Sep 15, 2026
52a1c73
fix(node): satisfy clippy under the integration feature
LKSNDRTMLKV Sep 15, 2026
b995905
refactor(node): read the audit cadence without touching the env
LKSNDRTMLKV Sep 15, 2026
301c5f8
fix(seal): three defects found reviewing the new checks
LKSNDRTMLKV Sep 15, 2026
7863da4
docs(seal): say what the node checks, and what it does not
LKSNDRTMLKV Sep 15, 2026
02b0780
docs(seal): state the real reason for the audit's daily target
LKSNDRTMLKV Sep 15, 2026
1ab7ac5
docs(seal): point the audit's finding at the repair route
LKSNDRTMLKV Sep 15, 2026
87f7289
test(vault): pin the refusal for a seal with no signature
LKSNDRTMLKV Sep 15, 2026
6eda897
Merge remote-tracking branch 'origin/main' into feat/seal-qualification
LKSNDRTMLKV Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,38 @@
# contract.

[advisories]
# Empty by design. RUSTSEC-2023-0071 (`rsa`, reached only through the
# never-enabled `sqlx-mysql`) was suppressed here until the sqlx 0.8 -> 0.9
# bump pruned both crates from Cargo.lock — exactly the `release-on` condition
# that entry recorded. The advisory no longer fires, so the entry is deleted
# rather than left suppressed.
ignore = []
# --- RUSTSEC-2023-0071 --------------------------------------------------
# crate: rsa 0.9.10
# path: dpp-seal -> x509-verify -> rsa
# class: reachable-but-mitigated
# rationale: The Marvin Attack is a timing sidechannel in RSA **private
# key** operations — it recovers a key by timing an attacker's
# decryption or signing queries against it. This workspace
# performs no RSA private-key operation anywhere: it holds no
# RSA key at all. Its own keys are Ed25519 (JWS) and P-256 (the
# local seal backend), and `rsa` is reached solely to verify a
# certificate authority's signature over a seal certificate,
# using a public key read out of a published EU Trusted List.
# Verification touches no secret, so there is no key to recover
# and no oracle to time. `x509-verify` is pinned to the
# algorithms measured in the published lists — RSA is 96.8% of
# 373 qualified-CA certificates, so dropping RSA is not an
# option; see crates/dpp-seal/tests/ca_key_survey.rs.
# THIS ENTRY IS VOID THE MOMENT ANY RSA PRIVATE-KEY OPERATION
# APPEARS — signing, decrypting, or key generation. The
# mitigation is the absence of a private key, not a workaround,
# and `scripts/no-rsa-private-key.sh` (in `just check`) holds it:
# it fails if any workspace crate takes `rsa` as a direct
# dependency, or if RSA private-key material appears in our own
# source. The claim is therefore checked rather than asserted.
# anchor: scripts/no-rsa-private-key.sh::RsaPrivateKey
# owner: LKSNDRTMLKV
# recorded: 2026-09-15
# expires: 2027-03-15
# release-on: `rsa` ships a constant-time fix, or `x509-verify` is replaced
# by a verifier whose RSA implementation carries no advisory
# (ring / aws-lc-rs), or this workspace stops verifying RSA
# signatures.
ignore = [
"RUSTSEC-2023-0071",
]
90 changes: 68 additions & 22 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,31 @@ RESOLVER_BASE_URL=https://id.odal-node.io
#
# SEAL_EIDEASY_HMAC_KEY is shown once, when generated in eID Easy's Eseal
# Settings. Treat it like MTLS_PROXY_SHARED_SECRET: this file is mode 600.
# SEAL_CONFORMANCE_LEVEL is the baseline level every seal request asks for --
# B, T, LT or LTA -- and it defaults to LT. LT is the first level that stays
# verifiable after the signing certificate expires, which is what a passport
# needs, since its retention lock is permanent. An unrecognised value fails the
# boot rather than falling back to the default.
# SEAL_CONFORMANCE_LEVEL is the baseline level every seal request asks for —
# B, T, LT or LTA. It defaults to LTA, and the default belongs to the BACKEND
# rather than to the node: every backend wired today reaches LTA, and one that
# does not would name its own instead of forcing the node down to meet it.
#
# THE TWO MUST AGREE, AND THE NODE NOW REFUSES TO BOOT IF THEY DO NOT. A request
# the backend does not advertise is refused before the (billable) call, so a node
# left on the default LT while its eID Easy client is enabled only for
# CAdES_BASELINE_T would seal nothing. That used to be silent — rows retried,
# exhausted hours later, and the passports stayed published-but-unsealed while
# GET /vault/api/v1/seal reported sealingConfigured: true throughout. The
# mismatch is knowable at boot, so it is now checked there and the error names
# the axis that failed. Either have eID Easy enable CAdES_BASELINE_LT for the
# client and leave the default alone, or set both to the same level deliberately.
# LTA is the level that stays verifiable after the signing certificate expires
# and carries an archival timestamp besides, which is what a passport needs
# since its retention lock is permanent. An unrecognised value fails the boot
# rather than falling back, so a misspelled LTA cannot quietly seal lower than
# you think it does.
#
# YOU NO LONGER NEED TO SET THE PROFILE TO MATCH. With
# SEAL_EIDEASY_SIGNATURE_PROFILE unset it is derived from the level above, so
# the two cannot contradict. They used to: the level defaulted to LT while the
# profile defaulted to CAdES_BASELINE_T, so a node configured for the provider
# and nothing else refused to boot — correctly, since every published passport
# would have enqueued a row that could never drain, but the error pointed at
# LOWERING the level to meet a default nobody had chosen.
#
# A profile you DO pin is left alone, because naming one says something
# specific. The node still refuses to boot when it contradicts the level, and
# names the axis that failed. That refusal is why this is not silent any more:
# it used to mean rows retried, exhausted hours later, and passports left
# published-but-unsealed while GET /vault/api/v1/seal reported
# sealingConfigured: true throughout.
#
# There is deliberately NO variable for the seal *mode*. It states whose
# attestation the seal is — a provider holding the key on your behalf, or your
Expand All @@ -198,8 +208,9 @@ RESOLVER_BASE_URL=https://id.odal-node.io
# SEAL_EIDEASY_BASE_URL=https://test.eideasy.com # sandbox; prod = https://id.eideasy.com
# SEAL_EIDEASY_CLIENT_ID=
# SEAL_EIDEASY_HMAC_KEY=
# SEAL_EIDEASY_SIGNATURE_PROFILE=CAdES_BASELINE_T # optional; must be enabled for the client
# SEAL_CONFORMANCE_LEVEL=LT # optional; must match the profile above
# SEAL_EIDEASY_SIGNATURE_PROFILE=CAdES_BASELINE_LTA # optional; derived from the level when
# # unset; must be enabled for the client
# SEAL_CONFORMANCE_LEVEL=LTA # optional; this is already the default
#
# --- Local development sealer (no QTSP, no credentials, no legal weight) -----
# A real detached CAdES signature over the passport's JWS digest, under a
Expand All @@ -212,10 +223,19 @@ RESOLVER_BASE_URL=https://id.odal-node.io
# enrolment is outstanding. It is not a substitute for a qualified seal and
# produces nothing fit for compliance use.
#
# BOTH LINES ARE REQUIRED. The local sealer advertises BaselineB only — it is
# self-signed, with no timestamp, no revocation data and no archival timestamp —
# so it cannot honour the LT default. Setting SEAL_PROVIDER=local without
# SEAL_CONFORMANCE_LEVEL=B now fails the boot naming exactly that.
# IT NOW EMITS THE FULL LTA STRUCTURE, so it needs no lower level set beside
# it. The node generates a local timestamping authority next to its sealing key,
# and a seal carries what ETSI EN 319 122-1 Table 1 requires at each level: a
# signature timestamp from T, revocation material in SignedData.crls from LT,
# and an archive timestamp at LTA.
#
# That is the SHAPE of a long-term seal, not the substance of one. The
# signature, the timestamps and the revocation list are all made by keys this
# node generated for itself. The TSA certificate says so — its organisation
# field is "NOT A QUALIFIED TIMESTAMP" and its policy identifier is deliberately
# unregistered — and the seal port stays on the Ghost trust tier regardless.
# What it buys is that every path which reads a seal can be exercised against a
# realistic envelope rather than a stripped-down one.
#
# SEAL_LOCAL_KEY_PATH is where the generated key and certificate persist between
# runs. Persisted rather than regenerated per boot, so a seal produced yesterday
Expand All @@ -229,8 +249,34 @@ RESOLVER_BASE_URL=https://id.odal-node.io
# absolute path there, or a `just up` and a `cargo run` will disagree about which
# certificate signed what.
# SEAL_PROVIDER=local
# SEAL_CONFORMANCE_LEVEL=B
# SEAL_LOCAL_KEY_PATH=/data/seal-local # default: ./.seal-local
# SEAL_LOCAL_KEY_PATH=/data/seal-local # default: ./.seal-local; holds the sealing
# # key and certificate, and the local TSA's
#
# ── How often this node re-checks the seals it already holds ─────────────────
#
# A background pass opens every stored seal and reports the ones that no longer
# verify — the failure no count can find, because a corrupt seal is still a seal
# and every "is it sealed" query says yes. It walks the estate in batches and
# publishes only when it wraps, so these two numbers decide how fresh the answer
# on `GET /api/v1/seal` can be: a walk takes (stored seals / batch) * interval.
#
# Defaults suit thousands of seals. A node holding hundreds of thousands should
# raise the batch, and the boot log says so if a full walk would take more than
# 24 hours.
#
# A day is the target because a seal that has stopped verifying is invisible to
# every other number the node reports — the walk is the only thing that will ever
# say so. (The same 24h appears in the EU validation policy as the maximum age of
# revocation information for a signing certificate. It does not bind this walk:
# the revocation material read here is the CRL inside the seal, fixed at sealing
# time. It would bind a node that fetched fresh revocation data, which this one
# does not.)
#
# An unparseable value fails the boot rather than falling back to the default:
# believing your seals are checked hourly while they are checked daily is the
# failure this surface exists to prevent.
# SEAL_AUDIT_BATCH=200 # seals opened per pass; 1..=10000
# SEAL_AUDIT_INTERVAL_SECS=60 # seconds between passes; 1..=3600

# =============================================================================
# EU digital product passport registry (ESPR Art. 13).
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,8 @@ api/openapi.html
# them and what they prove. The guard that must not depend on them, the check
# that the fork is the `xml-sec` which actually resolved, reads Cargo.lock and
# runs everywhere.
#
# The rule has to be on whatever branch the files are sitting on, not only on
# the one that introduced it: without it a plain `git add crates/dpp-seal/`
# recommits 60k lines of XML, which is how it came to be written twice.
crates/dpp-seal/tests/fixtures/local/*.xml
Loading