Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/dpp-seal/src/trustlist/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
106 changes: 106 additions & 0 deletions crates/dpp-seal/src/trustlist/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -110,6 +192,7 @@ impl std::error::Error for LotlRejected {}
pub struct VerifiedLotl {
pointers: Vec<TrustedListPointer>,
signed_by: String,
anchor_freshness: AnchorFreshness,
}

impl VerifiedLotl {
Expand All @@ -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
Expand Down Expand Up @@ -213,10 +306,23 @@ pub fn verify_lotl_with(xml: &str, anchor: &LotlAnchor) -> Result<VerifiedLotl,

let pointers = parse_lotl(xml).map_err(|e| LotlRejected::Malformed(e.to_string()))?;

// Computed after the verdict, never as part of it — see `AnchorFreshness`.
// Logged as well as returned because the caller that most needs to act on it
// is an operator reading logs, not the code holding the `VerifiedLotl`.
let freshness = anchor_freshness(xml, anchor.notice_uri());
match &freshness {
AnchorFreshness::Current => {}
other => tracing::warn!(
pinned_notice = anchor.notice_celex(),
"trusted-list anchor: {other}"
),
}

Ok(VerifiedLotl {
pointers,
signed_by: base64::engine::general_purpose::STANDARD
.encode(<sha2::Sha256 as sha2::Digest>::digest(&der)),
anchor_freshness: freshness,
})
}

Expand Down
102 changes: 101 additions & 1 deletion crates/dpp-seal/src/trustlist/verify_tests.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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(
"<TrustServiceStatusList/>",
"https://example.invalid/notice"
),
AnchorFreshness::Unknown
);
assert_eq!(
anchor_freshness("not xml at all", "https://example.invalid/notice"),
AnchorFreshness::Unknown
);
}
}
Loading