diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3eb0ac..e9e4bc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,36 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): problem at the publisher, a bad signature is an altered document, and the two send an operator to different people. +- **A verified list of trusted lists now says whether the pin it verified + against is still the notice in force.** New `AnchorFreshness` on + `VerifiedLotl`: `Current`, `Superseded { lotl_names }`, or `Unknown`. + + The trust anchor is pinned from an Official Journal notice rather than chained + to a certificate authority, and the Commission republishes that notice. A pin + nobody refreshes eventually meets a LOTL signed by a certificate it does not + name and fails closed as `NotAnchored` — on a date nobody has in a calendar, + looking like an outage rather than a lapsed pin. This is the only warning + before that. + + **A signal, never a verdict.** A superseded pin keeps verifying until the + certificates actually rotate, and that window is the only chance to refresh + without an outage — so folding this into `LotlRejected` would refuse documents + that verify perfectly and turn the early warning into the thing it exists to + prevent. `a_superseded_pin_does_not_refuse_a_document_that_verifies` pins that. + + **`Unknown` is not `Current`.** A document naming no notice cannot be checked, + and reporting it as up to date is how a staleness signal goes quiet at the + moment it matters. + + Reported and logged: the caller that most needs to act on it is an operator + reading logs, not the code holding the `VerifiedLotl`. + + The check rests on the document listing its notice **first** in + `SchemeInformationURI`, ahead of the pivot chain and twenty-three per-language + legal notices. That ordering is asserted against the real published document + rather than trusted, so if it ever changes the result is a red test rather than + a comparison against a pivot URL. + - **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 diff --git a/crates/dpp-seal/src/trustlist/mod.rs b/crates/dpp-seal/src/trustlist/mod.rs index 76f0be1a..21e0305a 100644 --- a/crates/dpp-seal/src/trustlist/mod.rs +++ b/crates/dpp-seal/src/trustlist/mod.rs @@ -95,6 +95,6 @@ pub use fetch::{ pub use model::{ListedProvider, ListedService, TrustedListPointer, UnverifiedTrustedList}; pub use parse::{parse_lotl, parse_trusted_list}; pub use verify::{ - LotlRejected, TrustedListRejected, VerifiedLotl, VerifiedTrustedList, verify_lotl, - verify_lotl_with, verify_trusted_list, + AnchorFreshness, LotlRejected, TrustedListRejected, VerifiedLotl, VerifiedTrustedList, + verify_lotl, verify_lotl_with, verify_trusted_list, }; diff --git a/crates/dpp-seal/src/trustlist/verify.rs b/crates/dpp-seal/src/trustlist/verify.rs index 218d2381..a117438e 100644 --- a/crates/dpp-seal/src/trustlist/verify.rs +++ b/crates/dpp-seal/src/trustlist/verify.rs @@ -97,6 +97,88 @@ impl std::fmt::Display for LotlRejected { impl std::error::Error for LotlRejected {} +/// Whether the pinned anchor is still the notice the LOTL itself names. +/// +/// **Not part of the verdict.** A superseded pin keeps verifying until the +/// Commission actually rotates the signing certificates, which is exactly why +/// this is worth reporting: the window between "a newer notice exists" and "the +/// old certificates stop being used" is the only chance to refresh without an +/// outage. Folding it into [`LotlRejected`] would refuse documents that verify +/// perfectly today. +/// +/// The failure it exists to pre-empt is unpleasant: a pin nobody refreshes +/// eventually meets a LOTL signed by a certificate the notice no longer names, +/// which fails closed as [`LotlRejected::NotAnchored`] — on a date nobody has in +/// a calendar, looking like an outage rather than a lapsed pin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnchorFreshness { + /// The LOTL names the notice this build is pinned to. + Current, + /// The LOTL names a different notice, so the Commission has republished. + /// + /// Carries what the document named so an operator can go and read it, + /// rather than being told only that their pin is wrong. + Superseded { + /// The notice the LOTL names as currently in force. + lotl_names: String, + }, + /// The document names no notice at all, so the question cannot be answered. + /// + /// Distinct from [`Current`](Self::Current) deliberately. Treating "could + /// not tell" as "up to date" is how a staleness signal goes quiet at the + /// moment it is most needed — the same fail-closed direction the rest of + /// this module takes. + Unknown, +} + +impl std::fmt::Display for AnchorFreshness { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Current => f.write_str("the pinned notice is the one in force"), + Self::Superseded { lotl_names } => write!( + f, + "the list of lists names {lotl_names} as the notice in force, which is not the \ + one this build pins — refresh the anchor from it before the signing \ + certificates rotate" + ), + Self::Unknown => { + f.write_str("the list of lists names no notice, so the pin cannot be checked") + } + } + } +} + +/// The notice a LOTL names as currently in force, compared against `pinned`. +/// +/// The document lists its notice **first** in `SchemeInformationURI`, ahead of +/// the pivot chain and the per-language legal notices. That ordering is what +/// makes this cheap; it is also the only thing this reads, so a document that +/// reorders those entries would report `Superseded` rather than silently +/// comparing the wrong one. +pub(super) fn anchor_freshness(xml: &str, pinned: &str) -> AnchorFreshness { + let Ok(doc) = Document::parse(xml) else { + return AnchorFreshness::Unknown; + }; + let named = doc + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "SchemeInformationURI") + .and_then(|n| { + n.children() + .find(|c| c.is_element() && c.tag_name().name() == "URI") + }) + .and_then(|n| n.text()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + match named { + None => AnchorFreshness::Unknown, + Some(uri) if uri == pinned => AnchorFreshness::Current, + Some(uri) => AnchorFreshness::Superseded { + lotl_names: uri.to_owned(), + }, + } +} + /// A list of trusted lists whose signature has been verified against the anchor. /// /// The type exists so a verified document cannot be passed where an unverified @@ -110,6 +192,7 @@ impl std::error::Error for LotlRejected {} pub struct VerifiedLotl { pointers: Vec, signed_by: String, + anchor_freshness: AnchorFreshness, } impl VerifiedLotl { @@ -119,6 +202,16 @@ impl VerifiedLotl { &self.pointers } + /// Whether the pin this verified against is still the notice in force. + /// + /// Read it. A `Verified` document says the signature and the anchor agree + /// *today*; this says whether that will still be true after the next + /// rotation, and it is the only warning there is. + #[must_use] + pub fn anchor_freshness(&self) -> &AnchorFreshness { + &self.anchor_freshness + } + /// Base64 SHA-256 of the anchored certificate that signed it. /// /// For a trust report: it says *which* of the authorised certificates the @@ -213,10 +306,23 @@ pub fn verify_lotl_with(xml: &str, anchor: &LotlAnchor) -> Result {} + other => tracing::warn!( + pinned_notice = anchor.notice_celex(), + "trusted-list anchor: {other}" + ), + } + Ok(VerifiedLotl { pointers, signed_by: base64::engine::general_purpose::STANDARD .encode(::digest(&der)), + anchor_freshness: freshness, }) } diff --git a/crates/dpp-seal/src/trustlist/verify_tests.rs b/crates/dpp-seal/src/trustlist/verify_tests.rs index 56a13f27..8292573c 100644 --- a/crates/dpp-seal/src/trustlist/verify_tests.rs +++ b/crates/dpp-seal/src/trustlist/verify_tests.rs @@ -1,6 +1,8 @@ //! Verifying the real list of trusted lists against the real anchor. -use super::verify::{LotlRejected, TrustedListRejected, verify_lotl}; +use super::verify::{ + AnchorFreshness, LotlRejected, TrustedListRejected, anchor_freshness, verify_lotl, +}; /// The EU list of trusted lists, as published. /// @@ -398,3 +400,101 @@ fn no_rejection_message_carries_a_stray_line_break() { ); } } + +/// Whether the pinned anchor is still the notice the Commission names. +/// +/// The anchor is pinned from an Official Journal notice and the Commission +/// republishes that notice. A pin nobody refreshes eventually meets a LOTL +/// signed by a certificate it does not name, and fails closed on a date nobody +/// has in a calendar. This is the only warning before that. +mod anchor_freshness_signal { + use super::*; + + /// The assumption the whole check rests on, asserted against the real + /// document rather than trusted. + /// + /// The LOTL lists its notice **first** in `SchemeInformationURI`, ahead of + /// the pivot chain and twenty-three per-language legal notices. If that + /// ordering ever changed, this check would compare against a pivot URL and + /// report `Superseded` forever — noisy rather than silent, which is the + /// right way round, but still wrong. + #[test] + fn the_notice_is_the_first_entry_the_document_lists() { + let freshness = anchor_freshness(EU_LOTL, "https://eur-lex.europa.eu/eli/C/2026/1944/oj"); + assert_eq!( + freshness, + AnchorFreshness::Current, + "the first SchemeInformationURI entry is no longer the OJ notice" + ); + } + + /// The published document and this build's pin agree today. + /// + /// When this fails, the Commission has republished and the anchor needs + /// refreshing from the notice named in the failure — which is the whole + /// point of the signal, arriving as a red test rather than an outage. + #[test] + fn the_pinned_anchor_is_still_the_notice_in_force() { + let verified = verify_lotl(EU_LOTL).expect("the LOTL verifies"); + assert_eq!( + verified.anchor_freshness(), + &AnchorFreshness::Current, + "the pinned notice is no longer the one the LOTL names" + ); + } + + /// A superseded pin is named, and names what to go and read. + #[test] + fn a_superseded_pin_says_which_notice_replaced_it() { + let freshness = anchor_freshness(EU_LOTL, "https://eur-lex.europa.eu/eli/C/2019/276/oj"); + let AnchorFreshness::Superseded { lotl_names } = &freshness else { + panic!("a pin the document does not name must be reported: {freshness:?}"); + }; + assert_eq!(lotl_names, "https://eur-lex.europa.eu/eli/C/2026/1944/oj"); + assert!( + freshness.to_string().contains("refresh the anchor"), + "an operator has to be told what to do: {freshness}" + ); + } + + /// Freshness is a signal, never a verdict. + /// + /// The document that would report `Superseded` against an older pin is the + /// same document that verifies cleanly — which is the situation this exists + /// for. Refusing it would turn an early warning into the outage it is meant + /// to prevent. + #[test] + fn a_superseded_pin_does_not_refuse_a_document_that_verifies() { + assert!( + verify_lotl(EU_LOTL).is_ok(), + "the real LOTL verifies against the real anchor" + ); + assert!( + matches!( + anchor_freshness(EU_LOTL, "https://eur-lex.europa.eu/eli/C/2019/276/oj"), + AnchorFreshness::Superseded { .. } + ), + "and would report a stale pin, without that changing the verdict" + ); + } + + /// A document naming no notice is `Unknown`, not `Current`. + /// + /// Treating "could not tell" as "up to date" is how a staleness signal goes + /// quiet exactly when it matters — the same fail-closed direction the + /// capacity and date questions take elsewhere in this workspace. + #[test] + fn a_document_that_names_no_notice_is_not_reported_as_current() { + assert_eq!( + anchor_freshness( + "", + "https://example.invalid/notice" + ), + AnchorFreshness::Unknown + ); + assert_eq!( + anchor_freshness("not xml at all", "https://example.invalid/notice"), + AnchorFreshness::Unknown + ); + } +}