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
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,56 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):

### Added

- **Nothing checked what conformance level a seal actually came back at.**

The request names a level. The boot gate checks that level against the
backend's *advertised* capabilities. The returned bytes were then taken on
trust. A provider answering `CAdES_BASELINE_T` to a request for `LT` — a
client enabled for the wrong profile, a plan change, a provider-side default —
produced a seal that was correct in every record this node kept, and that
stops verifying when the signing certificate expires, years after the passport
was retention-locked and long after anything could be done about it.

`dpp_seal::cades::evidenced_level` now reads the returned CMS and reports the
highest baseline level whose distinguishing material is present: a
`signature-time-stamp` for `T`, long-term revocation material for `LT`, an
archival timestamp for `LTA`. The drain calls it on every seal and logs at
`error` when a seal bought as long-term carries no long-term material,
counting it in a new **`seal_downgraded_total`**. Its own counter rather than
another `seal_total{outcome=…}`: that label is a partition — a row is sealed
*or* retried *or* exhausted — and a downgraded row is a sealed row too, so
putting it there would make `sum(seal_total)` exceed the rows drained.

**It warns and does not fail the row.** The seal exists and has been billed, so
backing off would buy the same weaker seal again on the next pass — paying
twice to record the problem twice. Same reasoning as the produced-but-unrecorded
path, reached from the other side.

Two things this deliberately is not. It is a **floor, not a verdict**:
conforming to a baseline level means satisfying every row of the profile's
requirements table, and none of that is checked here — a level is reported only
when the material for it *and every level below it* is present, so under-reporting
is possible and over-reporting is not. And nothing here is **validated**: a
timestamp token is counted because it is in the bytes, not because anyone
confirmed it timestamps this signature. Same register as the rest of that
module.

The subtle part is where the long-term material lives, because the two
profiles in play disagree and **both are lawful today**. Commission
Implementing Regulation (EU) 2026/248 lists the formats public sector bodies
must recognise, in two annexes. Annex I is ETSI EN 319 122-1, which puts the
material in `SignedData.crls` and marks the `revocation-values` attribute
**`shall not be present`** at that level. Annex II is ETSI TS 103 173, which
carries it in that very attribute — and Annex II is not history: Article 3(2)
obliges recognition of those formats for seals **created before 23 February
2028**.

So a seal produced to either profile is lawful now and carries exactly what
the other forbids. Both homes are accepted; reading only one reported an
Annex II seal as `T` and raised a downgrade alarm against a provider that had
done nothing wrong, which is what
`either_home_of_the_revocation_material_evidences_baseline_lt` pins.

- **`just coderabbit-check`**, deliberately *not* in `just check`: validates
`.coderabbit.yaml` against CodeRabbit's published JSON Schema, fetched fresh
on every run.
Expand Down Expand Up @@ -944,6 +994,28 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):

### Fixed

- **`sealedAt` read as evidence and was not.** `GET /dpp/{dppId}/seal`
documented the field as *"When the QTSP produced it."* Every construction site
sets the node's own clock, and for the local development backend there is no
QTSP at all. Documentation only, in the handler and in the published schema.

It matters more than a normal doc slip because of what surrounds it. A seal
carries an independently established signing time only from baseline `T`
upward, where a timestamp authority attests it; at `B` there is no timestamp
token anywhere in the envelope, so `sealedAt` is an unattested claim by the
party that bought the seal. That response is scrupulous about exactly this
distinction everywhere else — `signingCertRef` says "as reported by the seal,
never verified", `sealedPayloadHash` says "a record, not proof", `verification`
states what was not checked. This was the one field that read as evidence
without saying it was not.

- **`LocalIdentity::generated_at` returned `Utc::now()`**, under a doc comment
saying it returned when the certificate was generated. It had no callers, so
nothing was wrong today; anything wiring it into a trust report would have got
a fresh timestamp and no reason to doubt it. Removed rather than corrected —
the identity is persisted so yesterday's seal still verifies, and nothing has
needed to ask when it was minted.

- **`SEAL_PROVIDER=local` could not produce a single seal, under any
configuration.** The drain loop named `SealMode::ProviderSeal` as a literal.
That is not a setting — it is a claim about *whose* attestation the seal is,
Expand Down
8 changes: 8 additions & 0 deletions api/components/schemas/seals/SealResponse.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ properties:
sealedAt:
type: string
format: date-time
description: >-
**This node's clock when the backend answered — not a trusted timestamp.**
Not necessarily when the signature was formed. A seal carries an
independently established signing time only from baseline level `T`
upward, where a timestamp authority attests it; at `B` there is no such
token anywhere in the envelope, so this is an unattested claim by the
party that bought the seal. Anything resting on *when* the seal was made
must read the timestamp token out of `sealValue`.
signingCertRef:
type:
- string
Expand Down
3 changes: 2 additions & 1 deletion api/openapi.bundled.json
Original file line number Diff line number Diff line change
Expand Up @@ -6071,7 +6071,8 @@
},
"sealedAt": {
"type": "string",
"format": "date-time"
"format": "date-time",
"description": "**This node's clock when the backend answered — not a trusted timestamp.** Not necessarily when the signature was formed. A seal carries an independently established signing time only from baseline level `T` upward, where a timestamp authority attests it; at `B` there is no such token anywhere in the envelope, so this is an unattested claim by the party that bought the seal. Anything resting on *when* the seal was made must read the timestamp token out of `sealValue`."
},
"signingCertRef": {
"type": [
Expand Down
1 change: 1 addition & 0 deletions api/openapi.bundled.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4973,6 +4973,7 @@ components:
sealedAt:
type: string
format: date-time
description: '**This node''s clock when the backend answered — not a trusted timestamp.** Not necessarily when the signature was formed. A seal carries an independently established signing time only from baseline level `T` upward, where a timestamp authority attests it; at `B` there is no such token anywhere in the envelope, so this is an unattested claim by the party that bought the seal. Anything resting on *when* the seal was made must read the timestamp token out of `sealValue`.'
signingCertRef:
type:
- string
Expand Down
205 changes: 180 additions & 25 deletions crates/dpp-node/src/infra/seal_drain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use dpp_domain::{
ports::seal::SealPort,
seal::{
SealConformanceLevel, SealCredentialRef, SealEnvelope, SealFormat, SealMode, SealRequest,
SealedEnvelope,
},
};
use dpp_types::SealOutbox;
Expand Down Expand Up @@ -131,32 +132,35 @@ pub async fn drain_once(
metrics::histogram!("seal_seconds").record(started.elapsed().as_secs_f64());

match outcome {
Ok(envelope) => match outbox.mark_sealed(row.id, &envelope).await {
Ok(()) => {
metrics::counter!("seal_total", "outcome" => "sealed").increment(1);
stats.sealed += 1;
Ok(envelope) => {
report_shortfall(&envelope, conformance_level, &row.passport_id);
match outbox.mark_sealed(row.id, &envelope).await {
Ok(()) => {
metrics::counter!("seal_total", "outcome" => "sealed").increment(1);
stats.sealed += 1;
}
// The seal exists and has been billed, but recording it failed.
// Back off and retry the *write* — `mark_sealed` is the only
// thing that closes the row, so leaving it pending is correct
// even though the next attempt will buy a second seal. Loud,
// because this is the one path that can cost twice.
Err(e) => {
tracing::error!(
passport_id = %row.passport_id,
error = %e,
"seal produced but not recorded — a retry will re-seal and re-bill"
);
back_off_or_exhaust(
outbox,
row.id,
row.attempts,
format!("seal recorded failed: {e}"),
&mut stats,
)
.await;
}
}
// The seal exists and has been billed, but recording it failed.
// Back off and retry the *write* — `mark_sealed` is the only
// thing that closes the row, so leaving it pending is correct
// even though the next attempt will buy a second seal. Loud,
// because this is the one path that can cost twice.
Err(e) => {
tracing::error!(
passport_id = %row.passport_id,
error = %e,
"seal produced but not recorded — a retry will re-seal and re-bill"
);
back_off_or_exhaust(
outbox,
row.id,
row.attempts,
format!("seal recorded failed: {e}"),
&mut stats,
)
.await;
}
},
}
Err(e) => {
back_off_or_exhaust(outbox, row.id, row.attempts, e.to_string(), &mut stats).await;
}
Expand All @@ -165,6 +169,81 @@ pub async fn drain_once(
stats
}

/// 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
/// boot gate checks that level against the backend's *advertised* capabilities,
/// and the returned bytes were taken on trust. A provider answering
/// `CAdES_BASELINE_T` to a request for `LT` — a client enabled for the wrong
/// profile, a plan change, a provider-side default — produced a seal that was
/// correct in every record this node kept, and that stops verifying when the
/// signing certificate expires, years after the passport was retention-locked.
///
/// # Why this only warns
///
/// The seal exists and has been billed. Failing the row would retry and buy the
/// same wrong thing again, so the shortfall is reported and the row closes
/// normally. That is the same reasoning as the `mark_sealed` failure path above,
/// reached from the opposite direction: there the seal is right and the record
/// failed, here the record will be right and the seal is weaker than ordered.
/// Neither is fixed by re-buying.
///
/// Silence on the happy path is deliberate. A drain that logged every seal's
/// level would bury the one line that matters under one per passport.
fn report_shortfall(
envelope: &SealedEnvelope,
requested: SealConformanceLevel,
passport_id: &dpp_domain::passport::PassportId,
) {
// A placeholder is not a seal, so there is nothing to fall short of.
if envelope.placeholder {
return;
}

let Ok(der) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&envelope.seal_value,
) else {
// Unreadable here means unreadable everywhere, and the seal route says
// as much through a null `signingCertRef`. Not this function's alarm to
// raise.
return;
};

let Ok(Some(evidenced)) = dpp_seal::cades::evidenced_level(&der) else {
return;
};

// Ordering by what actually matters rather than by enum position: the
// question is whether the seal outlives its signing certificate, and that is
// the line `SealConformanceLevel` itself draws. A `B`-for-`T` substitution is
// real but survivable; a `T`-for-`LT` one is the permanent kind.
if requested.survives_certificate_expiry() && !evidenced.survives_certificate_expiry() {
// Its own counter, not another `seal_total` outcome. That label is a
// partition — a row is sealed *or* retried *or* exhausted — and a
// downgraded row is a `sealed` row as well, so adding it there would
// make `sum(seal_total)` exceed the rows drained and quietly corrupt
// every ratio built on it.
metrics::counter!("seal_downgraded_total").increment(1);
tracing::error!(
passport_id = %passport_id,
requested = ?requested,
evidenced = ?evidenced,
"sealed below the requested level: this seal carries no long-term validation \
material and will stop verifying when its signing certificate expires. The \
passport is retention-locked, so it cannot be re-sealed — check the profile \
enabled for this client with the provider"
);
} else if evidenced != requested {
tracing::warn!(
passport_id = %passport_id,
requested = ?requested,
evidenced = ?evidenced,
"the seal's material does not match the level requested"
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -317,6 +396,82 @@ mod tests {
assert_eq!(outbox.sealed.lock().unwrap().len(), 1);
}

/// A backend that seals below the requested level still closes the row.
///
/// 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
/// 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.
///
/// Real CAdES bytes from the local backend rather than a stub: the check
/// reads unsigned attributes out of a CMS structure, so a placeholder string
/// would exercise the early return and prove nothing about the shortfall
/// path. Those bytes carry no timestamp and no revocation material, which is
/// exactly a `B` answer to an `LT` request.
#[tokio::test]
async fn a_seal_below_the_requested_level_is_still_recorded() {
struct Downgrades(String);

#[async_trait]
impl SealPort for Downgrades {
async fn seal(&self, _req: SealRequest) -> Result<SealedEnvelope, DppError> {
Ok(SealedEnvelope {
format: SealFormat::Cades,
seal_value: self.0.clone(),
signing_cert_ref: None,
sealed_at: chrono::Utc::now(),
placeholder: false,
})
}
async fn verify(&self, _e: &SealedEnvelope) -> Result<SealVerification, DppError> {
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");
let der = identity.sign_detached(&[0x11; 32]).expect("sign");
let b_level = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &der);

assert_eq!(
dpp_seal::cades::evidenced_level(&der).unwrap(),
Some(SealConformanceLevel::BaselineB),
"the fixture must actually be a downgrade, or this test asserts nothing"
);

let outbox = Arc::new(FakeOutbox {
rows: Mutex::new(vec![row(0)]),
..Default::default()
});
let stats = drain_once(
&(outbox.clone() as Arc<dyn SealOutbox>),
&(Arc::new(Downgrades(b_level)) as Arc<dyn SealPort>),
&test_key_ref(),
SealMode::ProviderSeal,
SealConformanceLevel::BaselineLt,
10,
)
.await;

assert_eq!(stats.sealed, 1, "a downgraded seal is still a sealed row");
assert_eq!(
stats.retried, 0,
"re-buying would cost twice and fix nothing"
);
assert_eq!(outbox.sealed.lock().unwrap().len(), 1);
assert!(outbox.failed.lock().unwrap().is_empty());
}

/// The row's stored digest is what gets sealed — never one re-derived from
/// whatever the passport says at drain time.
#[tokio::test]
Expand Down
Loading
Loading