diff --git a/.cargo/audit.toml b/.cargo/audit.toml index afa1e161..70e6926d 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -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", +] diff --git a/.env.example b/.env.example index 6feb4d8e..b3eb1e37 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 @@ -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 @@ -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). diff --git a/.gitignore b/.gitignore index 82d2246e..a24fd477 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9da30f..8a8cf7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,540 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): rather than trusted, so if it ever changes the result is a red test rather than a comparison against a pivot URL. +- **Three defects found in review, in the checks added above.** Each is the same + class the subsystem exists to prevent, which is why they are listed rather than + quietly fixed: + + - **A failed database read published a clean bill of health.** `audit_seals_once` + reported an unreadable batch as an empty one, and an empty batch is how a walk + says it reached the end — so a blip during the first batch of a walk stored a + completed report saying nothing was checked and nothing was broken. It now has + three outcomes, not two, and the caller keeps its cursor and publishes nothing. + - **A stale CRL could outrank a later revocation.** The revocation reader + answered from the first list that verified; `SignedData.crls` is a SET, so + the order deciding it was an encoding accident. Every applicable list is now + read, a revocation anywhere ends the search, and the newest clean answer wins. + - **A missing CRL masked a proven expiry.** `TRY_LATER` was returned before the + validity window was considered, so a certificate proven expired at an attested + time reported as an unanswered question rather than as a failure. Everything + provable is now decided first, in order of severity. + +- **The seal audit, the dossier verifier and the repair route now act on the + certificate's standing** (#329). The check existed and was served; nothing + decided anything with it, so a seal made under a certificate that had been + revoked — or had expired, with an attested time to prove it — was counted + `sound`, passed the dossier's `qualifiedSeal` check, and was refused by the + repair route with "this seal verifies". + + `certificateFailed` is its own count on the audit report, its own gauge + (`seal_certificate_failed`) and its own line in `odal seal status`, **apart + from `broken`**, because the two need opposite responses: a broken seal is + worth replacing, and a seal made under a revoked certificate would only be + replaced by another from the same certificate. The repair route says exactly + that instead of sending the operator away satisfied. + + Where nothing attests *when* the seal was made, the same observation stays + `indeterminate` and the seal stays in `sound` — a certificate that has expired + since is the ordinary state of an old seal, not a defect in it. + +- **A timestamp is refused when its authority's certificate could not have made + it** (#331). The token's signature verifies for ever and `genTime` is whatever + the signer wrote, so the authority's validity window is the only thing inside a + token that limits when it could have been produced — and nothing read it. + + That mattered more after #322 than before: `judgedAt.attested` is what decides + whether a certificate finding is a failure or an open question, so a token + minted under any key at all could move an expired certificate back inside its + window. The imprint check binds a token to this signature; it does not stop + someone who can edit a stored seal from minting their own. + + Both timestamp readers now apply it — the signature timestamp and the archival + one. **The other half is still open**: whether the authority is one anybody + trusts is a Trusted List question about the `TSA/QTST` service type (Art. 42), + which needs #324. + +- **A seal whose certificate chain runs out is reported as such, not as an + unlisted provider** (#323). The third gap that issue named. + + Matching already considered every issuer name the seal's embedded certificates + refer to, so a seal carrying its intermediates reaches a listed root several + links up. A seal that does *not* carry them used to report `notListed` — a + statement about the Union's Trusted Lists — when the supportable statement was + about the seal: the link that would have led to a listed CA was never shipped. + + `IssuerStanding::ChainIncomplete` now says which, and names the certificate + that was missing so it can be acted on. The remedy is the provider's generator + setting, not their qualification: ETSI EN 319 122-1 clause 5.2.1 asks for those + intermediates where a signature is to be validated through a Trusted List. + + The strong finding is kept where it is earned — a chain that ends at a + self-issued root no list names is still `notListed`, and + `a_complete_chain_to_an_unlisted_root_is_still_unlisted` pins that the two did + not collapse into one. + +- **The seal certificate's validity window and revocation are now checked** + (#323) — `certificate` on `GET /api/v1/dpp/{dppId}/seal` and in the evidence + dossier's `qualifiedSeal`. + + The second limb of Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals by + Art. 40: a qualified certificate must have been **valid at the time of + signing**. Nothing read `notBefore`/`notAfter`, and no revocation list was ever + consulted, so a seal made with a long-expired or revoked certificate reached + the same verdict as one made yesterday. + + **The verdict turns on which moment is used.** The moment is the *attested* + sealing time — the timestamp token inside the seal, whose signature and imprint + are both checked — and this node's clock only as a marked fallback. An + out-of-window certificate is `totalFailed` / `expired` against a proven moment + and `indeterminate` / `outOfBoundsNoPoe` against an unproven one, because + certificates expire, sealed passports outlive them by years, and an expired + certificate today says nothing about a seal made while it was good. The same + asymmetry applies to revocation: a certificate revoked *after* a seal was made + does not unmake the seal. + + **Revocation is read from the seal, never fetched.** A CRL distribution point + is a URL inside a certificate an operator was handed, and following one would + have a background task issue requests to an address the input chooses. ETSI + EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward + precisely so a long-term seal can be checked offline — so a `B-LT` or `B-LTA` + seal is answerable and a `B-B` one honestly reports `indeterminate` / + `tryLater`. + + **A CRL is checked before it is believed, in both directions.** It must come + from the certificate's own issuer *and* verify under a certificate the seal + carries. The obvious attack is forging a revocation; the less obvious one is + the reverse — an unsigned empty list would otherwise *clear* a revoked + certificate — and + `a_crl_naming_the_issuer_but_signed_by_another_key_is_unusable` pins it. That + is the same asymmetry #323 was opened about, one level down. + + Still not `SealChecks::QualifiedValidation`: Art. 32(1)(f) is a declaration + this node cannot confirm, and (c), (d) and (h) are unasked. What changed is + that two of the gaps named in that issue are now closed rather than listed. + +- **A seal's reading is now also reported in ETSI EN 319 102-1's vocabulary** — + `validation` on `GET /api/v1/dpp/{dppId}/seal` and in the evidence dossier's + `qualifiedSeal`, with the audit's counts documented in the same terms. + + That standard is what CIR (EU) 2025/1945 points at for validating a qualified + seal, so it is the vocabulary an auditor's own tooling reports in. The field is + derived from `binding` and checks nothing extra. + + **It never says `totalPassed`, and the type cannot express it.** Clause 5.1.3 + puts "the constraints applicable to the signer's certificate have been + positively validated" among that indication's conditions — a certificate path + built and validated to a trust anchor, under a policy. This node builds none. + (The certificate's *validity window* and its revocation material are checked; + that arrived with #323, later in this entry.) So a seal that demonstrably + covers this passport's signature reports `indeterminate`: nothing has failed, + and not everything has been checked. Reading `coversThisSignature` as a + validation pass is exactly the misreading this makes impossible. + + The rest of the mapping: a broken seal is `totalFailed` / `sigCryptoFailure`, + and the standard makes that verdict **stable** — additional validation data + cannot lift it — which is the principled reason a replacement seal is worth + buying for those and for nothing else. A seal over a superseded signature is + `totalFailed` / `hashFailure` *against this passport's current signature*, + while remaining a sound attestation of the signature it does cover. An + unreadable seal is `indeterminate` with no sub-indication: none of table 6 + fits, which is the standard's own custom-diagnostic case, and `binding` is the + diagnostic. + + `QualifiedSealMember` in the dossier schema was five fields behind what the + dossier actually carries; it now documents all of them. + +- **The seal audit now survives a restart, bounds what a pass is about, and has + a configurable cadence** (#328). + + Three changes to the same walk, each fixing something the first version of it + could not. + + **Its position and its last result are stored** (`ops/pg/0038`). Keeping only + the result would not have been enough: the failure worth fixing is a node whose + estate takes longer to walk than the node goes between restarts, and such a + node never produces a result to keep — it would start from the beginning every + time, for ever, while doing every bit of the work. With the cursor stored, the + walk accumulates instead. `audit: null` now means *no pass has ever completed + against this database*, rather than none since this process started. + + **A pass is a statement about the seals that existed when it began.** Seals are + written while a walk runs, and which of them a pass happened to see depended on + where its cursor had reached — so "checked 1,204" described a population nobody + could name. Skipping the newer ones costs no coverage: the drain checks a + seal's binding before accepting it, so one written mid-walk was verified as it + landed. A seal that cannot be dated is kept, because not knowing when something + was sealed is not a reason to stop looking at it. + + **`SEAL_AUDIT_BATCH` and `SEAL_AUDIT_INTERVAL_SECS`** set the rate, and the + boot log now says what the configured rate buys for this node's estate — + warning when a full walk would take longer than 24 hours. That ceiling is not + ours: CIR (EU) 2025/1945, which pins how a qualified seal is validated under + Art. 32(3) and Art. 40 of Reg. (EU) No 910/2014, allows revocation information + for a signing certificate to be at most 24 hours old, so a slower walk could + not support that check once this node performs it (it does not yet — #323). An + unparseable value fails the boot rather than falling back to the default: + believing seals are checked hourly while they are checked daily is the failure + this whole surface exists to prevent. + +- **`odal seal repair `** — the CLI half of the repair route below. Reports + whether the broken seal's row was re-armed or the signature was simply queued + for the first time, and prints the node's own note about what it costs. A + refusal is an error rather than a printed verdict: the operator asked for an + action and the node declining to spend is not a quieter kind of success. The + audit block in `odal seal status` now points at it beside each named passport. + +- **`POST /api/v1/dpp/{dppId}/seal/repair`** (admin) — re-seal a passport whose + stored seal does not verify (#328). + + **This buys a second seal for a digest already paid for**, which is why it is a + route and not a sweep. The repair sweep cannot do it and says so in its own + code: it only queues passports carrying *no seal at all*, which is what lets it + run unattended without spending money twice. A broken seal is the case where + spending it again is right — the row was paid for and carries nothing, so the + passport is published and, in substance, unsealed. That decision belongs to + whoever pays, taken per passport. + + **It refuses unless the seal is demonstrably broken**, and the seal is opened + and checked *at the moment of the request* rather than read from the audit's + list, which could be hours old. A sound seal, a superseded one, one this node + cannot read, a passport with no seal, and a node with no sealing backend + configured are each refused with a `422` saying which — that last one because + queueing where nothing drains would answer "repaired" to an operator for whom + nothing will happen. + + The replacement covers the passport's **current** signature. Where the passport + was re-published since, that signature has never been sealed, so nothing is + re-bought and the action is `queued` rather than `rearmed`. + + Not idempotency-keyed and does not need to be: a seal row is keyed by + `(passport_id, payload_hash)`, so a retried request re-arms a row that is + already pending, which is a no-op — and a second repair after the replacement + lands is refused because the new seal verifies. + + `SealOutbox::rearm_sealed` is the only path that moves a `sealed` row, and + `rearm_sealed_leaves_rows_the_drain_owns_alone` pins that it moves nothing + else: a `pending` row re-armed underneath the drain would have its backoff + reset on every call. + +- **`GET /api/v1/seal` now reports what the seal audit found, and names the + passports** (#328). The counts beside it describe outbox rows and passports + carrying *no* seal; `audit` describes seals that exist and do not stand up — a + condition neither can see, since both ask whether the seal member is absent and + a worthless seal is present. + + **`null` means no pass has completed, not that nothing is wrong.** The pass + walks the estate in bounded batches and starts over, so the report is empty + until the first one finishes and absent entirely where no audit runs (it now + survives a restart — see below). Reporting a zero for a check that has not run + would be the one answer worse than reporting nothing. `completedAt` travels + with it, because on a large deployment these numbers are hours old by + construction. + + The list of broken passports is capped and says so through `truncated`: a node + with thousands of broken seals has one problem, not thousands, and the count + states its size. The audit also got its own cadence — a minute rather than the + sweep's hour — because it is a scan that wants to finish rather than a backstop + for a rare divergence, and nothing it reports is usable until the walk has been + all the way round. + +- **A stored seal that is broken is now found, rather than looking healthy** + (part of #328). The repair sweep and the operator rollup both ask the database + whether a passport's `seal` member is **absent** — so a seal that is present + and worthless answers "no" and is invisible: not swept, not counted, healthy in + every number the node reports, while the passport is in substance unsealed. + + Whether a stored seal stands up is cryptographic rather than relational, so no + widening of that query could reach it. A background audit walks sealed + passports in bounded batches from a cursor, opens each CAdES through the same + reader the drain uses to accept one, and logs and counts what does not verify + (`seal_broken`). Restarting at the end rather than stopping, because a + seal sound today can be corrupt tomorrow. + + **It reports and does not repair.** The existing sweep carries a guarantee + worth keeping — it cannot double-bill, because it only queues passports + carrying no seal at all — and re-queueing a broken seal breaks exactly that: + the row was paid for, and buying a second seal is justified only because the + first is worthless. That is a decision to take knowingly, not one for a + background loop on an operator's behalf. Repair is its own change. + + Two things are deliberately not findings. A seal over a **different** digest is + ordinarily a passport re-published after sealing, which the read route already + reports as `superseded`. A seal this node **cannot read** is counted apart: + treating "cannot check" as "broken" would make every seal from a backend + emitting an unparsed format look like corruption. + +- **The seal route now reports both the level asked for and the level the bytes + carry.** `conformanceLevel` is what this node requested, recorded on the + envelope; `evidencedLevel` is what `cades::evidenced_level` finds in the CAdES. + Neither was served at all. + + The pair is the point. A provider enabled for a weaker profile than was paid + for returns a seal that is correct in every record this node keeps and stops + verifying when its signing certificate expires — years later, on a passport + that is retention-locked and cannot be re-sealed. The drain already logs that + mismatch as it happens; serving both makes it answerable afterwards, from the + seal rather than from a log nobody kept. The CLI calls it `DOWNGRADED`. + +- **`odal seal status` and `odal seal status --all` render the new fields.** The + CLI is the surface a sandbox demo is driven from, and it showed none of them: + who issued the seal, whether it binds to the passport, or what tier the + configured backend resolved to. `SELF-SIGNED`, `PROVEN`, `BROKEN` and `ghost` + are called out rather than left to be inferred from a field name. + + The issuer is passed through the existing `plain()` sanitiser, and it is the + sharpest case in that module: a distinguished name read out of a certificate + **inside a seal** is the least node-chosen string the CLI displays, so it is + exactly where an ANSI escape would be put to forge output under the CLI's own + labels. + +- **A seal now reports whether its archival protection is still live** (#325). + An archival timestamp is what keeps a `B-LTA` seal verifiable after its signing + certificate expires — the whole point for a retention-locked passport, which + outlives every certificate involved. **It expires too**: its own timestamping + authority's certificate has a validity period, and ETSI's long-term profiles + expect re-timestamping before that. Nothing here renews, and + `evidencedLevel` reports `baseline-lta` from the *presence* of the material — + correctly, since the material is there — so a seal whose archival protection + lapsed years ago read exactly as it did the day it was bought. + + `archival` on the seal route and in the dossier: `notArchived`, `current` with + the date to renew by, `lapsed` with the date it went, or `unknown`. + **A signal, not a verdict** — a seal nearing its renewal date still verifies, + and that window is the only chance to renew without an outage — and no + threshold is applied, because how much notice is enough is the reader's policy. + `notArchived` is kept distinct from `lapsed`: a `B-LT` seal was never promised + long-term protection, and calling it lapsed would raise an alarm about a + commitment nobody made. + + One thing it does **not** establish: that the archival timestamp covers *this* + seal. The `archive-time-stamp-v3` imprint is over the concatenation clause + 5.5.3 specifies plus an `ats-hash-index-v3`, neither of which this crate builds + or reads — the same departure the local backend documents from the writing + side. + +- **A seal's attested time is now read, having been pointed at and never + reached.** `sealedAt` is the sealing node's own clock — an unattested claim by + the party that bought the seal — and that field's documentation has always said + the only place an attested time can be is the time-stamp token inside + `sealValue`. Nothing read it. `attestedSealedAt` now does, on the seal route + and in the dossier. + + **Checked, not merely read.** The `signature-time-stamp` attribute is + *unsigned*: the seal's own signature does not cover it, so swapping the whole + token costs nothing. So the token's own signature is verified, and its imprint + is matched against this seal's signature per EN 319 122-1 clause 5.3 — without + that second leg a genuine token lifted from another seal, sound in every way + and saying a different time, would be accepted. + `a_timestamp_token_from_another_seal_is_refused` does exactly that swap; + with the imprint check removed it reports the other seal's time. + + **Attested is not trusted.** A qualified time stamp is a QTSP's service + (Art. 42) with the presumption of accuracy attached (Art. 41(2)); establishing + that is a Trusted List question about the `TSA/QTST` service type, which this + node cannot yet ask. A self-signed authority's token verifies perfectly and + means nothing — which is what the local backend produces. + + The `TSTInfo` ASN.1 now has **one** definition, in `cades`, used by the local + authority for writing and the reader for reading; and the signature check that + both the seal and its token need is one function rather than two copies. + +- **The dossier now says who issued its seal, and what level the bytes carry.** + It named *which* certificate — a thumbprint, enough to ask an auditor's + question about and not enough to answer the first one anybody has. A + self-signed development seal and a QTSP's were otherwise the same field, and + telling them apart meant parsing the CAdES by hand. `qualifiedSeal.origin` is + the fact that decides whether anything else in that section carries weight, so + it travels with it. `qualifiedSeal.evidencedLevel` joins it, because a seal + weaker than ordered was visible only in a drain log no dossier reader has. + +- **Verifying a dossier now checks its seal, rather than only hashing it.** The + verifier ran eight checks and none looked at the seal — it was covered by + `content_integrity`, which catches substitution and says nothing about whether + the seal means anything. `qualified_seal` is now a check of its own. + + It needs no database, no node and no network: a dossier carries the CAdES + **and** the compact JWS that seal should cover, which is the point of the + format, since whoever opens the file is usually neither the issuer nor the + node. Nothing stored in the dossier is trusted to answer it — neither its + `payloadHash` nor the `binding` the generator wrote — both are recomputed from + `signedOverJws`. The generator's claim is evidence of what it believed; this + check says whether it was right. + + `Absent` rather than `Fail` wherever the question could not be put: no seal, no + seal reader supplied, or bytes this build cannot parse. A dossier marked failed + because nobody looked would be worse than one marked unchecked. + +- **The evidence dossier now states whether its seal covers the signature it is + served beside.** The dossier carries the seal, the passport's compact JWS and + its digest, so a verifier holding only that file has everything needed. The JWS + is the passport's **current** one — and a passport re-published after sealing + carries a seal over the *previous* signature until the drain catches up, a + window with no end if the drain has exhausted. Pairing the two silently handed + an authority a seal that does not verify against the document beside it, which + reads as tampering rather than as the stale seal it is. `qualifiedSeal.binding` + now says which. Reported rather than blocking generation: a dossier must be + producible in whatever state the passport is actually in. + +- **A seal can now be proven bound to its passport, from the seal's own bytes.** + `GET /api/v1/dpp/{dppId}/seal` gains **`binding`**: whether the stored seal + actually covers this passport's current signature, read out of its + `messageDigest` signed attribute (RFC 5652 §11.2) after checking the signature + over it. + + Until now the only seal-to-passport link was an outbox row recording what this + node *asked* a backend to seal. That record is useful — it survives a seal that + will not parse, and spots a re-published passport with a string comparison — + but it is bookkeeping, not evidence. A seal restored from a backup has no such + row; a seal stored against the wrong passport has a row that agrees with itself + and nothing else. The route's own note said as much: *"only the validator + establishes which digest the CAdES actually covers."* The node can now do it. + + **The signature is checked before the digest is read**, and that ordering is + the point. The attribute is plain DER and trivial to rewrite, but it sits + inside the signature, so rewriting it breaks the seal rather than retargeting + it. `a_seal_cannot_be_retargeted_by_editing_the_digest_it_names` forges exactly + that edit; with the check removed it reports `coversThisSignature`, which is + what makes the test worth keeping. `notIntact` therefore reports **no** digest: + a value inside a failed signature is not evidence of anything. + + `coverage` stays beside it, answering the same question from this node's + records. They are not folded together on purpose — one needs no cryptography + and survives an unreadable seal, the other is evidence — and **where they + disagree, the disagreement is the finding**. + +- **The local development backend now emits the whole `B-LTA` structure, and + the conformance default is per-backend.** It advertised `BaselineB` only, so + every path above that level — the drain's downgrade check, the boot + conformance probe, anything reading a long-term seal — could be exercised only + against a provider nobody can currently buy from. + + The node now generates a local timestamping authority beside its sealing key + and emits what ETSI EN 319 122-1 V1.3.1 Table 1 requires at each level: a + `signature-time-stamp` from `B-T`, revocation material in `SignedData.crls` + from `B-LT` — **not** the `revocation-values` attribute, which that table marks + "shall not be present" at these levels — and an `archive-time-stamp-v3` at + `B-LTA`. The TSA certificate travels with the seal so its tokens can be + checked. + + **The shape of a long-term seal, not the substance of one.** Every signature, + timestamp and revocation list is made by a key this node generated for itself. + The TSA certificate's organisation field reads `NOT A QUALIFIED TIMESTAMP` and + its policy identifier is deliberately unregistered, so a validator that looks + sees at once what it has; the backend still resolves to the `Ghost` trust tier, + and `qualify` still reports `SelfIssued`. Two departures from conformance are + documented rather than glossed: the archive timestamp's imprint is taken over + the signer's encoded form rather than clause 5.5.3's concatenation, and no + `ats-hash-index-v3` is produced. + + **`SEAL_CONFORMANCE_LEVEL` now defaults to the backend's own level rather than + the node's**, `LTA` for all three today. A default that cannot depend on the + backend is one that will eventually contradict it — which is exactly what had + happened — and the remedy then reads as "lower your level to suit the backend" + rather than "name a backend that can do the job". + +- **A seal's certificate path is now verified, so the top verdict means the + issuer is established rather than claimed.** `dpp_seal::qualification` matched + an issuer by *name* and stopped there, so a self-signed certificate relabelled + with a listed CA's name reached the top verdict — and still verified as a seal, + because a CMS signature covers the signed attributes rather than the + certificate travelling beside them. + + `cades::check_path_to` now walks from the seal's signer up through the + certificates the seal carries to a trust anchor the list publishes, verifying + every link. Intermediates come **only from the seal**: a path completed by a + document fetched over the network is not a path. + + Two new findings, kept apart deliberately. `SignatureNotFromListedCa` is the + forgery — a listed CA carries that name and did not sign this. `PathUnverifiable` + is *not an accusation*: the check could not be run at all. Collapsing them + would either brand a lawful seal a forgery or let an unrunnable check read as a + clean miss. The path is also checked **before** the trusted-list status, so a + forgery is reported as a forgery rather than as a date problem. + + Sized by measurement rather than assumption, and the assumption was wrong + twice over. Across the 373 qualified-CA certificates in the lists this + workspace can verify, **96.8% are RSA**, and the elliptic-curve remainder is + P-384 and P-521 with **not one P-256** — so the `p256` already here covered + none of them. `x509-verify` is pinned to exactly those algorithms; `k256`, + Ed25519, DSA and the broken-hash features are off. The measurement is kept as + `crates/dpp-seal/tests/ca_key_survey.rs` so it re-checks itself when a Member + State republishes. + + Two further findings drove the design. Member States do not publish the same + thing — Italy's list is 194 self-signed **roots** out of 203, while Finland's + and France's carry issuing CAs directly — so candidate selection considers + every issuer name the seal's chain refers to, not just the signer's own. + Without that the walk never starts and an Italian provider reports as unlisted. + And `SignedData.certificates` is a SET with no meaningful order, so the signer + is now located by its `SignerInfo` identifier rather than taken from position + zero; a real provider's seal travelling with its chain could previously report + an intermediate as the signing certificate. + + Still not `SealChecks::QualifiedValidation`, and the module says why: the + certificate's own validity window is never read, and revocation is never + consulted. + + **RUSTSEC-2023-0071** (`rsa`, no fix available) is registered in + `.cargo/audit.toml` as `reachable-but-mitigated`. The Marvin Attack recovers a + key by timing private-key operations; this workspace holds no RSA key and + performs no RSA private-key operation, so there is nothing to recover. The + entry voids itself the moment one appears. + +- **The seal routes now say whether a seal is worth anything, and they answer + two different questions.** + + `GET /api/v1/dpp/{dppId}/seal` gains **`origin`** — what *this stored seal's* + certificate says: its subject, its issuer, whether the two are the same name + (`selfIssued`), and the Annex III(j) `creationDevice` indication. Read from + the stored bytes, so it is right about a seal restored from a backup or made + before the backend was changed. `null` means **not read** — a placeholder, an + unparsed format, unreadable bytes — and never "not self-issued", which is a + finding and only comes from a certificate that was examined. + + `GET /api/v1/seal` gains **`trustMode`** — the tier the *currently configured* + backend resolved to (`ghost`, `sandbox`, `live`). The counts beside it say how + much sealing is outstanding; this says whether the sealing that does happen is + worth anything. A node can sit at `unsealedPublished: 0` while every one of + those seals was signed by a key it generated itself, and no count would show + it. `null` means no seal port was resolved at all, which is **not** `ghost`. + + Neither substitutes for the other. A node moved from the local backend to a + QTSP last week reports `live` on the summary and `selfIssued: true` on + everything sealed before the move, and both are correct. + + Reading a certificate is answered through a new `dpp_types::SealInspector` + port rather than by linking the seal adapter into the services that serve + seals — that crate also carries an HTTP client and an XML signature verifier, + which is a disproportionate dependency for reading a distinguished name. The + inspector is wired unconditionally, deliberately not behind the same guard as + the sealing outbox: a node that no longer seals still holds seals whose origin + a reader needs, and those are the least self-explanatory ones. + +- **A seal now says, out of its own bytes, whether a provider issued it.** + `dpp_seal::qualification::qualify` reports the two legs Art. 32(1) needs — + reached for seals through Art. 40 — against verified national trusted lists: + who issued the certificate and whether that issuer was a granted qualified CA + **at the time of sealing**, plus the Annex III(j) indication of where the key + lives, read off the certificate's QCStatements. + + The distinction it exists for is local versus provider. A node running the + local development backend produces seals that verify perfectly and mean + nothing legally, and the only way to tell before was to read the node's + configuration — which records what the operator intended, not what came back. + Self-issuance is a property of the certificate, so it is decided before any + trusted list is consulted and a node with no network still knows. + + **It is not a qualified-seal verdict, and nothing here returns a + `SealChecks`.** The issuer is matched by *name*, so the verdict says which + listed CA a seal claims, not that the CA signed it; no certificate path is + built. `naming_a_listed_ca_reaches_the_top_verdict_without_any_path_check` + pins that by relabelling a self-signed development certificate with a real + Finnish qualified CA's name and watching it reach the top verdict — while the + seal still verifies, because a CMS signature covers the signed attributes and + not the certificate travelling beside them. Closing the gap means verifying + the issuer's signature over the certificate, which needs a verifier for the + algorithms real QTSP certificate authorities use. + - **A life status that contradicts its own lineage is now reported.** The plausibility lint gains `lineage.life_status_unsupported`, from `dpp_rules::lineage::check_life_status_consistency`: a unit claiming @@ -326,6 +860,72 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): hand-edited source line is rewritten back. That is the check being sound rather than a gap — the lock always describes the build that ran. +- **Every node-supplied string the CLI prints is now sanitised** (#327). The + ANSI/newline guard covered one field by design, with the passport-document + strings deliberately left for their own change; this is that change. A string + carrying `ESC[2K +` can erase the line it is printed on and rewrite it, and one + carrying a newline can forge whole additional lines under the CLI's own + labels — and those strings arrive from imports, API callers and supply-chain + peers, not from the node's own choosing. + + The worst case is a table: `odal list` pads its columns, so a forged row is + indistinguishable from a real passport at a glance. Guarding happens at the + shared read and print helpers rather than at each call site, so a field added + later inherits it — `field()` reads and sanitises in one step, which makes the + safe path the short path. + +- **Signature checking covered none of the certificates a real provider uses.** + `verify_against_embedded_certificate` understood P-256 only — what the local + development backend emits, and **not one** of the 373 qualified-CA certificates + measured in the published trusted lists. That was worse than a missing feature: + the digest a seal covers lives in an attribute *inside* its signature, so a + signature this node could not check took the whole seal-to-passport binding + with it, degrading to "unknown" exactly when a real provider seal first + arrived. It now uses the same verifier as the path check, so the accepted + algorithms are one list rather than two that can drift. + + Doing that surfaced a real listed CA this build still could not verify: the + French notaries' delegated authority omits the `NULL` `AlgorithmIdentifier` + parameters that RFC 3279 §2.3.1 requires for `rsaEncryption`, and the strict + SPKI decoder refuses it. An absent NULL is now supplied before decoding, which + **changes no key material** — the modulus and exponent live in the BIT STRING + and are untouched. `every_listed_qualified_ca_yields_a_usable_verifier` holds + the line at every published CA (373/373), so a future non-conformance is caught + rather than quietly joining the set whose seals cannot be checked. + +- **A seal that does not cover the digest it was bought for is no longer + stored.** Nothing compared the returned envelope against the request. A + provider answering with a seal over another document — a mix-up, a crossed + request, a bug — was written onto the passport unexamined, leaving it *looking* + sealed while carrying an attestation about something else. The read route would + then call it `coversAnotherDigest`, which is indistinguishable from a passport + re-published after sealing: a provider error arriving disguised as routine + staleness. + + Refused, unlike a downgrade — a weak seal still covers the right passport and + re-buying gets the same weak thing, whereas this is not a seal for this + passport at all. The row backs off and the passport stays in + `unsealedPublished`, which is true. A seal this node **cannot read** is still + stored: "cannot check" must not become "reject", or a backend emitting a format + this node does not parse could never seal anything. + +- **The QTSP seal profile now follows the level actually requested.** + `SEAL_CONFORMANCE_LEVEL` defaults to `LT` while this provider's + `signature_profile` defaulted to `CAdES_BASELINE_T`, so a node configured for + the provider and nothing else **refused to boot**. That refusal was correct — + every published passport would have enqueued a seal row that could never drain + — but it named `SEAL_CONFORMANCE_LEVEL` as the thing to change, pointing an + operator at *lowering* the level to meet a default they never chose, giving up + long-term validation material to do it. + + The composition root now derives the profile from the requested level unless + the operator pinned one, which is left alone: someone who names a profile has + said something specific, and the capability probe already refuses a boot where + it contradicts the level. `profile_for_level` and `level_for_profile` are + pinned as inverses — drift between them would have the node request `LT`, + receive `T`, and record the request. + - **The two repository backends disagreed about which fields a patch may carry.** *(No change to how a node behaves: the PostgreSQL backend was and remains the one that ships. What changes is that the test double now agrees diff --git a/CLAUDE.md b/CLAUDE.md index 630c28a8..b6764ff6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -430,7 +430,8 @@ unprotected one. Read the table in the code. | GET | `/vault/api/v1/registry` | Bearer | EU-registry sync rollup | | GET | `/vault/api/v1/dpp/{dppId}/history` | Bearer | Audit trail | | GET | `/vault/api/v1/dpp/{dppId}/seal` | Bearer | eIDAS qualified seal + the JWS/digest it covers (`404` when unsealed) | -| GET | `/vault/api/v1/seal` | Bearer | Operator-wide sealing state — published passports carrying no seal, plus outbox totals | +| POST | `/vault/api/v1/dpp/{dppId}/seal/repair` | Bearer **(admin)** | Queue a replacement for a stored seal that does not verify. **Buys a second seal** for a digest already paid for, so it refuses unless the seal is demonstrably broken — checked at request time, never read from the audit's list | +| GET | `/vault/api/v1/seal` | Bearer | Operator-wide sealing state — published passports carrying no seal, outbox totals, the configured backend's trust tier, and what the last **completed** pass over every stored seal found (`audit`, `null` when none has) | | GET | `/vault/api/v1/dpp/{dppId}/stats` | Bearer | Per-passport scan telemetry (aggregate; scans + qrRenders, never summed) | | GET | `/vault/api/v1/stats` | Bearer | Operator-wide scan telemetry rollup | | POST | `/vault/internal/scan-batch` | mTLS (`CN=odal-resolver`) | Resolver scan-telemetry flush sink (off public + `/api/v1`) | diff --git a/Cargo.lock b/Cargo.lock index 0f81b8d5..780e2ac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,9 @@ dependencies = [ "axum", "base64 0.23.1", "chrono", + "cms", "criterion", + "der 0.7.10", "dotenvy", "dpp-common", "dpp-crypto", @@ -2526,6 +2528,7 @@ dependencies = [ "serde_json", "serial_test", "sha2 0.11.0", + "sqlx", "tempfile", "testcontainers", "thiserror 2.0.20", @@ -2537,6 +2540,7 @@ dependencies = [ "url", "uuid", "wat", + "x509-cert 0.2.5", "zeroize", ] @@ -2672,6 +2676,7 @@ dependencies = [ "der 0.7.10", "dpp-common", "dpp-domain", + "dpp-types", "hex", "hmac 0.13.0", "p256 0.13.2", @@ -2683,10 +2688,12 @@ dependencies = [ "sha2 0.11.0", "tempfile", "thiserror 2.0.20", + "time", "tokio", "tracing", "url", "x509-cert 0.2.5", + "x509-verify", "xml-sec", "zeroize", ] @@ -4204,6 +4211,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.9", +] [[package]] name = "leb128" @@ -4585,6 +4595,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-cmp" version = "0.1.0" @@ -4643,6 +4669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4805,6 +4832,18 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p384" version = "0.14.0" @@ -4819,6 +4858,19 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p521" version = "0.14.0" @@ -5010,6 +5062,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + [[package]] name = "pkcs1" version = "0.8.0-rc.4" @@ -5850,6 +5913,26 @@ dependencies = [ "lru", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1 0.7.5", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -6021,7 +6104,7 @@ dependencies = [ "crypto-primes", "digest 0.11.3", "hmac 0.13.0", - "pkcs1", + "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0", "rand_core 0.10.1", "sha1", @@ -8743,6 +8826,18 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "x509-ocsp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e54e695a31f0fecb826cf59ae2093c941d7ef932a1f8508185dd23b29ce2e2e" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "x509-cert 0.2.5", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -8761,6 +8856,26 @@ dependencies = [ "time", ] +[[package]] +name = "x509-verify" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43a49bf845cd2f3aff9603a4276409dbf2b8fa4454d3e9501bf5b0028342964" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "ecdsa 0.16.9", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", + "rsa", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "x509-cert 0.2.5", + "x509-ocsp", +] + [[package]] name = "x520-stringprep" version = "1.0.0" @@ -8799,8 +8914,8 @@ dependencies = [ "hmac 0.13.0", "md-5", "p256 0.14.0", - "p384", - "p521", + "p384 0.14.0", + "p521 0.14.0", "pem 4.0.0", "pkcs8 0.11.0", "quick-xml", diff --git a/api/components/schemas/evidence/QualifiedSealMember.yaml b/api/components/schemas/evidence/QualifiedSealMember.yaml index 37065115..90a759d0 100644 --- a/api/components/schemas/evidence/QualifiedSealMember.yaml +++ b/api/components/schemas/evidence/QualifiedSealMember.yaml @@ -13,10 +13,11 @@ description: | ⚠️ **This shape is described here but not enforced anywhere.** The field is held as untyped JSON on the dossier record, so no Rust type declares these - three members and the OpenAPI contract test has nothing to compare them - against. Treat the property list as documentation of intent, not as a - guarantee. Giving the dossier a real type for this member would close that, - and is the only thing that would. + members and the OpenAPI contract test has nothing to compare them against. + Treat the property list as documentation of intent, not as a guarantee. Giving + the dossier a real type for this member would close that, and is the only thing + that would — and it is worth doing: this list had fallen five fields behind + what the dossier actually carries before anyone read the two side by side. properties: seal: type: object @@ -28,3 +29,38 @@ properties: type: string pattern: ^[0-9a-f]{64}$ description: SHA-256 of the sealed payload, lowercase hex. + binding: + $ref: ../seals/SealBinding.yaml + validation: + $ref: ../seals/SealValidationStatus.yaml + certificate: + oneOf: + - $ref: ../seals/CertificateStanding.yaml + - type: 'null' + origin: + oneOf: + - $ref: ../seals/SealOrigin.yaml + - type: 'null' + description: >- + What the seal's own certificate says about who issued it — the one fact + that decides whether anything else in this member carries weight. + evidencedLevel: + type: + - string + - 'null' + description: >- + The baseline level the seal's **bytes** carry, which is not necessarily the + level this node asked for. A seal weaker than ordered is otherwise visible + only in a drain log no dossier reader has. + attestedSealedAt: + type: + - string + - 'null' + format: date-time + description: >- + A timestamp authority's statement of when the seal was made, read out of + the token inside the CAdES and checked. `seal.sealedAt` beside it is the + sealing node's own clock, which is an unattested claim by the party that + bought the seal. + archival: + $ref: ../seals/ArchivalFreshness.yaml diff --git a/api/components/schemas/seals/ArchivalFreshness.yaml b/api/components/schemas/seals/ArchivalFreshness.yaml new file mode 100644 index 00000000..9527c8c4 --- /dev/null +++ b/api/components/schemas/seals/ArchivalFreshness.yaml @@ -0,0 +1,58 @@ +type: object +description: >- + Whether a seal's archival protection is still live. + + + `evidencedLevel` reports `baseline-lta` from the **presence** of the archival + material, and is right to — the material is there. This reports whether it + still means anything. + + + An archival timestamp is what keeps a `B-LTA` seal verifiable after its + signing certificate expires, which is the whole point for a retention-locked + passport that outlives every certificate involved. **It expires too**: its own + timestamping authority's certificate has a validity period, and ETSI's + long-term profiles expect re-timestamping before that. Without this field a + seal whose archival protection lapsed years ago reads exactly as it did the day + it was bought. + + + **A signal, not a verdict.** A seal nearing its renewal date still verifies, + and that window is the only chance to renew without an outage. No threshold is + applied — how much notice is enough is a policy question for the reader. +required: + - state +properties: + state: + type: string + enum: + - notArchived + - current + - lapsed + - unknown + description: >- + `notArchived`: no archival timestamp — a seal below `B-LTA`. Nothing to + renew, which is a different thing from a renewal that has lapsed: such a + seal was never promised long-term protection. + + + `current`: the archival timestamp's authority certificate is still valid. + + + `lapsed`: it has expired. The seal may still verify today; what is gone is + the thing meant to keep it verifying once its signing certificate goes. + + + `unknown`: an archival timestamp is present and could not be read. **Not + `current`** — a token that cannot be checked is not a fresh one, and + reporting it as current is how a staleness signal goes quiet at the moment + it matters. + expires: + type: string + format: date-time + description: >- + When the archival timestamp's authority certificate expires — the date by + which re-timestamping must have happened, read from before or after it + depending on `state`. One value, so one name: `current` and `lapsed` are + the same date seen from either side. Absent for `notArchived` and + `unknown`. diff --git a/api/components/schemas/seals/CertificateStanding.yaml b/api/components/schemas/seals/CertificateStanding.yaml new file mode 100644 index 00000000..7bfd76e7 --- /dev/null +++ b/api/components/schemas/seals/CertificateStanding.yaml @@ -0,0 +1,26 @@ +type: object +description: >- + **Was the seal's signing certificate valid when the seal was made?** + + + The second limb of Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals + by Art. 40. The first limb — whether a qualified trust service provider issued + it — is a Trusted List question and is not answered here. + + + Two findings and the moment they were judged against, because the moment is + what decides them: a certificate outside its window is a failure when a + timestamp proves the seal was made outside it, and merely unproven otherwise. + Certificates expire, sealed passports outlive them by years, and an expired + certificate today says nothing about a seal made while it was good. +required: + - validity + - judgedAt + - revocation +properties: + validity: + $ref: ./ValidityWindow.yaml + judgedAt: + $ref: ./JudgedTime.yaml + revocation: + $ref: ./RevocationStanding.yaml diff --git a/api/components/schemas/seals/JudgedTime.yaml b/api/components/schemas/seals/JudgedTime.yaml new file mode 100644 index 00000000..7eb141e2 --- /dev/null +++ b/api/components/schemas/seals/JudgedTime.yaml @@ -0,0 +1,30 @@ +type: object +description: >- + The moment a certificate's standing was judged against, and whether anything + proves it. + + + Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks + whether the certificate was valid **at the time of signing**. The whole verdict + therefore turns on which moment is used and what that moment is worth. +required: + - at + - attested +properties: + at: + type: string + format: date-time + description: The moment used. + attested: + type: boolean + description: >- + True when the moment came from a timestamp token inside the seal, whose + signature and imprint were both checked. False means it is the node's own + clock — the validation time, not the signing time. + + + This separates a failure from an open question. ETSI EN 319 102-1 treats a + time nothing proves as no time at all, which is why a verdict resting on an + unattested moment is reported with a `NO_POE` sub-indication rather than as + `totalFailed`. Without it, every seal whose certificate has since expired — + eventually all of them — would read as invalid. diff --git a/api/components/schemas/seals/RevocationStanding.yaml b/api/components/schemas/seals/RevocationStanding.yaml new file mode 100644 index 00000000..29fb11cf --- /dev/null +++ b/api/components/schemas/seals/RevocationStanding.yaml @@ -0,0 +1,58 @@ +type: object +description: >- + What the seal's own revocation material says about its signing certificate. + + + **Read from the seal, never fetched.** A CRL distribution point is a URL inside + a certificate an operator was handed, and following one would have this node + issue requests to an address chosen by whoever produced the seal. ETSI + EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward for + exactly this reason: a seal meant to be checkable years later carries what is + needed to check it, offline. +required: + - status +properties: + status: + type: string + enum: + - notRevoked + - revoked + - notAvailable + - unusable + description: >- + `notRevoked` — a CRL from this certificate's issuer, whose own signature + verifies under a certificate the seal carries, does not list it. + + + `revoked` — that CRL lists it, and `at` says when. Whether this condemns the + seal depends on `judgedAt`: a certificate revoked *after* a seal was made + does not unmake it. + + + `notAvailable` — the seal carries no revocation material for this + certificate. Ordinary below `B-LT` and **not a defect**. + + + `unusable` — material was present and could not be relied on: an + unparseable list, or one whose signature does not verify. Distinct from + `notAvailable` because something claiming to be authoritative failed, which + is worth looking at. A CRL wearing the issuer's name but signed by another + key lands here and never on `notRevoked` — an unsigned list could otherwise + *clear* a revoked certificate. + asOf: + type: string + format: date-time + description: >- + With `notRevoked`, the CRL's `thisUpdate` — the moment its statement is + about. A list issued before the seal was made cannot rule out a later + revocation, and this is here so a reader can see which question was + actually answered. + at: + type: string + format: date-time + description: >- + With `revoked`, when the issuer says it was revoked. + reason: + type: string + description: >- + With `unusable`, what failed. diff --git a/api/components/schemas/seals/SealAuditReport.yaml b/api/components/schemas/seals/SealAuditReport.yaml new file mode 100644 index 00000000..9a505ebc --- /dev/null +++ b/api/components/schemas/seals/SealAuditReport.yaml @@ -0,0 +1,114 @@ +type: object +description: >- + What the last completed pass over **every stored seal** found. + + + The counts beside this describe outbox rows and passports carrying *no* seal. + This describes seals that exist and do not stand up — a condition neither can + see, because both ask the database whether the seal member is absent, and a + worthless seal is present. + + + Whether a stored seal stands up is cryptographic rather than relational: open + the CAdES, verify the signature, read the digest it covers. So it is found by a + background pass that walks the estate in bounded batches and starts over, not + by a query. +required: + - completedAt + - checked + - sound + - superseded + - broken + - certificateFailed + - unreadable + - brokenPassports + - truncated +properties: + completedAt: + type: string + format: date-time + description: >- + When the pass finished. The age of this is the age of every number below + it — a pass takes as long as the estate divided by the audit's throughput, + so on a large deployment these are hours old by construction. Fine for a + condition that does not appear suddenly, and stated rather than implied. + checked: + type: integer + description: Seals opened. + sound: + type: integer + description: >- + Seals covering their passport's current signature. + + + In EN 319 102-1's terms this is `indeterminate`, **not** `totalPassed`: + nothing about these seals has failed, and their certificates have not been + validated by this node. See `validation` on + `GET /api/v1/dpp/{dppId}/seal`. + superseded: + type: integer + description: >- + Seals over a different digest. Ordinarily a passport re-published after + sealing, which is **not** a defect — the seal remains a valid attestation + of the signature it covers, and `GET /api/v1/dpp/{dppId}/seal` reports it + per passport as `coverage`. + + + `totalFailed` / `hashFailure` when the question asked is about the + passport's *current* signature, which is the question this walk asks. + broken: + type: integer + description: >- + Seals whose own signature does not verify. **The finding.** Those passports + are published and, in substance, unsealed — and invisible to + `unsealedPublished`, which asks only whether a seal is present. + + + `totalFailed` / `sigCryptoFailure`, and EN 319 102-1 makes that verdict + stable: no additional validation data can lift it. That is what makes a + replacement seal worth buying for these and for nothing else here — + `POST /api/v1/dpp/{dppId}/seal/repair`. + certificateFailed: + type: integer + description: >- + Seals whose signature is sound and whose **certificate** was not, at the + moment they were made — revoked before sealing, or outside its validity + window, with an attested time to prove the order. `totalFailed` with a + certificate sub-indication. + + + Counted apart from `broken` because the two need opposite responses. A + broken seal is worth replacing; a seal made under a revoked certificate + would only be replaced by another from the same certificate, so + `POST /api/v1/dpp/{dppId}/seal/repair` refuses it and says why. + + + Where nothing attests when the seal was made, the same observation is + *indeterminate* rather than a failure and the seal stays in `sound`: a + certificate that has expired since is the ordinary state of an old seal. + unreadable: + type: integer + description: >- + Seals this node could not read — a placeholder, or a format it does not + parse. **Not a finding.** Treating "cannot check" as "broken" would make + every seal from a backend emitting an unparsed format look like corruption. + + + `indeterminate` with a custom diagnostic: a limit of the reader, not a + defect in the seal. + brokenPassports: + type: array + items: + type: string + format: uuid + description: >- + The passports carrying a broken seal, so an operator can act rather than + search a log. Capped — see `truncated`. A node with thousands of broken + seals has one problem, not thousands; `broken` states its size, and a list + long enough to prove that is a list nobody reads. + truncated: + type: boolean + description: >- + True when `brokenPassports` was cut short. Stated rather than left to be + inferred from the length matching the cap, which is an inference that is + right until the cap changes. diff --git a/api/components/schemas/seals/SealBinding.yaml b/api/components/schemas/seals/SealBinding.yaml new file mode 100644 index 00000000..fcbd2308 --- /dev/null +++ b/api/components/schemas/seals/SealBinding.yaml @@ -0,0 +1,62 @@ +type: object +description: >- + Whether the seal's **own bytes** say it covers this passport's current + signature. + + + A detached CAdES states what it covers in exactly one place — the + `messageDigest` signed attribute, RFC 5652 §11.2 — and that attribute sits + *inside* the signature. This reports what it says, after checking the signature + over it. That ordering is the whole point: the attribute is plain DER and + trivial to rewrite, but rewriting it breaks the signature, so a seal cannot be + retargeted at another passport by editing what it claims to cover. + + + Read it beside `coverage`, which answers the same question from this node's own + outbox records. Those records survive a seal that will not parse and need no + cryptography; this needs both and is evidence. **Where the two disagree, the + disagreement is the finding** — the records and the bytes are describing + different things, which neither source could have revealed alone. +required: + - result +properties: + result: + type: string + enum: + - coversThisSignature + - coversAnotherDigest + - notIntact + - unknown + description: >- + `coversThisSignature`: the seal names this exact signature and its + attributes verify under the certificate it carries. The strongest statement + this node can make without an external validator — whatever is true of the + seal's *trust*, it is demonstrably a seal over this passport and not over + anything else. + + + `coversAnotherDigest`: the seal is intact and names a different digest. + Ordinary after a re-publish, since the passport re-signed. It is also what a + seal stored against the wrong passport looks like, and the two are + indistinguishable from the bytes alone; what separates them is whether an + outbox row says this digest was ever requested for this passport. + + + `notIntact`: the signature over the seal's attributes does not verify, so + nothing the seal says about what it covers can be relied on. + + + `unknown`: the bytes could not be read, or carry no digest at all — a + placeholder, an unparsed format, or a signature with no signed attributes. + **Never** to be read as a mismatch. + covered: + type: string + description: >- + Hex SHA-256 the seal actually covers. Present only with + `coversAnotherDigest`, so a caller holding it can find which version of the + passport it belongs to. + + + Deliberately absent from `notIntact`: the attribute naming it sits inside a + signature that failed, so reporting it would hand the caller a number that + nothing vouches for. diff --git a/api/components/schemas/seals/SealOrigin.yaml b/api/components/schemas/seals/SealOrigin.yaml new file mode 100644 index 00000000..ae98c695 --- /dev/null +++ b/api/components/schemas/seals/SealOrigin.yaml @@ -0,0 +1,59 @@ +type: object +description: >- + What a stored seal's own certificate says about who issued it — read out of + the seal's bytes, never from this node's configuration. The two are different + facts: configuration records what the operator intended, and a seal restored + from a backup or made before a backend change was not produced by whatever is + configured now. + + + **Not a qualification verdict.** Establishing that a seal is qualified needs + the issuer matched against an EU Trusted List *and* the issuer's signature + over this certificate verified; neither is done to produce this. What it does + answer completely, and without a network, is whether anybody issued the + certificate at all. +required: + - subject + - issuer + - selfIssued + - creationDevice +properties: + subject: + type: string + description: The certificate's subject distinguished name, RFC 4514. + issuer: + type: string + description: The certificate's issuer distinguished name, RFC 4514. + selfIssued: + type: boolean + description: >- + Whether issuer and subject are the same name — the structural test for + "nobody issued this to us", and a property of the certificate that no + environment variable can change. True means the seal attests that a key + this node holds signed a digest and **nothing else**: it carries no legal + weight, and no Trusted List would give it any. + creationDevice: + type: string + enum: + - declaresQualifiedDevice + - noQualifiedDevice + - notAQualifiedCertificate + description: >- + What the certificate declares about the device holding its private key — a + declaration, never a verification. + + + `declaresQualifiedDevice`: the certificate carries the Annex III(j) + indication that Regulation (EU) No 910/2014 Art. 32(1)(f), reached for + seals through Art. 40, requires. + + + `noQualifiedDevice`: it carries qualified-certificate statements but not + that one. Lawful, and enough for Art. 40a — validation of an *advanced* + seal based on a qualified certificate, which omits the device leg — but + not for the Art. 32/40 pair. + + + `notAQualifiedCertificate`: it carries no qualified-certificate statements + at all, so it is not presenting itself as a qualified certificate in the + first place. A self-signed development certificate lands here. diff --git a/api/components/schemas/seals/SealRepairResponse.yaml b/api/components/schemas/seals/SealRepairResponse.yaml new file mode 100644 index 00000000..ab8d1808 --- /dev/null +++ b/api/components/schemas/seals/SealRepairResponse.yaml @@ -0,0 +1,32 @@ +type: object +description: >- + The outcome of repairing one passport's seal. +required: + - action + - payloadHash + - note +properties: + action: + type: string + enum: + - rearmed + - queued + description: >- + `rearmed`: a `sealed` row was re-armed — the one path that buys a second + seal for a digest already paid for, justified because the first does not + verify. + + + `queued`: a row was queued the ordinary way. The passport was re-published + since the broken seal was made, so the signature now needing a seal has + never been sealed and nothing is being re-bought. + payloadHash: + type: string + description: >- + Hex SHA-256 the replacement seal will cover — the passport's **current** + signature, not whatever the broken seal covered. + note: + type: string + description: >- + Stated rather than implied, like every other note on this surface: what was + queued, what it will cost, and why spending it is right here. diff --git a/api/components/schemas/seals/SealResponse.yaml b/api/components/schemas/seals/SealResponse.yaml index f5d3991a..b5858885 100644 --- a/api/components/schemas/seals/SealResponse.yaml +++ b/api/components/schemas/seals/SealResponse.yaml @@ -11,6 +11,9 @@ required: - currentJws - currentPayloadHash - coverage + - binding + - validation + - archival - verification properties: declaredBy: @@ -43,6 +46,64 @@ properties: certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed. + attestedSealedAt: + type: + - string + - 'null' + format: date-time + description: >- + **When a timestamp authority attests the seal was made.** + + + `sealedAt` is the sealing node's own clock and an unattested claim by the + party that bought the seal. This is a third party's statement, read out of + the time-stamp token inside `sealValue` — the only place in a seal an + attested time can be. + + + Checked rather than merely read: the token's own signature is verified and + its imprint is matched against this seal's signature. The attribute + carrying the token is *unsigned*, so a genuine token lifted from another + seal would otherwise be accepted and report someone else's time as this + one's. + + + `null` for a `B-B` seal, which carries no token, and for a token that + failed either check. + + + **Attested is not trusted.** A qualified electronic time stamp is a QTSP's + service (Art. 42) and the presumption of accuracy attaches to that + (Art. 41(2)); establishing it is a Trusted List question about the + `TSA/QTST` service type, which this node cannot yet ask. A self-signed + authority's token verifies perfectly and means nothing. + conformanceLevel: + oneOf: + - $ref: ../passport/SealConformanceLevel.yaml + - type: 'null' + description: >- + The baseline level this node **asked** for, recorded on the envelope. A + record of intent; `null` for a seal stored before the field existed. + evidencedLevel: + oneOf: + - $ref: ../passport/SealConformanceLevel.yaml + - type: 'null' + description: >- + The baseline level the seal's **bytes** carry. + + + The pair is the point. A provider enabled for a weaker profile than was + paid for returns a seal that is correct in every record this node keeps and + stops verifying when its signing certificate expires — years later, on a + passport that is retention-locked and cannot be re-sealed. Serving both + makes that answerable from the seal rather than from a log nobody kept. + + + A floor, not a conformance verdict: it reports that the distinguishing + material for a level is present, never that the material was validated. + `null` when the bytes could not be read. + archival: + $ref: ./ArchivalFreshness.yaml placeholder: type: boolean description: True when this is a placeholder with no legal validity. @@ -63,6 +124,31 @@ properties: Null for a seal restored from a backup or produced elsewhere. coverage: $ref: ./SealCoverage.yaml + binding: + $ref: ./SealBinding.yaml + validation: + $ref: ./SealValidationStatus.yaml + certificate: + oneOf: + - $ref: ./CertificateStanding.yaml + - type: 'null' + origin: + oneOf: + - $ref: ./SealOrigin.yaml + - type: 'null' + description: >- + What **this seal's own certificate** says about who issued it: did a + provider issue it, or did the node sign it itself? + + + Null means **not read** — a placeholder seal, a format this node does not + parse, unreadable bytes, or a deployment with no inspector wired. Never + read null as "not self-issued": that is a finding, and it only comes from + a certificate that was actually examined. + + + For what the *currently configured* backend produces, which is a different + question, see `trustMode` on `GET /api/v1/seal`. verification: type: string description: >- diff --git a/api/components/schemas/seals/SealSummaryResponse.yaml b/api/components/schemas/seals/SealSummaryResponse.yaml index 5df8b55d..3ca87338 100644 --- a/api/components/schemas/seals/SealSummaryResponse.yaml +++ b/api/components/schemas/seals/SealSummaryResponse.yaml @@ -22,3 +22,51 @@ properties: description: Rows that gave up after exhausting their retries. sealingConfigured: type: boolean + audit: + oneOf: + - $ref: ./SealAuditReport.yaml + - type: 'null' + description: >- + What the last completed pass over every stored seal found. + + + **`null` means no pass has completed**, not that nothing is wrong. The pass + walks the estate in bounded batches and starts over, so this is empty until + the first one finishes, and absent entirely on a deployment that runs no + audit. Reporting a zero here for a check that has not run would be the one + answer worse than reporting nothing. + + + A node that keeps its audit state restores the last completed report at + boot, so a restart no longer empties this. Where it is still `null` after a + deployment, the honest reading is that no pass has ever finished against + this database — on a large estate, check the audit's cadence before + concluding anything about the seals. + trustMode: + type: + - string + - 'null' + enum: + - ghost + - sandbox + - live + - null + description: >- + The tier the **currently configured** sealing backend resolved to. The + counts say how much sealing is outstanding; this says whether the sealing + that does happen is worth anything — a node can sit at + `unsealedPublished: 0` while every one of those seals was signed by a key + it generated itself, and no count would show it. + + + A different question from the per-passport `origin`, and neither + substitutes for the other: this describes the backend running now, that + describes the certificate inside one stored seal. A node moved from a + local backend to a QTSP last week reports `live` here and + `selfIssued: true` on everything sealed before the move, and both are + correct. + + + Null on a deployment that resolved no seal port at all. That is **not** + `ghost`: a port nobody wired and a port that landed on a placeholder are + different states, and only the second blocks a production boot. diff --git a/api/components/schemas/seals/SealValidationStatus.yaml b/api/components/schemas/seals/SealValidationStatus.yaml new file mode 100644 index 00000000..91c9af86 --- /dev/null +++ b/api/components/schemas/seals/SealValidationStatus.yaml @@ -0,0 +1,76 @@ +type: object +description: >- + `binding` restated in the vocabulary of ETSI EN 319 102-1 clause 5.1.3 — the + procedures standard CIR (EU) 2025/1945 points at for validating a qualified + electronic seal. + + + A translation, not a second opinion: it is derived from `binding` and adds no + checking of its own. It is here because these findings travel to readers whose + validation tooling speaks this vocabulary, and because the translation makes + explicit something our own field names let a reader assume. + + + **`totalPassed` is not among the values, and cannot be.** That indication + requires, among other conditions, that the constraints applicable to the + signer's certificate have been positively validated — a certificate path built + and validated to a trust anchor, under a policy. + + + This node checks the certificate's validity window and whatever revocation + material the seal carries (see `certificate`), and it checks one issuer + signature against a Trusted List entry. It builds no path and applies no policy + constraints. So a seal that demonstrably covers this signature reports + `indeterminate`: nothing has failed, and not everything has been checked. + Reading `coversThisSignature` as a validation pass is the misreading this + object exists to prevent. +required: + - indication +properties: + indication: + type: string + enum: + - totalFailed + - indeterminate + description: >- + `totalFailed`: the signature is demonstrably not valid. The standard makes + this **stable** — the same inputs always yield the same answer, and + additional validation data cannot lift it to a pass; only additional proofs + of existence can change a result at all. That stability is what makes a + broken seal safe to spend a replacement on. + + + `indeterminate`: the available information is insufficient to decide. Not a + weaker `totalFailed` — under CIR (EU) 2025/1945 it is its own technical + outcome, "neither an EU qualified electronic signature, nor an EU qualified + electronic seal", to be reported as such rather than collapsed into either + neighbour. + subIndication: + type: + - string + - 'null' + enum: + - sigCryptoFailure + - hashFailure + - null + description: >- + The table 6 value qualifying the indication, where one applies. + + + `sigCryptoFailure`: the signature value could not be verified with the + public key in the signing certificate — a seal this node reports as + `notIntact`. + + + `hashFailure`: a hash of signed data does not match. Reported for a seal + that is intact over a *different* digest, because validating **this + passport's current signature** against it fails on the hash. The seal + remains a sound attestation of the signature it does cover, which is why + the node counts it apart and refuses to repair it. + + + `null` is the standard's own custom-diagnostic case: no table 6 value fits, + so `binding` carries the diagnostic instead of stretching one that nearly + does. The two `null` cases are different diagnostics — "the certificate was + never checked" and "these bytes could not be read" — and `binding` keeps + them apart. diff --git a/api/components/schemas/seals/ValidityWindow.yaml b/api/components/schemas/seals/ValidityWindow.yaml new file mode 100644 index 00000000..16fb146d --- /dev/null +++ b/api/components/schemas/seals/ValidityWindow.yaml @@ -0,0 +1,31 @@ +type: object +description: >- + A certificate's validity window, and where the sealing moment falls in it. + + + The window travels with the verdict so a reader can check the arithmetic + rather than take it on trust — and because the two dates are what an auditor + compares against a Trusted List entry when asking the other half of + Art. 32(1)(b). +required: + - notBefore + - notAfter + - standing +properties: + notBefore: + type: string + format: date-time + description: The certificate's `notBefore`. + notAfter: + type: string + format: date-time + description: The certificate's `notAfter`. + standing: + type: string + enum: + - inside + - expired + - notYetValid + description: >- + Where the judged moment falls in the window. `expired` and `notYetValid` + are findings only when that moment is attested — see `judgedAt`. diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index fe8bc4b1..f94ae938 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -714,6 +714,75 @@ } } }, + "/vault/api/v1/dpp/{dppId}/seal/repair": { + "post": { + "operationId": "repairDppSeal", + "summary": "Re-seal a passport whose stored seal does not verify", + "description": "Queues a replacement seal for a passport whose stored seal is broken, and\nrefuses unless it demonstrably is.\n\n**This buys a second seal for a digest already paid for.** The node's own\nrepair sweep deliberately cannot do that — it only queues passports\ncarrying *no seal at all*, which is what lets it run unattended without\nspending money twice. A broken seal is the case where spending it again is\nright: the row was paid for and carries nothing, so the passport is\npublished and, in substance, unsealed. That is a decision for whoever pays,\ntaken per passport, which is why it is a route rather than a background\nloop.\n\n**The seal is opened and checked at the moment of the request**, never read\nfrom the audit's list. A stale finding would buy a seal for a passport that\nhas since been repaired or re-published.\n\nNot idempotency-keyed, and does not need to be: a seal row is keyed by\n`(passportId, payloadHash)`, so a retried request re-arms a row that is\nalready queued, which is a no-op. A second repair after the replacement\nlands is refused, because the new seal verifies.\n\nThe replacement covers the passport's **current** signature. Where the\npassport was re-published since the broken seal was made, that signature has\nnever been sealed, so nothing is re-bought and `action` is `queued` rather\nthan `rearmed`.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "A replacement seal has been queued.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SealRepairResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "No passport with that id. Distinct from `422`, which means the passport\nexists and is not in a state this repairs.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "The passport exists and there is nothing here to repair. The detail says\nwhich: the seal verifies; it is intact but covers a superseded signature,\nwhose replacement is already queued by the re-publish that caused it;\nthere is no seal at all, which the node's own sweep covers at no extra\ncost; the seal could not be read, so it cannot be shown to be broken;\nthis node has no sealing backend, so a queued repair would never drain;\nor the passport carries a seal and no signature, which should not occur\nand for which a replacement would cover nothing.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, "/vault/api/v1/seal": { "get": { "operationId": "getSealSummary", @@ -6313,6 +6382,9 @@ "currentJws", "currentPayloadHash", "coverage", + "binding", + "validation", + "archival", "verification" ], "properties": { @@ -6340,6 +6412,39 @@ ], "description": "Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed." }, + "attestedSealedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "**When a timestamp authority attests the seal was made.**\n\n`sealedAt` is the sealing node's own clock and an unattested claim by the party that bought the seal. This is a third party's statement, read out of the time-stamp token inside `sealValue` — the only place in a seal an attested time can be.\n\nChecked rather than merely read: the token's own signature is verified and its imprint is matched against this seal's signature. The attribute carrying the token is *unsigned*, so a genuine token lifted from another seal would otherwise be accepted and report someone else's time as this one's.\n\n`null` for a `B-B` seal, which carries no token, and for a token that failed either check.\n\n**Attested is not trusted.** A qualified electronic time stamp is a QTSP's service (Art. 42) and the presumption of accuracy attaches to that (Art. 41(2)); establishing it is a Trusted List question about the `TSA/QTST` service type, which this node cannot yet ask. A self-signed authority's token verifies perfectly and means nothing." + }, + "conformanceLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/SealConformanceLevel" + }, + { + "type": "null" + } + ], + "description": "The baseline level this node **asked** for, recorded on the envelope. A record of intent; `null` for a seal stored before the field existed." + }, + "evidencedLevel": { + "oneOf": [ + { + "$ref": "#/components/schemas/SealConformanceLevel" + }, + { + "type": "null" + } + ], + "description": "The baseline level the seal's **bytes** carry.\n\nThe pair is the point. A provider enabled for a weaker profile than was paid for returns a seal that is correct in every record this node keeps and stops verifying when its signing certificate expires — years later, on a passport that is retention-locked and cannot be re-sealed. Serving both makes that answerable from the seal rather than from a log nobody kept.\n\nA floor, not a conformance verdict: it reports that the distinguishing material for a level is present, never that the material was validated. `null` when the bytes could not be read." + }, + "archival": { + "$ref": "#/components/schemas/ArchivalFreshness" + }, "placeholder": { "type": "boolean", "description": "True when this is a placeholder with no legal validity." @@ -6362,6 +6467,33 @@ "coverage": { "$ref": "#/components/schemas/SealCoverage" }, + "binding": { + "$ref": "#/components/schemas/SealBinding" + }, + "validation": { + "$ref": "#/components/schemas/SealValidationStatus" + }, + "certificate": { + "oneOf": [ + { + "$ref": "#/components/schemas/CertificateStanding" + }, + { + "type": "null" + } + ] + }, + "origin": { + "oneOf": [ + { + "$ref": "#/components/schemas/SealOrigin" + }, + { + "type": "null" + } + ], + "description": "What **this seal's own certificate** says about who issued it: did a provider issue it, or did the node sign it itself?\n\nNull means **not read** — a placeholder seal, a format this node does not parse, unreadable bytes, or a deployment with no inspector wired. Never read null as \"not self-issued\": that is a finding, and it only comes from a certificate that was actually examined.\n\nFor what the *currently configured* backend produces, which is a different question, see `trustMode` on `GET /api/v1/seal`." + }, "verification": { "type": "string", "description": "Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List." @@ -6395,6 +6527,30 @@ }, "sealingConfigured": { "type": "boolean" + }, + "audit": { + "oneOf": [ + { + "$ref": "#/components/schemas/SealAuditReport" + }, + { + "type": "null" + } + ], + "description": "What the last completed pass over every stored seal found.\n\n**`null` means no pass has completed**, not that nothing is wrong. The pass walks the estate in bounded batches and starts over, so this is empty until the first one finishes, and absent entirely on a deployment that runs no audit. Reporting a zero here for a check that has not run would be the one answer worse than reporting nothing.\n\nA node that keeps its audit state restores the last completed report at boot, so a restart no longer empties this. Where it is still `null` after a deployment, the honest reading is that no pass has ever finished against this database — on a large estate, check the audit's cadence before concluding anything about the seals." + }, + "trustMode": { + "type": [ + "string", + "null" + ], + "enum": [ + "ghost", + "sandbox", + "live", + null + ], + "description": "The tier the **currently configured** sealing backend resolved to. The counts say how much sealing is outstanding; this says whether the sealing that does happen is worth anything — a node can sit at `unsealedPublished: 0` while every one of those seals was signed by a key it generated itself, and no count would show it.\n\nA different question from the per-passport `origin`, and neither substitutes for the other: this describes the backend running now, that describes the certificate inside one stored seal. A node moved from a local backend to a QTSP last week reports `live` here and `selfIssued: true` on everything sealed before the move, and both are correct.\n\nNull on a deployment that resolved no seal port at all. That is **not** `ghost`: a port nobody wired and a port that landed on a placeholder are different states, and only the second blocks a production boot." } } }, @@ -6439,6 +6595,33 @@ "unknown" ] }, + "SealRepairResponse": { + "type": "object", + "description": "The outcome of repairing one passport's seal.", + "required": [ + "action", + "payloadHash", + "note" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "rearmed", + "queued" + ], + "description": "`rearmed`: a `sealed` row was re-armed — the one path that buys a second seal for a digest already paid for, justified because the first does not verify.\n\n`queued`: a row was queued the ordinary way. The passport was re-published since the broken seal was made, so the signature now needing a seal has never been sealed and nothing is being re-bought." + }, + "payloadHash": { + "type": "string", + "description": "Hex SHA-256 the replacement seal will cover — the passport's **current** signature, not whatever the broken seal covered." + }, + "note": { + "type": "string", + "description": "Stated rather than implied, like every other note on this surface: what was queued, what it will cost, and why spending it is right here." + } + } + }, "EvidenceDossier": { "type": "object", "description": "A self-contained, signed snapshot of a passport's full proof chain,\npersisted by the node at generation time. Verification\n(`POST /evidence/{id}/verify`) is an integrity check of the stored\ndossier against its own signatures and hash chains. See\n`docs/architecture/EVIDENCE-DOSSIER.md` for the full specification,\nincluding why `calcReceipts`/`checkpoint` are always empty/`null` in\nformat v1.\n", @@ -6598,7 +6781,7 @@ }, "QualifiedSealMember": { "type": "object", - "description": "The passport's eIDAS qualified seal as carried inside an evidence dossier:\nthe seal envelope plus the JWS it was computed over and that payload's hash,\nso a verifier holding only the dossier has both the CAdES and the preimage to\ncheck it against.\n\nIncluded because a dossier is what an authority is handed, and the seal is its\none member carrying an Art. 35(2) presumption — and because it is unreachable\notherwise: the seal is stripped from `fullView` and `publicView` alike, since\nit covers the full-payload signature rather than any redaction. Bound into\n`contentHashes` like every other member.\n\n⚠️ **This shape is described here but not enforced anywhere.** The field is\nheld as untyped JSON on the dossier record, so no Rust type declares these\nthree members and the OpenAPI contract test has nothing to compare them\nagainst. Treat the property list as documentation of intent, not as a\nguarantee. Giving the dossier a real type for this member would close that,\nand is the only thing that would.\n", + "description": "The passport's eIDAS qualified seal as carried inside an evidence dossier:\nthe seal envelope plus the JWS it was computed over and that payload's hash,\nso a verifier holding only the dossier has both the CAdES and the preimage to\ncheck it against.\n\nIncluded because a dossier is what an authority is handed, and the seal is its\none member carrying an Art. 35(2) presumption — and because it is unreachable\notherwise: the seal is stripped from `fullView` and `publicView` alike, since\nit covers the full-payload signature rather than any redaction. Bound into\n`contentHashes` like every other member.\n\n⚠️ **This shape is described here but not enforced anywhere.** The field is\nheld as untyped JSON on the dossier record, so no Rust type declares these\nmembers and the OpenAPI contract test has nothing to compare them against.\nTreat the property list as documentation of intent, not as a guarantee. Giving\nthe dossier a real type for this member would close that, and is the only thing\nthat would — and it is worth doing: this list had fallen five fields behind\nwhat the dossier actually carries before anyone read the two side by side.\n", "properties": { "seal": { "type": "object", @@ -6612,6 +6795,51 @@ "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 of the sealed payload, lowercase hex." + }, + "binding": { + "$ref": "#/components/schemas/SealBinding" + }, + "validation": { + "$ref": "#/components/schemas/SealValidationStatus" + }, + "certificate": { + "oneOf": [ + { + "$ref": "#/components/schemas/CertificateStanding" + }, + { + "type": "null" + } + ] + }, + "origin": { + "oneOf": [ + { + "$ref": "#/components/schemas/SealOrigin" + }, + { + "type": "null" + } + ], + "description": "What the seal's own certificate says about who issued it — the one fact that decides whether anything else in this member carries weight." + }, + "evidencedLevel": { + "type": [ + "string", + "null" + ], + "description": "The baseline level the seal's **bytes** carry, which is not necessarily the level this node asked for. A seal weaker than ordered is otherwise visible only in a drain log no dossier reader has." + }, + "attestedSealedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "A timestamp authority's statement of when the seal was made, read out of the token inside the CAdES and checked. `seal.sealedAt` beside it is the sealing node's own clock, which is an unattested claim by the party that bought the seal." + }, + "archival": { + "$ref": "#/components/schemas/ArchivalFreshness" } } }, @@ -8761,6 +8989,275 @@ } } }, + "ArchivalFreshness": { + "type": "object", + "description": "Whether a seal's archival protection is still live.\n\n`evidencedLevel` reports `baseline-lta` from the **presence** of the archival material, and is right to — the material is there. This reports whether it still means anything.\n\nAn archival timestamp is what keeps a `B-LTA` seal verifiable after its signing certificate expires, which is the whole point for a retention-locked passport that outlives every certificate involved. **It expires too**: its own timestamping authority's certificate has a validity period, and ETSI's long-term profiles expect re-timestamping before that. Without this field a seal whose archival protection lapsed years ago reads exactly as it did the day it was bought.\n\n**A signal, not a verdict.** A seal nearing its renewal date still verifies, and that window is the only chance to renew without an outage. No threshold is applied — how much notice is enough is a policy question for the reader.", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "notArchived", + "current", + "lapsed", + "unknown" + ], + "description": "`notArchived`: no archival timestamp — a seal below `B-LTA`. Nothing to renew, which is a different thing from a renewal that has lapsed: such a seal was never promised long-term protection.\n\n`current`: the archival timestamp's authority certificate is still valid.\n\n`lapsed`: it has expired. The seal may still verify today; what is gone is the thing meant to keep it verifying once its signing certificate goes.\n\n`unknown`: an archival timestamp is present and could not be read. **Not `current`** — a token that cannot be checked is not a fresh one, and reporting it as current is how a staleness signal goes quiet at the moment it matters." + }, + "expires": { + "type": "string", + "format": "date-time", + "description": "When the archival timestamp's authority certificate expires — the date by which re-timestamping must have happened, read from before or after it depending on `state`. One value, so one name: `current` and `lapsed` are the same date seen from either side. Absent for `notArchived` and `unknown`." + } + } + }, + "SealBinding": { + "type": "object", + "description": "Whether the seal's **own bytes** say it covers this passport's current signature.\n\nA detached CAdES states what it covers in exactly one place — the `messageDigest` signed attribute, RFC 5652 §11.2 — and that attribute sits *inside* the signature. This reports what it says, after checking the signature over it. That ordering is the whole point: the attribute is plain DER and trivial to rewrite, but rewriting it breaks the signature, so a seal cannot be retargeted at another passport by editing what it claims to cover.\n\nRead it beside `coverage`, which answers the same question from this node's own outbox records. Those records survive a seal that will not parse and need no cryptography; this needs both and is evidence. **Where the two disagree, the disagreement is the finding** — the records and the bytes are describing different things, which neither source could have revealed alone.", + "required": [ + "result" + ], + "properties": { + "result": { + "type": "string", + "enum": [ + "coversThisSignature", + "coversAnotherDigest", + "notIntact", + "unknown" + ], + "description": "`coversThisSignature`: the seal names this exact signature and its attributes verify under the certificate it carries. The strongest statement this node can make without an external validator — whatever is true of the seal's *trust*, it is demonstrably a seal over this passport and not over anything else.\n\n`coversAnotherDigest`: the seal is intact and names a different digest. Ordinary after a re-publish, since the passport re-signed. It is also what a seal stored against the wrong passport looks like, and the two are indistinguishable from the bytes alone; what separates them is whether an outbox row says this digest was ever requested for this passport.\n\n`notIntact`: the signature over the seal's attributes does not verify, so nothing the seal says about what it covers can be relied on.\n\n`unknown`: the bytes could not be read, or carry no digest at all — a placeholder, an unparsed format, or a signature with no signed attributes. **Never** to be read as a mismatch." + }, + "covered": { + "type": "string", + "description": "Hex SHA-256 the seal actually covers. Present only with `coversAnotherDigest`, so a caller holding it can find which version of the passport it belongs to.\n\nDeliberately absent from `notIntact`: the attribute naming it sits inside a signature that failed, so reporting it would hand the caller a number that nothing vouches for." + } + } + }, + "SealValidationStatus": { + "type": "object", + "description": "`binding` restated in the vocabulary of ETSI EN 319 102-1 clause 5.1.3 — the procedures standard CIR (EU) 2025/1945 points at for validating a qualified electronic seal.\n\nA translation, not a second opinion: it is derived from `binding` and adds no checking of its own. It is here because these findings travel to readers whose validation tooling speaks this vocabulary, and because the translation makes explicit something our own field names let a reader assume.\n\n**`totalPassed` is not among the values, and cannot be.** That indication requires, among other conditions, that the constraints applicable to the signer's certificate have been positively validated — a certificate path built and validated to a trust anchor, under a policy.\n\nThis node checks the certificate's validity window and whatever revocation material the seal carries (see `certificate`), and it checks one issuer signature against a Trusted List entry. It builds no path and applies no policy constraints. So a seal that demonstrably covers this signature reports `indeterminate`: nothing has failed, and not everything has been checked. Reading `coversThisSignature` as a validation pass is the misreading this object exists to prevent.", + "required": [ + "indication" + ], + "properties": { + "indication": { + "type": "string", + "enum": [ + "totalFailed", + "indeterminate" + ], + "description": "`totalFailed`: the signature is demonstrably not valid. The standard makes this **stable** — the same inputs always yield the same answer, and additional validation data cannot lift it to a pass; only additional proofs of existence can change a result at all. That stability is what makes a broken seal safe to spend a replacement on.\n\n`indeterminate`: the available information is insufficient to decide. Not a weaker `totalFailed` — under CIR (EU) 2025/1945 it is its own technical outcome, \"neither an EU qualified electronic signature, nor an EU qualified electronic seal\", to be reported as such rather than collapsed into either neighbour." + }, + "subIndication": { + "type": [ + "string", + "null" + ], + "enum": [ + "sigCryptoFailure", + "hashFailure", + null + ], + "description": "The table 6 value qualifying the indication, where one applies.\n\n`sigCryptoFailure`: the signature value could not be verified with the public key in the signing certificate — a seal this node reports as `notIntact`.\n\n`hashFailure`: a hash of signed data does not match. Reported for a seal that is intact over a *different* digest, because validating **this passport's current signature** against it fails on the hash. The seal remains a sound attestation of the signature it does cover, which is why the node counts it apart and refuses to repair it.\n\n`null` is the standard's own custom-diagnostic case: no table 6 value fits, so `binding` carries the diagnostic instead of stretching one that nearly does. The two `null` cases are different diagnostics — \"the certificate was never checked\" and \"these bytes could not be read\" — and `binding` keeps them apart." + } + } + }, + "ValidityWindow": { + "type": "object", + "description": "A certificate's validity window, and where the sealing moment falls in it.\n\nThe window travels with the verdict so a reader can check the arithmetic rather than take it on trust — and because the two dates are what an auditor compares against a Trusted List entry when asking the other half of Art. 32(1)(b).", + "required": [ + "notBefore", + "notAfter", + "standing" + ], + "properties": { + "notBefore": { + "type": "string", + "format": "date-time", + "description": "The certificate's `notBefore`." + }, + "notAfter": { + "type": "string", + "format": "date-time", + "description": "The certificate's `notAfter`." + }, + "standing": { + "type": "string", + "enum": [ + "inside", + "expired", + "notYetValid" + ], + "description": "Where the judged moment falls in the window. `expired` and `notYetValid` are findings only when that moment is attested — see `judgedAt`." + } + } + }, + "JudgedTime": { + "type": "object", + "description": "The moment a certificate's standing was judged against, and whether anything proves it.\n\nRegulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks whether the certificate was valid **at the time of signing**. The whole verdict therefore turns on which moment is used and what that moment is worth.", + "required": [ + "at", + "attested" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time", + "description": "The moment used." + }, + "attested": { + "type": "boolean", + "description": "True when the moment came from a timestamp token inside the seal, whose signature and imprint were both checked. False means it is the node's own clock — the validation time, not the signing time.\n\nThis separates a failure from an open question. ETSI EN 319 102-1 treats a time nothing proves as no time at all, which is why a verdict resting on an unattested moment is reported with a `NO_POE` sub-indication rather than as `totalFailed`. Without it, every seal whose certificate has since expired — eventually all of them — would read as invalid." + } + } + }, + "RevocationStanding": { + "type": "object", + "description": "What the seal's own revocation material says about its signing certificate.\n\n**Read from the seal, never fetched.** A CRL distribution point is a URL inside a certificate an operator was handed, and following one would have this node issue requests to an address chosen by whoever produced the seal. ETSI EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward for exactly this reason: a seal meant to be checkable years later carries what is needed to check it, offline.", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "notRevoked", + "revoked", + "notAvailable", + "unusable" + ], + "description": "`notRevoked` — a CRL from this certificate's issuer, whose own signature verifies under a certificate the seal carries, does not list it.\n\n`revoked` — that CRL lists it, and `at` says when. Whether this condemns the seal depends on `judgedAt`: a certificate revoked *after* a seal was made does not unmake it.\n\n`notAvailable` — the seal carries no revocation material for this certificate. Ordinary below `B-LT` and **not a defect**.\n\n`unusable` — material was present and could not be relied on: an unparseable list, or one whose signature does not verify. Distinct from `notAvailable` because something claiming to be authoritative failed, which is worth looking at. A CRL wearing the issuer's name but signed by another key lands here and never on `notRevoked` — an unsigned list could otherwise *clear* a revoked certificate." + }, + "asOf": { + "type": "string", + "format": "date-time", + "description": "With `notRevoked`, the CRL's `thisUpdate` — the moment its statement is about. A list issued before the seal was made cannot rule out a later revocation, and this is here so a reader can see which question was actually answered." + }, + "at": { + "type": "string", + "format": "date-time", + "description": "With `revoked`, when the issuer says it was revoked." + }, + "reason": { + "type": "string", + "description": "With `unusable`, what failed." + } + } + }, + "CertificateStanding": { + "type": "object", + "description": "**Was the seal's signing certificate valid when the seal was made?**\n\nThe second limb of Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40. The first limb — whether a qualified trust service provider issued it — is a Trusted List question and is not answered here.\n\nTwo findings and the moment they were judged against, because the moment is what decides them: a certificate outside its window is a failure when a timestamp proves the seal was made outside it, and merely unproven otherwise. Certificates expire, sealed passports outlive them by years, and an expired certificate today says nothing about a seal made while it was good.", + "required": [ + "validity", + "judgedAt", + "revocation" + ], + "properties": { + "validity": { + "$ref": "#/components/schemas/ValidityWindow" + }, + "judgedAt": { + "$ref": "#/components/schemas/JudgedTime" + }, + "revocation": { + "$ref": "#/components/schemas/RevocationStanding" + } + } + }, + "SealOrigin": { + "type": "object", + "description": "What a stored seal's own certificate says about who issued it — read out of the seal's bytes, never from this node's configuration. The two are different facts: configuration records what the operator intended, and a seal restored from a backup or made before a backend change was not produced by whatever is configured now.\n\n**Not a qualification verdict.** Establishing that a seal is qualified needs the issuer matched against an EU Trusted List *and* the issuer's signature over this certificate verified; neither is done to produce this. What it does answer completely, and without a network, is whether anybody issued the certificate at all.", + "required": [ + "subject", + "issuer", + "selfIssued", + "creationDevice" + ], + "properties": { + "subject": { + "type": "string", + "description": "The certificate's subject distinguished name, RFC 4514." + }, + "issuer": { + "type": "string", + "description": "The certificate's issuer distinguished name, RFC 4514." + }, + "selfIssued": { + "type": "boolean", + "description": "Whether issuer and subject are the same name — the structural test for \"nobody issued this to us\", and a property of the certificate that no environment variable can change. True means the seal attests that a key this node holds signed a digest and **nothing else**: it carries no legal weight, and no Trusted List would give it any." + }, + "creationDevice": { + "type": "string", + "enum": [ + "declaresQualifiedDevice", + "noQualifiedDevice", + "notAQualifiedCertificate" + ], + "description": "What the certificate declares about the device holding its private key — a declaration, never a verification.\n\n`declaresQualifiedDevice`: the certificate carries the Annex III(j) indication that Regulation (EU) No 910/2014 Art. 32(1)(f), reached for seals through Art. 40, requires.\n\n`noQualifiedDevice`: it carries qualified-certificate statements but not that one. Lawful, and enough for Art. 40a — validation of an *advanced* seal based on a qualified certificate, which omits the device leg — but not for the Art. 32/40 pair.\n\n`notAQualifiedCertificate`: it carries no qualified-certificate statements at all, so it is not presenting itself as a qualified certificate in the first place. A self-signed development certificate lands here." + } + } + }, + "SealAuditReport": { + "type": "object", + "description": "What the last completed pass over **every stored seal** found.\n\nThe counts beside this describe outbox rows and passports carrying *no* seal. This describes seals that exist and do not stand up — a condition neither can see, because both ask the database whether the seal member is absent, and a worthless seal is present.\n\nWhether a stored seal stands up is cryptographic rather than relational: open the CAdES, verify the signature, read the digest it covers. So it is found by a background pass that walks the estate in bounded batches and starts over, not by a query.", + "required": [ + "completedAt", + "checked", + "sound", + "superseded", + "broken", + "certificateFailed", + "unreadable", + "brokenPassports", + "truncated" + ], + "properties": { + "completedAt": { + "type": "string", + "format": "date-time", + "description": "When the pass finished. The age of this is the age of every number below it — a pass takes as long as the estate divided by the audit's throughput, so on a large deployment these are hours old by construction. Fine for a condition that does not appear suddenly, and stated rather than implied." + }, + "checked": { + "type": "integer", + "description": "Seals opened." + }, + "sound": { + "type": "integer", + "description": "Seals covering their passport's current signature.\n\nIn EN 319 102-1's terms this is `indeterminate`, **not** `totalPassed`: nothing about these seals has failed, and their certificates have not been validated by this node. See `validation` on `GET /api/v1/dpp/{dppId}/seal`." + }, + "superseded": { + "type": "integer", + "description": "Seals over a different digest. Ordinarily a passport re-published after sealing, which is **not** a defect — the seal remains a valid attestation of the signature it covers, and `GET /api/v1/dpp/{dppId}/seal` reports it per passport as `coverage`.\n\n`totalFailed` / `hashFailure` when the question asked is about the passport's *current* signature, which is the question this walk asks." + }, + "broken": { + "type": "integer", + "description": "Seals whose own signature does not verify. **The finding.** Those passports are published and, in substance, unsealed — and invisible to `unsealedPublished`, which asks only whether a seal is present.\n\n`totalFailed` / `sigCryptoFailure`, and EN 319 102-1 makes that verdict stable: no additional validation data can lift it. That is what makes a replacement seal worth buying for these and for nothing else here — `POST /api/v1/dpp/{dppId}/seal/repair`." + }, + "certificateFailed": { + "type": "integer", + "description": "Seals whose signature is sound and whose **certificate** was not, at the moment they were made — revoked before sealing, or outside its validity window, with an attested time to prove the order. `totalFailed` with a certificate sub-indication.\n\nCounted apart from `broken` because the two need opposite responses. A broken seal is worth replacing; a seal made under a revoked certificate would only be replaced by another from the same certificate, so `POST /api/v1/dpp/{dppId}/seal/repair` refuses it and says why.\n\nWhere nothing attests when the seal was made, the same observation is *indeterminate* rather than a failure and the seal stays in `sound`: a certificate that has expired since is the ordinary state of an old seal." + }, + "unreadable": { + "type": "integer", + "description": "Seals this node could not read — a placeholder, or a format it does not parse. **Not a finding.** Treating \"cannot check\" as \"broken\" would make every seal from a backend emitting an unparsed format look like corruption.\n\n`indeterminate` with a custom diagnostic: a limit of the reader, not a defect in the seal." + }, + "brokenPassports": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "The passports carrying a broken seal, so an operator can act rather than search a log. Capped — see `truncated`. A node with thousands of broken seals has one problem, not thousands; `broken` states its size, and a list long enough to prove that is a list nobody reads." + }, + "truncated": { + "type": "boolean", + "description": "True when `brokenPassports` was cut short. Stated rather than left to be inferred from the length matching the cap, which is an inference that is right until the cap changes." + } + } + }, "AmendRequest": { "type": "object", "description": "Body for amending a published passport.\n\nA published passport's content is immutable — its signatures commit to its\nbytes and the retention guard refuses the write. An amendment therefore\npublishes a **new** passport carrying `supersedesId` back to the one being\ncorrected, and moves that one to the terminal `superseded` state. The\nsuperseded record keeps its signatures and stays resolvable by its own id.\n", diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index 85a14b45..e789419a 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -573,6 +573,82 @@ paths: $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' + /vault/api/v1/dpp/{dppId}/seal/repair: + post: + operationId: repairDppSeal + summary: Re-seal a passport whose stored seal does not verify + description: | + Queues a replacement seal for a passport whose stored seal is broken, and + refuses unless it demonstrably is. + + **This buys a second seal for a digest already paid for.** The node's own + repair sweep deliberately cannot do that — it only queues passports + carrying *no seal at all*, which is what lets it run unattended without + spending money twice. A broken seal is the case where spending it again is + right: the row was paid for and carries nothing, so the passport is + published and, in substance, unsealed. That is a decision for whoever pays, + taken per passport, which is why it is a route rather than a background + loop. + + **The seal is opened and checked at the moment of the request**, never read + from the audit's list. A stale finding would buy a seal for a passport that + has since been repaired or re-published. + + Not idempotency-keyed, and does not need to be: a seal row is keyed by + `(passportId, payloadHash)`, so a retried request re-arms a row that is + already queued, which is a no-op. A second repair after the replacement + lands is refused, because the new seal verifies. + + The replacement covers the passport's **current** signature. Where the + passport was re-published since the broken seal was made, that signature has + never been sealed, so nothing is re-bought and `action` is `queued` rather + than `rearmed`. + tags: + - DPP Management + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + responses: + '200': + description: A replacement seal has been queued. + content: + application/json: + schema: + $ref: '#/components/schemas/SealRepairResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: | + No passport with that id. Distinct from `422`, which means the passport + exists and is not in a state this repairs. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: | + The passport exists and there is nothing here to repair. The detail says + which: the seal verifies; it is intact but covers a superseded signature, + whose replacement is already queued by the re-publish that caused it; + there is no seal at all, which the node's own sweep covers at no extra + cost; the seal could not be read, so it cannot be shown to be broken; + this node has no sealing backend, so a queued repair would never drain; + or the passport carries a seal and no signature, which should not occur + and for which a replacement would cover nothing. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' /vault/api/v1/seal: get: operationId: getSealSummary @@ -5178,6 +5254,9 @@ components: - currentJws - currentPayloadHash - coverage + - binding + - validation + - archival - verification properties: declaredBy: @@ -5198,6 +5277,38 @@ components: - string - 'null' description: Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed. + attestedSealedAt: + type: + - string + - 'null' + format: date-time + description: |- + **When a timestamp authority attests the seal was made.** + + `sealedAt` is the sealing node's own clock and an unattested claim by the party that bought the seal. This is a third party's statement, read out of the time-stamp token inside `sealValue` — the only place in a seal an attested time can be. + + Checked rather than merely read: the token's own signature is verified and its imprint is matched against this seal's signature. The attribute carrying the token is *unsigned*, so a genuine token lifted from another seal would otherwise be accepted and report someone else's time as this one's. + + `null` for a `B-B` seal, which carries no token, and for a token that failed either check. + + **Attested is not trusted.** A qualified electronic time stamp is a QTSP's service (Art. 42) and the presumption of accuracy attaches to that (Art. 41(2)); establishing it is a Trusted List question about the `TSA/QTST` service type, which this node cannot yet ask. A self-signed authority's token verifies perfectly and means nothing. + conformanceLevel: + oneOf: + - $ref: '#/components/schemas/SealConformanceLevel' + - type: 'null' + description: The baseline level this node **asked** for, recorded on the envelope. A record of intent; `null` for a seal stored before the field existed. + evidencedLevel: + oneOf: + - $ref: '#/components/schemas/SealConformanceLevel' + - type: 'null' + description: |- + The baseline level the seal's **bytes** carry. + + The pair is the point. A provider enabled for a weaker profile than was paid for returns a seal that is correct in every record this node keeps and stops verifying when its signing certificate expires — years later, on a passport that is retention-locked and cannot be re-sealed. Serving both makes that answerable from the seal rather than from a log nobody kept. + + A floor, not a conformance verdict: it reports that the distinguishing material for a level is present, never that the material was validated. `null` when the bytes could not be read. + archival: + $ref: '#/components/schemas/ArchivalFreshness' placeholder: type: boolean description: True when this is a placeholder with no legal validity. @@ -5214,6 +5325,24 @@ components: description: 'Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator''s extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere.' coverage: $ref: '#/components/schemas/SealCoverage' + binding: + $ref: '#/components/schemas/SealBinding' + validation: + $ref: '#/components/schemas/SealValidationStatus' + certificate: + oneOf: + - $ref: '#/components/schemas/CertificateStanding' + - type: 'null' + origin: + oneOf: + - $ref: '#/components/schemas/SealOrigin' + - type: 'null' + description: |- + What **this seal's own certificate** says about who issued it: did a provider issue it, or did the node sign it itself? + + Null means **not read** — a placeholder seal, a format this node does not parse, unreadable bytes, or a deployment with no inspector wired. Never read null as "not self-issued": that is a finding, and it only comes from a certificate that was actually examined. + + For what the *currently configured* backend produces, which is a different question, see `trustMode` on `GET /api/v1/seal`. verification: type: string description: 'Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List.' @@ -5239,6 +5368,31 @@ components: description: Rows that gave up after exhausting their retries. sealingConfigured: type: boolean + audit: + oneOf: + - $ref: '#/components/schemas/SealAuditReport' + - type: 'null' + description: |- + What the last completed pass over every stored seal found. + + **`null` means no pass has completed**, not that nothing is wrong. The pass walks the estate in bounded batches and starts over, so this is empty until the first one finishes, and absent entirely on a deployment that runs no audit. Reporting a zero here for a check that has not run would be the one answer worse than reporting nothing. + + A node that keeps its audit state restores the last completed report at boot, so a restart no longer empties this. Where it is still `null` after a deployment, the honest reading is that no pass has ever finished against this database — on a large estate, check the audit's cadence before concluding anything about the seals. + trustMode: + type: + - string + - 'null' + enum: + - ghost + - sandbox + - live + - null + description: |- + The tier the **currently configured** sealing backend resolved to. The counts say how much sealing is outstanding; this says whether the sealing that does happen is worth anything — a node can sit at `unsealedPublished: 0` while every one of those seals was signed by a key it generated itself, and no count would show it. + + A different question from the per-passport `origin`, and neither substitutes for the other: this describes the backend running now, that describes the certificate inside one stored seal. A node moved from a local backend to a QTSP last week reports `live` here and `selfIssued: true` on everything sealed before the move, and both are correct. + + Null on a deployment that resolved no seal port at all. That is **not** `ghost`: a port nobody wired and a port that landed on a placeholder are different states, and only the second blocks a production boot. SealDeclarer: type: object description: | @@ -5288,6 +5442,29 @@ components: - current - superseded - unknown + SealRepairResponse: + type: object + description: The outcome of repairing one passport's seal. + required: + - action + - payloadHash + - note + properties: + action: + type: string + enum: + - rearmed + - queued + description: |- + `rearmed`: a `sealed` row was re-armed — the one path that buys a second seal for a digest already paid for, justified because the first does not verify. + + `queued`: a row was queued the ordinary way. The passport was re-published since the broken seal was made, so the signature now needing a seal has never been sealed and nothing is being re-bought. + payloadHash: + type: string + description: Hex SHA-256 the replacement seal will cover — the passport's **current** signature, not whatever the broken seal covered. + note: + type: string + description: 'Stated rather than implied, like every other note on this surface: what was queued, what it will cost, and why spending it is right here.' EvidenceDossier: type: object description: | @@ -5435,10 +5612,11 @@ components: ⚠️ **This shape is described here but not enforced anywhere.** The field is held as untyped JSON on the dossier record, so no Rust type declares these - three members and the OpenAPI contract test has nothing to compare them - against. Treat the property list as documentation of intent, not as a - guarantee. Giving the dossier a real type for this member would close that, - and is the only thing that would. + members and the OpenAPI contract test has nothing to compare them against. + Treat the property list as documentation of intent, not as a guarantee. Giving + the dossier a real type for this member would close that, and is the only thing + that would — and it is worth doing: this list had fallen five fields behind + what the dossier actually carries before anyone read the two side by side. properties: seal: type: object @@ -5450,6 +5628,32 @@ components: type: string pattern: ^[0-9a-f]{64}$ description: SHA-256 of the sealed payload, lowercase hex. + binding: + $ref: '#/components/schemas/SealBinding' + validation: + $ref: '#/components/schemas/SealValidationStatus' + certificate: + oneOf: + - $ref: '#/components/schemas/CertificateStanding' + - type: 'null' + origin: + oneOf: + - $ref: '#/components/schemas/SealOrigin' + - type: 'null' + description: What the seal's own certificate says about who issued it — the one fact that decides whether anything else in this member carries weight. + evidencedLevel: + type: + - string + - 'null' + description: The baseline level the seal's **bytes** carry, which is not necessarily the level this node asked for. A seal weaker than ordered is otherwise visible only in a drain log no dossier reader has. + attestedSealedAt: + type: + - string + - 'null' + format: date-time + description: A timestamp authority's statement of when the seal was made, read out of the token inside the CAdES and checked. `seal.sealedAt` beside it is the sealing node's own clock, which is an unattested claim by the party that bought the seal. + archival: + $ref: '#/components/schemas/ArchivalFreshness' EvidenceDossierRecord: type: object description: A stored dossier snapshot — the dossier plus its persistence envelope. @@ -7347,6 +7551,310 @@ components: $ref: '#/components/schemas/PublishBlocker' passportScope: $ref: '#/components/schemas/PassportScopeReport' + ArchivalFreshness: + type: object + description: |- + Whether a seal's archival protection is still live. + + `evidencedLevel` reports `baseline-lta` from the **presence** of the archival material, and is right to — the material is there. This reports whether it still means anything. + + An archival timestamp is what keeps a `B-LTA` seal verifiable after its signing certificate expires, which is the whole point for a retention-locked passport that outlives every certificate involved. **It expires too**: its own timestamping authority's certificate has a validity period, and ETSI's long-term profiles expect re-timestamping before that. Without this field a seal whose archival protection lapsed years ago reads exactly as it did the day it was bought. + + **A signal, not a verdict.** A seal nearing its renewal date still verifies, and that window is the only chance to renew without an outage. No threshold is applied — how much notice is enough is a policy question for the reader. + required: + - state + properties: + state: + type: string + enum: + - notArchived + - current + - lapsed + - unknown + description: |- + `notArchived`: no archival timestamp — a seal below `B-LTA`. Nothing to renew, which is a different thing from a renewal that has lapsed: such a seal was never promised long-term protection. + + `current`: the archival timestamp's authority certificate is still valid. + + `lapsed`: it has expired. The seal may still verify today; what is gone is the thing meant to keep it verifying once its signing certificate goes. + + `unknown`: an archival timestamp is present and could not be read. **Not `current`** — a token that cannot be checked is not a fresh one, and reporting it as current is how a staleness signal goes quiet at the moment it matters. + expires: + type: string + format: date-time + description: 'When the archival timestamp''s authority certificate expires — the date by which re-timestamping must have happened, read from before or after it depending on `state`. One value, so one name: `current` and `lapsed` are the same date seen from either side. Absent for `notArchived` and `unknown`.' + SealBinding: + type: object + description: |- + Whether the seal's **own bytes** say it covers this passport's current signature. + + A detached CAdES states what it covers in exactly one place — the `messageDigest` signed attribute, RFC 5652 §11.2 — and that attribute sits *inside* the signature. This reports what it says, after checking the signature over it. That ordering is the whole point: the attribute is plain DER and trivial to rewrite, but rewriting it breaks the signature, so a seal cannot be retargeted at another passport by editing what it claims to cover. + + Read it beside `coverage`, which answers the same question from this node's own outbox records. Those records survive a seal that will not parse and need no cryptography; this needs both and is evidence. **Where the two disagree, the disagreement is the finding** — the records and the bytes are describing different things, which neither source could have revealed alone. + required: + - result + properties: + result: + type: string + enum: + - coversThisSignature + - coversAnotherDigest + - notIntact + - unknown + description: |- + `coversThisSignature`: the seal names this exact signature and its attributes verify under the certificate it carries. The strongest statement this node can make without an external validator — whatever is true of the seal's *trust*, it is demonstrably a seal over this passport and not over anything else. + + `coversAnotherDigest`: the seal is intact and names a different digest. Ordinary after a re-publish, since the passport re-signed. It is also what a seal stored against the wrong passport looks like, and the two are indistinguishable from the bytes alone; what separates them is whether an outbox row says this digest was ever requested for this passport. + + `notIntact`: the signature over the seal's attributes does not verify, so nothing the seal says about what it covers can be relied on. + + `unknown`: the bytes could not be read, or carry no digest at all — a placeholder, an unparsed format, or a signature with no signed attributes. **Never** to be read as a mismatch. + covered: + type: string + description: |- + Hex SHA-256 the seal actually covers. Present only with `coversAnotherDigest`, so a caller holding it can find which version of the passport it belongs to. + + Deliberately absent from `notIntact`: the attribute naming it sits inside a signature that failed, so reporting it would hand the caller a number that nothing vouches for. + SealValidationStatus: + type: object + description: |- + `binding` restated in the vocabulary of ETSI EN 319 102-1 clause 5.1.3 — the procedures standard CIR (EU) 2025/1945 points at for validating a qualified electronic seal. + + A translation, not a second opinion: it is derived from `binding` and adds no checking of its own. It is here because these findings travel to readers whose validation tooling speaks this vocabulary, and because the translation makes explicit something our own field names let a reader assume. + + **`totalPassed` is not among the values, and cannot be.** That indication requires, among other conditions, that the constraints applicable to the signer's certificate have been positively validated — a certificate path built and validated to a trust anchor, under a policy. + + This node checks the certificate's validity window and whatever revocation material the seal carries (see `certificate`), and it checks one issuer signature against a Trusted List entry. It builds no path and applies no policy constraints. So a seal that demonstrably covers this signature reports `indeterminate`: nothing has failed, and not everything has been checked. Reading `coversThisSignature` as a validation pass is the misreading this object exists to prevent. + required: + - indication + properties: + indication: + type: string + enum: + - totalFailed + - indeterminate + description: |- + `totalFailed`: the signature is demonstrably not valid. The standard makes this **stable** — the same inputs always yield the same answer, and additional validation data cannot lift it to a pass; only additional proofs of existence can change a result at all. That stability is what makes a broken seal safe to spend a replacement on. + + `indeterminate`: the available information is insufficient to decide. Not a weaker `totalFailed` — under CIR (EU) 2025/1945 it is its own technical outcome, "neither an EU qualified electronic signature, nor an EU qualified electronic seal", to be reported as such rather than collapsed into either neighbour. + subIndication: + type: + - string + - 'null' + enum: + - sigCryptoFailure + - hashFailure + - null + description: |- + The table 6 value qualifying the indication, where one applies. + + `sigCryptoFailure`: the signature value could not be verified with the public key in the signing certificate — a seal this node reports as `notIntact`. + + `hashFailure`: a hash of signed data does not match. Reported for a seal that is intact over a *different* digest, because validating **this passport's current signature** against it fails on the hash. The seal remains a sound attestation of the signature it does cover, which is why the node counts it apart and refuses to repair it. + + `null` is the standard's own custom-diagnostic case: no table 6 value fits, so `binding` carries the diagnostic instead of stretching one that nearly does. The two `null` cases are different diagnostics — "the certificate was never checked" and "these bytes could not be read" — and `binding` keeps them apart. + ValidityWindow: + type: object + description: |- + A certificate's validity window, and where the sealing moment falls in it. + + The window travels with the verdict so a reader can check the arithmetic rather than take it on trust — and because the two dates are what an auditor compares against a Trusted List entry when asking the other half of Art. 32(1)(b). + required: + - notBefore + - notAfter + - standing + properties: + notBefore: + type: string + format: date-time + description: The certificate's `notBefore`. + notAfter: + type: string + format: date-time + description: The certificate's `notAfter`. + standing: + type: string + enum: + - inside + - expired + - notYetValid + description: Where the judged moment falls in the window. `expired` and `notYetValid` are findings only when that moment is attested — see `judgedAt`. + JudgedTime: + type: object + description: |- + The moment a certificate's standing was judged against, and whether anything proves it. + + Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks whether the certificate was valid **at the time of signing**. The whole verdict therefore turns on which moment is used and what that moment is worth. + required: + - at + - attested + properties: + at: + type: string + format: date-time + description: The moment used. + attested: + type: boolean + description: |- + True when the moment came from a timestamp token inside the seal, whose signature and imprint were both checked. False means it is the node's own clock — the validation time, not the signing time. + + This separates a failure from an open question. ETSI EN 319 102-1 treats a time nothing proves as no time at all, which is why a verdict resting on an unattested moment is reported with a `NO_POE` sub-indication rather than as `totalFailed`. Without it, every seal whose certificate has since expired — eventually all of them — would read as invalid. + RevocationStanding: + type: object + description: |- + What the seal's own revocation material says about its signing certificate. + + **Read from the seal, never fetched.** A CRL distribution point is a URL inside a certificate an operator was handed, and following one would have this node issue requests to an address chosen by whoever produced the seal. ETSI EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward for exactly this reason: a seal meant to be checkable years later carries what is needed to check it, offline. + required: + - status + properties: + status: + type: string + enum: + - notRevoked + - revoked + - notAvailable + - unusable + description: |- + `notRevoked` — a CRL from this certificate's issuer, whose own signature verifies under a certificate the seal carries, does not list it. + + `revoked` — that CRL lists it, and `at` says when. Whether this condemns the seal depends on `judgedAt`: a certificate revoked *after* a seal was made does not unmake it. + + `notAvailable` — the seal carries no revocation material for this certificate. Ordinary below `B-LT` and **not a defect**. + + `unusable` — material was present and could not be relied on: an unparseable list, or one whose signature does not verify. Distinct from `notAvailable` because something claiming to be authoritative failed, which is worth looking at. A CRL wearing the issuer's name but signed by another key lands here and never on `notRevoked` — an unsigned list could otherwise *clear* a revoked certificate. + asOf: + type: string + format: date-time + description: With `notRevoked`, the CRL's `thisUpdate` — the moment its statement is about. A list issued before the seal was made cannot rule out a later revocation, and this is here so a reader can see which question was actually answered. + at: + type: string + format: date-time + description: With `revoked`, when the issuer says it was revoked. + reason: + type: string + description: With `unusable`, what failed. + CertificateStanding: + type: object + description: |- + **Was the seal's signing certificate valid when the seal was made?** + + The second limb of Regulation (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40. The first limb — whether a qualified trust service provider issued it — is a Trusted List question and is not answered here. + + Two findings and the moment they were judged against, because the moment is what decides them: a certificate outside its window is a failure when a timestamp proves the seal was made outside it, and merely unproven otherwise. Certificates expire, sealed passports outlive them by years, and an expired certificate today says nothing about a seal made while it was good. + required: + - validity + - judgedAt + - revocation + properties: + validity: + $ref: '#/components/schemas/ValidityWindow' + judgedAt: + $ref: '#/components/schemas/JudgedTime' + revocation: + $ref: '#/components/schemas/RevocationStanding' + SealOrigin: + type: object + description: |- + What a stored seal's own certificate says about who issued it — read out of the seal's bytes, never from this node's configuration. The two are different facts: configuration records what the operator intended, and a seal restored from a backup or made before a backend change was not produced by whatever is configured now. + + **Not a qualification verdict.** Establishing that a seal is qualified needs the issuer matched against an EU Trusted List *and* the issuer's signature over this certificate verified; neither is done to produce this. What it does answer completely, and without a network, is whether anybody issued the certificate at all. + required: + - subject + - issuer + - selfIssued + - creationDevice + properties: + subject: + type: string + description: The certificate's subject distinguished name, RFC 4514. + issuer: + type: string + description: The certificate's issuer distinguished name, RFC 4514. + selfIssued: + type: boolean + description: 'Whether issuer and subject are the same name — the structural test for "nobody issued this to us", and a property of the certificate that no environment variable can change. True means the seal attests that a key this node holds signed a digest and **nothing else**: it carries no legal weight, and no Trusted List would give it any.' + creationDevice: + type: string + enum: + - declaresQualifiedDevice + - noQualifiedDevice + - notAQualifiedCertificate + description: |- + What the certificate declares about the device holding its private key — a declaration, never a verification. + + `declaresQualifiedDevice`: the certificate carries the Annex III(j) indication that Regulation (EU) No 910/2014 Art. 32(1)(f), reached for seals through Art. 40, requires. + + `noQualifiedDevice`: it carries qualified-certificate statements but not that one. Lawful, and enough for Art. 40a — validation of an *advanced* seal based on a qualified certificate, which omits the device leg — but not for the Art. 32/40 pair. + + `notAQualifiedCertificate`: it carries no qualified-certificate statements at all, so it is not presenting itself as a qualified certificate in the first place. A self-signed development certificate lands here. + SealAuditReport: + type: object + description: |- + What the last completed pass over **every stored seal** found. + + The counts beside this describe outbox rows and passports carrying *no* seal. This describes seals that exist and do not stand up — a condition neither can see, because both ask the database whether the seal member is absent, and a worthless seal is present. + + Whether a stored seal stands up is cryptographic rather than relational: open the CAdES, verify the signature, read the digest it covers. So it is found by a background pass that walks the estate in bounded batches and starts over, not by a query. + required: + - completedAt + - checked + - sound + - superseded + - broken + - certificateFailed + - unreadable + - brokenPassports + - truncated + properties: + completedAt: + type: string + format: date-time + description: When the pass finished. The age of this is the age of every number below it — a pass takes as long as the estate divided by the audit's throughput, so on a large deployment these are hours old by construction. Fine for a condition that does not appear suddenly, and stated rather than implied. + checked: + type: integer + description: Seals opened. + sound: + type: integer + description: |- + Seals covering their passport's current signature. + + In EN 319 102-1's terms this is `indeterminate`, **not** `totalPassed`: nothing about these seals has failed, and their certificates have not been validated by this node. See `validation` on `GET /api/v1/dpp/{dppId}/seal`. + superseded: + type: integer + description: |- + Seals over a different digest. Ordinarily a passport re-published after sealing, which is **not** a defect — the seal remains a valid attestation of the signature it covers, and `GET /api/v1/dpp/{dppId}/seal` reports it per passport as `coverage`. + + `totalFailed` / `hashFailure` when the question asked is about the passport's *current* signature, which is the question this walk asks. + broken: + type: integer + description: |- + Seals whose own signature does not verify. **The finding.** Those passports are published and, in substance, unsealed — and invisible to `unsealedPublished`, which asks only whether a seal is present. + + `totalFailed` / `sigCryptoFailure`, and EN 319 102-1 makes that verdict stable: no additional validation data can lift it. That is what makes a replacement seal worth buying for these and for nothing else here — `POST /api/v1/dpp/{dppId}/seal/repair`. + certificateFailed: + type: integer + description: |- + Seals whose signature is sound and whose **certificate** was not, at the moment they were made — revoked before sealing, or outside its validity window, with an attested time to prove the order. `totalFailed` with a certificate sub-indication. + + Counted apart from `broken` because the two need opposite responses. A broken seal is worth replacing; a seal made under a revoked certificate would only be replaced by another from the same certificate, so `POST /api/v1/dpp/{dppId}/seal/repair` refuses it and says why. + + Where nothing attests when the seal was made, the same observation is *indeterminate* rather than a failure and the seal stays in `sound`: a certificate that has expired since is the ordinary state of an old seal. + unreadable: + type: integer + description: |- + Seals this node could not read — a placeholder, or a format it does not parse. **Not a finding.** Treating "cannot check" as "broken" would make every seal from a backend emitting an unparsed format look like corruption. + + `indeterminate` with a custom diagnostic: a limit of the reader, not a defect in the seal. + brokenPassports: + type: array + items: + type: string + format: uuid + description: The passports carrying a broken seal, so an operator can act rather than search a log. Capped — see `truncated`. A node with thousands of broken seals has one problem, not thousands; `broken` states its size, and a list long enough to prove that is a list nobody reads. + truncated: + type: boolean + description: True when `brokenPassports` was cut short. Stated rather than left to be inferred from the length matching the cap, which is an inference that is right until the cap changes. AmendRequest: type: object description: | diff --git a/api/openapi.yaml b/api/openapi.yaml index 663498b3..0052b2b2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -178,6 +178,8 @@ components: $ref: components/schemas/seals/SealDeclarer.yaml SealCoverage: $ref: components/schemas/seals/SealCoverage.yaml + SealRepairResponse: + $ref: components/schemas/seals/SealRepairResponse.yaml EvidenceDossier: $ref: components/schemas/evidence/EvidenceDossier.yaml DossierManifest: @@ -343,6 +345,8 @@ paths: $ref: paths/vault/vault_api_v1_dpp_{dppId}_verify-tree.yaml /vault/api/v1/dpp/{dppId}/seal: $ref: paths/vault/vault_api_v1_dpp_{dppId}_seal.yaml + /vault/api/v1/dpp/{dppId}/seal/repair: + $ref: paths/vault/vault_api_v1_dpp_{dppId}_seal_repair.yaml /vault/api/v1/seal: $ref: paths/vault/vault_api_v1_seal.yaml /vault/api/v1/dpp/{dppId}/publish: diff --git a/api/paths/vault/vault_api_v1_dpp_{dppId}_seal_repair.yaml b/api/paths/vault/vault_api_v1_dpp_{dppId}_seal_repair.yaml new file mode 100644 index 00000000..60544651 --- /dev/null +++ b/api/paths/vault/vault_api_v1_dpp_{dppId}_seal_repair.yaml @@ -0,0 +1,75 @@ +post: + operationId: repairDppSeal + summary: Re-seal a passport whose stored seal does not verify + description: | + Queues a replacement seal for a passport whose stored seal is broken, and + refuses unless it demonstrably is. + + **This buys a second seal for a digest already paid for.** The node's own + repair sweep deliberately cannot do that — it only queues passports + carrying *no seal at all*, which is what lets it run unattended without + spending money twice. A broken seal is the case where spending it again is + right: the row was paid for and carries nothing, so the passport is + published and, in substance, unsealed. That is a decision for whoever pays, + taken per passport, which is why it is a route rather than a background + loop. + + **The seal is opened and checked at the moment of the request**, never read + from the audit's list. A stale finding would buy a seal for a passport that + has since been repaired or re-published. + + Not idempotency-keyed, and does not need to be: a seal row is keyed by + `(passportId, payloadHash)`, so a retried request re-arms a row that is + already queued, which is a no-op. A second repair after the replacement + lands is refused, because the new seal verifies. + + The replacement covers the passport's **current** signature. Where the + passport was re-published since the broken seal was made, that signature has + never been sealed, so nothing is re-bought and `action` is `queued` rather + than `rearmed`. + tags: + - DPP Management + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: ../../components/schemas/passport/DppId.yaml + responses: + '200': + description: A replacement seal has been queued. + content: + application/json: + schema: + $ref: ../../components/schemas/seals/SealRepairResponse.yaml + '401': + $ref: ../../components/responses/Unauthorized.yaml + '403': + $ref: ../../components/responses/Forbidden.yaml + '404': + description: | + No passport with that id. Distinct from `422`, which means the passport + exists and is not in a state this repairs. + content: + application/problem+json: + schema: + $ref: ../../components/schemas/errors/Problem.yaml + '422': + description: | + The passport exists and there is nothing here to repair. The detail says + which: the seal verifies; it is intact but covers a superseded signature, + whose replacement is already queued by the re-publish that caused it; + there is no seal at all, which the node's own sweep covers at no extra + cost; the seal could not be read, so it cannot be shown to be broken; + this node has no sealing backend, so a queued repair would never drain; + or the passport carries a seal and no signature, which should not occur + and for which a replacement would cover nothing. + content: + application/problem+json: + schema: + $ref: ../../components/schemas/errors/Problem.yaml + '400': + $ref: ../../components/responses/BadRequest.yaml diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index f7ee6d5b..8890162b 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -865,4 +865,15 @@ pub enum SealCommands { #[arg(long)] json: bool, }, + /// Queue a replacement seal for a passport whose stored seal does not + /// verify. Costs a seal: the node re-arms a row it already paid for, which + /// is justified only because what it holds is worthless. Refused unless the + /// seal is demonstrably broken, checked at the moment you ask + Repair { + /// Passport ID + id: String, + /// Output the raw route response instead of a summary + #[arg(long)] + json: bool, + }, } diff --git a/cli/src/commands/seal.rs b/cli/src/commands/seal.rs index 4d99a701..2baabd25 100644 --- a/cli/src/commands/seal.rs +++ b/cli/src/commands/seal.rs @@ -1,11 +1,14 @@ //! `odal seal status [id]` — what the node knows about qualified sealing, -//! operator-wide or for one passport. +//! operator-wide or for one passport — and `odal seal repair `, the one +//! command here that spends. use anyhow::Result; use crate::{ - core::seal::{SealStatus, action_seal_status, action_seal_summary}, - stateless::render::{render_seal_absent, render_seal_status, render_seal_summary}, + core::seal::{SealStatus, action_seal_repair, action_seal_status, action_seal_summary}, + stateless::render::{ + render_seal_absent, render_seal_repair, render_seal_status, render_seal_summary, + }, }; pub async fn run_seal_status(id: Option<&str>, json: bool) -> Result<()> { @@ -39,3 +42,24 @@ pub async fn run_seal_status(id: Option<&str>, json: bool) -> Result<()> { } Ok(()) } + +/// `odal seal repair ` — queue a replacement for a seal that does not verify. +/// +/// The node decides whether this is allowed, not the CLI: it opens the seal at +/// the moment of the request and refuses unless it is demonstrably broken. So +/// there is nothing to confirm here that the node does not already guard, and +/// nothing to pre-check that would not be stale by the time it was acted on. +/// +/// A refusal is an error rather than a printed verdict, because the operator +/// asked for something and it did not happen — and the node's own detail says +/// which of the several "nothing to repair" states this is. +pub async fn run_seal_repair(id: &str, json: bool) -> Result<()> { + let (client, cfg) = crate::http::load_client()?; + let repair = action_seal_repair(id, &client, &cfg).await?; + if json { + println!("{}", serde_json::to_string_pretty(&repair)?); + } else { + render_seal_repair(&repair, id); + } + Ok(()) +} diff --git a/cli/src/core/seal.rs b/cli/src/core/seal.rs index 95de67ac..0b029b66 100644 --- a/cli/src/core/seal.rs +++ b/cli/src/core/seal.rs @@ -1,8 +1,15 @@ //! Qualified-seal inspection via the node API. Pure HTTP — no direct DB access. //! -//! Read-only by design. Sealing is driven by the publish outbox and its drain, -//! so there is no "seal this now" here: a command that bought a seal out of band -//! would spend money on a third-party call outside the record the drain keeps. +//! Sealing is driven by the publish outbox and its drain, so there is no "seal +//! this now" here: a command that bought a seal out of band would spend money on +//! a third-party call outside the record the drain keeps. +//! +//! [`action_seal_repair`] is not that, and the difference is the whole reason it +//! is allowed to exist: it **queues** a replacement through the same outbox, so +//! the drain still owns the purchase, the retry and the record. What is unusual +//! about it is that the row was paid for once already — which is why the node +//! refuses unless the seal it holds is demonstrably broken, and why that check +//! stays on the node rather than being anticipated here. use anyhow::{Context, Result, bail}; use reqwest::StatusCode; @@ -91,6 +98,33 @@ pub async fn action_seal_status(id: &str, client: &OdalClient, cfg: &Config) -> } } +/// `POST /api/v1/dpp/{id}/seal/repair` — queue a replacement for a broken seal. +/// +/// Every non-success is an error, including the `422` that means "there is +/// nothing here to repair". That reads oddly beside [`action_seal_status`], +/// which turns a `404` into a first-class `Absent` — but the two situations are +/// opposite. There, the operator asked a question and "no seal" is an answer. +/// Here they asked for an action, and the node declining to spend is not a +/// quieter kind of success. +/// +/// The node's `detail` says which refusal it was, and those are worth reading: +/// a sound seal, one intact over a superseded signature, one this node cannot +/// read, no seal at all, or no sealing backend to drain a repair. +/// +/// Requires an admin credential. +pub async fn action_seal_repair(id: &str, client: &OdalClient, cfg: &Config) -> Result { + let (status, body) = client + .post_json( + &format!("{}/api/v1/dpp/{id}/seal/repair", cfg.vault_url), + &serde_json::json!({}), + ) + .await?; + if !status.is_success() { + bail!("Repair refused: {}", describe_error(status, &body)); + } + serde_json::from_str(&body).context("repair response was not JSON") +} + /// `GET /api/v1/seal` — operator-wide sealing state. pub async fn action_seal_summary(client: &OdalClient, cfg: &Config) -> Result { let (status, body) = client diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index 09a1d456..a24c95f0 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -39,7 +39,7 @@ use crate::commands::{ registry::{run_facility_audit, run_operator_id_audit, run_registry}, ruleset::run_ruleset_reload, schema::run_schema, - seal::run_seal_status, + seal::{run_seal_repair, run_seal_status}, stats::{run_operator_stats, run_passport_stats}, status::run_status, transfer::{run_transfer_initiate, run_transfer_resolve}, @@ -439,6 +439,9 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { Commands::Seal { command: SealCommands::Status { id, json }, } => run_seal_status(id.as_deref(), json).await, + Commands::Seal { + command: SealCommands::Repair { id, json }, + } => run_seal_repair(&id, json).await, Commands::Stats { days, json } => run_operator_stats(days, json).await, } } diff --git a/cli/src/stateless/render.rs b/cli/src/stateless/render.rs index ba288b45..ce322307 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -249,13 +249,15 @@ pub fn render_passport_list(page: &PassportPage) { ); println!("{}", "─".repeat(86)); for r in &page.rows { + // A forged row is the worst case in a table: the columns line up, so a + // fabricated line is indistinguishable from a real passport. println!( "{:<10} {:<32} {:<9} {:<18} {}", - r.status, - truncate(&r.product_name, 32), - r.product_group, - r.batch.as_deref().unwrap_or("—"), - r.updated + plain(&r.status), + plain(&truncate(&r.product_name, 32)), + plain(&r.product_group), + plain(r.batch.as_deref().unwrap_or("—")), + plain(&r.updated) ); } print!("\n{} shown", page.rows.len()); @@ -269,7 +271,14 @@ pub fn render_passport_list(page: &PassportPage) { /// browser's "View details"). Shows the full ID and QR link. pub fn render_passport_details(doc: &serde_json::Value) { let s = |k: &str| doc.get(k).and_then(|v| v.as_str()); - let line = |label: &str, val: &str| println!(" {:<14}{}", format!("{label}:"), val); + // Sanitised here rather than at each call site: every value below is a + // product document's own text — a manufacturer name, a batch reference, a + // facility label — which arrives from an import, an API caller or a + // supply-chain peer. Guarding the one place they are all printed means a + // field added later cannot miss it. + let line = |label: &str, val: &str| { + println!(" {:<14}{}", format!("{label}:"), plain(val)); + }; line("Product", s("productName").unwrap_or("—")); line("Status", s("status").unwrap_or("—")); @@ -420,7 +429,14 @@ pub fn render_history(entries: &[PassportAuditEntry], id: &str) { } println!("{:<26} {:<12} ACTOR", "TIMESTAMP", "ACTION"); for e in entries { - println!("{:<26} {:<12} {}", e.timestamp, e.action, e.actor); + // `actor` is whatever authenticated — a user id this node was handed, + // not one it chose. + println!( + "{:<26} {:<12} {}", + e.timestamp, + plain(&e.action), + plain(&e.actor) + ); } } @@ -429,6 +445,20 @@ fn stat_i64(v: &serde_json::Value, key: &str) -> i64 { v.get(key).and_then(serde_json::Value::as_i64).unwrap_or(0) } +/// Read a string field from a node response, sanitised. +/// +/// The safe path made the **short** path, which is the only version of this +/// rule that survives the next person adding a field. Reading with +/// `get(..).as_str()` and printing the result is one line shorter than reading +/// it and remembering [`plain`]; making the sanitised form the convenient one +/// removes the choice rather than documenting it. +fn field(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(serde_json::Value::as_str) + .map(plain) +} + /// Strip control characters from a node-supplied string before it is printed. /// /// `lastIngestAt` below is echoed straight from the node's response into a @@ -442,9 +472,11 @@ fn stat_i64(v: &serde_json::Value, key: &str) -> i64 { /// timestamp byte-identical and a tampered one visibly wrong, which is the /// right outcome for a field whose only job is to be read at a glance. /// -/// Scope: this guards the field this module added. The passport-document -/// strings rendered further up have the same shape and predate it — widening -/// the fix is its own change, not one to smuggle in here. +/// Scope: **every** node-supplied string this module prints, now. It began as a +/// guard on one field, with the passport-document strings left for their own +/// change — this is that change. Where a renderer reads or prints through a +/// shared helper, the helper sanitises, so a field added later inherits it +/// instead of needing to remember. fn plain(s: &str) -> String { s.chars().filter(|c| !c.is_control()).collect() } @@ -644,12 +676,7 @@ pub fn render_schema_check(result: &SchemaCheckResult) { /// passport was re-published after sealing and the seal still covers the /// signature it was bought for. pub fn render_seal_status(seal: &serde_json::Value, id: &str) { - let s = |key: &str| { - seal.get(key) - .and_then(serde_json::Value::as_str) - .unwrap_or("-") - .to_owned() - }; + let s = |key: &str| field(seal, key).unwrap_or_else(|| "-".to_owned()); let placeholder = seal .get("placeholder") .and_then(serde_json::Value::as_bool) @@ -665,17 +692,248 @@ pub fn render_seal_status(seal: &serde_json::Value, id: &str) { } println!(" Format : {}", s("format")); - println!(" Sealed at : {}", s("sealedAt")); + + // Both levels, and loudly when they differ. A seal below the level it was + // bought at is correct in every record this node keeps and stops verifying + // when its certificate expires — on a passport that cannot be re-sealed. + let asked = s("conformanceLevel"); + let evidenced = s("evidencedLevel"); + if evidenced == "-" { + println!(" Level : {asked} requested; the bytes could not be read"); + } else if asked == "-" || asked == evidenced { + println!(" Level : {evidenced}"); + } else { + println!( + " Level : {} requested {asked}, the bytes carry {evidenced}", + style("DOWNGRADED").red().bold() + ); + println!(" this seal carries less validation material than was paid for"); + } + + println!( + " Sealed at : {} (this node's clock, unattested)", + s("sealedAt") + ); + // A third party's statement of when, as opposed to ours. Absent below B-T, + // and absent for a token that failed its checks — which is not the same as + // a seal made at an unknown time, but is the same answer: we cannot say. + match field(seal, "attestedSealedAt") { + Some(at) => { + println!(" Attested at : {at} by the timestamp inside the seal"); + } + None => println!(" Attested at : none — no timestamp token this node could check"), + } // The certificate the seal names as its signer — which certificate to ask // about, not whether it was qualified. Absent for seals made before the // extraction landed, or when the CAdES could not be parsed. + match field(seal, "signingCertRef") { + Some(cert) => println!(" Signing cert : {cert}"), + None => println!(" Signing cert : not recorded (predates extraction, or unparseable)"), + } + + // Who issued the certificate — the question an operator asks first, and the + // one that used to be answerable only by reading the node's configuration. + match seal.get("origin") { + Some(origin) if !origin.is_null() => { + let self_issued = origin + .get("selfIssued") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + // Sanitised, and this is the sharpest case in the file: the issuer + // is a distinguished name read out of a certificate **inside a + // seal**. A provider's certificate, or a seal handed to this node, + // is not something this node chose the bytes of — so a name + // carrying ANSI escapes or newlines could repaint the lines around + // it and forge output under the CLI's own labels. + let issuer = field(origin, "issuer").unwrap_or_else(|| "-".to_owned()); + if self_issued { + println!( + " Issued by : {} {issuer}", + style("SELF-SIGNED").yellow().bold() + ); + println!( + " nobody issued this certificate — it attests that a key \ + this node holds" + ); + println!(" signed a digest, and carries no legal weight"); + } else { + println!(" Issued by : {issuer}"); + } + let device = match origin + .get("creationDevice") + .and_then(serde_json::Value::as_str) + { + Some("declaresQualifiedDevice") => { + "declares a qualified creation device (Annex III(j))" + } + Some("noQualifiedDevice") => { + "a qualified certificate, but its key is not in a qualified device" + } + Some("notAQualifiedCertificate") => { + "not presenting as a qualified certificate at all" + } + _ => "-", + }; + println!(" Key device : {device}"); + } + _ => println!( + " Issued by : not read (placeholder, unparsed format, or unreadable bytes)" + ), + } + + // Whether the seal's own bytes cover this passport. Distinct from Coverage + // below, which answers from this node's records. + let binding = seal.get("binding"); + let result = binding + .and_then(|b| b.get("result")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + match result { + "coversThisSignature" => { + println!( + " Binding : {} the seal's own signed attributes cover this passport's", + style("PROVEN").green().bold() + ); + println!(" current signature, and the signature over them verifies"); + } + "coversAnotherDigest" => { + let covered = binding + .and_then(|b| field(b, "covered")) + .unwrap_or_else(|| "-".to_owned()); + println!( + " Binding : {} the seal covers {covered},", + style("OTHER DIGEST").yellow().bold() + ); + println!(" not this passport's current signature"); + } + "notIntact" => { + println!( + " Binding : {} the signature over the seal's attributes does not", + style("BROKEN").red().bold() + ); + println!(" verify, so nothing it says about what it covers holds"); + } + _ => println!(" Binding : not read — no digest recoverable from the envelope"), + } + + // Whether the certificate itself stood up when the seal was made — the + // second limb of Art. 32(1)(b), and the question a reader asks immediately + // after "does the seal cover this passport". + if let Some(cert) = seal.get("certificate").filter(|c| !c.is_null()) { + let attested = cert + .get("judgedAt") + .and_then(|j| j.get("attested")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + // The qualifier is the whole point: the same observation is a finding + // against a proven sealing time and an open question against a guess. + let basis = if attested { + "at the attested sealing time" + } else { + "as of now — nothing attests when this seal was made" + }; + match cert + .get("validity") + .and_then(|v| v.get("standing")) + .and_then(serde_json::Value::as_str) + { + Some("inside") => println!(" Certificate : within its validity window {basis}"), + Some("expired") => println!( + " Certificate : {} outside its validity window {basis}", + style("EXPIRED").red().bold() + ), + Some("notYetValid") => println!( + " Certificate : {} not yet valid {basis}", + style("NOT YET VALID").red().bold() + ), + _ => {} + } + match cert + .get("revocation") + .and_then(|r| r.get("status")) + .and_then(serde_json::Value::as_str) + { + Some("revoked") => println!( + " Revocation : {} the issuer's own list names this certificate", + style("REVOKED").red().bold() + ), + Some("notRevoked") => { + let as_of = cert + .get("revocation") + .and_then(|r| field(r, "asOf")) + .unwrap_or_else(|| "-".to_owned()); + println!(" Revocation : not listed, as of {as_of}"); + } + // Said plainly rather than left blank: "no CRL travelled with this + // seal" and "the certificate is fine" are different facts. + Some("notAvailable") => { + println!(" Revocation : not asked — the seal carries no revocation material") + } + Some("unusable") => { + let reason = cert + .get("revocation") + .and_then(|r| field(r, "reason")) + .unwrap_or_default(); + println!(" Revocation : {} {reason}", style("UNUSABLE").yellow()); + } + _ => {} + } + } + + // The same reading in the standard's words, because that is the vocabulary + // an auditor's own tooling reports in — and because `PROVEN` above is the + // line most likely to be read as "validated", which it is not. + if let Some(validation) = seal.get("validation") { + let indication = field(validation, "indication").unwrap_or_default(); + let sub = field(validation, "subIndication"); + match (indication.as_str(), sub.as_deref()) { + ("totalFailed", Some(sub)) => { + println!(" EN 319 102-1 : TOTAL-FAILED / {}", sub.to_uppercase()); + } + ("totalFailed", None) => println!(" EN 319 102-1 : TOTAL-FAILED"), + ("indeterminate", Some(sub)) => { + println!(" EN 319 102-1 : INDETERMINATE / {}", sub.to_uppercase()); + } + ("indeterminate", None) => { + println!(" EN 319 102-1 : INDETERMINATE — nothing has failed, and the chain"); + println!(" is not validated to a trust anchor, so nothing has"); + println!(" passed either"); + } + _ => {} + } + } + + // Whether the long-term protection is still live. Silent for a seal that + // was never archived: `B-LT` was not promised it, and a line about renewal + // would imply an obligation nobody took on. match seal - .get("signingCertRef") + .get("archival") + .and_then(|a| a.get("state")) .and_then(serde_json::Value::as_str) { - Some(cert) => println!(" Signing cert : {cert}"), - None => println!(" Signing cert : not recorded (predates extraction, or unparseable)"), + Some("current") => { + let by = seal + .get("archival") + .and_then(|a| field(a, "expires")) + .unwrap_or_else(|| "-".to_owned()); + println!(" Archival : live until {by} — re-timestamp before then"); + } + Some("lapsed") => { + let at = seal + .get("archival") + .and_then(|a| field(a, "expires")) + .unwrap_or_else(|| "-".to_owned()); + println!( + " Archival : {} expired {at}; the long-term protection is gone", + style("LAPSED").red().bold() + ); + println!(" the level still reads LTA, which is how this goes unseen"); + } + Some("unknown") => { + println!(" Archival : present but unreadable — treated as not fresh") + } + _ => {} } let coverage = s("coverage"); @@ -713,6 +971,35 @@ pub fn render_seal_absent(id: &str) { println!(" Sealing runs off a drain after publish — it is not part of the publish call."); } +/// Render what a repair queued. +/// +/// Says that a seal is being bought, because it is: this is the one command +/// here that spends. The node's own note travels with the response and is +/// printed rather than paraphrased — it is written where the rule lives. +pub fn render_seal_repair(repair: &serde_json::Value, id: &str) { + let action = field(repair, "action").unwrap_or_else(|| "queued".to_owned()); + println!("Repair queued for {}", plain(id)); + match action.as_str() { + // The distinction the response draws, and the one an operator paying + // for seals cares about. + "rearmed" => println!( + " {} the broken seal's row was re-armed — a replacement will be bought", + style("REARMED").yellow().bold() + ), + _ => println!( + " {} this signature had never been sealed, so nothing is bought twice", + style("QUEUED").green().bold() + ), + } + if let Some(hash) = field(repair, "payloadHash") { + println!(" Covers: {hash}"); + } + if let Some(note) = field(repair, "note") { + println!(" {note}"); + } + println!(" The node's drain buys it; watch `odal seal status {id}`."); +} + /// Render the operator-wide sealing summary. /// /// Leads with the passport count, not the row counts. An operator asking about @@ -737,6 +1024,99 @@ pub fn render_seal_summary(summary: &serde_json::Value) { return; } + match summary.get("trustMode").and_then(serde_json::Value::as_str) { + Some("live") => println!("Sealing tier: {}", style("live").green()), + Some("sandbox") => println!( + "Sealing tier: {} a real provider, on its test certificate", + style("sandbox").yellow() + ), + Some("ghost") => println!( + "Sealing tier: {} these seals carry no legal weight whatsoever", + style("ghost").red().bold() + ), + // Not the same as `ghost`: a port nobody resolved versus one that landed + // on a placeholder. Only the second blocks a production boot. + _ => println!("Sealing tier: not reported by this node"), + } + + // What the last completed pass over every stored seal found. Absent is not + // zero: a pass walks the estate over several minutes and starts over, so a + // node that has just restarted has genuinely not looked yet. + match summary.get("audit").filter(|a| !a.is_null()) { + None => { + println!("Stored seals: not audited yet — no pass over them has completed"); + println!(" this is not the same as 'none broken': a pass walks the"); + println!(" whole estate, and until one finishes nothing has looked"); + } + Some(audit) => { + let a = |k: &str| { + audit + .get(k) + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + }; + let at = field(audit, "completedAt").unwrap_or_else(|| "-".to_owned()); + // Reported on its own line rather than folded into the broken count: + // the two need different responses, and repair refuses this one. + if a("certificateFailed") > 0 { + println!( + "Stored seals: {} {} were made under a certificate that was not valid", + style("CERTIFICATE").red().bold(), + a("certificateFailed") + ); + println!( + " at the time — re-sealing does not help, since the replacement" + ); + println!(" would come from the same certificate"); + } + if a("broken") == 0 { + // "all signatures verify", not "all verify": the certificate + // line above may have just reported that some of them were made + // under a certificate that was not valid. Those seals verify and + // are worth nothing, and one line must not unsay the other. + println!( + "Stored seals: {} checked, all signatures verify (as of {at})", + a("checked") + ); + } else { + println!( + "Stored seals: {} {} of {} do not verify (as of {at})", + style("BROKEN").red().bold(), + a("broken"), + a("checked") + ); + println!( + " those passports are published and, in substance, unsealed —" + ); + println!( + " and invisible to the count below, which asks only whether a" + ); + println!(" seal is present"); + for id in audit + .get("brokenPassports") + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + { + if let Some(id) = id.as_str() { + println!(" {}", plain(id)); + } + } + if audit + .get("truncated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + println!(" … list truncated; see `broken` for the total"); + } + // The finding is only useful beside what to do about it, and the + // route re-checks before acting — so a name from a report that + // has since been repaired costs nothing but a refusal. + println!(" repair one with: odal seal repair "); + } + } + } + let unsealed = n("unsealedPublished"); println!("Sealing"); if unsealed == 0 { @@ -788,6 +1168,55 @@ mod node_supplied_text { assert_eq!(out, "2026-09-10[2J[H"); } + /// A distinguished name is the sharpest case, and it is now rendered. + /// + /// `render_seal_status` prints the issuer read out of a certificate **inside + /// a seal**. That is the least node-chosen string this module displays — a + /// provider's certificate, or a seal handed to this node from elsewhere — so + /// it is exactly the field an attacker would put escapes in. + #[test] + fn a_distinguished_name_carrying_escapes_is_defanged() { + let out = plain("CN=Acme\u{1b}[2K\rO=Qualified CA"); + assert!(!out.contains('\u{1b}'), "escape survived: {out:?}"); + assert!(!out.contains('\r'), "carriage return survived: {out:?}"); + assert!( + out.contains("CN=Acme"), + "the readable part must survive: {out:?}" + ); + } + + /// The table renderers are the worst case, so the sweep reached them. + /// + /// A forged row in `odal list` lines up with the real ones: the columns are + /// padded, so a fabricated passport is indistinguishable from a genuine one + /// at a glance. That is a different failure from a mangled field, and it is + /// why this went past the field that started it. + #[test] + fn a_product_name_cannot_forge_a_table_row() { + let forged = plain("Widget\nfake-row Totally Real Product"); + assert!( + !forged.contains('\n'), + "a newline would open a second line under the table's own columns: {forged:?}" + ); + assert!( + forged.contains("Widget"), + "the readable part survives: {forged:?}" + ); + } + + /// The reading helper sanitises, so a field added later inherits it. + /// + /// This is the part meant to outlast the sweep. Auditing every call site + /// once fixes today; making the short way the safe way is what stops the + /// next field being added unguarded. + #[test] + fn reading_a_field_sanitises_it() { + let v = serde_json::json!({ "issuer": "CN=Acme\u{1b}[2Kfake" }); + let read = super::field(&v, "issuer").expect("present"); + assert!(!read.contains('\u{1b}'), "escape survived: {read:?}"); + assert_eq!(super::field(&v, "absent"), None); + } + /// A newline in the field would let a node forge extra CLI output lines /// under this command's own labels. #[test] diff --git a/crates/dpp-dal/src/pg/mod.rs b/crates/dpp-dal/src/pg/mod.rs index 2dcf1ec9..bfb0bba9 100644 --- a/crates/dpp-dal/src/pg/mod.rs +++ b/crates/dpp-dal/src/pg/mod.rs @@ -38,7 +38,7 @@ pub use repo_registry_identity::PgRegistryIdentityRepo; pub use repo_registry_sync::PgRegistrySyncRepo; pub use repo_registry_transfer::PgRegistryTransferRepo; pub use repo_scan::PgScanTelemetryRepo; -pub use repo_seal::PgSealOutboxRepo; +pub use repo_seal::{PgSealAuditRepo, PgSealOutboxRepo}; pub use repo_snapshot::PgSnapshotOutboxRepo; pub use repo_transfer::PgTransferRepo; pub use repo_unsold_goods::PgUnsoldGoodsRepo; diff --git a/crates/dpp-dal/src/pg/repo_seal.rs b/crates/dpp-dal/src/pg/repo_seal.rs index 3c11bb32..cc6859ca 100644 --- a/crates/dpp-dal/src/pg/repo_seal.rs +++ b/crates/dpp-dal/src/pg/repo_seal.rs @@ -16,11 +16,15 @@ //! already produced and already billed, and the next pass would buy it again. use async_trait::async_trait; +use chrono::{DateTime, Utc}; use sqlx::Row; use uuid::Uuid; use dpp_domain::{DppError, passport::PassportId, seal::SealedEnvelope}; -use dpp_types::{SealOutbox, SealOutboxCounts, SealRow}; +use dpp_types::{ + SealAuditProgress, SealAuditReport, SealAuditStore, SealOutbox, SealOutboxCounts, SealRow, + SealedPassport, +}; use super::{PgDal, db_err, require_updated}; @@ -302,4 +306,195 @@ impl SealOutbox for PgSealOutboxRepo { .map_err(db_err)?; Ok(row.get::("unsealed")) } + + async fn rearm_sealed( + &self, + passport_id: PassportId, + payload_hash: &str, + reason: &str, + ) -> Result { + // The mirror of `enqueue`'s clause: that one re-arms `exhausted` and + // refuses everything else; this one re-arms `sealed` and refuses + // everything else. Neither touches `pending` — a row the drain already + // owns must not have its backoff reset underneath it. + // + // The reason is written onto the row rather than only logged, because + // this is the one place a seal is bought twice for the same digest and + // the record of why should sit beside the row that spent the money. + let result = sqlx::query( + r#"UPDATE odal.seal_outbox + SET status = 'pending', + attempts = 0, + next_attempt_at = now(), + message = $3, + updated_at = now() + WHERE passport_id = $1 + AND payload_hash = $2 + AND status = 'sealed'"#, + ) + .bind(passport_id.0) + .bind(payload_hash) + .bind(reason) + .execute(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(result.rows_affected() > 0) + } + + async fn sealed_passports( + &self, + limit: i64, + after: Option, + sealed_before: Option>, + ) -> Result, DppError> { + // The mirror image of the count above: passports that DO carry a seal. + // Whether that seal stands up is not a question SQL can ask, so the rows + // go back to a caller that can open a CAdES. + // + // `jwsSignature` comes back so the digest can be derived with + // `digest_for_jws` — the same single definition the enqueue path uses. + // Deriving it in SQL would be a second implementation of what a seal + // covers, which is the drift that buys seals over digests nothing else + // recognises. + // The `sealed_before` bound is expressed against `seal_outbox.sealed_at` + // — a real timestamptz column — rather than against the `sealedAt` + // inside the envelope. Casting a JSON string to timestamptz would put a + // malformed value one row carries in charge of whether the audit runs at + // all, and there is no safe cast to fall back on. + // + // The clause is a NOT EXISTS for the same reason it is not a join: a + // passport with no outbox row is **kept**. Not knowing when a seal was + // made is not a reason to stop looking at it. + let rows = sqlx::query( + r#"SELECT p.id, p.doc->'seal' AS seal, p.doc->>'jwsSignature' AS jws + FROM odal.passport p + WHERE p.published_at IS NOT NULL + AND p.doc->'seal' IS NOT NULL + AND p.doc->>'jwsSignature' IS NOT NULL + AND ($1::uuid IS NULL OR p.id > $1::uuid) + AND ($3::timestamptz IS NULL OR NOT EXISTS ( + SELECT 1 FROM odal.seal_outbox s + WHERE s.passport_id = p.id + AND s.status = 'sealed' + AND s.sealed_at >= $3::timestamptz + )) + ORDER BY p.id + LIMIT $2"#, + ) + .bind(after.map(|p| p.0)) + .bind(limit) + .bind(sealed_before) + .fetch_all(self.dal.pool()) + .await + .map_err(db_err)?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let id: uuid::Uuid = row.get("id"); + let jws: String = row.get("jws"); + let seal: serde_json::Value = row.get("seal"); + // A seal member that will not deserialise is skipped rather than + // failing the batch: one unreadable row must not stop the rest of + // the audit, and a row nothing can read is reported by its absence + // from the walk rather than by aborting it. + let Ok(seal) = serde_json::from_value::(seal) else { + tracing::warn!(passport_id = %id, "stored seal member will not deserialise"); + continue; + }; + out.push(SealedPassport { + passport_id: PassportId(id), + payload_hash: dpp_types::digest_for_jws(&jws), + seal, + }); + } + Ok(out) + } +} + +/// PostgreSQL implementation of [`SealAuditStore`] (`ops/pg/0038`). +/// +/// One row, overwritten in place. It carries a cache of derived data, and that +/// shapes every decision here: a value that will not deserialise is treated as +/// absent rather than as an error, because the alternative is a node that +/// refuses to audit its seals on account of a stale row it could simply +/// recompute. +pub struct PgSealAuditRepo { + dal: PgDal, +} + +impl PgSealAuditRepo { + /// Construct a repo sharing the given pool handle. + pub fn new(dal: PgDal) -> Self { + Self { dal } + } +} + +#[async_trait] +impl SealAuditStore for PgSealAuditRepo { + async fn load(&self) -> Result<(Option, Option), DppError> { + let row = sqlx::query("SELECT progress, report FROM odal.seal_audit_state WHERE id = 1") + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + let Some(row) = row else { + return Ok((None, None)); + }; + + // Read each half independently. A progress written by an older shape + // must not cost the report beside it, which is the half an operator is + // actually looking at. + let progress = row + .get::, _>("progress") + .and_then(|v| match serde_json::from_value::(v) { + Ok(p) => Some(p), + Err(e) => { + tracing::warn!(error = %e, "stored seal-audit progress is unreadable; the walk restarts"); + None + } + }); + let report = row + .get::, _>("report") + .and_then(|v| match serde_json::from_value::(v) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!(error = %e, "stored seal-audit report is unreadable; reported as no completed pass"); + None + } + }); + Ok((progress, report)) + } + + async fn save_progress(&self, progress: &SealAuditProgress) -> Result<(), DppError> { + let value = serde_json::to_value(progress) + .map_err(|e| DppError::Serialisation(format!("seal audit progress: {e}")))?; + sqlx::query( + r#"INSERT INTO odal.seal_audit_state (id, progress) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET progress = $1, updated_at = now()"#, + ) + .bind(value) + .execute(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(()) + } + + async fn complete(&self, report: &SealAuditReport) -> Result<(), DppError> { + let value = serde_json::to_value(report) + .map_err(|e| DppError::Serialisation(format!("seal audit report: {e}")))?; + // The progress is cleared in the same statement that stores the report. + // Left behind, it would be a finished walk that a restart picks up and + // "continues" from its own end — one empty batch, then a second report + // claiming to have checked nothing. + sqlx::query( + r#"INSERT INTO odal.seal_audit_state (id, progress, report) + VALUES (1, NULL, $1) + ON CONFLICT (id) DO UPDATE SET progress = NULL, report = $1, updated_at = now()"#, + ) + .bind(value) + .execute(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(()) + } } diff --git a/crates/dpp-node/Cargo.toml b/crates/dpp-node/Cargo.toml index 2dff726f..bbad9207 100644 --- a/crates/dpp-node/Cargo.toml +++ b/crates/dpp-node/Cargo.toml @@ -92,6 +92,20 @@ tower = { version = "0.5", features = ["util"] } serial_test = "4" wat = "1" futures = "0.3" +# To corrupt a real seal's signature in the seal-audit test. Garbage bytes are +# classified `unknown` — correctly, since nothing could be checked — so a seal +# that *parses* and fails verification is the only way to exercise the `broken` +# arm, and building one means touching CMS. Already in the graph via `dpp-seal`. +# Raw SQL in the seal-repair test, to corrupt a stored seal where it lies. +# Going through the repo would write a well-formed envelope, which is the one +# thing that cannot produce the state under test. +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "postgres"] } +cms = { version = "0.2", default-features = false } +der = "0.7" +# To move a stored seal certificate's validity window into the past, which is the +# cheapest way to produce a seal that verifies and whose certificate did not. +# Already in the graph via `dpp-seal`. +x509-cert = "0.2" criterion = { workspace = true } uuid = { workspace = true } testcontainers = { workspace = true } diff --git a/crates/dpp-node/src/boot/db.rs b/crates/dpp-node/src/boot/db.rs index 4aec57d7..f326e20b 100644 --- a/crates/dpp-node/src/boot/db.rs +++ b/crates/dpp-node/src/boot/db.rs @@ -38,6 +38,7 @@ pub struct DbComponents { pub webhook_store: Arc, pub snapshot_outbox: Arc, pub seal_outbox: Arc, + pub seal_audit: Arc, pub scan_repo: Arc, pub unsold_goods_repo: Arc, pub job_store: Arc, @@ -112,6 +113,7 @@ pub async fn init_db(cfg: &NodeConfig) -> anyhow::Result { webhook_store: Arc::new(PgWebhookRepo::new(dal.clone())), snapshot_outbox: Arc::new(PgSnapshotOutboxRepo::new(dal.clone())), seal_outbox: Arc::new(PgSealOutboxRepo::new(dal.clone())), + seal_audit: Arc::new(dpp_dal::pg::PgSealAuditRepo::new(dal.clone())), scan_repo: Arc::new(PgScanTelemetryRepo::new(dal.clone())), unsold_goods_repo: Arc::new(dpp_dal::pg::PgUnsoldGoodsRepo::new(dal.clone())), job_store: Arc::new(PgJobStore::new(dal.clone())), diff --git a/crates/dpp-node/src/boot/tasks.rs b/crates/dpp-node/src/boot/tasks.rs index a7158e37..cc257772 100644 --- a/crates/dpp-node/src/boot/tasks.rs +++ b/crates/dpp-node/src/boot/tasks.rs @@ -428,6 +428,386 @@ pub fn spawn_seal_sweep(outbox: Arc) { }); } +/// How many stored seals one audit pass opens. +/// +/// Smaller than the sweep's batch, because this does cryptographic work per row +/// rather than one query: a pass opens each CAdES, checks a signature and reads +/// a digest out of it. Walking the whole estate in one go would be a spike of +/// CPU on a background task that is in no hurry — the walk continues from its +/// cursor on the next tick, so a large estate is covered over several passes +/// rather than in one. +/// Overridable with `SEAL_AUDIT_BATCH` — see [`seal_audit_cadence`]. +const SEAL_AUDIT_BATCH: i64 = 200; + +/// How often an audit pass runs. +/// +/// Its own cadence rather than [`SWEEP_INTERVAL`], which is hourly because the +/// sweep is a backstop for a rare divergence. This is a **scan that wants to +/// finish**: nothing it reports is usable until the walk has been all the way +/// round, and at the sweep's cadence a modest estate would take most of a day — +/// which is also how long a restart would leave the report blank. +/// +/// Together with [`SEAL_AUDIT_BATCH`] this is the knob: a pass covers +/// `SEAL_AUDIT_BATCH` seals every interval, so the freshness of the report is +/// the estate divided by that rate. A pass is one query plus a few hundred +/// signature checks, which is why it can afford to be this frequent. +/// Overridable with `SEAL_AUDIT_INTERVAL_SECS` — see [`seal_audit_cadence`]. +const SEAL_AUDIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +/// The ceiling the cadence should be read against. +/// +/// **A day to notice a corrupt seal**, which is the outer bound worth accepting +/// for a condition an operator can do nothing about until they are told. A walk +/// slower than this leaves a passport that is published and, in substance, +/// unsealed sitting unreported for longer than anyone would choose. +/// +/// The number is borrowed rather than invented: CIR (EU) 2025/1945 — the act +/// that pins how a qualified seal is validated, through Art. 32(3) and Art. 40 +/// of Reg. (EU) No 910/2014 — caps revocation information for the signing +/// certificate at 24 hours old. +/// +/// **That cap does not currently bind this walk, and the difference matters.** +/// The revocation material this node reads is the CRL *inside* the seal: fixed +/// at sealing time, immutable, and the whole point of the long-term profiles. +/// Re-reading it hourly rather than daily learns nothing new about revocation. +/// The cap would bind the day this node fetches *fresh* revocation data to +/// validate a current signature — which it does not do, and will not without +/// the outbound path that implies. +const SEAL_AUDIT_TARGET_WRAP: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +/// The batch and interval this node's audit will actually run at. +/// +/// # Why these are configurable +/// +/// Together they are the only knob on how fresh the stored-seal report can be: +/// a pass covers `batch` seals every `interval`, so a full walk takes the estate +/// divided by that rate, and the report is that old at worst. The defaults suit +/// a node holding thousands of seals. One holding a million would be walking for +/// days — long enough that the answer describes an estate that has moved on, and +/// far past [`SEAL_AUDIT_TARGET_WRAP`]. +/// +/// # An unparseable value fails the boot rather than falling back +/// +/// The default is a fine value, so falling back to it is tempting. It is also +/// exactly the failure this whole surface exists to prevent: an operator who set +/// a cadence, believes their seals are checked at it, and is running at some +/// other one because of a typo. A node told to do something it cannot parse +/// should say so, not quietly do something else. +/// +/// # Errors +/// +/// An unparseable or out-of-range `SEAL_AUDIT_BATCH` / `SEAL_AUDIT_INTERVAL_SECS`. +fn seal_audit_cadence() -> anyhow::Result<(i64, std::time::Duration)> { + seal_audit_cadence_from(|name| std::env::var(name).ok()) +} + +/// The whole of [`seal_audit_cadence`]'s rule over an arbitrary lookup. +/// +/// Split out so it is testable as a pure function, the same arrangement +/// `dpp_seal::config::SealProvider::resolve` uses and for the same reasons: +/// exercising it through the real environment means mutating process-global +/// state from a test, which is `unsafe` under Rust 2024, forces the tests to +/// serialise against one another, and lets a stray variable in a developer's +/// shell decide the result. +/// +/// # Errors +/// +/// An unparseable or out-of-range value. +fn seal_audit_cadence_from( + get: impl Fn(&str) -> Option, +) -> anyhow::Result<(i64, std::time::Duration)> { + fn read( + get: &impl Fn(&str) -> Option, + name: &str, + default: T, + min: T, + max: T, + ) -> anyhow::Result + where + T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, + { + let Some(raw) = get(name) else { + return Ok(default); + }; + let raw = raw.trim(); + let Ok(value) = raw.parse::() else { + anyhow::bail!("{name} '{raw}' is not a whole number (expected {min}..={max})"); + }; + anyhow::ensure!( + value >= min && value <= max, + "{name} '{value}' is out of range (expected {min}..={max})" + ); + Ok(value) + } + + let batch = read(&get, "SEAL_AUDIT_BATCH", SEAL_AUDIT_BATCH, 1, 10_000)?; + // An hour is the ceiling because a pass this task cannot run within one is + // not a cadence, it is a manual job with extra steps; one second is the + // floor because the pass does real cryptographic work per row. + let interval = read( + &get, + "SEAL_AUDIT_INTERVAL_SECS", + SEAL_AUDIT_INTERVAL.as_secs(), + 1, + 3_600, + )?; + Ok((batch, std::time::Duration::from_secs(interval))) +} + +/// How long a full walk of `sealed` seals takes at this cadence. +/// +/// The `+ 1` is the empty batch that proves the end: a walk publishes when a +/// batch comes back with nothing in it, so the pass that finds nothing is part +/// of the round trip. +fn projected_wrap(sealed: i64, batch: i64, interval: std::time::Duration) -> std::time::Duration { + let batch = batch.max(1); + // Rounded up, not down: a final part-full batch is still a whole pass. The + // floor version under-reported the wrap for every estate that is not an + // exact multiple of the batch, which is nearly all of them. + let full = sealed.div_euclid(batch) + i64::from(sealed.rem_euclid(batch) > 0); + let passes = full + 1; + interval.saturating_mul(u32::try_from(passes).unwrap_or(u32::MAX)) +} + +/// Spawn the stored-seal audit. +/// +/// # What it finds that nothing else can +/// +/// [`spawn_seal_sweep`] and the operator rollup both ask the database whether a +/// passport's `seal` member is **absent**. A seal that is present and worthless +/// answers "no" to that and is therefore invisible: not swept, not counted, +/// healthy in every number the node reports — while the passport is, in +/// substance, unsealed. +/// +/// Whether a stored seal stands up is cryptographic rather than relational, so +/// no widening of that query could reach it. This opens them. +/// +/// # It reports and does not repair +/// +/// [`spawn_seal_sweep`] carries a guarantee worth keeping: it cannot double-bill, +/// because it only queues passports carrying no seal at all. Re-queueing a broken +/// seal breaks exactly that — the row was paid for, and buying a second seal is +/// justified only because the first is worthless. That is a decision to take +/// knowingly rather than one for a background loop to take on an operator's +/// behalf, on the strength of a check that has not yet met a real provider's +/// seal. +/// +/// So a finding is logged at `error` and gauged on `seal_broken`. Repair is its +/// own route, driven by an operator. +/// +/// # The walk survives a restart +/// +/// A pass publishes only when it reaches the end, so both the position and the +/// result are worth keeping across a restart. Without the position, a node whose +/// estate takes longer to walk than it goes between deployments starts again +/// from the beginning every time and never publishes anything at all, while +/// doing every bit of the work. Without the result, the operator surface says +/// "no pass has completed" for a whole walk after each restart, which on a large +/// estate is hours and reads exactly like an audit that is not running. +/// +/// `store` is optional so a node without one still audits; it simply forgets +/// across restarts, which is the old behaviour and still honest. +/// +/// # Errors +/// +/// An unusable `SEAL_AUDIT_BATCH` / `SEAL_AUDIT_INTERVAL_SECS` — see +/// [`seal_audit_cadence`]. +pub fn spawn_seal_audit( + outbox: Arc, + log: Arc, + store: Option>, +) -> anyhow::Result<()> { + let (batch_size, interval) = seal_audit_cadence()?; + tokio::spawn(async move { + // The same reader the drain uses to accept a seal, so the audit and the + // acceptance cannot come to disagree about what a sound seal is. + let inspector = dpp_seal::CadesInspector::new(); + let mut cursor = None; + // Accumulated across the whole walk, not per batch. The gauge has to + // answer "how many broken seals does this node hold", and a value set + // from a 100-row batch answers "how many were in the last hundred" — + // which flaps between passes and reads as zero most of the time on an + // estate where the answer is not zero. + let mut walk = dpp_node::infra::seal_drain::SealAudit::default(); + // The moment this walk began, and the bound every batch is read against, + // so one pass is a statement about exactly the seals that existed then. + let mut started_at = chrono::Utc::now(); + + if let Some(store) = store.as_ref() { + match store.load().await { + Ok((progress, report)) => { + // The report first: it is what the operator surface serves, + // and it should be answerable before the first batch runs. + if let Some(report) = report { + tracing::info!( + completed_at = %report.completed_at, + checked = report.checked, + broken = report.broken, + "restored the last completed seal audit" + ); + log.record(report); + } + if let Some(progress) = progress { + tracing::info!( + started_at = %progress.started_at, + checked = progress.checked, + "resuming the seal audit walk where it left off" + ); + cursor = progress.cursor; + started_at = progress.started_at; + walk = dpp_node::infra::seal_drain::SealAudit { + checked: progress.checked, + sound: progress.sound, + superseded: progress.superseded, + broken: progress.broken, + certificate_failed: progress.certificate_failed, + unreadable: progress.unreadable, + broken_passports: progress.broken_passports, + }; + } + } + // A cache that cannot be read costs a walk, not the audit. + Err(e) => tracing::warn!(error = %e, "could not read the stored seal audit"), + } + } + + // What the configured cadence actually buys, said once at boot against + // the estate this node holds — the number is meaningless without it. + if let Ok(counts) = outbox.status_counts().await { + let wrap = projected_wrap(counts.sealed, batch_size, interval); + if wrap > SEAL_AUDIT_TARGET_WRAP { + tracing::warn!( + sealed = counts.sealed, + batch = batch_size, + interval_secs = interval.as_secs(), + wrap_hours = wrap.as_secs() / 3600, + "a full pass over this node's stored seals takes longer than 24h at the \ + configured cadence — raise SEAL_AUDIT_BATCH or lower \ + SEAL_AUDIT_INTERVAL_SECS. A seal that stops verifying is invisible to \ + every other number this node reports, so the walk is the only thing that \ + will ever say so, and this is how long that takes" + ); + } else { + tracing::info!( + sealed = counts.sealed, + batch = batch_size, + interval_secs = interval.as_secs(), + wrap_mins = wrap.as_secs() / 60, + "seal audit cadence" + ); + } + } + + loop { + tokio::time::sleep(interval).await; + let Some((audit, next)) = dpp_node::infra::seal_drain::audit_seals_once( + &outbox, + &inspector, + batch_size, + cursor, + Some(started_at), + ) + .await + else { + // The batch could not be read. Keep the cursor and the totals, + // publish nothing: a walk that never saw the estate must not + // report on it. + continue; + }; + + walk.checked += audit.checked; + walk.sound += audit.sound; + walk.superseded += audit.superseded; + walk.broken += audit.broken; + walk.certificate_failed += audit.certificate_failed; + walk.unreadable += audit.unreadable; + for id in audit.broken_passports { + if walk.broken_passports.len() < dpp_node::infra::seal_drain::MAX_NAMED_BROKEN { + walk.broken_passports.push(id); + } + } + + if audit.broken > 0 { + tracing::error!( + broken = audit.broken, + checked = audit.checked, + "stored seals do not verify — those passports are published and, in \ + substance, unsealed, and `unsealedPublished` cannot see them" + ); + } + + // An empty batch means the walk reached the end. Publish the total, + // then start again from the beginning: a seal sound today can be + // corrupt tomorrow, and a pass that ran once would only ever catch + // what was already broken. + if next.is_none() { + // No `_total` suffix: these are gauges, and every other gauge + // here is unsuffixed (`seal_outbox_pending`, + // `registry_outbox_rejected`) while `_total` marks the counters + // (`seal_total`, `seal_downgraded_total`). A gauge named like a + // counter gets `rate()` applied to it, which means nothing. + metrics::gauge!("seal_broken").set(walk.broken as f64); + metrics::gauge!("seal_unreadable").set(walk.unreadable as f64); + // Its own gauge, not folded into `seal_broken`: the two need + // different responses, and an operator alerting on one should + // not be woken by the other. + metrics::gauge!("seal_certificate_failed").set(walk.certificate_failed as f64); + let report = dpp_types::SealAuditReport { + completed_at: chrono::Utc::now(), + checked: walk.checked, + sound: walk.sound, + superseded: walk.superseded, + broken: walk.broken, + certificate_failed: walk.certificate_failed, + unreadable: walk.unreadable, + truncated: (walk.broken_passports.len() as u64) < walk.broken, + broken_passports: walk.broken_passports.clone(), + }; + log.record(report.clone()); + if let Some(store) = store.as_ref() + && let Err(e) = store.complete(&report).await + { + // Served from memory regardless; what is lost is the answer + // surviving the next restart. + tracing::warn!(error = %e, "could not store the completed seal audit"); + } + tracing::debug!( + checked = walk.checked, + sound = walk.sound, + superseded = walk.superseded, + broken = walk.broken, + certificate_failed = walk.certificate_failed, + unreadable = walk.unreadable, + "seal audit completed a pass over every stored seal" + ); + walk = dpp_node::infra::seal_drain::SealAudit::default(); + // The next walk describes the estate as it is now, including + // everything sealed while this one was running. + started_at = chrono::Utc::now(); + } else if let Some(store) = store.as_ref() + && let Err(e) = store + .save_progress(&dpp_types::SealAuditProgress { + started_at, + cursor: next, + checked: walk.checked, + sound: walk.sound, + superseded: walk.superseded, + broken: walk.broken, + certificate_failed: walk.certificate_failed, + unreadable: walk.unreadable, + broken_passports: walk.broken_passports.clone(), + }) + .await + { + // The walk carries on in memory; only its survival is lost. + tracing::warn!(error = %e, "could not store the seal audit's position"); + } + cursor = next; + } + }); + Ok(()) +} + /// Spawn the continuity tier's repair sweep. /// /// The drain only ever sees reconciles that were successfully queued. This loop @@ -561,3 +941,107 @@ pub fn spawn_ruleset_poll( } }); } + +#[cfg(test)] +mod seal_audit_cadence_tests { + use super::*; + + /// A lookup standing in for the process environment. + /// + /// Nothing here calls `set_var`: the rule is a pure function over this, so + /// the tests run in parallel, cannot be changed by a developer's shell, and + /// need no `unsafe`. + fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let owned: Vec<(String, String)> = vars + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + move |name| { + owned + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + } + } + + #[test] + fn the_defaults_are_what_ships() { + let (batch, interval) = seal_audit_cadence_from(env(&[])).expect("defaults parse"); + assert_eq!(batch, SEAL_AUDIT_BATCH); + assert_eq!(interval, SEAL_AUDIT_INTERVAL); + } + + #[test] + fn a_configured_cadence_is_honoured() { + let (batch, interval) = seal_audit_cadence_from(env(&[ + ("SEAL_AUDIT_BATCH", " 2000 "), + ("SEAL_AUDIT_INTERVAL_SECS", "10"), + ])) + .expect("parses"); + assert_eq!( + batch, 2000, + "surrounding whitespace is not a typo worth failing on" + ); + assert_eq!(interval.as_secs(), 10); + } + + /// **A value that cannot be read fails the boot rather than falling back.** + /// + /// The default is a perfectly good cadence, which is exactly why falling + /// back to it is the wrong move: the operator would be told nothing and + /// would believe their seals were being checked at the rate they set. + #[test] + fn an_unparseable_cadence_is_refused() { + let err = seal_audit_cadence_from(env(&[("SEAL_AUDIT_INTERVAL_SECS", "60s")])) + .expect_err("'60s' is not a number of seconds"); + assert!( + err.to_string().contains("SEAL_AUDIT_INTERVAL_SECS"), + "the message must name the variable: {err}" + ); + } + + /// Zero is the interesting rejection: it reads as "off", and would be a + /// busy loop hammering the database with no sleep between passes. + #[test] + fn an_out_of_range_cadence_is_refused() { + assert!( + seal_audit_cadence_from(env(&[("SEAL_AUDIT_INTERVAL_SECS", "0")])).is_err(), + "zero seconds is a busy loop" + ); + assert!( + seal_audit_cadence_from(env(&[("SEAL_AUDIT_BATCH", "0")])).is_err(), + "a zero batch never advances the cursor and never wraps" + ); + } + + /// The arithmetic behind the boot warning, including the empty batch that + /// proves the end of the walk. + #[test] + fn a_walk_costs_one_pass_per_batch_plus_the_one_that_finds_nothing() { + let minute = std::time::Duration::from_secs(60); + assert_eq!( + projected_wrap(0, 200, minute), + minute, + "an empty estate still takes the pass that discovers it is empty" + ); + assert_eq!(projected_wrap(200, 200, minute), 2 * minute); + assert_eq!(projected_wrap(201, 200, minute), 3 * minute); + } + + /// The case the warning exists for: an estate large enough that the shipped + /// cadence cannot get round it inside the day. + #[test] + fn a_large_estate_outruns_the_default_cadence() { + let wrap = projected_wrap(1_000_000, SEAL_AUDIT_BATCH, SEAL_AUDIT_INTERVAL); + assert!( + wrap > SEAL_AUDIT_TARGET_WRAP, + "a million seals at 200/min is days, and the operator must be told: {wrap:?}" + ); + // And that the knob is the answer, not a rewrite. + let tuned = projected_wrap(1_000_000, 10_000, std::time::Duration::from_secs(10)); + assert!( + tuned < SEAL_AUDIT_TARGET_WRAP, + "the same estate fits inside the day at a cadence the range allows: {tuned:?}" + ); + } +} diff --git a/crates/dpp-node/src/boot/trust.rs b/crates/dpp-node/src/boot/trust.rs index 6e816610..237a39cc 100644 --- a/crates/dpp-node/src/boot/trust.rs +++ b/crates/dpp-node/src/boot/trust.rs @@ -76,3 +76,34 @@ pub fn build_and_enforce( } Ok(trust) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The seal summary route looks the sealing tier up by name, and the name + /// lives here. + /// + /// Two crates and no compiler link between them: `dpp-vault` cannot see this + /// module, so it carries a string constant and finds nothing if the port is + /// ever renamed. Nothing would fail — `trustMode` would simply serve `null`, + /// which the route documents as "no seal port was resolved", so a rename + /// would turn a real answer into a plausible-looking absence. + #[test] + fn the_seal_port_is_named_what_the_seal_route_looks_up() { + let report = build_and_enforce( + TrustMode::Live, + TrustMode::Live, + TrustMode::Live, + TrustMode::Live, + TrustMode::Live, + ) + .expect("an all-live posture boots under any profile"); + + assert_eq!( + report.mode_of(dpp_vault::handlers::seal::SEAL_TRUST_PORT), + Some(TrustMode::Live), + "the seal route's port name must match the one registered here" + ); + } +} diff --git a/crates/dpp-node/src/infra/seal.rs b/crates/dpp-node/src/infra/seal.rs index 700f6db7..0189bf3b 100644 --- a/crates/dpp-node/src/infra/seal.rs +++ b/crates/dpp-node/src/infra/seal.rs @@ -10,7 +10,7 @@ //! | Variable | Values | Meaning | //! |---------------------------|--------------------------|----------------------------------| //! | `SEAL_PROVIDER` | unset / `qtsp` / `local` | Which backend to build | -//! | `SEAL_CONFORMANCE_LEVEL` | `B` / `T` / `LT` / `LTA` | Baseline level to request (`LT`) | +//! | `SEAL_CONFORMANCE_LEVEL` | `B` / `T` / `LT` / `LTA` | Baseline level to request (default: the backend's own, `LTA` for all of them today) | //! //! Each backend then reads its own variables — see `dpp_seal::eideasy::config` //! and `dpp_seal::local::config`. A partial or unrecognised configuration is an @@ -187,14 +187,38 @@ fn ensure_drainable(w: &SealWiring) -> Result<()> { ) } -/// Read `SEAL_CONFORMANCE_LEVEL`, defaulting to `B-LT`. +/// The provider profile to impose, given what the operator pinned and asked for. /// -/// An unrecognised value fails the boot rather than falling back to the default, -/// which would let a deployment that asked for `B-LTA` and misspelled it seal at -/// a lower level than it believes it is sealing at. -fn conformance_level_from_env() -> Result { +/// `None` leaves the adapter's own default alone: either the operator pinned a +/// profile, in which case it is theirs to be wrong about, or the level is one +/// this adapter cannot name and the capability probe should report that as the +/// mismatch it is rather than having it papered over here. +/// +/// Split out from `wiring_from_env` so the rule is testable without a process +/// environment — the same reason the vault's coverage rule is a pure function. +fn derived_profile(pinned: Option<&str>, level: SealConformanceLevel) -> Option { + if pinned.is_some() { + return None; + } + dpp_seal::eideasy::profile_for_level(level).map(ToOwned::to_owned) +} + +/// Read `SEAL_CONFORMANCE_LEVEL`, falling back to the backend's own default. +/// +/// **The default belongs to the backend, not to the node.** A default that +/// cannot depend on which backend is wired is a default that will eventually +/// contradict one: it fails the boot, from a value nobody chose, and the +/// operator is pointed at lowering the level to suit a backend rather than at +/// the backend. Every backend wired today reaches `B-LTA`; one that does not — +/// a plan that only covers `B-T`, a future adapter — names its own here instead +/// of forcing the whole node down to meet it. +/// +/// An unrecognised value fails the boot rather than falling back, which would +/// let a deployment that asked for `B-LTA` and misspelled it seal at a lower +/// level than it believes it is sealing at. +fn conformance_level_from_env(default: SealConformanceLevel) -> Result { let Ok(raw) = std::env::var("SEAL_CONFORMANCE_LEVEL") else { - return Ok(SealConformanceLevel::BaselineLt); + return Ok(default); }; match raw.trim().to_ascii_uppercase().as_str() { "B" | "B-B" | "BASELINE_B" => Ok(SealConformanceLevel::BaselineB), @@ -220,11 +244,36 @@ pub fn from_env() -> Result { } fn wiring_from_env() -> Result { - let conformance_level = conformance_level_from_env()?; + // Read *after* the provider, so each backend can name the level it should + // reach by default. Reading it first is what made a node-wide default able + // to contradict the backend it was wired against. match dpp_seal::SealProvider::from_env().context("seal provider")? { dpp_seal::SealProvider::Qtsp => { - let cfg = + // A provider is bought from, so ask for everything: `B-LTA` is the + // only level that keeps a seal verifiable past its own signing + // certificate, which a retention-locked passport outlives. + let conformance_level = conformance_level_from_env(SealConformanceLevel::BaselineLta)?; + let mut cfg = dpp_seal::eideasy::EideasyConfig::from_env().context("QTSP seal configuration")?; + // Derive the provider's profile from the level actually requested, + // unless the operator pinned one. + // + // The two defaults used to contradict: the level defaults to `LT` + // and the profile to `CAdES_BASELINE_T`, so a node configured for + // this provider and nothing else refused to boot — correctly, since + // every published passport would have enqueued a row that could + // never drain. But the failure named `SEAL_CONFORMANCE_LEVEL` as the + // thing to change, which points an operator at *lowering the level* + // to match the profile: giving up long-term validation material to + // fix a default they never chose. One level, one source. + if let Some(profile) = derived_profile( + std::env::var(dpp_seal::eideasy::config::ENV_SIGNATURE_PROFILE) + .ok() + .as_deref(), + conformance_level, + ) { + cfg.signature_profile = profile; + } // Sandbox is a real seal from a real API, but over the provider's // test certificate — a distinct claim from both Ghost and Live. let trust = match cfg.environment { @@ -254,6 +303,12 @@ fn wiring_from_env() -> Result { }) } dpp_seal::SealProvider::Local => { + // The same level as a provider, deliberately. This backend emits the + // whole `B-LTA` structure — signature timestamp, revocation material, + // archive timestamp — so a sandbox exercises the shape a real seal + // has rather than a stripped-down one that hides every path above + // `B-B`. It remains legally nothing; see the `Ghost` tier below. + let conformance_level = conformance_level_from_env(SealConformanceLevel::BaselineLta)?; let cfg = dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; let backend = dpp_seal::local::LocalIdentity::load_or_create(&cfg.key_path) @@ -282,6 +337,11 @@ fn wiring_from_env() -> Result { }) } dpp_seal::SealProvider::None => { + // Nothing drains here, so the level is only ever used by the + // capability probe. `GhostSeal` advertises every level, so this + // agrees with the others rather than being a special case to + // remember. + let conformance_level = conformance_level_from_env(SealConformanceLevel::BaselineLta)?; tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); let port: Arc = Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)); @@ -383,6 +443,42 @@ mod seal_mode_and_drainability { assert!(mode_for(&caps(vec![], vec![SealConformanceLevel::BaselineB])).is_err()); } + /// An unpinned profile follows the level that was actually requested. + /// + /// The defect this closes: the level defaults to `LT` and this provider's + /// profile defaulted to `CAdES_BASELINE_T`, so a node configured for the + /// provider and nothing else refused to boot. Correctly — every published + /// passport would have enqueued a row that could never drain — but the + /// message named `SEAL_CONFORMANCE_LEVEL` as the thing to change, pointing + /// the operator at *lowering* the level to meet a default they never chose, + /// and giving up long-term validation material to do it. + #[test] + fn an_unpinned_profile_follows_the_requested_level() { + assert_eq!( + derived_profile(None, SealConformanceLevel::BaselineLt).as_deref(), + Some("CAdES_BASELINE_LT"), + "the level default must not land on a profile that contradicts it" + ); + assert_eq!( + derived_profile(None, SealConformanceLevel::BaselineLta).as_deref(), + Some("CAdES_BASELINE_LTA") + ); + } + + /// A pinned profile is left alone, wrong or right. + /// + /// An operator who names a profile has said something specific, and the + /// capability probe already refuses the boot if it contradicts the level. + /// Silently correcting it would hide a real disagreement about what this + /// node is buying. + #[test] + fn a_pinned_profile_is_never_overridden() { + assert_eq!( + derived_profile(Some("CAdES_BASELINE_B"), SealConformanceLevel::BaselineLta), + None + ); + } + /// The local backend's real shape: operator seal, `BaselineB` only. With the /// mode derived, this is drainable — which is the whole point. #[test] diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index dc1ce09c..7ef9940a 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -134,6 +134,26 @@ pub async fn drain_once( match outcome { Ok(envelope) => { report_shortfall(&envelope, conformance_level, &row.passport_id); + if let Some(why) = covers_something_else(&envelope, &row.payload_hash) { + metrics::counter!("seal_total", "outcome" => "misbound").increment(1); + tracing::error!( + passport_id = %row.passport_id, + requested = %row.payload_hash, + %why, + "the seal that came back does not cover the digest it was bought for — \ + not stored. The passport stays visibly unsealed, which is true, rather \ + than carrying a seal that attests to something else" + ); + back_off_or_exhaust( + outbox, + row.id, + row.attempts, + format!("seal does not cover the requested digest: {why}"), + &mut stats, + ) + .await; + continue; + } match outbox.mark_sealed(row.id, &envelope).await { Ok(()) => { metrics::counter!("seal_total", "outcome" => "sealed").increment(1); @@ -169,6 +189,227 @@ pub async fn drain_once( stats } +/// How many broken passports one walk names before it stops collecting. +/// +/// A node with thousands of broken seals has one problem, not thousands; the +/// count says how big it is, and a list long enough to prove that is a list +/// nobody reads. Bounded here rather than at the route so the memory is bounded +/// too — a walk over a damaged estate must not accumulate an id per row. +pub const MAX_NAMED_BROKEN: usize = 100; + +/// What one audit pass found. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SealAudit { + /// Seals opened. + pub checked: u64, + /// Seals that cover their passport's current signature **and** whose + /// certificate nothing has been shown to fail. + /// + /// The second half is weaker than it looks, deliberately. A certificate that + /// is expired *now* with nothing attesting when the seal was made stays + /// here: EN 319 102-1 calls that indeterminate, not failed, and treating it + /// otherwise would eventually condemn every seal this node holds. + pub sound: u64, + /// Seals whose signature is sound and whose **certificate** was not, at the + /// moment they were made. + /// + /// Revoked before sealing, or outside its validity window, with an attested + /// time to prove the order — `TOTAL-FAILED` under EN 319 102-1 with a + /// certificate sub-indication. + /// + /// Counted apart from [`Self::broken`] because the two need opposite + /// actions. A broken seal is repairable: buy another one, and the + /// replacement is sound. A seal made under a revoked certificate is not — + /// the same backend would produce another seal under the same certificate — + /// so folding these into `broken` would put them in front of a repair route + /// that cannot help them. + pub certificate_failed: u64, + /// Seals over a **different** digest — ordinarily a passport re-published + /// after sealing, which is not a defect. + pub superseded: u64, + /// Seals whose own signature does not verify. These are the finding. + pub broken: u64, + /// Seals this node could not read. Not a finding, and not counted as one. + pub unreadable: u64, + /// The passports carrying a broken seal, up to [`MAX_NAMED_BROKEN`]. + /// + /// `broken` keeps counting after this stops filling, so the two together say + /// "this many, and here are the first hundred". + pub broken_passports: Vec, +} + +/// Open a bounded batch of stored seals and report what they are worth. +/// +/// # The gap this closes +/// +/// The repair sweep and the operator rollup both ask the database whether the +/// `seal` member is **absent**. A seal that is present and worthless satisfies +/// neither: its passport is not swept, not counted, and looks healthy in every +/// number this node reports, while being in substance unsealed. +/// +/// That question is cryptographic rather than relational, so it needs a pass +/// that opens seals. This is that pass. +/// +/// # It reports and does not repair, deliberately +/// +/// The existing sweep carries a guarantee worth keeping: *it cannot double-bill, +/// because it only queues passports carrying no seal at all.* Re-queueing a +/// broken seal breaks exactly that — the row was already paid for, and buying a +/// second seal is justified only because the first is worthless. That is a +/// decision an operator should take knowingly, not one a background loop should +/// take on their behalf on the strength of a check that has never met a real +/// provider's seal. +/// +/// So this counts and names them, and an operator decides per passport through +/// `POST /api/v1/dpp/{dppId}/seal/repair` — which re-checks the seal at the +/// moment of the request rather than trusting this list, because a finding here +/// can be hours old. +/// +/// # What is not a finding +/// +/// A seal over a **different** digest is ordinarily a passport re-published +/// after sealing, which the read route already reports as `superseded` and which +/// the sweep deliberately leaves alone. It is counted separately rather than +/// alarmed on. +/// +/// # Three outcomes, not two +/// +/// `None` means the batch could not be read at all. It is separate from an empty +/// batch — which is how a walk reports that it reached the end — because the two +/// would otherwise be indistinguishable on the first batch of a walk, and the +/// caller would publish a completed pass that had checked nothing. +/// +/// A seal this node **cannot read** is not a finding either. Treating "cannot +/// check" as "broken" would make every seal from a backend emitting a format +/// this node does not parse look like corruption. EU law keeps the same three +/// answers apart: CIR (EU) 2025/1945, which pins how a qualified seal is to be +/// validated, makes *indeterminate* its own technical outcome — neither valid +/// nor invalid — and requires it to be reported as such. +/// +/// # `sealed_before` bounds what the pass is about +/// +/// Seals are written while a walk runs, and which of them a pass happens to see +/// otherwise depends on where its cursor had reached. Passing the walk's start +/// time makes the pass a statement about exactly the seals that existed then, +/// and costs no coverage: the drain checks a seal's binding before accepting it, +/// so one written during the walk was verified as it landed and is covered by +/// the next pass anyway. +pub async fn audit_seals_once( + outbox: &Arc, + inspector: &dyn dpp_types::SealInspector, + limit: i64, + after: Option, + sealed_before: Option>, +) -> Option<(SealAudit, Option)> { + let batch = match outbox.sealed_passports(limit, after, sealed_before).await { + Ok(b) => b, + // `None`, not an empty pass. An empty *batch* is how a walk says it + // reached the end, so returning one here would tell the caller the + // estate had been covered — and on the first batch of a walk, where the + // cursor is `None` too, that published a completed report saying zero + // seals were checked and none were broken. + // + // A database blip must not produce a clean bill of health from a check + // that never ran. The caller keeps its cursor and tries again. + Err(e) => { + tracing::warn!(error = %e, "seal audit could not read stored seals"); + return None; + } + }; + + // An empty batch means the walk reached the end; the caller restarts it. + let cursor = batch.last().map(|p| p.passport_id); + let mut audit = SealAudit::default(); + + for row in &batch { + audit.checked += 1; + let binding = inspector.binding(&row.seal, &row.payload_hash); + match &binding { + dpp_types::SealBinding::CoversThisSignature => { + // The signature holds; the certificate behind it is a separate + // question, and one nothing asked until now. A seal made under a + // certificate its CA had already revoked covers its passport + // perfectly and is worth nothing. + let certificate = inspector.certificate_standing(&row.seal, chrono::Utc::now()); + let status = dpp_types::SealValidationStatus::of(&binding, certificate.as_ref()); + if status.indication == dpp_types::ValidationIndication::TotalFailed { + audit.certificate_failed += 1; + tracing::error!( + passport_id = %row.passport_id, + sub_indication = ?status.sub_indication, + "a stored seal's certificate was not valid when the seal was made — \ + the seal covers its passport and carries no weight. Re-sealing does \ + not help: the replacement would come from the same certificate" + ); + } else { + audit.sound += 1; + } + } + dpp_types::SealBinding::CoversAnotherDigest { .. } => audit.superseded += 1, + dpp_types::SealBinding::NotIntact => { + audit.broken += 1; + if audit.broken_passports.len() < MAX_NAMED_BROKEN { + audit.broken_passports.push(row.passport_id); + } + tracing::error!( + passport_id = %row.passport_id, + "stored seal does not verify — this passport is published and, in \ + substance, unsealed. It is invisible to `unsealedPublished`, which asks \ + only whether a seal is present" + ); + } + dpp_types::SealBinding::Unknown => audit.unreadable += 1, + } + } + + // No gauge here, deliberately. This function sees **one batch**, and a gauge + // set from a batch flaps: three after a batch carrying three, zero after the + // next clean one, on an estate where both are true at once. The quantity an + // operator can act on is "broken seals in the last complete pass", which + // only the caller owning the walk can know — see `spawn_seal_audit`. + Some((audit, cursor)) +} + +/// Refuse a seal that does not cover the digest it was bought for. +/// +/// `Some(reason)` means do not store it. +/// +/// # Why this one refuses where `report_shortfall` only warns +/// +/// The two failures look similar and are not. A downgraded seal is still a seal +/// **over the right passport** — weaker than ordered, but real, and re-buying +/// gets the same weak thing, so warning and keeping it is right. +/// +/// A seal over the wrong digest is not a seal for this passport at all. Storing +/// it would leave a passport that *looks* sealed and is not, and the read route +/// would report it as `coversAnotherDigest` — indistinguishable from the +/// ordinary case of a passport re-published after sealing. A provider error +/// would arrive disguised as routine staleness. +/// +/// So it is not stored. The row backs off and eventually exhausts, and the +/// passport stays in `unsealedPublished`, which is the honest state and one the +/// rollup already surfaces. +/// +/// # What is deliberately not refused +/// +/// `Unknown` — a placeholder, or a format this node does not parse. "Cannot +/// check" must never become "reject", or the first backend emitting something +/// other than CAdES would be unable to seal anything at all. +/// +/// The check runs through the same `CadesInspector` the read route uses, so the +/// drain and the route cannot come to disagree about what covering means. +fn covers_something_else(envelope: &SealedEnvelope, requested: &str) -> Option { + use dpp_types::SealInspector as _; + + match dpp_seal::CadesInspector::new().binding(envelope, requested) { + dpp_types::SealBinding::CoversThisSignature | dpp_types::SealBinding::Unknown => None, + dpp_types::SealBinding::CoversAnotherDigest { covered } => { + Some(format!("it covers {covered}")) + } + dpp_types::SealBinding::NotIntact => Some("its signature does not verify".to_owned()), + } +} + /// Say so when a seal carries less than was asked for. /// /// Until now nothing looked at what came back. The request names a level, the @@ -306,6 +547,17 @@ mod tests { Ok(SealOutboxCounts::default()) } + async fn sealed_passports( + &self, + _limit: i64, + _after: Option, + _sealed_before: Option>, + ) -> Result, DppError> { + Ok(Vec::new()) + } + async fn rearm_sealed(&self, _p: PassportId, _h: &str, _r: &str) -> Result { + unreachable!("the drain never repairs") + } async fn unsealed_published_count(&self) -> Result { // These tests drive the drain, which never asks. A passport-level // count has no meaning against a fake holding only rows. @@ -402,6 +654,264 @@ mod tests { /// /// The shortfall is reported, and that is *all* it does. The seal exists and /// has been billed, so failing the row would back it off and buy the same + /// **A seal over the wrong digest is not stored, however well-formed it is.** + /// + /// A provider answering with a seal over some other document — a mix-up, a + /// request crossed with another client's, a bug — used to be written onto + /// the passport unexamined, because nothing compared what came back against + /// what was asked for. The passport would then *look* sealed while carrying + /// an attestation about something else, and the read route would call it + /// `coversAnotherDigest`, which is indistinguishable from the ordinary case + /// of a passport re-published after sealing. A provider error would arrive + /// disguised as routine staleness. + /// + /// Refused, unlike a downgrade: a weak seal still covers the right passport + /// and re-buying gets the same weak thing, whereas this is not a seal for + /// this passport at all. The row backs off and the passport stays in + /// `unsealedPublished`, which is true. + /// + /// Real CAdES bytes again, over a genuinely different digest — a stub would + /// exercise the unreadable path, which is the one case deliberately allowed + /// through. + #[tokio::test] + async fn a_seal_over_the_wrong_digest_is_refused_rather_than_stored() { + struct WrongDigest(String); + + #[async_trait] + impl SealPort for WrongDigest { + async fn seal(&self, _req: SealRequest) -> Result { + Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value: self.0.clone(), + signing_cert_ref: None, + conformance_level: Some(SealConformanceLevel::BaselineB), + sealed_at: chrono::Utc::now(), + placeholder: false, + }) + } + async fn verify(&self, _e: &SealedEnvelope) -> Result { + unreachable!("the drain never verifies") + } + fn capabilities(&self) -> SealCapabilities { + SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::ProviderSeal], + supported_levels: SealConformanceLevel::ALL.to_vec(), + supported_envelopes: vec![SealEnvelope::Detached], + } + } + } + + let dir = tempfile::tempdir().expect("tempdir"); + let identity = + dpp_seal::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + // Sealed over a digest that is emphatically not the row's. + let der = identity.sign_detached(&[0x99; 32]).expect("sign"); + let elsewhere = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &der); + + let outbox = Arc::new(FakeOutbox { + rows: Mutex::new(vec![row(0)]), + ..Default::default() + }); + let stats = drain_once( + &(outbox.clone() as Arc), + &(Arc::new(WrongDigest(elsewhere)) as Arc), + &test_key_ref(), + SealMode::ProviderSeal, + SealConformanceLevel::BaselineB, + 10, + ) + .await; + + assert_eq!(stats.sealed, 0, "it must not be recorded as a sealed row"); + assert!( + outbox.sealed.lock().unwrap().is_empty(), + "and nothing may be written onto the passport" + ); + assert_eq!(stats.retried, 1); + assert!( + outbox.failed.lock().unwrap()[0].contains("does not cover the requested digest"), + "the reason must name the mismatch, not a transport failure: {:?}", + outbox.failed.lock().unwrap() + ); + } + + /// An unreadable seal is stored, because "cannot check" is not "reject". + /// + /// The line the check must not cross. A backend emitting a format this node + /// does not parse — JAdES, PAdES — would otherwise be unable to seal + /// anything at all, refused by a check that never ran rather than by a + /// finding. + #[tokio::test] + async fn a_seal_this_node_cannot_read_is_still_stored() { + let (outbox, seal) = fakes(false, false, 0); + let stats = drain_once( + &(outbox.clone() as Arc), + &(seal as Arc), + &test_key_ref(), + SealMode::ProviderSeal, + SealConformanceLevel::BaselineLt, + 10, + ) + .await; + + assert_eq!( + stats.sealed, 1, + "an envelope whose coverage cannot be determined must still be recorded" + ); + } + + /// The audit names a broken seal that every existing count calls healthy. + /// + /// The whole point of the pass. Both `enqueue_unsealed` and + /// `unsealed_published_count` ask whether the `seal` member is absent, so a + /// passport carrying a seal that does not verify is swept by neither and + /// counted by neither — it looks fine in every number the node reports. + /// + /// The three non-findings are checked in the same run, because each is a way + /// this could become a nuisance rather than a signal: a sound seal, a + /// superseded one (an ordinary re-publish, which the sweep deliberately + /// leaves alone), and one this node cannot read. + #[tokio::test] + async fn the_audit_separates_a_broken_seal_from_the_three_things_that_are_not() { + struct Stored(Vec); + + #[async_trait] + impl SealOutbox for Stored { + async fn sealed_passports( + &self, + _limit: i64, + _after: Option, + _sealed_before: Option>, + ) -> Result, DppError> { + Ok(self.0.clone()) + } + async fn rearm_sealed( + &self, + _p: PassportId, + _h: &str, + _r: &str, + ) -> Result { + unreachable!("the audit must not repair") + } + async fn enqueue(&self, _p: PassportId, _h: &str) -> Result<(), DppError> { + unreachable!("the audit must not queue anything") + } + async fn enqueue_unsealed(&self, _l: i64, _c: i64) -> Result { + unreachable!("the audit must not queue anything") + } + async fn due(&self, _l: i64) -> Result, DppError> { + Ok(Vec::new()) + } + async fn mark_sealed( + &self, + _i: uuid::Uuid, + _e: &SealedEnvelope, + ) -> Result<(), DppError> { + unreachable!("the audit must not write") + } + async fn sealed_digest(&self, _p: PassportId) -> Result, DppError> { + Ok(None) + } + async fn mark_attempt_failed( + &self, + _i: uuid::Uuid, + _m: String, + ) -> Result<(), DppError> { + unreachable!("the audit must not write") + } + async fn mark_exhausted(&self, _i: uuid::Uuid, _m: String) -> Result<(), DppError> { + unreachable!("the audit must not write") + } + async fn status_counts(&self) -> Result { + Ok(dpp_types::SealOutboxCounts::default()) + } + async fn unsealed_published_count(&self) -> Result { + Ok(0) + } + } + + let dir = tempfile::tempdir().expect("tempdir"); + let id = dpp_seal::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let digest = [0x11; 32]; + let hex_digest = hex::encode(digest); + let sound = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + id.sign_detached(&digest).expect("sign"), + ); + + // One byte of the signature flipped: the structure is intact, so it + // parses and reaches the signature check, and fails it. + let corrupted = { + use der::{Decode as _, Encode as _}; + let der_bytes = id.sign_detached(&digest).expect("sign"); + let info = cms::content_info::ContentInfo::from_der(&der_bytes).expect("CMS"); + let mut sd: cms::signed_data::SignedData = + info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + let mut bytes = signers[0].signature.as_bytes().to_vec(); + let last = bytes.len() - 1; + bytes[last] ^= 0xff; + signers[0].signature = der::asn1::OctetString::new(bytes).expect("octets"); + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + let rebuilt = cms::content_info::ContentInfo { + content_type: info.content_type, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"); + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &rebuilt) + }; + + let envelope = |value: String, placeholder: bool| SealedEnvelope { + format: SealFormat::Cades, + seal_value: value, + signing_cert_ref: None, + conformance_level: Some(SealConformanceLevel::BaselineB), + sealed_at: chrono::Utc::now(), + placeholder, + }; + let row = |seal: SealedEnvelope, hash: &str| dpp_types::SealedPassport { + passport_id: PassportId::new(), + seal, + payload_hash: hash.to_owned(), + }; + + let outbox: Arc = Arc::new(Stored(vec![ + // Sound: covers the digest it is asked about. + row(envelope(sound.clone(), false), &hex_digest), + // Superseded: intact, over a different digest. An ordinary + // re-publish, and not a finding. + row(envelope(sound.clone(), false), &hex::encode([0x22; 32])), + // Broken: a real seal whose signature has been corrupted. It must + // still *parse* — garbage bytes are `unknown`, correctly, because + // nothing could be checked, and that is a different finding. + row(envelope(corrupted.clone(), false), &hex_digest), + // Unreadable: a placeholder carries no certificate to check. + row(envelope("Z2hvc3Q=".to_owned(), true), &hex_digest), + ])); + + let (audit, cursor) = + audit_seals_once(&outbox, &dpp_seal::CadesInspector::new(), 100, None, None) + .await + .expect("the batch is readable"); + + assert_eq!(audit.checked, 4); + assert_eq!(audit.sound, 1); + assert_eq!(audit.superseded, 1, "a re-publish is not a broken seal"); + assert_eq!( + audit.unreadable, 1, + "a placeholder is not a broken seal either" + ); + assert_eq!( + audit.broken, 1, + "the one finding: a seal that reads and does not verify" + ); + assert!(cursor.is_some(), "the walk must hand back a cursor"); + } + /// weaker seal again on the next pass — paying twice to record the problem /// twice. The same reasoning as the produced-but-unrecorded path, reached /// from the other side. @@ -444,7 +954,13 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let identity = dpp_seal::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); - let der = identity.sign_detached(&[0x11; 32]).expect("sign"); + // Over the digest the row actually asks for. A double that sealed some + // other digest would now be refused before the level is ever considered + // — correctly, and it would make this test assert the wrong refusal. + let requested = row(0).payload_hash; + let der = identity + .sign_detached(&hex::decode(&requested).expect("the row's digest is hex")) + .expect("sign"); let b_level = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &der); assert_eq!( @@ -454,7 +970,10 @@ mod tests { ); let outbox = Arc::new(FakeOutbox { - rows: Mutex::new(vec![row(0)]), + rows: Mutex::new(vec![SealRow { + payload_hash: requested, + ..row(0) + }]), ..Default::default() }); let stats = drain_once( diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 7270f988..afda370c 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -174,6 +174,8 @@ async fn main() -> anyhow::Result<()> { let credentials_live = credential_trust != TrustMode::Ghost; // ── eIDAS qualified seal ───────────────────────────────────────────────── + // Shared between the audit task that fills it and the route that reports it. + let seal_audit = Arc::new(dpp_types::SealAuditLog::default()); let seal_wiring = dpp_node::infra::seal::from_env()?; let sealing_live = seal_wiring.drains; @@ -278,6 +280,14 @@ async fn main() -> anyhow::Result<()> { if sealing_live { passport_service = passport_service.with_seal_outbox(db.seal_outbox.clone()); } + // The inspector is wired unconditionally, and deliberately not under + // `sealing_live` above. Reading a stored seal's certificate is not sealing: + // a node whose provider was dropped, or one serving passports sealed before + // a backend change, still holds seals whose origin a reader needs — and + // those are precisely the ones least self-explanatory. Gating this would + // withdraw the answer exactly where it is worth most. + passport_service = + passport_service.with_seal_inspector(Arc::new(dpp_seal::CadesInspector::new())); let service = Arc::new(passport_service); let operator_service = Arc::new(OperatorService::new(db.operator_repo.clone())); let api_key_service = Arc::new(ApiKeyService::new(db.api_key_repo.clone())); @@ -349,6 +359,7 @@ async fn main() -> anyhow::Result<()> { // Reported on the authenticated `/vault/api/v1/node/state`, not on the // public `/health` — see `dpp_node::router::node_health`. trust: Some(trust.clone()), + seal_audit: Some(seal_audit.clone()), // The port, not a version snapshot — `/node/state` reads the version in // force at the moment it is asked, and `POST /ruleset/reload` reaches // the same channel this booted from. @@ -402,6 +413,14 @@ async fn main() -> anyhow::Result<()> { // forever with nothing to notice. boot::tasks::spawn_seal_sweep(db.seal_outbox.clone()); } + // Read-only, and deliberately outside the `sealing_live` guard above: a node + // that has stopped sealing still holds the seals it bought, and those are + // exactly the ones nobody is watching any more. + boot::tasks::spawn_seal_audit( + db.seal_outbox.clone(), + seal_audit.clone(), + Some(db.seal_audit.clone()), + )?; // Continuity tier: only spawn when object storage is configured — without a // store there is nothing to reconcile against (and the vault never enqueues). if let Some(store) = snapshot_store { diff --git a/crates/dpp-node/tests/openapi_contract.rs b/crates/dpp-node/tests/openapi_contract.rs index 1e4b0b51..3cd26d53 100644 --- a/crates/dpp-node/tests/openapi_contract.rs +++ b/crates/dpp-node/tests/openapi_contract.rs @@ -288,6 +288,16 @@ const UNCHECKED: &[(&str, &str)] = &[ `EvidenceDossier`; giving the dossier a real type for the member is \ the only thing that would actually close it", ), + ( + "RevocationStanding", + "an internally-tagged enum whose four variants carry three different \ + payload fields (`asOf`, `at`, `reason`), so no single fixture can emit \ + the union of documented properties and the key-set comparison reports \ + the other three as undocumented. The wire form of every variant is \ + pinned instead by `every_revocation_standing_serialises_as_documented`, \ + which also checks the documented property list against what the \ + variants can actually emit", + ), ( "ResponsibilityBasis", "a `oneOf` of a string enum and an externally-tagged object (`OtherUnionLaw`, which carries a citation), the same shape as `CredentialRole` below and unexpressible by either checker for the same reason. The wire form of each variant is pinned instead by `every_responsibility_basis_serialises_as_documented`", @@ -475,6 +485,15 @@ fn object_cases() -> Vec { case!("OperatorScanStats", fixtures::operator_scan_stats()); case!("DailyScanCount", fixtures::daily_scan_count()); case!("SealResponse", fixtures::seal_response()); + case!("SealOrigin", fixtures::seal_origin()); + case!("SealBinding", fixtures::seal_binding()); + case!("SealValidationStatus", fixtures::seal_validation_status()); + case!("CertificateStanding", fixtures::certificate_standing()); + case!("ValidityWindow", fixtures::certificate_standing().validity); + case!("JudgedTime", fixtures::certificate_standing().judged_at); + case!("ArchivalFreshness", fixtures::archival_freshness()); + case!("SealAuditReport", fixtures::seal_audit_report()); + case!("SealRepairResponse", fixtures::seal_repair_response()); case!("SealDeclarer", fixtures::seal_declarer()); case!("SealSummaryResponse", fixtures::seal_summary_response()); case!("InstalledPlugin", fixtures::installed_plugin()); @@ -1035,6 +1054,81 @@ fn deactivation_reason_documents_every_kind() { ); } +/// `RevocationStanding` is `UNCHECKED` above: its variants carry three different +/// payload fields, so a single fixture cannot emit the union the key-set +/// comparison expects. This pins what that comparison could not — the wire form +/// of every variant, and that the schema documents exactly the fields they can +/// emit between them. +/// +/// The property that matters is the one a reader of the spec relies on: which +/// field to read for which `status`. Getting that wrong would send an auditor to +/// `at` for a CRL's `thisUpdate`, which is a different date about a different +/// question. +#[test] +fn every_revocation_standing_serialises_as_documented() { + let spec = spec(); + let schema = &schemas(&spec)["RevocationStanding"]; + let documented: BTreeSet = schema["properties"] + .as_object() + .expect("RevocationStanding documents properties") + .keys() + .cloned() + .collect(); + + let ts = fixtures::certificate_standing().judged_at.at; + let cases: &[(dpp_types::RevocationStanding, &str, Option<&str>)] = &[ + ( + dpp_types::RevocationStanding::NotRevoked { as_of: ts }, + "notRevoked", + Some("asOf"), + ), + ( + dpp_types::RevocationStanding::Revoked { at: ts }, + "revoked", + Some("at"), + ), + ( + dpp_types::RevocationStanding::NotAvailable, + "notAvailable", + None, + ), + ( + dpp_types::RevocationStanding::Unusable { + reason: "the CRL's own signature does not verify".to_owned(), + }, + "unusable", + Some("reason"), + ), + ]; + + let mut emitted: BTreeSet = BTreeSet::new(); + for (value, status, payload) in cases { + let json = serde_json::to_value(value).expect("serialises"); + let object = json.as_object().expect("an object"); + assert_eq!( + object["status"], *status, + "the tag is what a reader switches on: {json}" + ); + match payload { + Some(field) => assert!( + object.contains_key(*field), + "`{status}` must carry `{field}`: {json}" + ), + None => assert_eq!( + object.len(), + 1, + "`{status}` answers nothing further and must carry nothing further: {json}" + ), + } + emitted.extend(object.keys().cloned()); + } + + assert_eq!( + documented, emitted, + "the schema must document exactly the fields these variants emit" + ); +} + /// `ResponsibilityBasis` is `UNCHECKED` above because neither checker can /// express a `oneOf` mixing string variants with an externally-tagged object. /// This pins what the schema could not: the wire form of every variant, read off @@ -3559,14 +3653,130 @@ mod fixtures { seal_value: "MIIB...".into(), sealed_at: ts(), signing_cert_ref: Some("5".repeat(64)), + attested_sealed_at: Some(ts()), + archival: archival_freshness(), + conformance_level: Some(SealConformanceLevel::BaselineLta), + // Populated and deliberately **different** from the level above: the + // downgrade is the case these two fields exist to make visible, so + // the fixture is the one where they disagree. + evidenced_level: Some(SealConformanceLevel::BaselineT), placeholder: false, current_jws: "eyJhbGciOiJFZERTQSJ9..iii".into(), current_payload_hash: "6".repeat(64), sealed_payload_hash: Some("6".repeat(64)), coverage: SealCoverage::Current, + // Populated rather than `None`, deliberately. A null would still + // carry the `origin` key and satisfy a top-level key-set check, + // while never once comparing the nested object against + // `SealOrigin.yaml` — so the schema this exists to pin would go + // unchecked. + origin: Some(seal_origin()), + binding: seal_binding(), + validation: dpp_types::SealValidationStatus::of( + &seal_binding(), + Some(&certificate_standing()), + ), + certificate: Some(certificate_standing()), // A `&'static str` constant on the response type; the fixture only // needs a value of the right shape for the key set. - verification: "not validated by this node", + verification: "not validated by this node — see the note on the route", + } + } + + /// The `rearmed` outcome — the one that spends money. + /// + /// `queued` is the milder half of the pair, so the fixture is the other: if + /// the wire name for crossing the double-billing line ever drifted, that is + /// the one worth catching. + pub fn seal_repair_response() -> dpp_vault::handlers::seal::SealRepairResponse { + dpp_vault::handlers::seal::SealRepairResponse { + action: dpp_vault::handlers::seal::SealRepairAction::Rearmed, + payload_hash: "9".repeat(64), + note: "a replacement seal has been queued", + } + } + + /// A pass that found something, with the list truncated. + /// + /// The populated shape rather than a clean one: a report with no findings + /// leaves `brokenPassports` empty, which would check the array's presence + /// and never its element type. + pub fn seal_audit_report() -> dpp_types::SealAuditReport { + dpp_types::SealAuditReport { + completed_at: ts(), + checked: 1200, + sound: 1180, + superseded: 17, + broken: 2, + certificate_failed: 1, + unreadable: 1, + broken_passports: vec![PassportId::new()], + truncated: true, + } + } + + /// The variant carrying a date, so the optional field is checked too. + /// + /// `lapsed` rather than `current`: both carry a date, and this is the one a + /// reader has to act on. + pub fn archival_freshness() -> dpp_types::ArchivalFreshness { + dpp_types::ArchivalFreshness::Lapsed { expires: ts() } + } + + /// The variant carrying a payload, so the optional field is checked too. + /// + /// `coversThisSignature` would satisfy a key-set check while never comparing + /// `covered` against the schema — and that field is the one a caller acts on. + pub fn seal_binding() -> dpp_types::SealBinding { + dpp_types::SealBinding::CoversAnotherDigest { + covered: "7".repeat(64), + } + } + + /// A certificate that is revoked and expired, judged against an attested + /// moment. + /// + /// The loaded variant of every optional part: a revocation date to compare, + /// a window that has been left, and `attested: true`, which is what turns + /// those from observations into findings. A fixture where nothing had + /// happened would check the key set and none of the values. + pub fn certificate_standing() -> dpp_types::CertificateStanding { + dpp_types::CertificateStanding { + validity: dpp_types::ValidityWindow { + not_before: ts(), + not_after: ts(), + standing: dpp_types::WindowStanding::Expired, + }, + judged_at: dpp_types::JudgedTime { + at: ts(), + attested: true, + }, + revocation: dpp_types::RevocationStanding::Revoked { at: ts() }, + } + } + + /// The `indeterminate` half of the vocabulary, with its `null` + /// sub-indication. + /// + /// Chosen over `notIntact` deliberately: the nullable field is the one a + /// schema can get wrong without any fixture noticing, and this is also the + /// answer a *sound* seal gets — the value most likely to be misread, and so + /// the one worth pinning against the spec. + pub fn seal_validation_status() -> dpp_types::SealValidationStatus { + dpp_types::SealBinding::CoversThisSignature.validation_status() + } + + /// A self-issued origin — what the local development backend produces. + /// + /// The negative case is the realistic one: nothing in this workspace can + /// mint a certificate a provider actually issued, and a fixture claiming + /// otherwise would pin a shape no code here can produce. + pub fn seal_origin() -> dpp_types::SealOrigin { + dpp_types::SealOrigin { + subject: "CN=Odal Node local development seal".into(), + issuer: "CN=Odal Node local development seal".into(), + self_issued: true, + creation_device: dpp_types::CreationDevice::NotAQualifiedCertificate, } } @@ -3577,6 +3787,8 @@ mod fixtures { sealed: 40, exhausted: 0, sealing_configured: true, + trust_mode: Some("live"), + audit: Some(seal_audit_report()), } } diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index f81f66ac..55b5486c 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -19,6 +19,12 @@ //! the returned `.p7s` validates against the EU Trusted List. Those need the //! provider's sandbox, and no local test can stand in for them. //! +//! The last test here runs the same loop over the **local** backend instead, +//! which produces genuine detached CAdES rather than a stand-in, and reads +//! `dpp_seal::qualification`'s verdict off the seal that lands in the database. +//! That is the one part of the Trusted List question answerable without a +//! provider: whether anybody issued the certificate at all. +//! //! Run: `just seal-sim` (or //! `cargo test -p dpp-node --features integration-tests --test seal_outbox -- --nocapture`) @@ -606,3 +612,1037 @@ async fn a_wrong_key_is_rejected_and_the_row_stays_pending() { "a rejected call must never leave a seal on the passport" ); } + +/// A real passport's seal says, out of its own bytes, that no provider issued it. +/// +/// The question an operator asks before relying on anything: *is this seal worth +/// something?* Until now the only way to answer it was to read the node's +/// configuration — which records what the operator intended, not what came back. +/// `dpp_seal::qualification` answers it from the seal instead. +/// +/// Everything here is the real path: real PostgreSQL, real Ed25519 publish, the +/// real outbox and drain, and the real local backend producing genuine detached +/// CAdES. The verdict is then read off the bytes that landed in the database. +/// +/// No Trusted List is consulted, and that is deliberate rather than a shortcut — +/// a self-issued certificate is recognised as such before any list is reached, so +/// a node that cannot get to the network still knows its seals carry no legal +/// weight. The list lookups are exercised against real published lists in +/// `dpp_seal::qualification`'s own tests. +#[tokio::test] +async fn a_locally_sealed_passport_reports_that_no_provider_issued_it() { + use dpp_seal::qualification::{IssuerStanding, qualify}; + use dpp_types::CreationDevice; + + let _pg = start_pg().await; + let dal = _pg.dal.clone(); + + let key_dir = tempfile::tempdir().expect("temp dir"); + let key_path = key_dir.path().join("keystore.json"); + let store = dpp_crypto::keystore::KeyStore::open(&key_path, "test-pass").expect("keystore"); + store.generate_key("root").expect("generate key"); + let identity = Arc::new(dpp_vc::LocalIdentityService::new( + Arc::new(store), + "root".to_owned(), + "seal-sim.example.com".to_owned(), + )); + + let passport_repo = Arc::new(PgPassportRepo::new(dal.clone())); + let seal_outbox = Arc::new(PgSealOutboxRepo::new(dal.clone())); + let service = PassportService::new( + passport_repo.clone(), + identity, + Arc::new(dpp_domain::PassthroughRegistry::new()) as Arc, + Arc::new(PgAuditRepo::new(dal.clone())), + Arc::new(dpp_common::event::NoOpEventBus), + Arc::new(GhostRegistrySync), + Arc::new(GhostArchive), + OperatorIdentity { + legal_name: "Test Operator GmbH".to_owned(), + country: "MK".to_owned(), + }, + ) + .with_seal_outbox(seal_outbox.clone()) + // Wired exactly as the composition root wires it, and unconditionally for + // the same reason: reading a stored seal is not sealing. + .with_seal_inspector(Arc::new(dpp_seal::CadesInspector::new())) + // So the dossier an authority is handed can be generated and inspected. + .with_evidence_store(Arc::new(dpp_dal::pg::PgEvidenceDossierRepo::new( + dal.clone(), + ))); + + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + let published = service.publish(id, &auth()).await.expect("publish"); + let jws = published.jws_signature.clone().expect("publish signs"); + let expected_digest = hex::encode(Sha256::digest(jws.as_bytes())); + + // The local backend: a key and a self-signed certificate generated per node. + let seal_dir = tempfile::tempdir().expect("temp dir"); + let backend = + dpp_seal::local::LocalIdentity::load_or_create(seal_dir.path()).expect("identity"); + let adapter: Arc = Arc::new(QtspSealAdapter::new(backend)); + + let outbox_dyn: Arc = seal_outbox.clone(); + let stats = drain_once( + &outbox_dyn, + &adapter, + &dpp_domain::seal::SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + credential_id: "node".to_owned(), + }, + // What this backend actually offers: it seals on its own behalf, at + // every baseline level. + SealMode::OperatorSeal, + SealConformanceLevel::BaselineLta, + 10, + ) + .await; + assert_eq!(stats.sealed, 1, "the drain must seal the queued row"); + + let sealed = passport_repo + .find_by_id(id) + .await + .expect("read back") + .expect("passport exists"); + let seal = sealed.seal.clone().expect("seal landed on the passport"); + let der = BASE64 + .decode(&seal.seal_value) + .expect("the seal is base64 DER"); + + // ── The level the bytes actually evidence ─────────────────────────────── + // + // Not the level that was requested, and not the one recorded on the + // envelope: what `cades::evidenced_level` finds in the CAdES itself. This + // is the assertion that makes the local backend a usable stand-in — a + // sandbox run exercises the structure a provider's seal has, rather than a + // `B-B` envelope that skips every path above it. + assert_eq!( + dpp_seal::cades::evidenced_level(&der).expect("readable"), + Some(SealConformanceLevel::BaselineLta), + "the local backend must emit the material an LTA seal carries, not merely claim the level" + ); + + // ── That the seal is bound to THIS passport, from its own bytes ───────── + // + // The question a demo has to be able to answer: not "is there a seal on this + // passport" — anyone can store bytes in a column — but "does this seal + // actually cover this passport's signature?" + // + // Answered here without consulting the outbox row that bought it. The seal + // states what it covers in its own signed attributes, and the signature over + // those attributes is checked before they are read, so the answer survives a + // restore from backup and cannot be forged by editing the claim. + let inspector = service + .seal_inspector + .as_ref() + .expect("the inspector is wired"); + assert_eq!( + inspector.binding(&seal, &expected_digest), + dpp_types::SealBinding::CoversThisSignature, + "the stored seal must demonstrably cover this passport's current signature" + ); + // And the node's own record agrees, which is the cross-check: the bytes and + // the bookkeeping describing the same thing is what makes either believable. + assert_eq!( + seal_outbox + .sealed_digest(id) + .await + .expect("record") + .as_deref(), + Some(expected_digest.as_str()) + ); + // The level the route will serve, from the bytes rather than the record. + assert_eq!( + inspector.evidenced_level(&seal), + Some(SealConformanceLevel::BaselineLta), + "requested LTA and the bytes must carry LTA, or the seal route reports a downgrade" + ); + assert_eq!( + seal.conformance_level, + Some(SealConformanceLevel::BaselineLta) + ); + + println!("binding : coversThisSignature ({expected_digest})"); + + // ── And the dossier an authority is actually handed says so ───────────── + // + // The dossier serves the seal beside the passport's *current* JWS. Those are + // the same thing here, and are not always: a passport re-published after + // sealing carries a seal over the previous signature until the drain catches + // up. Pairing them silently would hand an authority a seal that does not + // verify against the document beside it — which reads as tampering rather + // than as the stale seal it is. So the dossier states the relationship. + let record = service + .generate_evidence(id, &auth()) + .await + .expect("dossier generated"); + let seal_section = record + .dossier + .qualified_seal + .clone() + .expect("a sealed passport's dossier carries its seal"); + assert_eq!( + seal_section["payloadHash"].as_str(), + Some(expected_digest.as_str()) + ); + assert_eq!( + seal_section["binding"]["result"].as_str(), + Some("coversThisSignature"), + "the dossier must say whether the seal covers the JWS it serves beside it: {seal_section}" + ); + + // And verifying that dossier **checks** the claim rather than reading it + // back. The generator's `binding` says what it believed; the verifier + // recomputes the digest from `signedOverJws` and opens the seal itself, so + // this is the whole chain closing on a file that needs no database, no node + // and no network to check. + let report = service + .verify_evidence(record.id) + .await + .expect("dossier verifies"); + let seal_check = report + .checks + .iter() + .find(|c| c.name == "qualified_seal") + .expect("the verifier runs a seal check"); + assert!( + matches!(seal_check.status, dpp_types::evidence::CheckStatus::Pass), + "the dossier's own seal must check out: {:?}", + seal_check.status + ); + // The two facts that decide whether anything else in that section carries + // weight, and which used to be recoverable only by parsing the CAdES by hand. + assert_eq!( + seal_section["origin"]["selfIssued"].as_bool(), + Some(true), + "a dossier must say on its face that its seal was signed by the node itself" + ); + assert_eq!( + seal_section["evidencedLevel"].as_str(), + Some("baseline-lta"), + "and what the bytes actually carry: {seal_section}" + ); + + // A third party's statement of when this was sealed, checked rather than + // read: the token's signature holds and its imprint covers this signature. + let attested = inspector + .attested_sealing_time(&seal) + .expect("an LTA seal carries a timestamp this node can check"); + assert!( + (chrono::Utc::now() - attested).num_seconds().abs() < 300, + "the attested time should be about now: {attested}" + ); + assert!( + seal_section["attestedSealedAt"].as_str().is_some(), + "and the dossier must carry it: {seal_section}" + ); + println!("attested : {attested}"); + + // The archival protection a fresh LTA seal carries, and the date it has to + // be renewed by. The level alone would say `baseline-lta` for ever. + let dpp_types::ArchivalFreshness::Current { expires } = + inspector.archival_freshness(&seal, chrono::Utc::now()) + else { + panic!("a freshly archived seal is current"); + }; + assert!(expires > chrono::Utc::now()); + assert_eq!( + seal_section["archival"]["state"].as_str(), + Some("current"), + "and the dossier must stamp it: {seal_section}" + ); + println!("archival : current until {expires}"); + + println!("dossier : qualified_seal = Pass"); + + // ── The verdict, read out of the stored seal ──────────────────────────── + let verdict = qualify(&der, &[], seal.sealed_at).expect("a readable CAdES seal"); + + println!("\n═══ QUALIFICATION OF THE STORED SEAL ═══"); + println!("passportId : {id}"); + println!("verdict : {verdict}"); + + let IssuerStanding::SelfIssued { subject } = &verdict.issuer else { + panic!("the local backend is self-signed: {:?}", verdict.issuer); + }; + assert!(!subject.is_empty(), "and the certificate names itself"); + assert!( + !verdict.is_provider_seal(), + "no provider stands behind this passport's seal" + ); + assert_eq!( + verdict.creation_device, + CreationDevice::NotAQualifiedCertificate, + "and the certificate makes no Annex III(j) claim about where its key lives" + ); + + // The backend's own verdict says the same thing from the other direction: + // the bytes check out, and that is the whole of what they prove. The two + // must not be able to disagree — a seal that verified *and* reported a + // provider behind it would be the failure this pair exists to make visible. + let verification = adapter.verify(&seal).await.expect("verifiable"); + assert_eq!( + verification.checks, + dpp_domain::seal::SealChecks::SignatureOnly + ); + assert!( + !verification.is_qualified_pass(), + "a self-signed development seal is never a qualified pass" + ); + + // ── What the seal read route will serve ───────────────────────────────── + // + // The same finding reached the way the HTTP handler reaches it: off the + // service's inspector, over the envelope as stored. This pins the service + // wiring and the value; the response's own shape is pinned by the OpenAPI + // contract gate, and the handler between them is a single `and_then`. + let served = service + .seal_inspector + .as_ref() + .expect("the inspector is wired") + .origin(&seal) + .expect("a readable CAdES seal"); + + assert!( + served.self_issued, + "the seal route must report that nothing issued this certificate" + ); + assert_eq!(served.issuer, served.subject); + assert_eq!( + served.creation_device, + CreationDevice::NotAQualifiedCertificate + ); + + println!( + "origin : selfIssued={} issuer={}", + served.self_issued, served.issuer + ); + + // ── The certificate's own standing, end to end ────────────────────────── + // + // Art. 32(1)(b)'s second limb, against a seal this workspace actually + // produced rather than a fixture. It exercises the whole revocation path: + // the local backend emits a real, signed, empty CRL under its own name at + // `B-LTA`, so reaching `notRevoked` means the list was matched to the + // certificate's issuer *and* its signature verified — a CRL that failed + // either check would report `unusable`, and one that was absent + // `notAvailable`. + let standing = service + .seal_inspector + .as_ref() + .expect("the inspector is wired") + .certificate_standing(&seal, Utc::now()) + .expect("a readable CAdES seal"); + + assert_eq!( + standing.validity.standing, + dpp_types::WindowStanding::Inside, + "a seal made moments ago is inside its certificate's window" + ); + match standing.revocation { + dpp_types::RevocationStanding::NotRevoked { as_of } => { + assert!( + (Utc::now() - as_of).num_minutes().abs() < 5, + "the CRL the local backend embeds speaks for right now" + ); + } + other => panic!("the local backend's own CRL must be usable, got {other:?}"), + } + + // And the verdict over all of it. Everything this node can check passes, + // and the answer is still `indeterminate` — the chain is not validated to a + // trust anchor, so `TOTAL-PASSED` is not reachable and the type cannot + // express it. A development seal reporting a pass is exactly the confusion + // this whole surface is arranged to prevent. + let binding = service + .seal_inspector + .as_ref() + .expect("the inspector is wired") + .binding(&seal, &expected_digest); + let status = dpp_types::SealValidationStatus::of(&binding, Some(&standing)); + assert_eq!( + status.indication, + dpp_types::ValidationIndication::Indeterminate + ); + assert_eq!( + status.sub_indication, None, + "nothing failed, so no table 6 value applies" + ); + + println!( + "certificate: window={:?} revocation={:?}", + standing.validity.standing, standing.revocation + ); +} + +/// **A seal corrupted at rest is found, repaired, and the replacement binds.** +/// +/// The whole loop the audit and the repair route exist for, against real +/// Postgres. Corruption at rest is the failure this addresses — a bad restore, a +/// truncated column, a disk that lied — and it is invisible to every count the +/// node reports, because those ask whether a seal is *present* and a corrupt one +/// is. +/// +/// The corruption is applied with raw SQL on purpose. Going through +/// `mark_sealed` would write a well-formed envelope and close a row, which is +/// the one thing that cannot produce the state under test; the point is a +/// passport whose stored bytes changed underneath the node. +#[tokio::test] +async fn a_seal_corrupted_at_rest_is_found_and_repaired() { + use dpp_types::SealInspector as _; + + let _pg = start_pg().await; + let dal = _pg.dal.clone(); + + let key_dir = tempfile::tempdir().expect("temp dir"); + let store = + dpp_crypto::keystore::KeyStore::open(key_dir.path().join("keystore.json"), "test-pass") + .expect("keystore"); + store.generate_key("root").expect("generate key"); + let identity = Arc::new(dpp_vc::LocalIdentityService::new( + Arc::new(store), + "root".to_owned(), + "seal-sim.example.com".to_owned(), + )); + + let passport_repo = Arc::new(PgPassportRepo::new(dal.clone())); + let seal_outbox = Arc::new(PgSealOutboxRepo::new(dal.clone())); + let service = PassportService::new( + passport_repo.clone(), + identity, + Arc::new(dpp_domain::PassthroughRegistry::new()) as Arc, + Arc::new(PgAuditRepo::new(dal.clone())), + Arc::new(dpp_common::event::NoOpEventBus), + Arc::new(GhostRegistrySync), + Arc::new(GhostArchive), + OperatorIdentity { + legal_name: "Test Operator GmbH".to_owned(), + country: "MK".to_owned(), + }, + ) + .with_seal_outbox(seal_outbox.clone()) + .with_seal_inspector(Arc::new(dpp_seal::CadesInspector::new())); + + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + let published = service.publish(id, &auth()).await.expect("publish"); + let expected_digest = hex::encode(Sha256::digest( + published.jws_signature.as_ref().expect("signed").as_bytes(), + )); + + let seal_dir = tempfile::tempdir().expect("temp dir"); + let backend = + dpp_seal::local::LocalIdentity::load_or_create(seal_dir.path()).expect("identity"); + let adapter: Arc = Arc::new(QtspSealAdapter::new(backend)); + let outbox_dyn: Arc = seal_outbox.clone(); + let key_ref = dpp_domain::seal::SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + credential_id: "node".to_owned(), + }; + let drained = drain_once( + &outbox_dyn, + &adapter, + &key_ref, + SealMode::OperatorSeal, + SealConformanceLevel::BaselineLta, + 10, + ) + .await; + assert_eq!(drained.sealed, 1); + + let inspector = dpp_seal::CadesInspector::new(); + let sound = passport_repo + .find_by_id(id) + .await + .unwrap() + .unwrap() + .seal + .expect("sealed"); + assert_eq!( + inspector.binding(&sound, &expected_digest), + dpp_types::SealBinding::CoversThisSignature + ); + + // ── Corrupt it where it lies ──────────────────────────────────────────── + // + // One byte of the signature flipped, re-encoded. The structure survives, so + // the seal still parses and reaches the signature check — which is what + // makes this `broken` rather than `unreadable`, and the two are treated very + // differently downstream. + let corrupted = { + use der::{Decode as _, Encode as _}; + let bytes = BASE64.decode(&sound.seal_value).expect("base64"); + let info = cms::content_info::ContentInfo::from_der(&bytes).expect("CMS"); + let mut sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + let mut sig = signers[0].signature.as_bytes().to_vec(); + let last = sig.len() - 1; + sig[last] ^= 0xff; + signers[0].signature = der::asn1::OctetString::new(sig).expect("octets"); + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + BASE64.encode( + cms::content_info::ContentInfo { + content_type: info.content_type, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"), + ) + }; + sqlx::query("UPDATE odal.passport SET doc = jsonb_set(doc, '{seal,sealValue}', $1::jsonb) WHERE id = $2") + .bind(serde_json::to_string(&corrupted).expect("json")) + .bind(id.0) + .execute(dal.pool()) + .await + .expect("corrupt the stored seal"); + + // ── Every existing count still calls it healthy ───────────────────────── + assert_eq!( + seal_outbox.unsealed_published_count().await.unwrap(), + 0, + "the count asks whether a seal is present, and a corrupt one is" + ); + + // ── The audit finds it ────────────────────────────────────────────────── + let (audit, _) = + dpp_node::infra::seal_drain::audit_seals_once(&outbox_dyn, &inspector, 100, None, None) + .await + .expect("the batch is readable"); + assert_eq!(audit.broken, 1, "the audit must see what the counts cannot"); + assert_eq!(audit.broken_passports, vec![id], "and name it"); + + // ── Repair: re-arm the sealed row, then drain ─────────────────────────── + // + // This is the step that spends money, and the one `enqueue` refuses: the row + // is `sealed`, so the ordinary path leaves it alone. + seal_outbox + .enqueue(id, &expected_digest) + .await + .expect("enqueue is a no-op here"); + assert_eq!( + seal_outbox.status_counts().await.unwrap().pending, + 0, + "enqueue must NOT move a sealed row — that guarantee is what makes the sweep safe" + ); + + assert!( + seal_outbox + .rearm_sealed(id, &expected_digest, "test: stored seal did not verify") + .await + .expect("rearm"), + "the repair path does move it" + ); + + let repaired = drain_once( + &outbox_dyn, + &adapter, + &key_ref, + SealMode::OperatorSeal, + SealConformanceLevel::BaselineLta, + 10, + ) + .await; + assert_eq!(repaired.sealed, 1, "the replacement was bought"); + + let after = passport_repo + .find_by_id(id) + .await + .unwrap() + .unwrap() + .seal + .expect("sealed"); + assert_eq!( + inspector.binding(&after, &expected_digest), + dpp_types::SealBinding::CoversThisSignature, + "and it covers the same signature the broken one was meant to" + ); + assert_ne!( + after.seal_value, corrupted, + "the corrupt bytes are gone, not merely re-marked" + ); + + let (clean, _) = + dpp_node::infra::seal_drain::audit_seals_once(&outbox_dyn, &inspector, 100, None, None) + .await + .expect("the batch is readable"); + assert_eq!(clean.broken, 0, "and the audit agrees it is fixed"); +} + +/// `rearm_sealed` moves **only** `sealed` rows. +/// +/// The guard that keeps the repair path from becoming a way to disturb rows the +/// drain owns. A `pending` row re-armed underneath the drain would have its +/// backoff reset on every call, turning a failing row into a hot loop — which is +/// the reasoning `enqueue` already gives for refusing the same thing. +#[tokio::test] +async fn rearm_sealed_leaves_rows_the_drain_owns_alone() { + let _pg = start_pg().await; + let outbox = PgSealOutboxRepo::new(_pg.dal.clone()); + // A real passport: `seal_outbox.passport_id` carries a foreign key, so a + // row cannot be queued for an id nothing owns. + let passport_repo = PgPassportRepo::new(_pg.dal.clone()); + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + let digest = "ab".repeat(32); + + outbox.enqueue(id, &digest).await.expect("queue"); + assert_eq!(outbox.status_counts().await.unwrap().pending, 1); + + assert!( + !outbox + .rearm_sealed(id, &digest, "should not apply") + .await + .expect("rearm"), + "a pending row is the drain's, and must not be re-armed underneath it" + ); + assert_eq!( + outbox.status_counts().await.unwrap().pending, + 1, + "and it is still exactly where it was" + ); +} + +/// A minimal envelope, to close a row. +/// +/// Nothing reads these bytes: the test below is about what the *row* records, +/// and the seal-reading path has its own suites against real CAdES. +fn envelope_for_a_closed_row() -> dpp_domain::seal::SealedEnvelope { + dpp_domain::seal::SealedEnvelope { + format: dpp_domain::seal::SealFormat::Cades, + seal_value: "BASE64-NOT-READ-HERE".into(), + signing_cert_ref: None, + conformance_level: None, + sealed_at: Utc::now(), + placeholder: false, + } +} + +/// **A re-armed row says why it was re-armed, on the row.** +/// +/// This is the one place the node buys a seal for a digest it has already paid +/// for, and the reason is written onto the row rather than only logged. A log +/// line is the wrong home for it: logs rotate, and the question this answers — +/// *why does this passport have two seal rows for one signature* — is asked +/// months later by whoever is reconciling an invoice, against the database. +/// +/// It also survives the drain: `message` is what the row carries until an +/// attempt overwrites it, so the record is legible for as long as the repair is +/// outstanding, which is when anyone would look. +#[tokio::test] +async fn a_rearmed_row_records_why_it_was_rearmed() { + let _pg = start_pg().await; + let outbox = PgSealOutboxRepo::new(_pg.dal.clone()); + let passport_repo = PgPassportRepo::new(_pg.dal.clone()); + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + let digest = "cd".repeat(32); + + outbox.enqueue(id, &digest).await.expect("queue"); + let row = outbox + .due(10) + .await + .expect("due") + .into_iter() + .find(|r| r.passport_id == id) + .expect("the queued row is due"); + outbox + .mark_sealed(row.id, &envelope_for_a_closed_row()) + .await + .expect("close the row"); + + let reason = "repaired by user-ops: stored seal did not verify"; + assert!( + outbox + .rearm_sealed(id, &digest, reason) + .await + .expect("rearm"), + "a sealed row is the one thing this moves" + ); + + let message: Option = + sqlx::query_scalar("SELECT message FROM odal.seal_outbox WHERE passport_id = $1") + .bind(id.0) + .fetch_one(_pg.dal.pool()) + .await + .expect("read the row back"); + assert_eq!( + message.as_deref(), + Some(reason), + "the row must carry the reason a second seal is being bought" + ); + + // And the retry state was reset, or the replacement would inherit the + // backoff of a row that had already succeeded. + let attempts: i32 = + sqlx::query_scalar("SELECT attempts FROM odal.seal_outbox WHERE passport_id = $1") + .bind(id.0) + .fetch_one(_pg.dal.pool()) + .await + .expect("read attempts"); + assert_eq!(attempts, 0); +} + +/// **A completed pass survives a restart, and so does an unfinished one.** +/// +/// Both halves matter for different reasons. The report is what the operator +/// surface serves, and without it a restart makes the node say "no pass has +/// completed" for a whole walk — hours on a large estate, and indistinguishable +/// from an audit that is not running. The *position* is the one that saves a +/// node whose estate takes longer to walk than it goes between restarts: without +/// it, such a node starts from the beginning for ever and publishes nothing at +/// all, while doing every bit of the work. +#[tokio::test] +async fn an_audit_walk_and_its_last_report_outlive_the_process() { + use dpp_types::SealAuditStore as _; + + let _pg = start_pg().await; + let store = dpp_dal::pg::PgSealAuditRepo::new(_pg.dal.clone()); + + // A node that has never finished a walk reports neither — the state the + // route renders as `audit: null`. + let (progress, report) = store.load().await.expect("load"); + assert!(progress.is_none() && report.is_none()); + + let started = Utc::now(); + let id = PassportId::new(); + store + .save_progress(&dpp_types::SealAuditProgress { + started_at: started, + cursor: Some(id), + checked: 600, + sound: 598, + superseded: 1, + broken: 1, + certificate_failed: 0, + unreadable: 0, + broken_passports: vec![id], + }) + .await + .expect("save progress"); + + let (progress, report) = store.load().await.expect("load"); + let progress = progress.expect("the walk is still in flight"); + assert_eq!(progress.cursor, Some(id), "a restart resumes, not restarts"); + assert_eq!(progress.checked, 600, "and keeps the totals it had counted"); + assert_eq!( + progress.started_at.timestamp(), + started.timestamp(), + "including the moment the walk began, which bounds what it is about" + ); + assert!( + report.is_none(), + "a walk in flight is not a result — publishing a partial count would read \ + exactly like a complete one" + ); + + // Completing publishes the report and clears the walk. + let completed = dpp_types::SealAuditReport { + completed_at: Utc::now(), + checked: 1200, + sound: 1198, + superseded: 1, + broken: 1, + certificate_failed: 0, + unreadable: 0, + truncated: false, + broken_passports: vec![id], + }; + store.complete(&completed).await.expect("complete"); + + let (progress, report) = store.load().await.expect("load"); + assert!( + progress.is_none(), + "a finished walk must not be resumable, or a restart would 'continue' from \ + its own end and publish a second report having checked nothing" + ); + let report = report.expect("the completed pass is served after a restart"); + assert_eq!(report.checked, 1200); + assert_eq!(report.broken_passports, vec![id], "and still names them"); +} + +/// **A pass describes the seals that existed when it started.** +/// +/// Seals land while a walk runs, and which of them a pass happens to see would +/// otherwise depend on where its cursor had reached — so two consecutive passes +/// disagree for reasons that have nothing to do with the seals. The bound makes +/// the population nameable. It costs no coverage: the drain checks a seal's +/// binding before accepting it, so one written mid-walk was verified as it +/// landed, and the next pass covers it anyway. +#[tokio::test] +async fn a_walk_skips_seals_written_after_it_began() { + let _pg = start_pg().await; + let outbox = PgSealOutboxRepo::new(_pg.dal.clone()); + let passport_repo = PgPassportRepo::new(_pg.dal.clone()); + + // Two sealed passports, both closed through the outbox exactly as the drain + // closes them. + let mut ids = Vec::new(); + for _ in 0..2 { + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + // Published with a signature, or the walk's own query skips it. + sqlx::query( + "UPDATE odal.passport SET published_at = now(), + doc = jsonb_set(doc, '{jwsSignature}', '\"header.payload.sig\"'::jsonb, true) + WHERE id = $1", + ) + .bind(id.0) + .execute(_pg.dal.pool()) + .await + .expect("publish"); + let digest = dpp_types::digest_for_jws("header.payload.sig"); + outbox.enqueue(id, &digest).await.expect("queue"); + let row = outbox + .due(10) + .await + .expect("due") + .into_iter() + .find(|r| r.passport_id == id) + .expect("queued row is due"); + outbox + .mark_sealed(row.id, &envelope_for_a_closed_row()) + .await + .expect("seal"); + ids.push(id); + } + + let unbounded = outbox + .sealed_passports(10, None, None) + .await + .expect("walk everything"); + assert_eq!(unbounded.len(), 2, "both seals exist"); + + // A walk that started before either was sealed sees neither. + let bounded = outbox + .sealed_passports(10, None, Some(Utc::now() - chrono::Duration::hours(1))) + .await + .expect("bounded walk"); + assert!( + bounded.is_empty(), + "seals written after the walk began are not this pass's business" + ); + + // And one that started now sees both, because both predate it. + let after = outbox + .sealed_passports(10, None, Some(Utc::now() + chrono::Duration::seconds(1))) + .await + .expect("bounded walk"); + assert_eq!(after.len(), 2); + + // A seal that cannot be dated is kept rather than skipped: not knowing when + // something was sealed is not a reason to stop looking at it. + // + // Produced by clearing the row's `sealed_at` rather than removing the row, + // because the app role deliberately holds no DELETE on this table — the row + // is the record that a seal was bought. A seal written before the outbox + // carried timestamps arrives in exactly this state. + sqlx::query("UPDATE odal.seal_outbox SET sealed_at = NULL WHERE passport_id = $1") + .bind(ids[0].0) + .execute(_pg.dal.pool()) + .await + .expect("undate the seal"); + let orphaned = outbox + .sealed_passports(10, None, Some(Utc::now() - chrono::Duration::hours(1))) + .await + .expect("bounded walk"); + assert_eq!( + orphaned.len(), + 1, + "an undateable seal stays in the walk: {orphaned:?}" + ); + assert_eq!(orphaned[0].passport_id, ids[0]); +} + +/// **A seal whose certificate was not valid when it was made is found by the +/// audit, and is not called broken.** +/// +/// The condition #329 was filed for. The signature holds and the seal covers its +/// passport perfectly — every count the node reports says healthy — while the +/// certificate that made it had expired before the attested moment, which +/// ETSI EN 319 102-1 reports as `TOTAL-FAILED`. +/// +/// The tamper is the cheapest one that produces the state: the seal +/// certificate's validity window is moved into the past. **The CMS signature +/// does not break**, because it covers the signed attributes and not the +/// certificate travelling beside them — the same asymmetry that lets a +/// relabelled issuer through, one field along. +/// +/// Counted apart from `broken` on purpose: a broken seal is worth replacing, and +/// this one is not, since the replacement would come from the same certificate. +#[tokio::test] +async fn a_seal_made_under_an_invalid_certificate_is_found_and_not_called_broken() { + use der::{Decode as _, Encode as _}; + use dpp_types::SealInspector as _; + + let _pg = start_pg().await; + let dal = _pg.dal.clone(); + + let key_dir = tempfile::tempdir().expect("temp dir"); + let store = + dpp_crypto::keystore::KeyStore::open(key_dir.path().join("keystore.json"), "test-pass") + .expect("keystore"); + store.generate_key("root").expect("generate key"); + let identity = Arc::new(dpp_vc::LocalIdentityService::new( + Arc::new(store), + "root".to_owned(), + "seal-sim.example.com".to_owned(), + )); + + let passport_repo = Arc::new(PgPassportRepo::new(dal.clone())); + let seal_outbox = Arc::new(PgSealOutboxRepo::new(dal.clone())); + let service = PassportService::new( + passport_repo.clone(), + identity, + Arc::new(dpp_domain::PassthroughRegistry::new()) as Arc, + Arc::new(PgAuditRepo::new(dal.clone())), + Arc::new(dpp_common::event::NoOpEventBus), + Arc::new(GhostRegistrySync), + Arc::new(GhostArchive), + OperatorIdentity { + legal_name: "Test Operator GmbH".to_owned(), + country: "MK".to_owned(), + }, + ) + .with_seal_outbox(seal_outbox.clone()) + .with_seal_inspector(Arc::new(dpp_seal::CadesInspector::new())); + + let draft = draft_passport(); + let id = draft.id; + passport_repo.create(draft).await.expect("create draft"); + let published = service.publish(id, &auth()).await.expect("publish"); + let expected_digest = hex::encode(Sha256::digest( + published.jws_signature.as_ref().expect("signed").as_bytes(), + )); + + let seal_dir = tempfile::tempdir().expect("temp dir"); + let backend = + dpp_seal::local::LocalIdentity::load_or_create(seal_dir.path()).expect("identity"); + let adapter: Arc = Arc::new(QtspSealAdapter::new(backend)); + let outbox_dyn: Arc = seal_outbox.clone(); + let key_ref = dpp_domain::seal::SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + credential_id: "node".to_owned(), + }; + // LTA, so the seal carries a timestamp: without an attested moment the same + // certificate would be `indeterminate` rather than a finding, which is the + // distinction the audit relies on. + assert_eq!( + drain_once( + &outbox_dyn, + &adapter, + &key_ref, + SealMode::OperatorSeal, + SealConformanceLevel::BaselineLta, + 10, + ) + .await + .sealed, + 1 + ); + + // ── Move the certificate's window into the past ───────────────────────── + let sound = passport_repo + .find_by_id(id) + .await + .unwrap() + .unwrap() + .seal + .expect("sealed"); + let backdated = { + let bytes = BASE64.decode(&sound.seal_value).expect("base64"); + let info = cms::content_info::ContentInfo::from_der(&bytes).expect("CMS"); + let mut sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let certs = sd.certificates.as_ref().expect("a certificate"); + let mut choices = certs.0.as_slice().to_vec(); + let cms::cert::CertificateChoices::Certificate(cert) = &mut choices[0] else { + panic!("the local backend embeds an X.509 certificate"); + }; + let secs = |days: i64| { + std::time::Duration::from_secs( + u64::try_from(Utc::now().timestamp() - days * 86_400).expect("after 1970"), + ) + }; + cert.tbs_certificate.validity = x509_cert::time::Validity { + not_before: x509_cert::time::Time::GeneralTime( + der::asn1::GeneralizedTime::from_unix_duration(secs(730)).expect("a date"), + ), + not_after: x509_cert::time::Time::GeneralTime( + der::asn1::GeneralizedTime::from_unix_duration(secs(365)).expect("a date"), + ), + }; + let mut set = der::asn1::SetOfVec::new(); + set.insert(choices.remove(0)).expect("certificate"); + sd.certificates = Some(cms::signed_data::CertificateSet(set)); + BASE64.encode( + cms::content_info::ContentInfo { + content_type: info.content_type, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"), + ) + }; + sqlx::query("UPDATE odal.passport SET doc = jsonb_set(doc, '{seal,sealValue}', $1::jsonb) WHERE id = $2") + .bind(serde_json::to_string(&backdated).expect("json")) + .bind(id.0) + .execute(dal.pool()) + .await + .expect("backdate the stored certificate"); + + // ── Every existing signal still says healthy ──────────────────────────── + let inspector = dpp_seal::CadesInspector::new(); + let stored = passport_repo + .find_by_id(id) + .await + .unwrap() + .unwrap() + .seal + .expect("sealed"); + assert_eq!( + inspector.binding(&stored, &expected_digest), + dpp_types::SealBinding::CoversThisSignature, + "the signature still holds — that is what makes this invisible" + ); + assert_eq!(seal_outbox.unsealed_published_count().await.unwrap(), 0); + + // ── The audit sees it, and files it apart from broken ─────────────────── + let (audit, _) = + dpp_node::infra::seal_drain::audit_seals_once(&outbox_dyn, &inspector, 100, None, None) + .await + .expect("the batch is readable"); + assert_eq!( + audit.certificate_failed, 1, + "the certificate was not valid at the attested moment" + ); + assert_eq!( + audit.broken, 0, + "and this is not a broken seal: replacing it would buy another from the same certificate" + ); + assert_eq!(audit.sound, 0, "nor is it sound"); + + // ── And the verdict says which, in the standard's words ───────────────── + let standing = inspector + .certificate_standing(&stored, Utc::now()) + .expect("readable"); + let status = dpp_types::SealValidationStatus::of( + &inspector.binding(&stored, &expected_digest), + Some(&standing), + ); + assert_eq!( + status.indication, + dpp_types::ValidationIndication::TotalFailed + ); + assert_eq!( + status.sub_indication, + Some(dpp_types::ValidationSubIndication::Expired), + "an attested time proves the seal was made after the window closed" + ); +} diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index c679e7cd..33f0cf23 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -242,6 +242,8 @@ async fn start_node_with_ruleset( // The trust posture is asserted through the authenticated node-state // route, not the public probe. trust: Some(trust.clone()), + // No audit task in this harness, so no completed pass to report. + seal_audit: None, ruleset_admin: Some(ruleset.clone()), idempotency: None, }; diff --git a/crates/dpp-seal/Cargo.toml b/crates/dpp-seal/Cargo.toml index 3bb8cf21..153ee1d7 100644 --- a/crates/dpp-seal/Cargo.toml +++ b/crates/dpp-seal/Cargo.toml @@ -19,6 +19,7 @@ dpp-domain = { workspace = true } # is what stands between a document that has not yet been trusted and the # network. That is precisely what the resolving SSRF guard is for. dpp-common = { path = "../dpp-common" } +dpp-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -67,8 +68,32 @@ der = "0.7" const-oid = "0.9" p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } rcgen = "0.14" +# Verifies one X.509 signature against an issuer's public key — and explicitly +# does NOT build certificate paths, which is what is wanted here: the trust +# anchor is already established (Official Journal -> LOTL -> national list), so +# what remains is a single signature check against a CA certificate the list +# itself carries. +# +# Features are pinned to the algorithms measured in the published lists +# (`trustlist::ca_key_survey`): RSA is 96.8% of 373 qualified-CA certificates, +# the elliptic-curve remainder is P-384 and P-521, and **none is P-256** — so +# the `p256` already here covers none of them. `p256` stays anyway because the +# local development backend emits it. +# +# Deliberately NOT enabled: `k256` (secp256k1 — a Bitcoin curve no EU CA uses), +# `ed25519`, `dsa`, and the broken-hash features `md2`/`md5`/`sha1`. An +# algorithm this cannot verify reports an unverified path rather than a pass, +# which is the safe direction, and `ca_key_survey` fails if a Member State ever +# lists a key algorithm outside RSA and EC. +x509-verify = { version = "0.4.8", default-features = false, features = [ + "x509", "rsa", "p256", "p384", "p521", "sha2", +] } [dev-dependencies] tempfile = "3" +# `rcgen` takes its dates as `time::OffsetDateTime` and does not re-export the +# type, so the certificate-standing tests — which turn on a certificate's +# `notBefore`/`notAfter` — need it directly. Already in the graph via `rcgen`. +time = { version = "0.3", default-features = false } tokio = { workspace = true } axum = { workspace = true } diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index b6fe903d..598db08f 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -267,17 +267,52 @@ mod tests { report.combinations_checked > 0, "a kit run that exercised no combination proves nothing" ); - // A note, not a failure, and one worth pinning: this backend is - // self-signed with no timestamp, so `BaselineB` is the only level it can - // honestly advertise — and the kit says out loud that such a seal stops - // verifying inside the retention period of anything it covers. + // This backend now advertises every baseline level, including ones that + // outlive the signing certificate, so the kit no longer notes that its + // seals expire with it. That note was about *levels*, and the levels + // genuinely changed. + // + // What did **not** change is the only thing that ever mattered about + // these seals, so it is pinned here instead. A structurally complete + // `B-LTA` envelope signed by a key this node generated for itself is + // still worth nothing: the verdict is founded on + // `SealChecks::SignatureOnly` and is not a qualified pass. An `LTA` that + // ever started reading as a qualified one would be this change's failure + // mode, and this is where it would be caught. + let sealed = adapter + .seal(SealRequest { + payload_hash: "ab".repeat(32), + mode: SealMode::OperatorSeal, + key_ref: SealCredentialRef { + qtsp_id: "local".into(), + credential_id: "node".into(), + }, + sig_format: SealFormat::Cades, + conformance_level: SealConformanceLevel::BaselineLta, + envelope: SealEnvelope::Detached, + }) + .await + .expect("the local backend seals at LTA"); + + let verdict = adapter.verify(&sealed).await.expect("verifiable"); + assert_eq!(verdict.checks, dpp_domain::seal::SealChecks::SignatureOnly); assert!( - report - .notes - .iter() - .any(|n| n.contains("certificate expiry")), - "the kit must still report that these seals do not outlive the certificate: {:?}", - report.notes + !verdict.is_qualified_pass(), + "a locally signed LTA envelope is structurally complete and legally nothing" + ); + assert_eq!( + dpp_seal_level(&sealed), + Some(SealConformanceLevel::BaselineLta), + "and the bytes must actually carry the LTA material, or the level is a claim" ); } + + /// The level the seal's own bytes evidence. + fn dpp_seal_level(env: &SealedEnvelope) -> Option { + use base64::Engine as _; + let der = base64::engine::general_purpose::STANDARD + .decode(&env.seal_value) + .expect("base64"); + crate::cades::evidenced_level(&der).expect("readable") + } } diff --git a/crates/dpp-seal/src/cades.rs b/crates/dpp-seal/src/cades.rs index e2718b37..d147edc7 100644 --- a/crates/dpp-seal/src/cades.rs +++ b/crates/dpp-seal/src/cades.rs @@ -5,25 +5,40 @@ //! ASN.1 handling across two of them is how the two quietly stop agreeing about //! what a seal contains. //! -//! # Everything here is *reported*, never *verified* +//! # What is *reported* and what is *verified*, kept apart by name //! -//! This module reads a structure. It builds no certificate chain, contacts no -//! Trusted List, and checks no revocation — so a certificate it names is the one -//! the seal **claims** signed it, on the seal's own word. For a qualified seal -//! that claim is worth checking and this module cannot check it; establishing -//! that the certificate was qualified, and current, at the moment of sealing is -//! an independent AdES validator's job. +//! Most of this module reads a structure and reports what it found. A +//! certificate it names is the one the seal **claims** signed it, on the seal's +//! own word — [`signer_certificate`], [`evidenced_level`] and +//! [`signer_certificate_thumbprint`] all work that way, and are named so the +//! distinction survives a skim. +//! +//! [`check_path_to`] is the exception, and the only one. It verifies signatures: +//! given a trust anchor established elsewhere, it walks from the seal's signer up +//! through the certificates the seal carries and checks that each link was really +//! signed by the one above it. That is a genuine cryptographic result, not a +//! report. +//! +//! It still does **not** make a seal qualified. No validity window is read and no +//! revocation is consulted, so a certificate that had expired or been revoked at +//! the moment of sealing passes this check. Establishing *that* remains an +//! independent AdES validator's job. //! //! The distinction is the whole reason this is a separate module with its own //! vocabulary. A convenience field that reads as verification while verifying //! nothing is worse than an absent one, because an absent field prompts the //! question and a populated one settles it wrongly. +use chrono::{DateTime, Utc}; use cms::cert::CertificateChoices; use cms::content_info::ContentInfo; use cms::signed_data::{SignedData, SignerInfo}; use der::{Decode as _, Encode as _}; use dpp_domain::seal::SealConformanceLevel; +use dpp_types::{ + CertificateStanding, CreationDevice, JudgedTime, RevocationStanding, SealOrigin, + ValidityWindow, WindowStanding, +}; use x509_cert::Certificate; use crate::error::SealError; @@ -32,23 +47,74 @@ fn malformed(what: impl std::fmt::Display) -> SealError { SealError::Backend(format!("cannot read the seal: {what}")) } -/// The one signer and the certificate it travels with. +/// The signer, its certificate, and everything else the seal carries. struct Signed { signer: SignerInfo, certificate: Certificate, - /// Whether `SignedData.crls` carried anything. + /// Every X.509 certificate embedded in the seal, the signer's included. + /// + /// A real CAdES seal usually travels with its issuing chain, which is what + /// makes a path to a trusted list's anchor reachable without fetching + /// anything: Italy's list publishes self-signed **roots**, so the + /// intermediate that actually issued a seal certificate will be found here + /// or nowhere. + chain: Vec, + /// The encapsulated content, when the structure carries one. + /// + /// Absent for a detached seal — that is what detached means. Present for a + /// **time-stamp token**, which is itself a `SignedData` and carries its + /// `TSTInfo` here; reading an attested sealing time means reaching it. + econtent: Option>, + /// Whatever `SignedData.crls` carried, as certificate revocation lists. /// - /// Captured here because the enclosing `SignedData` is dropped when this is - /// built, and it is the modern home of the revocation material that - /// distinguishes a long-term seal — see [`evidenced_level`]. A boolean - /// rather than the values themselves: nothing in this crate reads them, and - /// carrying them would invite a caller to treat "revocation data is present" - /// as "revocation was checked", which is the confusion this whole module is - /// arranged to prevent. - crls_present: bool, + /// Held rather than counted. This was a boolean for as long as nothing read + /// the values — the reasoning being that carrying them would invite a caller + /// to treat "revocation data is present" as "revocation was checked", which + /// is the confusion this module is arranged to prevent. [`certificate_standing`] + /// now actually reads them, so the distinction is made by what that function + /// returns instead: presence alone reports `NotAvailable` until a CRL is + /// found that covers this certificate and verifies under its issuer. + /// + /// Only the `crl` choice is kept. `other` is a container for formats this + /// crate cannot read — an OCSP response among them — and keeping an + /// unreadable value would have to be reported as material this node checked. + crls: Vec, +} + +/// `id-ce-subjectKeyIdentifier` — RFC 5280 §4.2.1.2. +const ID_CE_SUBJECT_KEY_IDENTIFIER: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("2.5.29.14"); + +/// Which embedded certificate the `SignerInfo` actually names. +/// +/// **Not simply the first.** RFC 5652 makes `SignedData.certificates` a SET, so +/// its order carries no meaning, and a seal travelling with its issuing chain +/// may well list an intermediate before the end-entity certificate. Taking +/// `[0]` happens to work for a seal carrying exactly one certificate — which is +/// every seal this crate produces — and silently reports the wrong certificate +/// for a real provider's seal, which is the case that matters. +fn signer_certificate_of<'a>( + signer: &SignerInfo, + certs: &'a [Certificate], +) -> Option<&'a Certificate> { + match &signer.sid { + cms::signed_data::SignerIdentifier::IssuerAndSerialNumber(ias) => certs.iter().find(|c| { + c.tbs_certificate.issuer == ias.issuer + && c.tbs_certificate.serial_number == ias.serial_number + }), + cms::signed_data::SignerIdentifier::SubjectKeyIdentifier(ski) => certs.iter().find(|c| { + c.tbs_certificate + .extensions + .as_ref() + .and_then(|e| e.iter().find(|e| e.extn_id == ID_CE_SUBJECT_KEY_IDENTIFIER)) + // The extension wraps the key identifier in an OCTET STRING, so + // its DER is a 2-byte header plus the bytes the SignerInfo names. + .is_some_and(|e| e.extn_value.as_bytes().ends_with(ski.0.as_bytes())) + }), + } } -/// Parse a detached CMS `SignedData` down to its single signer and certificate. +/// Parse a detached CMS `SignedData` down to its single signer and certificates. /// /// One signer is not a simplification: this crate sends one digest per request /// and a response bearing more than one signature does not answer the request @@ -73,14 +139,44 @@ fn parse(seal_der: &[u8]) -> Result { .certificates .as_ref() .ok_or_else(|| malformed("it carries no certificate"))?; - let Some(CertificateChoices::Certificate(certificate)) = certs.0.as_slice().first() else { + let chain: Vec = certs + .0 + .as_slice() + .iter() + .filter_map(|c| match c { + CertificateChoices::Certificate(c) => Some(c.clone()), + _ => None, + }) + .collect(); + if chain.is_empty() { return Err(malformed("it carries no X.509 certificate")); - }; + } + + // A seal naming a certificate it does not carry cannot be read: the + // alternative is to guess, and a guess here misattributes the seal. + let certificate = signer_certificate_of(signer, &chain) + .ok_or_else(|| malformed("it names a signer certificate it does not carry"))? + .clone(); Ok(Signed { signer: signer.clone(), - certificate: certificate.clone(), - crls_present: sd.crls.as_ref().is_some_and(|c| !c.0.as_slice().is_empty()), + certificate, + chain, + econtent: sd + .encap_content_info + .econtent + .as_ref() + .and_then(|c| c.decode_as::().ok()) + .map(|o| o.as_bytes().to_vec()), + crls: sd.crls.as_ref().map_or_else(Vec::new, |c| { + c.0.as_slice() + .iter() + .filter_map(|choice| match choice { + cms::revocation::RevocationInfoChoice::Crl(crl) => Some(crl.clone()), + cms::revocation::RevocationInfoChoice::Other(_) => None, + }) + .collect() + }), }) } @@ -193,7 +289,7 @@ pub fn evidenced_level(seal_der: &[u8]) -> Result, }; let timestamped = has(ID_AA_SIGNATURE_TIME_STAMP_TOKEN); - let long_term = signed.crls_present || has(ID_AA_ETS_REVOCATION_VALUES); + let long_term = !signed.crls.is_empty() || has(ID_AA_ETS_REVOCATION_VALUES); let archived = has(ID_AA_ETS_ARCHIVE_TIMESTAMP_V3) || has(ID_AA_ETS_ARCHIVE_TIMESTAMP_V2); Ok(Some(match (timestamped, long_term, archived) { @@ -233,6 +329,65 @@ pub fn signer_certificate_thumbprint(seal_der: &[u8]) -> Result, Ok(Some(hex::encode(Sha256::digest(&der)))) } +/// The digest the seal actually covers, read out of its signed attributes. +/// +/// # Why this is the binding, and the outbox row is not +/// +/// A detached CAdES says what it covers in exactly one place: the +/// `messageDigest` signed attribute, RFC 5652 §11.2. Everything else is +/// bookkeeping. This node records what it *asked* a backend to seal, and that +/// record is genuinely useful — it survives when the seal does not parse, and it +/// is how a re-published passport is spotted without any AdES tooling — but it +/// is a statement about our own outbox, not about the bytes. The two can +/// disagree, and only one of them is evidence. +/// +/// Reading it turns "this seal is for this passport" from a claim resting on our +/// own records into something checkable against the seal itself. +/// +/// # Only meaningful alongside the signature +/// +/// This attribute is *inside* the signature, which is what makes it worth +/// reading — but nothing here checks that signature. A caller comparing this +/// digest without also calling [`verify_against_embedded_certificate`] is +/// trusting a value anyone could have edited. Both, or neither. +/// +/// `Ok(None)` when the bytes are not a readable seal, or carry no signed +/// attributes at all — a seal whose digest is not inside it, which is a +/// different finding from one that names the wrong digest. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the attribute is present but malformed. That is +/// not a "no" — a caller must not read it as a mismatch. +pub fn covered_digest(seal_der: &[u8]) -> Result>, SealError> { + let Ok(signed) = parse(seal_der) else { + return Ok(None); + }; + let Some(attrs) = signed.signer.signed_attrs.as_ref() else { + return Ok(None); + }; + let Some(attr) = attrs + .iter() + .find(|a| a.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST) + else { + return Ok(None); + }; + + // RFC 5652 §11.2: exactly one value, an OCTET STRING. More than one is + // malformed rather than ambiguous, and picking the first would invent an + // answer to a question the seal did not settle. + let [value] = attr.values.as_slice() else { + return Err(malformed(format!( + "the messageDigest attribute carries {} values, not one", + attr.values.len() + ))); + }; + let octets: der::asn1::OctetString = value + .decode_as() + .map_err(|e| malformed(format!("the messageDigest is not an OCTET STRING: {e}")))?; + Ok(Some(octets.as_bytes().to_vec())) +} + /// Check the signature against the certificate the seal carries. /// /// A `true` means the signature over the signed attributes verifies under the @@ -242,11 +397,86 @@ pub fn signer_certificate_thumbprint(seal_der: &[u8]) -> Result, /// only a fragment of it depends on the certificate, which is why the decision to /// report it as a verdict belongs to the backend rather than here. /// -/// Only P-256 is understood, which is what this crate's local backend produces. -pub fn verify_against_embedded_certificate(seal_der: &[u8]) -> Result { - use p256::ecdsa::VerifyingKey; - use p256::ecdsa::signature::Verifier as _; +/// `rsaEncryption` — RFC 8017. Its `AlgorithmIdentifier` parameters must be NULL. +const RSA_ENCRYPTION: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); + +/// Build a verifier for a certificate's public key. +/// +/// # Why this is not just `VerifyingKey::try_from` +/// +/// RFC 3279 §2.3.1 requires the `AlgorithmIdentifier` parameters for +/// `rsaEncryption` to be present and NULL, and the strict SPKI decoder behind +/// the verifier enforces it. **A real listed qualified CA does not comply**: the +/// French notaries' delegated authority (`OU=REAL,OU=AC déléguée,OU=Notaires`) +/// omits the field, and every seal issued under it would otherwise report as +/// unverifiable — not as invalid, which is the safe direction, but as an answer +/// this node cannot give about a lawful seal. +/// +/// So an absent NULL is supplied before decoding. **This changes no key +/// material.** The modulus and exponent live in the SPKI's BIT STRING and are +/// untouched; the parameters field is metadata about the algorithm identifier, +/// and the algorithm is already known from its OID. Nothing weaker is accepted — +/// a key that fails for any other reason still fails. +/// +/// Narrow on purpose: only `rsaEncryption`, only when the field is absent, and +/// never when it is present and wrong. `every_listed_qualified_ca_yields_a_usable_verifier` +/// holds the line at **every** published CA, so a future non-conformance is +/// caught rather than quietly joining the set of authorities whose seals this +/// node cannot check. +fn verifying_key(certificate: &Certificate) -> Result { + let spki = &certificate.tbs_certificate.subject_public_key_info; + if spki.algorithm.oid != RSA_ENCRYPTION || spki.algorithm.parameters.is_some() { + return x509_verify::VerifyingKey::try_from(certificate) + .map_err(|e| malformed(format!("no verifier for this key: {e}"))); + } + + let mut normalised = spki.clone(); + normalised.algorithm.parameters = Some( + der::Any::encode_from(&der::asn1::Null) + .map_err(|e| malformed(format!("cannot encode the NULL parameters: {e}")))?, + ); + let der = normalised + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the public key: {e}")))?; + let reparsed = x509_cert::spki::SubjectPublicKeyInfoRef::from_der(&der) + .map_err(|e| malformed(format!("cannot re-read the public key: {e}")))?; + x509_verify::VerifyingKey::try_from(reparsed) + .map_err(|e| malformed(format!("no verifier for this RSA key: {e}"))) +} + +/// Whether this build can check signatures made by `certificate_der`'s key. +/// +/// The operator-facing form of the question `verifying_key` answers: will seals +/// issued under this certificate authority be checkable here, or will they come +/// back unverifiable? A `false` is not a judgement on the CA — it says this build +/// has no verifier for its algorithm, which is a gap on our side. +/// +/// # Errors +/// +/// None — an unreadable certificate is one whose signatures cannot be checked, +/// which is the same answer. +#[must_use] +pub fn can_verify_signatures_of(certificate_der: &[u8]) -> bool { + Certificate::from_der(certificate_der).is_ok_and(|c| verifying_key(&c).is_ok()) +} +/// # Every algorithm the published population actually uses +/// +/// This was P-256 only, which is what the local development backend emits — and +/// **none** of what a real provider does. Measured across the qualified CAs in +/// the trusted lists (`tests/ca_key_survey.rs`), 96.8% are RSA and the +/// elliptic-curve remainder is P-384 and P-521 with not one P-256. +/// +/// That mattered more than a missing feature. A caller that could not check the +/// signature could not read the digest either — the digest lives in an attribute +/// inside it — so this function returning an error was the whole seal-to-passport +/// binding degrading to "unknown" on the first real provider seal, silently, at +/// exactly the point it started to matter. +/// +/// It now uses the same verifier as [`check_path_to`], so the algorithms it +/// accepts are one list in `Cargo.toml` rather than two that can drift. +pub fn verify_against_embedded_certificate(seal_der: &[u8]) -> Result { let signed = parse(seal_der)?; // Absent signed attributes means the digest is not inside the seal, so @@ -261,64 +491,1725 @@ pub fn verify_against_embedded_certificate(seal_der: &[u8]) -> Result Result>, SealError> { + let signed = parse(seal_der)?; + let Some(attrs) = signed.signer.unsigned_attrs.as_ref() else { + return Ok(None); + }; + let Some(attr) = attrs + .iter() + .find(|a| a.oid == ID_AA_SIGNATURE_TIME_STAMP_TOKEN) + else { + return Ok(None); + }; + let [value] = attr.values.as_slice() else { + return Ok(None); + }; + let Ok(token_der) = value.to_der() else { + return Ok(None); + }; + let Ok(token) = parse(&token_der) else { + return Ok(None); + }; + + // The token signs its own payload, so both legs have to hold: the signature + // over the signed attributes, and the digest inside them over the content. + // Checking only the first would let the TSTInfo be swapped for another. + if !signature_holds(&token).unwrap_or(false) { + return Ok(None); + } + let Some(content) = token.econtent.as_ref() else { + return Ok(None); + }; + if !digest_matches(&token, content) { + return Ok(None); + } + + let Ok(info) = TstInfo::from_der(content) else { + return Ok(None); + }; + + // And that the authority's own certificate was valid when it says it + // stamped. Without this, a token minted under an expired certificate — or + // one built by whoever edited the seal — carries the same weight as a real + // one, and this time is what decides whether a certificate finding is a + // failure or an open question. + let Some(gen_time) = to_utc(info.gen_time.to_unix_duration().as_secs()) else { + return Ok(None); + }; + if !stamped_within_its_certificate(&token, gen_time) { + return Ok(None); + } + + // And that it is a timestamp **of this signature**, not merely a valid one. + // + // EN 319 122-1 clause 5.3: the imprint is over the `SignerInfo` signature + // value, without its ASN.1 tag and length. Without this leg a genuine token + // lifted from another seal — internally sound, signed by a real authority, + // saying a different time — would be accepted, and an unsigned attribute is + // exactly where such a swap is free to make. + { + use sha2::{Digest as _, Sha256}; + if info.message_imprint.hashed_message.as_bytes() + != Sha256::digest(signed.signer.signature.as_bytes()).as_slice() + { + return Ok(None); + } + } + Ok(Some(gen_time)) +} + +/// Whether a parsed structure's signature holds under its own certificate. +/// Was the authority's certificate valid at the moment the token claims? +/// +/// A timestamp is a statement by a certificate holder, and a statement made +/// after that certificate expired — or before it began — is not one the +/// certificate supports. Nothing else in a token is self-limiting: the signature +/// verifies for ever, and `genTime` is whatever the signer wrote. +/// +/// **This is the local half of checking an authority.** The other half — whether +/// anyone trusts it — is a Trusted List question about the `TSA/QTST` service +/// type (Reg. (EU) No 910/2014 Art. 42), which this node cannot yet ask. A +/// self-signed authority's token passes here, and that is correct: its +/// certificate is genuinely valid, it is simply nobody's. +/// +/// A window that cannot be read is refused rather than assumed: a token whose +/// authority certificate will not parse is not evidence of a time. +fn stamped_within_its_certificate(token: &Signed, gen_time: DateTime) -> bool { + let validity = &token.certificate.tbs_certificate.validity; + let (Some(not_before), Some(not_after)) = ( + instant_of(validity.not_before), + instant_of(validity.not_after), + ) else { + return false; + }; + (not_before..=not_after).contains(&gen_time) +} - // Re-encode as SET OF, matching what was signed (RFC 5652 §5.4). +fn signature_holds(signed: &Signed) -> Result { + let Some(signed_attrs) = signed.signer.signed_attrs.as_ref() else { + return Ok(false); + }; + let key = verifying_key(&signed.certificate)?; let to_verify = signed_attrs .to_der() .map_err(|e| malformed(format!("cannot re-encode the signed attributes: {e}")))?; - let sig = p256::ecdsa::DerSignature::from_bytes(signed.signer.signature.as_bytes()) - .map_err(|e| malformed(format!("not a DER ECDSA signature: {e}")))?; + let signature = x509_verify::SignatureRef::new( + &signed.signer.signature_algorithm, + signed.signer.signature.as_bytes(), + ); + match key.verify(x509_verify::VerifyInfo::new( + x509_verify::MessageOwned::from(to_verify), + signature, + )) { + Ok(()) => Ok(true), + Err(x509_verify::Error::Verification) => Ok(false), + Err(e) => Err(malformed(format!( + "the signature could not be checked: {e}" + ))), + } +} + +/// Whether the signed `messageDigest` matches `content`. +fn digest_matches(signed: &Signed, content: &[u8]) -> bool { + use sha2::{Digest as _, Sha256}; - Ok(vk.verify(&to_verify, &sig).is_ok()) + signed + .signer + .signed_attrs + .as_ref() + .and_then(|a| { + a.iter() + .find(|a| a.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST) + }) + .and_then(|a| a.values.as_slice().first()) + .and_then(|v| v.decode_as::().ok()) + .is_some_and(|d| d.as_bytes() == Sha256::digest(content).as_slice()) } -#[cfg(test)] -mod tests { - use super::*; +// ─── Whether the archival protection is still live ─────────────────────────── - /// Unreadable bytes yield no reference rather than an error. +/// How much life is left in a seal's archival timestamp. +/// +/// # Why a seal can stop being long-term without anything changing +/// +/// An archival timestamp is what keeps a `B-LTA` seal verifiable after its +/// signing certificate expires — which for a retention-locked passport is the +/// whole point, because the document outlives every certificate involved. +/// +/// **The archival timestamp expires too.** Its own timestamping authority's +/// certificate has a validity period, and once that passes the token can no +/// longer be validated on its own terms. ETSI's long-term profiles handle this +/// by **re-timestamping** before it happens, a new archive timestamp over the +/// old one. Nothing here does that, and nothing would notice: `evidenced_level` +/// reports `BaselineLta` from the *presence* of the attribute, so a seal whose +/// archival timestamp lapsed years ago reports exactly as it did on the day it +/// was bought. +/// +/// # A signal, never a verdict +/// +/// Deliberately the same posture the trust anchor's freshness takes, and for the +/// same reason: a seal whose archival timestamp is nearing expiry still verifies, +/// and that window is the only chance to renew without an outage. Folding this +/// into a failure would refuse documents that are fine and destroy the early +/// warning it exists to give. +/// +/// No threshold is applied here. [`Self::Current`] carries the date, and how +/// much notice is enough is a policy question belonging to whoever reads it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArchivalFreshness { + /// No archival timestamp at all — a seal below `B-LTA`. /// - /// The seal itself is fine — it was produced, paid for, and stored. Losing it - /// because a convenience field could not be filled would trade something that - /// matters for something that does not. - #[test] - fn unreadable_bytes_yield_no_thumbprint() { - assert_eq!( - signer_certificate_thumbprint(b"not a CMS structure at all").unwrap(), - None - ); - assert_eq!(signer_certificate_thumbprint(&[]).unwrap(), None); + /// Nothing to renew, which is different from a renewal that has lapsed. + NotArchived, + /// The archival timestamp's authority certificate is still valid. + Current { + /// When that certificate expires — the date by which re-timestamping + /// has to have happened. + expires: DateTime, + }, + /// It has expired. The archival protection has lapsed. + /// + /// The seal may still verify today; what is gone is the thing that was + /// meant to keep it verifying once its signing certificate goes. + Lapsed { + /// When the authority's certificate expired. The same value + /// [`Self::Current`] carries, read from the other side of it. + expires: DateTime, + }, + /// An archival timestamp is present and could not be read. + /// + /// **Not [`Self::Current`].** A token that cannot be checked is not a fresh + /// one, and reporting it as current is how a staleness signal goes quiet at + /// the moment it matters. + Unknown, +} + +/// Read how much life is left in a seal's archival timestamp. +/// +/// # What is checked, and the one thing that is not +/// +/// The token's own signature is verified, so a fabricated archival timestamp +/// does not produce a reassuring date. What is **not** checked is that the token +/// archives *this* seal: the `archive-time-stamp-v3` imprint is computed over the +/// concatenation EN 319 122-1 clause 5.5.3 specifies together with an +/// `ats-hash-index-v3`, neither of which this crate builds or reads — see +/// `local::LocalIdentity::sign_detached_at`, which says the same thing from the +/// writing side. +/// +/// So this answers *"is there archival protection here, and has it lapsed?"* and +/// not *"is this seal archived?"*. The distinction matters for a seal from +/// elsewhere and not at all for one this node made. +/// +/// The **latest** timestamp decides, by `genTime`. A renewal chain is a sequence +/// of archive timestamps each covering the one before it, and it is the newest +/// that carries the protection forward — reading an older one would report a +/// renewed seal as lapsed. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the seal itself cannot be read. +pub fn archival_freshness( + seal_der: &[u8], + now: DateTime, +) -> Result { + let signed = parse(seal_der)?; + let Some(attrs) = signed.signer.unsigned_attrs.as_ref() else { + return Ok(ArchivalFreshness::NotArchived); + }; + + let archival: Vec<_> = attrs + .iter() + .filter(|a| { + a.oid == ID_AA_ETS_ARCHIVE_TIMESTAMP_V3 || a.oid == ID_AA_ETS_ARCHIVE_TIMESTAMP_V2 + }) + .collect(); + if archival.is_empty() { + return Ok(ArchivalFreshness::NotArchived); } - /// Reading the certificate out of a seal agrees with the identity that - /// signed it. + // The newest readable token wins. An unreadable one among several is not + // fatal — but if *none* reads, the answer is unknown rather than absent. + let mut newest: Option<(DateTime, DateTime)> = None; + for attr in archival { + for value in attr.values.as_slice() { + let Some((made_at, expires)) = archival_token(value) else { + continue; + }; + if newest.is_none_or(|(seen, _)| made_at > seen) { + newest = Some((made_at, expires)); + } + } + } + + let Some((_, expires)) = newest else { + return Ok(ArchivalFreshness::Unknown); + }; + Ok(if expires > now { + ArchivalFreshness::Current { expires } + } else { + ArchivalFreshness::Lapsed { expires } + }) +} + +/// One archive timestamp's `(genTime, authority certificate expiry)`. +/// +/// `None` when the token cannot be read or its signature does not hold — a +/// token that failed its check must not contribute a date. +fn archival_token(value: &der::Any) -> Option<(DateTime, DateTime)> { + let token_der = value.to_der().ok()?; + let token = parse(&token_der).ok()?; + if !signature_holds(&token).unwrap_or(false) { + return None; + } + let content = token.econtent.as_ref()?; + if !digest_matches(&token, content) { + return None; + } + let info = TstInfo::from_der(content).ok()?; + + let made_at = to_utc(info.gen_time.to_unix_duration().as_secs())?; + // The same rule the signature timestamp uses. Here it also keeps the two + // halves of this answer consistent: `expires` below is read off the very + // certificate being checked, so a token stamped outside that window would + // have this seal reported as archived until a date its own authority could + // not have vouched for. + if !stamped_within_its_certificate(&token, made_at) { + return None; + } + let expires = to_utc( + token + .certificate + .tbs_certificate + .validity + .not_after + .to_unix_duration() + .as_secs(), + )?; + Some((made_at, expires)) +} + +/// Seconds since the epoch as a UTC instant. +fn to_utc(seconds: u64) -> Option> { + DateTime::from_timestamp(i64::try_from(seconds).ok()?, 0) +} + +// ─── Who issued it, and where its key lives ────────────────────────────────── + +/// `id-pe-qcStatements` — the extension a qualified certificate carries. +/// +/// RFC 3739 §3.2.6, as profiled by ETSI EN 319 412-5. Its absence is itself a +/// finding: a certificate with no QCStatements at all is not claiming to be a +/// qualified certificate. +const ID_PE_QC_STATEMENTS: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.1.3"); + +/// `esi4-qcStatement-4` — the key resides in a qualified creation device. +/// +/// ETSI EN 319 412-5 §4.2.2. **This is the Annex III(j) indication**: Art. 32(1)(f) +/// (reached for seals through Art. 40) requires that the seal was created by a +/// qualified electronic seal creation device, and Annex III(j) requires the +/// certificate to say so "in a form suitable for automated processing". This OID +/// is that form. +const ID_ETSI_QCS_QC_SSCD: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("0.4.0.1862.1.4"); + +/// One `QCStatement`, of which only the identifier is read. +/// +/// RFC 3739 §3.2.6. The optional `statementInfo` is decoded so the structure +/// parses, and then ignored — presence of the identifier is the whole signal, +/// and reading further would invite treating a declaration as a verification. +#[derive(der::Sequence)] +struct QcStatement { + id: const_oid::ObjectIdentifier, + #[asn1(optional = "true")] + _info: Option, +} + +/// The signer certificate's issuer, subject, and Annex III(j) indication. +/// +/// Read out of the seal rather than taken from configuration, deliberately. A +/// node knows which backend it was told to use; it does not thereby know what +/// the returned bytes contain, and a verdict founded on a configuration flag +/// attests the operator's intent rather than the seal. Everything here comes +/// from the certificate the seal carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignerCertificate { + /// The issuer distinguished name, RFC 4514, for reading. + pub issuer: String, + /// The subject distinguished name, RFC 4514, for reading. + pub subject: String, + /// DER of the issuer distinguished name, for matching. /// - /// This is the claim that makes `signing_cert_ref` comparable across - /// backends. One backend knows its own certificate and reports it directly; - /// the other can only read it back out of the bytes a provider returned. If - /// those two ever produced different values for the same certificate, the - /// field would silently stop being a key an auditor can match on. + /// The bytes rather than the string, because this is what gets compared + /// against a listed CA's *subject* and a string comparison would depend on + /// how two independent implementations chose to escape a name. + pub issuer_der: Vec, + /// Whether issuer and subject are the same name — a self-signed certificate. /// - /// The local backend supplies realistic bytes here because it is the only - /// source of a genuine CMS structure in this crate — a hand-rolled fixture - /// would prove that the parser agrees with the fixture, not with reality. - #[test] - fn the_thumbprint_matches_the_identity_that_signed() { - let dir = tempfile::tempdir().expect("tempdir"); - let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); - let seal = id.sign_detached(&[0x11; 32]).expect("sign"); + /// The structural test for "nobody issued this to us". It is a property of + /// the certificate, so it cannot be misconfigured into being false. + pub self_issued: bool, + /// What the certificate declares about the creation device. + pub creation_device: CreationDevice, +} - assert_eq!( - signer_certificate_thumbprint(&seal).unwrap(), - Some(id.cert_thumbprint()), - "the certificate read out of a seal must be the one that signed it" +impl SignerCertificate { + /// The shareable half — everything but the bytes used for matching. + /// + /// [`Self::issuer_der`] stays behind: it exists so a listed CA's subject can + /// be compared exactly, which is this crate's business and nobody else's. + #[must_use] + pub fn origin(&self) -> SealOrigin { + SealOrigin { + subject: self.subject.clone(), + issuer: self.issuer.clone(), + self_issued: self.self_issued, + creation_device: self.creation_device, + } + } +} + +/// Read the signer certificate's issuer, subject and device indication. +/// +/// Says nothing about whether the seal verifies — that is +/// [`verify_against_embedded_certificate`], and the two are deliberately +/// separate: *who issued this* and *does it check out* are different questions, +/// and a caller that wants a trust verdict needs both answered rather than one +/// standing in for the other. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the bytes are not a readable CMS seal. +pub fn signer_certificate(seal_der: &[u8]) -> Result { + let signed = parse(seal_der)?; + let tbs = &signed.certificate.tbs_certificate; + + let issuer_der = tbs + .issuer + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the issuer name: {e}")))?; + let subject_der = tbs + .subject + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the subject name: {e}")))?; + + Ok(SignerCertificate { + issuer: tbs.issuer.to_string(), + subject: tbs.subject.to_string(), + self_issued: issuer_der == subject_der, + issuer_der, + creation_device: creation_device(tbs), + }) +} + +/// Whether a given certificate authority actually signed the seal's certificate. +/// +/// Three outcomes rather than a boolean, because *this CA did not sign it* and +/// *the check could not be run* send a reader in opposite directions. Collapsing +/// them would either brand a lawful seal a forgery or let an unrunnable check +/// read as a clean miss — the same split [`super::trustlist`] draws between a +/// document signed by the wrong key and one altered after signing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IssuerCheck { + /// The issuer's public key verifies its signature over the seal + /// certificate's `tbsCertificate`. This CA issued it. + Verified, + /// The signature does not verify under this issuer's key. + /// + /// Not an error: it is a finding, and the one that catches a certificate + /// relabelled with a listed CA's name. + NotSignedByThisIssuer, + /// The check could not be run at all. + /// + /// An unreadable issuer certificate, or a key algorithm this build does not + /// verify — see the `x509-verify` feature list in `Cargo.toml`, which is + /// pinned to the algorithms actually measured in the published lists. Never + /// to be reported as either of the above. + Unverifiable(String), +} + +/// The DER of every issuer name the seal's embedded certificates refer to. +/// +/// What a caller needs to decide **which** trust anchors are worth trying. The +/// signer's own issuer is not enough on its own: where a trusted list publishes +/// self-signed roots — Italy's does, for 194 of its 203 entries — the anchor's +/// subject matches the *intermediate's* issuer, one link further up, and a +/// caller filtering on the signer's issuer alone would find no candidate and +/// report an unlisted provider. +/// +/// Duplicates are removed; order is not meaningful. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the seal cannot be read. +pub fn chain_issuer_names(seal_der: &[u8]) -> Result>, SealError> { + let signed = parse(seal_der)?; + let mut names: Vec> = Vec::new(); + for certificate in &signed.chain { + if let Ok(der) = certificate.tbs_certificate.issuer.to_der() + && !names.contains(&der) + { + names.push(der); + } + } + Ok(names) +} + +/// How far a certificate path may be walked before it is treated as a loop. +/// +/// Real chains are two or three links. The cap is not a tuning parameter: the +/// certificates come out of a seal an operator was handed, so a hostile one may +/// carry a chain shaped to make this walk expensive. +const MAX_PATH_LENGTH: usize = 8; + +/// Check whether the seal's certificate chains to `anchor_certificate_der`. +/// +/// **A path check against one anchor, not a path builder.** The trust anchor is +/// established elsewhere — the Official Journal anchors the list of trusted +/// lists, which anchors each national list, which carries this certificate — so +/// what remains is whether the seal's signer chains up to it. +/// +/// Intermediates are taken **only from the seal itself**. A CAdES seal normally +/// travels with its issuing chain, and nothing here fetches a certificate over +/// the network: a path completed by a document an attacker could serve is not a +/// path. This matters more than it sounds, because Member States do not publish +/// the same thing — Italy's list carries self-signed roots, so an intermediate +/// is needed to reach them, while Finland's and France's carry the issuing CAs +/// directly and the walk finishes in one step. +/// +/// Every link is verified. Reaching the anchor by name alone would let any +/// embedded certificate claim any issuer, which is the hole this whole function +/// exists to close. +/// +/// Signatures are verified with the tolerant comparison rather than the strict +/// one: the strict variant refuses a non-normalised ECDSA signature, which is +/// lawful in X.509 and emitted by real certificate authorities. Using it would +/// reject genuine certificates as forgeries. +/// +/// Nothing here checks a validity window or consults revocation. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the *seal* cannot be read. A problem with the +/// *anchor* is [`IssuerCheck::Unverifiable`], not an error: the seal is fine and +/// one candidate among several could not be checked. +pub fn check_path_to( + seal_der: &[u8], + anchor_certificate_der: &[u8], +) -> Result { + let signed = parse(seal_der)?; + + let anchor = match Certificate::from_der(anchor_certificate_der) { + Ok(c) => c, + Err(e) => { + return Ok(IssuerCheck::Unverifiable(format!( + "the anchor certificate does not parse: {e}" + ))); + } + }; + let anchor_key = match verifying_key(&anchor) { + Ok(k) => k, + Err(e) => { + return Ok(IssuerCheck::Unverifiable(format!( + "no verifier for the anchor's key: {e}" + ))); + } + }; + + let mut current = &signed.certificate; + let mut seen: Vec<&x509_cert::name::Name> = Vec::new(); + for _ in 0..MAX_PATH_LENGTH { + // The anchor first, so a chain that also embeds a copy of it cannot + // lengthen the walk. + if current.tbs_certificate.issuer == anchor.tbs_certificate.subject { + return Ok(verified_under(&anchor, &anchor_key, current)); + } + + // Otherwise climb one link, using only what the seal carries. A + // certificate is never its own issuer here: a self-signed certificate + // that is not the anchor terminates the walk rather than looping. + let Some(next) = signed.chain.iter().find(|c| { + c.tbs_certificate.subject == current.tbs_certificate.issuer + && c.tbs_certificate.subject != current.tbs_certificate.subject + }) else { + return Ok(IssuerCheck::NotSignedByThisIssuer); + }; + if seen.contains(&&next.tbs_certificate.subject) { + return Ok(IssuerCheck::Unverifiable( + "the embedded certificates form a loop".to_owned(), + )); + } + seen.push(¤t.tbs_certificate.subject); + + // The intermediate must have signed the certificate below it, or the + // chain is decoration. + let key = match verifying_key(next) { + Ok(k) => k, + Err(e) => { + return Ok(IssuerCheck::Unverifiable(format!( + "no verifier for an intermediate's key: {e}" + ))); + } + }; + match verified_under(next, &key, current) { + IssuerCheck::Verified => {} + other => return Ok(other), + } + current = next; + } + + Ok(IssuerCheck::Unverifiable(format!( + "the certificate path is longer than {MAX_PATH_LENGTH} links" + ))) +} + +/// The signature-algorithm family an OID belongs to, where it is one this +/// module can reason about. +/// +/// Prefix matching on the arc rather than an enumeration of every OID: RSA's +/// signature algorithms all sit under `1.2.840.113549.1.1`, alongside the key +/// algorithm itself, and ECDSA's under `1.2.840.10045`. A new hash in either +/// family lands in the right place without an edit here, and an OID from +/// neither family is simply unknown, which is the honest answer. +fn algorithm_family(oid: &str) -> Option<&'static str> { + if oid.starts_with("1.2.840.113549.1.1.") { + Some("RSA") + } else if oid.starts_with("1.2.840.10045.") { + Some("ECDSA") + } else if oid == "1.3.101.112" || oid == "1.3.101.113" { + Some("EdDSA") + } else { + None + } +} + +/// One link: did `issuer` sign `certificate`? +fn verified_under( + issuer: &Certificate, + key: &x509_verify::VerifyingKey, + certificate: &Certificate, +) -> IssuerCheck { + // Settle an algorithm mismatch here rather than letting the verifier report + // it as an unrecognised OID. A certificate authority holding an RSA key + // cannot have produced an ECDSA signature, so that pairing is a **definite** + // non-match — and reporting it as "could not be checked" would leave the + // commonest forgery, a self-signed P-256 certificate relabelled with an + // RSA-keyed CA's name, looking like a gap in this build's algorithm support. + let signature_family = algorithm_family(&certificate.signature_algorithm.oid.to_string()); + let key_family = algorithm_family( + &issuer + .tbs_certificate + .subject_public_key_info + .algorithm + .oid + .to_string(), + ); + if let (Some(signature), Some(k)) = (signature_family, key_family) + && signature != k + { + return IssuerCheck::NotSignedByThisIssuer; + } + + match key.verify(certificate) { + Ok(()) => IssuerCheck::Verified, + // `verify` folds "does not verify" and "cannot verify this algorithm" + // into one error type, so the signature-level one is named explicitly + // and everything else stays unverifiable. Guessing the other way round + // would report a forgery whenever a new algorithm appeared. + Err(x509_verify::Error::Verification) => IssuerCheck::NotSignedByThisIssuer, + Err(e) => IssuerCheck::Unverifiable(format!("the check could not be run: {e}")), + } +} + +/// The Annex III(j) indication, read off the QCStatements extension. +/// +/// An unparseable extension reports [`CreationDevice::NoQualifiedDevice`] rather +/// than failing: the statement was not found, which is what the variant says, +/// and the failure direction that matters is never reporting a device that was +/// not declared. +fn creation_device(tbs: &x509_cert::TbsCertificate) -> CreationDevice { + let Some(ext) = tbs + .extensions + .as_ref() + .and_then(|e| e.iter().find(|e| e.extn_id == ID_PE_QC_STATEMENTS)) + else { + return CreationDevice::NotAQualifiedCertificate; + }; + + let declared = Vec::::from_der(ext.extn_value.as_bytes()) + .is_ok_and(|s| s.iter().any(|s| s.id == ID_ETSI_QCS_QC_SSCD)); + + if declared { + CreationDevice::DeclaresQualifiedDevice + } else { + CreationDevice::NoQualifiedDevice + } +} + +/// Where the certificate chain the seal carries comes to an end. +/// +/// Two endings, and telling them apart is what separates a finding from an +/// absence of one. A chain that reaches a self-issued certificate is complete as +/// far as the seal is concerned: everything needed to judge it is present, and a +/// verdict about it is a verdict about the whole path. A chain that simply runs +/// out is not — the link that would have led somewhere was never shipped, and +/// anything concluded from its absence is a guess. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChainTerminus { + /// The walk reached a certificate that issued itself. + SelfIssuedRoot { + /// Its subject, which is also its issuer. + subject: String, + }, + /// The walk stopped because the seal carries no certificate with the name it + /// needed next. + /// + /// ETSI EN 319 122-1 clause 5.2.1 asks a generator to include the signing + /// certificate (a *shall*) and, where the signature is meant to be validated + /// through a Trusted List, the intermediates between it and a listed CA (a + /// *should*, and only for certificates "not available to verifiers"). So a + /// seal in this state is not necessarily non-conformant — but it is one this + /// node cannot follow, and the honest report says which of those it is. + Truncated { + /// The issuer name the walk needed and could not find. + missing_issuer: String, + }, +} + +/// Follow the seal's embedded certificates up from its signer, and say how far +/// they go. +/// +/// Deliberately makes no judgement about trust: it answers only whether the +/// material to judge the chain is present. The trusted list question is asked +/// elsewhere, and asking it against a chain that ran out is how "we could not +/// look" turns into "we looked and found nothing". +/// +/// # Errors +/// +/// Propagates a seal that will not parse. +pub fn chain_terminus(seal_der: &[u8]) -> Result { + let signed = parse(seal_der)?; + let mut current = &signed.certificate; + + for _ in 0..MAX_PATH_LENGTH { + if current.tbs_certificate.issuer == current.tbs_certificate.subject { + return Ok(ChainTerminus::SelfIssuedRoot { + subject: current.tbs_certificate.subject.to_string(), + }); + } + // The same climb `check_path_to` makes, and it must stay the same: a + // walk that found a link the other could not would report a complete + // chain the verifier then failed to follow. + let Some(next) = signed.chain.iter().find(|c| { + c.tbs_certificate.subject == current.tbs_certificate.issuer + && c.tbs_certificate.subject != current.tbs_certificate.subject + }) else { + return Ok(ChainTerminus::Truncated { + missing_issuer: current.tbs_certificate.issuer.to_string(), + }); + }; + current = next; + } + + // A loop, or a chain longer than the walk allows. Reported as truncated + // rather than as a root: what is certain is that this walk did not reach + // one. + Ok(ChainTerminus::Truncated { + missing_issuer: current.tbs_certificate.issuer.to_string(), + }) +} + +/// What this node can establish about the seal certificate's own standing: +/// its validity window, and what the seal's revocation material says. +/// +/// # The second limb of Art. 32(1)(b) +/// +/// Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks two +/// things of the certificate: that a qualified provider issued it, and that it +/// **was valid at the time of signing**. Whether the issuer is qualified is a +/// trusted list question and lives in [`crate::qualification`]. This is the +/// other half, and until now nothing asked it — a certificate that had expired +/// or been revoked when the seal was made still reached the top verdict. +/// +/// # Which moment, and what it is worth +/// +/// "At the time of signing" is only answerable if the signing time is known, and +/// a seal's own claim about when it was made is worth nothing. So the moment +/// used is the **attested** one — the checked timestamp token, which a seal +/// carries from `B-T` upward — and `now` is the fallback, marked unattested. +/// +/// That mark is load-bearing. A verdict reached against an unproven moment is +/// reported with a `NO_POE` sub-indication rather than as a failure, because a +/// certificate that has expired *since* the seal was made says nothing bad about +/// the seal: certificates expire, and sealed passports outlive them by years. +/// +/// # Revocation is read from the seal, never fetched +/// +/// The CRL distribution point in a certificate is a URL chosen by whoever issued +/// it, and this certificate arrived inside a seal an operator was handed. +/// Fetching it would have a background task make requests to an address the +/// input controls. The long-term profiles remove the need: ETSI EN 319 122-1 +/// puts revocation values in `SignedData.crls` from `B-LT` upward precisely so a +/// seal can be checked years later, offline. +/// +/// So a `B-LT` or `B-LTA` seal can be answered, and a `B-B` or `B-T` seal +/// reports `NotAvailable` — which is not a defect, only a question this material +/// cannot answer. +/// +/// # Errors +/// +/// Propagates a seal that will not parse. Everything narrower — no CRL, a CRL +/// from another issuer, one whose signature does not verify — is a value, not an +/// error: they are answers about the seal rather than failures to read it. +pub fn certificate_standing( + seal_der: &[u8], + now: DateTime, +) -> Result { + let signed = parse(seal_der)?; + + let judged_at = match attested_sealing_time(seal_der)? { + Some(at) => JudgedTime { at, attested: true }, + None => JudgedTime { + at: now, + attested: false, + }, + }; + + let validity = &signed.certificate.tbs_certificate.validity; + let (Some(not_before), Some(not_after)) = ( + instant_of(validity.not_before), + instant_of(validity.not_after), + ) else { + // Not a verdict of "invalid" — a refusal to guess. A window this cannot + // read is a malformed certificate, and reporting it as expired or as + // valid would both be inventions. + return Err(malformed("the certificate's validity window is unreadable")); + }; + let standing = if judged_at.at < not_before { + WindowStanding::NotYetValid + } else if judged_at.at > not_after { + WindowStanding::Expired + } else { + WindowStanding::Inside + }; + + Ok(CertificateStanding { + validity: ValidityWindow { + not_before, + not_after, + standing, + }, + judged_at, + revocation: revocation_from(&signed), + }) +} + +/// An ASN.1 `Time` as an instant. +/// +/// Both of its encodings carry an absolute moment in UTC, so this is a unit +/// conversion and not a timezone decision. `None` only for a value outside the +/// representable range, which a caller reports rather than papers over: a +/// certificate whose window cannot be read has not been shown to be valid. +fn instant_of(t: x509_cert::time::Time) -> Option> { + to_utc(t.to_unix_duration().as_secs()) +} + +/// What the seal's embedded CRLs say about its signing certificate. +/// +/// Every step can only narrow the answer, and each narrowing is reported as what +/// it is. A CRL from another issuer says nothing about this certificate; one +/// whose signature does not verify is not evidence of anything, and treating +/// either as "not revoked" would manufacture an assurance out of material that +/// carries none. +fn revocation_from(signed: &Signed) -> RevocationStanding { + let issuer = &signed.certificate.tbs_certificate.issuer; + let serial = &signed.certificate.tbs_certificate.serial_number; + + let mut last_problem: Option = None; + // The newest clean answer, kept rather than returned. A seal may carry + // several CRLs from the same issuer — a long-term seal accumulates them — + // and returning on the first that verifies would report `notRevoked` from a + // stale list while a later one carries the revocation. A revocation found + // anywhere ends the search; nothing else can. + let mut clean: Option> = None; + for crl in &signed.crls { + if &crl.tbs_cert_list.issuer != issuer { + // Not a problem, just not about this certificate: a seal may carry + // the CRLs for every certificate in its chain. + continue; + } + + // The CRL must be signed by the same issuer, checked against a + // certificate the seal carries. Without that, a revocation list is a + // list of numbers anybody could have written — and an attacker able to + // add one could *unrevoke* a certificate by shipping an empty CRL. + let Some(issuer_cert) = signed + .chain + .iter() + .find(|c| &c.tbs_certificate.subject == issuer) + else { + last_problem = Some("the CRL's issuer certificate is not in the seal".to_owned()); + continue; + }; + let key = match verifying_key(issuer_cert) { + Ok(k) => k, + Err(e) => { + last_problem = Some(format!("no verifier for the CRL issuer's key: {e}")); + continue; + } + }; + if let Err(e) = key.verify(crl) { + last_problem = Some(format!("the CRL's own signature does not verify: {e}")); + continue; + } + + if let Some(entry) = crl + .tbs_cert_list + .revoked_certificates + .as_ref() + .and_then(|r| r.iter().find(|e| &e.serial_number == serial)) + { + let Some(at) = instant_of(entry.revocation_date) else { + last_problem = Some("the revocation date is unreadable".to_owned()); + continue; + }; + return RevocationStanding::Revoked { at }; + } + let Some(as_of) = instant_of(crl.tbs_cert_list.this_update) else { + last_problem = Some("the CRL's thisUpdate is unreadable".to_owned()); + continue; + }; + if clean.is_none_or(|seen| as_of > seen) { + clean = Some(as_of); + } + } + + match (clean, last_problem) { + // A list that answered outranks one that failed: something usable was + // found, and the failure of another copy does not unsay it. + (Some(as_of), _) => RevocationStanding::NotRevoked { as_of }, + (None, Some(reason)) => RevocationStanding::Unusable { reason }, + (None, None) => RevocationStanding::NotAvailable, + } +} + +// ─── The certificate's own standing ────────────────────────────────────────── + +/// A CA, a leaf it issued, and whatever CRLs a test wants to embed. +/// +/// Real certificates and a real CRL signature, because every assertion below is +/// about whether a signature checks out. A fixture that faked one would be +/// testing the fixture. +#[cfg(test)] +mod standing_tests { + use super::*; + use time::OffsetDateTime; + + /// Seconds, as `rcgen`'s date type. + fn at(offset_days: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(Utc::now().timestamp() + offset_days * 86_400) + .expect("a representable date") + } + + struct Issued { + ca: rcgen::CertifiedIssuer<'static, rcgen::KeyPair>, + leaf_der: Vec, + leaf_serial: rcgen::SerialNumber, + } + + /// A CA and a leaf whose validity window is `from`..`to`, in days from now. + fn issue(from: i64, to: i64) -> Issued { + issue_from("Test Issuing CA", from, to) + } + + /// The same, under a chosen CA name — so a test can produce either a + /// genuinely different issuer or a different key wearing the same name. + fn issue_from(ca_name: &str, from: i64, to: i64) -> Issued { + fn key() -> rcgen::KeyPair { + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("key") + } + let mut ca_params = + rcgen::CertificateParams::new(vec![ca_name.to_owned()]).expect("params"); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, ca_name); + // A CA that may sign CRLs, or `rcgen` refuses to make one with it. + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + ca_params.not_before = at(-3650); + ca_params.not_after = at(3650); + let ca = rcgen::CertifiedIssuer::self_signed(ca_params, key()).expect("a CA"); + + let mut leaf_params = + rcgen::CertificateParams::new(vec!["Test Sealing Certificate".to_owned()]) + .expect("params"); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Test Sealing Certificate"); + leaf_params.not_before = at(from); + leaf_params.not_after = at(to); + let leaf_serial = leaf_params.serial_number.clone().unwrap_or_else(|| { + let s = rcgen::SerialNumber::from(42u64); + leaf_params.serial_number = Some(s.clone()); + s + }); + let leaf = leaf_params + .signed_by(&key(), &*ca) + .expect("a leaf signed by the CA"); + + Issued { + ca, + leaf_der: leaf.der().to_vec(), + leaf_serial, + } + } + + /// A CRL from `issued`'s CA, listing the leaf as revoked `days` from now + /// when `revoked` is set. + fn crl(issued: &Issued, revoked: Option) -> x509_cert::crl::CertificateList { + let params = rcgen::CertificateRevocationListParams { + this_update: at(0), + next_update: at(30), + crl_number: rcgen::SerialNumber::from(1u64), + issuing_distribution_point: None, + key_identifier_method: rcgen::KeyIdMethod::Sha256, + revoked_certs: revoked + .map(|days| rcgen::RevokedCertParams { + serial_number: issued.leaf_serial.clone(), + revocation_time: at(days), + reason_code: Some(rcgen::RevocationReason::KeyCompromise), + invalidity_date: None, + }) + .into_iter() + .collect(), + }; + let der = params + .signed_by(&issued.ca) + .expect("a signed CRL") + .der() + .to_vec(); + x509_cert::crl::CertificateList::from_der(&der).expect("the CRL parses") + } + + /// A seal carrying `certificates` and `crls`, built from a real local seal + /// so the CMS structure is one this crate actually produces. + fn seal_with( + certificates: &[Vec], + crls: &[x509_cert::crl::CertificateList], + ) -> (Vec, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id.sign_detached(&[0x22; 32]).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + + let parsed: Vec = certificates + .iter() + .map(|d| Certificate::from_der(d).expect("a certificate")) + .collect(); + let mut certs = der::asn1::SetOfVec::new(); + for c in &parsed { + certs + .insert(cms::cert::CertificateChoices::Certificate(c.clone())) + .expect("certificate"); + } + sd.certificates = Some(cms::signed_data::CertificateSet(certs)); + + // The signer must name the certificate under test, or the parser refuses + // the seal before any of this is reached. + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + signers[0].sid = cms::signed_data::SignerIdentifier::IssuerAndSerialNumber( + cms::cert::IssuerAndSerialNumber { + issuer: parsed[0].tbs_certificate.issuer.clone(), + serial_number: parsed[0].tbs_certificate.serial_number.clone(), + }, + ); + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + + if !crls.is_empty() { + let mut choices = der::asn1::SetOfVec::new(); + for crl in crls { + choices + .insert(cms::revocation::RevocationInfoChoice::Crl(crl.clone())) + .expect("crl"); + } + sd.crls = Some(cms::revocation::RevocationInfoChoices(choices)); + } + + let reencoded = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"); + (reencoded, dir) + } + + /// **A certificate that expired before now is reported expired.** + /// + /// The gap #323 named: nothing read `notAfter`, so a seal made with a + /// long-dead certificate reached the same verdict as one made yesterday. + #[test] + fn an_expired_certificate_is_seen_to_be_expired() { + let issued = issue(-400, -30); + let (seal, _dir) = seal_with(&[issued.leaf_der.clone(), issued.ca.der().to_vec()], &[]); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + assert_eq!(standing.validity.standing, WindowStanding::Expired); + assert!(standing.validity.not_after < Utc::now()); + assert!( + !standing.judged_at.attested, + "this fixture seals at B-B, which carries no timestamp at all — so the moment \ + is this clock, and the flag says so. `Expired` here is an observation; only \ + the attested case turns it into a finding" + ); + } + + /// **A timestamp minted under a certificate that could not have made it is + /// not a time.** + /// + /// The token's own signature verifies for ever and `genTime` is whatever the + /// signer wrote, so the authority's validity window is the only thing in the + /// token that limits when it could have been made. Without the check, anyone + /// able to edit a stored seal can attach a timestamp of their own choosing — + /// they cannot steal someone else's, because the imprint binds it to this + /// signature, but they can mint one — and an attested moment is what turns a + /// certificate finding from an open question into a failure, or from a + /// failure into nothing at all. + /// + /// The tamper here is the cheapest possible: the authority's certificate is + /// relabelled with a window that ended before the token's own `genTime`. + /// Nothing about the token's signature breaks, for the same reason + /// relabelling a seal certificate's issuer does not break the seal — a CMS + /// signature covers the signed attributes, not the certificate beside them. + #[test] + fn a_token_stamped_outside_its_authoritys_window_is_not_a_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id + .sign_detached_at(&[0x44; 32], SealConformanceLevel::BaselineLta) + .expect("sign"); + assert!( + attested_sealing_time(&seal).expect("readable").is_some(), + "the fixture must start with a time, or this proves nothing" + ); + + let tampered = with_backdated_tsa_certificate(&seal); + + assert_eq!( + attested_sealing_time(&tampered).expect("readable"), + None, + "a token its authority's certificate could not have made is not a time" + ); + // And the consequence, which is the reason this matters: with no + // attested moment the certificate is judged against this clock and every + // finding drawn from it is reported as unproven rather than as a + // failure. + let standing = certificate_standing(&tampered, Utc::now()).expect("readable"); + assert!(!standing.judged_at.attested); + } + + /// The same seal with the timestamp authority's certificate given a window + /// that closed before the token was stamped. + fn with_backdated_tsa_certificate(seal_der: &[u8]) -> Vec { + let info = ContentInfo::from_der(seal_der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + let attrs = signers[0] + .unsigned_attrs + .as_ref() + .expect("an LTA seal carries unsigned attributes"); + let mut rebuilt = der::asn1::SetOfVec::new(); + for attr in attrs.iter() { + if attr.oid != ID_AA_SIGNATURE_TIME_STAMP_TOKEN { + rebuilt.insert(attr.clone()).expect("attribute"); + continue; + } + + let token_der = attr.values.as_slice()[0].to_der().expect("token DER"); + let token_info = ContentInfo::from_der(&token_der).expect("token CMS"); + let mut token: SignedData = token_info.content.decode_as().expect("token SignedData"); + + // A window that ended a year ago, so `genTime` (now) falls outside + // it however the clock is read. + let long_ago = std::time::Duration::from_secs( + u64::try_from(Utc::now().timestamp()).expect("after 1970") - 730 * 86_400, + ); + let until = std::time::Duration::from_secs( + u64::try_from(Utc::now().timestamp()).expect("after 1970") - 365 * 86_400, + ); + let certs = token + .certificates + .as_ref() + .expect("the token's certificate"); + let mut choices = certs.0.as_slice().to_vec(); + let cms::cert::CertificateChoices::Certificate(cert) = &mut choices[0] else { + panic!("the local TSA embeds an X.509 certificate"); + }; + cert.tbs_certificate.validity = x509_cert::time::Validity { + not_before: x509_cert::time::Time::GeneralTime( + der::asn1::GeneralizedTime::from_unix_duration(long_ago).expect("a date"), + ), + not_after: x509_cert::time::Time::GeneralTime( + der::asn1::GeneralizedTime::from_unix_duration(until).expect("a date"), + ), + }; + let mut set = der::asn1::SetOfVec::new(); + set.insert(choices.remove(0)).expect("certificate"); + token.certificates = Some(cms::signed_data::CertificateSet(set)); + + let mut values = der::asn1::SetOfVec::new(); + values + .insert( + der::Any::encode_from(&ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&token).expect("encode token"), + }) + .expect("wrap token"), + ) + .expect("value"); + rebuilt + .insert(x509_cert::attr::Attribute { + oid: attr.oid, + values, + }) + .expect("attribute"); + } + signers[0].unsigned_attrs = Some(x509_cert::attr::Attributes::from(rebuilt)); + + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + + ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode") + } + + /// **An LTA seal's certificate is judged against its attested time.** + /// + /// The two readers must agree: `certificate_standing` calls + /// `attested_sealing_time` for its moment, and the `attested` flag it sets is + /// what decides whether an out-of-window certificate is a failure or an open + /// question. A disagreement here would be invisible — the verdict would + /// simply be the weaker one, for ever, on every seal that carries a + /// timestamp. + #[test] + fn an_lta_seals_certificate_is_judged_against_its_attested_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id + .sign_detached_at(&[0x33; 32], SealConformanceLevel::BaselineLta) + .expect("sign"); + + let attested = attested_sealing_time(&seal).expect("readable"); + assert!( + attested.is_some(), + "an LTA seal carries a timestamp whose signature and imprint both check out" + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + assert!( + standing.judged_at.attested, + "the standing must be judged against that time, not against this clock" + ); + assert_eq!(Some(standing.judged_at.at), attested); + } + + /// A certificate valid now is inside its window, and the window is reported + /// so a reader can check the arithmetic rather than trust the verdict. + #[test] + fn a_current_certificate_is_inside_its_window() { + let issued = issue(-30, 400); + let (seal, _dir) = seal_with(&[issued.leaf_der.clone(), issued.ca.der().to_vec()], &[]); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + assert_eq!(standing.validity.standing, WindowStanding::Inside); + assert!(standing.validity.not_before < standing.validity.not_after); + } + + /// **A revoked certificate is found, from the seal's own CRL.** + /// + /// No network: the CRL travels inside the seal, which is what ETSI's + /// long-term profiles put it there for. + #[test] + fn a_revoked_certificate_is_found_in_the_seals_own_crl() { + let issued = issue(-30, 400); + let revocation = crl(&issued, Some(-1)); + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + &[revocation], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + match standing.revocation { + RevocationStanding::Revoked { at } => assert!(at < Utc::now()), + other => panic!("expected a revocation, got {other:?}"), + } + } + + /// **Every applicable CRL is read, not the first one that verifies.** + /// + /// A long-term seal accumulates revocation material, and `SignedData.crls` + /// is a SET — its order carries no meaning. Answering from whichever copy + /// came first would report `notRevoked` from a stale list while a later one + /// carries the revocation, and the order deciding it would be an encoding + /// accident. + #[test] + fn a_stale_clean_crl_does_not_outrank_a_later_revocation() { + let issued = issue(-30, 400); + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + // Clean first, revoking second: the SET may present them either way. + &[crl(&issued, None), crl(&issued, Some(-1))], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + match standing.revocation { + RevocationStanding::Revoked { .. } => {} + other => panic!("a revocation anywhere ends the search, got {other:?}"), + } + } + + /// A CRL that covers the certificate and does not list it says so, and says + /// *as of when* — a CRL older than the seal cannot rule out a later + /// revocation, and the field is there so a reader can see which question was + /// answered. + #[test] + fn a_clean_crl_reports_the_moment_it_speaks_for() { + let issued = issue(-30, 400); + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + &[crl(&issued, None)], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + match standing.revocation { + RevocationStanding::NotRevoked { as_of } => { + assert!((Utc::now() - as_of).num_minutes().abs() < 5); + } + other => panic!("expected a clean CRL, got {other:?}"), + } + } + + /// **A CRL nobody signed for is not evidence.** + /// + /// The attack this closes is the interesting direction: an empty CRL + /// *unrevokes* a certificate. Anyone able to add one to a seal could turn a + /// revoked certificate into a clean report, so the list's own signature is + /// checked against a certificate the seal carries before a word of it is + /// believed. + #[test] + fn a_crl_whose_signature_does_not_verify_is_unusable() { + let issued = issue(-30, 400); + let mut tampered = crl(&issued, Some(-1)); + // Drop the revocation, leaving the signature over the original content. + tampered.tbs_cert_list.revoked_certificates = None; + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + &[tampered], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + match standing.revocation { + RevocationStanding::Unusable { reason } => { + assert!( + reason.contains("signature"), + "the reason must name what failed: {reason}" + ); + } + other => panic!("a tampered CRL must not be believed, got {other:?}"), + } + } + + /// A seal carrying no revocation material says so, and that is not a defect: + /// `B-B` and `B-T` seals are not required to carry any. + #[test] + fn a_seal_with_no_crl_reports_that_it_could_not_ask() { + let issued = issue(-30, 400); + let (seal, _dir) = seal_with(&[issued.leaf_der.clone(), issued.ca.der().to_vec()], &[]); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + assert_eq!(standing.revocation, RevocationStanding::NotAvailable); + } + + /// A CRL from a different issuer is silently not about this certificate — + /// neither an answer nor a problem. A seal legitimately carries the CRLs for + /// every certificate in its chain. + #[test] + fn a_crl_from_another_issuer_is_not_an_answer() { + let issued = issue(-30, 400); + let other = issue_from("Some Other CA", -30, 400); + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + &[crl(&other, Some(-1))], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + assert_eq!( + standing.revocation, + RevocationStanding::NotAvailable, + "another CA's list says nothing about this certificate, in either direction" + ); + } + + /// **A CRL wearing the issuer's name, signed by another key, is refused.** + /// + /// The same asymmetry #323 was opened about, one level down: a name is not a + /// signature. Matching a CRL to a certificate by issuer name alone would let + /// anyone who can put bytes in a seal revoke a certificate they do not + /// control — or, worse in the other direction, clear a revoked one by + /// shipping an empty list under the right name. + /// + /// The answer is `unusable` rather than `notAvailable` on purpose: something + /// claiming to be authoritative was there and did not check out, which is + /// worth looking at. Silence would file it beside "no CRL was included", + /// which is an ordinary state of a `B-T` seal. + #[test] + fn a_crl_naming_the_issuer_but_signed_by_another_key_is_unusable() { + let issued = issue(-30, 400); + // Same subject name, freshly generated key. + let impostor = issue(-30, 400); + let (seal, _dir) = seal_with( + &[issued.leaf_der.clone(), issued.ca.der().to_vec()], + &[crl(&impostor, Some(-1))], + ); + + let standing = certificate_standing(&seal, Utc::now()).expect("readable"); + match standing.revocation { + RevocationStanding::Unusable { reason } => assert!( + reason.contains("signature"), + "the reason must name what failed: {reason}" + ), + other => panic!("a CRL under a borrowed name must not be believed, got {other:?}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unreadable bytes yield no reference rather than an error. + /// + /// The seal itself is fine — it was produced, paid for, and stored. Losing it + /// because a convenience field could not be filled would trade something that + /// matters for something that does not. + #[test] + fn unreadable_bytes_yield_no_thumbprint() { + assert_eq!( + signer_certificate_thumbprint(b"not a CMS structure at all").unwrap(), + None + ); + assert_eq!(signer_certificate_thumbprint(&[]).unwrap(), None); + } + + /// Reading the certificate out of a seal agrees with the identity that + /// signed it. + /// + /// This is the claim that makes `signing_cert_ref` comparable across + /// backends. One backend knows its own certificate and reports it directly; + /// the other can only read it back out of the bytes a provider returned. If + /// those two ever produced different values for the same certificate, the + /// field would silently stop being a key an auditor can match on. + /// + /// The local backend supplies realistic bytes here because it is the only + /// source of a genuine CMS structure in this crate — a hand-rolled fixture + /// would prove that the parser agrees with the fixture, not with reality. + #[test] + fn the_thumbprint_matches_the_identity_that_signed() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id.sign_detached(&[0x11; 32]).expect("sign"); + + assert_eq!( + signer_certificate_thumbprint(&seal).unwrap(), + Some(id.cert_thumbprint()), + "the certificate read out of a seal must be the one that signed it" + ); + } + + // ─── attested sealing time ─────────────────────────────────────────────── + + /// A timestamped seal reports a time a third party attests, not our clock. + #[test] + fn a_timestamped_seal_reports_an_attested_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineT) + .expect("sign"); + + let attested = attested_sealing_time(&der) + .expect("readable") + .expect("a B-T seal carries a timestamp"); + + let drift = (chrono::Utc::now() - attested).num_seconds().abs(); + assert!( + drift < 300, + "the attested time should be about now: {attested}" + ); + } + + /// A `B-B` seal carries no timestamp, and reports none rather than a guess. + /// + /// The distinction the whole function exists for: no attested time is a + /// different answer from the node's own clock, and `SealedEnvelope::sealed_at` + /// is the latter. + #[test] + fn a_seal_with_no_timestamp_attests_no_time() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id.sign_detached(&[0x11; 32]).expect("sign"); + + assert_eq!(attested_sealing_time(&der).expect("readable"), None); + } + + /// **A genuine token lifted from another seal is refused.** + /// + /// The attack the imprint check exists for, and the reason verifying the + /// token's own signature is not enough on its own. `signature-time-stamp` is + /// an *unsigned* attribute — the seal's signature does not cover it — so + /// swapping the whole token costs nothing. The token moved here is perfectly + /// valid: real authority, sound signature, real `genTime`. It is simply a + /// timestamp of a *different* signature. + /// + /// EN 319 122-1 clause 5.3 says the imprint is over this `SignerInfo`'s + /// signature value, so checking it is what ties the time to this seal. + #[test] + fn a_timestamp_token_from_another_seal_is_refused() { + use cms::content_info::ContentInfo; + use cms::signed_data::SignedData; + + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let mine = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineT) + .expect("sign"); + // A second seal over different content, so its timestamp covers a + // different signature value. + let theirs = id + .sign_detached_at(&[0x22; 32], SealConformanceLevel::BaselineT) + .expect("sign"); + + let borrowed = { + let info = ContentInfo::from_der(&theirs).expect("CMS"); + let sd: SignedData = info.content.decode_as().expect("SignedData"); + sd.signer_infos.0.as_slice()[0] + .unsigned_attrs + .as_ref() + .expect("a B-T seal has unsigned attributes") + .iter() + .find(|a| a.oid == ID_AA_SIGNATURE_TIME_STAMP_TOKEN) + .expect("a signature timestamp") + .clone() + }; + + let info = ContentInfo::from_der(&mine).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + let mut attrs = der::asn1::SetOfVec::new(); + attrs.insert(borrowed).expect("attribute"); + signers[0].unsigned_attrs = Some(attrs); + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + + let swapped = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"); + + assert_eq!( + attested_sealing_time(&swapped).expect("readable"), + None, + "a valid token over someone else's signature must not become this seal's time" + ); + } + + // ─── archival freshness ────────────────────────────────────────────────── + + /// A `B-LTA` seal reports when its archival protection has to be renewed. + #[test] + fn an_archived_seal_reports_a_renewal_date() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineLta) + .expect("sign"); + + let ArchivalFreshness::Current { expires: renew_by } = + archival_freshness(&der, chrono::Utc::now()).expect("readable") + else { + panic!("a fresh LTA seal is current"); + }; + assert!( + renew_by > chrono::Utc::now(), + "the renewal date must be in the future: {renew_by}" + ); + } + + /// The same seal, read from far enough in the future, reports the lapse. + /// + /// The whole point of the signal. Nothing about the seal changes — only the + /// clock — which is exactly how this failure arrives in practice: on a date + /// nobody has in a calendar, years after the passport was locked. + #[test] + fn the_same_seal_read_later_reports_that_it_lapsed() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineLta) + .expect("sign"); + + let ArchivalFreshness::Current { expires: renew_by } = + archival_freshness(&der, chrono::Utc::now()).expect("readable") + else { + panic!("current now"); + }; + let after = renew_by + chrono::Duration::seconds(1); + + assert_eq!( + archival_freshness(&der, after).expect("readable"), + ArchivalFreshness::Lapsed { expires: renew_by }, + "past its authority's certificate, the archival protection is gone" + ); + } + + /// A seal with no archival timestamp has nothing to renew, and says so. + /// + /// `NotArchived` is not `Lapsed`: a `B-LT` seal was never promised long-term + /// protection, and reporting it as lapsed would raise an alarm about a + /// commitment nobody made. + #[test] + fn a_seal_below_lta_has_nothing_to_renew() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineLt) + .expect("sign"); + + assert_eq!( + archival_freshness(&der, chrono::Utc::now()).expect("readable"), + ArchivalFreshness::NotArchived + ); + } + + /// The level says `BaselineLta` either way — which is why this exists. + /// + /// `evidenced_level` reports the *presence* of the archival material, and is + /// right to: the material is there. The two answers together are the finding + /// — a seal that still evidences LTA and whose archival protection has gone. + #[test] + fn a_lapsed_seal_still_evidences_lta() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id + .sign_detached_at(&[0x11; 32], SealConformanceLevel::BaselineLta) + .expect("sign"); + + // Past the local authority's certificate, which runs to 4096. + let far_future = "9999-01-01T00:00:00Z" + .parse::>() + .expect("a representable instant"); + assert!(matches!( + archival_freshness(&der, far_future).expect("readable"), + ArchivalFreshness::Lapsed { .. } + )); + assert_eq!( + evidenced_level(&der).expect("readable"), + Some(SealConformanceLevel::BaselineLta), + "the level is unchanged, which is exactly how this goes unnoticed" ); } diff --git a/crates/dpp-seal/src/eideasy/client.rs b/crates/dpp-seal/src/eideasy/client.rs index de6ac3de..314d5e3b 100644 --- a/crates/dpp-seal/src/eideasy/client.rs +++ b/crates/dpp-seal/src/eideasy/client.rs @@ -240,6 +240,30 @@ impl SealBackend for EideasyClient { } } +/// The eID Easy `signature_profile` string naming a baseline level. +/// +/// The inverse of [`level_for_profile`], and the reason a caller need not know +/// the provider's spelling to ask for a level. `signature_profile_round_trips` +/// pins the two against each other: they are a pair, and a pair that drifts +/// would have the node request one level and the provider produce another, +/// which is precisely the downgrade `cades::evidenced_level` exists to notice +/// after the fact. +/// `None` for a level this adapter cannot name. [`SealConformanceLevel`] is +/// `#[non_exhaustive]`, so core may add one; guessing a profile string for it +/// would ask the provider for something and record something else. A caller +/// getting `None` should leave the profile alone and let the capability probe +/// report the mismatch it actually is. +#[must_use] +pub fn profile_for_level(level: SealConformanceLevel) -> Option<&'static str> { + match level { + SealConformanceLevel::BaselineB => Some("CAdES_BASELINE_B"), + SealConformanceLevel::BaselineT => Some("CAdES_BASELINE_T"), + SealConformanceLevel::BaselineLt => Some("CAdES_BASELINE_LT"), + SealConformanceLevel::BaselineLta => Some("CAdES_BASELINE_LTA"), + _ => None, + } +} + /// The baseline level an eID Easy `signature_profile` string names. /// /// `None` for anything unrecognised, which empties `supported_levels` and — since @@ -306,6 +330,31 @@ fn clock_hint(ours: u64, theirs: Option) -> super::error::AuthHint { mod tests { use super::*; + /// The two profile mappings are inverses, and must stay so. + /// + /// They are a pair used in opposite directions: one decides what this + /// adapter *advertises* it can produce, the other what the node *asks* the + /// provider for. Drift between them would have the node request `LT`, + /// receive `T`, and record the request — the exact downgrade + /// `cades::evidenced_level` exists to catch after the fact, arriving on a + /// retention-locked passport that cannot be re-sealed. + #[test] + fn the_profile_mappings_are_inverses() { + for level in [ + SealConformanceLevel::BaselineB, + SealConformanceLevel::BaselineT, + SealConformanceLevel::BaselineLt, + SealConformanceLevel::BaselineLta, + ] { + let profile = profile_for_level(level).expect("this adapter names every current level"); + assert_eq!( + level_for_profile(profile), + Some(level), + "{profile} must round-trip to the level it was derived from" + ); + } + } + #[test] fn hmac_message_matches_documented_example() { // From eID Easy docs (POST, the e-seal path, ts, exact body). diff --git a/crates/dpp-seal/src/eideasy/config.rs b/crates/dpp-seal/src/eideasy/config.rs index 7cd07fc6..f8387656 100644 --- a/crates/dpp-seal/src/eideasy/config.rs +++ b/crates/dpp-seal/src/eideasy/config.rs @@ -76,7 +76,13 @@ pub const ENV_GROUP: &str = "SEAL_EIDEASY_*"; const ENV_BASE_URL: &str = "SEAL_EIDEASY_BASE_URL"; const ENV_CLIENT_ID: &str = "SEAL_EIDEASY_CLIENT_ID"; const ENV_HMAC_KEY: &str = "SEAL_EIDEASY_HMAC_KEY"; -const ENV_SIGNATURE_PROFILE: &str = "SEAL_EIDEASY_SIGNATURE_PROFILE"; +/// The profile override. +/// +/// Public because the composition root needs to know whether the operator set +/// it. When they did not, the profile is derived from the conformance level +/// they *did* ask for — see [`crate::eideasy::profile_for_level`] — and a +/// hard-coded default here would silently contradict that. +pub const ENV_SIGNATURE_PROFILE: &str = "SEAL_EIDEASY_SIGNATURE_PROFILE"; /// Whether any variable of this backend's group is set. pub fn any_env_set(get: &impl Fn(&str) -> Option) -> bool { diff --git a/crates/dpp-seal/src/eideasy/mod.rs b/crates/dpp-seal/src/eideasy/mod.rs index 17fbdcb1..e0e9439d 100644 --- a/crates/dpp-seal/src/eideasy/mod.rs +++ b/crates/dpp-seal/src/eideasy/mod.rs @@ -54,7 +54,7 @@ pub mod types; #[cfg(test)] mod tests; -pub use client::EideasyClient; +pub use client::{EideasyClient, profile_for_level}; pub use config::{EideasyConfig, EideasyEnvironment}; pub use error::{AuthHint, EideasyError}; pub use types::{EsealFile, EsealRequest, EsealResponse, EsealSignatureOut, MIME_JSON, MIME_PDF}; diff --git a/crates/dpp-seal/src/inspect.rs b/crates/dpp-seal/src/inspect.rs new file mode 100644 index 00000000..af8a2d01 --- /dev/null +++ b/crates/dpp-seal/src/inspect.rs @@ -0,0 +1,432 @@ +//! The [`SealInspector`] adapter — reading a stored seal's certificate. +//! +//! Answers one question for the services that serve seals: **did a provider +//! issue this, or did the node sign it itself?** That is a property of the +//! bytes, so it is the same answer whichever backend produced them, and this +//! adapter is consequently stateless and provider-agnostic. +//! +//! It sits here rather than in the consuming service because CMS and X.509 +//! parsing belongs beside the code that produces seals — one home for +//! certificate handling, so two places cannot disagree about what a seal says. +//! [`dpp_types::SealInspector`] is the seam that keeps the consumer from having +//! to link this crate to ask. + +use base64::Engine as _; +use dpp_domain::seal::{SealConformanceLevel, SealFormat, SealedEnvelope}; +use dpp_types::{SealBinding, SealInspector, SealOrigin}; + +use crate::cades; + +/// Reads CAdES seals. +/// +/// Stateless: it holds no credential and reaches no network, because reading a +/// certificate out of bytes needs neither. Construct it with +/// [`CadesInspector::new`] wherever a [`SealInspector`] is wanted. +#[derive(Debug, Clone, Copy, Default)] +pub struct CadesInspector; + +impl CadesInspector { + /// A new inspector. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl SealInspector for CadesInspector { + /// # What yields `None` + /// + /// A placeholder envelope, a format this adapter does not parse, a + /// `sealValue` that is not base64, and bytes that are not readable CMS. All + /// four are *not read* rather than a finding, which is why none of them + /// fabricates a `SealOrigin` — a `self_issued` of either value would be a + /// claim about a certificate nobody examined. + /// + /// An unreadable seal is logged at `warn`: a stored seal that will not parse + /// is an anomaly worth investigating, and the caller only learns that the + /// field is absent. + fn origin(&self, envelope: &SealedEnvelope) -> Option { + if envelope.placeholder { + return None; + } + // Only CAdES here. The other formats in `SealFormat` are lawful and this + // crate does not emit them; silently parsing one as CMS would either + // fail confusingly or, worse, half-succeed. + if envelope.format != SealFormat::Cades { + tracing::debug!( + format = ?envelope.format, + "seal origin not read: this inspector reads CAdES only" + ); + return None; + } + + let der = match base64::engine::general_purpose::STANDARD.decode(&envelope.seal_value) { + Ok(d) => d, + Err(e) => { + tracing::warn!(error = %e, "stored seal value is not base64; origin not read"); + return None; + } + }; + + match cades::signer_certificate(&der) { + Ok(c) => Some(c.origin()), + Err(e) => { + tracing::warn!(error = %e, "stored seal could not be read; origin not read"); + None + } + } + } + + /// # The order of the two checks + /// + /// The signature is checked **first**, and a failure short-circuits without + /// reporting a digest. The digest lives in an attribute inside that + /// signature: reading it out of a seal whose signature does not verify and + /// then comparing it would be comparing against a value anybody could have + /// written, and reporting it would hand a caller a number that nothing + /// vouches for. + fn binding(&self, envelope: &SealedEnvelope, payload_hash: &str) -> SealBinding { + let Some(der) = self.readable(envelope) else { + return SealBinding::Unknown; + }; + + match cades::verify_against_embedded_certificate(&der) { + Ok(true) => {} + Ok(false) => return SealBinding::NotIntact, + Err(e) => { + // A seal carrying no signed attributes lands here: its digest is + // not inside it and cannot be recovered from the envelope alone. + // That is unknown, not broken. + tracing::warn!(error = %e, "stored seal could not be checked; binding not read"); + return SealBinding::Unknown; + } + } + + let covered = match cades::covered_digest(&der) { + Ok(Some(d)) => hex::encode(d), + Ok(None) => return SealBinding::Unknown, + Err(e) => { + tracing::warn!(error = %e, "stored seal names no readable digest"); + return SealBinding::Unknown; + } + }; + + // Compared case-insensitively on the hex rather than on bytes: the value + // arrives as a string from an outbox row and a wire field, and a seal + // that matched or not depending on who upper-cased it would be a very + // confusing bug to meet. + if covered.eq_ignore_ascii_case(payload_hash) { + SealBinding::CoversThisSignature + } else { + SealBinding::CoversAnotherDigest { covered } + } + } + fn evidenced_level(&self, envelope: &SealedEnvelope) -> Option { + let der = self.readable(envelope)?; + match cades::evidenced_level(&der) { + Ok(level) => level, + Err(e) => { + tracing::warn!(error = %e, "stored seal could not be read; level not evidenced"); + None + } + } + } + fn attested_sealing_time( + &self, + envelope: &SealedEnvelope, + ) -> Option> { + let der = self.readable(envelope)?; + match cades::attested_sealing_time(&der) { + Ok(at) => at, + Err(e) => { + tracing::warn!(error = %e, "stored seal could not be read; no attested time"); + None + } + } + } + fn archival_freshness( + &self, + envelope: &SealedEnvelope, + now: chrono::DateTime, + ) -> dpp_types::ArchivalFreshness { + let Some(der) = self.readable(envelope) else { + return dpp_types::ArchivalFreshness::NotArchived; + }; + match cades::archival_freshness(&der, now) { + Ok(cades::ArchivalFreshness::NotArchived) => dpp_types::ArchivalFreshness::NotArchived, + Ok(cades::ArchivalFreshness::Current { expires }) => { + dpp_types::ArchivalFreshness::Current { expires } + } + Ok(cades::ArchivalFreshness::Lapsed { expires }) => { + dpp_types::ArchivalFreshness::Lapsed { expires } + } + Ok(cades::ArchivalFreshness::Unknown) => dpp_types::ArchivalFreshness::Unknown, + Err(e) => { + tracing::warn!(error = %e, "stored seal could not be read; archival state unknown"); + dpp_types::ArchivalFreshness::Unknown + } + } + } + + fn certificate_standing( + &self, + envelope: &SealedEnvelope, + now: chrono::DateTime, + ) -> Option { + let der = self.readable(envelope)?; + match cades::certificate_standing(&der, now) { + Ok(standing) => Some(standing), + // Nothing is reported rather than a standing built on a guess. A + // seal that will not parse has no certificate to speak about, and + // saying "not revoked, window unknown" would be an assurance drawn + // from bytes nobody could read. + Err(e) => { + tracing::warn!(error = %e, "stored seal could not be read; certificate standing unknown"); + None + } + } + } +} + +impl CadesInspector { + /// The seal's DER, when there is something readable to work with. + /// + /// Shared by both questions so they cannot disagree about which envelopes + /// are worth opening — a placeholder that yielded no origin but did yield a + /// binding would be incoherent. + fn readable(&self, envelope: &SealedEnvelope) -> Option> { + if envelope.placeholder || envelope.format != SealFormat::Cades { + return None; + } + base64::engine::general_purpose::STANDARD + .decode(&envelope.seal_value) + .ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp_domain::seal::SealConformanceLevel; + + /// An envelope carrying `seal_value`, with everything else plausible. + fn envelope(seal_value: String, format: SealFormat, placeholder: bool) -> SealedEnvelope { + SealedEnvelope { + format, + seal_value, + signing_cert_ref: None, + conformance_level: Some(SealConformanceLevel::BaselineB), + sealed_at: chrono::Utc::now(), + placeholder, + } + } + + /// A real local seal reads back as self-issued. + /// + /// The whole point of the adapter, over genuine bytes rather than a fixture: + /// the local backend is the only source of real CMS in this crate. + #[test] + fn a_real_local_seal_reads_as_self_issued() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id.sign_detached(&[0x11; 32]).expect("sign"); + let value = base64::engine::general_purpose::STANDARD.encode(&der); + + let origin = CadesInspector::new() + .origin(&envelope(value, SealFormat::Cades, false)) + .expect("a readable seal"); + + assert!(origin.self_issued, "the local backend is self-signed"); + assert_eq!(origin.issuer, origin.subject); + assert_eq!( + origin.creation_device, + dpp_types::CreationDevice::NotAQualifiedCertificate + ); + } + + /// A placeholder is never read, and never reported as self-issued. + /// + /// The trap this guards: a ghost envelope carries no certificate, and the + /// cheapest wrong answer — "no issuer, so self-issued" — would report the + /// most alarming finding about a seal that does not exist. Absent is the + /// only honest answer. + #[test] + fn a_placeholder_yields_no_origin_rather_than_a_finding() { + let e = envelope("Z2hvc3Q=".to_owned(), SealFormat::Cades, true); + assert!(CadesInspector::new().origin(&e).is_none()); + } + + /// Unreadable bytes yield no origin either, for the same reason. + #[test] + fn unreadable_bytes_yield_no_origin() { + let junk = base64::engine::general_purpose::STANDARD.encode(b"not a CMS structure"); + assert!( + CadesInspector::new() + .origin(&envelope(junk, SealFormat::Cades, false)) + .is_none() + ); + assert!( + CadesInspector::new() + .origin(&envelope( + "not base64 at all!!".to_owned(), + SealFormat::Cades, + false + )) + .is_none() + ); + } + + // ─── What the seal says it covers ──────────────────────────────────────── + + /// The digest a local seal was taken over, hex-encoded. + fn digest_hex(bytes: &[u8]) -> String { + hex::encode(bytes) + } + + /// A seal reports the signature it actually covers. + /// + /// The whole point: this comes out of the seal's own `messageDigest` + /// attribute, not out of any record this node keeps, so it holds for a seal + /// restored from a backup or produced somewhere else entirely. + #[test] + fn a_seal_reports_the_signature_it_actually_covers() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let digest = [0x11; 32]; + let der = id.sign_detached(&digest).expect("sign"); + let e = envelope( + base64::engine::general_purpose::STANDARD.encode(&der), + SealFormat::Cades, + false, + ); + + assert_eq!( + CadesInspector::new().binding(&e, &digest_hex(&digest)), + SealBinding::CoversThisSignature + ); + } + + /// Asked about a different signature, the same seal says so. + /// + /// What a re-published passport looks like: the passport re-signed, so its + /// current signature is not the one sealed. The digest the seal *does* cover + /// is reported, because a caller holding it can go and find which version it + /// belongs to. + #[test] + fn a_seal_asked_about_another_signature_reports_the_one_it_covers() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let digest = [0x11; 32]; + let der = id.sign_detached(&digest).expect("sign"); + let e = envelope( + base64::engine::general_purpose::STANDARD.encode(&der), + SealFormat::Cades, + false, + ); + + let SealBinding::CoversAnotherDigest { covered } = + CadesInspector::new().binding(&e, &digest_hex(&[0x22; 32])) + else { + panic!("this seal covers a different digest"); + }; + assert_eq!(covered, digest_hex(&digest)); + } + + /// **A seal cannot be retargeted by editing what it says it covers.** + /// + /// The attack this check exists to stop, and the reason the signature is + /// verified before the digest is read. Rewriting the `messageDigest` + /// attribute to name another passport's signature is trivial — the attribute + /// is plain DER — but it sits *inside* the signature, so the edit breaks it. + /// + /// Note what is reported: `NotIntact`, with **no digest**. Reporting the + /// forged value would hand a caller a number that nothing vouches for, and + /// reporting `CoversAnotherDigest` would describe a broken seal as merely + /// out of date. + #[test] + fn a_seal_cannot_be_retargeted_by_editing_the_digest_it_names() { + use cms::content_info::ContentInfo; + use cms::signed_data::SignedData; + use der::{Decode as _, Encode as _}; + + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id.sign_detached(&[0x11; 32]).expect("sign"); + + let target = [0x22; 32]; + let info = ContentInfo::from_der(&der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + + let attrs = signers[0].signed_attrs.as_ref().expect("signed attributes"); + let mut rebuilt = der::asn1::SetOfVec::new(); + for a in attrs.iter() { + let a = if a.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST { + let mut values = der::asn1::SetOfVec::new(); + values + .insert( + der::Any::encode_from( + &der::asn1::OctetString::new(target.as_slice()).expect("octets"), + ) + .expect("any"), + ) + .expect("value"); + x509_cert::attr::Attribute { oid: a.oid, values } + } else { + a.clone() + }; + rebuilt.insert(a).expect("attribute"); + } + signers[0].signed_attrs = Some(rebuilt); + + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + let forged = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode"); + + let e = envelope( + base64::engine::general_purpose::STANDARD.encode(&forged), + SealFormat::Cades, + false, + ); + + assert_eq!( + CadesInspector::new().binding(&e, &digest_hex(&target)), + SealBinding::NotIntact, + "the edit must break the signature rather than succeed in retargeting the seal" + ); + } + + /// A placeholder binds to nothing, and says so rather than denying. + #[test] + fn a_placeholder_binds_to_nothing_rather_than_mismatching() { + let e = envelope("Z2hvc3Q=".to_owned(), SealFormat::Cades, true); + assert_eq!( + CadesInspector::new().binding(&e, &digest_hex(&[0x11; 32])), + SealBinding::Unknown, + "an unread seal must never report as covering something else" + ); + } + + /// A format this adapter does not parse is skipped, not guessed at. + #[test] + fn a_non_cades_format_is_not_parsed_as_cms() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let der = id.sign_detached(&[0x11; 32]).expect("sign"); + let value = base64::engine::general_purpose::STANDARD.encode(&der); + + // The very bytes that read fine as CAdES, labelled otherwise. + assert!( + CadesInspector::new() + .origin(&envelope(value, SealFormat::Jades, false)) + .is_none(), + "the declared format decides, not what the bytes happen to be" + ); + } +} diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index b71289b6..c31fe44c 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -45,10 +45,13 @@ pub mod config; pub mod eideasy; pub mod error; pub mod ghost; +pub mod inspect; pub mod local; +pub mod qualification; pub mod trustlist; pub use adapter::QtspSealAdapter; pub use backend::SealBackend; pub use config::{SEAL_PROVIDER, SealProvider}; pub use error::SealError; +pub use inspect::CadesInspector; diff --git a/crates/dpp-seal/src/local/mod.rs b/crates/dpp-seal/src/local/mod.rs index 6cdab3b3..7fbee12a 100644 --- a/crates/dpp-seal/src/local/mod.rs +++ b/crates/dpp-seal/src/local/mod.rs @@ -13,5 +13,8 @@ pub mod config; pub mod sealer; +mod timestamp; + pub use config::LocalConfig; pub use sealer::LocalIdentity; +pub use timestamp::DEVELOPMENT_TSA_POLICY; diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index 0b707a04..0998887c 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -43,6 +43,8 @@ use p256::ecdsa::{DerSignature, SigningKey}; use x509_cert::Certificate; use x509_cert::attr::Attribute; +use cms::revocation::{RevocationInfoChoice, RevocationInfoChoices}; + use crate::backend::SealBackend; use crate::error::SealError; @@ -51,6 +53,11 @@ pub struct LocalIdentity { key: SigningKey, cert: Certificate, cert_der: Vec, + /// The authority that timestamps this node's own seals. + /// + /// Held here rather than built per seal because its certificate has to stay + /// the same across restarts: a token issued yesterday refers to it. + tsa: super::timestamp::LocalTsa, } impl LocalIdentity { @@ -68,7 +75,11 @@ impl LocalIdentity { let key_der = std::fs::read(&key_path).map_err(io_err("read the local seal key"))?; let cert_der = std::fs::read(&cert_path).map_err(io_err("read the local seal certificate"))?; - return Self::from_der(&key_der, cert_der); + return Self::from_der( + &key_der, + cert_der, + super::timestamp::LocalTsa::load_or_create(dir)?, + ); } let (key_der, cert_der) = generate()?; @@ -76,10 +87,15 @@ impl LocalIdentity { std::fs::write(&key_path, &key_der).map_err(io_err("write the local seal key"))?; std::fs::write(&cert_path, &cert_der) .map_err(io_err("write the local seal certificate"))?; - Self::from_der(&key_der, cert_der) + let tsa = super::timestamp::LocalTsa::load_or_create(dir)?; + Self::from_der(&key_der, cert_der, tsa) } - fn from_der(key_der: &[u8], cert_der: Vec) -> Result { + fn from_der( + key_der: &[u8], + cert_der: Vec, + tsa: super::timestamp::LocalTsa, + ) -> Result { use p256::pkcs8::DecodePrivateKey as _; let key = SigningKey::from_pkcs8_der(key_der) .map_err(|e| SealError::Config(format!("local seal key is not a P-256 PKCS#8: {e}")))?; @@ -90,6 +106,7 @@ impl LocalIdentity { key, cert, cert_der, + tsa, }) } @@ -105,6 +122,48 @@ impl LocalIdentity { /// separately from what it covers — the same arrangement a provider returns /// and the same one the passport's `jwsSignature` expects. pub fn sign_detached(&self, digest: &[u8]) -> Result, SealError> { + self.sign_detached_at(digest, SealConformanceLevel::BaselineB) + } + + /// Produce a detached CMS `SignedData` over `digest`, at `level`. + /// + /// # What each level adds, and where the requirement comes from + /// + /// ETSI EN 319 122-1 V1.3.1 Table 1, read directly: + /// + /// - **B-B** — `SignedData.certificates` shall be present. Nothing else. + /// - **B-T** — a `signature-time-stamp` unsigned attribute *shall* be + /// present, computed over the signature (clause 5.3). + /// - **B-LT** — revocation material for long-term validation *shall be + /// provided*, in `SignedData.crls`. Note the table also says the older + /// `certificate-values` and `revocation-values` attributes **shall not be + /// present** at this level, so this deliberately does not emit them. + /// - **B-LTA** — an `archive-time-stamp-v3` *shall be provided* + /// (clause 5.5.3). + /// + /// Cumulative, because the table is: an `B-LTA` signature carries + /// everything the levels below it carry. + /// + /// # This is a faithful shape, not a conformant signature + /// + /// The attributes are real, well-formed and really signed, which is what + /// lets every path in this workspace that reads a seal be exercised against + /// something other than a stub. Two deliberate departures from conformance, + /// both of which matter only to an external validator that this seal could + /// never satisfy anyway — it is self-signed and on no Trusted List: + /// + /// - the `archive-time-stamp-v3` message imprint is taken over the signer's + /// encoded form rather than the concatenation clause 5.5.3 specifies, and + /// - no `ats-hash-index-v3` attribute is produced. + /// + /// Building those faithfully would be work in service of a validator that + /// rejects the certificate on the first check. Saying so here is cheaper and + /// more honest than a comment claiming conformance nobody verified. + pub fn sign_detached_at( + &self, + digest: &[u8], + level: SealConformanceLevel, + ) -> Result, SealError> { use der::asn1::{OctetString, SetOfVec}; use p256::ecdsa::signature::Signer as _; @@ -136,7 +195,7 @@ impl LocalIdentity { // reconstruct what was signed. Attaching `messageDigest` is also what // CAdES requires, so this is the faithful shape rather than a // concession. - let signed_attrs = signed_attributes(digest)?; + let signed_attrs = signed_attributes(ID_DATA, digest)?; // RFC 5652 §5.4: the signature is computed over the DER **SET OF** // encoding of the signed attributes, not over the `[0] IMPLICIT` form @@ -163,6 +222,7 @@ impl LocalIdentity { .map_err(|e| SealError::Config(format!("cannot encode the signature: {e}")))?, unsigned_attrs: None, }; + let signer_info = self.at_level(signer_info, level)?; let mut digest_algorithms = SetOfVec::new(); digest_algorithms @@ -173,6 +233,19 @@ impl LocalIdentity { certs .insert(CertificateChoices::Certificate(self.cert.clone())) .map_err(|e| SealError::Config(format!("cannot attach the certificate: {e}")))?; + // From B-T upward the seal carries a timestamp, and EN 319 122-1 + // requires a verifier to be able to build a path for every timestamp in + // it. A token whose signing certificate travels nowhere is unverifiable + // by anyone but the node that made it. + if level.survives_certificate_expiry() || level == SealConformanceLevel::BaselineT { + certs + .insert(CertificateChoices::Certificate( + self.tsa.certificate().clone(), + )) + .map_err(|e| { + SealError::Config(format!("cannot attach the TSA certificate: {e}")) + })?; + } let mut signer_infos = SetOfVec::new(); signer_infos @@ -184,7 +257,7 @@ impl LocalIdentity { digest_algorithms: DigestAlgorithmIdentifiers::from(digest_algorithms), encap_content_info: econtent, certificates: Some(CertificateSet::from(certs)), - crls: None, + crls: self.revocation_material(level)?, signer_infos: SignerInfos::from(signer_infos), }; @@ -198,12 +271,171 @@ impl LocalIdentity { } } +impl LocalIdentity { + /// `id-aa-signatureTimeStampToken` — RFC 5126 §6.1.1, EN 319 122-1 cl. 5.3. + const ID_AA_SIGNATURE_TIME_STAMP_TOKEN: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.16.2.14"); + + /// `id-aa-ets-archiveTimestampV3` — EN 319 122-1 cl. 5.5.3, annex D. + const ID_AA_ETS_ARCHIVE_TIMESTAMP_V3: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("0.4.0.1733.2.4"); + + /// Attach the unsigned attributes `level` requires to `signer`. + /// + /// The archive timestamp is applied **after** the signature timestamp and + /// over a signer that already carries it, which is the ordering EN 319 122-1 + /// clause 5.5.3 describes: the archive timestamp protects the signature + /// *including* the material added to it, so applying it first would leave + /// the timestamp it is supposed to cover outside its imprint. + fn at_level( + &self, + signer: SignerInfo, + level: SealConformanceLevel, + ) -> Result { + use sha2::{Digest as _, Sha256}; + + if level == SealConformanceLevel::BaselineB { + return Ok(signer); + } + + // Clause 5.3: computed on the signature field *without* its ASN.1 tag + // and length — the value octets alone. Taken before the signer is moved, + // because the imprint is over the signature as it stands *now*: an + // attribute added first would not change it, but reading it afterwards + // would invite exactly that mistake. + let imprint = Sha256::digest(signer_signature_value(&signer)); + let mut signer = + self.with_attribute(signer, Self::ID_AA_SIGNATURE_TIME_STAMP_TOKEN, &imprint, 1)?; + + if level == SealConformanceLevel::BaselineLta { + // See `sign_detached_at`: the imprint departs from clause 5.5.3's + // concatenation deliberately, and the departure is documented there + // rather than hidden behind a plausible-looking helper name. + let encoded = signer.to_der().map_err(|e| { + SealError::Config(format!("cannot encode the signer for archiving: {e}")) + })?; + signer = self.with_attribute( + signer, + Self::ID_AA_ETS_ARCHIVE_TIMESTAMP_V3, + &Sha256::digest(&encoded), + 2, + )?; + } + + Ok(signer) + } + + /// `signer` with one more unsigned attribute carrying a time-stamp token. + fn with_attribute( + &self, + mut signer: SignerInfo, + oid: const_oid::ObjectIdentifier, + imprint: &[u8], + serial: u64, + ) -> Result { + use der::asn1::SetOfVec; + + let mut values = SetOfVec::new(); + values + .insert(self.tsa.token(imprint, serial)?) + .map_err(|e| SealError::Config(format!("cannot build the timestamp attribute: {e}")))?; + + let mut attrs = signer + .unsigned_attrs + .take() + .map(|a| a.iter().cloned().collect::>()) + .unwrap_or_default(); + attrs.push(Attribute { oid, values }); + + let mut set = SetOfVec::new(); + for a in attrs { + set.insert(a).map_err(|e| { + SealError::Config(format!("cannot collect unsigned attributes: {e}")) + })?; + } + signer.unsigned_attrs = Some(set); + Ok(signer) + } + + /// The revocation material `level` requires, in the home EN 319 122-1 names. + /// + /// `SignedData.crls`, and **not** the `revocation-values` unsigned + /// attribute: Table 1 marks that older home "shall not be present" at B-LT + /// and B-LTA. `cades::evidenced_level` accepts either because a seal bought + /// under the superseded profile is still lawful; a seal *produced* here has + /// no such excuse. + /// + /// The list is empty, which is the truthful statement — this node has + /// revoked nothing — rather than a placeholder entry that would claim a + /// revocation that never happened. + fn revocation_material( + &self, + level: SealConformanceLevel, + ) -> Result, SealError> { + use der::asn1::{BitString, SetOfVec}; + use p256::ecdsa::signature::Signer as _; + + if !level.survives_certificate_expiry() { + return Ok(None); + } + + let algorithm = x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ECDSA_WITH_SHA_256, + parameters: None, + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| SealError::Config(format!("the clock is before 1970: {e}")))?; + + let tbs = x509_cert::crl::TbsCertList { + version: x509_cert::Version::V2, + signature: algorithm.clone(), + // Self-signed, so the sealing certificate is its own issuer and this + // node is the only authority that could speak to its revocation. + issuer: self.cert.tbs_certificate.subject.clone(), + this_update: x509_cert::time::Time::GeneralTime( + der::asn1::GeneralizedTime::from_unix_duration(now) + .map_err(|e| SealError::Config(format!("cannot encode thisUpdate: {e}")))?, + ), + next_update: None, + revoked_certificates: None, + crl_extensions: None, + }; + let signature: DerSignature = self.key.sign( + &tbs.to_der() + .map_err(|e| SealError::Config(format!("cannot encode the CRL body: {e}")))?, + ); + + let crl = x509_cert::crl::CertificateList { + tbs_cert_list: tbs, + signature_algorithm: algorithm, + signature: BitString::from_bytes(signature.to_bytes().as_ref()) + .map_err(|e| SealError::Config(format!("cannot encode the CRL signature: {e}")))?, + }; + + let mut set = SetOfVec::new(); + set.insert(RevocationInfoChoice::Crl(crl)) + .map_err(|e| SealError::Config(format!("cannot attach the CRL: {e}")))?; + Ok(Some(RevocationInfoChoices(set))) + } +} + +/// The value octets of a `SignerInfo`'s signature, without tag or length. +/// +/// EN 319 122-1 clause 5.3 is explicit that the signature timestamp covers the +/// signature field "without the ASN.1 tag and length". Timestamping the encoded +/// OCTET STRING instead would produce a token that no other implementation +/// could reproduce. +fn signer_signature_value(signer: &SignerInfo) -> &[u8] { + signer.signature.as_bytes() +} + #[async_trait] impl SealBackend for LocalIdentity { async fn seal(&self, req: SealRequest) -> Result { let digest = hex::decode(&req.payload_hash) .map_err(|e| SealError::Config(format!("payload hash is not hex: {e}")))?; - let der = self.sign_detached(&digest)?; + let der = self.sign_detached_at(&digest, req.conformance_level)?; Ok(SealedEnvelope { format: SealFormat::Cades, @@ -238,15 +470,20 @@ impl SealBackend for LocalIdentity { SealCapabilities { supported_formats: vec![SealFormat::Cades], supported_modes: vec![SealMode::OperatorSeal], - // `BaselineB` and no further, read off what `sign_detached` - // actually emits: the signature alone. No signature timestamp - // (`BaselineT`), no certificates or revocation data - // (`BaselineLt`), no archival timestamp (`BaselineLta`). Claiming a - // higher level would claim evidence these bytes do not carry — and - // `BaselineB` is documented as not suiting a retention-locked - // document, which is the honest position for a self-signed - // development sealer. - supported_levels: vec![SealConformanceLevel::BaselineB], + // Every baseline level, read off what `sign_detached_at` actually + // emits: the signature (B), a signature timestamp (T), revocation + // material in `SignedData.crls` (LT) and an archive timestamp + // (LTA), each required by ETSI EN 319 122-1 Table 1 at that level. + // + // **Advertising a level is a claim about structure, never about + // trust.** Every one of these is signed by a key this node generated + // and timestamped by an authority it generated, so an `LTA` seal + // from here is a faithfully shaped envelope with no legal weight + // whatsoever — which is exactly what makes it useful for exercising + // the paths that read a seal, and useless for anything else. The + // node says so separately and structurally by resolving this backend + // to the `Ghost` trust tier. + supported_levels: SealConformanceLevel::ALL.to_vec(), // Detached: the signature travels beside the digest it covers and // never wraps it. supported_envelopes: vec![SealEnvelope::Detached], @@ -307,7 +544,10 @@ impl SealBackend for LocalIdentity { /// asked to seal. The second is the one that matters here — it is what puts the /// sealed value inside the signature, so a holder of the bytes alone can check /// them. -fn signed_attributes(digest: &[u8]) -> Result { +pub(super) fn signed_attributes( + content_type: const_oid::ObjectIdentifier, + digest: &[u8], +) -> Result { use der::asn1::{OctetString, SetOfVec}; let attr = |oid, value: Any| -> Result { @@ -320,7 +560,7 @@ fn signed_attributes(digest: &[u8]) -> Result { let content_type = attr( const_oid::db::rfc5911::ID_CONTENT_TYPE, - Any::encode_from(&ID_DATA) + Any::encode_from(&content_type) .map_err(|e| SealError::Config(format!("cannot encode the content type: {e}")))?, )?; let message_digest = attr( diff --git a/crates/dpp-seal/src/local/timestamp.rs b/crates/dpp-seal/src/local/timestamp.rs new file mode 100644 index 00000000..41d1f0cd --- /dev/null +++ b/crates/dpp-seal/src/local/timestamp.rs @@ -0,0 +1,251 @@ +//! A local timestamping authority, so the development backend can produce the +//! shape of a `B-T`, `B-LT` and `B-LTA` seal. +//! +//! # What this is for +//! +//! ETSI EN 319 122-1 Table 1 requires a `signature-time-stamp` from `B-T` +//! upward and an `archive-time-stamp-v3` at `B-LTA`, and both are RFC 3161 +//! time-stamp tokens. Without a timestamping authority the local backend can +//! only ever emit `B-B`, which means every path in this workspace that depends +//! on a higher level — the drain's downgrade check, the evidence dossier, the +//! conformance probe at boot — can only be exercised against a provider nobody +//! can currently buy from. +//! +//! So this stands one up locally. The node generates a TSA key and certificate +//! beside its sealing identity and timestamps its own seals. +//! +//! # What this is emphatically not +//! +//! **Not a qualified timestamp, and not a trusted one.** Regulation (EU) +//! No 910/2014 Art. 42 makes a qualified electronic time stamp a service of a +//! qualified trust service provider, and Art. 41(2) attaches the presumption of +//! accuracy to that. This TSA is a key this node generated for itself: its token +//! attests that a machine's own clock said something, which is worth exactly +//! nothing to anybody else. +//! +//! Three things make that unmistakable rather than a matter of reading the docs: +//! +//! - the TSA certificate is **self-signed** and names itself `NOT A QUALIFIED +//! TIMESTAMP`, so it appears on no Trusted List and never will; +//! - its policy identifier is **deliberately unregistered** — see +//! [`DEVELOPMENT_TSA_POLICY`] — so a validator checking TSA policy sees at once +//! that this is not a real one; +//! - the whole backend already resolves to the `Ghost` trust tier, which a +//! production profile refuses at boot. +//! +//! The tokens are therefore **structurally faithful and legally void**, which is +//! precisely what a development stand-in should be: enough to exercise every +//! path that reads a seal, never enough to be mistaken for one. + +use std::path::Path; + +use der::{Decode as _, Encode as _, asn1::Any}; +use p256::ecdsa::{DerSignature, SigningKey}; +use x509_cert::Certificate; + +use crate::error::SealError; + +use crate::cades::ID_CT_TST_INFO; + +/// The policy this development authority stamps under, deliberately unregistered. +/// +/// RFC 3161 §2.4.2 requires `TSTInfo.policy`, and a real authority names a +/// policy it has published and been audited against. `1.2.3.4.x` is the +/// conventional example arc, allocated to nobody — so this value cannot collide +/// with a real policy and cannot be mistaken for one. A validator that checks +/// the policy identifier learns immediately what it is looking at, which is the +/// intent: the fastest way to be honest about a simulated authority is to make +/// the simulation legible in the bytes. +pub const DEVELOPMENT_TSA_POLICY: &str = "1.2.3.4.1"; + +/// A locally generated timestamping identity: one key, one self-signed +/// certificate carrying the timestamping extended key usage. +pub(super) struct LocalTsa { + key: SigningKey, + cert: Certificate, +} + +impl LocalTsa { + /// Load the authority at `dir`, generating it on first use. + /// + /// Persisted for the same reason the sealing identity is: a token issued + /// yesterday has to keep referring to a certificate that still exists. + pub(super) fn load_or_create(dir: &Path) -> Result { + let key_path = dir.join("tsa-key.pkcs8.der"); + let cert_path = dir.join("tsa-cert.der"); + + let (key_der, cert_der) = if key_path.exists() && cert_path.exists() { + ( + std::fs::read(&key_path).map_err(io_err("read the local TSA key"))?, + std::fs::read(&cert_path).map_err(io_err("read the local TSA certificate"))?, + ) + } else { + let generated = generate()?; + std::fs::create_dir_all(dir).map_err(io_err("create the local TSA directory"))?; + std::fs::write(&key_path, &generated.0).map_err(io_err("write the local TSA key"))?; + std::fs::write(&cert_path, &generated.1) + .map_err(io_err("write the local TSA certificate"))?; + generated + }; + + use p256::pkcs8::DecodePrivateKey as _; + Ok(Self { + key: SigningKey::from_pkcs8_der(&key_der).map_err(|e| { + SealError::Config(format!("local TSA key is not a P-256 PKCS#8: {e}")) + })?, + cert: Certificate::from_der(&cert_der).map_err(|e| { + SealError::Config(format!("local TSA certificate is not valid DER: {e}")) + })?, + }) + } + + /// An RFC 3161 `TimeStampToken` over `imprint`, as an attribute value. + /// + /// `imprint` is the SHA-256 of whatever is being timestamped — the caller + /// decides what that is, because the two levels that need a token compute it + /// over different things (EN 319 122-1 clauses 5.3 and 5.5.3). + /// + /// The token is an **attached** `SignedData`: its `eContent` carries the + /// `TSTInfo`, which is what makes a token self-contained and checkable. + pub(super) fn token(&self, imprint: &[u8], serial: u64) -> Result { + use der::asn1::{OctetString, SetOfVec}; + use p256::ecdsa::signature::Signer as _; + + let sha256 = x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ID_SHA_256, + parameters: None, + }; + + let info = crate::cades::TstInfo { + version: 1, + policy: const_oid::ObjectIdentifier::new(DEVELOPMENT_TSA_POLICY) + .map_err(|e| SealError::Config(format!("the TSA policy OID is malformed: {e}")))?, + message_imprint: crate::cades::MessageImprint { + hash_algorithm: sha256.clone(), + hashed_message: OctetString::new(imprint) + .map_err(|e| SealError::Config(format!("cannot encode the imprint: {e}")))?, + }, + serial_number: serial, + gen_time: der::asn1::GeneralizedTime::from_unix_duration( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| SealError::Config(format!("the clock is before 1970: {e}")))?, + ) + .map_err(|e| SealError::Config(format!("cannot encode the timestamp: {e}")))?, + }; + let tst_der = info + .to_der() + .map_err(|e| SealError::Config(format!("cannot encode the TSTInfo: {e}")))?; + + // The token signs its own payload, so the signed attributes carry the + // TSTInfo's content type and digest — not the sealed document's. + let signed_attrs = { + use sha2::{Digest as _, Sha256}; + super::sealer::signed_attributes(ID_CT_TST_INFO, &Sha256::digest(&tst_der))? + }; + let to_sign = signed_attrs.to_der().map_err(|e| { + SealError::Config(format!("cannot encode the token's signed attributes: {e}")) + })?; + let signature: DerSignature = self.key.sign(&to_sign); + + let signer_info = cms::signed_data::SignerInfo { + version: cms::content_info::CmsVersion::V1, + sid: cms::signed_data::SignerIdentifier::IssuerAndSerialNumber( + cms::cert::IssuerAndSerialNumber { + issuer: self.cert.tbs_certificate.issuer.clone(), + serial_number: self.cert.tbs_certificate.serial_number.clone(), + }, + ), + digest_alg: sha256.clone(), + signed_attrs: Some(signed_attrs), + signature_algorithm: x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ECDSA_WITH_SHA_256, + parameters: None, + }, + signature: OctetString::new(signature.to_bytes().as_ref()).map_err(|e| { + SealError::Config(format!("cannot encode the token's signature: {e}")) + })?, + unsigned_attrs: None, + }; + + let mut digest_algorithms = SetOfVec::new(); + digest_algorithms + .insert(sha256) + .map_err(|e| SealError::Config(format!("cannot record the digest algorithm: {e}")))?; + let mut certs = SetOfVec::new(); + certs + .insert(cms::cert::CertificateChoices::Certificate( + self.cert.clone(), + )) + .map_err(|e| SealError::Config(format!("cannot attach the TSA certificate: {e}")))?; + let mut signer_infos = SetOfVec::new(); + signer_infos + .insert(signer_info) + .map_err(|e| SealError::Config(format!("cannot attach the token's signer: {e}")))?; + + let signed_data = cms::signed_data::SignedData { + version: cms::content_info::CmsVersion::V3, + digest_algorithms: cms::signed_data::DigestAlgorithmIdentifiers::from( + digest_algorithms, + ), + encap_content_info: cms::signed_data::EncapsulatedContentInfo { + econtent_type: ID_CT_TST_INFO, + econtent: Some( + Any::new(der::Tag::OctetString, tst_der.as_slice()) + .map_err(|e| SealError::Config(format!("cannot wrap the TSTInfo: {e}")))?, + ), + }, + certificates: Some(cms::signed_data::CertificateSet::from(certs)), + crls: None, + signer_infos: cms::signed_data::SignerInfos::from(signer_infos), + }; + + Any::encode_from(&cms::content_info::ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: Any::encode_from(&signed_data).map_err(|e| { + SealError::Config(format!("cannot encode the token's SignedData: {e}")) + })?, + }) + .map_err(|e| SealError::Config(format!("cannot encode the time-stamp token: {e}"))) + } + + /// The authority's certificate, so a seal can carry it. + /// + /// EN 319 122-1 requires a verifier to be able to build a path for every + /// timestamp in the signature, and a token whose signing certificate travels + /// nowhere cannot be checked by anyone. + pub(super) fn certificate(&self) -> &Certificate { + &self.cert + } +} + +/// Generate a P-256 key and a self-signed TSA certificate for it. +/// +/// RFC 3161 §2.3 requires the timestamping extended key usage, **critical and +/// sole**: a certificate that may also do other things is not a TSA +/// certificate. `rcgen` emits `extendedKeyUsage` as critical when it is the only +/// purpose set, which is the shape wanted here. +fn generate() -> Result<(Vec, Vec), SealError> { + let mut params = rcgen::CertificateParams::new(vec!["odal-local-tsa".to_owned()]) + .map_err(|e| SealError::Config(format!("cannot build TSA certificate params: {e}")))?; + params.distinguished_name.push( + rcgen::DnType::CommonName, + "Odal Node local development timestamp", + ); + params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "NOT A QUALIFIED TIMESTAMP"); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::TimeStamping]; + + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| SealError::Config(format!("cannot generate a TSA P-256 key: {e}")))?; + let cert = params + .self_signed(&key) + .map_err(|e| SealError::Config(format!("cannot self-sign the TSA certificate: {e}")))?; + + Ok((key.serialize_der(), cert.der().to_vec())) +} + +fn io_err(what: &'static str) -> impl Fn(std::io::Error) -> SealError { + move |e| SealError::Config(format!("cannot {what}: {e}")) +} diff --git a/crates/dpp-seal/src/qualification.rs b/crates/dpp-seal/src/qualification.rs new file mode 100644 index 00000000..d0380b94 --- /dev/null +++ b/crates/dpp-seal/src/qualification.rs @@ -0,0 +1,597 @@ +//! Whether a seal was made under a qualified provider, or signed here. +//! +//! The question this answers is the one an operator, an auditor and a market +//! surveillance authority all ask first, and which nothing in this crate could +//! answer before: **did a qualified trust service provider issue the certificate +//! behind this seal, or did the node sign it itself?** A node running the local +//! development backend produces seals that verify perfectly and mean nothing +//! legally, and until now the two were distinguishable only by knowing which +//! backend was configured. +//! +//! Everything here is read out of the seal. Configuration is not consulted, +//! deliberately: a node knows which backend it was *told* to use, which attests +//! the operator's intent rather than the bytes that came back. +//! +//! # What this reaches, and what it does not +//! +//! Regulation (EU) No 910/2014 Art. 40 applies Art. 32 to seals *mutatis +//! mutandis*, and Art. 32(1) has two legs that an ordinary AdES validation does +//! not reach: +//! +//! - **Art. 32(1)(a)–(b)** — the certificate was a qualified certificate issued +//! by a QTSP. A Trusted List question (Art. 22), answered here by +//! [`IssuerStanding`]. +//! - **Art. 32(1)(f)** — the seal was created by a qualified electronic seal +//! creation device, which **Annex III(j)** requires the certificate to declare +//! in machine-processable form. Answered here by +//! [`CreationDevice`](dpp_types::CreationDevice). +//! +//! Two legs, so two fields rather than one ladder: they are independent, and a +//! seal can hold either without the other. +//! +//! ## The issuer is verified, not merely named +//! +//! Matching starts from the name — the seal certificate's issuer distinguished +//! name against the subject of a CA certificate the list carries — and **does +//! not stop there**. Every listed certificate under that name is then tried as a +//! verifier of the CA's signature over this certificate's `tbsCertificate`, and +//! only a certificate that actually verifies reaches +//! [`IssuerStanding::QualifiedAtSealing`]. +//! +//! Every listed certificate, because a certificate authority rotating its key +//! publishes the old and the new together under one subject name. Stopping at +//! the first would report a genuine seal as unsigned, intermittently, and only +//! for providers mid-rotation. +//! +//! Before that check existed, a self-signed certificate relabelled with a listed +//! CA's name reached the top verdict — and **still verified as a seal**, because +//! a CMS signature covers the signed attributes rather than the certificate +//! travelling beside them. What the relabelling breaks is the certificate's own +//! signature, which is exactly what this now checks. +//! +//! ## A chain that runs out is not a finding about the lists +//! +//! Matching considers every issuer name the seal's embedded certificates refer +//! to, so a seal carrying its intermediates reaches a listed root two or more +//! links up. A seal that does **not** carry them is a different state, and +//! [`IssuerStanding::ChainIncomplete`] reports it as one: the walk ran out of +//! links, so a listed CA may sit above the gap and "no list names this issuer" +//! would be an accusation drawn from a certificate nobody shipped. +//! +//! Which state a seal is in is decided by where its chain ends — +//! [`crate::cades::chain_terminus`] — not by whether the lookup happened to +//! fail. Nothing is fetched to fill the gap: the material a verifier is expected +//! to have is what the seal carries and what the lists publish, and an AIA URL +//! inside an untrusted certificate is not something a node should be dialling. +//! +//! ## The certificate's own standing is a different question, asked elsewhere +//! +//! Art. 32(1)(b), reached for seals by Art. 40, has two limbs: the certificate +//! must have been **issued by a qualified trust service provider**, and it must +//! have been **valid at the time of signing**. This module answers the first — +//! it is the trusted list question, and the list is the only thing that can +//! answer it. +//! +//! The second is answerable from the seal alone, so it lives with the rest of +//! the certificate reading in [`crate::cades::certificate_standing`]: the +//! validity window judged against an attested sealing time, and revocation read +//! from the CRLs the seal carries. This module deliberately does not fold that +//! in. A caller wanting the whole of (b) asks both, which keeps each answer +//! traceable to the evidence it came from — a trusted list, or the bytes. +//! +//! ## Still not `SealChecks::QualifiedValidation` +//! +//! Conditions of Art. 32(1) remain unchecked, and none is a matter of degree: +//! +//! - **(f), the creation device.** [`CreationDevice`] reports what Annex III(j) +//! *declares*, which is the certificate's word for it. Nothing confirms it, +//! and nothing could from bytes alone. +//! - **(c) and (d)** — that the validation data corresponds to what the relying +//! party was given, and that the data representing the seal creator is +//! correctly provided — are about the presentation, not the envelope. +//! - **(h)**, the Art. 26 requirements for an advanced seal. +//! +//! So nothing in this module returns a +//! [`SealChecks`](dpp_domain::seal::SealChecks). A rung would be claimed by +//! whoever wired it up next, and the missing conditions would go with it. + +use base64::Engine as _; +use chrono::{DateTime, Utc}; +use der::{Decode as _, Encode as _}; +use dpp_domain::trusted_list::{TrustServiceStatus, TrustServiceType}; + +use crate::cades::{IssuerCheck, SignerCertificate, signer_certificate}; +use crate::error::SealError; +use crate::trustlist::VerifiedTrustedList; +use dpp_types::CreationDevice; + +/// What the Trusted Lists say about the certificate's issuer. +/// +/// "At sealing" throughout, never "now". A provider granted qualified status in +/// 2029 was not qualified in 2027, and a present-tense check would certify a seal +/// that never was; a provider withdrawn last week did not retroactively unmake +/// the seals it issued. Art. 32(1)(b) asks about the time of sealing, and trusted +/// lists carry the history to answer it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IssuerStanding { + /// The certificate is its own issuer — nobody issued it to this node. + /// + /// What the local development backend produces, and a structural property of + /// the certificate rather than a configuration flag, so it cannot be turned + /// off by an environment variable. A seal here carries **no legal weight + /// whatsoever**; it attests that a key this node holds signed a digest. + SelfIssued { + /// The subject name, which is also the issuer name. + subject: String, + }, + /// Some provider issued it, and no list consulted names that issuer as a + /// qualified CA. + /// + /// Note **consulted**: this is a statement about the lists that were passed + /// in, not about the Union. A caller holding one Member State's list and + /// getting this answer has learnt that the issuer is not in *that* list. + NotListed { + /// The issuer name the certificate carries. + issuer: String, + }, + /// The seal did not carry enough certificates to reach a root, and no list + /// names any issuer it does refer to. + /// + /// **Weaker than [`Self::NotListed`], deliberately.** There, the chain ends + /// at a certificate that issued itself: everything the seal has to say about + /// its own provenance has been said, and no consulted list names it. Here + /// the walk ran out of links — the certificate that would have led somewhere + /// was never shipped — so a listed CA may well sit above the gap, and + /// reporting an unlisted provider would be an accusation drawn from an + /// absence. + /// + /// The distinction is not academic: Member States do not publish the same + /// thing. Most Italian entries are self-signed roots, so a seal from an + /// Italian provider is expected to reach its listed CA through an + /// intermediate — and a provider that omits that intermediate produces + /// exactly this state. ETSI EN 319 122-1 clause 5.2.1 asks generators to + /// include those intermediates where the signature is to be validated + /// through a Trusted List, which is the remedy an operator seeing this + /// should ask their provider for. + ChainIncomplete { + /// The issuer name the seal certificate carries. + issuer: String, + /// The name the walk needed next and the seal did not carry. Equal to + /// `issuer` when the seal carries only its signing certificate. + missing_issuer: String, + }, + /// A listed CA carries this name, and **did not sign this certificate**. + /// + /// The forgery finding. Every listed certificate under that name was tried — + /// a CA mid-rotation publishes several — and none of their keys verifies the + /// signature over this certificate's `tbsCertificate`. + /// + /// Distinct from [`Self::NotListed`], which says nobody listed carries the + /// name at all. Here somebody does, and the seal is claiming to be theirs. + SignatureNotFromListedCa { + /// The issuer name the certificate claims. + issuer: String, + /// The provider whose name was claimed. + provider: Option, + /// The list carrying that name. + territory: Option, + }, + /// A listed CA carries this name and the signature could not be checked. + /// + /// **Not an accusation, and never to be read as one.** The usual cause is a + /// key algorithm this build does not verify; a CA certificate that will not + /// parse is the other. Reporting it as + /// [`Self::SignatureNotFromListedCa`] would call a possibly-genuine seal a + /// forgery on the strength of a check that never ran — the same mistake as + /// reading an unknown trusted-list status as a withdrawn one. + PathUnverifiable { + /// The issuer name the certificate claims. + issuer: String, + /// The provider whose name was claimed. + provider: Option, + /// The list carrying that name. + territory: Option, + /// Why no candidate could be checked. + reason: String, + }, + /// The issuer is a listed CA, but was not granted qualified status when the + /// seal was made. + /// + /// Reached only once the path verifies: this CA did issue the certificate, + /// and was not qualified at the time. + NotQualifiedAtSealing { + /// The issuer name. + issuer: String, + /// The provider the list files that CA under. + provider: Option, + /// The list's `SchemeTerritory`. + territory: Option, + /// The status in force at the sealing time. + /// + /// `None` means the list's history does not reach back that far, which + /// is a **different finding** from a recorded non-granted status: the + /// list is silent about that moment rather than negative about it, and + /// another source may still answer. Collapsing the two into a boolean is + /// how "we do not know" becomes "it was not qualified". + status: Option, + }, + /// A granted qualified CA issued this certificate, and was granted when the + /// seal was made. + /// + /// The issuer's signature over this certificate's `tbsCertificate` verifies + /// under a key the trusted list publishes for it, so the name is not merely + /// claimed. What is still **not** established here: the certificate's own + /// validity window at sealing time, and whether it had been revoked — see + /// the module documentation. + QualifiedAtSealing { + /// The issuer name. + issuer: String, + /// The provider the list files that CA under. + provider: Option, + /// The list's `SchemeTerritory`. + territory: Option, + /// Whether the same provider also held **qualified remote seal creation + /// device management** at the sealing time. + /// + /// The Art. 39a service. eIDAS 2 made managing remote qualified seal + /// creation devices a qualified trust service in its own right, and the + /// Art. 51(3) transitional that allowed it to be done without qualified + /// status **expired on 21 May 2026**. A provider holding the certificate + /// leg and not this one cannot supply the creation-device limb of + /// Art. 3(27) for a *remote* seal, however good the certificate is. + /// + /// False is therefore not merely an absence for a cloud-sealing + /// arrangement, which is what this node's hosted mode is. + remote_qscd_management: bool, + }, +} + +/// The two legs of Art. 32(1), read off one seal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SealQualification { + /// Art. 32(1)(a)–(b) — who issued the certificate, and their standing. + pub issuer: IssuerStanding, + /// Art. 32(1)(f) via Annex III(j) — what the certificate declares about the + /// device holding its key. + pub creation_device: CreationDevice, +} + +impl SealQualification { + /// Whether a provider issued this seal's certificate at all. + /// + /// The plain local-versus-provider split, separated from every question of + /// standing. False means the node signed it itself, and no amount of trusted + /// list will change that; true means there is an issuer worth asking about, + /// whatever [`Self::issuer`] then says about them. + #[must_use] + pub fn is_provider_seal(&self) -> bool { + !matches!(self.issuer, IssuerStanding::SelfIssued { .. }) + } +} + +impl std::fmt::Display for SealQualification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.issuer { + IssuerStanding::SelfIssued { subject } => write!( + f, + "signed locally by {subject} — self-issued, on no Trusted List, of no legal effect" + ), + IssuerStanding::NotListed { issuer } => write!( + f, + "issued by {issuer}, which no consulted Trusted List names as a qualified CA" + ), + IssuerStanding::ChainIncomplete { + issuer, + missing_issuer, + } => write!( + f, + "issued by {issuer}; the seal does not carry a certificate for {missing_issuer}, so no path to a listed CA could be followed — ask the provider to include the intermediates (ETSI EN 319 122-1 clause 5.2.1)" + ), + IssuerStanding::SignatureNotFromListedCa { + issuer, territory, .. + } => { + write!(f, "claims {issuer}, a CA listed")?; + if let Some(t) = territory { + write!(f, " in {t}")?; + } + write!( + f, + " — but no certificate that list publishes for that name signed this one" + ) + } + IssuerStanding::PathUnverifiable { + issuer, + territory, + reason, + .. + } => { + write!(f, "claims {issuer}, a CA listed")?; + if let Some(t) = territory { + write!(f, " in {t}")?; + } + write!(f, " — the signature could not be checked: {reason}") + } + IssuerStanding::NotQualifiedAtSealing { + issuer, + territory, + status, + .. + } => { + write!(f, "issued by {issuer}, listed")?; + if let Some(t) = territory { + write!(f, " in {t}")?; + } + match status { + Some(s) => write!(f, " but {} when the seal was made", s.as_uri()), + None => write!(f, " but with no recorded status when the seal was made"), + } + } + IssuerStanding::QualifiedAtSealing { + issuer, + territory, + remote_qscd_management, + .. + } => { + write!(f, "issued by {issuer}, a qualified CA")?; + if let Some(t) = territory { + write!(f, " in {t}")?; + } + write!(f, " when the seal was made")?; + if !remote_qscd_management { + write!( + f, + "; the provider does not hold Art. 39a remote QSCD management" + )?; + } + // The certificate's own validity and revocation are no longer + // unchecked — they are simply not this verdict's subject, and + // `cades::certificate_standing` reports them separately. Saying + // "not checked" here would now be false, and saying nothing + // would let this read as the whole of Art. 32(1)(b). + write!( + f, + " — issuer signature verified; the certificate's own validity window and revocation are reported separately" + ) + } + } + } +} + +/// Report what the Trusted Lists say about a seal's issuer. +/// +/// `sealed_at` is when the seal was made, and is what the lists are questioned +/// about — see [`IssuerStanding`]. Pass the envelope's own `sealed_at`, not the +/// present moment. +/// +/// `lists` are the national lists to consult, each already verified against a +/// verified list of trusted lists. An empty slice is legitimate and yields +/// [`IssuerStanding::NotListed`] for every provider seal, which is the honest +/// answer to "is this issuer in nothing?". +/// +/// This says nothing about whether the seal verifies; that is +/// [`verify_against_embedded_certificate`](crate::cades::verify_against_embedded_certificate), +/// and a trust report needs both. +/// +/// # Errors +/// +/// [`SealError::Backend`] when the bytes are not a readable CMS seal. +pub fn qualify( + seal_der: &[u8], + lists: &[VerifiedTrustedList], + sealed_at: DateTime, +) -> Result { + let certificate = signer_certificate(seal_der)?; + Ok(SealQualification { + issuer: standing(seal_der, &certificate, lists, sealed_at), + creation_device: certificate.creation_device, + }) +} + +/// The issuer's standing, given a certificate already read. +fn standing( + seal_der: &[u8], + certificate: &SignerCertificate, + lists: &[VerifiedTrustedList], + sealed_at: DateTime, +) -> IssuerStanding { + if certificate.self_issued { + return IssuerStanding::SelfIssued { + subject: certificate.subject.clone(), + }; + } + + let issuer = certificate.issuer.clone(); + // Candidates are chosen against **every** issuer name the seal's embedded + // certificates refer to, not only the signer's own. Where a Member State + // publishes self-signed roots, the anchor's subject matches an + // intermediate's issuer one link further up, and filtering on the signer's + // issuer alone would select nothing and report an unlisted provider — with + // the chain to prove otherwise sitting inside the seal. + let names = crate::cades::chain_issuer_names(seal_der).unwrap_or_default(); + let candidates = find_issuer_candidates(&names, lists); + let Some(first) = candidates.first() else { + // Nothing matched — but *why* nothing matched decides what may be said. + // A chain that ends at a self-issued root has told its whole story, and + // "no list names it" is a finding about that story. A chain that simply + // ran out has not, and the same sentence would be an accusation drawn + // from a certificate nobody shipped. + return match crate::cades::chain_terminus(seal_der) { + Ok(crate::cades::ChainTerminus::Truncated { missing_issuer }) => { + IssuerStanding::ChainIncomplete { + issuer, + missing_issuer, + } + } + // A complete chain, or a seal that stopped parsing between here and + // the read above — which cannot happen, and if it did, the weaker + // statement is the safe one. + Ok(crate::cades::ChainTerminus::SelfIssuedRoot { .. }) => { + IssuerStanding::NotListed { issuer } + } + Err(_) => IssuerStanding::ChainIncomplete { + issuer: issuer.clone(), + missing_issuer: issuer, + }, + }; + }; + // Where the path cannot be pinned to one candidate, the name that was + // matched is reported from the first. They all carry the same subject name + // — that is what made them candidates — so the provider and territory are + // the answer to "whose name is on this", which is the question those + // variants are reporting on. + let provider = first.provider.name.clone(); + let territory = first.territory.clone(); + + let (found, unverifiable) = verify_path(seal_der, &candidates); + let Some(found) = found else { + return match unverifiable { + // Never collapsed into the variant below. "We could not check" and + // "this CA did not sign it" are opposite findings, and only one of + // them is an accusation. + Some(reason) => IssuerStanding::PathUnverifiable { + issuer, + provider, + territory, + reason, + }, + None => IssuerStanding::SignatureNotFromListedCa { + issuer, + provider, + territory, + }, + }; + }; + + let status = found.service.history.status_at(sealed_at).cloned(); + if !status.as_ref().is_some_and(TrustServiceStatus::is_granted) { + return IssuerStanding::NotQualifiedAtSealing { + issuer, + provider: found.provider.name.clone(), + territory: found.territory.clone(), + status, + }; + } + + IssuerStanding::QualifiedAtSealing { + issuer, + provider: found.provider.name.clone(), + territory: found.territory.clone(), + remote_qscd_management: found + .provider + .services_of_type(TrustServiceType::REMOTE_QSEAL_CD_MANAGEMENT) + .any(|s| s.history.was_granted_at(sealed_at)), + } +} + +/// Which candidate actually signed the certificate, and why none could be checked. +/// +/// Returns the first candidate whose key verifies. The second value is the first +/// reason a candidate could not be checked at all, and it is only meaningful +/// when no candidate verified — it is what separates *nobody here signed this* +/// from *we were unable to ask*. +fn verify_path<'a>( + seal_der: &[u8], + candidates: &'a [Candidate<'a>], +) -> (Option<&'a Candidate<'a>>, Option) { + let mut unverifiable = None; + for candidate in candidates { + match crate::cades::check_path_to(seal_der, &candidate.certificate_der) { + Ok(IssuerCheck::Verified) => return (Some(candidate), None), + Ok(IssuerCheck::NotSignedByThisIssuer) => {} + Ok(IssuerCheck::Unverifiable(why)) => { + if unverifiable.is_none() { + unverifiable = Some(why); + } + } + // The seal parsed once already to get here, so this is not reachable + // by a malformed seal. Recorded as unverifiable rather than ignored, + // because the one thing it must not become is an accusation. + Err(e) => { + if unverifiable.is_none() { + unverifiable = Some(e.to_string()); + } + } + } + } + (None, unverifiable) +} + +/// A listed CA certificate carrying the seal certificate's issuer name. +struct Candidate<'a> { + provider: &'a crate::trustlist::ListedProvider, + service: &'a crate::trustlist::ListedService, + territory: Option, + /// The CA certificate's DER, for the signature check. + certificate_der: Vec, +} + +/// Every listed CA certificate whose subject is an issuer name the seal refers to. +/// +/// **All of them, not the first.** A certificate authority rotating its key +/// publishes the old and the new certificate together, under the same subject +/// name, so relying parties do not break at the cutover — the trusted list +/// module already records that Finland names five signing certificates for this +/// reason. Stopping at the first match would verify against whichever the list +/// happened to order first and report a genuine seal as unsigned, intermittently +/// and only for providers mid-rotation. +/// +/// Matched on the DER of the distinguished name, which is exact. That is +/// stricter than RFC 5280's comparison rules, so a Member State that re-encoded +/// a name would produce no candidates — reported as +/// [`IssuerStanding::NotListed`], which understates the issuer's standing rather +/// than overstating it. +/// +/// Only [`TrustServiceType::QUALIFIED_CERTIFICATE_CA`] entries are considered. +/// That is the service type Art. 32(1)(a)–(b) turns on, and a provider listed +/// for some other qualified service is not thereby a qualified CA. +fn find_issuer_candidates<'a>( + issuer_names: &[Vec], + lists: &'a [VerifiedTrustedList], +) -> Vec> { + let mut found = Vec::new(); + for list in lists { + for (provider, services) in + list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) + { + for service in services { + for c in &service.certificates { + let Some(der) = decoded(c) else { continue }; + if subject_der(&der).is_some_and(|s| issuer_names.contains(&s)) { + found.push(Candidate { + provider, + service, + territory: list.territory().map(str::to_owned), + certificate_der: der, + }); + } + } + } + } + } + found +} + +/// A base64 certificate's DER, or `None` if it will not decode. +/// +/// A list entry that cannot be read is skipped rather than fatal. One +/// unparseable certificate among hundreds must not stop the others being +/// searched, and the consequence of skipping is a miss — the safe direction. +fn decoded(base64_certificate: &str) -> Option> { + base64::engine::general_purpose::STANDARD + .decode(base64_certificate.trim()) + .ok() +} + +/// A certificate's subject name, DER-encoded. +fn subject_der(der: &[u8]) -> Option> { + x509_cert::Certificate::from_der(der) + .ok()? + .tbs_certificate + .subject + .to_der() + .ok() +} + +#[cfg(test)] +#[path = "qualification_tests.rs"] +mod tests; diff --git a/crates/dpp-seal/src/qualification_tests.rs b/crates/dpp-seal/src/qualification_tests.rs new file mode 100644 index 00000000..333692f2 --- /dev/null +++ b/crates/dpp-seal/src/qualification_tests.rs @@ -0,0 +1,529 @@ +//! What the verdict says about a seal, and what it deliberately does not. + +use super::*; +use crate::trustlist::{verify_lotl, verify_trusted_list}; +use cms::content_info::ContentInfo; +use cms::signed_data::SignedData; + +const EU_LOTL: &str = include_str!("../tests/fixtures/eu-lotl.xml"); +const FI_LIST: &str = include_str!("../tests/fixtures/fi-trusted-list.xml"); + +/// Finland's list, verified through the verified list of lists. +/// +/// The real document rather than a fixture assembled here: a hand-built list +/// would prove the matcher agrees with the builder, and the names in a published +/// list are exactly the thing whose encoding this module bets on. +fn finnish_list() -> VerifiedTrustedList { + let lotl = verify_lotl(EU_LOTL).expect("the LOTL verifies"); + let pointer = lotl + .pointers() + .iter() + .find(|p| p.territory.as_deref() == Some("FI")) + .expect("the LOTL points at Finland") + .clone(); + verify_trusted_list(FI_LIST, &pointer).expect("Finland's list verifies") +} + +/// A seal from the local backend, and the directory holding its key. +/// +/// The directory is returned because dropping it removes the key store. +fn local_seal() -> (Vec, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id.sign_detached(&[0x11; 32]).expect("sign"); + (seal, dir) +} + +/// The same seal with its certificate's issuer name replaced. +/// +/// Nothing is re-signed, deliberately. The relabelling invalidates the +/// certificate's own signature — the one its issuer made over its +/// `tbsCertificate` — and leaves the CMS signature intact, which is precisely the +/// asymmetry these tests exist to show. +fn seal_issued_by(issuer: x509_cert::name::Name) -> (Vec, tempfile::TempDir) { + let (der, dir) = local_seal(); + + let info = ContentInfo::from_der(&der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + + let certs = sd.certificates.as_ref().expect("a certificate"); + let mut choices = certs.0.as_slice().to_vec(); + let cms::cert::CertificateChoices::Certificate(cert) = &mut choices[0] else { + panic!("the local backend embeds an X.509 certificate"); + }; + cert.tbs_certificate.issuer = issuer; + let relabelled = cert.clone(); + + let mut set = der::asn1::SetOfVec::new(); + set.insert(choices.remove(0)).expect("certificate"); + sd.certificates = Some(cms::signed_data::CertificateSet(set)); + // The `SignerInfo` names its certificate by issuer and serial, so relabelling + // the certificate without re-pointing the signer would leave a seal naming a + // certificate it does not carry — which the parser rejects outright, and + // which is a different, less interesting failure than the one under test. + name_signer(&mut sd, &relabelled); + + (reencode(sd), dir) +} + +/// Point the seal's single `SignerInfo` at `certificate`. +fn name_signer(sd: &mut SignedData, certificate: &x509_cert::Certificate) { + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + signers[0].sid = cms::signed_data::SignerIdentifier::IssuerAndSerialNumber( + cms::cert::IssuerAndSerialNumber { + issuer: certificate.tbs_certificate.issuer.clone(), + serial_number: certificate.tbs_certificate.serial_number.clone(), + }, + ); + let mut set = der::asn1::SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); +} + +/// Re-encode a modified `SignedData` as a detached CAdES seal. +fn reencode(sd: SignedData) -> Vec { + ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + } + .to_der() + .expect("re-encode") +} + +/// A seal carrying exactly `certificates`, the first being the signer's. +/// +/// The CMS signature is left as the local backend made it and no longer matches +/// the certificate now named — which is fine and deliberate, because `qualify` +/// asks who issued the certificate, not whether the seal verifies. The two are +/// separate questions and a helper that conflated them would hide it. +fn seal_carrying(certificates: &[Vec]) -> (Vec, tempfile::TempDir) { + let (der, dir) = local_seal(); + let info = ContentInfo::from_der(&der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + + let parsed: Vec = certificates + .iter() + .map(|d| x509_cert::Certificate::from_der(d).expect("a certificate")) + .collect(); + + let mut set = der::asn1::SetOfVec::new(); + for c in &parsed { + set.insert(cms::cert::CertificateChoices::Certificate(c.clone())) + .expect("certificate"); + } + sd.certificates = Some(cms::signed_data::CertificateSet(set)); + name_signer(&mut sd, &parsed[0]); + + (reencode(sd), dir) +} + +/// A qualified CA that Finland lists as granted right now, and its subject name. +fn granted_finnish_ca() -> (String, x509_cert::name::Name) { + let list = finnish_list(); + let now = Utc::now(); + + for (provider, services) in list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) + { + for service in services { + if !service.history.was_granted_at(now) { + continue; + } + for c in &service.certificates { + let bytes = base64::engine::general_purpose::STANDARD + .decode(c.trim()) + .expect("base64"); + if let Ok(cert) = x509_cert::Certificate::from_der(&bytes) { + return ( + provider.name.clone().unwrap_or_default(), + cert.tbs_certificate.subject, + ); + } + } + } + } + panic!("Finland lists at least one granted qualified CA"); +} + +/// A locally signed seal says so, structurally. +/// +/// The answer the operator asks for first, and the one that was previously +/// obtainable only by reading configuration. Nothing here consults +/// `SEAL_PROVIDER`: the certificate is its own issuer, which no environment +/// variable can change. +#[test] +fn a_locally_signed_seal_is_self_issued_and_claims_nothing() { + let (seal, _dir) = local_seal(); + let verdict = qualify(&seal, &[finnish_list()], Utc::now()).expect("readable seal"); + + let IssuerStanding::SelfIssued { subject } = &verdict.issuer else { + panic!("the local backend is self-signed: {:?}", verdict.issuer); + }; + assert!(!subject.is_empty(), "and it names itself"); + + assert!( + !verdict.is_provider_seal(), + "no provider stands behind a self-issued certificate" + ); + assert_eq!( + verdict.creation_device, + CreationDevice::NotAQualifiedCertificate, + "and it carries no QCStatements, so it is not presenting as a qualified certificate" + ); +} + +/// Self-issued is decided before any list is consulted. +/// +/// So the verdict is the same with no lists at all — which matters because a +/// node that cannot reach a Trusted List must still be able to tell an operator +/// that its seals are locally signed. Degrading that to "unknown" would hide the +/// finding that needs no network to make. +#[test] +fn a_locally_signed_seal_needs_no_trusted_list_to_be_recognised() { + let (seal, _dir) = local_seal(); + let with_list = qualify(&seal, &[finnish_list()], Utc::now()).expect("readable"); + let without = qualify(&seal, &[], Utc::now()).expect("readable"); + assert_eq!(with_list, without); +} + +/// A seal carrying one certificate and naming an unlisted issuer reports that +/// the **chain could not be followed**, not that the provider is unlisted. +/// +/// The two readings are different accusations. "No list names this issuer" is a +/// statement about the Union's lists; "the seal did not carry the certificate +/// that would have led to one" is a statement about the seal. Only the second is +/// supportable here, because the certificate above this one — listed or not — +/// was never shipped. +#[test] +fn a_seal_whose_chain_runs_out_reports_that_rather_than_an_unlisted_issuer() { + let issuer: x509_cert::name::Name = "CN=Definitely Not A Qualified CA,C=FI" + .parse() + .expect("a name"); + let (seal, _dir) = seal_issued_by(issuer); + + let verdict = qualify(&seal, &[finnish_list()], Utc::now()).expect("readable seal"); + + let IssuerStanding::ChainIncomplete { + issuer, + missing_issuer, + } = &verdict.issuer + else { + panic!( + "the seal carries no certificate for its issuer: {:?}", + verdict.issuer + ); + }; + assert!(issuer.contains("Definitely Not A Qualified CA")); + assert_eq!( + issuer, missing_issuer, + "with one certificate in the seal, the name it needs next is its own issuer" + ); + assert!( + verdict.is_provider_seal(), + "an issuer other than the subject means somebody issued it, listed or not" + ); +} + +/// **A certificate relabelled with a listed CA's name is caught.** +/// +/// The case the path check exists for, and one that reached the top verdict +/// before it. A seal minted by the local development backend has its certificate +/// relabelled to carry a genuinely qualified Finnish CA's name; nothing else +/// about it is touched, and that CA never saw it. +/// +/// **The seal still verifies as a seal**, which is the part worth pausing on. A +/// CMS signature covers the signed attributes, not the certificate travelling +/// beside them, so relabelling the issuer leaves it intact. What the relabelling +/// breaks is the *certificate's own* signature, made by its issuer over its +/// `tbsCertificate` — so this check is the only thing in the workspace that +/// notices. +#[test] +fn a_certificate_relabelled_with_a_listed_cas_name_is_caught() { + let (provider_name, ca_subject) = granted_finnish_ca(); + let (seal, _dir) = seal_issued_by(ca_subject); + + let verdict = qualify(&seal, &[finnish_list()], Utc::now()).expect("readable seal"); + + let IssuerStanding::SignatureNotFromListedCa { + provider, + territory, + .. + } = &verdict.issuer + else { + panic!( + "no listed key signed this certificate: {:?}", + verdict.issuer + ); + }; + assert_eq!(provider.as_deref(), Some(provider_name.as_str())); + assert_eq!(territory.as_deref(), Some("FI")); + + assert!( + crate::cades::verify_against_embedded_certificate(&seal).expect("checkable"), + "and the seal itself still verifies, which is why the certificate's own \ + signature has to be checked separately" + ); +} + +/// A forgery is reported as a forgery, not as a date problem. +/// +/// Ordering, stated as a test. The same relabelled certificate, dated to a +/// moment the list says nothing about: were the status consulted first, this +/// would come back `NotQualifiedAtSealing` and read as paperwork trouble with an +/// otherwise sound seal. The path is checked first because the status of a CA +/// that did not issue a certificate is beside the point. +#[test] +fn a_forged_certificate_is_reported_as_forged_rather_than_as_a_date_problem() { + let (_, ca_subject) = granted_finnish_ca(); + let (seal, _dir) = seal_issued_by(ca_subject); + + let long_ago = "1999-01-01T00:00:00Z" + .parse::>() + .expect("a time"); + let verdict = qualify(&seal, &[finnish_list()], long_ago).expect("readable seal"); + + assert!( + matches!( + verdict.issuer, + IssuerStanding::SignatureNotFromListedCa { .. } + ), + "the path is checked before the status: {:?}", + verdict.issuer + ); +} + +// ─── A certificate authority whose key the test holds ──────────────────────── + +/// A root, an optional intermediate, and a certificate genuinely issued under them. +struct TestChain { + /// The root's DER — what a trusted list would carry. + root: Vec, + /// The intermediate's DER, when the leaf was issued one level down. + intermediate: Option>, + /// The leaf certificate's DER. + leaf: Vec, +} + +/// Generate a certificate authority and a certificate it really issued. +/// +/// The only way to exercise the **positive** path today. No published trusted +/// list carries a CA whose private key a test can hold, and this workspace can +/// obtain no QTSP credential, so every real-data test can only reach the failure +/// branches. Here the test is the authority, and a real signature is checked to +/// be really recognised. +/// +/// P-256 because that is what `rcgen` generates. The published population is +/// RSA, P-384 and P-521 — see `trustlist::ca_key_survey` — so this exercises the +/// mechanism rather than the algorithms, and the algorithm coverage is a feature +/// list in `Cargo.toml` pinned to that measurement. +fn test_chain(with_intermediate: bool) -> TestChain { + fn params(name: &str, is_ca: bool) -> rcgen::CertificateParams { + let mut params = rcgen::CertificateParams::new(vec![name.to_owned()]).expect("params"); + if is_ca { + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + } + params + .distinguished_name + .push(rcgen::DnType::CommonName, name); + params + } + fn key() -> rcgen::KeyPair { + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("key") + } + + let root = rcgen::CertifiedIssuer::self_signed(params("Test Root CA", true), key()) + .expect("a self-signed root"); + + let intermediate = with_intermediate.then(|| { + rcgen::CertifiedIssuer::signed_by(params("Test Intermediate CA", true), key(), &root) + .expect("an intermediate signed by the root") + }); + + let leaf_key = key(); + let leaf_params = params("Test Sealing Certificate", false); + let leaf = match &intermediate { + Some(issuer) => leaf_params.signed_by(&leaf_key, &**issuer), + None => leaf_params.signed_by(&leaf_key, &*root), + } + .expect("a leaf signed by its issuer"); + + TestChain { + root: root.der().to_vec(), + intermediate: intermediate.map(|i| i.der().to_vec()), + leaf: leaf.der().to_vec(), + } +} + +/// A trusted list naming `ca_der` as a qualified CA, granted since 2000. +fn list_naming(ca_der: &[u8]) -> VerifiedTrustedList { + use dpp_domain::trusted_list::{TrustServiceHistory, TrustServiceStatusPeriod}; + + let service = crate::trustlist::ListedService { + service_type: TrustServiceType::new(TrustServiceType::QUALIFIED_CERTIFICATE_CA), + name: Some("Test Qualified CA".to_owned()), + history: TrustServiceHistory::new(vec![TrustServiceStatusPeriod { + status: TrustServiceStatus::Granted, + starting_at: "2000-01-01T00:00:00Z".parse().expect("a time"), + }]), + certificates: vec![base64::engine::general_purpose::STANDARD.encode(ca_der)], + }; + VerifiedTrustedList::asserted_for_tests( + crate::trustlist::UnverifiedTrustedList { + territory: Some("FI".to_owned()), + providers: vec![crate::trustlist::ListedProvider { + name: Some("Test Provider Oy".to_owned()), + services: vec![service], + }], + }, + "test", + ) +} + +/// A certificate a listed CA really issued reaches the top verdict. +/// +/// The positive case, which could not be written before the path check existed: +/// `QualifiedAtSealing` now means the issuer is established, not merely claimed. +#[test] +fn a_certificate_a_listed_ca_really_issued_is_qualified() { + let chain = test_chain(false); + let (seal, _dir) = seal_carrying(std::slice::from_ref(&chain.leaf)); + + let verdict = qualify(&seal, &[list_naming(&chain.root)], Utc::now()).expect("readable seal"); + + let IssuerStanding::QualifiedAtSealing { provider, .. } = &verdict.issuer else { + panic!("a genuinely issued certificate: {:?}", verdict.issuer); + }; + assert_eq!(provider.as_deref(), Some("Test Provider Oy")); +} + +/// The walk climbs an intermediate the seal carries to reach a listed root. +/// +/// Not a refinement. Member States do not publish the same thing: Italy's list +/// is 194 self-signed roots out of 203 entries, so for most Italian providers +/// the listed certificate is **not** the one that issued the seal. Without this, +/// those seals report `NotListed` — which looks like an unlisted provider and is +/// in fact a missing hop. +#[test] +fn an_intermediate_carried_by_the_seal_reaches_a_listed_root() { + let chain = test_chain(true); + let intermediate = chain.intermediate.clone().expect("an intermediate"); + let (seal, _dir) = seal_carrying(&[chain.leaf.clone(), intermediate]); + + let verdict = qualify(&seal, &[list_naming(&chain.root)], Utc::now()).expect("readable seal"); + + assert!( + matches!(verdict.issuer, IssuerStanding::QualifiedAtSealing { .. }), + "the root is two links up, and the seal carries the link between: {:?}", + verdict.issuer + ); +} + +/// **Without the intermediate, the same leaf cannot reach the same root — and +/// the verdict says so in those terms.** +/// +/// The control for the test above, and the scenario this distinction exists +/// for: the root *is* listed, the leaf *was* issued under it, and the only thing +/// wrong is that the seal did not carry the link between. Reporting an unlisted +/// provider here would point an operator at their QTSP's qualification when the +/// remedy is a generator setting — ETSI EN 319 122-1 clause 5.2.1 asks for those +/// intermediates where a signature is to be validated through a Trusted List. +#[test] +fn a_leaf_stripped_of_its_intermediate_reports_an_incomplete_chain() { + let chain = test_chain(true); + let (seal, _dir) = seal_carrying(std::slice::from_ref(&chain.leaf)); + + let verdict = qualify(&seal, &[list_naming(&chain.root)], Utc::now()).expect("readable seal"); + + let IssuerStanding::ChainIncomplete { missing_issuer, .. } = &verdict.issuer else { + panic!( + "the listed root is two links up and the middle one is gone: {:?}", + verdict.issuer + ); + }; + assert!( + missing_issuer.contains("Test Intermediate CA"), + "the report must name what was missing, or it cannot be acted on: {missing_issuer}" + ); + + // And the strong statement is still available where it is earned: put the + // intermediate back and the same leaf reaches the listed root. + let intermediate = chain.intermediate.clone().expect("an intermediate"); + let (whole, _dir) = seal_carrying(&[chain.leaf.clone(), intermediate]); + assert!( + matches!( + qualify(&whole, &[list_naming(&chain.root)], Utc::now()) + .expect("readable seal") + .issuer, + IssuerStanding::QualifiedAtSealing { .. } + ), + "the gap was the only thing in the way" + ); +} + +/// **A complete chain that no list names is still `NotListed`.** +/// +/// The other side of the distinction, and the reason `ChainIncomplete` is not +/// simply a softer wording for the same finding. Here the seal carries its whole +/// path up to a certificate that issued itself: everything it has to say about +/// its own provenance has been said, nothing is missing, and no consulted list +/// names any of it. That is a finding about the lists, and it is safe to make. +#[test] +fn a_complete_chain_to_an_unlisted_root_is_still_unlisted() { + let chain = test_chain(true); + let intermediate = chain.intermediate.clone().expect("an intermediate"); + let (seal, _dir) = seal_carrying(&[chain.leaf.clone(), intermediate, chain.root.clone()]); + + // Finland's real list rather than a second built one: `test_chain` names + // every root it makes "Test Root CA", so a hand-built list would collide on + // the name and report `SignatureNotFromListedCa` — a listed CA carrying that + // name which did not sign this. That is the correct answer to a different + // question, and it is not the one under test here. + let verdict = qualify(&seal, &[finnish_list()], Utc::now()).expect("readable seal"); + + assert!( + matches!(verdict.issuer, IssuerStanding::NotListed { .. }), + "the chain is whole and ends at a root nobody lists: {:?}", + verdict.issuer + ); +} + +/// A seal predating the list's history is not granted, and not known either. +/// +/// Art. 32(1)(b) asks about the time of sealing. A present-tense check would +/// certify a seal made before its issuer was ever qualified, so the same +/// genuinely issued certificate that passes above fails here on the date alone — +/// and it is genuinely issued, so it reaches the status check rather than +/// stopping at the path. +/// +/// The status is `None` rather than a withdrawn status: the list is silent about +/// 1999, which is a different finding from a recorded refusal, and the type keeps +/// them apart. +#[test] +fn a_seal_older_than_the_lists_history_is_not_qualified() { + let chain = test_chain(false); + let (seal, _dir) = seal_carrying(std::slice::from_ref(&chain.leaf)); + + let long_ago = "1999-01-01T00:00:00Z" + .parse::>() + .expect("a time"); + let verdict = qualify(&seal, &[list_naming(&chain.root)], long_ago).expect("readable seal"); + + let IssuerStanding::NotQualifiedAtSealing { status, .. } = &verdict.issuer else { + panic!("nothing was granted in 1999: {:?}", verdict.issuer); + }; + assert!( + status.is_none(), + "the list says nothing about 1999, which is not the same as saying no" + ); +} + +/// Unreadable bytes are an error, not a verdict. +/// +/// The opposite choice from [`crate::cades::signer_certificate_thumbprint`], +/// which degrades to `None` so a stored seal is never lost to a convenience +/// field. Here the whole answer is the verdict, and inventing one for bytes that +/// could not be read is how a report comes to say "not qualified" about a seal +/// nobody looked at. +#[test] +fn bytes_that_are_not_a_seal_yield_no_verdict() { + assert!(qualify(b"not a CMS structure", &[], Utc::now()).is_err()); +} diff --git a/crates/dpp-seal/src/trustlist/verify.rs b/crates/dpp-seal/src/trustlist/verify.rs index a117438e..60e43902 100644 --- a/crates/dpp-seal/src/trustlist/verify.rs +++ b/crates/dpp-seal/src/trustlist/verify.rs @@ -427,6 +427,31 @@ pub struct VerifiedTrustedList { } impl VerifiedTrustedList { + /// Assert verification, for tests only. + /// + /// The type has no public constructor so that nothing outside this module + /// can claim a list was verified when it was not, and that stays true: this + /// is `pub(crate)` and `#[cfg(test)]`, so it does not exist in a built + /// library. + /// + /// It earns its place by making the *positive* path testable. Everything + /// downstream of a verified list — matching a seal's issuer, walking a + /// certificate path to it, reading a status at sealing time — could + /// otherwise only ever be exercised on its failure branches, because no + /// published list carries a CA whose private key a test can hold. With this, + /// a test can generate its own certificate authority and check that a + /// genuinely issued certificate is genuinely recognised. + #[cfg(test)] + pub(crate) fn asserted_for_tests( + content: super::model::UnverifiedTrustedList, + signed_by: impl Into, + ) -> Self { + Self { + content, + signed_by: signed_by.into(), + } + } + /// The `SchemeTerritory`, where the list gives one. #[must_use] pub fn territory(&self) -> Option<&str> { diff --git a/crates/dpp-seal/tests/ca_key_survey.rs b/crates/dpp-seal/tests/ca_key_survey.rs new file mode 100644 index 00000000..596acfd4 --- /dev/null +++ b/crates/dpp-seal/tests/ca_key_survey.rs @@ -0,0 +1,395 @@ +//! What key algorithms the listed qualified CAs actually use. +//! +//! Sizes the certificate-path verification that `super::super::qualification` +//! does not do. Verifying that a listed CA issued a seal's certificate means +//! checking the CA's signature over that certificate, and **the CA's key type +//! decides which verifier is needed** — a CA holding an RSA key signs with RSA. +//! +//! Measured rather than assumed, because assuming from a sample is the mistake +//! this workspace has already made twice: the fetch cap was sized from the +//! fixtures that happened to be committed, and "only Italy and France need the +//! fork" was a stale measurement by the time it was written down. + +use base64::Engine as _; +use der::{Decode as _, Encode as _}; +use dpp_domain::trusted_list::TrustServiceType; + +use dpp_seal::trustlist::{VerifiedTrustedList, verify_lotl, verify_trusted_list}; + +const EU_LOTL: &str = include_str!("fixtures/eu-lotl.xml"); +const FI_LIST: &str = include_str!("fixtures/fi-trusted-list.xml"); + +/// `rsaEncryption` — RFC 8017. +const RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1"; +/// `id-ecPublicKey` — RFC 5480. +const EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1"; + +/// Every distinct public-key algorithm among a list's qualified CA certificates. +/// +/// Returns `(algorithm OID, count)`, and counts certificates rather than +/// services: one service may name several, and a rotation publishes old and new +/// together. +fn ca_key_algorithms(list_xml: &str, territory: &str) -> Vec<(String, usize)> { + let lotl = verify_lotl(EU_LOTL).expect("the LOTL verifies"); + let pointer = lotl + .pointers() + .iter() + .find(|p| p.territory.as_deref() == Some(territory)) + .expect("the LOTL points at this territory") + .clone(); + let list = verify_trusted_list(list_xml, &pointer).expect("the list verifies"); + + let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (_, services) in list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) { + for service in services { + for c in &service.certificates { + let Ok(der) = base64::engine::general_purpose::STANDARD.decode(c.trim()) else { + continue; + }; + let Ok(cert) = x509_cert::Certificate::from_der(&der) else { + continue; + }; + let oid = cert + .tbs_certificate + .subject_public_key_info + .algorithm + .oid + .to_string(); + *counts.entry(oid).or_default() += 1; + } + } + } + counts.into_iter().collect() +} + +/// **RSA is not optional.** A path verifier that handles only P-256 verifies +/// almost nothing. +/// +/// This is the measurement behind the decision to take on an RSA dependency +/// rather than reuse the `p256` verifier already in this crate. `cades`'s +/// [`verify_against_embedded_certificate`](crate::cades::verify_against_embedded_certificate) +/// is P-256-only, which is right for the local development backend that emits +/// P-256 and useless against the population below. +/// +/// Recorded as an assertion rather than a comment so it re-checks itself +/// whenever Finland republishes: if RSA ever stopped dominating, the scope of +/// that work would change and this would say so. +#[test] +fn the_listed_qualified_cas_are_overwhelmingly_rsa() { + let algorithms = ca_key_algorithms(FI_LIST, "FI"); + assert!( + !algorithms.is_empty(), + "Finland lists qualified CA certificates" + ); + + let total: usize = algorithms.iter().map(|(_, n)| n).sum(); + let rsa = algorithms + .iter() + .find(|(oid, _)| oid == RSA_ENCRYPTION) + .map_or(0, |(_, n)| *n); + let ec = algorithms + .iter() + .find(|(oid, _)| oid == EC_PUBLIC_KEY) + .map_or(0, |(_, n)| *n); + + println!("FI qualified-CA certificate keys ({total} certificates):"); + for (oid, n) in &algorithms { + let name = match oid.as_str() { + RSA_ENCRYPTION => "rsaEncryption", + EC_PUBLIC_KEY => "id-ecPublicKey", + _ => "(other)", + }; + println!(" {oid} {name:<16} {n}"); + } + + assert!( + rsa > ec, + "RSA dominates, so a P-256-only path verifier would verify almost nothing \ + (rsa={rsa}, ec={ec}, total={total})" + ); + assert_eq!( + rsa + ec, + total, + "only RSA and EC keys appear; a third algorithm would widen the verifier's scope \ + and should be looked at rather than silently skipped" + ); +} + +/// **Every listed qualified CA yields a usable verifier.** +/// +/// The survey above says what algorithms are out there; this says we can +/// actually check a signature made with them. It is the test that would have +/// caught the real gap: `verify_against_embedded_certificate` was P-256 only, +/// which covers the local development backend and **none** of the certificates +/// below — so the first real provider seal would have failed to verify, taken +/// the seal-to-passport binding with it (the digest lives inside the signature), +/// and reported "unknown" rather than anything alarming. +/// +/// Run over the real published certificates rather than a fixture, because the +/// question is whether this build copes with what Member States actually +/// publish, and that is not a thing a fixture can answer. +#[test] +fn every_listed_qualified_ca_yields_a_usable_verifier() { + let mut checked = 0; + let mut unusable = Vec::new(); + + for (territory, xml) in available_lists() { + let list = verified(&xml, territory); + for (_, services) in list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) { + for service in services { + for c in &service.certificates { + let Some(der) = base64::engine::general_purpose::STANDARD + .decode(c.trim()) + .ok() + else { + continue; + }; + let Ok(cert) = x509_cert::Certificate::from_der(&der) else { + continue; + }; + checked += 1; + if !dpp_seal::cades::can_verify_signatures_of(&der) { + unusable.push(format!( + "{territory} oid={} subject={}", + cert.tbs_certificate.subject_public_key_info.algorithm.oid, + cert.tbs_certificate.subject, + )); + } + } + } + } + } + + println!( + "{checked} listed qualified-CA certificates, {} this build cannot verify", + unusable.len() + ); + assert!( + checked > 0, + "no certificate was checked, so this proves nothing" + ); + assert!( + unusable.is_empty(), + "this build cannot verify signatures from these listed CAs, so a seal issued under one would report as unverifiable rather than as sound: {unusable:?}" + ); +} + +/// The same survey across the larger lists held locally, when they are present. +/// +/// Finland alone is a sample of one, and sizing work from the sample that +/// happened to be committed is precisely the error recorded in this module's +/// header. These documents are ~3 MB each and git-ignored, so this skips loudly +/// rather than failing when they are absent — a checkout without them reads as +/// "not measured here", never as a pass. +#[test] +fn the_larger_lists_agree_about_the_algorithms_in_play() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/local"); + let mut measured = 0; + + for (territory, file) in [("IT", "it-trusted-list.xml"), ("FR", "fr-trusted-list.xml")] { + let path = dir.join(file); + let Ok(xml) = std::fs::read_to_string(&path) else { + eprintln!("SKIPPED {territory}: {} is absent", path.display()); + continue; + }; + let algorithms = ca_key_algorithms(&xml, territory); + let total: usize = algorithms.iter().map(|(_, n)| n).sum(); + println!("{territory} qualified-CA certificate keys ({total} certificates):"); + for (oid, n) in &algorithms { + println!(" {oid} {n}"); + } + assert!( + algorithms + .iter() + .all(|(oid, _)| oid == RSA_ENCRYPTION || oid == EC_PUBLIC_KEY), + "{territory} lists a key algorithm outside RSA and EC, which widens the \ + verifier's scope: {algorithms:?}" + ); + measured += 1; + } + + if measured == 0 { + eprintln!( + "SKIPPED: no local trusted lists present, so this measurement did not run. \ + See crates/dpp-seal/tests/fixtures/local/ for how to fetch them." + ); + } +} + +/// The curves in play, where a CA holds an EC key. +/// +/// Relevant because `p256` — the only elliptic-curve verifier this crate has — +/// does not cover them. Finland's EC qualified CAs are **all P-384**, so the +/// existing verifier covers none of them, and "we already have P-256" is not a +/// head start on this work. +#[test] +fn ec_listed_cas_are_not_the_curve_this_crate_already_verifies() { + let mut curves = std::collections::BTreeMap::new(); + collect_curves(FI_LIST, "FI", &mut curves); + + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/local"); + for (territory, file) in [("IT", "it-trusted-list.xml"), ("FR", "fr-trusted-list.xml")] { + match std::fs::read_to_string(dir.join(file)) { + Ok(xml) => collect_curves(&xml, territory, &mut curves), + Err(_) => eprintln!("SKIPPED {territory}: local list absent"), + } + } + + println!("EC qualified-CA curves across the lists measured: {curves:?}"); + for curve in curves.keys() { + assert!( + [P256, P384, P521].contains(&curve.as_str()), + "an unexpected curve widens the verifier's scope: {curve}" + ); + } + assert!( + curves.contains_key(P384), + "P-384 is in the population, so an elliptic-curve leg cannot stop at P-256" + ); +} + +/// `secp256r1` / NIST P-256 — RFC 5480 §2.1.1.1. What `cades` already verifies. +const P256: &str = "1.2.840.10045.3.1.7"; +/// `secp384r1` / NIST P-384. +const P384: &str = "1.3.132.0.34"; +/// `secp521r1` / NIST P-521. +const P521: &str = "1.3.132.0.35"; + +/// Tally the named curves of one list's EC qualified-CA certificates. +fn collect_curves( + list_xml: &str, + territory: &str, + into: &mut std::collections::BTreeMap, +) { + let lotl = verify_lotl(EU_LOTL).expect("the LOTL verifies"); + let pointer = lotl + .pointers() + .iter() + .find(|p| p.territory.as_deref() == Some(territory)) + .expect("the LOTL points at this territory") + .clone(); + let list = verify_trusted_list(list_xml, &pointer).expect("the list verifies"); + + for (_, services) in list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) { + for service in services { + for c in &service.certificates { + let Ok(der) = base64::engine::general_purpose::STANDARD.decode(c.trim()) else { + continue; + }; + let Ok(cert) = x509_cert::Certificate::from_der(&der) else { + continue; + }; + let spki = &cert.tbs_certificate.subject_public_key_info; + if spki.algorithm.oid.to_string() != EC_PUBLIC_KEY { + continue; + } + // The named curve travels in the algorithm parameters. + let curve = spki + .algorithm + .parameters + .as_ref() + .and_then(|p| p.to_der().ok()) + .and_then(|d| der::asn1::ObjectIdentifier::from_der(&d).ok()) + .map_or_else(|| "(unnamed)".to_owned(), |o| o.to_string()); + *into.entry(curve).or_default() += 1; + } + } + } +} + +/// **The lists publish trust anchors, not chains.** +/// +/// No listed certificate is issued by another listed certificate — measured +/// across every list available here. That is what makes the intermediate a real +/// problem rather than a tidy-up: a seal's certificate is issued by a CA that +/// may be a listed entry itself, or may be one level below a listed root, and +/// the list never carries the link between. +/// +/// Member States do not even agree on which they publish. Italy's list is +/// overwhelmingly self-signed roots; Finland's and France's carry no self-signed +/// certificate at all, so their entries are issuing CAs whose own roots are +/// outside the list. A verifier that matched only a seal's direct issuer would +/// work for Finland and France and fail for most of Italy. +/// +/// Hence `cades::check_path_to` walking intermediates **out of the seal**, and +/// `chain_issuer_names` widening candidate selection past the signer's own +/// issuer. +#[test] +fn the_lists_publish_anchors_not_chains() { + use std::collections::HashMap; + + for (territory, xml) in available_lists() { + let list = verified(&xml, territory); + + let mut by_subject: HashMap, ()> = HashMap::new(); + let mut certificates: Vec = Vec::new(); + for (_, services) in list.providers_offering(TrustServiceType::QUALIFIED_CERTIFICATE_CA) { + for service in services { + for c in &service.certificates { + let Some(cert) = base64::engine::general_purpose::STANDARD + .decode(c.trim()) + .ok() + .and_then(|d| x509_cert::Certificate::from_der(&d).ok()) + else { + continue; + }; + if let Ok(subject) = cert.tbs_certificate.subject.to_der() { + by_subject.insert(subject, ()); + } + certificates.push(cert); + } + } + } + + let mut self_signed = 0; + let mut chaining = 0; + for cert in &certificates { + let (Ok(issuer), Ok(subject)) = ( + cert.tbs_certificate.issuer.to_der(), + cert.tbs_certificate.subject.to_der(), + ) else { + continue; + }; + if issuer == subject { + self_signed += 1; + } else if by_subject.contains_key(&issuer) { + chaining += 1; + } + } + + println!( + "{territory}: {} certificates, {self_signed} self-signed, {chaining} issued by another listed certificate", + certificates.len() + ); + assert_eq!( + chaining, 0, + "{territory} lists a certificate issued by another listed certificate, so the assumption that a list is a set of anchors no longer holds and candidate selection should be looked at again" + ); + } +} + +/// Every list this checkout can verify: Finland always, the larger two when the +/// git-ignored fixtures are present. +fn available_lists() -> Vec<(&'static str, String)> { + let mut lists = vec![("FI", FI_LIST.to_owned())]; + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/local"); + for (territory, file) in [("IT", "it-trusted-list.xml"), ("FR", "fr-trusted-list.xml")] { + match std::fs::read_to_string(dir.join(file)) { + Ok(xml) => lists.push((territory, xml)), + Err(_) => eprintln!("SKIPPED {territory}: local list absent"), + } + } + lists +} + +/// One list, verified through the verified list of lists. +fn verified(xml: &str, territory: &str) -> VerifiedTrustedList { + let lotl = verify_lotl(EU_LOTL).expect("the LOTL verifies"); + let pointer = lotl + .pointers() + .iter() + .find(|p| p.territory.as_deref() == Some(territory)) + .expect("the LOTL points at this territory") + .clone(); + verify_trusted_list(xml, &pointer).expect("the list verifies") +} diff --git a/crates/dpp-types/src/lib.rs b/crates/dpp-types/src/lib.rs index 7e6bce98..56a5b43f 100644 --- a/crates/dpp-types/src/lib.rs +++ b/crates/dpp-types/src/lib.rs @@ -72,7 +72,12 @@ pub use scan::{ DailyScanCount, OperatorScanStats, PassportScanStats, QrRenderIncrement, ScanIncrement, ScanPruneCounts, ScanTelemetryRepository, }; -pub use seal::{SealOutbox, SealOutboxCounts, SealRow, digest_for_jws}; +pub use seal::{ + ArchivalFreshness, CertificateStanding, CreationDevice, JudgedTime, RevocationStanding, + SealAuditLog, SealAuditProgress, SealAuditReport, SealAuditStore, SealBinding, SealInspector, + SealOrigin, SealOutbox, SealOutboxCounts, SealRow, SealValidationStatus, SealedPassport, + ValidationIndication, ValidationSubIndication, ValidityWindow, WindowStanding, digest_for_jws, +}; pub use snapshot::{ SnapshotOutbox, SnapshotOutboxCounts, SnapshotReconcileRow, SnapshotReconcileStatus, SnapshotStore, diff --git a/crates/dpp-types/src/seal.rs b/crates/dpp-types/src/seal.rs index edf97392..a6345f0b 100644 --- a/crates/dpp-types/src/seal.rs +++ b/crates/dpp-types/src/seal.rs @@ -29,9 +29,13 @@ //! produces a different JWS, hence a different digest, hence a distinct row. use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use dpp_domain::{DppError, passport::PassportId, seal::SealedEnvelope}; +use dpp_domain::{ + DppError, passport::PassportId, seal::SealConformanceLevel, seal::SealedEnvelope, +}; /// The digest a qualified seal is applied over: hex SHA-256 of a passport's /// compact JWS. @@ -75,6 +79,243 @@ pub struct SealOutboxCounts { pub exhausted: i64, } +/// What a completed pass over every stored seal found. +/// +/// Reported as a whole rather than as a running total, because the useful +/// quantity is "how many broken seals does this node hold" and that is only +/// answerable once the walk has been all the way round. A figure accumulated +/// mid-walk answers "how many in the part seen so far", which reads as a +/// smaller number than the truth and falls to zero every time the walk restarts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealAuditReport { + /// When the pass finished. + /// + /// The age of this is the age of every number below it. A pass takes as long + /// as the estate divided by the audit's throughput, so on a large deployment + /// these are hours old by construction — which is fine for a condition that + /// does not appear suddenly, and worth stating rather than implying + /// freshness. + pub completed_at: DateTime, + /// Seals opened. + pub checked: u64, + /// Seals covering their passport's current signature. + /// + /// EN 319 102-1 `INDETERMINATE`, not `TOTAL-PASSED` — see + /// [`SealBinding::validation_status`]. Nothing about these seals has failed; + /// their certificates have not been validated. + pub sound: u64, + /// Seals over a different digest — ordinarily a passport re-published after + /// sealing, which is not a defect. + /// + /// `TOTAL-FAILED` / `HASH_FAILURE` when the question asked is about this + /// passport's *current* signature, which is the question this walk asks. + pub superseded: u64, + /// Seals whose own signature does not verify. + /// + /// `TOTAL-FAILED` / `SIG_CRYPTO_FAILURE`, and the standard makes that + /// verdict stable: no additional validation data can lift it. That is what + /// makes a replacement worth buying for these and for nothing else here. + pub broken: u64, + /// Seals whose signature is sound and whose **certificate** was not, at the + /// moment they were made. + /// + /// `TOTAL-FAILED` with a certificate sub-indication — revoked before + /// sealing, or outside its validity window, with an attested time to prove + /// the order. Where nothing attests the moment, the finding is indeterminate + /// and the seal stays in [`Self::sound`]: a certificate that has expired + /// since is the ordinary state of an old seal, not a defect in it. + /// + /// Apart from `broken` because the two need opposite actions: a broken seal + /// is worth replacing, and one made under a revoked certificate would only + /// be replaced by another from the same certificate. + pub certificate_failed: u64, + /// Seals this node could not read. Not a finding. + /// + /// `INDETERMINATE` with a custom diagnostic — the format is one this node + /// does not parse, which is a limit of the reader rather than a defect in + /// the seal. + pub unreadable: u64, + /// The passports carrying a broken seal, so an operator can act rather than + /// grep a log. + /// + /// Capped — see [`Self::truncated`]. A node with thousands of broken seals + /// has one problem, not thousands, and the count above already states its + /// size; a list long enough to prove that is a list nobody reads. + pub broken_passports: Vec, + /// True when [`Self::broken_passports`] was cut short. + /// + /// Stated rather than left to be inferred from the length matching the cap, + /// which is the kind of inference that is right until the cap changes. + pub truncated: bool, +} + +/// The last completed audit pass, shared between the task that runs it and the +/// route that reports it. +/// +/// # Why this is held in memory rather than stored +/// +/// The findings are **derived**: they can be recomputed from the seals at any +/// time, and the only reason not to recompute them on demand is that opening +/// every CAdES would give a read route unpredictable latency. That makes this a +/// cache, and a cache is a poor candidate for a table — a repaired seal would +/// leave a stale row until the next walk, and the row would need invalidating by +/// something that already knows the answer. +/// +/// [`Self::last`] returns an `Option` and the route reports the absence rather +/// than a zero: **"no pass has completed" and "no broken seals" are different**, +/// and serving the second when the first is true is how a monitoring surface +/// reassures an operator about something it has not looked at. EU law draws the +/// same line for validation generally — CIR (EU) 2025/1945, which pins the +/// validation standards for qualified seals, makes *indeterminate* a distinct +/// technical outcome from valid and from invalid, and requires it to be reported +/// as such rather than collapsed into either. +/// +/// # This is seeded from storage, not only from the running process +/// +/// A restart empties the slot, and used to leave the route blind for the length +/// of a whole walk — hours on a large estate, and indistinguishable from an +/// audit that is not running. Worse, a deployment whose estate takes longer to +/// walk than it goes between restarts would never publish anything at all. +/// +/// So a node that keeps a [`SealAuditStore`] loads the last stored report into +/// this at boot and stores each new one. `None` then means what it says: **no +/// pass has ever completed against this database**, not merely none since this +/// process started. Nodes without a store keep the old behaviour, which is the +/// honest degradation — the absence is still the truth about what this process +/// knows. +#[derive(Debug, Default)] +pub struct SealAuditLog(std::sync::RwLock>); + +impl SealAuditLog { + /// Publish the result of a completed pass. + pub fn record(&self, report: SealAuditReport) { + // A poisoned lock means a panic while holding it. Recovering is right + // here: the data is a cache of a derived value, so the worst a poisoned + // read can serve is a stale report, and refusing to record would leave + // the route permanently blind for a reason unrelated to seals. + let mut slot = self + .0 + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = Some(report); + } + + /// The last completed pass, if there has been one. + #[must_use] + pub fn last(&self) -> Option { + self.0 + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// A walk that has started and not yet reached the end. +/// +/// The audit covers the estate in batches, so between the first batch and the +/// last there is a partial result that is **not** publishable: a count from half +/// the estate reads exactly like a count from all of it, and the difference is +/// the whole value of the number. This is that half-finished state, kept so it +/// can be picked up again rather than thrown away. +/// +/// # Why this is stored and the report alone would not be enough +/// +/// The failure it exists for is a node whose estate takes longer to walk than +/// the node goes between restarts. Storing only the finished report does not +/// help there, because the finished report is exactly what such a node never +/// produces — it would begin again from the start every time, for ever, and +/// report nothing while doing a great deal of work. Keeping the cursor makes the +/// progress survive, so the walk finishes eventually however often the process +/// is replaced. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealAuditProgress { + /// When this walk began — and the bound it passes to + /// [`SealOutbox::sealed_passports`], so the pass describes exactly the seals + /// that existed at that moment. + pub started_at: DateTime, + /// The last passport the walk has looked at. `None` means it is at the + /// beginning. + pub cursor: Option, + /// Seals opened so far in this walk. + pub checked: u64, + /// Sound so far. + pub sound: u64, + /// Intact over a superseded signature so far. + pub superseded: u64, + /// Broken so far. + pub broken: u64, + /// Certificate-failed so far. + pub certificate_failed: u64, + /// Unreadable so far. + pub unreadable: u64, + /// The broken passports named so far, capped by the caller. + pub broken_passports: Vec, +} + +/// Where a node keeps what its seal audit has found and how far it has got. +/// +/// # This is a cache with one job the cache argument does not cover +/// +/// The findings are derived and recomputable, which is why they are *also* held +/// in [`SealAuditLog`] in memory and served from there. Storing them buys two +/// things memory cannot: +/// +/// - a restart no longer erases the answer, so the route stops saying "no pass +/// has completed" for the length of a whole walk after every deployment — +/// which on a large estate is hours, and is indistinguishable from an audit +/// that is not running; +/// - a walk survives the restart too, so progress accumulates instead of +/// resetting. +/// +/// What it deliberately does **not** become is a record of validations. Under +/// Reg. (EU) No 910/2014 Art. 33, reached for seals by Art. 40, a *qualified* +/// validation service is a QTSP service whose result carries the provider's own +/// advanced signature or seal. Nothing here is signed and nothing here is +/// qualified; this is a node's own housekeeping, and a stored row must not be +/// presented as an attestation that a seal was valid at a moment in time. +#[async_trait] +pub trait SealAuditStore: Send + Sync { + /// Read back the walk in progress and the last completed report. + /// + /// Both are independently optional: a node that has never finished a walk + /// has progress and no report, and a node that finished one and has not + /// started the next has a report and no progress. + /// + /// # Errors + /// + /// Propagates the store's own failure. + async fn load(&self) -> Result<(Option, Option), DppError>; + + /// Save the walk's position after a batch, leaving the last report alone. + /// + /// # Errors + /// + /// Propagates the store's own failure. + async fn save_progress(&self, progress: &SealAuditProgress) -> Result<(), DppError>; + + /// Publish a completed pass: store `report` and clear the progress, so a + /// restart starts the next walk cleanly rather than resuming a finished one. + /// + /// # Errors + /// + /// Propagates the store's own failure. + async fn complete(&self, report: &SealAuditReport) -> Result<(), DppError>; +} + +/// One sealed passport, as an audit pass needs to see it. +#[derive(Debug, Clone)] +pub struct SealedPassport { + /// The passport carrying the seal. + pub passport_id: PassportId, + /// The seal stored on it. + pub seal: SealedEnvelope, + /// Hex SHA-256 of the passport's **current** compact JWS — what a sound + /// seal should be covering. + pub payload_hash: String, +} + /// The sealing outbox — enqueued in the publish transaction, drained by the node. #[async_trait] pub trait SealOutbox: Send + Sync { @@ -182,4 +423,989 @@ pub trait SealOutbox: Send + Sync { /// condition and shares this predicate, but adds its own guards for rows the /// drain already owns — those belong to the repair, not to the question. async fn unsealed_published_count(&self) -> Result; + + /// Re-arm a **sealed** row so its passport is sealed again. + /// + /// # This deliberately crosses the line [`Self::enqueue`] holds + /// + /// `enqueue` re-arms only `exhausted` rows, and says why: a `sealed` row + /// "has an artifact that was paid for, so re-queueing it buys the same + /// attestation twice". That reasoning is sound wherever the artifact is + /// worth something. It is exactly wrong where the artifact is a seal that + /// does not verify — there the row is paid for **and** carries nothing, and + /// the passport is published and, in substance, unsealed. + /// + /// So this is the one path that re-arms a `sealed` row, and it is the + /// caller's job to have established that the seal is worthless first. The + /// store cannot check that — whether a seal stands up is cryptographic — so + /// the guarantee lives at the call site and nowhere else. + /// + /// Returns whether a row actually moved. `false` means there was no `sealed` + /// row for that digest, which is not an error: the passport may have been + /// re-published, or a repair may already be queued. + /// + /// # Errors + /// + /// Propagates the store's own failure. + async fn rearm_sealed( + &self, + passport_id: PassportId, + payload_hash: &str, + reason: &str, + ) -> Result; + + /// Sealed passports, in id order, for an audit pass to read. + /// + /// # Why "unsealed" is not the only failure worth finding + /// + /// [`Self::enqueue_unsealed`] and [`Self::unsealed_published_count`] both ask + /// the same question of the database: is the `seal` member absent? **A seal + /// that is present but worthless satisfies neither clause.** Its passport is + /// not swept, not counted, and looks healthy in every number this node + /// reports — while being, in substance, unsealed. + /// + /// That question cannot be a SQL clause. Whether a stored seal stands up is + /// cryptographic: open the CAdES, check the signature, read the digest it + /// covers and compare. So this hands the rows out and lets a caller that can + /// read seals decide. + /// + /// Paged with `after` as a cursor rather than an offset: passport ids are + /// UUIDv7 and time-ordered, so a cursor is stable against rows arriving + /// mid-walk, which an offset is not. + /// + /// # `sealed_before` pins what a pass is a statement *about* + /// + /// A walk takes minutes to hours, and seals are written while it runs. Which + /// of those a pass happens to see depends on where its cursor had reached — + /// so without a bound, "checked 1,204" describes a population nobody can + /// name, and two consecutive passes disagree for reasons that are not about + /// the seals. + /// + /// Passing the moment the walk started makes the pass a statement about + /// exactly the seals that existed then. Skipping the newer ones costs + /// nothing in coverage: the drain checks a seal's binding before it accepts + /// it, so a seal written during the walk was verified as it landed, and the + /// next pass covers it anyway. + /// + /// `None` walks everything, which is what a caller doing a one-off sweep + /// wants. A passport whose seal cannot be dated is **included** either way: + /// not knowing when something was sealed is not a reason to stop looking at + /// it. + /// + /// # Errors + /// + /// Propagates the store's own failure. + async fn sealed_passports( + &self, + limit: i64, + after: Option, + sealed_before: Option>, + ) -> Result, DppError>; +} + +// ─── Reading a stored seal ──────────────────────────────────────────────────── + +/// What a seal's certificate declares about the device holding its private key. +/// +/// A **declaration**, never a verification. The certificate says where its key +/// lives; nothing confirms it, and nothing could from bytes alone — that +/// assurance comes from the issuing QTSP's conformity assessment. The name says +/// `Declares` for that reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CreationDevice { + /// The certificate carries the Annex III(j) indication. + /// + /// Regulation (EU) No 910/2014 Art. 32(1)(f), reached for seals through + /// Art. 40, requires that the seal was created by a qualified electronic seal + /// creation device, and Annex III(j) requires the certificate to say so in a + /// form suitable for automated processing. + DeclaresQualifiedDevice, + /// It carries qualified-certificate statements, but not that one. + /// + /// Lawful, and enough for Art. 40a — validation of an *advanced* seal based + /// on a qualified certificate, which omits the device leg — but not for the + /// Art. 32/40 pair. + NoQualifiedDevice, + /// It carries no qualified-certificate statements at all. + /// + /// A different finding from [`Self::NoQualifiedDevice`]: this certificate is + /// not presenting itself as a qualified certificate in the first place. A + /// self-signed development certificate lands here. + NotAQualifiedCertificate, +} + +/// What a stored seal's own certificate says about who issued it. +/// +/// **Read out of the seal, never from configuration.** A node knows which +/// backend it was *told* to use, which attests the operator's intent rather than +/// the bytes that came back — and a seal restored from a backup, or made before +/// a backend was changed, was not produced by the backend running now. +/// +/// Nothing here is a qualification verdict. Establishing that a seal is +/// qualified needs the issuer matched against an EU Trusted List *and* the +/// issuer's signature over this certificate verified; neither is done to produce +/// this. What it does answer, completely and without a network, is whether +/// anybody issued the certificate at all. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealOrigin { + /// The certificate's subject distinguished name, RFC 4514. + pub subject: String, + /// The certificate's issuer distinguished name, RFC 4514. + pub issuer: String, + /// Whether issuer and subject are the same name. + /// + /// The structural test for "nobody issued this to us", and the one an + /// operator asks first. True means the seal attests that a key this node + /// holds signed a digest, and **nothing else** — it carries no legal weight + /// and no Trusted List will give it any. + /// + /// A property of the certificate, so no environment variable can change it. + pub self_issued: bool, + /// What the certificate declares about the creation device (Annex III(j)). + pub creation_device: CreationDevice, +} + +/// Whether a seal's own bytes say it covers a particular signature. +/// +/// # Why this exists beside the outbox record +/// +/// A node records the digest it *asked* a backend to seal, and that record is +/// genuinely useful: it survives a seal that will not parse, and it spots a +/// re-published passport with a string comparison and no AdES tooling. But it is +/// a statement about this node's own bookkeeping. A seal restored from a backup +/// has no such row; a seal stored against the wrong passport has a row that +/// agrees with itself and nothing else. +/// +/// A detached CAdES says what it covers in exactly one place — the +/// `messageDigest` signed attribute, RFC 5652 §11.2 — and that attribute is +/// *inside* the signature. Reading it turns "this seal is for this passport" +/// from a claim resting on our records into a fact checkable against the seal. +/// +/// The two answers can disagree, and **the disagreement is the finding**: it +/// means the records and the bytes describe different things, which no single +/// source could have told anyone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "result")] +pub enum SealBinding { + /// The seal names this exact signature, and its attributes verify under the + /// certificate it carries. + /// + /// The strongest statement this node can make without an external validator: + /// whatever else is true of the seal's *trust*, it is demonstrably a seal + /// over this passport's current signature and not over anything else. + CoversThisSignature, + /// The seal is intact and names a different digest. + /// + /// Ordinary after a re-publish — the passport re-signed, so its current + /// signature is not the one sealed. It is also what a seal stored against the + /// wrong passport looks like, and the two are indistinguishable from here; + /// what distinguishes them is whether an outbox row exists saying this digest + /// was ever requested for this passport. + CoversAnotherDigest { + /// The hex SHA-256 the seal actually covers. + covered: String, + }, + /// The signature over the seal's attributes does not verify. + /// + /// No digest is reported, deliberately: the attribute naming it is inside a + /// signature that failed, so its contents are not evidence of anything. A + /// caller shown a digest here would be shown a number that nothing vouches + /// for. + NotIntact, + /// The bytes could not be read, or carry no digest at all. + /// + /// A placeholder seal, an unparsed format, or a signature with no signed + /// attributes — a seal whose digest is not inside it and cannot be recovered + /// from the envelope alone. **Never** to be read as a mismatch. + Unknown, +} + +/// The main status indication of a validation process, as ETSI EN 319 102-1 +/// clause 5.1.3 defines it. +/// +/// The standard has three. **This enum has two**, and the missing one is the +/// point: `TOTAL-PASSED` requires, among other things, that the constraints +/// applicable to the signer's certificate "have been positively validated" — +/// which means a certificate path built and validated to a trust anchor, under +/// a policy, at the right moment. +/// +/// This node checks format, cryptography, the certificate's validity window and +/// whatever revocation material the seal carries. It builds no path: the issuer +/// signature check in `qualification` is one link against a Trusted List entry, +/// not a validated chain, and nothing applies the policy constraints clause +/// 5.1.3 also requires. A seal can therefore be as sound as this node can +/// establish and still not have passed, and an enum that could express +/// `totalPassed` would eventually be asked to. +/// +/// Making that unrepresentable is the honest encoding of what the node does +/// today. Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, +/// is the requirement that is missing, and implementing it is what would make a +/// third variant meaningful. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ValidationIndication { + /// The signature is demonstrably not valid, and no further data can change + /// that. + /// + /// The standard is explicit that this is stable: the same inputs always + /// yield the same answer, and *additional validation data* cannot lift a + /// `TOTAL-FAILED` to a pass — only additional proofs of existence can change + /// a result at all. That stability is what makes it safe to spend money on a + /// replacement seal for one, and it is why the repair route refuses + /// everything else. + TotalFailed, + /// The available information is insufficient to decide. + /// + /// Not a weaker "failed". Under CIR (EU) 2025/1945 — the act that pins how a + /// qualified seal is validated — an indeterminate result is its own + /// technical outcome, "neither an EU qualified electronic signature, nor an + /// EU qualified electronic seal", and is to be reported as such rather than + /// collapsed into either neighbour. + Indeterminate, +} + +/// The sub-indication qualifying a [`ValidationIndication`], from EN 319 102-1 +/// table 6. +/// +/// Only the two this node can actually justify are modelled. Where none of the +/// table's values fits, the standard's own instruction is to report a custom +/// diagnostic instead — and [`SealBinding`] beside this *is* that diagnostic, in +/// machine-readable form, which is why the field is optional here rather than +/// stretched to a value that nearly fits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ValidationSubIndication { + /// The signature value could not be verified with the public key in the + /// signing certificate. + SigCryptoFailure, + /// A hash of signed data does not match the value in the signature — here, + /// the passport's current signature against the digest the seal covers. + HashFailure, + /// The signing certificate was revoked before the seal was made, and a + /// timestamp proves the order. + Revoked, + /// The signing certificate had expired when the seal was made, and a + /// timestamp proves it. + Expired, + /// The signing certificate was not yet valid when the seal was made, and a + /// timestamp proves it. + NotYetValid, + /// The certificate is revoked, and nothing here proves the seal was made + /// before that. + /// + /// `NO_POE` throughout table 6 means *no proof of existence*: without an + /// attested time the seal cannot be placed on either side of the revocation, + /// and a revoked certificate does not retroactively unmake a seal it made + /// while valid. Reporting this as a failure would condemn every sound seal + /// from a provider that later rotated a key. + RevokedNoPoe, + /// The certificate is outside its validity window, and nothing here proves + /// the seal was made while it was inside. + /// + /// The ordinary state of a long-lived seal: certificates expire, sealed + /// passports are kept for a decade, and a `B-B` seal carries no time anybody + /// can trust. It is what an archival timestamp exists to fix. + OutOfBoundsNoPoe, + /// Revocation information was not available, so the question could not be + /// asked. + /// + /// Not a defect in the seal. A `B-B` or `B-T` seal is not required to carry + /// revocation material — ETSI EN 319 122-1 puts it in `SignedData.crls` from + /// `B-LT` upward — and this node reads only what the seal carries. + TryLater, +} + +/// What this node's reading of a seal amounts to in EN 319 102-1's vocabulary. +/// +/// A translation, not a second opinion: every value is derived from +/// [`SealBinding`] and adds no checking. It exists because the audit's findings +/// travel to readers whose tooling speaks that vocabulary, and because the +/// translation makes one thing explicit that our own names let a reader assume — +/// that a sound seal here has **not** passed validation, it has merely not +/// failed it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealValidationStatus { + /// The main indication. + pub indication: ValidationIndication, + /// The sub-indication, where one from table 6 applies. `null` means the + /// standard's custom-diagnostic case, and `binding` carries the diagnostic. + pub sub_indication: Option, +} + +impl SealBinding { + /// This reading, stated in EN 319 102-1's terms. + /// + /// The mapping, and why each one: + /// + /// | This node | Indication | Sub-indication | + /// |---|---|---| + /// | `coversThisSignature` | `indeterminate` | — | + /// | `coversAnotherDigest` | `totalFailed` | `hashFailure` | + /// | `notIntact` | `totalFailed` | `sigCryptoFailure` | + /// | `unknown` | `indeterminate` | — | + /// + /// **A sound seal is `indeterminate`, not passed**, for the reason + /// [`ValidationIndication`] gives: the certificate has not been validated, + /// so the strongest honest answer is that nothing has failed yet. + /// + /// **A superseded seal is `totalFailed`**, which reads oddly until the + /// question is stated precisely: validating *this passport's current + /// signature* against that seal fails on the hash, exactly as table 6 + /// describes. The seal itself is intact and is a perfectly good attestation + /// of the signature it does cover — which is why this node reports it + /// separately and does not alarm on it, and why a repair is refused. + /// + /// Both `indeterminate` cases are the custom-diagnostic case, and they are + /// not the same diagnostic: one is "the certificate was never checked", the + /// other "these bytes could not be read". `binding` keeps them apart. + #[must_use] + pub fn validation_status(&self) -> SealValidationStatus { + SealValidationStatus::of(self, None) + } +} + +/// Where a certificate's validity window sits relative to the moment being asked +/// about. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WindowStanding { + /// The moment falls inside `notBefore`..`notAfter`. + Inside, + /// It is after `notAfter`. + Expired, + /// It is before `notBefore`. + NotYetValid, +} + +/// A certificate's validity window, and where the sealing moment falls in it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidityWindow { + /// The certificate's `notBefore`. + pub not_before: DateTime, + /// The certificate's `notAfter`. + pub not_after: DateTime, + /// Where the judged moment falls. + pub standing: WindowStanding, +} + +/// The moment a certificate's standing was judged against, and whether anything +/// proves it. +/// +/// Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals by Art. 40, asks +/// whether the certificate was valid **at the time of signing** — so the whole +/// answer turns on which time is used and what that time is worth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JudgedTime { + /// The moment used. + pub at: DateTime, + /// True when it came from a timestamp token inside the seal, checked. + /// + /// False means it is this node's clock — the validation time, not the + /// signing time. Every verdict resting on an unattested moment is reported + /// with a `NO_POE` sub-indication for exactly this reason: EN 319 102-1 + /// treats a time nothing proves as no time at all, and so does this. + pub attested: bool, +} + +/// What the seal's own revocation material says about its certificate. +/// +/// **Read from the seal, never fetched.** A CRL distribution point is a URL +/// inside a certificate an operator was handed, and following one would make a +/// background task issue requests to an address chosen by whoever produced the +/// seal. The long-term profiles exist precisely so this is unnecessary: ETSI +/// EN 319 122-1 puts revocation values in `SignedData.crls` from `B-LT` upward, +/// so a seal meant to be checkable years later carries what is needed to check +/// it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// `rename_all` renames the *variants*; the fields inside them need +// `rename_all_fields`, or `as_of` ships as `as_of` while the spec documents +// `asOf`. The same trap put `lapsed_at` on the wire once already — see +// `ArchivalFreshness`, which carries both attributes for the same reason. +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "status" +)] +pub enum RevocationStanding { + /// A CRL covering this certificate lists it as not revoked. + NotRevoked { + /// The CRL's `thisUpdate` — the moment its statement is about. + /// + /// A CRL issued *before* the seal was made cannot show a revocation that + /// happened after it. Carried so a reader can see which question was + /// actually answered rather than assuming the strongest one. + as_of: DateTime, + }, + /// The certificate appears on a CRL as revoked. + Revoked { + /// When the CA says it was revoked. + at: DateTime, + }, + /// The seal carries no revocation material for this certificate. + /// + /// Ordinary below `B-LT`, and not a defect. + NotAvailable, + /// Material is present and cannot be relied on. + Unusable { + /// Why — an unparseable CRL, one from another issuer, or one whose own + /// signature does not verify. Separate from [`Self::NotAvailable`] + /// because something was there and failed, which is worth looking at. + reason: String, + }, +} + +/// What this node can establish about the seal's signing certificate. +/// +/// Art. 32(1)(b) has two limbs — issued by a qualified provider, and **valid at +/// the time of signing** — and this answers the second. The first is a trusted +/// list question, answered elsewhere. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CertificateStanding { + /// The certificate's window, and where the judged moment falls in it. + pub validity: ValidityWindow, + /// The moment judged against, and whether anything proves it. + pub judged_at: JudgedTime, + /// What the seal's own revocation material says. + pub revocation: RevocationStanding, +} + +impl SealValidationStatus { + /// The EN 319 102-1 status for a seal, from everything this node checked. + /// + /// # The order is the point + /// + /// A seal can fail in several ways at once, and the reported answer must be + /// the one that decides the outcome. Cryptography first: a signature that + /// does not verify makes every later question moot, because the attributes + /// those questions read sit inside it. Then the certificate, whose failures + /// are `TOTAL-FAILED` only when a timestamp proves the ordering — without + /// one they are indeterminate, and reporting them as failures would condemn + /// every sound seal whose certificate has since expired, which is all of + /// them eventually. + /// + /// # Passing is still not reachable + /// + /// Even with the window checked and the CRL read, `TOTAL-PASSED` needs the + /// whole of clause 5.1.3's list — the format check, the policy constraints, + /// and a certificate chain validated to a trust anchor. + /// [`ValidationIndication`] therefore still has two variants, and a seal + /// that survives every check here reports `indeterminate`. + #[must_use] + pub fn of(binding: &SealBinding, certificate: Option<&CertificateStanding>) -> Self { + use ValidationSubIndication as Sub; + + let failed = |sub| Self { + indication: ValidationIndication::TotalFailed, + sub_indication: Some(sub), + }; + let unsure = |sub| Self { + indication: ValidationIndication::Indeterminate, + sub_indication: sub, + }; + + match binding { + SealBinding::NotIntact => return failed(Sub::SigCryptoFailure), + SealBinding::CoversAnotherDigest { .. } => return failed(Sub::HashFailure), + SealBinding::CoversThisSignature | SealBinding::Unknown => {} + } + + let Some(cert) = certificate else { + return unsure(None); + }; + let proven = cert.judged_at.attested; + + // **Everything provable first, in order of severity; then the things + // that could not be established.** An unresolved question must never + // mask a settled failure — a certificate proven to have been expired + // when the seal was made is `TOTAL-FAILED` whether or not a CRL happened + // to travel with the seal, and reporting `TRY_LATER` there would hide a + // stable finding behind a missing file. + // + // Revocation outranks expiry among the failures: expiry is scheduled and + // ordinary, revocation is someone saying this key should not have been + // used. + if let RevocationStanding::Revoked { at } = &cert.revocation + && proven + && *at <= cert.judged_at.at + { + return failed(Sub::Revoked); + } + if proven { + match cert.validity.standing { + WindowStanding::Expired => return failed(Sub::Expired), + WindowStanding::NotYetValid => return failed(Sub::NotYetValid), + WindowStanding::Inside => {} + } + } + + // Nothing failed. What is left is what could not be settled, most + // informative first: a revocation this node cannot place in time says + // more than a certificate outside its window, which says more than a + // list that never arrived. + if matches!(cert.revocation, RevocationStanding::Revoked { .. }) { + return unsure(Some(Sub::RevokedNoPoe)); + } + if cert.validity.standing != WindowStanding::Inside { + return unsure(Some(Sub::OutOfBoundsNoPoe)); + } + match cert.revocation { + // The question could not be answered, which table 6 calls + // `TRY_LATER` — it may be answerable when the material is there. + RevocationStanding::NotAvailable | RevocationStanding::Unusable { .. } => { + unsure(Some(Sub::TryLater)) + } + _ => unsure(None), + } + } +} + +/// How much life is left in a seal's archival timestamp. +/// +/// An archival timestamp is what keeps a `B-LTA` seal verifiable after its +/// signing certificate expires — the whole point for a retention-locked +/// passport, which outlives every certificate involved. **It expires too**: its +/// own timestamping authority's certificate has a validity period, and ETSI's +/// long-term profiles expect re-timestamping before that. Nothing here does +/// that, and the level says `baseline-lta` either way, so a lapse is otherwise +/// invisible. +/// +/// **A signal, never a verdict.** A seal nearing its renewal date still +/// verifies, and that window is the only chance to renew without an outage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// `rename_all` renames the *variants*; the fields inside them need +// `rename_all_fields`. `SealBinding` above happens not to show the difference — +// its one field is a single word — which is exactly how this would have shipped +// with `lapsed_at` on the wire had the contract gate not compared the spec to +// the type. +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "state" +)] +pub enum ArchivalFreshness { + /// No archival timestamp at all — a seal below `B-LTA`. + /// + /// Nothing to renew, which is a different thing from a renewal that has + /// lapsed: a `B-LT` seal was never promised long-term protection, and saying + /// it lapsed would raise an alarm about a commitment nobody made. + NotArchived, + /// The archival timestamp's authority certificate is still valid. + Current { + /// When the authority's certificate expires — the date by which + /// re-timestamping must have happened. + /// + /// No threshold is applied: how much notice is enough is a policy + /// question for whoever reads this. + expires: DateTime, + }, + /// It has expired, so the archival protection has lapsed. + /// + /// The seal may still verify today. What is gone is the thing meant to keep + /// it verifying once its signing certificate goes. + Lapsed { + /// When the authority's certificate expired. + /// + /// The same date `current` carries, read from the other side of it — + /// one value, so the wire has one name for it rather than two that must + /// be kept in step. + expires: DateTime, + }, + /// An archival timestamp is present and could not be read. + /// + /// **Not `current`.** A token that cannot be checked is not a fresh one, and + /// reporting it as current is how a staleness signal goes quiet at the + /// moment it matters. + Unknown, +} + +/// Reads a stored seal's certificate. +/// +/// # Why this is a port rather than a function +/// +/// Parsing CMS and X.509 needs ASN.1 machinery that belongs with the seal +/// adapter, beside the code that produces seals in the first place — one home +/// for certificate handling, so two places cannot disagree about what a seal +/// says. The services that *serve* seals sit above that adapter and must not +/// link it: the crate carrying it also carries an HTTP client and an XML +/// signature verifier, which is a disproportionate dependency for reading a +/// distinguished name. +/// +/// So the question is declared here, where every consumer already looks, and +/// answered by whichever adapter the composition root resolved — the same +/// arrangement [`SealOutbox`] uses, and for the same reason. +pub trait SealInspector: Send + Sync { + /// What the envelope's certificate says, or `None` if it cannot be read. + /// + /// `None` for a placeholder envelope, for a format this adapter does not + /// parse, and for bytes that will not decode. All three mean *not read* — + /// **never** that the seal was self-issued, which is a finding and must come + /// from a certificate that was actually examined. + fn origin(&self, envelope: &SealedEnvelope) -> Option; + + /// Whether the envelope's own bytes say it covers `payload_hash`. + /// + /// `payload_hash` is the hex SHA-256 the caller wants to test against — for + /// a passport, [`digest_for_jws`] of its current compact JWS. + /// + /// The implementation must check the seal's signature as well as reading the + /// digest out of it. The attribute naming the digest sits inside that + /// signature, so a comparison made without it is a comparison against a value + /// anybody could have written. + fn binding(&self, envelope: &SealedEnvelope, payload_hash: &str) -> SealBinding; + + /// The baseline level the envelope's **bytes** carry, as distinct from the + /// level recorded on it. + /// + /// `SealedEnvelope::conformance_level` records what this node *asked* for. + /// This reports what arrived. The two disagreeing is a downgrade — a + /// provider enabled for a weaker profile than was paid for — and it is the + /// failure that matters most, because it lands on a retention-locked + /// passport that cannot be re-sealed and only shows years later when the + /// signing certificate expires. + /// + /// A floor, not a conformance verdict: it reports that the *distinguishing + /// material* for a level is present, never that the material was validated. + /// + /// `None` when the bytes cannot be read. + fn evidenced_level(&self, envelope: &SealedEnvelope) -> Option; + + /// The time a timestamp authority attests the seal was made. + /// + /// Not [`SealedEnvelope::sealed_at`], which is the sealing node's own clock + /// when the backend answered — an unattested claim by the party that bought + /// the seal. From `B-T` upward the envelope carries a time-stamp token, and + /// that token is the only place in a seal an attested time can be. + /// + /// The implementation must check the token's own signature **and** that its + /// imprint covers this seal's signature. The attribute carrying it is + /// *unsigned*, so swapping in a genuine token from another seal costs + /// nothing and would otherwise be accepted. + /// + /// `None` for a `B-B` seal, and for a token that fails either check — a time + /// that did not survive checking must not be reported as a time. + /// + /// Says nothing about whether the authority is **trusted** or **qualified**: + /// Art. 42 makes a qualified time stamp a QTSP service, which is a Trusted + /// List question about the `TSA/QTST` service type and is not asked here. + fn attested_sealing_time(&self, envelope: &SealedEnvelope) -> Option>; + + /// How much life is left in the envelope's archival timestamp, as of `now`. + /// + /// `now` is a parameter rather than read from the clock so the answer is a + /// function of its inputs — the same seal is current today and lapsed later, + /// and a caller reporting on a past moment should be able to say so. + fn archival_freshness( + &self, + envelope: &SealedEnvelope, + now: DateTime, + ) -> ArchivalFreshness; + + /// What the seal's own certificate's standing was when the seal was made. + /// + /// The second limb of Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals + /// by Art. 40: a qualified certificate must have been **valid at the time of + /// signing**. Whether its issuer was a qualified provider is the other limb + /// and a trusted list question; this one is answerable from the seal alone. + /// + /// `now` is a parameter for the same reason it is on + /// [`Self::archival_freshness`] — so the answer is a function of its inputs. + /// It is used only as the fallback moment, and only when the seal carries no + /// attested time; [`JudgedTime::attested`] says which happened, and that + /// distinction decides whether an out-of-window certificate is a failure or + /// merely unproven. + /// + /// `None` when the bytes cannot be read — never a verdict of valid or + /// invalid, for the reason every other question here returns an option. + fn certificate_standing( + &self, + envelope: &SealedEnvelope, + now: DateTime, + ) -> Option; +} + +#[cfg(test)] +mod validation_vocabulary { + use super::*; + + /// **The one that matters: a sound seal has not passed.** + /// + /// EN 319 102-1 clause 5.1.3 puts "the constraints applicable to the + /// signer's certificate have been positively validated" among the conditions + /// for `TOTAL-PASSED`, and this node validates no certificate. Reporting a + /// pass would claim a check nobody ran — the same failure as reporting a + /// zero for an audit that has not completed, one level down. + #[test] + fn a_seal_this_node_calls_sound_is_indeterminate_not_passed() { + let status = SealBinding::CoversThisSignature.validation_status(); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!( + status.sub_indication, None, + "no table 6 value says 'the certificate was never checked' — that is the \ + standard's custom-diagnostic case, and `binding` is the diagnostic" + ); + } + + /// A broken seal is the stable verdict, and the whole basis of the repair + /// route: more validation data cannot lift a `TOTAL-FAILED`. + #[test] + fn a_broken_seal_is_total_failed_on_the_cryptography() { + let status = SealBinding::NotIntact.validation_status(); + assert_eq!(status.indication, ValidationIndication::TotalFailed); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::SigCryptoFailure) + ); + } + + /// Against *this passport's current signature*, a superseded seal fails on + /// the hash. It remains a sound attestation of the signature it covers, + /// which is why the node counts it apart and refuses to repair it. + #[test] + fn a_superseded_seal_fails_on_the_hash_of_this_signature() { + let status = SealBinding::CoversAnotherDigest { + covered: "ab".repeat(32), + } + .validation_status(); + assert_eq!(status.indication, ValidationIndication::TotalFailed); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::HashFailure) + ); + } + + /// "Cannot read" is not "failed" — the distinction CIR (EU) 2025/1945 makes + /// a technical outcome in its own right, and the reason repairing one is + /// refused. + #[test] + fn an_unreadable_seal_is_indeterminate_not_failed() { + let status = SealBinding::Unknown.validation_status(); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!(status.sub_indication, None); + } + + fn standing( + window: WindowStanding, + attested: bool, + revocation: RevocationStanding, + ) -> CertificateStanding { + let at = "2027-06-01T00:00:00Z" + .parse::>() + .expect("a time"); + CertificateStanding { + validity: ValidityWindow { + not_before: "2026-01-01T00:00:00Z".parse().expect("a time"), + not_after: "2027-01-01T00:00:00Z".parse().expect("a time"), + standing: window, + }, + judged_at: JudgedTime { at, attested }, + revocation, + } + } + + fn clean() -> RevocationStanding { + RevocationStanding::NotRevoked { + as_of: "2027-06-01T00:00:00Z".parse().expect("a time"), + } + } + + /// **An expired certificate fails only when a timestamp proves the order.** + /// + /// Art. 32(1)(b) asks whether the certificate was valid *at the time of + /// signing*, so the verdict turns entirely on whether the signing time is + /// known. With an attested time the seal was made after expiry and that is a + /// failure; without one, all that is known is that the certificate is + /// expired *now* — which is the eventual state of every certificate, and + /// says nothing about a seal made years earlier. + #[test] + fn an_expired_certificate_fails_only_against_a_proven_time() { + let proven = standing(WindowStanding::Expired, true, clean()); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&proven)); + assert_eq!(status.indication, ValidationIndication::TotalFailed); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::Expired) + ); + + let unproven = standing(WindowStanding::Expired, false, clean()); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&unproven)); + assert_eq!( + status.indication, + ValidationIndication::Indeterminate, + "without a proof of existence this must not read as a failure" + ); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::OutOfBoundsNoPoe) + ); + } + + /// The same rule for revocation, and the asymmetry matters more here: a + /// certificate revoked *after* a seal was made does not unmake the seal. + #[test] + fn a_revoked_certificate_fails_only_against_a_proven_time() { + let revoked_before = RevocationStanding::Revoked { + at: "2027-01-05T00:00:00Z".parse().expect("a time"), + }; + let proven = standing(WindowStanding::Inside, true, revoked_before.clone()); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&proven)); + assert_eq!(status.indication, ValidationIndication::TotalFailed); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::Revoked) + ); + + let unproven = standing(WindowStanding::Inside, false, revoked_before); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&unproven)); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::RevokedNoPoe) + ); + } + + /// A revocation *after* the attested sealing time is not a finding at all — + /// the seal was made while the certificate was good. + #[test] + fn a_revocation_after_sealing_does_not_condemn_the_seal() { + let later = RevocationStanding::Revoked { + at: "2027-12-01T00:00:00Z".parse().expect("a time"), + }; + let cert = standing(WindowStanding::Inside, true, later); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&cert)); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::RevokedNoPoe), + "reported, because a reader should see it — but not as a failure" + ); + } + + /// No revocation material is `TRY_LATER`: the question could not be asked, + /// which is the ordinary state of a `B-B` or `B-T` seal. + #[test] + fn a_seal_carrying_no_revocation_material_is_try_later() { + let cert = standing( + WindowStanding::Inside, + true, + RevocationStanding::NotAvailable, + ); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&cert)); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::TryLater) + ); + } + + /// **A settled failure is not hidden behind an unanswered question.** + /// + /// A certificate proven expired when the seal was made is `TOTAL-FAILED` + /// whether or not a revocation list travelled with the seal. Reporting + /// `TRY_LATER` because no CRL was found would let a missing file suppress a + /// stable finding — and `TRY_LATER` invites a reader to come back later, + /// which will never change this answer. + #[test] + fn a_missing_crl_does_not_mask_a_proven_window_failure() { + let cert = standing( + WindowStanding::Expired, + true, + RevocationStanding::NotAvailable, + ); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&cert)); + assert_eq!(status.indication, ValidationIndication::TotalFailed); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::Expired) + ); + } + + /// The same ordering among the things that could not be settled: a + /// revocation this node cannot place in time says more than a certificate + /// outside its window, which says more than a list that never arrived. + #[test] + fn the_unsettled_answers_are_ordered_by_what_they_say() { + let revoked_unproven = standing( + WindowStanding::Expired, + false, + RevocationStanding::Revoked { + at: "2027-01-05T00:00:00Z".parse().expect("a time"), + }, + ); + assert_eq!( + SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&revoked_unproven)) + .sub_indication, + Some(ValidationSubIndication::RevokedNoPoe) + ); + + let out_of_window = standing( + WindowStanding::Expired, + false, + RevocationStanding::NotAvailable, + ); + assert_eq!( + SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&out_of_window)) + .sub_indication, + Some(ValidationSubIndication::OutOfBoundsNoPoe) + ); + } + + /// **The cryptography decides first.** + /// + /// A seal whose signature does not verify is `SIG_CRYPTO_FAILURE` whatever + /// the certificate says — and it must be, because the attributes every other + /// question reads sit inside that signature. Reporting the revocation + /// instead would hand a reader a finding drawn from bytes nothing vouches + /// for. + #[test] + fn a_broken_signature_outranks_every_certificate_finding() { + let revoked = standing( + WindowStanding::Expired, + true, + RevocationStanding::Revoked { + at: "2026-06-01T00:00:00Z".parse().expect("a time"), + }, + ); + let status = SealValidationStatus::of(&SealBinding::NotIntact, Some(&revoked)); + assert_eq!( + status.sub_indication, + Some(ValidationSubIndication::SigCryptoFailure) + ); + } + + /// Everything checked, nothing wrong — and still not a pass, because the + /// chain has not been validated to a trust anchor and the policy constraints + /// have not been applied. + #[test] + fn a_certificate_that_survives_every_check_is_still_only_indeterminate() { + let cert = standing(WindowStanding::Inside, true, clean()); + let status = SealValidationStatus::of(&SealBinding::CoversThisSignature, Some(&cert)); + assert_eq!(status.indication, ValidationIndication::Indeterminate); + assert_eq!( + status.sub_indication, None, + "nothing in table 6 applies, so this is the custom-diagnostic case" + ); + } + + /// The wire strings are the standard's names in this API's casing, and + /// consumers key on them. + #[test] + fn the_vocabulary_serialises_to_the_documented_strings() { + let json = serde_json::to_value(SealBinding::NotIntact.validation_status()).unwrap(); + assert_eq!(json["indication"], "totalFailed"); + assert_eq!(json["subIndication"], "sigCryptoFailure"); + + let sound = serde_json::to_value(SealBinding::CoversThisSignature.validation_status()) + .expect("serialises"); + assert_eq!(sound["indication"], "indeterminate"); + assert!( + sound["subIndication"].is_null(), + "the custom-diagnostic case must be visibly empty, not filled with a near fit" + ); + } } diff --git a/crates/dpp-types/src/trust.rs b/crates/dpp-types/src/trust.rs index fd7fab40..0ab66763 100644 --- a/crates/dpp-types/src/trust.rs +++ b/crates/dpp-types/src/trust.rs @@ -138,6 +138,18 @@ impl NodeTrustReport { Self { profile, ports } } + /// The tier one named port resolved to, if the composition root resolved it. + /// + /// `None` means the port was never resolved — a deployment that wires no such + /// adapter at all — which is **not** the same as `Ghost`, a port that was + /// resolved and landed on a placeholder. A caller reporting "not configured" + /// and one reporting "configured with a stand-in" are telling an operator two + /// different things, and only one of them is a boot blocker. + #[must_use] + pub fn mode_of(&self, port: &str) -> Option { + self.ports.iter().find(|p| p.port == port).map(|p| p.mode) + } + /// Required ports that resolved to `Ghost` — the production boot blockers. #[must_use] pub fn ghosted_required(&self) -> Vec<&'static str> { @@ -275,6 +287,32 @@ mod tests { assert!(report.enforce_profile().is_ok()); } + #[test] + fn a_named_port_reports_its_own_mode() { + let report = NodeTrustReport::new( + NodeProfile::Development, + ports(TrustMode::Live, TrustMode::Ghost, TrustMode::Sandbox), + ); + assert_eq!(report.mode_of("seal"), Some(TrustMode::Live)); + assert_eq!(report.mode_of("registry_sync"), Some(TrustMode::Ghost)); + } + + /// A port nobody resolved is `None`, not `Ghost`. + /// + /// The distinction a caller serves to an operator: "this deployment wires no + /// such adapter" and "this deployment wired a placeholder" are different + /// states, and only the second refuses a production boot. Defaulting the + /// first to `Ghost` would report a blocker that does not exist — and, worse, + /// would make a *renamed* port indistinguishable from a ghosted one. + #[test] + fn a_port_that_was_never_resolved_is_none_rather_than_ghost() { + let report = NodeTrustReport::new( + NodeProfile::Development, + ports(TrustMode::Live, TrustMode::Live, TrustMode::Live), + ); + assert_eq!(report.mode_of("no_such_port"), None); + } + #[test] fn posture_json_surfaces_each_port_mode() { let report = NodeTrustReport::new( diff --git a/crates/dpp-vault/src/domain/service/evidence.rs b/crates/dpp-vault/src/domain/service/evidence.rs index bac28cc5..2a2c9095 100644 --- a/crates/dpp-vault/src/domain/service/evidence.rs +++ b/crates/dpp-vault/src/domain/service/evidence.rs @@ -75,7 +75,10 @@ impl PassportService { // rather than serialising to bytes and re-parsing through // `verify_dossier_json`, which exists for the *uploaded-document* path // where the input genuinely starts as untyped bytes. - Ok(crate::domain::verify::verify_dossier(&record.dossier)) + Ok(crate::domain::verify::verify_dossier( + &record.dossier, + self.seal_inspector.as_deref(), + )) } /// Assemble the evidence dossier for a passport. Requires the passport to @@ -202,12 +205,96 @@ impl PassportService { // than any redaction of it. `None` when the seal is still queued. let qualified_seal = passport.seal.as_ref().and_then(|seal| { let jws = passport.jws_signature.as_ref()?; + let payload_hash = crate::domain::service::seal::seal_digest(&passport)?; + // Read once: `binding` and `validation` below must agree, and two + // separate calls could in principle answer differently — which would + // put a dossier on the record contradicting itself. + let binding = self + .seal_inspector + .as_ref() + .map_or(dpp_types::SealBinding::Unknown, |i| { + i.binding(seal, &payload_hash) + }); + // Stamped at generation like every other finding here: judged + // against an attested sealing time where the seal carries one, and + // against this clock otherwise — which is why the moment used + // travels inside the answer. + let certificate = self + .seal_inspector + .as_ref() + .and_then(|i| i.certificate_standing(seal, chrono::Utc::now())); Some(serde_json::json!({ "seal": seal, // Served so a verifier holding only this file has both the CAdES // and what it should be checked against, with no reconstruction. "signedOverJws": jws, - "payloadHash": crate::domain::service::seal::seal_digest(&passport)?, + "payloadHash": payload_hash, + // Whether the seal actually covers the signature served beside + // it — and this is not decoration. + // + // The JWS above is the passport's *current* one. A passport + // re-published after sealing carries a seal over the previous + // signature until the drain catches up, and if the drain is + // exhausted that window has no end. A dossier pairing the two + // silently would hand an authority a seal that does not verify + // against the document beside it — which reads as tampering, + // rather than as the stale seal it is. + // + // Reported rather than allowed to block generation: a dossier + // must be producible in whatever state the passport is actually + // in, and saying so plainly beats refusing to say anything. + "binding": binding, + // The same reading in ETSI EN 319 102-1's words — the vocabulary + // an auditor's own validation tooling reports in, and the one + // CIR (EU) 2025/1945 points at for qualified seals. Derived, not + // a second check. + // + // It never says `totalPassed`: that requires the signer's + // certificate to have been validated, which this node does not + // do. A dossier reader seeing `coversThisSignature` and no such + // caveat could reasonably conclude otherwise. + "validation": dpp_types::SealValidationStatus::of(&binding, certificate.as_ref()), + // Whether the certificate was valid when the seal was made — + // Art. 32(1)(b)'s second limb, and the question an authority + // holding this file would otherwise have to answer by finding a + // CRL from years ago. A `B-LT` seal carries one; this reads it. + "certificate": certificate, + // Who issued the certificate behind the seal. + // + // The dossier already named *which* certificate, as a thumbprint + // — enough to ask an auditor's question about, and not enough to + // answer the first one anybody actually has. A self-signed + // development seal and a QTSP's are the same field otherwise, and + // telling them apart meant parsing the CAdES by hand. + // + // The one fact that decides whether anything else in this section + // carries weight, so it travels with it rather than being + // recoverable from it. + "origin": self.seal_inspector.as_ref().and_then(|i| i.origin(seal)), + // What the bytes carry, beside what was asked for — a seal + // weaker than ordered is otherwise visible only in a drain log + // that no dossier reader has. + "evidencedLevel": self + .seal_inspector + .as_ref() + .and_then(|i| i.evidenced_level(seal)), + // A third party's statement of when this was sealed, as opposed + // to `seal.sealedAt`, which is the sealing node's own clock. An + // authority reading a dossier has no other way to reach it — the + // token is inside the CAdES. + "attestedSealedAt": self + .seal_inspector + .as_ref() + .and_then(|i| i.attested_sealing_time(seal)), + // Stamped at generation, like every other finding here. A reader + // opening this file in 2035 needs to know the archival + // protection had not already lapsed when it was made — and + // cannot recompute it against the clock of the day it is read, + // because that answer would be about a different moment. + "archival": self.seal_inspector.as_ref().map_or( + dpp_types::ArchivalFreshness::Unknown, + |i| i.archival_freshness(seal, chrono::Utc::now()), + ), })) }); diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index cc08a40b..562f861a 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -127,6 +127,17 @@ pub struct PassportService { /// node with no QTSP configured) means published passports carry no seal — /// visibly absent rather than faked, which is what the trust report reports. pub seal_outbox: Option>, + /// Reads a stored seal's certificate, for the seal read route. + /// + /// Independent of [`Self::seal_outbox`] on purpose: a node that no longer + /// seals — the provider was dropped, or this is a standalone vault serving + /// archived passports — still serves seals it holds, and *those* are the ones + /// whose origin a reader most needs. Tying the two would make the answer + /// disappear exactly when the seal is oldest and least self-explanatory. + /// + /// `None` means the route reports the origin as unread rather than as any + /// particular finding. + pub seal_inspector: Option>, /// Public base URL under which this deployment serves its continuity /// snapshots, if it serves them at all. /// @@ -172,6 +183,7 @@ impl PassportService { webhooks: None, snapshot_outbox: None, seal_outbox: None, + seal_inspector: None, snapshot_public_base_url: None, resolver_base_url: "https://id.odal-node.io".to_owned(), } @@ -254,6 +266,15 @@ impl PassportService { self } + /// Provide the seal inspector, so the seal read route can report whether a + /// provider issued the certificate behind a stored seal or the node signed + /// it itself. + #[must_use] + pub fn with_seal_inspector(mut self, inspector: Arc) -> Self { + self.seal_inspector = Some(inspector); + self + } + /// Set the resolver base URL used to build passport carrier (QR) URLs at /// publish. Defaults to `https://id.odal-node.io` when not set. #[must_use] diff --git a/crates/dpp-vault/src/domain/verify/engine.rs b/crates/dpp-vault/src/domain/verify/engine.rs index d4e6220e..6052c173 100644 --- a/crates/dpp-vault/src/domain/verify/engine.rs +++ b/crates/dpp-vault/src/domain/verify/engine.rs @@ -28,7 +28,10 @@ pub enum DossierParseError { /// (the verify endpoints) should use [`verify_dossier_json`] instead — it /// additionally runs the `input_fidelity` check, which needs the original /// raw bytes. -pub fn verify_dossier(dossier: &DossierV1) -> VerificationReport { +pub fn verify_dossier( + dossier: &DossierV1, + seals: Option<&dyn dpp_types::SealInspector>, +) -> VerificationReport { let mut checks = Vec::new(); let issuer_key = dossier @@ -173,6 +176,22 @@ pub fn verify_dossier(dossier: &DossierV1) -> VerificationReport { }, }); + // 8. The seal covers the signature served beside it. + // + // A dossier is self-contained by design: it carries the CAdES *and* the + // compact JWS it should be checked against, so this needs no database, no + // node and no network — which is the point, since whoever opens the file is + // typically neither of the first two. + // + // Note what is **not** trusted: the dossier's own `payloadHash`, nor the + // `binding` the generator wrote into it. Both are recomputed here from + // `signedOverJws`. The generator's claim is evidence of what it believed; + // this check exists to say whether it was right. + checks.push(CheckResult { + name: "qualified_seal".into(), + status: qualified_seal_status(dossier, seals), + }); + VerificationReport { trust_anchor_note: format!( "trust anchored to the dossier's embedded DID-document snapshot dated {}", @@ -188,6 +207,83 @@ pub fn verify_dossier(dossier: &DossierV1) -> VerificationReport { /// bounds at snapshot time is reported as **incomplete** (Absent), not failed — /// so a dossier is never invalidated merely because a component was offline when /// it was assembled. +/// Whether the dossier's seal covers the signature the dossier serves with it. +/// +/// `Absent` rather than `Fail` wherever the question could not be put: a +/// passport with no seal, a caller that supplied no inspector, or bytes this +/// build cannot read. None of those is a finding about the seal, and a dossier +/// marked failed because nobody looked would be worse than one marked unchecked. +fn qualified_seal_status( + dossier: &DossierV1, + seals: Option<&dyn dpp_types::SealInspector>, +) -> CheckStatus { + let Some(section) = &dossier.qualified_seal else { + return CheckStatus::Absent("this passport carries no qualified seal".into()); + }; + let Some(seals) = seals else { + return CheckStatus::Absent( + "no seal reader was supplied, so the seal was not opened".into(), + ); + }; + + let envelope: dpp_domain::seal::SealedEnvelope = + match serde_json::from_value(section.get("seal").cloned().unwrap_or_default()) { + Ok(e) => e, + Err(e) => return CheckStatus::Fail(format!("the seal member is malformed: {e}")), + }; + let Some(jws) = section.get("signedOverJws").and_then(|v| v.as_str()) else { + return CheckStatus::Fail( + "the seal is served without the signature it should cover".into(), + ); + }; + + // Recomputed, never read off the dossier: a `payloadHash` taken on trust + // would let an edited one make a mismatched seal look sound. + let expected = dpp_types::digest_for_jws(jws); + + match seals.binding(&envelope, &expected) { + dpp_types::SealBinding::CoversThisSignature => { + // The signature holds. Whether the certificate behind it did is a + // separate question, and passing without asking it would put a + // `Pass` on a dossier whose seal was made under a certificate its CA + // had already revoked — the one reader of this file who cannot + // check that for themselves is the one it is written for. + // + // Recomputed from the envelope rather than read from the dossier's + // own `certificate` member: a stored finding is the generator's + // word, and this check exists to be independent of it. + let certificate = seals.certificate_standing(&envelope, chrono::Utc::now()); + let status = dpp_types::SealValidationStatus::of( + &dpp_types::SealBinding::CoversThisSignature, + certificate.as_ref(), + ); + match status.indication { + dpp_types::ValidationIndication::TotalFailed => CheckStatus::Fail(format!( + "the seal covers this signature, and its certificate was not valid when the seal was made ({:?}) — ETSI EN 319 102-1 reports this as TOTAL-FAILED, and re-sealing would not help: the replacement would come from the same certificate", + status.sub_indication + )), + // Everything this node checks, checked. Not a statement that the + // seal is qualified: no chain was validated to a trust anchor, + // which is what `TOTAL-PASSED` would need. + dpp_types::ValidationIndication::Indeterminate => CheckStatus::Pass, + } + } + dpp_types::SealBinding::CoversAnotherDigest { covered } => CheckStatus::Fail(format!( + "the seal covers {covered}, not the signature served with it ({expected}) — the \ + passport was most likely re-published after sealing" + )), + dpp_types::SealBinding::NotIntact => CheckStatus::Fail( + "the signature over the seal's own attributes does not verify, so nothing it says \ + about what it covers can be relied on" + .into(), + ), + dpp_types::SealBinding::Unknown => CheckStatus::Absent( + "the seal could not be read — a placeholder, or a format this build does not parse" + .into(), + ), + } +} + fn component_graph_status(report: &serde_json::Value) -> CheckStatus { const TAMPER: [&str; 3] = ["hashMismatch", "cycle", "malformedRef"]; @@ -243,14 +339,17 @@ fn component_graph_status(report: &serde_json::Value) -> CheckStatus { /// /// # Errors /// [`DossierParseError`] — see above. -pub fn verify_dossier_json(bytes: &[u8]) -> Result { +pub fn verify_dossier_json( + bytes: &[u8], + seals: Option<&dyn dpp_types::SealInspector>, +) -> Result { let raw: serde_json::Value = serde_json::from_slice(bytes)?; // Parsed a second time directly from `bytes` rather than `raw.clone()` — // cheaper than deep-cloning an already-parsed `Value` tree, and this // function runs on every dossier verify (both stored and uploaded). let dossier: DossierV1 = serde_json::from_slice(bytes)?; - let mut report = verify_dossier(&dossier); + let mut report = verify_dossier(&dossier, seals); report.checks.push(input_fidelity_check(&raw, &dossier)); Ok(report) } @@ -424,7 +523,7 @@ mod tests { fn clean_dossier_verifies_fully() { let signing_key = SigningKey::from_bytes(&[9u8; 32]); let dossier = valid_dossier(&signing_key); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(report.all_verified(), "{report:?}"); assert_eq!(report.exit_code(), 0); } @@ -448,7 +547,7 @@ mod tests { &signing_key, serde_json::json!({ "verified": true, "nodes": [] }), ); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert_eq!(*by_name(&report, "component_graph"), CheckStatus::Pass); assert_eq!(*by_name(&report, "content_integrity"), CheckStatus::Pass); assert!(report.all_verified()); @@ -464,7 +563,7 @@ mod tests { "nodes": [{ "path": ["u://leaf"], "verified": false, "reason": "hashMismatch" }] }), ); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "component_graph"), CheckStatus::Fail(_) @@ -483,7 +582,7 @@ mod tests { "nodes": [{ "path": ["u://remote"], "verified": false, "reason": "unreachable" }] }), ); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); // Unreachable-at-snapshot is incomplete, not tampered. assert!(matches!( by_name(&report, "component_graph"), @@ -502,7 +601,7 @@ mod tests { ); // Alter the attested report without re-signing the manifest. dossier.component_graph = Some(serde_json::json!({ "verified": false, "nodes": [] })); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "content_integrity"), CheckStatus::Fail(_) @@ -526,7 +625,7 @@ mod tests { let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); dossier.manifest_jws = sign(&signing_key, &manifest_value); assert_eq!( - *by_name(&verify_dossier(&dossier), "content_integrity"), + *by_name(&verify_dossier(&dossier, None), "content_integrity"), CheckStatus::Pass ); @@ -537,7 +636,7 @@ mod tests { "payloadHash": "ab".repeat(32), })); assert!(matches!( - by_name(&verify_dossier(&dossier), "content_integrity"), + by_name(&verify_dossier(&dossier, None), "content_integrity"), CheckStatus::Fail(_) )); } @@ -547,7 +646,7 @@ mod tests { let signing_key = SigningKey::from_bytes(&[9u8; 32]); let mut dossier = valid_dossier(&signing_key); dossier.full_view.payload["status"] = serde_json::json!("draft"); // tamper, jws not re-signed - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "full_view_signature"), @@ -570,7 +669,7 @@ mod tests { let signing_key = SigningKey::from_bytes(&[9u8; 32]); let mut dossier = valid_dossier(&signing_key); dossier.public_view.jws = format!("{}x", dossier.public_view.jws); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "public_view_signature"), @@ -592,7 +691,7 @@ mod tests { let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); dossier.manifest_jws = sign(&signing_key, &manifest_value); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "audit_chain"), CheckStatus::Fail(_) @@ -605,7 +704,7 @@ mod tests { fn absent_checkpoint_and_receipts_are_informational_not_failures() { let signing_key = SigningKey::from_bytes(&[9u8; 32]); let dossier = valid_dossier(&signing_key); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "checkpoint"), CheckStatus::Absent(_) @@ -679,7 +778,7 @@ mod tests { dossier.manifest_jws = sign(&signing_key, &manifest_value); // Sanity: clean chain verifies before we tamper it. - let clean_report = verify_dossier(&dossier); + let clean_report = verify_dossier(&dossier, None); assert!(clean_report.all_verified(), "{clean_report:?}"); // Tamper the outgoing operator's signature, then re-sign the manifest so @@ -701,7 +800,7 @@ mod tests { let manifest_value = serde_json::to_value(&dossier.manifest).unwrap(); dossier.manifest_jws = sign(&signing_key, &manifest_value); - let report = verify_dossier(&dossier); + let report = verify_dossier(&dossier, None); assert!(matches!( by_name(&report, "transfer_chain"), CheckStatus::Fail(_) @@ -721,7 +820,7 @@ mod tests { #[test] fn clean_json_round_trips_through_verify_dossier_json() { let bytes = valid_dossier_bytes(); - let report = verify_dossier_json(&bytes).expect("parses"); + let report = verify_dossier_json(&bytes, None).expect("parses"); assert!(report.all_verified(), "{report:?}"); assert_eq!(*by_name(&report, "input_fidelity"), CheckStatus::Pass); } @@ -733,7 +832,7 @@ mod tests { value["notARealField"] = serde_json::json!("sneaky"); let bytes = serde_json::to_vec(&value).unwrap(); - let err = verify_dossier_json(&bytes) + let err = verify_dossier_json(&bytes, None) .expect_err("unknown field must be a hard parse error, not a report"); assert!(matches!(err, DossierParseError::Json(_))); } @@ -790,7 +889,7 @@ mod tests { serde_json::json!("approved"); let bytes = serde_json::to_vec(&value).unwrap(); - let report = verify_dossier_json(&bytes) + let report = verify_dossier_json(&bytes, None) .expect("TransferRecord tolerates unknown fields, so this must parse"); assert!(matches!( by_name(&report, "input_fidelity"), @@ -801,7 +900,7 @@ mod tests { #[test] fn malformed_json_is_a_parse_error() { - let err = verify_dossier_json(b"not json").unwrap_err(); + let err = verify_dossier_json(b"not json", None).unwrap_err(); assert!(matches!(err, DossierParseError::Json(_))); } @@ -814,7 +913,7 @@ mod tests { fn verify_dossier_json_never_panics( bytes in proptest::collection::vec(any::(), 0..1024) ) { - let _ = verify_dossier_json(&bytes); + let _ = verify_dossier_json(&bytes, None); } } } diff --git a/crates/dpp-vault/src/handlers/evidence.rs b/crates/dpp-vault/src/handlers/evidence.rs index e2d88225..a095dc68 100644 --- a/crates/dpp-vault/src/handlers/evidence.rs +++ b/crates/dpp-vault/src/handlers/evidence.rs @@ -111,11 +111,15 @@ pub async fn verify_evidence_handler( /// `POST /api/v1/evidence/verify` — verify an uploaded dossier document. pub async fn verify_document_handler( - State(_state): State, + State(state): State, Extension(_auth): Extension, body: Bytes, ) -> impl IntoResponse { - match verify_dossier_json(&body) { + // The uploaded file is self-contained — it carries the seal and the + // signature that seal should cover — so this check needs nothing from the + // node but the ability to read CAdES. A deployment without a seal reader + // reports the check absent rather than failing it. + match verify_dossier_json(&body, state.service.seal_inspector.as_deref()) { Ok(report) => (StatusCode::OK, Json(report)).into_response(), Err(e) => api_error( StatusCode::UNPROCESSABLE_ENTITY, diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index 7f0dc4ec..15826efd 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -39,7 +39,7 @@ use serde::Serialize; use crate::domain::service::seal::seal_digest; use crate::{middleware::auth::AuthContext, state::AppState}; -use super::error::{internal_error, not_found_error, parse_passport_id}; +use super::error::{internal_error, not_found_error, parse_passport_id, validation_error}; /// Who declared the content a seal covers, which is not who sealed it. /// @@ -113,6 +113,61 @@ pub struct SealResponse { /// /// `null` when the seal predates extraction or could not be parsed. pub signing_cert_ref: Option, + /// **When a timestamp authority attests the seal was made.** + /// + /// `sealedAt` above is this node's own clock and an unattested claim by the + /// party that bought the seal. This is a third party's statement, read out + /// of the time-stamp token in `sealValue` — which the documentation for that + /// field has always said was the only place an attested time can be, and + /// which nothing read until now. + /// + /// Checked, not merely read. The token's own signature is verified, and its + /// imprint is matched against this seal's signature: the attribute carrying + /// it is *unsigned*, so a genuine token lifted from another seal would + /// otherwise be accepted and would report someone else's time as this one's. + /// + /// `null` for a `B-B` seal, which carries no token, and for a token that + /// failed either check. + /// + /// **Attested is not trusted.** Art. 42 makes a qualified time stamp a + /// QTSP's service and Art. 41(2) attaches the presumption of accuracy to + /// that; establishing it is a Trusted List question about the `TSA/QTST` + /// service type, which this node cannot yet ask. A self-signed authority's + /// token verifies perfectly and means nothing — which is exactly what the + /// local development backend produces. + pub attested_sealed_at: Option>, + /// The baseline level this node **asked** for, recorded on the envelope. + /// + /// `null` for a seal stored before the field existed. A record of intent — + /// read `evidencedLevel` for what actually arrived. + pub conformance_level: Option, + /// The baseline level the seal's **bytes** carry. + /// + /// The pair is the point. A provider enabled for a weaker profile than was + /// paid for returns a seal that is correct in every record this node keeps + /// and stops verifying when its signing certificate expires — years later, + /// on a passport that is retention-locked and cannot be re-sealed. The drain + /// logs that mismatch when it happens; serving both here makes it answerable + /// afterwards, from the seal rather than from a log nobody kept. + /// + /// A floor, not a conformance verdict: it reports that the distinguishing + /// material for a level is present, never that the material was validated. + /// `null` when the bytes could not be read. + pub evidenced_level: Option, + /// Whether the seal's archival protection is still live. + /// + /// `evidencedLevel` reports `baseline-lta` from the *presence* of the + /// archival material, and is right to — the material is there. This reports + /// whether it still means anything: an archival timestamp's own authority + /// certificate expires, and ETSI's long-term profiles expect re-timestamping + /// before it does. Nothing here renews, so without this a seal whose + /// archival protection lapsed years ago reads exactly as it did the day it + /// was bought. + /// + /// **A signal, not a verdict.** `current` carries the renewal date and + /// applies no threshold: a seal nearing that date still verifies, and that + /// window is the only chance to renew without an outage. + pub archival: dpp_types::ArchivalFreshness, /// True when this is a `GhostSeal` placeholder with no legal validity. pub placeholder: bool, /// The passport's **current** compact JWS. @@ -130,17 +185,105 @@ pub struct SealResponse { /// the CAdES actually covers. The two agreeing is the cross-check. pub sealed_payload_hash: Option, - /// Whether the stored seal covers the passport's current signature. + /// Whether the stored seal covers the passport's current signature, + /// **according to this node's own outbox records**. + /// + /// Read `binding` beside it. This field answers from the row that bought the + /// seal; that one answers from the seal's own bytes. They are the same + /// question asked of two different sources, and where they disagree the + /// disagreement is the finding — the records and the bytes are describing + /// different things, which neither source could have revealed alone. pub coverage: SealCoverage, + /// Whether the seal's **own bytes** say it covers this passport's current + /// signature. + /// + /// A detached CAdES states what it covers in exactly one place — the + /// `messageDigest` signed attribute, inside the signature — and this reports + /// what that attribute says, after checking the signature over it. It is the + /// strongest statement this node can make without an external validator: + /// whatever is true of the seal's *trust*, `coversThisSignature` means it is + /// demonstrably a seal over this passport and not over anything else. + /// + /// It is deliberately not folded into `coverage`. That field survives a seal + /// that will not parse and needs no cryptography; this one is evidence and + /// needs both. Collapsing them would lose the case that matters most — a + /// seal with no outbox row, where the records can say nothing and the bytes + /// can say everything. + pub binding: dpp_types::SealBinding, + /// The field above, restated in ETSI EN 319 102-1's vocabulary. + /// + /// Derived from `binding` and adding no checking — a translation for readers + /// whose validation tooling speaks that vocabulary, which is the one CIR + /// (EU) 2025/1945 points at for qualified seals. + /// + /// **It never says `totalPassed`, and cannot.** That indication requires the + /// signer's certificate constraints to have been positively validated, and + /// this node validates no certificate — so a seal that is demonstrably over + /// this signature reports `indeterminate`: nothing has failed, and not + /// everything has been checked. Reading that as a defect would be a + /// misreading; reading `coversThisSignature` as a validation pass was the + /// misreading this field exists to prevent. + pub validation: dpp_types::SealValidationStatus, + /// **Was the signing certificate valid when the seal was made?** + /// + /// The second limb of Reg. (EU) No 910/2014 Art. 32(1)(b), reached for seals + /// by Art. 40 — the first being whether a qualified provider issued it, + /// which is a Trusted List question this route does not ask. + /// + /// Two answers in one: where the sealing moment falls in the certificate's + /// validity window, and what the seal's own revocation material says. The + /// moment itself travels with them, because the whole verdict turns on it — + /// an attested time makes an out-of-window certificate a failure, and an + /// unattested one leaves it merely unproven, since certificates expire and + /// sealed passports outlive them. + /// + /// **Revocation is read from the seal, never fetched.** A CRL distribution + /// point is a URL inside a certificate an operator was handed. The long-term + /// profiles carry the material for exactly this reason, so a `B-LT` or + /// `B-LTA` seal can be answered and a `B-B` one reports that it could not + /// ask. + /// + /// `null` when the seal could not be read. + pub certificate: Option, + /// What **this seal's own certificate** says about who issued it. + /// + /// The first question a reader has and the one nothing here could answer + /// before: did a provider issue the certificate behind this seal, or did the + /// node sign it itself? `selfIssued: true` means the seal attests that a key + /// this node holds signed a digest, and nothing more — no legal weight, and + /// no Trusted List would give it any. + /// + /// Read from the stored bytes, not from configuration, because those are + /// different facts. A seal made before the backend was changed, or restored + /// from a backup, was not produced by whatever is configured now — see + /// `trustMode` on `GET /api/v1/seal`, which answers the other question. + /// + /// `null` means **not read**: a placeholder seal, a format this node does not + /// parse, unreadable bytes, or a deployment with no inspector wired. Never + /// read it as "not self-issued" — that is a finding and only comes from a + /// certificate that was actually examined. + /// + /// **Not a qualification verdict.** Establishing that a seal is qualified + /// needs the issuer matched against an EU Trusted List *and* the issuer's + /// signature over this certificate verified. Neither is done here, and + /// `selfIssued: false` says only that some name other than the subject's + /// appears in the issuer field. + pub origin: Option, /// Stated, not implied: this node did not cryptographically validate the /// CAdES, and says so rather than letting the response read as a verdict. pub verification: &'static str, } -const NOT_VALIDATED: &str = "not validated by this node — a detached CAdES must be checked by an independent AdES \ - validator against the EU Trusted List. `coverage` answers a narrower question from this \ - node's own records and is not a substitute: it reports which digest was requested, while \ - only the validator establishes which digest the CAdES actually covers. Compare the two."; +const NOT_VALIDATED: &str = "not validated by this node — a full verdict needs an independent AdES validator against \ + the EU Trusted List. What *is* checked is in the fields beside this one: `binding` opens the \ + CAdES and reports the digest it covers, after verifying the signature over the attribute \ + naming it; `certificate` reports the signing certificate's validity window and whatever \ + revocation material the seal carries; `validation` restates both in ETSI EN 319 102-1's \ + terms. What is not: no certificate path is built to a trust anchor, no validation policy is \ + applied, and whether the issuer is a qualified provider is a Trusted List question asked \ + elsewhere — which is why `totalPassed` is unreachable here by construction. `coverage` is \ + weaker again, reporting which digest this node's own records say was requested; compare it \ + with `binding`, because the two can disagree."; /// Whether the stored seal covers the passport's current signature. /// @@ -223,6 +366,21 @@ pub async fn seal_handler( None => None, }; let coverage = coverage_of(sealed_payload_hash.as_deref(), &payload_hash); + // Read once and reused: `validation` is a restatement of `binding`, and two + // separate calls could in principle disagree — which would put a response on + // the wire contradicting itself in two fields that must mean the same thing. + let binding = state + .service + .seal_inspector + .as_ref() + .map_or(dpp_types::SealBinding::Unknown, |i| { + i.binding(seal, &payload_hash) + }); + let certificate = state + .service + .seal_inspector + .as_ref() + .and_then(|i| i.certificate_standing(seal, chrono::Utc::now())); // Has responsibility moved since this passport was sealed? Only a *completed* // handover counts: an initiated one that nobody accepted has moved nothing, @@ -260,17 +418,252 @@ pub async fn seal_handler( seal_value: seal.seal_value.clone(), sealed_at: seal.sealed_at, signing_cert_ref: seal.signing_cert_ref.clone(), + attested_sealed_at: state + .service + .seal_inspector + .as_ref() + .and_then(|i| i.attested_sealing_time(seal)), + archival: state + .service + .seal_inspector + .as_ref() + .map_or(dpp_types::ArchivalFreshness::Unknown, |i| { + i.archival_freshness(seal, chrono::Utc::now()) + }), + conformance_level: seal.conformance_level, + evidenced_level: state + .service + .seal_inspector + .as_ref() + .and_then(|i| i.evidenced_level(seal)), placeholder: seal.placeholder, current_jws: jws, - current_payload_hash: payload_hash, + current_payload_hash: payload_hash.clone(), sealed_payload_hash, coverage, + origin: state + .service + .seal_inspector + .as_ref() + .and_then(|i| i.origin(seal)), + binding: binding.clone(), + validation: dpp_types::SealValidationStatus::of(&binding, certificate.as_ref()), + certificate, verification: NOT_VALIDATED, }), ) .into_response() } +/// What a repair did. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SealRepairAction { + /// A `sealed` row was re-armed — the one path that buys a second seal for a + /// digest already paid for, justified because the first is worthless. + Rearmed, + /// A row was queued the ordinary way. The passport was re-published since + /// the broken seal was made, so the signature now needing a seal has never + /// been sealed and nothing is being re-bought. + Queued, +} + +/// The outcome of repairing one passport's seal. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SealRepairResponse { + /// What happened. + pub action: SealRepairAction, + /// The digest the replacement seal will cover — the passport's **current** + /// signature, not whatever the broken seal covered. + pub payload_hash: String, + /// Stated rather than implied, like every other note on this surface. + pub note: &'static str, +} + +const REPAIR_NOTE: &str = "a replacement seal has been queued; the node's drain will buy it from the \ + configured backend and overwrite the broken one. This costs a seal — the row was already paid \ + for once, and buying a second is justified only because the first does not verify. Nothing is \ + re-bought where the passport was re-published since, because that signature has never been \ + sealed."; + +/// `POST /api/v1/dpp/{dppId}/seal/repair` — re-seal a passport whose stored seal +/// does not verify. +/// +/// # Why this is a route and not a sweep +/// +/// The repair sweep carries a guarantee stated in its own code: it cannot +/// double-bill, because it only queues passports carrying **no seal at all**. +/// Repairing a broken seal breaks exactly that — the row was paid for, and +/// buying a second seal is justified only because the first is worthless. That +/// is a decision for whoever pays, taken per passport, not one for a background +/// loop to take on their behalf on the strength of a check that has not yet met +/// a real provider's seal. +/// +/// # It refuses unless the seal is demonstrably broken +/// +/// The guard that makes crossing that line safe, and the reason this cannot be +/// driven from a stale list: the seal is opened and checked **now**, at the +/// moment of the request. A sound seal, a superseded one, and one this node +/// cannot read are all refused with `422`, because none of them establishes that +/// what is stored is worthless. +/// +/// # Not idempotency-keyed, and why +/// +/// A seal row is keyed by `(passport_id, payload_hash)`, so a retried request +/// re-arms a row that is already `pending` — which is a no-op, since the re-arm +/// only moves `sealed` rows. The natural key already provides what a key would, +/// and a second repair after the replacement lands is refused by the check +/// above, because the new seal verifies. +/// +/// `404` when the passport does not exist. `422` when it exists and is not in a +/// state this repairs — the same split the transfer routes draw. +pub async fn seal_repair_handler( + State(state): State, + Extension(auth): Extension, + Path(dpp_id): Path, +) -> impl IntoResponse { + if let Some(resp) = super::error::require_admin(&auth) { + return resp; + } + let passport_id = match parse_passport_id(&dpp_id) { + Ok(id) => id, + Err(resp) => return resp, + }; + let passport = match state.service.find_by_id(passport_id).await { + Ok(p) => p, + Err(dpp_domain::DppError::NotFound(_)) => return not_found_error("DPP not found."), + Err(e) => return internal_error(e), + }; + + let Some(seal) = passport.seal.as_ref() else { + return validation_error( + "This passport carries no seal, so there is nothing to repair. A published passport \ + with no seal is queued by the node's own sweep, which costs nothing extra because \ + no seal was ever bought for it.", + ); + }; + let Some(outbox) = state.service.seal_outbox.as_ref() else { + // Queueing here would create a row nothing consumes and answer "repaired" + // to an operator for whom nothing will happen. + return validation_error( + "This node has no sealing backend configured, so a queued repair would never drain. \ + Configure SEAL_PROVIDER before repairing.", + ); + }; + let Some(inspector) = state.service.seal_inspector.as_ref() else { + return validation_error( + "This node cannot read seals, so it cannot establish that this one is broken. \ + Repairing on an unchecked seal would buy a second seal for an artifact that may be \ + perfectly sound.", + ); + }; + + // Not `unwrap_or_default()`, which the read route above can afford and this + // one cannot: an empty digest here would be written onto a queue row and the + // drain would go and buy a seal over nothing. A sealed passport with no + // signature should be unreachable — the seal is applied to the signature — + // so this is a refusal rather than a repair. + let Some(payload_hash) = seal_digest(&passport) else { + return validation_error( + "This passport carries a seal but no signature, so there is no digest for a \ + replacement to cover. That combination should not occur; repairing it would queue a \ + seal over nothing.", + ); + }; + + // Checked now, not read from the audit's list. A stale finding would buy a + // seal for a passport that has since been repaired or re-published. + match inspector.binding(seal, &payload_hash) { + dpp_types::SealBinding::NotIntact => {} + dpp_types::SealBinding::CoversThisSignature => { + // "Nothing to repair" is right, and on its own it is misleading for + // one case: a seal whose signature holds and whose *certificate* had + // been revoked or had expired when it was made. Something is wrong + // there, repair cannot fix it — a replacement would come from the + // same certificate — and an operator sent away with "this seal + // verifies" would not learn either fact. + let certificate = inspector.certificate_standing(seal, chrono::Utc::now()); + let status = dpp_types::SealValidationStatus::of( + &dpp_types::SealBinding::CoversThisSignature, + certificate.as_ref(), + ); + if status.indication == dpp_types::ValidationIndication::TotalFailed { + return validation_error( + "This seal verifies, and the certificate that made it was not valid at the \ + time — revoked, or outside its validity window, with an attested time to \ + prove it. Re-sealing does not repair that: the replacement would come from \ + the same certificate. The passport needs a seal from a credential that was \ + valid, which is a provider question rather than a queue one.", + ); + } + return validation_error( + "This seal verifies and covers this passport's current signature. There is \ + nothing to repair, and re-sealing would buy a second seal for a sound one.", + ); + } + dpp_types::SealBinding::CoversAnotherDigest { .. } => { + return validation_error( + "This seal is intact and covers a different signature — the passport was \ + re-published after it was made. The signature now needing a seal has its own \ + queue row from that re-publish; there is nothing here to repair.", + ); + } + dpp_types::SealBinding::Unknown => { + return validation_error( + "This seal could not be read, so it cannot be shown to be broken. An unreadable \ + seal is not the same as a worthless one — it may be a format this node does not \ + parse.", + ); + } + } + + // Which path applies turns on whether a `sealed` row still holds the current + // digest. If the passport was re-published since, the digest now needing a + // seal has never been sealed, so this is an ordinary queue and buys nothing + // twice. + let already_sealed = match outbox.sealed_digest(passport_id).await { + Ok(d) => d.as_deref() == Some(payload_hash.as_str()), + Err(e) => return internal_error(e), + }; + + let action = if already_sealed { + let reason = format!("repaired by {}: stored seal did not verify", auth.user_id); + match outbox + .rearm_sealed(passport_id, &payload_hash, &reason) + .await + { + Ok(true) => SealRepairAction::Rearmed, + // Lost a race with a concurrent repair, or the row moved. Either way + // a repair is in flight, which is what was asked for. + Ok(false) => SealRepairAction::Queued, + Err(e) => return internal_error(e), + } + } else { + if let Err(e) = outbox.enqueue(passport_id, &payload_hash).await { + return internal_error(e); + } + SealRepairAction::Queued + }; + + tracing::warn!( + passport_id = %passport_id, + actor = %auth.user_id, + ?action, + "seal repair queued — a second seal will be bought for this passport" + ); + + ( + StatusCode::OK, + Json(SealRepairResponse { + action, + payload_hash, + note: REPAIR_NOTE, + }), + ) + .into_response() +} + /// Operator-wide sealing state. /// /// `unsealedPublished` is the headline and the other three are context, not the @@ -295,6 +688,56 @@ pub struct SealSummaryResponse { /// nothing outstanding. Stated so a reader cannot mistake "not sealing" for /// "all sealed". pub sealing_configured: bool, + /// The tier the **currently configured** sealing backend resolved to: + /// `ghost`, `sandbox` or `live`. + /// + /// The counts above say how much sealing is outstanding. This says whether + /// the sealing that *does* happen is worth anything — a node can sit at + /// `unsealedPublished: 0` while every one of those seals was signed by a key + /// it generated itself, and no count would show it. + /// + /// **A different question from the per-passport `origin`, and neither + /// substitutes for the other.** This describes the backend running now; that + /// describes the certificate inside one stored seal. A node moved from the + /// local backend to a QTSP last week reports `live` here and + /// `selfIssued: true` on everything sealed before the move, and both are + /// correct. + /// + /// `null` on a deployment that resolved no seal port at all — the standalone + /// vault, which has no composition root. That is **not** `ghost`: a port + /// nobody wired and a port that landed on a placeholder are different states, + /// and only the second blocks a production boot. + pub trust_mode: Option<&'static str>, + /// What the last completed pass over every stored seal found. + /// + /// The counts above describe **outbox rows** and passports carrying *no* + /// seal. This describes seals that exist and do not stand up — a condition + /// neither of those can see, because both ask the database whether the seal + /// member is absent and a worthless seal is present. + /// + /// `null` means **no pass has completed**, not that nothing is wrong. A pass + /// walks the estate in bounded batches and starts over, so this is empty for + /// a while after a restart and its `completedAt` is hours old by construction + /// on a large deployment. Reporting a zero here for a check that has not run + /// would be the one answer worse than reporting nothing. + pub audit: Option, +} + +/// The port name the composition root files the sealing backend under. +/// +/// A literal because the trust report keys on `&'static str` names chosen at the +/// composition root, and this crate cannot see that module. A rename there would +/// silently yield `null` here, which +/// `the_seal_port_name_matches_what_the_node_registers` catches instead. +pub const SEAL_TRUST_PORT: &str = "seal"; + +/// The tier the configured sealing backend resolved to, if one was resolved. +fn seal_trust_mode(state: &AppState) -> Option<&'static str> { + state + .trust + .as_ref() + .and_then(|t| t.mode_of(SEAL_TRUST_PORT)) + .map(|m| m.as_str()) } /// `GET /api/v1/seal` — operator-wide sealing state. @@ -315,6 +758,11 @@ pub async fn seal_summary_handler( sealed: 0, exhausted: 0, sealing_configured: false, + trust_mode: seal_trust_mode(&state), + // Reported even with no outbox: a node that has stopped sealing + // still holds the seals it bought, and those are exactly the + // ones nothing else is watching. + audit: state.seal_audit.as_ref().and_then(|a| a.last()), }), ) .into_response(); @@ -337,6 +785,8 @@ pub async fn seal_summary_handler( sealed: counts.sealed, exhausted: counts.exhausted, sealing_configured: true, + trust_mode: seal_trust_mode(&state), + audit: state.seal_audit.as_ref().and_then(|a| a.last()), }), ) .into_response() @@ -371,6 +821,60 @@ mod tests { assert_eq!(coverage_of(None, A), SealCoverage::Unknown); } + /// The origin's wire shape is a published contract, camelCase included. + #[test] + fn origin_serialises_to_the_documented_shape() { + let origin = dpp_types::SealOrigin { + subject: "CN=a".to_owned(), + issuer: "CN=b".to_owned(), + self_issued: false, + creation_device: dpp_types::CreationDevice::DeclaresQualifiedDevice, + }; + let j = serde_json::to_value(&origin).expect("serialise"); + assert_eq!(j["subject"], "CN=a"); + assert_eq!(j["issuer"], "CN=b"); + assert_eq!(j["selfIssued"], false); + assert_eq!(j["creationDevice"], "declaresQualifiedDevice"); + } + + /// Every creation-device value has a stable wire name. + /// + /// Enumerated rather than spot-checked because the two negative ones are the + /// pair most easily confused, and a reader who cannot tell them apart loses + /// the distinction between *this certificate is not a qualified certificate* + /// and *it is one, whose key is not in a qualified device*. + #[test] + fn every_creation_device_value_has_a_stable_wire_name() { + use dpp_types::CreationDevice as D; + let rendered = |d: D| serde_json::to_string(&d).expect("serialise"); + assert_eq!( + rendered(D::DeclaresQualifiedDevice), + "\"declaresQualifiedDevice\"" + ); + assert_eq!(rendered(D::NoQualifiedDevice), "\"noQualifiedDevice\""); + assert_eq!( + rendered(D::NotAQualifiedCertificate), + "\"notAQualifiedCertificate\"" + ); + } + + /// An absent origin serialises as `null`, never as a defaulted finding. + /// + /// The failure this guards is a `#[serde(skip_serializing_if)]` or a + /// `#[serde(default)]` added later for tidiness: either would turn "we could + /// not read this seal" into a field that looks like it was read, and + /// `selfIssued` would then be absent rather than unknown. `null` is the + /// answer the route documents, so it has to actually appear. + #[test] + fn an_unread_origin_is_null_rather_than_omitted_or_defaulted() { + let j = serde_json::to_value(serde_json::json!({ + "origin": Option::::None, + })) + .expect("serialise"); + assert!(j.get("origin").is_some(), "the key must be present"); + assert!(j["origin"].is_null(), "and its value must be null"); + } + /// The wire values are part of the published contract. #[test] fn coverage_serialises_to_the_documented_strings() { diff --git a/crates/dpp-vault/src/main.rs b/crates/dpp-vault/src/main.rs index c17a8ade..30c7e27f 100644 --- a/crates/dpp-vault/src/main.rs +++ b/crates/dpp-vault/src/main.rs @@ -181,6 +181,9 @@ async fn main() -> anyhow::Result<()> { // No composition root here resolves trust ports or a ruleset, so this // binary reports neither rather than inventing a posture for them. trust: None, + // The standalone vault has no composition root, so it spawns no audit + // and reports none — rather than reporting a clean one it never ran. + seal_audit: None, ruleset_admin: None, // The standalone vault has a database, so it *could* carry keys — but // nothing here spawns the purge task the fused node runs, and a store diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index 1eeb6aca..9134a3ec 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -52,7 +52,7 @@ use crate::{ registry_status::{passport_registry_handler, registry_rollup_handler}, ruleset::reload_ruleset_handler, scan_ingest::{scan_ingest_handler, scan_ingest_mtls}, - seal::{seal_handler, seal_summary_handler}, + seal::{seal_handler, seal_repair_handler, seal_summary_handler}, stats::{operator_stats_handler, passport_stats_handler}, supersede::supersede_handler, suspend::suspend_handler, @@ -128,6 +128,11 @@ pub fn build(state: AppState) -> Router { // every audience view — it covers the full-payload signature, so it // verifies against no redaction. .route("/dpp/{dppId}/seal", get(seal_handler)) + // Re-seal a passport whose stored seal does not verify. Admin, and it + // refuses unless the seal is demonstrably broken — it buys a second + // seal for a digest already paid for, which is only right when the + // first is worthless. + .route("/dpp/{dppId}/seal/repair", post(seal_repair_handler)) // The operator-wide counterpart: the per-passport route cannot answer // "is anything unsealed" without already knowing which passport to ask. .route("/seal", get(seal_summary_handler)) diff --git a/crates/dpp-vault/src/state.rs b/crates/dpp-vault/src/state.rs index 581f2592..78424f51 100644 --- a/crates/dpp-vault/src/state.rs +++ b/crates/dpp-vault/src/state.rs @@ -125,6 +125,14 @@ pub struct AppState { /// `None` for the standalone vault binary, which has no composition root to /// resolve trust ports. pub trust: Option>, + /// The last completed pass over every stored seal. + /// + /// `None` on a deployment that runs no audit — the standalone vault, which + /// has no composition root to spawn one. Distinct from "a pass ran and found + /// nothing", which is the report itself being present with `broken: 0`, and + /// the difference is the whole point: a surface that reports zero for a check + /// nobody ran is worse than one that reports nothing. + pub seal_audit: Option>, /// The node's signed Compliance Current channel: the version in force /// (reported alongside `trust`) and the reload the admin route triggers. /// diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index eabec30f..5061a02b 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -216,12 +216,44 @@ impl IdentityPort for MockIdentity { // Vault Axum app factory // --------------------------------------------------------------------------- +/// How a harness wires the two seal ports. +/// +/// Both are optional in production — a node with no provider selected links +/// neither — and the handlers behave differently for each combination, so the +/// tests that care must be able to say which node they are talking to. +pub struct SealWiring { + /// The adapter that reads seals. `None` is a node that stores seals and + /// cannot open them, which is the shipped default: the crate that parses + /// CAdES is resolved by the composition root, never linked by the vault. + pub inspector: Option>, + /// Whether the outbox is wired at all. `false` is a node with no sealing + /// backend, where anything queued would sit forever. + pub outbox: bool, +} + +impl Default for SealWiring { + fn default() -> Self { + // Mirrors every other harness default: an outbox, as any node with a + // provider has, and no inspector, as any node without the seal crate. + Self { + inspector: None, + outbox: true, + } + } +} + pub async fn start_vault(dal: PgDal) -> String { - start_vault_with_identity(dal, Arc::new(MockIdentity), None).await + start_vault_with_identity(dal, Arc::new(MockIdentity), None, SealWiring::default()).await } pub async fn start_vault_failing_signer(dal: PgDal) -> String { - start_vault_with_identity(dal, Arc::new(FailingIdentity), None).await + start_vault_with_identity(dal, Arc::new(FailingIdentity), None, SealWiring::default()).await +} + +/// A vault whose seal ports are wired to order — for the routes whose answers +/// turn on whether this node can read a seal, or buy one. +pub async fn start_vault_with_seal(dal: PgDal, seal: SealWiring) -> String { + start_vault_with_identity(dal, Arc::new(MockIdentity), None, seal).await } /// A vault with the credential path **configured** — the audience-scoped route @@ -231,7 +263,13 @@ pub async fn start_vault_with_credentials( directory: Arc, trust: Arc, ) -> String { - start_vault_with_identity(dal, Arc::new(MockIdentity), Some((directory, trust))).await + start_vault_with_identity( + dal, + Arc::new(MockIdentity), + Some((directory, trust)), + SealWiring::default(), + ) + .await } type CredentialWiring = ( @@ -243,6 +281,7 @@ async fn start_vault_with_identity( dal: PgDal, identity: Arc, credentials: Option, + seal: SealWiring, ) -> String { struct PgPing(PgDal); #[async_trait] @@ -266,39 +305,47 @@ async fn start_vault_with_identity( // Mirror production: the registry reader stamps the default facility + primary // operator identifier onto new passports, read live from the operator config. - let service = Arc::new( - PassportService::new( - passport_repo, - identity, - compliance, - audit_repo, - event_bus, - registry_sync, - Arc::new(GhostArchive), - OperatorIdentity { - legal_name: "Test Operator GmbH".to_owned(), - country: "DE".to_owned(), - }, - ) - .with_registry_reader(operator_repo.clone()) - // Mirror production here too: the node wires both registry outboxes, so - // a harness without them exercises a publish path that does not exist in - // any real deployment — and reports the registry surface as - // unconfigured. - .with_registry_outbox(Arc::new(PgRegistrySyncRepo::new(dal.clone()))) - .with_transfer_outbox(Arc::new(PgRegistryTransferRepo::new(dal.clone()))) - // Same reasoning as the two above: any node with a seal provider - // selected wires this, so a harness without it reports the sealing - // surface as unconfigured and cannot exercise it at all. - .with_seal_outbox(Arc::new(PgSealOutboxRepo::new(dal.clone()))) - // Same reasoning again, and it had already cost something: without a - // transfer store the seal route's `responsibilityMayHaveTransferred` - // took its `None => false` branch, so a test asserting the flag was - // `false` passed because nothing *could* be recorded rather than - // because nothing was. - .with_transfer_store(Arc::new(PgTransferRepo::new(dal.clone()))) - .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))), - ); + let mut service = PassportService::new( + passport_repo, + identity, + compliance, + audit_repo, + event_bus, + registry_sync, + Arc::new(GhostArchive), + OperatorIdentity { + legal_name: "Test Operator GmbH".to_owned(), + country: "DE".to_owned(), + }, + ) + .with_registry_reader(operator_repo.clone()) + // Mirror production here too: the node wires both registry outboxes, so + // a harness without them exercises a publish path that does not exist in + // any real deployment — and reports the registry surface as + // unconfigured. + .with_registry_outbox(Arc::new(PgRegistrySyncRepo::new(dal.clone()))) + .with_transfer_outbox(Arc::new(PgRegistryTransferRepo::new(dal.clone()))) + // Same reasoning again, and it had already cost something: without a + // transfer store the seal route's `responsibilityMayHaveTransferred` + // took its `None => false` branch, so a test asserting the flag was + // `false` passed because nothing *could* be recorded rather than + // because nothing was. + .with_transfer_store(Arc::new(PgTransferRepo::new(dal.clone()))) + .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))); + + // Wired after the chain rather than inside it, because unlike every port + // above these two are what a test is *about* when it is about sealing: any + // node with a seal provider selected wires the outbox, so a harness without + // it reports the sealing surface as unconfigured and cannot exercise it at + // all — which is exactly the node some of these tests need to talk to. + if seal.outbox { + service = service.with_seal_outbox(Arc::new(PgSealOutboxRepo::new(dal.clone()))); + } + if let Some(inspector) = seal.inspector { + service = service.with_seal_inspector(inspector); + } + let service = Arc::new(service); + let operator_service = Arc::new(OperatorService::new(operator_repo)); let api_key_service = Arc::new(ApiKeyService::new(api_key_repo)); let registry_identity_service = Arc::new(RegistryIdentityService::new(Arc::new( @@ -333,6 +380,9 @@ async fn start_vault_with_identity( // These tests exercise the vault in isolation, with no composition // root resolving trust ports or a ruleset. trust: None, + // No audit task in a test harness, and so no report — which is the + // honest answer rather than a clean one nothing produced. + seal_audit: None, ruleset_admin: None, // Off by default: these suites exercise handlers, not retry semantics. // `dpp-common`'s `idempotency_flow` suite drives the middleware itself. diff --git a/crates/dpp-vault/tests/seal_repair_route.rs b/crates/dpp-vault/tests/seal_repair_route.rs new file mode 100644 index 00000000..3c5eebcf --- /dev/null +++ b/crates/dpp-vault/tests/seal_repair_route.rs @@ -0,0 +1,670 @@ +//! `POST /api/v1/dpp/{dppId}/seal/repair` over real HTTP. +//! +//! This is the only route in the vault that **spends money**: it re-arms a row +//! whose seal was already bought, so the node's drain buys a second one. Every +//! other sealing path is explicitly built not to — `enqueue` re-arms `exhausted` +//! rows only, and the unsealed sweep queues passports carrying no seal at all — +//! and each of those carries its reasoning in its own code. +//! +//! So what is asserted here is mostly **refusals**. The full loop (corrupt a +//! stored seal, find it, repair it, watch the replacement bind) is proven in the +//! node's simulation against real CAdES bytes; what that suite cannot reach is +//! the handler's judgement about *when* the line may be crossed, because it +//! never has a broken seal and a sound one to tell apart. Each test below is one +//! state that looks repairable and is not. +//! +//! The inspector is scripted rather than real, and deliberately: the vault must +//! not link the crate that parses CAdES — it depends on the *question*, not on +//! whichever adapter answers it. Every branch of this handler is chosen by that +//! one verdict, and the adapter that produces it for real bytes is proven where +//! it lives. + +#![cfg(feature = "integration-tests")] + +mod helpers; +use helpers::{ + SealWiring, TestClient, make_jwt, make_jwt_scoped, start_postgres, start_vault, + start_vault_with_seal, +}; + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use dpp_dal::pg::{PgDal, PgPassportRepo, PgSealOutboxRepo}; +use dpp_domain::passport::{ManufacturerInfo, Passport, PassportId}; +use dpp_domain::ports::passport_repo::PassportRepository; +use dpp_domain::product_group::ProductGroup; +use dpp_domain::seal::{SealConformanceLevel, SealFormat, SealedEnvelope}; +use dpp_domain::status::PassportStatus; +use dpp_types::{ArchivalFreshness, SealBinding, SealInspector, SealOrigin, SealOutbox}; + +fn op() -> String { + "00000000-0000-0000-0000-000000000001".to_owned() +} + +/// Not JWT-shaped on purpose — nothing here parses it, and a realistic compact +/// JWS would only trip secret scanners on a literal that is not a credential. +const JWS: &str = "header.payload.signature"; + +/// An inspector whose verdict is whatever the test says it is. +struct ScriptedInspector(SealBinding, Option); + +impl SealInspector for ScriptedInspector { + fn origin(&self, _envelope: &SealedEnvelope) -> Option { + None + } + + fn binding(&self, _envelope: &SealedEnvelope, _payload_hash: &str) -> SealBinding { + self.0.clone() + } + + fn evidenced_level(&self, _envelope: &SealedEnvelope) -> Option { + None + } + + fn attested_sealing_time(&self, _envelope: &SealedEnvelope) -> Option> { + None + } + + fn archival_freshness( + &self, + _envelope: &SealedEnvelope, + _now: DateTime, + ) -> ArchivalFreshness { + ArchivalFreshness::NotArchived + } + + fn certificate_standing( + &self, + _envelope: &SealedEnvelope, + _now: DateTime, + ) -> Option { + self.1.clone() + } +} + +/// A vault whose stored seals all read as `verdict`. +async fn vault_reading(dal: &PgDal, verdict: SealBinding) -> String { + start_vault_with_seal( + dal.clone(), + SealWiring { + inspector: Some(Arc::new(ScriptedInspector(verdict, None))), + outbox: true, + }, + ) + .await +} + +/// A published, signed passport carrying no seal yet. +async fn seed(dal: &PgDal) -> PassportId { + seed_with(dal, Some(JWS), None).await +} + +/// The same, with the signature and the seal chosen by the caller — for the one +/// state that has to be built rather than reached. +async fn seed_with(dal: &PgDal, jws: Option<&str>, seal: Option) -> PassportId { + let passport = Passport { + id: PassportId::new(), + batch_id: None, + serial_number: None, + product_name: "Repair Route Battery".into(), + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, + manufacturer: ManufacturerInfo { + name: "TestCorp GmbH".into(), + address: "Berlin, DE".into(), + registered_trade_name: None, + electronic_address: None, + country: None, + did_web_url: None, + }, + materials: vec![], + co2e_per_unit: None, + repairability_score: None, + compliance_result: None, + lint_result: None, + product_group_data: None, + status: PassportStatus::Published, + qr_code_url: None, + jws_signature: jws.map(ToOwned::to_owned), + public_jws_signature: None, + disclosure_signatures: Default::default(), + created_at: Utc::now(), + updated_at: Utc::now(), + published_at: Some(Utc::now()), + placed_on_market_date: None, + schema_version: "2.0.0".into(), + retention_locked: true, + version: 1, + supersedes_id: None, + derived_from: Vec::new(), + component_refs: Vec::new(), + life_status: None, + retention_until: None, + product_id: None, + commodity_code: None, + operator_identifier: None, + responsible_operator: None, + facility: None, + seal, + }; + let id = passport.id; + PgPassportRepo::new(dal.clone()) + .create(passport) + .await + .expect("seed passport"); + id +} + +fn envelope() -> SealedEnvelope { + SealedEnvelope { + format: SealFormat::Cades, + seal_value: "BASE64-DETACHED-CADES".into(), + signing_cert_ref: None, + conformance_level: None, + sealed_at: Utc::now(), + placeholder: false, + } +} + +/// Take one passport all the way to a closed `sealed` row over `digest`. +/// +/// Driven through the outbox port rather than written with SQL: `mark_sealed` +/// is what puts the envelope on the passport in production, and a fixture that +/// wrote both halves itself could put them in a combination the drain cannot +/// produce. +async fn seal_it(dal: &PgDal, id: PassportId, digest: &str) { + let outbox = PgSealOutboxRepo::new(dal.clone()); + outbox.enqueue(id, digest).await.expect("queue"); + let due = outbox.due(10).await.expect("due"); + let row = due + .iter() + .find(|r| r.passport_id == id) + .expect("the row just queued is due"); + outbox + .mark_sealed(row.id, &envelope()) + .await + .expect("close the row"); +} + +async fn counts(dal: &PgDal) -> dpp_types::SealOutboxCounts { + PgSealOutboxRepo::new(dal.clone()) + .status_counts() + .await + .expect("counts") +} + +fn detail(body: &serde_json::Value) -> String { + body["detail"].as_str().unwrap_or_default().to_owned() +} + +fn note(body: &serde_json::Value) -> String { + body["note"].as_str().unwrap_or_default().to_owned() +} + +// --------------------------------------------------------------------------- +// The two repairs +// --------------------------------------------------------------------------- + +/// **A broken seal is re-armed, and the response names the digest it will buy.** +/// +/// The payload hash matters as much as the action: a replacement covers the +/// passport's *current* signature, so an operator reading the response can check +/// what they are paying for against what the passport now carries. +#[tokio::test(flavor = "multi_thread")] +async fn a_broken_seal_is_rearmed_and_names_the_digest_it_will_cover() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + let digest = dpp_types::digest_for_jws(JWS); + seal_it(&pg.dal, id, &digest).await; + assert_eq!(counts(&pg.dal).await.sealed, 1, "fixture must start sealed"); + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + + assert_eq!( + body["action"], "rearmed", + "a sealed row over this digest is re-armed, not queued afresh: {body}" + ); + assert_eq!( + body["payloadHash"], digest, + "the response must name the signature the replacement will cover" + ); + assert!( + note(&body).contains("costs a seal"), + "the note must say plainly that this spends: {body}" + ); + + let after = counts(&pg.dal).await; + assert_eq!(after.pending, 1, "the row is back in front of the drain"); + assert_eq!( + after.sealed, 0, + "and is no longer counted as a seal this node holds" + ); +} + +/// **A broken seal over a superseded signature is queued, and buys nothing +/// twice.** +/// +/// The passport was re-published after the seal was made, so the digest now +/// needing a seal has never been sealed. That makes this an ordinary enqueue — +/// the distinction the response draws between `rearmed` and `queued` — and the +/// old row must be left exactly where it is, because it still records a seal +/// that was bought. +#[tokio::test(flavor = "multi_thread")] +async fn a_broken_seal_over_a_superseded_signature_is_queued_not_rearmed() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + + // Sealed over an older signature than the one the passport now carries. + let superseded = dpp_types::digest_for_jws("header.older-payload.signature"); + seal_it(&pg.dal, id, &superseded).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + + assert_eq!(body["action"], "queued"); + assert_eq!( + body["payloadHash"], + dpp_types::digest_for_jws(JWS), + "the queued row must cover the passport's current signature" + ); + + let after = counts(&pg.dal).await; + assert_eq!(after.pending, 1, "one new row, for the current signature"); + assert_eq!( + after.sealed, 1, + "and the row recording the seal that was already bought is untouched" + ); +} + +/// **Retrying a repair queues nothing more.** +/// +/// The route is not idempotency-keyed, and this is the claim that stands in for +/// a key: a seal row is keyed by `(passport_id, payload_hash)`, so a second +/// request lands on a row that is already `pending` — which `enqueue` leaves +/// alone and `rearm_sealed` does not match. A client that retries on a timeout +/// must not buy a third seal. +#[tokio::test(flavor = "multi_thread")] +async fn a_repeated_repair_does_not_queue_a_second_seal() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let path = format!("/api/v1/dpp/{id}/seal/repair"); + let first = client.post_json(&path, serde_json::json!({})).await; + assert_eq!(first.status(), 200); + + let second = client.post_json(&path, serde_json::json!({})).await; + assert_eq!(second.status(), 200, "a retry is answered, not rejected"); + let body: serde_json::Value = second.json().await.unwrap(); + assert_eq!( + body["action"], "queued", + "there is no sealed row left to re-arm — the repair is already in flight" + ); + + let after = counts(&pg.dal).await; + assert_eq!( + after.pending, 1, + "still exactly one repair queued, not one per request" + ); + assert_eq!(after.sealed, 0); +} + +// --------------------------------------------------------------------------- +// The refusals — each is a state that looks repairable and is not +// --------------------------------------------------------------------------- + +/// A seal that verifies over this signature is the healthy state. Repairing it +/// would buy a second seal for a sound one, which is the failure this route +/// exists to make impossible rather than merely unlikely. +#[tokio::test(flavor = "multi_thread")] +async fn a_sound_seal_is_refused() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::CoversThisSignature).await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("verifies"), + "the refusal must say the seal is sound: {body}" + ); + + assert_eq!( + counts(&pg.dal).await.pending, + 0, + "and nothing may be queued on the way to refusing" + ); +} + +/// **A seal whose certificate was not valid when it was made is refused, and +/// told why.** +/// +/// "This seal verifies" is true and, on its own, misleading here: something *is* +/// wrong, and repair is not the remedy — a replacement would be bought from the +/// same backend under the same certificate. An operator sent away with the sound +/// message would learn neither fact. +#[tokio::test(flavor = "multi_thread")] +async fn a_seal_made_under_an_invalid_certificate_is_refused_with_its_own_reason() { + let pg = start_postgres().await; + let at = Utc::now(); + let revoked = dpp_types::CertificateStanding { + validity: dpp_types::ValidityWindow { + not_before: at - chrono::Duration::days(400), + not_after: at + chrono::Duration::days(400), + standing: dpp_types::WindowStanding::Inside, + }, + // Attested, because that is what makes this a finding rather than an + // open question — the unproven case is deliberately left alone. + judged_at: dpp_types::JudgedTime { at, attested: true }, + revocation: dpp_types::RevocationStanding::Revoked { + at: at - chrono::Duration::days(1), + }, + }; + let base = start_vault_with_seal( + pg.dal.clone(), + SealWiring { + inspector: Some(Arc::new(ScriptedInspector( + SealBinding::CoversThisSignature, + Some(revoked), + ))), + outbox: true, + }, + ) + .await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("same certificate"), + "the refusal must say why re-sealing is not the remedy: {body}" + ); + assert_eq!( + counts(&pg.dal).await.pending, + 0, + "and nothing may be queued on the way to refusing" + ); +} + +/// An **intact** seal over another digest is an ordinary re-publish, not damage. +/// The signature now needing a seal already has its own row from that publish, +/// so there is nothing here to repair — and the seal being repaired would still +/// be a valid attestation of the signature it covers. +#[tokio::test(flavor = "multi_thread")] +async fn a_superseded_but_intact_seal_is_refused() { + let pg = start_postgres().await; + let base = vault_reading( + &pg.dal, + SealBinding::CoversAnotherDigest { + covered: "00".repeat(32), + }, + ) + .await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("re-published"), + "the refusal must name the ordinary cause rather than imply damage: {body}" + ); + assert_eq!(counts(&pg.dal).await.pending, 0); +} + +/// **"Cannot check" must not become "worthless".** +/// +/// An unreadable seal may be a format this node does not parse, or a provider's +/// envelope this adapter has never met. Treating that as broken would spend an +/// operator's money on the node's own ignorance — and would do it at exactly the +/// moment a new provider was introduced. +#[tokio::test(flavor = "multi_thread")] +async fn an_unreadable_seal_is_refused() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::Unknown).await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("could not be read"), + "the refusal must distinguish unreadable from broken: {body}" + ); + assert_eq!(counts(&pg.dal).await.pending, 0); +} + +/// **A passport carrying a seal and no signature is refused, not queued over +/// nothing.** +/// +/// The state should be unreachable — a seal is applied to a signature — so the +/// route treats it as a refusal rather than repairing it. It has to be built by +/// hand for exactly that reason, and it is built because the `422` is documented +/// on the route: an error condition that is described and never constructed is a +/// claim about behaviour that nothing checks. +/// +/// What it guards is small and sharp: the digest would otherwise be empty, and +/// the drain would be sent to buy a seal over nothing. +#[tokio::test(flavor = "multi_thread")] +async fn a_seal_with_no_signature_to_cover_is_refused() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed_with(&pg.dal, None, Some(envelope())).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("no signature"), + "the refusal must name what is missing: {body}" + ); + assert_eq!( + counts(&pg.dal).await.pending, + 0, + "and nothing may be queued over an empty digest" + ); +} + +/// A passport with no seal is the sweep's job, and the sweep costs nothing +/// extra because no seal was ever bought for it. Answering here would route an +/// ordinary case through the one path that can double-bill. +#[tokio::test(flavor = "multi_thread")] +async fn a_passport_with_no_seal_is_refused() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("no seal"), + "the refusal must point at the sweep rather than repair: {body}" + ); +} + +/// A node that cannot open a seal cannot establish that one is broken, so it +/// must not act on the assumption that it is. This is the shipped default: the +/// CAdES reader is resolved by the composition root, and a standalone vault +/// links nothing that parses seals. +#[tokio::test(flavor = "multi_thread")] +async fn a_node_that_cannot_read_seals_refuses_to_repair() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("cannot read seals"), + "the refusal must name the missing capability: {body}" + ); +} + +/// **A queued repair on a node with nothing to drain is a lie.** +/// +/// The row would sit for ever while the response said the repair was under way. +/// The only honest answer is to refuse and say what is missing — and this one +/// only surfaced from asking what happens *after* the row is written. +#[tokio::test(flavor = "multi_thread")] +async fn a_node_with_no_sealing_backend_refuses_to_repair() { + let pg = start_postgres().await; + let id = seed(&pg.dal).await; + // Seeded while an outbox still exists, then served by a vault without one — + // the state a node lands in when a provider is removed from its config. + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let base = start_vault_with_seal( + pg.dal.clone(), + SealWiring { + inspector: Some(Arc::new(ScriptedInspector(SealBinding::NotIntact, None))), + outbox: false, + }, + ) + .await; + let client = TestClient::new(&base, make_jwt(&op())); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 422); + let body: serde_json::Value = resp.json().await.unwrap(); + assert!( + detail(&body).contains("never drain"), + "the refusal must say why a queued repair would not help: {body}" + ); +} + +// --------------------------------------------------------------------------- +// Who may ask +// --------------------------------------------------------------------------- + +/// Spending an operator's money is an admin action. A write-scoped key can +/// publish all day and still must not buy a second seal. +#[tokio::test(flavor = "multi_thread")] +async fn the_route_requires_admin() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + seal_it(&pg.dal, id, &dpp_types::digest_for_jws(JWS)).await; + + let client = TestClient::new(&base, make_jwt_scoped(&op(), "write")); + let resp = client + .post_json( + &format!("/api/v1/dpp/{id}/seal/repair"), + serde_json::json!({}), + ) + .await; + assert_eq!(resp.status(), 403); + assert_eq!( + counts(&pg.dal).await.pending, + 0, + "a refused caller must not have moved the row" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_route_requires_authentication() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let id = seed(&pg.dal).await; + + let resp = reqwest::Client::new() + .post(format!("{base}/api/v1/dpp/{id}/seal/repair")) + .send() + .await + .expect("request"); + assert_eq!(resp.status(), 401); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unknown_passport_is_a_404() { + let pg = start_postgres().await; + let base = vault_reading(&pg.dal, SealBinding::NotIntact).await; + let client = TestClient::new(&base, make_jwt(&op())); + + let resp = client + .post_json( + &format!("/api/v1/dpp/{}/seal/repair", PassportId::new()), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 404, + "a passport that does not exist is a 404, not a 422 about its seal" + ); +} diff --git a/crates/dpp-vault/tests/seal_route.rs b/crates/dpp-vault/tests/seal_route.rs index 75232a1b..d384ea3e 100644 --- a/crates/dpp-vault/tests/seal_route.rs +++ b/crates/dpp-vault/tests/seal_route.rs @@ -4,8 +4,8 @@ //! reach: the drain tests stop at the database, and the full-loop simulation //! calls `PassportService` directly. What is asserted here is mostly about //! *honesty of shape* — that an unsealed passport is a 404 rather than an empty -//! seal object, and that the response carries the preimage a verifier needs plus -//! a plain statement that this node validated nothing. +//! seal object, that the response carries the preimage a verifier needs, and +//! that it says both what it checked and what it did not. #![cfg(feature = "integration-tests")] @@ -125,10 +125,19 @@ async fn a_sealed_passport_returns_the_seal_and_its_preimage() { ); // And the response must not read as a verdict. + // + // The claim to pin is no longer "no check was performed" — several are, and + // the fields beside this one report them. It is that a **full** verdict is + // somebody else's, and that the response says which checks it is standing + // behind rather than leaving a reader to assume. let verification = body["verification"].as_str().expect("verification present"); assert!( - verification.contains("not validated by this node"), - "the response must state plainly that no CAdES check was performed: {verification}" + verification.contains("independent AdES validator"), + "the response must send a reader to a real validator for the verdict: {verification}" + ); + assert!( + verification.contains("no certificate path is built"), + "and must name what it did not check, not only what it did: {verification}" ); } @@ -239,6 +248,43 @@ async fn a_sealed_passport_is_not_reported_as_unsealed() { assert_eq!(body["unsealedPublished"], 0); } +/// **A node whose audit has not completed a pass reports nothing, not zero.** +/// +/// The audit walks every stored seal in bounded batches and publishes only when +/// it wraps, so `audit` is absent for a while after every restart. A zero there +/// would read as "checked, nothing wrong" — the one answer worse than silence, +/// because it is the answer an operator would act on. The counts beside it stay +/// populated, which is the point: they describe rows and absent seals, and a +/// worthless seal is present, so nothing in them can cover for a missing report. +/// +/// This harness wires no audit task at all, which is the same state a freshly +/// booted node is in. +#[tokio::test(flavor = "multi_thread")] +async fn an_audit_that_has_not_completed_a_pass_reports_nothing_rather_than_zero() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + seed(&pg.dal, Some(envelope()), Some(JWS)).await; + let client = TestClient::new(&base, make_jwt(&op())); + + let resp = client.get("/api/v1/seal").await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + + assert!( + body["audit"].is_null(), + "no completed pass must serve null, never a report with zeroes: {body}" + ); + assert!( + body.get("audit").is_some(), + "and the field must be present and null rather than omitted, so a reader \ + cannot mistake an old client for a clean estate" + ); + assert_eq!( + body["unsealedPublished"], 0, + "the counts are unaffected — they answer a different question" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn the_summary_route_requires_authentication() { let pg = start_postgres().await; diff --git a/docs/guides/OPERATOR-SETUP.md b/docs/guides/OPERATOR-SETUP.md index ac8dac86..d8be2d6c 100644 --- a/docs/guides/OPERATOR-SETUP.md +++ b/docs/guides/OPERATOR-SETUP.md @@ -333,7 +333,19 @@ but the qualified seal, the third-party archive, and the registry notifications are simulated. Wiring those up is a separate exercise; until then, treat the output as operationally real and legally not. -`odal seal status` reports the sealing side specifically. +`odal seal status` reports the sealing side specifically. It also reports what +the node's background pass over the seals it *already holds* has found — a seal +can stop verifying long after it was bought, through a bad restore or a +truncated column, and every "is it sealed" count still says yes because a +worthless seal is present. Two readings to keep apart there: + +- **`not audited yet`** means no pass has completed, not that nothing is wrong. + A pass walks the whole estate; `SEAL_AUDIT_BATCH` and `SEAL_AUDIT_INTERVAL_SECS` + set how long that takes, and the boot log says so for your estate. +- **a named passport** can be re-sealed with `odal seal repair `, which buys + a replacement. That costs a seal, so the node refuses unless the one it holds + is demonstrably broken — checked at the moment you ask, not read from the + report. --- diff --git a/justfile b/justfile index bac1ef3f..88bff337 100644 --- a/justfile +++ b/justfile @@ -251,6 +251,12 @@ fmt-check: debug-check: bash scripts/debug-check.sh +# Hold the claim that suppresses RUSTSEC-2023-0071: `rsa` stays transitive and +# verification-only. The advisory is a timing sidechannel in RSA *private key* +# operations, and the suppression rests entirely on this workspace having none. +no-rsa-private-key: + bash scripts/no-rsa-private-key.sh + # Forbid raw "dpp.passport."/"dpp.import." subject literals outside dpp-common::event # (event_type/NATS-subject strings must come from the `subjects` constants, or a # renamed subject silently stops matching subscribers). @@ -322,7 +328,7 @@ doc: cargo doc --workspace --no-deps # Fast gate (no Docker) — mirrors CI jobs: fmt, clippy, debug-prints, test-unit, audit -check: fmt-check lint debug-check subjects-check mod-rs-check harness-check contract-fixture-check contract-fixture-check-self-test spec-version-check outbound-check grants-check migrations-check check-plugins test check-integration audit +check: fmt-check lint debug-check no-rsa-private-key subjects-check mod-rs-check harness-check contract-fixture-check contract-fixture-check-self-test spec-version-check outbound-check grants-check migrations-check check-plugins test check-integration audit # Full local CI mirror — adds integration-feature clippy + the Docker tiers (needs Docker running) ci: check lint-integration test-integration test-pg diff --git a/ops/pg/0038_seal_audit_state.sql b/ops/pg/0038_seal_audit_state.sql new file mode 100644 index 00000000..5504fbbc --- /dev/null +++ b/ops/pg/0038_seal_audit_state.sql @@ -0,0 +1,53 @@ +-- ============================================================================ +-- 0038 — seal_audit_state: where the stored-seal audit keeps its position and +-- its last completed result. +-- +-- The audit opens every stored seal and checks whether it still stands up — +-- the one failure no SQL clause can find, because a seal that is present and +-- worthless satisfies every "is the seal member absent" test the node makes. +-- It walks the estate in batches and publishes only when it reaches the end, +-- since a count from half the estate reads exactly like a count from all of it. +-- +-- Both halves lived only in process memory, and that cost two things: +-- +-- * a restart erased the answer, so the operator surface reported "no pass +-- has completed" for the length of a whole walk after every deployment — +-- hours on a large estate, and indistinguishable from an audit that is not +-- running at all; +-- * a restart also erased the *position*, so a node whose estate takes longer +-- to walk than it goes between restarts would begin again from the start +-- for ever and never publish anything, while doing all of the work. +-- +-- The second is the reason the cursor is here and not just the report: storing +-- only the result cannot help a node that never produces one. +-- +-- Deliberately NOT a record of validations. Under Reg. (EU) No 910/2014 Art. 33, +-- reached for seals by Art. 40, a qualified validation service is a QTSP service +-- whose result carries the provider's own advanced signature or seal. Nothing +-- written here is signed and nothing here is qualified — it is a node's own +-- housekeeping, kept so an operator can see when it last looked. +-- +-- Singleton: one node, one audit, one row, enforced by the primary key check. +-- Single-tenant, so no `operator_id` column. +-- ============================================================================ + +CREATE TABLE odal.seal_audit_state ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + -- The walk in progress: cursor, start time, and the totals accumulated so + -- far. NULL between a completed pass and the start of the next. + -- + -- JSONB rather than columns because the shape is owned by the serde type that + -- reads it back, the same arrangement every other document column here uses, + -- and because a partial walk is read and written whole — never queried into. + progress JSONB, + -- The last pass that reached the end. NULL means none ever has, which is the + -- state the operator surface reports as an absence rather than as a zero: a + -- check that has not run must not be served as a check that found nothing. + report JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 0010's ALL-TABLES grant was a one-time snapshot; tables added later need their +-- own grant (same pattern as 0017/0021/0022/0023/0028). No DELETE: the row is +-- overwritten in place and there is nothing here to remove. +GRANT SELECT, INSERT, UPDATE ON odal.seal_audit_state TO odal_app; diff --git a/scripts/no-rsa-private-key.sh b/scripts/no-rsa-private-key.sh new file mode 100644 index 00000000..8e004b1c --- /dev/null +++ b/scripts/no-rsa-private-key.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Enforces the claim that suppresses RUSTSEC-2023-0071. +# +# The `rsa` crate carries the Marvin Attack advisory, for which no fix exists +# or is expected: it is a timing sidechannel in RSA **private key** operations, +# recovering a key by timing an attacker's decryption or signing queries. The +# entry in `.cargo/audit.toml` suppresses it on one ground — this workspace +# holds no RSA private key and performs no RSA private-key operation, so there +# is nothing to recover and no oracle to time. +# +# That is a claim about the code, and the audit register's own header says a +# prose claim rots silently without something re-checking it. Its `anchor` +# field can only grep for a symbol; it cannot see a private key arriving three +# crates away. This can. +# +# Two things are checked, matching how such a key could actually appear: +# +# 1. No workspace crate declares `rsa` as a **direct** dependency. Reaching +# it only through `x509-verify` is what keeps the use verification-only; +# a direct dependency is how a signing or decryption path starts. +# 2. No workspace source names an RSA private-key type or operation. +# +# If either fires, the suppression is void: delete the ignore entry in +# `.cargo/audit.toml` and deal with the advisory on its merits, or remove the +# private key. Do not widen this script to accommodate the new code. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +STATUS=0 + +# ── 1. `rsa` must stay transitive ─────────────────────────────────────────── +# Matches a dependency line, not a word in a comment or a feature named "rsa" +# inside another crate's feature list. +if grep -rn --include="Cargo.toml" -E '^\s*rsa\s*=' crates cli Cargo.toml 2>/dev/null; then + echo "ERROR: a workspace crate depends on \`rsa\` directly." >&2 + echo " RUSTSEC-2023-0071 is suppressed in .cargo/audit.toml on the ground that" >&2 + echo " this workspace performs no RSA private-key operation. A direct dependency" >&2 + echo " is how one starts. Re-argue the suppression or drop the dependency." >&2 + STATUS=1 +fi + +# ── 2. No RSA private-key material in our own source ──────────────────────── +# `RsaPrivateKey` is the type; the two `SigningKey`s are the private-key +# signing APIs in `rsa`'s padding modules. `DecryptingKey` covers the +# decryption side, which is where the Marvin Attack was first demonstrated. +if grep -rn --include="*.rs" \ + -e 'RsaPrivateKey' \ + -e 'rsa::pkcs1v15::SigningKey' \ + -e 'rsa::pss::SigningKey' \ + -e 'rsa::pkcs1v15::DecryptingKey' \ + -e 'rsa::oaep' \ + crates/*/src crates/*/tests cli/src 2>/dev/null; then + echo "ERROR: RSA private-key material or a private-key operation in workspace source." >&2 + echo " This voids the RUSTSEC-2023-0071 suppression in .cargo/audit.toml, whose" >&2 + echo " whole ground is that no such key or operation exists here." >&2 + STATUS=1 +fi + +if [[ $STATUS -eq 0 ]]; then + echo "no-rsa-private-key: rsa is transitive and verification-only." +fi +exit $STATUS