From f6d4a84dfb49a692303b57aa884cf83b5fd44316 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 10 Sep 2026 23:33:33 +0200 Subject: [PATCH 1/2] feat(seal): report the level a seal actually came back at --- CHANGELOG.md | 67 ++++ .../schemas/seals/SealResponse.yaml | 8 + api/openapi.bundled.json | 3 +- api/openapi.bundled.yaml | 1 + crates/dpp-node/src/infra/seal_drain.rs | 205 ++++++++++-- crates/dpp-seal/src/cades.rs | 315 ++++++++++++++++++ crates/dpp-seal/src/local/sealer.rs | 5 - crates/dpp-vault/src/handlers/seal.rs | 13 +- 8 files changed, 585 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478547f8..6da86ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,51 @@ 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 + standards in play disagree. ETSI EN 319 122-1 puts it in `SignedData.crls` and + marks the `revocation-values` attribute **`shall not be present`** at that + level. ETSI TS 103 173 — the older profile, and the one Commission + Implementing Decision (EU) 2015/1506 names for cross-border recognition — puts + it in that very attribute. A seal built to the profile the law cites therefore + carries exactly what the modern standard forbids at the same level. Both homes + are accepted; reading only one reported a lawful `LT` 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. @@ -944,6 +989,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, diff --git a/api/components/schemas/seals/SealResponse.yaml b/api/components/schemas/seals/SealResponse.yaml index 60cb0832..f5d3991a 100644 --- a/api/components/schemas/seals/SealResponse.yaml +++ b/api/components/schemas/seals/SealResponse.yaml @@ -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 diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index 8c2b4710..adf34937 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -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": [ diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index dcce4e68..e59c1ad8 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -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 diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index f3f52744..1c4beea9 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -32,6 +32,7 @@ use dpp_domain::{ ports::seal::SealPort, seal::{ SealConformanceLevel, SealCredentialRef, SealEnvelope, SealFormat, SealMode, SealRequest, + SealedEnvelope, }, }; use dpp_types::SealOutbox; @@ -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; } @@ -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::*; @@ -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 { + 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 { + 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), + &(Arc::new(Downgrades(b_level)) as Arc), + &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] diff --git a/crates/dpp-seal/src/cades.rs b/crates/dpp-seal/src/cades.rs index 9af2ed7e..d147563d 100644 --- a/crates/dpp-seal/src/cades.rs +++ b/crates/dpp-seal/src/cades.rs @@ -23,6 +23,7 @@ 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 x509_cert::Certificate; use crate::error::SealError; @@ -35,6 +36,16 @@ fn malformed(what: impl std::fmt::Display) -> SealError { struct Signed { signer: SignerInfo, certificate: Certificate, + /// Whether `SignedData.crls` carried anything. + /// + /// 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, } /// Parse a detached CMS `SignedData` down to its single signer and certificate. @@ -69,9 +80,119 @@ fn parse(seal_der: &[u8]) -> Result { Ok(Signed { signer: signer.clone(), certificate: certificate.clone(), + crls_present: sd.crls.as_ref().is_some_and(|c| !c.0.as_slice().is_empty()), }) } +// ─── Conformance level, as far as the bytes show it ────────────────────────── + +/// `id-aa-signatureTimeStampToken` — the timestamp that distinguishes T. +/// +/// RFC 3161 / RFC 5126 §6.1.1. Not in `const-oid`'s bundled databases, so it is +/// written out here with its source rather than reached for by a path that does +/// not exist. +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-revocationValues` — revocation material carried as an attribute. +/// +/// RFC 5126 §6.3.4. The **older** home for the material that distinguishes a +/// long-term seal; see [`evidenced_level`] for why both homes are accepted. +const ID_AA_ETS_REVOCATION_VALUES: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.16.2.24"); + +/// `id-aa-ets-archiveTimestampV3` — the archival timestamp that distinguishes LTA. +/// +/// ETSI-assigned (`itu-t(0) identified-organization(4) etsi(0) 1733 attributes(2) +/// 4`), which is why it is not in any RFC database. +const ID_AA_ETS_ARCHIVE_TIMESTAMP_V3: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("0.4.0.1733.2.4"); + +/// `id-aa-ets-archiveTimestampV2` — the predecessor of the above. +/// +/// RFC 5126 §6.4.1. Accepted alongside v3 because a seal carrying either has +/// archival material; which generation it is does not change that. +const ID_AA_ETS_ARCHIVE_TIMESTAMP_V2: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.16.2.48"); + +/// The highest baseline level whose material is actually present in the seal. +/// +/// # Why this exists +/// +/// The level is chosen on the request and, until this function, nothing ever +/// looked at what came back. A provider that returns `CAdES_BASELINE_T` when +/// asked for `LT` — a client enabled for the wrong profile, a plan change, a +/// provider-side default — produces a seal that is correct in every record this +/// node keeps and stops verifying when the signing certificate expires, years +/// after the passport was retention-locked and long after anything could be +/// done about it. +/// +/// # This is a floor, not a verdict +/// +/// It reports that the *distinguishing material* for a level is present. It does +/// **not** report conformance. Conforming to a baseline level means satisfying +/// every row of the profile's requirements table — `signing-time` present, +/// `content-type` equal to `id-data`, the ESS signing-certificate reference, and +/// much more — and none of that is checked here. Nor is any of the material +/// validated: a timestamp token is counted because it is there, not because +/// anybody confirmed it timestamps this signature or that its TSA is trusted. +/// +/// So a `BaselineLt` from this function means "the material an LT seal carries +/// is in these bytes", and the honest use of it is to notice when *less* is +/// present than was paid for. Establishing the converse is an AdES validator's +/// job, as everywhere else in this module. +/// +/// # Cumulative, deliberately +/// +/// A level is reported only when the material for it *and every level below it* +/// is present. Under-reporting is safe — it prompts a look at a seal that may be +/// fine. Over-reporting hides exactly the downgrade this function exists to +/// catch, so a stray archival timestamp over a seal with no revocation material +/// yields `BaselineT`, not `BaselineLta`. +/// +/// # Two homes for the long-term material, and both count +/// +/// This is the part that is easy to get wrong, because the two standards in play +/// disagree. ETSI EN 319 122-1 puts an LT seal's revocation material in +/// `SignedData.crls` and marks the `revocation-values` attribute **`shall not be +/// present`** at that level. ETSI TS 103 173 — the older profile, and the one +/// Commission Implementing Decision (EU) 2015/1506 actually names for +/// cross-border recognition — gives its LT-Level clause a `Revocation values` +/// subclause, the CAdES-X Long attribute. +/// +/// A seal built to the profile the law cites therefore carries precisely the +/// attribute the modern standard forbids at the same level. Reading only +/// `SignedData.crls` would misreport such a seal as `BaselineT` and raise a +/// downgrade alarm against a provider that did nothing wrong. Both are accepted. +/// +/// `Ok(None)` when the bytes are not a seal this module can read, matching +/// [`signer_certificate_thumbprint`]: a stored seal must not be lost to a parse +/// failure on a field that describes it. +pub fn evidenced_level(seal_der: &[u8]) -> Result, SealError> { + let Ok(signed) = parse(seal_der) else { + return Ok(None); + }; + + let has = |oid: const_oid::ObjectIdentifier| { + signed + .signer + .unsigned_attrs + .as_ref() + .is_some_and(|attrs| attrs.iter().any(|a| a.oid == oid)) + }; + + let timestamped = has(ID_AA_SIGNATURE_TIME_STAMP_TOKEN); + let long_term = signed.crls_present || 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) { + (true, true, true) => SealConformanceLevel::BaselineLta, + (true, true, false) => SealConformanceLevel::BaselineLt, + (true, false, _) => SealConformanceLevel::BaselineT, + (false, _, _) => SealConformanceLevel::BaselineB, + })) +} + /// Hex SHA-256 over the DER of the certificate the seal carries. /// /// **Reported by the seal, not verified.** This identifies *which* certificate @@ -190,6 +311,200 @@ mod tests { ); } + // ─── evidenced_level ───────────────────────────────────────────────────── + + /// A real seal from the local backend, and the same seal with material added. + /// + /// Built by decorating genuine bytes rather than assembling a fixture from + /// nothing, for the reason given above: a hand-rolled structure would prove + /// the reader agrees with the fixture. Decorating leaves the signature + /// invalid — nothing here re-signs — which is correct for this function, + /// because it reports what a seal *carries* and validates none of it. + fn decorated( + attrs: &[const_oid::ObjectIdentifier], + with_crls: bool, + ) -> (Vec, tempfile::TempDir) { + use der::asn1::SetOfVec; + + 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 info = ContentInfo::from_der(&der).expect("CMS"); + let mut sd: SignedData = info.content.decode_as().expect("SignedData"); + + if !attrs.is_empty() { + let mut unsigned = SetOfVec::new(); + for oid in attrs { + let mut values = SetOfVec::new(); + // The content is irrelevant: presence is the whole signal, and + // a real token would still not be validated by anything here. + values + .insert(der::Any::from(der::asn1::Null)) + .expect("attribute value"); + unsigned + .insert(x509_cert::attr::Attribute { oid: *oid, values }) + .expect("unsigned attribute"); + } + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + signers[0].unsigned_attrs = Some(unsigned); + let mut set = SetOfVec::new(); + set.insert(signers.remove(0)).expect("signer"); + sd.signer_infos = cms::signed_data::SignerInfos::from(set); + } + + if with_crls { + // One CRL entry stands for "the revocation material an LT seal + // carries". Its contents are never read — `crls_present` is a + // boolean by design. + let crl = x509_cert::crl::CertificateList { + tbs_cert_list: x509_cert::crl::TbsCertList { + version: x509_cert::Version::V2, + signature: sd.signer_infos.0.as_slice()[0].signature_algorithm.clone(), + issuer: match &sd.signer_infos.0.as_slice()[0].sid { + cms::signed_data::SignerIdentifier::IssuerAndSerialNumber(ias) => { + ias.issuer.clone() + } + other => panic!("the local backend identifies by issuer+serial: {other:?}"), + }, + this_update: x509_cert::time::Time::UtcTime( + der::asn1::UtcTime::from_unix_duration(core::time::Duration::from_secs(0)) + .expect("time"), + ), + next_update: None, + revoked_certificates: None, + crl_extensions: None, + }, + signature_algorithm: sd.signer_infos.0.as_slice()[0].signature_algorithm.clone(), + signature: der::asn1::BitString::from_bytes(&[0]).expect("bits"), + }; + let mut set = SetOfVec::new(); + set.insert(cms::revocation::RevocationInfoChoice::Crl(crl)) + .expect("crl"); + sd.crls = Some(cms::revocation::RevocationInfoChoices(set)); + } + + let info = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&sd).expect("encode"), + }; + (info.to_der().expect("der"), dir) + } + + /// Unreadable bytes yield no level rather than a wrong one. + #[test] + fn unreadable_bytes_evidence_no_level() { + assert_eq!(evidenced_level(b"not a CMS structure").unwrap(), None); + assert_eq!(evidenced_level(&[]).unwrap(), None); + } + + /// The local backend's seal evidences `BaselineB`, and that is what it + /// advertises. + /// + /// The one place in this crate where the claim and the bytes can be checked + /// against each other end to end: `sign_detached` sets `unsigned_attrs: + /// None`, `capabilities()` advertises `BaselineB` only, and this asserts the + /// two agree. If the sealer ever grew a timestamp without its advertisement + /// following, this fails. + #[test] + fn the_local_seal_evidences_baseline_b() { + let (der, _dir) = decorated(&[], false); + assert_eq!( + evidenced_level(&der).unwrap(), + Some(SealConformanceLevel::BaselineB) + ); + } + + /// A signature timestamp, and nothing more, evidences `BaselineT`. + #[test] + fn a_signature_timestamp_evidences_baseline_t() { + let (der, _dir) = decorated(&[ID_AA_SIGNATURE_TIME_STAMP_TOKEN], false); + assert_eq!( + evidenced_level(&der).unwrap(), + Some(SealConformanceLevel::BaselineT) + ); + } + + /// Both homes of the long-term material evidence `BaselineLt`. + /// + /// The finding this test exists for. ETSI EN 319 122-1 puts an LT seal's + /// revocation material in `SignedData.crls` and forbids the + /// `revocation-values` attribute at that level; ETSI TS 103 173 — the older + /// profile, and the one Commission Implementing Decision (EU) 2015/1506 + /// names for cross-border recognition — carries it in that very attribute. + /// + /// So a seal produced to the profile the law cites carries exactly what the + /// modern standard forbids. Reading only one home would report such a seal + /// as `BaselineT` and raise a downgrade alarm against a provider that did + /// nothing wrong. + #[test] + fn either_home_of_the_revocation_material_evidences_baseline_lt() { + let (modern, _a) = decorated(&[ID_AA_SIGNATURE_TIME_STAMP_TOKEN], true); + let (legacy, _b) = decorated( + &[ + ID_AA_SIGNATURE_TIME_STAMP_TOKEN, + ID_AA_ETS_REVOCATION_VALUES, + ], + false, + ); + + assert_eq!( + evidenced_level(&modern).unwrap(), + Some(SealConformanceLevel::BaselineLt), + "EN 319 122-1 puts the material in SignedData.crls" + ); + assert_eq!( + evidenced_level(&legacy).unwrap(), + Some(SealConformanceLevel::BaselineLt), + "TS 103 173 — the profile 2015/1506 cites — puts it in revocation-values" + ); + } + + /// Both archival timestamp generations evidence `BaselineLta`. + #[test] + fn an_archival_timestamp_evidences_baseline_lta() { + for oid in [ + ID_AA_ETS_ARCHIVE_TIMESTAMP_V3, + ID_AA_ETS_ARCHIVE_TIMESTAMP_V2, + ] { + let (der, _dir) = decorated(&[ID_AA_SIGNATURE_TIME_STAMP_TOKEN, oid], true); + assert_eq!( + evidenced_level(&der).unwrap(), + Some(SealConformanceLevel::BaselineLta), + "{oid} is an archival timestamp" + ); + } + } + + /// Material for a high level without the levels beneath it reports the floor. + /// + /// The direction of this rounding is the whole point. Under-reporting + /// prompts a look at a seal that may be fine; over-reporting hides the + /// downgrade the function exists to catch, so an archival timestamp over a + /// seal carrying no revocation material must not read as `BaselineLta`. + #[test] + fn a_level_is_never_reported_above_the_material_beneath_it() { + let (no_revocation, _a) = decorated( + &[ + ID_AA_SIGNATURE_TIME_STAMP_TOKEN, + ID_AA_ETS_ARCHIVE_TIMESTAMP_V3, + ], + false, + ); + assert_eq!( + evidenced_level(&no_revocation).unwrap(), + Some(SealConformanceLevel::BaselineT), + "archival material without long-term material is not LTA" + ); + + let (no_timestamp, _b) = decorated(&[ID_AA_ETS_REVOCATION_VALUES], true); + assert_eq!( + evidenced_level(&no_timestamp).unwrap(), + Some(SealConformanceLevel::BaselineB), + "revocation material without a signature timestamp is not LT" + ); + } + /// A seal signed by a different identity reports a different certificate. /// /// Without this, the function could return a constant and the test above diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index c0a12b66..c45ed32e 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -196,11 +196,6 @@ impl LocalIdentity { info.to_der() .map_err(|e| SealError::Config(format!("cannot DER-encode the seal: {e}"))) } - - /// When this identity's certificate was generated. - pub fn generated_at(&self) -> chrono::DateTime { - Utc::now() - } } #[async_trait] diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index b76213ad..7f0dc4ec 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -89,7 +89,18 @@ pub struct SealResponse { pub format: String, /// Base64 detached CAdES (`.p7s`) as returned by the QTSP. pub seal_value: String, - /// When the QTSP produced it. + /// **This node's clock when the backend answered — not a trusted timestamp.** + /// + /// Stated rather than left to inference, like every other field here. It is + /// not necessarily when the signature was formed, and for the local + /// development backend there is no QTSP involved at all. + /// + /// A seal carries an independently established signing time only from + /// `B-T` upward, where a timestamp authority attests it. At `B-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`, which is the only + /// place an attested time can be. pub sealed_at: chrono::DateTime, /// Hex SHA-256 of the certificate the seal names as its signer, **as From 03c7740e555793a793c33c1acd37da65b52151e1 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 10 Sep 2026 23:50:32 +0200 Subject: [PATCH 2/2] docs(seal): cite 2026/248, which repealed 2015/1506 --- CHANGELOG.md | 21 ++++++++------ crates/dpp-seal/src/cades.rs | 53 ++++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da86ed1..db384322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,14 +171,19 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): module. The subtle part is where the long-term material lives, because the two - standards in play disagree. ETSI EN 319 122-1 puts it in `SignedData.crls` and - marks the `revocation-values` attribute **`shall not be present`** at that - level. ETSI TS 103 173 — the older profile, and the one Commission - Implementing Decision (EU) 2015/1506 names for cross-border recognition — puts - it in that very attribute. A seal built to the profile the law cites therefore - carries exactly what the modern standard forbids at the same level. Both homes - are accepted; reading only one reported a lawful `LT` seal as `T` and raised a - downgrade alarm against a provider that had done nothing wrong, which is what + 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 diff --git a/crates/dpp-seal/src/cades.rs b/crates/dpp-seal/src/cades.rs index d147563d..e2718b37 100644 --- a/crates/dpp-seal/src/cades.rs +++ b/crates/dpp-seal/src/cades.rs @@ -152,18 +152,29 @@ const ID_AA_ETS_ARCHIVE_TIMESTAMP_V2: const_oid::ObjectIdentifier = /// /// # Two homes for the long-term material, and both count /// -/// This is the part that is easy to get wrong, because the two standards in play -/// disagree. ETSI EN 319 122-1 puts an LT seal's revocation material in -/// `SignedData.crls` and marks the `revocation-values` attribute **`shall not be -/// present`** at that level. ETSI TS 103 173 — the older profile, and the one -/// Commission Implementing Decision (EU) 2015/1506 actually names for -/// cross-border recognition — gives its LT-Level clause a `Revocation values` -/// subclause, the CAdES-X Long attribute. +/// This is the part that is easy to get wrong, because the two profiles in play +/// disagree — and **both are lawful right now**, which is what makes reading +/// only one of them a live defect rather than a theoretical one. /// -/// A seal built to the profile the law cites therefore carries precisely the -/// attribute the modern standard forbids at the same level. Reading only -/// `SignedData.crls` would misreport such a seal as `BaselineT` and raise a -/// downgrade alarm against a provider that did nothing wrong. Both are accepted. +/// Commission Implementing Regulation (EU) 2026/248 lists the formats public +/// sector bodies must recognise. It carries two annexes: +/// +/// - **Annex I** — the current set, which for CAdES is ETSI EN 319 122-1. That +/// standard puts an LT seal's revocation material in `SignedData.crls`, and +/// marks the `revocation-values` attribute **`shall not be present`** at that +/// level. +/// - **Annex II** — the superseded set, which for CAdES is ETSI TS 103 173. +/// That profile's LT-Level clause has a `Revocation values` subclause: the +/// CAdES-X Long attribute, in the very place Annex I's standard forbids it. +/// +/// Annex II is not history. Article 3(2) obliges recognition of seals in those +/// formats where they were **created before 23 February 2028**, so seals of both +/// shapes are being produced and must both be read correctly until then — and, +/// since a passport's seal is retention-locked, long after. +/// +/// Reading only `SignedData.crls` would misreport an Annex II seal as +/// `BaselineT` and raise a downgrade alarm against a provider that did nothing +/// wrong. Both are accepted. /// /// `Ok(None)` when the bytes are not a seal this module can read, matching /// [`signer_certificate_thumbprint`]: a stored seal must not be lost to a parse @@ -427,15 +438,17 @@ mod tests { /// Both homes of the long-term material evidence `BaselineLt`. /// - /// The finding this test exists for. ETSI EN 319 122-1 puts an LT seal's + /// The finding this test exists for. ETSI EN 319 122-1 — Annex I of + /// Commission Implementing Regulation (EU) 2026/248 — puts an LT seal's /// revocation material in `SignedData.crls` and forbids the - /// `revocation-values` attribute at that level; ETSI TS 103 173 — the older - /// profile, and the one Commission Implementing Decision (EU) 2015/1506 - /// names for cross-border recognition — carries it in that very attribute. + /// `revocation-values` attribute at that level. ETSI TS 103 173, that + /// Regulation's Annex II, carries it in that very attribute. /// - /// So a seal produced to the profile the law cites carries exactly what the - /// modern standard forbids. Reading only one home would report such a seal - /// as `BaselineT` and raise a downgrade alarm against a provider that did + /// Both annexes are live: Article 3(2) obliges recognition of Annex II + /// formats for seals created before 23 February 2028. So a seal produced to + /// either profile is lawful today and carries exactly what the other + /// forbids. Reading only one home would report an Annex II seal as + /// `BaselineT` and raise a downgrade alarm against a provider that did /// nothing wrong. #[test] fn either_home_of_the_revocation_material_evidences_baseline_lt() { @@ -451,12 +464,12 @@ mod tests { assert_eq!( evidenced_level(&modern).unwrap(), Some(SealConformanceLevel::BaselineLt), - "EN 319 122-1 puts the material in SignedData.crls" + "EN 319 122-1 (2026/248 Annex I) puts the material in SignedData.crls" ); assert_eq!( evidenced_level(&legacy).unwrap(), Some(SealConformanceLevel::BaselineLt), - "TS 103 173 — the profile 2015/1506 cites — puts it in revocation-values" + "TS 103 173 (2026/248 Annex II, lawful until Feb 2028) puts it in revocation-values" ); }