From ec8206ff4d2a289568ea77ad3f8b08d39075ec98 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 13 Aug 2026 22:54:51 +0200 Subject: [PATCH 01/20] refactor(seal): give each backend its own module --- crates/dpp-seal/src/adapter.rs | 4 +- crates/dpp-seal/src/config.rs | 383 +++++++------------------- crates/dpp-seal/src/eideasy/client.rs | 2 +- crates/dpp-seal/src/eideasy/config.rs | 287 +++++++++++++++++++ crates/dpp-seal/src/eideasy/mod.rs | 2 + crates/dpp-seal/src/lib.rs | 14 +- crates/dpp-seal/src/local/config.rs | 77 ++++++ crates/dpp-seal/src/local/mod.rs | 15 + crates/dpp-seal/src/tests.rs | 2 +- 9 files changed, 492 insertions(+), 294 deletions(-) create mode 100644 crates/dpp-seal/src/eideasy/config.rs create mode 100644 crates/dpp-seal/src/local/config.rs create mode 100644 crates/dpp-seal/src/local/mod.rs diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index affd39e..947473f 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -16,7 +16,7 @@ use dpp_domain::{ }; use tracing::warn; -use crate::config::EideasyConfig; +use crate::eideasy::EideasyConfig; use crate::eideasy::client::EideasyClient; use crate::error::SealError; @@ -122,7 +122,7 @@ impl SealPort for QtspSealAdapter { #[cfg(test)] mod tests { use super::*; - use crate::config::{SANDBOX_BASE_URL, test_config}; + use crate::eideasy::config::{SANDBOX_BASE_URL, test_config}; #[test] fn unconfigured_reports_ghost_capabilities() { diff --git a/crates/dpp-seal/src/config.rs b/crates/dpp-seal/src/config.rs index 164dd29..4a3445d 100644 --- a/crates/dpp-seal/src/config.rs +++ b/crates/dpp-seal/src/config.rs @@ -1,337 +1,148 @@ -//! Configuration for the eID Easy Cloud Direct e-Sealing backend. +//! Which sealing backend this node runs, resolved from the environment. //! -//! The HMAC key is an operator-supplied, node-wide credential for one outbound -//! service, needed at boot and never created at runtime — the same category as -//! `EU_REGISTRY_CLIENT_SECRET` and `MTLS_PROXY_SHARED_SECRET`, and delivered the -//! same way (the node's `.env`, mode 600). It is deliberately not -//! in the key store, which holds Ed25519 key pairs for DID publication, and not -//! in Postgres, which would put a live signing credential in every backup. - -use std::time::Duration; - -use zeroize::Zeroizing; +//! This module knows the *selection* rule and nothing about any particular +//! backend. Each backend owns its own variables, its own validation and its own +//! failure messages, inside its own module — so adding one is additive here and +//! removing one leaves nothing behind. use crate::error::SealError; -/// Which eID Easy deployment the configured base URL points at. -/// -/// Derived from the host rather than configured separately, so the environment -/// and the URL cannot disagree. This is what the node maps to its trust tier — -/// a sandbox seal is a real seal from a real API, but it carries eID Easy's test -/// certificate and has no legal validity, which is a different claim from Ghost. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EideasyEnvironment { - /// `test.eideasy.com` — developer credentials, eID Easy test certificates. - Sandbox, - /// `id.eideasy.com` — production, bound to the qualified certificate. - Production, -} - -pub const SANDBOX_BASE_URL: &str = "https://test.eideasy.com"; -pub const PRODUCTION_BASE_URL: &str = "https://id.eideasy.com"; - -const SANDBOX_HOST: &str = "test.eideasy.com"; -const PRODUCTION_HOST: &str = "id.eideasy.com"; - -/// Resolved eID Easy configuration. +/// Selects the sealing backend. Unset means no seal provider. /// -/// `Debug` is implemented by hand: the derived one would print `hmac_key` in -/// full through any `{:?}` — a `tracing` field, an `anyhow` context, a panic -/// message. -#[derive(Clone)] -pub struct EideasyConfig { - /// Origin only, no trailing slash. The path is [`crate::eideasy::client::ESEAL_PATH`]. - pub base_url: String, - /// Which deployment `base_url` names. - pub environment: EideasyEnvironment, - /// From eID Easy "My Webpages". Not a secret — it travels in the request body. - pub client_id: String, - /// The e-seal HMAC key, shown once at generation. - pub hmac_key: Zeroizing, - /// Must be a profile eID Easy has enabled for `client_id`. - pub signature_profile: String, - pub request_timeout: Duration, -} - -impl std::fmt::Debug for EideasyConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EideasyConfig") - .field("base_url", &self.base_url) - .field("environment", &self.environment) - .field("client_id", &self.client_id) - .field("hmac_key", &"[redacted]") - .field("signature_profile", &self.signature_profile) - .field("request_timeout", &self.request_timeout) - .finish() - } -} - -/// Selects which QTSP backend the node seals with. -/// -/// `eideasy` is the only value today. It exists as a selector anyway because env -/// var names are a published interface: adding a provider later must not force -/// every self-hoster through a config migration. An unrecognised value is a boot -/// error, not a fallback — a node that cannot name its trust provider must not -/// quietly become one that has none. +/// Deliberately explicit rather than inferred from whichever credentials happen +/// to be present: a node that cannot name its trust provider must not quietly +/// become one that has none. pub const SEAL_PROVIDER: &str = "SEAL_PROVIDER"; -const PROVIDER_EIDEASY: &str = "eideasy"; const PROVIDER_NONE: &str = "none"; -impl EideasyConfig { - /// Read configuration from the environment. - /// - /// Returns `Ok(None)` when no provider is selected — the node then wires - /// `GhostSeal`, which a development profile permits and a production profile - /// refuses at boot. - /// - /// A *partial* configuration is an error, not a `None`. Silently falling back - /// to a ghost because one of three variables was misspelled would downgrade a - /// node from qualified sealing to no sealing on a typo, which is precisely the - /// failure the trust report exists to make impossible. Setting the - /// `SEAL_EIDEASY_*` variables without selecting the provider is the same class - /// of mistake and is refused for the same reason. - /// - /// When a second backend lands this becomes a dispatch on - /// [`SEAL_PROVIDER`]; the shape is here so that change is additive. - pub fn from_env() -> Result, SealError> { +/// The backend a node is configured to seal with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SealProvider { + /// The hosted QTSP backend. + Qtsp, + /// In-process signing with a locally generated key. Development only — the + /// certificate is not on an EU Trusted List, so the envelope is structurally + /// a seal and legally nothing. + Local, + /// No provider. The node wires `GhostSeal`, which a development profile + /// permits and a production profile refuses at boot. + None, +} + +impl SealProvider { + /// Read the selection from the process environment. + pub fn from_env() -> Result { Self::resolve(|name| std::env::var(name).ok()) } - /// The whole of [`Self::from_env`]'s logic, over an arbitrary lookup. + /// The whole of [`Self::from_env`]'s logic over an arbitrary lookup. /// - /// Split out so the rules above — provider selection, all-or-nothing - /// credentials, host allowlisting — are testable as a pure function. - /// Exercising them through the real environment would mean mutating - /// process-global state from tests, which is `unsafe` under Rust 2024, forces - /// them to serialise against one another, and lets a stray variable in the - /// developer's shell change the result. - pub fn resolve(get: impl Fn(&str) -> Option) -> Result, SealError> { - let read = |name: &str| { - get(name) - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - }; - - let base_url = read("SEAL_EIDEASY_BASE_URL"); - let client_id = read("SEAL_EIDEASY_CLIENT_ID"); - let hmac_key = read("SEAL_EIDEASY_HMAC_KEY"); - let any_eideasy_var = base_url.is_some() || client_id.is_some() || hmac_key.is_some(); - - match read(SEAL_PROVIDER).as_deref() { + /// Split out so the rule is testable as a pure function. Exercising it + /// through the real environment would mean mutating process-global state + /// from tests, which is `unsafe` under Rust 2024, forces them to serialise + /// against one another, and lets a stray variable in the developer's shell + /// change the result. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let selected = get(SEAL_PROVIDER) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()); + + match selected.as_deref() { None | Some(PROVIDER_NONE) => { - if any_eideasy_var { + // A backend's credentials present without its provider selected + // is a misconfiguration, not a fallback: silently ghosting on a + // misspelled variable would downgrade a node from qualified + // sealing to no sealing, which is exactly what the trust report + // exists to make impossible. + if let Some(stray) = stray_backend_vars(&get) { return Err(SealError::Config(format!( - "SEAL_EIDEASY_* is set but {SEAL_PROVIDER} is not `{PROVIDER_EIDEASY}` — \ - set it, or unset the SEAL_EIDEASY_* variables to run with GhostSeal" + "{stray} is set but {SEAL_PROVIDER} is not — set it, or unset those \ + variables to run with GhostSeal" ))); } - return Ok(None); - } - Some(PROVIDER_EIDEASY) => {} - Some(other) => { - return Err(SealError::Config(format!( - "unknown {SEAL_PROVIDER} `{other}` — supported: `{PROVIDER_EIDEASY}`, \ - `{PROVIDER_NONE}`" - ))); + Ok(Self::None) } + Some(crate::eideasy::config::PROVIDER) => Ok(Self::Qtsp), + Some(crate::local::config::PROVIDER) => Ok(Self::Local), + Some(other) => Err(SealError::Config(format!( + "unknown {SEAL_PROVIDER} `{other}` — supported: `{}`, `{}`, `{PROVIDER_NONE}`", + crate::eideasy::config::PROVIDER, + crate::local::config::PROVIDER + ))), } - - let missing: Vec<&str> = [ - ("SEAL_EIDEASY_BASE_URL", &base_url), - ("SEAL_EIDEASY_CLIENT_ID", &client_id), - ("SEAL_EIDEASY_HMAC_KEY", &hmac_key), - ] - .iter() - .filter(|(_, v)| v.is_none()) - .map(|(n, _)| *n) - .collect(); - if !missing.is_empty() { - return Err(SealError::Config(format!( - "{SEAL_PROVIDER}=`{PROVIDER_EIDEASY}` but {} not set", - missing.join(", ") - ))); - } - - let base_url = base_url.expect("checked non-empty above"); - let environment = environment_for(&base_url)?; - - Ok(Some(Self { - base_url: base_url.trim_end_matches('/').to_owned(), - environment, - client_id: client_id.expect("checked non-empty above"), - hmac_key: Zeroizing::new(hmac_key.expect("checked non-empty above")), - signature_profile: read("SEAL_EIDEASY_SIGNATURE_PROFILE").unwrap_or_else(|| { - crate::eideasy::EsealRequest::PROFILE_CADES_BASELINE_T.to_owned() - }), - request_timeout: Duration::from_secs(30), - })) - } -} - -/// Map a base URL to its eID Easy deployment, refusing anything else. -/// -/// The allowlist is the point: an unrecognised host cannot be classified, and a -/// seal whose trust tier we cannot state is worse than no seal. A typo in -/// `SEAL_EIDEASY_BASE_URL` fails the node at boot rather than shipping passport -/// digests to whatever host was actually spelled. -fn environment_for(base_url: &str) -> Result { - let parsed = url::Url::parse(base_url) - .map_err(|e| SealError::Config(format!("SEAL_EIDEASY_BASE_URL is not a URL: {e}")))?; - if parsed.scheme() != "https" { - return Err(SealError::Config(format!( - "SEAL_EIDEASY_BASE_URL must be https, got {}", - parsed.scheme() - ))); - } - match parsed.host_str() { - Some(SANDBOX_HOST) => Ok(EideasyEnvironment::Sandbox), - Some(PRODUCTION_HOST) => Ok(EideasyEnvironment::Production), - other => Err(SealError::Config(format!( - "SEAL_EIDEASY_BASE_URL host {} is not eID Easy — expected {SANDBOX_HOST} or {PRODUCTION_HOST}", - other.unwrap_or("") - ))), } } -/// Config pointed at an arbitrary base URL, for tests that stand up a local mock. -/// -/// Deliberately bypasses [`environment_for`]: the host allowlist guards the -/// operator-facing `from_env` path, and applying it here would make the endpoint -/// untestable without reaching eID Easy. -#[cfg(test)] -pub(crate) fn test_config(base_url: &str) -> EideasyConfig { - EideasyConfig { - base_url: base_url.to_owned(), - environment: EideasyEnvironment::Sandbox, - client_id: "test-client".into(), - hmac_key: Zeroizing::new("test-key".into()), - signature_profile: "CAdES_BASELINE_T".into(), - request_timeout: Duration::from_secs(5), - } +/// The name of a backend's variable group that is set while no provider is +/// selected, if any. Each backend answers for its own prefix. +fn stray_backend_vars(get: &impl Fn(&str) -> Option) -> Option<&'static str> { + [ + ( + crate::eideasy::config::ENV_GROUP, + crate::eideasy::config::any_env_set(get), + ), + ( + crate::local::config::ENV_GROUP, + crate::local::config::any_env_set(get), + ), + ] + .into_iter() + .find_map(|(group, set)| set.then_some(group)) } #[cfg(test)] mod tests { use super::*; - /// Resolve against a fixed set of variables — no process environment, so - /// these are pure, parallel-safe, and cannot be perturbed by the developer's - /// shell. - fn resolve(vars: &[(&str, &str)]) -> Result, SealError> { - let owned: Vec<(String, String)> = vars + /// A lookup over a fixed set of variables — no process environment, so these + /// are pure and parallel-safe. + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, String)> = pairs .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(); - EideasyConfig::resolve(|name| { + move |name: &str| { owned .iter() .find(|(k, _)| k == name) .map(|(_, v)| v.clone()) - }) - } - - const FULL: [(&str, &str); 4] = [ - (SEAL_PROVIDER, PROVIDER_EIDEASY), - ("SEAL_EIDEASY_BASE_URL", SANDBOX_BASE_URL), - ("SEAL_EIDEASY_CLIENT_ID", "client-id"), - ("SEAL_EIDEASY_HMAC_KEY", "hmac-key"), - ]; - - #[test] - fn no_provider_selected_is_a_ghost_not_an_error() { - assert!(resolve(&[]).unwrap().is_none()); - assert!( - resolve(&[(SEAL_PROVIDER, PROVIDER_NONE)]) - .unwrap() - .is_none() - ); - } - - #[test] - fn a_full_configuration_resolves() { - let cfg = resolve(&FULL).unwrap().expect("configured"); - assert_eq!(cfg.environment, EideasyEnvironment::Sandbox); - assert_eq!(cfg.client_id, "client-id"); - // Defaulted, not required. - assert_eq!(cfg.signature_profile, "CAdES_BASELINE_T"); - } - - /// The failure this selector exists to prevent: credentials present, provider - /// not selected. Silently ghosting here would downgrade a node that was - /// configured for qualified sealing into one with none. - #[test] - fn credentials_without_a_selected_provider_are_refused() { - let err = resolve(&FULL[1..]).expect_err("must not ghost silently"); - assert!(err.to_string().contains(SEAL_PROVIDER)); - } - - #[test] - fn a_partial_configuration_names_what_is_missing() { - let err = resolve(&FULL[..2]).expect_err("partial must fail"); - let msg = err.to_string(); - assert!(msg.contains("SEAL_EIDEASY_CLIENT_ID"), "{msg}"); - assert!(msg.contains("SEAL_EIDEASY_HMAC_KEY"), "{msg}"); - } - - #[test] - fn an_unknown_provider_fails_the_boot() { - let err = - resolve(&[(SEAL_PROVIDER, "some-other-qtsp")]).expect_err("unknown provider must fail"); - assert!(err.to_string().contains("some-other-qtsp")); + } } - /// Blank is not "set" — an operator commenting a value out leaves an empty - /// string behind, and that must read as absent rather than as a credential. #[test] - fn blank_values_count_as_unset() { - assert!( - resolve(&[(SEAL_PROVIDER, " ")]).unwrap().is_none(), - "a whitespace-only provider should ghost, not fail as unknown" - ); + fn unset_is_none() { + assert_eq!(SealProvider::resolve(env(&[])).unwrap(), SealProvider::None); } #[test] - fn known_hosts_map_to_their_environments() { + fn explicit_none_is_none() { assert_eq!( - environment_for(SANDBOX_BASE_URL).unwrap(), - EideasyEnvironment::Sandbox - ); - assert_eq!( - environment_for(PRODUCTION_BASE_URL).unwrap(), - EideasyEnvironment::Production + SealProvider::resolve(env(&[(SEAL_PROVIDER, "none")])).unwrap(), + SealProvider::None ); } #[test] - fn an_unknown_host_is_refused() { - // A typo must not become "some seal from somewhere" — it must not boot. - for url in [ - "https://id.eideasy.com.evil.test", - "https://eideasy.com", - "https://localhost:8080", - ] { - assert!( - environment_for(url).is_err(), - "{url} was accepted as an eID Easy host" - ); - } - } - - #[test] - fn plaintext_http_is_refused() { - assert!(environment_for("http://test.eideasy.com").is_err()); + fn an_unknown_provider_names_the_supported_ones() { + let msg = SealProvider::resolve(env(&[(SEAL_PROVIDER, "acme")])) + .unwrap_err() + .to_string(); + assert!(msg.contains("acme"), "{msg}"); + assert!(msg.contains(crate::local::config::PROVIDER), "{msg}"); } + /// Credentials without a selection is refused rather than ghosted. + /// + /// The failure this guards is a typo in `SEAL_PROVIDER` on a node that is + /// otherwise fully configured to seal: falling back would publish passports + /// with no qualified seal and no error. #[test] - fn debug_does_not_print_the_hmac_key() { - let mut cfg = test_config(SANDBOX_BASE_URL); - cfg.hmac_key = Zeroizing::new("super-secret-key-material".into()); - let rendered = format!("{cfg:?}"); - assert!( - !rendered.contains("super-secret-key-material"), - "Debug leaked the HMAC key: {rendered}" - ); - assert!(rendered.contains("[redacted]")); + fn backend_vars_without_a_selection_are_refused() { + let msg = SealProvider::resolve(env(&[("SEAL_LOCAL_KEY_PATH", "/tmp/k.pem")])) + .unwrap_err() + .to_string(); + assert!(msg.contains(SEAL_PROVIDER), "{msg}"); } } diff --git a/crates/dpp-seal/src/eideasy/client.rs b/crates/dpp-seal/src/eideasy/client.rs index 25db56c..a81991b 100644 --- a/crates/dpp-seal/src/eideasy/client.rs +++ b/crates/dpp-seal/src/eideasy/client.rs @@ -21,7 +21,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use hmac::{Hmac, Mac}; use sha2::Sha256; -use crate::config::EideasyConfig; +use super::config::EideasyConfig; use crate::error::SealError; use super::types::{EsealFile, EsealRequest, EsealResponse}; diff --git a/crates/dpp-seal/src/eideasy/config.rs b/crates/dpp-seal/src/eideasy/config.rs new file mode 100644 index 0000000..7cd07fc --- /dev/null +++ b/crates/dpp-seal/src/eideasy/config.rs @@ -0,0 +1,287 @@ +//! Configuration for the eID Easy Cloud Direct e-Sealing backend. +//! +//! The HMAC key is an operator-supplied, node-wide credential for one outbound +//! service, needed at boot and never created at runtime — the same category as +//! `EU_REGISTRY_CLIENT_SECRET` and `MTLS_PROXY_SHARED_SECRET`, and delivered the +//! same way (the node's `.env`, mode 600). It is deliberately not +//! in the key store, which holds Ed25519 key pairs for DID publication, and not +//! in Postgres, which would put a live signing credential in every backup. + +use std::time::Duration; + +use zeroize::Zeroizing; + +use crate::error::SealError; + +/// Which eID Easy deployment the configured base URL points at. +/// +/// Derived from the host rather than configured separately, so the environment +/// and the URL cannot disagree. This is what the node maps to its trust tier — +/// a sandbox seal is a real seal from a real API, but it carries eID Easy's test +/// certificate and has no legal validity, which is a different claim from Ghost. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EideasyEnvironment { + /// `test.eideasy.com` — developer credentials, eID Easy test certificates. + Sandbox, + /// `id.eideasy.com` — production, bound to the qualified certificate. + Production, +} + +pub const SANDBOX_BASE_URL: &str = "https://test.eideasy.com"; +pub const PRODUCTION_BASE_URL: &str = "https://id.eideasy.com"; + +const SANDBOX_HOST: &str = "test.eideasy.com"; +const PRODUCTION_HOST: &str = "id.eideasy.com"; + +/// Resolved eID Easy configuration. +/// +/// `Debug` is implemented by hand: the derived one would print `hmac_key` in +/// full through any `{:?}` — a `tracing` field, an `anyhow` context, a panic +/// message. +#[derive(Clone)] +pub struct EideasyConfig { + /// Origin only, no trailing slash. The path is [`crate::eideasy::client::ESEAL_PATH`]. + pub base_url: String, + /// Which deployment `base_url` names. + pub environment: EideasyEnvironment, + /// From eID Easy "My Webpages". Not a secret — it travels in the request body. + pub client_id: String, + /// The e-seal HMAC key, shown once at generation. + pub hmac_key: Zeroizing, + /// Must be a profile eID Easy has enabled for `client_id`. + pub signature_profile: String, + pub request_timeout: Duration, +} + +impl std::fmt::Debug for EideasyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EideasyConfig") + .field("base_url", &self.base_url) + .field("environment", &self.environment) + .field("client_id", &self.client_id) + .field("hmac_key", &"[redacted]") + .field("signature_profile", &self.signature_profile) + .field("request_timeout", &self.request_timeout) + .finish() + } +} + +/// The `SEAL_PROVIDER` value that selects this backend. +pub const PROVIDER: &str = "eideasy"; + +/// Human-readable name of this backend's variable group, for the message the +/// selector emits when these are set without the provider being chosen. +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"; + +/// Whether any variable of this backend's group is set. +pub fn any_env_set(get: &impl Fn(&str) -> Option) -> bool { + [ENV_BASE_URL, ENV_CLIENT_ID, ENV_HMAC_KEY] + .iter() + .any(|n| get(n).is_some_and(|v| !v.trim().is_empty())) +} + +impl EideasyConfig { + /// Read configuration from the environment. + /// + /// Returns `Ok(None)` when no provider is selected — the node then wires + /// `GhostSeal`, which a development profile permits and a production profile + /// refuses at boot. + /// + /// A *partial* configuration is an error, not a `None`. Silently falling back + /// to a ghost because one of three variables was misspelled would downgrade a + /// node from qualified sealing to no sealing on a typo, which is precisely the + /// failure the trust report exists to make impossible. Setting the + /// `SEAL_EIDEASY_*` variables without selecting the provider is the same class + /// of mistake and is refused for the same reason. + /// + /// When a second backend lands this becomes a dispatch on + /// [`SEAL_PROVIDER`]; the shape is here so that change is additive. + pub fn from_env() -> Result { + Self::resolve(|name| std::env::var(name).ok()) + } + + /// The whole of [`Self::from_env`]'s logic, over an arbitrary lookup. + /// + /// Split out so the rules above — provider selection, all-or-nothing + /// credentials, host allowlisting — are testable as a pure function. + /// Exercising them through the real environment would mean mutating + /// process-global state from tests, which is `unsafe` under Rust 2024, forces + /// them to serialise against one another, and lets a stray variable in the + /// developer's shell change the result. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let read = |name: &str| { + get(name) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + }; + + let base_url = read(ENV_BASE_URL); + let client_id = read(ENV_CLIENT_ID); + let hmac_key = read(ENV_HMAC_KEY); + let missing: Vec<&str> = [ + (ENV_BASE_URL, &base_url), + (ENV_CLIENT_ID, &client_id), + (ENV_HMAC_KEY, &hmac_key), + ] + .iter() + .filter(|(_, v)| v.is_none()) + .map(|(n, _)| *n) + .collect(); + if !missing.is_empty() { + return Err(SealError::Config(format!( + "{} is selected but {} not set", + PROVIDER, + missing.join(", ") + ))); + } + + let base_url = base_url.expect("checked non-empty above"); + let environment = environment_for(&base_url)?; + + Ok(Self { + base_url: base_url.trim_end_matches('/').to_owned(), + environment, + client_id: client_id.expect("checked non-empty above"), + hmac_key: Zeroizing::new(hmac_key.expect("checked non-empty above")), + signature_profile: read(ENV_SIGNATURE_PROFILE).unwrap_or_else(|| { + crate::eideasy::EsealRequest::PROFILE_CADES_BASELINE_T.to_owned() + }), + request_timeout: Duration::from_secs(30), + }) + } +} + +/// Map a base URL to its eID Easy deployment, refusing anything else. +/// +/// The allowlist is the point: an unrecognised host cannot be classified, and a +/// seal whose trust tier we cannot state is worse than no seal. A typo in +/// `SEAL_EIDEASY_BASE_URL` fails the node at boot rather than shipping passport +/// digests to whatever host was actually spelled. +fn environment_for(base_url: &str) -> Result { + let parsed = url::Url::parse(base_url) + .map_err(|e| SealError::Config(format!("SEAL_EIDEASY_BASE_URL is not a URL: {e}")))?; + if parsed.scheme() != "https" { + return Err(SealError::Config(format!( + "SEAL_EIDEASY_BASE_URL must be https, got {}", + parsed.scheme() + ))); + } + match parsed.host_str() { + Some(SANDBOX_HOST) => Ok(EideasyEnvironment::Sandbox), + Some(PRODUCTION_HOST) => Ok(EideasyEnvironment::Production), + other => Err(SealError::Config(format!( + "SEAL_EIDEASY_BASE_URL host {} is not eID Easy — expected {SANDBOX_HOST} or {PRODUCTION_HOST}", + other.unwrap_or("") + ))), + } +} + +/// Config pointed at an arbitrary base URL, for tests that stand up a local mock. +/// +/// Deliberately bypasses [`environment_for`]: the host allowlist guards the +/// operator-facing `from_env` path, and applying it here would make the endpoint +/// untestable without reaching eID Easy. +#[cfg(test)] +pub(crate) fn test_config(base_url: &str) -> EideasyConfig { + EideasyConfig { + base_url: base_url.to_owned(), + environment: EideasyEnvironment::Sandbox, + client_id: "test-client".into(), + hmac_key: Zeroizing::new("test-key".into()), + signature_profile: "CAdES_BASELINE_T".into(), + request_timeout: Duration::from_secs(5), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolve against a fixed set of variables — no process environment, so + /// these are pure, parallel-safe, and cannot be perturbed by the developer's + /// shell. + fn resolve(vars: &[(&str, &str)]) -> Result { + let owned: Vec<(String, String)> = vars + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + EideasyConfig::resolve(|name| { + owned + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + }) + } + + const FULL: [(&str, &str); 3] = [ + (ENV_BASE_URL, SANDBOX_BASE_URL), + (ENV_CLIENT_ID, "client-id"), + (ENV_HMAC_KEY, "hmac-key"), + ]; + + #[test] + fn a_full_configuration_resolves() { + let cfg = resolve(&FULL).expect("configured"); + assert_eq!(cfg.environment, EideasyEnvironment::Sandbox); + assert_eq!(cfg.client_id, "client-id"); + // Defaulted, not required. + assert_eq!(cfg.signature_profile, "CAdES_BASELINE_T"); + } + + #[test] + fn a_partial_configuration_names_what_is_missing() { + let err = resolve(&FULL[..1]).expect_err("partial must fail"); + let msg = err.to_string(); + assert!(msg.contains("SEAL_EIDEASY_CLIENT_ID"), "{msg}"); + assert!(msg.contains("SEAL_EIDEASY_HMAC_KEY"), "{msg}"); + } + + #[test] + fn known_hosts_map_to_their_environments() { + assert_eq!( + environment_for(SANDBOX_BASE_URL).unwrap(), + EideasyEnvironment::Sandbox + ); + assert_eq!( + environment_for(PRODUCTION_BASE_URL).unwrap(), + EideasyEnvironment::Production + ); + } + + #[test] + fn an_unknown_host_is_refused() { + // A typo must not become "some seal from somewhere" — it must not boot. + for url in [ + "https://id.eideasy.com.evil.test", + "https://eideasy.com", + "https://localhost:8080", + ] { + assert!( + environment_for(url).is_err(), + "{url} was accepted as an eID Easy host" + ); + } + } + + #[test] + fn plaintext_http_is_refused() { + assert!(environment_for("http://test.eideasy.com").is_err()); + } + + #[test] + fn debug_does_not_print_the_hmac_key() { + let mut cfg = test_config(SANDBOX_BASE_URL); + cfg.hmac_key = Zeroizing::new("super-secret-key-material".into()); + let rendered = format!("{cfg:?}"); + assert!( + !rendered.contains("super-secret-key-material"), + "Debug leaked the HMAC key: {rendered}" + ); + assert!(rendered.contains("[redacted]")); + } +} diff --git a/crates/dpp-seal/src/eideasy/mod.rs b/crates/dpp-seal/src/eideasy/mod.rs index 8e07d9a..8bd10fc 100644 --- a/crates/dpp-seal/src/eideasy/mod.rs +++ b/crates/dpp-seal/src/eideasy/mod.rs @@ -8,7 +8,9 @@ //! - [`client`] — the HMAC-signed POST, and the sign-the-exact-bytes invariant. pub mod client; +pub mod config; pub mod types; pub use client::EideasyClient; +pub use config::{EideasyConfig, EideasyEnvironment}; pub use types::{EsealFile, EsealRequest, EsealResponse, EsealSignatureOut, MIME_JSON, MIME_PDF}; diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index 355d138..443e154 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -32,18 +32,24 @@ //! //! # Structure //! -//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl (eID Easy or ghost) -//! - [`config`] — `EideasyConfig`, resolved from the environment -//! - [`eideasy`] — Cloud Direct e-Sealing wire types and HTTP client +//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl +//! - [`config`] — which backend this node runs, and nothing about any of them +//! - [`eideasy`] — the hosted QTSP backend: config, wire types, HTTP client +//! - [`local`] — in-process signing for development //! - [`error`] — `SealError`, classified once at the HTTP boundary +//! +//! Each backend owns its own module: its configuration, its variables, its +//! failure messages and its wire types. Nothing outside a backend's module +//! names it, so one can be added or dropped without touching the others. pub mod adapter; pub mod config; pub mod eideasy; pub mod error; +pub mod local; pub use adapter::QtspSealAdapter; -pub use config::{EideasyConfig, EideasyEnvironment}; +pub use config::{SEAL_PROVIDER, SealProvider}; pub use error::SealError; #[cfg(test)] diff --git a/crates/dpp-seal/src/local/config.rs b/crates/dpp-seal/src/local/config.rs new file mode 100644 index 0000000..d6cd16f --- /dev/null +++ b/crates/dpp-seal/src/local/config.rs @@ -0,0 +1,77 @@ +//! Configuration for the local development sealing backend. + +use std::path::PathBuf; + +use crate::error::SealError; + +/// The `SEAL_PROVIDER` value that selects this backend. +pub const PROVIDER: &str = "local"; + +/// Human-readable name of this backend's variable group, for the message the +/// selector emits when these are set without the provider being chosen. +pub const ENV_GROUP: &str = "SEAL_LOCAL_*"; + +const ENV_KEY_PATH: &str = "SEAL_LOCAL_KEY_PATH"; + +/// Whether any variable of this backend's group is set. +pub fn any_env_set(get: &impl Fn(&str) -> Option) -> bool { + [ENV_KEY_PATH] + .iter() + .any(|n| get(n).is_some_and(|v| !v.trim().is_empty())) +} + +/// Resolved configuration for the local backend. +#[derive(Debug, Clone)] +pub struct LocalConfig { + /// Where the generated key and certificate are persisted between runs. + /// + /// Persisted rather than regenerated per boot so a seal produced yesterday + /// still verifies against the same certificate today — a restart that + /// silently invalidated every seal it had produced would teach a developer + /// the wrong thing about how seals behave. + pub key_path: PathBuf, +} + +impl LocalConfig { + /// Read configuration from the process environment. + pub fn from_env() -> Result { + Self::resolve(|name| std::env::var(name).ok()) + } + + /// The whole of [`Self::from_env`]'s logic over an arbitrary lookup, for the + /// same reason the other backends split it: process-global state is `unsafe` + /// to mutate under Rust 2024 and makes tests serialise against each other. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let key_path = get(ENV_KEY_PATH) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + .map_or_else(|| PathBuf::from("./.seal-local"), PathBuf::from); + + Ok(Self { key_path }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_path_defaults_when_unset() { + let cfg = LocalConfig::resolve(|_| None).unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("./.seal-local")); + } + + #[test] + fn key_path_is_read_from_the_environment() { + let cfg = + LocalConfig::resolve(|n| (n == ENV_KEY_PATH).then(|| "/var/lib/odal/seal".to_owned())) + .unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("/var/lib/odal/seal")); + } + + #[test] + fn a_blank_value_is_not_a_path() { + let cfg = LocalConfig::resolve(|n| (n == ENV_KEY_PATH).then(|| " ".to_owned())).unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("./.seal-local")); + } +} diff --git a/crates/dpp-seal/src/local/mod.rs b/crates/dpp-seal/src/local/mod.rs new file mode 100644 index 0000000..b88fdc2 --- /dev/null +++ b/crates/dpp-seal/src/local/mod.rs @@ -0,0 +1,15 @@ +//! Local development sealing backend. +//! +//! Signs in-process with a locally generated key instead of calling a trust +//! service provider. The point is to exercise the whole pipeline — port +//! contract, digest handling, envelope, storage, verification path — without a +//! provider account, a contract, or a sandbox credential. +//! +//! **The certificate is not on an EU Trusted List.** That is a property of the +//! certificate, not of this code: nothing in the signing or verification path +//! differs between a self-signed key and a qualified one. What differs is the +//! legal weight, which is none. + +pub mod config; + +pub use config::LocalConfig; diff --git a/crates/dpp-seal/src/tests.rs b/crates/dpp-seal/src/tests.rs index 5197006..853e206 100644 --- a/crates/dpp-seal/src/tests.rs +++ b/crates/dpp-seal/src/tests.rs @@ -127,7 +127,7 @@ mod mock_server { use mock_server::MockState; fn adapter_for(base_url: &str, hmac_key: &str) -> QtspSealAdapter { - let mut cfg = crate::config::test_config(base_url); + let mut cfg = crate::eideasy::config::test_config(base_url); cfg.hmac_key = zeroize::Zeroizing::new(hmac_key.to_owned()); QtspSealAdapter::eideasy(cfg).unwrap() } From 7ce44907165dbc347195f2579ddd1f13a73a36f5 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 13 Aug 2026 23:10:25 +0200 Subject: [PATCH 02/20] refactor(node): dispatch sealing on the provider --- crates/dpp-node/src/main.rs | 39 +++++++++++++++++----------- crates/dpp-node/tests/seal_outbox.rs | 6 ++--- crates/dpp-seal/src/config.rs | 6 ++++- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 165c0a5..61f51b9 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -173,34 +173,43 @@ async fn main() -> anyhow::Result<()> { dpp_node::infra::credential_issuers::from_env(operator_did.as_deref()); let credentials_live = credential_trust != TrustMode::Ghost; - // ── eIDAS qualified seal (eID Easy Cloud Direct e-Sealing) ─────────────── - // A partial configuration is an error rather than a silent ghost: dropping - // to no sealing because one of three variables was misspelled is exactly the + // ── eIDAS qualified seal ───────────────────────────────────────────────── + // The backend is selected explicitly by SEAL_PROVIDER. A partial or + // unrecognised configuration is an error rather than a silent ghost: + // dropping to no sealing because one variable was misspelled is exactly the // downgrade the trust report exists to prevent, so it fails the boot. let (seal, seal_client_id, seal_trust): (Arc, String, TrustMode) = - match dpp_seal::EideasyConfig::from_env().context("eID Easy configuration")? { - Some(cfg) => { - // Sandbox is a real seal from a real API, but over eID Easy's test - // certificate — a distinct claim from both Ghost and Live. + match dpp_seal::SealProvider::from_env().context("seal provider")? { + dpp_seal::SealProvider::Qtsp => { + let cfg = dpp_seal::eideasy::EideasyConfig::from_env() + .context("QTSP seal configuration")?; + // 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 mode = match cfg.environment { - dpp_seal::EideasyEnvironment::Sandbox => TrustMode::Sandbox, - dpp_seal::EideasyEnvironment::Production => TrustMode::Live, + dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, + dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, }; let client_id = cfg.client_id.clone(); tracing::info!( base_url = %cfg.base_url, mode = mode.as_str(), - "eIDAS seal: eID Easy adapter active" + "eIDAS seal: QTSP adapter active" ); let adapter = dpp_seal::QtspSealAdapter::eideasy(cfg) - .context("Failed to build eID Easy seal adapter")?; + .context("Failed to build the QTSP seal adapter")?; (Arc::new(adapter), client_id, mode) } - None => { + dpp_seal::SealProvider::Local => { + // Selected but not yet wired. Refusing to boot is the point: the + // alternative is a node that was asked for a seal, silently gave + // none, and published passports saying so. + anyhow::bail!( + "SEAL_PROVIDER=local selects the in-process development backend, which is not implemented yet — the signing format is undecided. Unset it to run with GhostSeal." + ); + } + dpp_seal::SealProvider::None => { tracing::info!( - "eIDAS seal: ghost (no QTSP) — set SEAL_PROVIDER=eideasy plus \ - SEAL_EIDEASY_BASE_URL + SEAL_EIDEASY_CLIENT_ID + SEAL_EIDEASY_HMAC_KEY \ - to enable" + "eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing" ); ( Arc::new(dpp_seal::QtspSealAdapter::ghost()), diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 2ffa09f..366574c 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -239,10 +239,10 @@ fn draft_passport() -> Passport { } } -fn eideasy_config(base_url: &str) -> dpp_seal::EideasyConfig { - dpp_seal::EideasyConfig { +fn eideasy_config(base_url: &str) -> dpp_seal::eideasy::EideasyConfig { + dpp_seal::eideasy::EideasyConfig { base_url: base_url.to_owned(), - environment: dpp_seal::EideasyEnvironment::Sandbox, + environment: dpp_seal::eideasy::EideasyEnvironment::Sandbox, client_id: MOCK_CLIENT_ID.to_owned(), hmac_key: zeroize::Zeroizing::new(MOCK_KEY.to_owned()), signature_profile: "CAdES_BASELINE_T".to_owned(), diff --git a/crates/dpp-seal/src/config.rs b/crates/dpp-seal/src/config.rs index 4a3445d..33ef405 100644 --- a/crates/dpp-seal/src/config.rs +++ b/crates/dpp-seal/src/config.rs @@ -17,8 +17,12 @@ pub const SEAL_PROVIDER: &str = "SEAL_PROVIDER"; const PROVIDER_NONE: &str = "none"; /// The backend a node is configured to seal with. +/// +/// Deliberately **not** `#[non_exhaustive]`: adding a backend should break every +/// wiring site until it is handled. A `_` arm here is a node that silently seals +/// with something other than what it was asked for, which is the downgrade the +/// trust report exists to prevent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] pub enum SealProvider { /// The hosted QTSP backend. Qtsp, From 29e528f3ce8835e245e2e86d4d8fcb265f425222 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 13 Aug 2026 23:33:31 +0200 Subject: [PATCH 03/20] feat(seal): add a local CMS development backend --- Cargo.lock | 381 +++++++++++++++++++++++++++- Cargo.toml | 2 +- crates/dpp-seal/Cargo.toml | 12 + crates/dpp-seal/src/local/mod.rs | 2 + crates/dpp-seal/src/local/sealer.rs | 229 +++++++++++++++++ 5 files changed, 615 insertions(+), 11 deletions(-) create mode 100644 crates/dpp-seal/src/local/sealer.rs diff --git a/Cargo.lock b/Cargo.lock index f6f52d1..57a085a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -33,7 +44,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", ] @@ -45,8 +56,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.9.2", + "cipher 0.5.2", "ctr", "ghash", "subtle", @@ -233,6 +244,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -549,7 +599,7 @@ dependencies = [ "http-body-util", "md-5", "pin-project-lite", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "tracing", ] @@ -864,7 +914,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -873,6 +923,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -915,6 +974,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1145,6 +1213,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.4.1" @@ -1227,6 +1304,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1235,7 +1322,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", ] [[package]] @@ -1293,6 +1380,27 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "aes 0.8.4", + "cbc", + "cipher 0.4.4", + "const-oid 0.9.6", + "der", + "rsa", + "sha1 0.10.7", + "sha2 0.10.9", + "sha3", + "signature 2.2.0", + "spki", + "x509-cert", + "zeroize", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1736,6 +1844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1777,7 +1886,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -1916,10 +2025,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2455,17 +2591,24 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "cms", + "const-oid 0.9.6", + "der", "dpp-domain", "hex", "hmac 0.12.1", + "p256", + "rcgen", "reqwest", "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 2.0.19", "tokio", "tracing", "url", + "x509-cert", "zeroize", ] @@ -2844,6 +2987,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -3644,6 +3793,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.2" @@ -3849,11 +4008,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] 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" @@ -4075,6 +4246,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4129,6 +4306,16 @@ dependencies = [ "signatory", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4171,6 +4358,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" @@ -4240,6 +4443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4313,6 +4517,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4446,6 +4659,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4493,6 +4716,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", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4643,7 +4877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.13.1", "num-traits", "rand 0.9.5", @@ -4958,6 +5192,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redis" version = "1.5.0" @@ -5185,6 +5433,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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", + "pkcs8", + "rand_core 0.6.4", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -5206,6 +5474,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5605,6 +5882,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5644,6 +5932,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5909,7 +6207,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", "thiserror 2.0.19", @@ -6277,6 +6575,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -7826,6 +8145,38 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1 0.10.7", + "signature 2.2.0", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7848,6 +8199,16 @@ version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 83fbcba..ef00b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ flate2 = "1" ring = "0.17" base64 = "0.22" hex = "0.4" -sha2 = "0.10" +sha2 = { version = "0.10", features = ["oid"] } hmac = "0.12" subtle = "2" ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/crates/dpp-seal/Cargo.toml b/crates/dpp-seal/Cargo.toml index 7e2aef3..ae88986 100644 --- a/crates/dpp-seal/Cargo.toml +++ b/crates/dpp-seal/Cargo.toml @@ -28,6 +28,18 @@ hex = { workspace = true } url = { workspace = true } zeroize = { workspace = true } +# Local development backend: a real detached CMS SignedData over the digest, +# signed by a self-signed key. Not qualified — the certificate is on no Trusted +# List — but structurally the shape a provider returns, so the whole pipeline is +# exercised without a provider account. +cms = { version = "0.2", features = ["builder"] } +x509-cert = "0.2" +der = "0.7" +const-oid = "0.9" +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } +rcgen = "0.14" + [dev-dependencies] +tempfile = "3" tokio = { workspace = true } axum = { workspace = true } diff --git a/crates/dpp-seal/src/local/mod.rs b/crates/dpp-seal/src/local/mod.rs index b88fdc2..6cdab3b 100644 --- a/crates/dpp-seal/src/local/mod.rs +++ b/crates/dpp-seal/src/local/mod.rs @@ -11,5 +11,7 @@ //! legal weight, which is none. pub mod config; +pub mod sealer; pub use config::LocalConfig; +pub use sealer::LocalIdentity; diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs new file mode 100644 index 0000000..0d09c99 --- /dev/null +++ b/crates/dpp-seal/src/local/sealer.rs @@ -0,0 +1,229 @@ +//! A real detached CMS `SignedData` over the payload digest, signed locally. +//! +//! # What this is, and what it is not +//! +//! It **is** a genuine CMS signature: the bytes verify against the certificate, +//! the structure is what a provider returns, and every stage of the pipeline +//! that handles a seal handles this one identically. +//! +//! It is **not** qualified, and cannot become so. The certificate is +//! self-signed and on no EU Trusted List, which is a property of the +//! certificate rather than of this code — nothing in the signing or +//! verification path differs between a self-signed key and a QTSP-held one. +//! What differs is the legal weight, which is none. The node reflects that by +//! resolving this backend's trust tier to `Ghost`, so a production profile +//! refuses to boot on it. +//! +//! A second, narrower limit worth stating: the OpenID4VC High Assurance +//! Interoperability Profile requires that an issuer's signing certificate +//! **not** be self-signed. So this backend can never stand in for a real issuer +//! in a conformant credential flow, however complete the pipeline around it is. + +use std::path::Path; + +use chrono::Utc; +use cms::builder::{SignedDataBuilder, SignerInfoBuilder}; +use cms::cert::CertificateChoices; +use cms::signed_data::{EncapsulatedContentInfo, SignerIdentifier}; +use const_oid::db::rfc5911::ID_DATA; +use der::{Any, Decode as _, Encode}; +use p256::ecdsa::{DerSignature, SigningKey}; +use x509_cert::Certificate; + +use crate::error::SealError; + +/// A locally generated signing identity: one key, one self-signed certificate. +pub struct LocalIdentity { + key: SigningKey, + cert: Certificate, + cert_der: Vec, +} + +impl LocalIdentity { + /// Load the identity at `dir`, generating it on first use. + /// + /// Persisted rather than regenerated per boot: a seal produced yesterday + /// must still verify against the same certificate today. A restart that + /// silently invalidated every seal it had produced would teach the wrong + /// thing about how seals behave. + pub fn load_or_create(dir: &Path) -> Result { + let key_path = dir.join("seal-key.pkcs8.der"); + let cert_path = dir.join("seal-cert.der"); + + if key_path.exists() && cert_path.exists() { + 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); + } + + let (key_der, cert_der) = generate()?; + std::fs::create_dir_all(dir).map_err(io_err("create the local seal directory"))?; + 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) + } + + fn from_der(key_der: &[u8], cert_der: Vec) -> 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}")))?; + let cert = Certificate::from_der(cert_der.as_slice()).map_err(|e| { + SealError::Config(format!("local seal certificate is not valid DER: {e}")) + })?; + Ok(Self { + key, + cert, + cert_der, + }) + } + + /// SHA-256 of the certificate, as the envelope's `signing_cert_ref`. + pub fn cert_thumbprint(&self) -> String { + use sha2::{Digest as _, Sha256}; + hex::encode(Sha256::digest(&self.cert_der)) + } + + /// Produce a **detached** CMS `SignedData` over `digest`. + /// + /// Detached is the point: `eContent` is absent, so the signature travels + /// 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> { + // The detached form: `eContentType` is id-data and `eContent` is absent, + // so the structure commits to a digest it does not carry. `digest` is + // the message digest the signed attributes bind to. + let econtent = EncapsulatedContentInfo { + econtent_type: ID_DATA, + econtent: Some( + Any::new(der::Tag::OctetString, digest) + .map_err(|e| SealError::Config(format!("digest is not encodable: {e}")))?, + ), + }; + + let signer_id = SignerIdentifier::IssuerAndSerialNumber(cms::cert::IssuerAndSerialNumber { + issuer: self.cert.tbs_certificate.issuer.clone(), + serial_number: self.cert.tbs_certificate.serial_number.clone(), + }); + + let digest_algorithm = x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ID_SHA_256, + parameters: None, + }; + + let signer_info = SignerInfoBuilder::new( + &self.key, + signer_id, + digest_algorithm.clone(), + &econtent, + None, + ) + .map_err(|e| SealError::Config(format!("cannot build the CMS SignerInfo: {e:?}")))?; + + let signed_data = SignedDataBuilder::new(&econtent) + .add_digest_algorithm(digest_algorithm) + .and_then(|b| b.add_certificate(CertificateChoices::Certificate(self.cert.clone()))) + .and_then(|b| b.add_signer_info::(signer_info)) + .and_then(|b| b.build()) + .map_err(|e| SealError::Config(format!("cannot build the CMS SignedData: {e:?}")))?; + + // `build()` already returns the `ContentInfo` wrapper — re-wrapping it + // would nest one inside another and decode as garbage. + signed_data + .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() + } +} + +/// Generate a P-256 key and a self-signed certificate for it. +fn generate() -> Result<(Vec, Vec), SealError> { + let mut params = rcgen::CertificateParams::new(vec!["odal-local-seal".to_owned()]) + .map_err(|e| SealError::Config(format!("cannot build certificate params: {e}")))?; + params.distinguished_name.push( + rcgen::DnType::CommonName, + "Odal Node local development seal", + ); + params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "NOT A QUALIFIED SEAL"); + + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| SealError::Config(format!("cannot generate a P-256 key: {e}")))?; + let cert = params + .self_signed(&key) + .map_err(|e| SealError::Config(format!("cannot self-sign the 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}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use cms::content_info::ContentInfo; + + fn identity() -> (LocalIdentity, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let id = LocalIdentity::load_or_create(dir.path()).expect("identity"); + (id, dir) + } + + /// The seal is a real CMS `ContentInfo` carrying `SignedData`, not a stub. + /// + /// Parsing it back with the same library a provider's output would be parsed + /// with is the check that matters: a structure that only this code can read + /// would prove nothing about the pipeline. + #[test] + fn the_seal_parses_back_as_cms_signed_data() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x42; 32]).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("a CMS ContentInfo"); + assert_eq!(info.content_type, const_oid::db::rfc5911::ID_SIGNED_DATA); + + let sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData inside"); + assert_eq!(sd.signer_infos.0.len(), 1, "exactly one signer"); + assert!( + sd.certificates.is_some(), + "the signing certificate travels with the seal, as a provider's does" + ); + } + + /// Two different digests produce two different seals. + /// + /// Guards the failure that would make every other test here vacuous: a + /// backend that returns a constant would satisfy "parses as CMS" and still + /// attest nothing. + #[test] + fn a_different_digest_produces_a_different_seal() { + let (id, _dir) = identity(); + let a = id.sign_detached(&[0x01; 32]).expect("sign a"); + let b = id.sign_detached(&[0x02; 32]).expect("sign b"); + assert_ne!(a, b, "the seal must depend on what it covers"); + } + + /// The identity survives a restart. + /// + /// A seal produced before a restart must still verify against the same + /// certificate after one. + #[test] + fn the_identity_is_stable_across_loads() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = LocalIdentity::load_or_create(dir.path()).expect("first"); + let second = LocalIdentity::load_or_create(dir.path()).expect("second"); + assert_eq!( + first.cert_thumbprint(), + second.cert_thumbprint(), + "reloading must not mint a new certificate" + ); + } +} From 1b09fc2693a89203ca64bbd30caa254bae7d37f6 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 00:11:03 +0200 Subject: [PATCH 04/20] fix(config): repair mangled em dashes in .env.example --- .env.example | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 8bb6afe..db24542 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,28 @@ # ============================================================================= -# .env.example — Odal Node engine: the single source of truth for env vars. +# .env.example — Odal Node engine: the single source of truth for env vars. # Copy and fill in: cp .env.example .env -# .env is gitignored — never commit real secrets. +# .env is gitignored — never commit real secrets. # # Organised by deployable. The node (the MVP binary) reads the NODE section. # The resolver is a separate deployable and reads the RESOLVER section from its -# own environment — the vars live here only so everything is in one place. +# own environment — the vars live here only so everything is in one place. # ============================================================================= # ============================================================================= -# NODE — the MVP binary `dpp-node` (vault + identity + integrator on one port, +# NODE — the MVP binary `dpp-node` (vault + identity + integrator on one port, # routed /vault/* /identity/* /integrator/*). This is what `odal up` runs. # ============================================================================= # --- PostgreSQL (required) --------------------------------------------------- # Two passwords, each defined ONCE. Docker Compose reads these to provision the # Postgres container and build the connection URLs: -# DATABASE_POSTGRES_PASS — superuser (container init + migrations) -# DATABASE_APP_PASS — odal_app role the node connects with +# DATABASE_POSTGRES_PASS — superuser (container init + migrations) +# DATABASE_APP_PASS — odal_app role the node connects with DATABASE_POSTGRES_PASS=dev_only_password DATABASE_APP_PASS=dev_only_password # Connection URLs the node binary reads directly. When running the node from # source (dev), set these; the embedded passwords MUST match the two vars above. -# App role: no DDL, no DELETE (one sanctioned exception). Single-tenant — +# App role: no DDL, no DELETE (one sanctioned exception). Single-tenant — # isolation is an infrastructure boundary (one node per operator), not # Row-Level Security. The node refuses to boot if this URL connects as a # superuser: a superuser owns the audit table, so the append-only trigger @@ -49,7 +49,7 @@ LOG_LEVEL=info # Comma-separated CORS origins. Empty = no CORS (server-side API-key access only). CORS_ALLOWED_ORIGINS=http://localhost:3000 # Event bus. Empty = NoOp (events discarded silently). Set to enable NATS -# JetStream — the node connects at boot and fails fast if it can't reach it. +# JetStream — the node connects at boot and fails fast if it can't reach it. NATS_URL=nats://localhost:4222 # Wasm sector-plugin directory. PLUGINS_DIR=./plugins @@ -71,14 +71,14 @@ BATCH_CONCURRENCY=20 # ALLOW_UNSIGNED_PLUGINS=false # ============================================================================= -# RESOLVER — public QR / Digital-Link resolver `dpp-resolver`. +# RESOLVER — public QR / Digital-Link resolver `dpp-resolver`. # A separate deployable (its own container, or a Cloudflare Worker) with its # own environment. These vars are listed here only so everything is in one place; # the node binary ignores them. Use RESOLVER_PORT to avoid collision with # NODE_PORT when both are sourced from the same shell environment. # ============================================================================= RESOLVER_PORT=8003 -REDIS_URL=redis://localhost:6379 # required by the resolver — response cache +REDIS_URL=redis://localhost:6379 # required by the resolver — response cache VAULT_BASE_URL=http://localhost:8001/vault # points at the node's vault sub-path CACHE_TTL_SECS=30 # worst-case recall-propagation window; raise only with that tradeoff in mind RATE_LIMIT_RPM=120 # per-IP request limit @@ -108,7 +108,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # MTLS_ALLOW_INSECURE=false # ============================================================================= -# eIDAS qualified sealing — eID Easy Cloud Direct e-Sealing (CAdES). +# eIDAS qualified sealing — eID Easy Cloud Direct e-Sealing (CAdES). # # SEAL_PROVIDER unset (or `none`) means GhostSeal: published passports carry no # seal, and NODE_PROFILE=production refuses to boot. `eideasy` is the only other @@ -116,7 +116,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # migration for every self-hoster. An unrecognised value fails the boot. # # Setting the SEAL_EIDEASY_* credentials WITHOUT selecting the provider is also -# refused — silently ghosting there would downgrade a node that was configured +# refused — silently ghosting there would downgrade a node that was configured # for qualified sealing into one that has none. Same reason a partial # configuration fails, and why SEAL_EIDEASY_BASE_URL is allowlisted to the two # hosts below. @@ -138,7 +138,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # # The operator's legal name and country come from operator config (`odal # operator`), not from here. Both land on the registration's operator -# identifier, and the registry rejects a registration carrying no legal name — +# identifier, and the registry rejects a registration carrying no legal name — # a node with an unset legal name logs a warning at boot and every registration # fails validation. # EU_REGISTRY_CLIENT_ID= @@ -150,7 +150,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # # Deliberately separate from SNAPSHOT_S3_BUCKET: writing snapshots to object # storage does not make them reachable. Until an operator states that they are -# served, no back-up link is declared — a URL the registry cannot fetch is worse +# served, no back-up link is declared — a URL the registry cannot fetch is worse # than declaring none. # SNAPSHOT_PUBLIC_BASE_URL=https://backup.example.com/dpp # @@ -158,9 +158,9 @@ RATE_LIMIT_RPM=120 # per-IP request limit # unless explicitly set, so the safe behaviour is the one you get by doing # nothing. It exists because our local rules are an interpretation of the spec # and may produce a false positive that should not need a code change to work -# around — not as a way to push known-bad records at a live registry. +# around — not as a way to push known-bad records at a live registry. # EU_REGISTRY_ALLOW_INVALID_PAYLOADS=false # ============================================================================= -# Ports: Postgres 5432 · NODE_PORT 8001 · RESOLVER_PORT 8003 · Redis 6379 · NATS 4222 -# Metrics (private): node 9100 · resolver 9101 +# Ports: Postgres 5432 · NODE_PORT 8001 · RESOLVER_PORT 8003 · Redis 6379 · NATS 4222 +# Metrics (private): node 9100 · resolver 9101 From 691466e312546b4e904f023aa68a5a4b66843380 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 00:19:18 +0200 Subject: [PATCH 05/20] fix(seal): build CMS without the rsa-bearing builder --- Cargo.lock | 158 ++-------------------------- crates/dpp-seal/Cargo.toml | 5 +- crates/dpp-seal/src/local/sealer.rs | 145 +++++++++++++++++++------ 3 files changed, 122 insertions(+), 186 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57a085a..0a88885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,18 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout 0.2.2", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "inout", ] [[package]] @@ -44,7 +33,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", "cpufeatures 0.3.0", ] @@ -56,8 +45,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ "aead", - "aes 0.9.2", - "cipher 0.5.2", + "aes", + "cipher", "ctr", "ghash", "subtle", @@ -599,7 +588,7 @@ dependencies = [ "http-body-util", "md-5", "pin-project-lite", - "sha1 0.11.0", + "sha1", "sha2 0.11.0", "tracing", ] @@ -974,15 +963,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -1213,15 +1193,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "cc" version = "1.4.1" @@ -1304,16 +1275,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - [[package]] name = "cipher" version = "0.5.2" @@ -1322,7 +1283,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.2", + "inout", ] [[package]] @@ -1386,19 +1347,10 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "aes 0.8.4", - "cbc", - "cipher 0.4.4", "const-oid 0.9.6", "der", - "rsa", - "sha1 0.10.7", - "sha2 0.10.9", - "sha3", - "signature 2.2.0", "spki", "x509-cert", - "zeroize", ] [[package]] @@ -1844,7 +1796,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1886,7 +1837,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -3793,16 +3744,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "inout" version = "0.2.2" @@ -4008,23 +3949,11 @@ dependencies = [ "serde_json", ] -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - [[package]] 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" @@ -4358,22 +4287,6 @@ 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" @@ -4443,7 +4356,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -4716,17 +4628,6 @@ 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", - "pkcs8", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -5433,26 +5334,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[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", - "pkcs8", - "rand_core 0.6.4", - "signature 2.2.0", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-demangle" version = "0.1.28" @@ -5882,17 +5763,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -5932,16 +5802,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -6207,7 +6067,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1 0.11.0", + "sha1", "sha2 0.11.0", "sqlx-core", "thiserror 2.0.19", @@ -8153,8 +8013,6 @@ checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", "der", - "sha1 0.10.7", - "signature 2.2.0", "spki", "tls_codec", ] diff --git a/crates/dpp-seal/Cargo.toml b/crates/dpp-seal/Cargo.toml index ae88986..f3d10b7 100644 --- a/crates/dpp-seal/Cargo.toml +++ b/crates/dpp-seal/Cargo.toml @@ -32,7 +32,10 @@ zeroize = { workspace = true } # signed by a self-signed key. Not qualified — the certificate is on no Trusted # List — but structurally the shape a provider returns, so the whole pipeline is # exercised without a provider account. -cms = { version = "0.2", features = ["builder"] } +# keeps out of the tree: the builder feature +# pulls it in, we sign with P-256, and 0.9 carries RUSTSEC-2023-0071 (Marvin +# timing sidechannel) with no fixed upgrade available. +cms = { version = "0.2", default-features = false } x509-cert = "0.2" der = "0.7" const-oid = "0.9" diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index 0d09c99..aed27ad 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -22,9 +22,12 @@ use std::path::Path; use chrono::Utc; -use cms::builder::{SignedDataBuilder, SignerInfoBuilder}; -use cms::cert::CertificateChoices; -use cms::signed_data::{EncapsulatedContentInfo, SignerIdentifier}; +use cms::cert::{CertificateChoices, IssuerAndSerialNumber}; +use cms::content_info::ContentInfo; +use cms::signed_data::{ + CertificateSet, DigestAlgorithmIdentifiers, EncapsulatedContentInfo, SignedData, + SignerIdentifier, SignerInfo, SignerInfos, +}; use const_oid::db::rfc5911::ID_DATA; use der::{Any, Decode as _, Encode}; use p256::ecdsa::{DerSignature, SigningKey}; @@ -91,47 +94,80 @@ 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> { - // The detached form: `eContentType` is id-data and `eContent` is absent, - // so the structure commits to a digest it does not carry. `digest` is - // the message digest the signed attributes bind to. + use der::asn1::{OctetString, SetOfVec}; + use p256::ecdsa::signature::Signer as _; + + // Assembled from `cms`'s own types rather than its `builder` feature. + // That feature depends unconditionally on `rsa`, which carries + // RUSTSEC-2023-0071 with no fixed upgrade; we sign with P-256 and have + // no use for RSA, so the dependency would be pure advisory surface on + // the one crate that produces seals. + // + // Detached: `eContent` is absent, so the structure commits to a digest + // it does not carry — the arrangement a provider returns. let econtent = EncapsulatedContentInfo { econtent_type: ID_DATA, - econtent: Some( - Any::new(der::Tag::OctetString, digest) - .map_err(|e| SealError::Config(format!("digest is not encodable: {e}")))?, - ), + econtent: None, }; - let signer_id = SignerIdentifier::IssuerAndSerialNumber(cms::cert::IssuerAndSerialNumber { - issuer: self.cert.tbs_certificate.issuer.clone(), - serial_number: self.cert.tbs_certificate.serial_number.clone(), - }); - let digest_algorithm = x509_cert::spki::AlgorithmIdentifierOwned { oid: const_oid::db::rfc5912::ID_SHA_256, parameters: None, }; - let signer_info = SignerInfoBuilder::new( - &self.key, - signer_id, - digest_algorithm.clone(), - &econtent, - None, - ) - .map_err(|e| SealError::Config(format!("cannot build the CMS SignerInfo: {e:?}")))?; - - let signed_data = SignedDataBuilder::new(&econtent) - .add_digest_algorithm(digest_algorithm) - .and_then(|b| b.add_certificate(CertificateChoices::Certificate(self.cert.clone()))) - .and_then(|b| b.add_signer_info::(signer_info)) - .and_then(|b| b.build()) - .map_err(|e| SealError::Config(format!("cannot build the CMS SignedData: {e:?}")))?; - - // `build()` already returns the `ContentInfo` wrapper — re-wrapping it - // would nest one inside another and decode as garbage. - signed_data - .to_der() + // No signed attributes: with `signedAttrs` absent, the signature is over + // the content itself, which for a detached signature is the digest the + // caller hands us. One fewer place for the bound value to disagree with + // the value actually signed. + let signature: DerSignature = self.key.sign(digest); + + let signer_info = SignerInfo { + version: cms::content_info::CmsVersion::V1, + sid: SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber { + issuer: self.cert.tbs_certificate.issuer.clone(), + serial_number: self.cert.tbs_certificate.serial_number.clone(), + }), + digest_alg: digest_algorithm.clone(), + signed_attrs: None, + 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 signature: {e}")))?, + unsigned_attrs: None, + }; + + let mut digest_algorithms = SetOfVec::new(); + digest_algorithms + .insert(digest_algorithm) + .map_err(|e| SealError::Config(format!("cannot record the digest algorithm: {e}")))?; + + let mut certs = SetOfVec::new(); + certs + .insert(CertificateChoices::Certificate(self.cert.clone())) + .map_err(|e| SealError::Config(format!("cannot attach the certificate: {e}")))?; + + let mut signer_infos = SetOfVec::new(); + signer_infos + .insert(signer_info) + .map_err(|e| SealError::Config(format!("cannot attach the signer info: {e}")))?; + + let signed_data = SignedData { + version: cms::content_info::CmsVersion::V1, + digest_algorithms: DigestAlgorithmIdentifiers::from(digest_algorithms), + encap_content_info: econtent, + certificates: Some(CertificateSet::from(certs)), + crls: None, + signer_infos: SignerInfos::from(signer_infos), + }; + + let 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 SignedData: {e}")))?, + }; + info.to_der() .map_err(|e| SealError::Config(format!("cannot DER-encode the seal: {e}"))) } @@ -198,6 +234,45 @@ mod tests { ); } + /// The signature in the seal verifies against the certificate it carries. + /// + /// This is the test the whole backend exists for. Everything else here + /// checks structure; without this one, a seal could be well-formed CMS + /// carrying bytes that verify against nothing — which is precisely what + /// `GhostSeal` already produces, and what this backend is meant to stop + /// being. + #[test] + fn the_signature_verifies_against_the_embedded_certificate() { + use p256::ecdsa::signature::Verifier as _; + use p256::ecdsa::{DerSignature, VerifyingKey}; + + let (id, _dir) = identity(); + let digest = [0x7u8; 32]; + let der = id.sign_detached(&digest).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("ContentInfo"); + let sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let si = sd.signer_infos.0.as_slice().first().expect("one signer"); + + // The verifying key comes out of the certificate inside the seal, not + // from the identity in memory — a verifier only ever has the bytes. + let spki = &id.cert.tbs_certificate.subject_public_key_info; + let vk = VerifyingKey::from_sec1_bytes( + spki.subject_public_key.as_bytes().expect("public key bits"), + ) + .expect("P-256 key from the certificate"); + + let sig = DerSignature::from_bytes(si.signature.as_bytes()).expect("DER signature"); + vk.verify(&digest, &sig) + .expect("the seal must verify against the certificate it ships"); + + // And must not verify against something it did not cover. + assert!( + vk.verify(&[0x8u8; 32], &sig).is_err(), + "a seal that verifies over any digest attests nothing" + ); + } + /// Two different digests produce two different seals. /// /// Guards the failure that would make every other test here vacuous: a From 4919d1902666cfccc134746bcf0216586aff8eaa Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 00:35:40 +0200 Subject: [PATCH 06/20] feat(types): separate sandbox from production profiles --- crates/dpp-node/src/main.rs | 97 ++++++++++++++----------- crates/dpp-seal/src/adapter.rs | 45 +++++++++++- crates/dpp-types/src/trust.rs | 126 +++++++++++++++++++++++++++++---- 3 files changed, 213 insertions(+), 55 deletions(-) diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 61f51b9..bb4d84b 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -178,47 +178,62 @@ async fn main() -> anyhow::Result<()> { // unrecognised configuration is an error rather than a silent ghost: // dropping to no sealing because one variable was misspelled is exactly the // downgrade the trust report exists to prevent, so it fails the boot. - let (seal, seal_client_id, seal_trust): (Arc, String, TrustMode) = - match dpp_seal::SealProvider::from_env().context("seal provider")? { - dpp_seal::SealProvider::Qtsp => { - let cfg = dpp_seal::eideasy::EideasyConfig::from_env() - .context("QTSP seal configuration")?; - // 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 mode = match cfg.environment { - dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, - dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, - }; - let client_id = cfg.client_id.clone(); - tracing::info!( - base_url = %cfg.base_url, - mode = mode.as_str(), - "eIDAS seal: QTSP adapter active" - ); - let adapter = dpp_seal::QtspSealAdapter::eideasy(cfg) - .context("Failed to build the QTSP seal adapter")?; - (Arc::new(adapter), client_id, mode) - } - dpp_seal::SealProvider::Local => { - // Selected but not yet wired. Refusing to boot is the point: the - // alternative is a node that was asked for a seal, silently gave - // none, and published passports saying so. - anyhow::bail!( - "SEAL_PROVIDER=local selects the in-process development backend, which is not implemented yet — the signing format is undecided. Unset it to run with GhostSeal." - ); - } - dpp_seal::SealProvider::None => { - tracing::info!( - "eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing" - ); - ( - Arc::new(dpp_seal::QtspSealAdapter::ghost()), - String::new(), - TrustMode::Ghost, - ) - } - }; - let sealing_live = seal_trust != TrustMode::Ghost; + // Two questions, deliberately not one flag. `seal_trust` is *legal* standing + // and decides whether a profile will boot. `seal_drains` is *mechanical* — + // whether the backend emits an envelope worth draining — and a locally + // signed seal answers yes to the second while answering `Ghost` to the + // first. Collapsing them either strands the local backend unexercised or + // lets a self-signed certificate satisfy a production boot. + let (seal, seal_client_id, seal_trust, seal_drains): ( + Arc, + String, + TrustMode, + bool, + ) = match dpp_seal::SealProvider::from_env().context("seal provider")? { + dpp_seal::SealProvider::Qtsp => { + let cfg = + dpp_seal::eideasy::EideasyConfig::from_env().context("QTSP seal configuration")?; + // 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 mode = match cfg.environment { + dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, + dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, + }; + let client_id = cfg.client_id.clone(); + tracing::info!( + base_url = %cfg.base_url, + mode = mode.as_str(), + "eIDAS seal: QTSP adapter active" + ); + let adapter = dpp_seal::QtspSealAdapter::eideasy(cfg) + .context("Failed to build the QTSP seal adapter")?; + (Arc::new(adapter), client_id, mode, true) + } + dpp_seal::SealProvider::Local => { + let cfg = + dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; + let adapter = dpp_seal::QtspSealAdapter::local(&cfg) + .context("Failed to build the local seal adapter")?; + tracing::warn!( + key_path = %cfg.key_path.display(), + "eIDAS seal: LOCAL development backend — a real CMS signature under a self-signed certificate, on no EU Trusted List and of no legal weight" + ); + // Ghost as a trust tier, because no authority stands behind a + // self-signed certificate — so a sandbox or production profile + // refuses to boot on it. But the envelope is real, so it drains. + (Arc::new(adapter), String::new(), TrustMode::Ghost, true) + } + dpp_seal::SealProvider::None => { + tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); + ( + Arc::new(dpp_seal::QtspSealAdapter::ghost()), + String::new(), + TrustMode::Ghost, + false, + ) + } + }; + let sealing_live = seal_drains; let trust = boot::trust::build_and_enforce( seal_trust, diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index 947473f..4304be2 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -6,6 +6,7 @@ //! errored. use async_trait::async_trait; +use base64::Engine as _; use chrono::Utc; use dpp_domain::{ domain::error::DppError, @@ -31,6 +32,9 @@ pub struct QtspSealAdapter { } enum Backend { + /// In-process CMS signing under a self-signed certificate. Real bytes, no + /// legal weight — the node resolves it to the `Ghost` trust tier. + Local(Box), /// Live eID Easy Cloud Direct e-Sealing. Eideasy(Box), /// Placeholder with no legal validity; a production node refuses to boot on it. @@ -45,6 +49,15 @@ impl QtspSealAdapter { }) } + /// Adapter backed by the local development sealer. + pub fn local(config: &crate::local::LocalConfig) -> Result { + Ok(Self { + backend: Backend::Local(Box::new(crate::local::LocalIdentity::load_or_create( + &config.key_path, + )?)), + }) + } + /// Placeholder adapter — synthetic envelopes, no legal validity. pub fn ghost() -> Self { Self { @@ -56,7 +69,7 @@ impl QtspSealAdapter { pub fn config(&self) -> Option<&EideasyConfig> { match &self.backend { Backend::Eideasy(c) => Some(c.config()), - Backend::Ghost => None, + Backend::Local(_) | Backend::Ghost => None, } } } @@ -69,6 +82,21 @@ impl SealPort for QtspSealAdapter { warn!("eID Easy not configured — using GhostSeal (placeholder, no legal validity)"); return GhostSeal.seal(req).await; } + Backend::Local(id) => { + let der = id + .sign_detached(&hex::decode(&req.payload_hash).map_err(|e| { + SealError::Config(format!("payload hash is not hex: {e}")) + })?)?; + return Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value: base64::engine::general_purpose::STANDARD.encode(&der), + signing_cert_ref: Some(id.cert_thumbprint()), + sealed_at: Utc::now(), + // Not a placeholder: these bytes verify. Legal standing is + // the trust tier's business, not the envelope's. + placeholder: false, + }); + } Backend::Eideasy(c) => c, }; @@ -99,7 +127,13 @@ impl SealPort for QtspSealAdapter { async fn verify(&self, env: &SealedEnvelope) -> Result { match &self.backend { Backend::Ghost => GhostSeal.verify(env).await, - Backend::Eideasy(_) => Err(SealError::Unsupported(VERIFY_UNSUPPORTED).into()), + // Refused for the local backend too, and for the same reason: a + // detached CMS needs an independent AdES validator, and reporting a + // seal valid on a check we did not perform is the failure this + // method exists to avoid. That the key is ours changes nothing. + Backend::Local(_) | Backend::Eideasy(_) => { + Err(SealError::Unsupported(VERIFY_UNSUPPORTED).into()) + } } } @@ -108,6 +142,13 @@ impl SealPort for QtspSealAdapter { // The placeholder path genuinely produces (synthetic) seals — report // what `GhostSeal` actually does. Backend::Ghost => GhostSeal.capabilities(), + // Real detached CAdES bytes. `OperatorSeal` because the certificate + // is generated per node rather than held centrally on operators' + // behalf — the local backend rehearses the shape option C takes. + Backend::Local(_) => SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::OperatorSeal], + }, // eID Easy Direct e-Sealing offers the CAdES profile only, and this // node holds the seal on operators' behalf, so `ProviderSeal` is the // one mode. `verify()` is unsupported and no capability claims it. diff --git a/crates/dpp-types/src/trust.rs b/crates/dpp-types/src/trust.rs index 15791e4..8f115cd 100644 --- a/crates/dpp-types/src/trust.rs +++ b/crates/dpp-types/src/trust.rs @@ -18,7 +18,10 @@ use serde::Serialize; /// Trust tier a resolved adapter operates at. Gauge encoding: Ghost=0, Sandbox=1, /// Live=2 (`trust_mode{port="…"}`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +/// Ordered deliberately: `Ghost < Sandbox < Live`. The ordering is what lets a +/// profile state a floor rather than enumerate the tiers it rejects, so adding a +/// tier later does not silently pass an existing guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] pub enum TrustMode { /// Placeholder — no real trust authority behind it (test double). @@ -57,27 +60,51 @@ impl TrustMode { } } -/// Deployment profile. Defaults to `Development`; `NODE_PROFILE=production` -/// opts into the strict boot guard. +/// Deployment profile — which trust tiers this environment will boot on. +/// +/// Three environments, not two, because "sandbox" is a property of the +/// **deployment** rather than a tier a production node may quietly carry. A +/// sandbox node is a full node in every respect except that the authorities +/// behind it are test ones; running it separately is the closest rehearsal of +/// production there is, and keeping the profiles apart is what stops a test +/// certificate ever sealing a passport that claims to be real. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum NodeProfile { /// Ghosts allowed; the default and the licensed dev-environment profile. Development, - /// Ghosts on required ports are a hard boot failure. + /// A real deployment against **test** authorities. Ghosts on required ports + /// are a hard boot failure, exactly as in production — what differs is that + /// `Sandbox` tiers are accepted, so the environment can be exercised + /// end-to-end without a production credential. + Sandbox, + /// Ghosts **and** sandboxes on required ports are a hard boot failure. + /// Only `Live` will do: a production node states that its passports are + /// backed by real authorities, and a sandbox tier makes that untrue. Production, } impl NodeProfile { /// Read `NODE_PROFILE` from the environment. Anything other than - /// `production` (including unset) is `Development`. + /// `production` or `sandbox` (including unset) is `Development`. #[must_use] pub fn from_env() -> Self { match std::env::var("NODE_PROFILE").ok().as_deref() { Some("production") => Self::Production, + Some("sandbox") => Self::Sandbox, _ => Self::Development, } } + + /// The lowest trust tier this profile will boot a required port on. + #[must_use] + pub fn minimum_tier(self) -> Option { + match self { + Self::Development => None, + Self::Sandbox => Some(TrustMode::Sandbox), + Self::Production => Some(TrustMode::Live), + } + } } /// One resolved trust port and the tier it operates at. @@ -127,17 +154,33 @@ impl NodeTrustReport { /// # Errors /// The offending-port message when a production node would boot on ghosts. pub fn enforce_profile(&self) -> Result<(), String> { - if self.profile != NodeProfile::Production { + let Some(minimum) = self.profile.minimum_tier() else { return Ok(()); - } - let ghosts = self.ghosted_required(); - if ghosts.is_empty() { + }; + + // Below the floor, not merely ghosted. `Production` demands `Live`, so a + // sandbox tier fails it too — a production node asserts that real + // authorities stand behind its passports, and a provider's test + // certificate makes that assertion false while looking identical. + let below: Vec<&str> = self + .ports + .iter() + .filter(|p| p.required && p.mode < minimum) + .map(|p| p.port) + .collect(); + if below.is_empty() { return Ok(()); } + + let profile = match self.profile { + NodeProfile::Production => "production", + NodeProfile::Sandbox => "sandbox", + NodeProfile::Development => "development", + }; Err(format!( - "NODE_PROFILE=production refuses to boot: required trust port(s) [{}] resolved to a \ - placeholder (ghost). Configure a real adapter or run with NODE_PROFILE=development.", - ghosts.join(", ") + "NODE_PROFILE={profile} refuses to boot: required trust port(s) [{}] resolved below `{}`. Configure a real adapter, or run a profile that admits the tier you have.", + below.join(", "), + minimum.as_str() )) } @@ -236,4 +279,63 @@ mod tests { assert_eq!(j["trust_mode"]["registry_sync"], "sandbox"); assert_eq!(j["trust_mode"]["archive"], "live"); } + + /// A production node refuses a **sandbox** tier, not only a ghost. + /// + /// This is the separation stated as a test. Before it, `Production` + /// admitted `Sandbox`, so a production deployment could seal passports with + /// a provider's test certificate and assert nothing was wrong — the two + /// look identical from outside, and only the tier distinguishes them. + #[test] + fn production_refuses_a_sandbox_seal() { + let report = NodeTrustReport::new( + NodeProfile::Production, + ports(TrustMode::Sandbox, TrustMode::Live, TrustMode::Live), + ); + let err = report + .enforce_profile() + .expect_err("production must not boot on a test certificate"); + assert!(err.contains("seal"), "{err}"); + assert!(err.contains("live"), "the message names the floor: {err}"); + } + + /// A sandbox node boots on sandbox tiers, and still refuses ghosts. + /// + /// The point of the profile: a full rehearsal of production against test + /// authorities. Admitting ghosts too would make it a development node with + /// a different name. + #[test] + fn sandbox_admits_sandbox_but_not_ghost() { + let ok = NodeTrustReport::new( + NodeProfile::Sandbox, + ports(TrustMode::Sandbox, TrustMode::Sandbox, TrustMode::Ghost), + ); + assert!(ok.enforce_profile().is_ok(), "sandbox tiers are the point"); + + let bad = NodeTrustReport::new( + NodeProfile::Sandbox, + ports(TrustMode::Ghost, TrustMode::Sandbox, TrustMode::Live), + ); + assert!( + bad.enforce_profile().is_err(), + "a ghost on a required port is a boot failure in sandbox too" + ); + } + + /// Development boots on anything, including all ghosts. + #[test] + fn development_admits_every_tier() { + let report = NodeTrustReport::new( + NodeProfile::Development, + ports(TrustMode::Ghost, TrustMode::Ghost, TrustMode::Ghost), + ); + assert!(report.enforce_profile().is_ok()); + } + + /// The ordering the floor comparison relies on. + #[test] + fn trust_tiers_are_ordered_ghost_sandbox_live() { + assert!(TrustMode::Ghost < TrustMode::Sandbox); + assert!(TrustMode::Sandbox < TrustMode::Live); + } } From c11fbe6cae67e19b3be15b21de35f77efd3a47b0 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 00:52:00 +0200 Subject: [PATCH 07/20] refactor(seal): move provider prose and tests into its module --- crates/dpp-seal/src/eideasy/mod.rs | 13 +++++++++++++ crates/dpp-seal/src/{ => eideasy}/tests.rs | 0 crates/dpp-seal/src/lib.rs | 16 +--------------- 3 files changed, 14 insertions(+), 15 deletions(-) rename crates/dpp-seal/src/{ => eideasy}/tests.rs (100%) diff --git a/crates/dpp-seal/src/eideasy/mod.rs b/crates/dpp-seal/src/eideasy/mod.rs index 8bd10fc..48f3da2 100644 --- a/crates/dpp-seal/src/eideasy/mod.rs +++ b/crates/dpp-seal/src/eideasy/mod.rs @@ -4,6 +4,16 @@ //! HMAC-authenticated server-to-server digest sealing. The wire contract is //! eID Easy's published Cloud Direct e-Sealing API. //! +//! +//! Cloud Direct e-Sealing produces **CAdES** over the payload digest (not +//! JAdES). Auth is HMAC over the exact request bytes. CAdES from a qualified +//! QTSP carries the same eIDAS Art. 35 legal presumption as any other AdES +//! envelope; the DPP registry requires a *qualified* seal, not a specific +//! envelope format. +//! +//! Sandbox needs no legal entity — it seals with the provider's test +//! certificates. The entity gates *production* only. +//! //! - [`types`] — request/response wire types. //! - [`client`] — the HMAC-signed POST, and the sign-the-exact-bytes invariant. @@ -11,6 +21,9 @@ pub mod client; pub mod config; pub mod types; +#[cfg(test)] +mod tests; + pub use client::EideasyClient; pub use config::{EideasyConfig, EideasyEnvironment}; pub use types::{EsealFile, EsealRequest, EsealResponse, EsealSignatureOut, MIME_JSON, MIME_PDF}; diff --git a/crates/dpp-seal/src/tests.rs b/crates/dpp-seal/src/eideasy/tests.rs similarity index 100% rename from crates/dpp-seal/src/tests.rs rename to crates/dpp-seal/src/eideasy/tests.rs diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index 443e154..81dbda0 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -10,17 +10,6 @@ //! no legal validity, which is why a production node's trust report refuses //! to boot while the seal port resolves to a ghost. //! -//! # Provider -//! -//! **eID Easy Cloud Direct e-Sealing**, which aggregates qualified QTSPs and -//! produces **CAdES** over the payload digest. Auth is HMAC over the exact -//! request bytes. CAdES from a qualified QTSP carries the same eIDAS -//! Art. 35 legal presumption as any other AdES envelope; the DPP registry -//! requires a *qualified* seal, not a specific envelope format. -//! -//! Sandbox needs no legal entity — it seals with eID Easy test certificates. -//! The entity gates *production* only. -//! //! # What is sealed //! //! The digest handed to `SealPort::seal` is over the passport's `jwsSignature` @@ -34,7 +23,7 @@ //! //! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl //! - [`config`] — which backend this node runs, and nothing about any of them -//! - [`eideasy`] — the hosted QTSP backend: config, wire types, HTTP client +//! - [`eideasy`] — a hosted QTSP backend: its config, wire types and client //! - [`local`] — in-process signing for development //! - [`error`] — `SealError`, classified once at the HTTP boundary //! @@ -51,6 +40,3 @@ pub mod local; pub use adapter::QtspSealAdapter; pub use config::{SEAL_PROVIDER, SealProvider}; pub use error::SealError; - -#[cfg(test)] -mod tests; From 4849badb187291974121483562c25801d7a4e178 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 01:51:27 +0200 Subject: [PATCH 08/20] refactor(seal): dispatch on a SealBackend trait --- CLAUDE.md | 7 +- crates/dpp-node/src/main.rs | 20 ++- crates/dpp-node/tests/seal_outbox.rs | 16 ++- crates/dpp-seal/README.md | 57 +++++--- crates/dpp-seal/src/adapter.rs | 193 +++++++------------------- crates/dpp-seal/src/backend.rs | 57 ++++++++ crates/dpp-seal/src/eideasy/client.rs | 88 +++++++++--- crates/dpp-seal/src/eideasy/error.rs | 80 +++++++++++ crates/dpp-seal/src/eideasy/mod.rs | 6 +- crates/dpp-seal/src/eideasy/tests.rs | 15 +- crates/dpp-seal/src/eideasy/types.rs | 14 +- crates/dpp-seal/src/error.rs | 102 +++----------- crates/dpp-seal/src/ghost.rs | 47 +++++++ crates/dpp-seal/src/lib.rs | 24 ++-- crates/dpp-seal/src/local/sealer.rs | 88 ++++++++++++ 15 files changed, 522 insertions(+), 292 deletions(-) create mode 100644 crates/dpp-seal/src/backend.rs create mode 100644 crates/dpp-seal/src/eideasy/error.rs create mode 100644 crates/dpp-seal/src/ghost.rs diff --git a/CLAUDE.md b/CLAUDE.md index 2bb09ac..70903d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,9 +155,10 @@ dpp-integrator — CSV/XLSX bulk import dpp-common — event bus trait, telemetry, config helpers, RFC 7807 errors dpp-plugin-host — wasmtime sandbox for sector Wasm plugins dpp-node — MVP single binary fusing vault + identity + integrator -dpp-seal — eIDAS qualified seal adapter: eID Easy Cloud Direct e-Sealing - (CAdES) with a GhostSeal fallback. Wired into dpp-node, but the - drain only arms against a real QTSP — see the sealing_live guard +dpp-seal — eIDAS qualified seal adapter: one `SealBackend` behind the + `SealPort`, selected by SEAL_PROVIDER (hosted QTSP / local dev + sealer / ghost). Only the hosted one has legal weight; the drain + arms for any backend that emits a real envelope (sealing_live) dpp-factor-data — licensed LCI factor data store: GhostFactorProvider + FactorStore trait (no dependent yet) cli/ — management CLI (clap); package `dpp-cli`, binary `odal` ``` diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index bb4d84b..fd27153 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -205,14 +205,19 @@ async fn main() -> anyhow::Result<()> { mode = mode.as_str(), "eIDAS seal: QTSP adapter active" ); - let adapter = dpp_seal::QtspSealAdapter::eideasy(cfg) + let backend = dpp_seal::eideasy::EideasyClient::new(cfg) .context("Failed to build the QTSP seal adapter")?; - (Arc::new(adapter), client_id, mode, true) + ( + Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + client_id, + mode, + true, + ) } dpp_seal::SealProvider::Local => { let cfg = dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; - let adapter = dpp_seal::QtspSealAdapter::local(&cfg) + let backend = dpp_seal::local::LocalIdentity::load_or_create(&cfg.key_path) .context("Failed to build the local seal adapter")?; tracing::warn!( key_path = %cfg.key_path.display(), @@ -221,12 +226,17 @@ async fn main() -> anyhow::Result<()> { // Ghost as a trust tier, because no authority stands behind a // self-signed certificate — so a sandbox or production profile // refuses to boot on it. But the envelope is real, so it drains. - (Arc::new(adapter), String::new(), TrustMode::Ghost, true) + ( + Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + String::new(), + TrustMode::Ghost, + true, + ) } dpp_seal::SealProvider::None => { tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); ( - Arc::new(dpp_seal::QtspSealAdapter::ghost()), + Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)), String::new(), TrustMode::Ghost, false, diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 366574c..a0cc09e 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -250,6 +250,13 @@ fn eideasy_config(base_url: &str) -> dpp_seal::eideasy::EideasyConfig { } } +/// The real `SealPort` over the real provider backend, pointed at the mock. +fn eideasy_adapter(cfg: dpp_seal::eideasy::EideasyConfig) -> Arc { + Arc::new(QtspSealAdapter::new( + dpp_seal::eideasy::EideasyClient::new(cfg).expect("build adapter"), + )) +} + // ─── The loop ───────────────────────────────────────────────────────────────── /// Publish a real passport, let the real drain seal it against the mock, and @@ -324,8 +331,7 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { ); // ── 3. Drain: the real adapter against the mock ────────────────────────── - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(eideasy_config(&base_url)).expect("build adapter")); + let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; assert_eq!(stats.sealed, 1, "the drain must seal the queued row"); @@ -449,8 +455,7 @@ async fn a_republish_needs_and_gets_its_own_seal() { let first = service.publish(id, &auth()).await.expect("publish"); let first_jws = first.jws_signature.clone().unwrap(); - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(eideasy_config(&base_url)).expect("build adapter")); + let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; @@ -527,8 +532,7 @@ async fn a_wrong_key_is_rejected_and_the_row_stays_pending() { let mut bad = eideasy_config(&base_url); bad.hmac_key = zeroize::Zeroizing::new("the-wrong-key".to_owned()); - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(bad).expect("build adapter")); + let adapter = eideasy_adapter(bad); let outbox_dyn: Arc = seal_outbox.clone(); let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; diff --git a/crates/dpp-seal/README.md b/crates/dpp-seal/README.md index ddf694c..ea0dd06 100644 --- a/crates/dpp-seal/README.md +++ b/crates/dpp-seal/README.md @@ -2,10 +2,11 @@ eIDAS qualified electronic seal adapter for [Odal Node](https://odal-node.io). -Implements `SealPort` (from `dpp-domain::ports::seal`) against **eID Easy Cloud -Direct e-Sealing**, which aggregates qualified QTSPs and returns **CAdES**. When -eID Easy is not configured the adapter delegates to `GhostSeal`, and a production -node refuses to boot. +Implements `SealPort` (from `dpp-domain::ports::seal`) over one of three +backends, selected by `SEAL_PROVIDER`: **eID Easy Cloud Direct e-Sealing**, which +aggregates qualified QTSPs and returns **CAdES**; a **local** development sealer; +or `GhostSeal` when nothing is configured. Only the first carries legal weight, +and a production node refuses to boot on either of the others. > **Not yet exercised against the live provider.** Every code path is built and > tested against a local mock; the sandbox test is blocked on eID Easy enabling @@ -15,16 +16,27 @@ node refuses to boot. | Component | Status | |---|---| -| `QtspSealAdapter` | eID Easy backend, or `GhostSeal` when unconfigured | -| `config::EideasyConfig` | `from_env()`, host allowlist, redacting `Debug` | -| `eideasy::client` | HMAC-signed POST to `/api/signatures/e-seal` | -| `eideasy::types` | Request/response wire types | -| `error::SealError` | Typed auth / transport / protocol / provider failures | - -`verify()` is **not** implemented and says so, returning a typed error. Verifying -a detached CAdES needs an independent AdES validator — no Rust implementation -exists — and a seal is worth exactly as much as the independence of whoever -checked it, so this adapter never reports a verdict it did not compute. +| `backend::SealBackend` | The seam each backend implements | +| `QtspSealAdapter` | `SealPort` over one `dyn SealBackend`; names none of them | +| `config::SealProvider` | Which backend this node runs, resolved from the environment | +| `eideasy::` | The hosted QTSP backend: config (host allowlist, redacting `Debug`), HMAC-signed POST to `/api/signatures/e-seal`, wire types, and its own typed errors | +| `local::` | A real detached CMS `SignedData` under a self-signed P-256 key | +| `ghost::` | `GhostSeal` as a backend — synthetic envelopes, no legal validity | +| `error::SealError` | Config / transport / backend / unsupported — nothing provider-shaped | + +Each backend owns its module entirely: its variables, its validation, its failure +messages, its wire types, and its own construction. Nothing outside a backend's +module names it, so one can be added or dropped without touching the others. + +`verify()` is **not** implemented for the hosted backend and says so, returning a +typed error. It is the `SealBackend` default, so a new backend refuses by +construction and has to override it to claim otherwise. + +The reason is independence, not tooling. A qualified seal is worth exactly as +much as the independence of whoever checked it, so a verdict this node issues on +a seal this node bought attests nothing a relying party should accept — validate +those elsewhere. Rust AdES libraries exist and are improving; none of that +changes the answer. ## The one rule @@ -81,17 +93,19 @@ unused, for if they confirm the accurate value is accepted. ## Configuration ``` -SEAL_PROVIDER=eideasy # or `none` (default) for GhostSeal +SEAL_PROVIDER=eideasy # `local`, or `none` (default) for GhostSeal SEAL_EIDEASY_BASE_URL=https://test.eideasy.com # sandbox; prod = https://id.eideasy.com SEAL_EIDEASY_CLIENT_ID=... # from test.eideasy.com "My Webpages" SEAL_EIDEASY_HMAC_KEY=... # generated once in Eseal Settings SEAL_EIDEASY_SIGNATURE_PROFILE=CAdES_BASELINE_T # optional; must be enabled for client + +SEAL_LOCAL_KEY_PATH=./.seal-local # optional; only for SEAL_PROVIDER=local ``` -`SEAL_PROVIDER` takes `eideasy` or `none`, and an unrecognised value fails the -boot. It exists with one provider because env var names are a published -interface: adding a QTSP later must not force every self-hoster through a config -migration. +`SEAL_PROVIDER` takes `eideasy`, `local` or `none`, and an unrecognised value +fails the boot. The selection is deliberately explicit rather than inferred from +whichever credentials happen to be present: a node that cannot name its trust +provider must not quietly become one that has none. Three failure modes are all refused rather than ghosted, for the same reason — each would downgrade a node that was configured for qualified sealing into one @@ -105,8 +119,9 @@ in the node `.env` (mode 600) beside store, which holds Ed25519 pairs for DID publication, and not in Postgres, which would put a live signing credential in every backup. -When eID Easy config is absent, `QtspSealAdapter` falls back to `GhostSeal` and -logs a warning; `NODE_PROFILE=production` refuses to boot. +With no provider selected the node wires the ghost backend, which logs a warning +on every seal; `NODE_PROFILE=production` refuses to boot on it — and on the local +backend too, whose certificate is on no EU Trusted List. ## License diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index 4304be2..a06eebb 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -1,75 +1,29 @@ //! `QtspSealAdapter` — the `SealPort` implementation. //! -//! Two states, and no third: an eID Easy backend, or `GhostSeal`. The previous -//! "configured but unimplemented" tier is gone — it was a state that had to -//! report empty capabilities so it would not contradict a `seal()` that always -//! errored. +//! It holds one [`SealBackend`] and does nothing else: forward the port's three +//! calls, and collapse [`SealError`] into `DppError` at the boundary. Which +//! backend it holds is decided by [`crate::config`] and constructed by that +//! backend's own module, so nothing in this file names one — adding or removing +//! a backend leaves it untouched. use async_trait::async_trait; -use base64::Engine as _; -use chrono::Utc; use dpp_domain::{ domain::error::DppError, - ports::seal::{ - GhostSeal, SealCapabilities, SealFormat, SealMode, SealPort, SealRequest, SealVerification, - SealedEnvelope, - }, + ports::seal::{SealCapabilities, SealPort, SealRequest, SealVerification, SealedEnvelope}, }; -use tracing::warn; -use crate::eideasy::EideasyConfig; -use crate::eideasy::client::EideasyClient; -use crate::error::SealError; +use crate::backend::SealBackend; -/// What `verify()` says instead of guessing. -const VERIFY_UNSUPPORTED: &str = "seal verification is not implemented: a detached CAdES must be validated by an independent \ - AdES validator (no Rust implementation exists), and this adapter will not report a seal \ - valid on a check it did not perform"; - -/// eIDAS seal adapter. +/// eIDAS seal adapter over whichever backend the node was configured with. pub struct QtspSealAdapter { - backend: Backend, -} - -enum Backend { - /// In-process CMS signing under a self-signed certificate. Real bytes, no - /// legal weight — the node resolves it to the `Ghost` trust tier. - Local(Box), - /// Live eID Easy Cloud Direct e-Sealing. - Eideasy(Box), - /// Placeholder with no legal validity; a production node refuses to boot on it. - Ghost, + backend: Box, } impl QtspSealAdapter { - /// Adapter backed by eID Easy. - pub fn eideasy(config: EideasyConfig) -> Result { - Ok(Self { - backend: Backend::Eideasy(Box::new(EideasyClient::new(config)?)), - }) - } - - /// Adapter backed by the local development sealer. - pub fn local(config: &crate::local::LocalConfig) -> Result { - Ok(Self { - backend: Backend::Local(Box::new(crate::local::LocalIdentity::load_or_create( - &config.key_path, - )?)), - }) - } - - /// Placeholder adapter — synthetic envelopes, no legal validity. - pub fn ghost() -> Self { + /// Wrap a backend as the node's `SealPort`. + pub fn new(backend: impl SealBackend + 'static) -> Self { Self { - backend: Backend::Ghost, - } - } - - /// The configured backend, when there is one. `None` means ghost-backed. - pub fn config(&self) -> Option<&EideasyConfig> { - match &self.backend { - Backend::Eideasy(c) => Some(c.config()), - Backend::Local(_) | Backend::Ghost => None, + backend: Box::new(backend), } } } @@ -77,106 +31,63 @@ impl QtspSealAdapter { #[async_trait] impl SealPort for QtspSealAdapter { async fn seal(&self, req: SealRequest) -> Result { - let client = match &self.backend { - Backend::Ghost => { - warn!("eID Easy not configured — using GhostSeal (placeholder, no legal validity)"); - return GhostSeal.seal(req).await; - } - Backend::Local(id) => { - let der = id - .sign_detached(&hex::decode(&req.payload_hash).map_err(|e| { - SealError::Config(format!("payload hash is not hex: {e}")) - })?)?; - return Ok(SealedEnvelope { - format: SealFormat::Cades, - seal_value: base64::engine::general_purpose::STANDARD.encode(&der), - signing_cert_ref: Some(id.cert_thumbprint()), - sealed_at: Utc::now(), - // Not a placeholder: these bytes verify. Legal standing is - // the trust tier's business, not the envelope's. - placeholder: false, - }); - } - Backend::Eideasy(c) => c, - }; - - // The digest doubles as the correlation label: eID Easy echoes `fileName` - // back, so deriving it from the digest makes a mismatched response visible - // without holding request state. No extension — see `EsealFile::file_name`. - let file_name = format!( - "dpp-{}", - &req.payload_hash[..req.payload_hash.len().min(16)] - ); - let seal_value = client - .seal_digest(&file_name, &req.payload_hash) - .await - .map_err(DppError::from)?; - - Ok(SealedEnvelope { - format: SealFormat::Cades, - seal_value, - // The signing certificate travels *inside* the detached CAdES, and - // reading it out needs the CMS parser this adapter deliberately does - // not have. Left `None` rather than filled with a guess. - signing_cert_ref: None, - sealed_at: Utc::now(), - placeholder: false, - }) + self.backend.seal(req).await.map_err(DppError::from) } async fn verify(&self, env: &SealedEnvelope) -> Result { - match &self.backend { - Backend::Ghost => GhostSeal.verify(env).await, - // Refused for the local backend too, and for the same reason: a - // detached CMS needs an independent AdES validator, and reporting a - // seal valid on a check we did not perform is the failure this - // method exists to avoid. That the key is ours changes nothing. - Backend::Local(_) | Backend::Eideasy(_) => { - Err(SealError::Unsupported(VERIFY_UNSUPPORTED).into()) - } - } + self.backend.verify(env).await.map_err(DppError::from) } fn capabilities(&self) -> SealCapabilities { - match &self.backend { - // The placeholder path genuinely produces (synthetic) seals — report - // what `GhostSeal` actually does. - Backend::Ghost => GhostSeal.capabilities(), - // Real detached CAdES bytes. `OperatorSeal` because the certificate - // is generated per node rather than held centrally on operators' - // behalf — the local backend rehearses the shape option C takes. - Backend::Local(_) => SealCapabilities { - supported_formats: vec![SealFormat::Cades], - supported_modes: vec![SealMode::OperatorSeal], - }, - // eID Easy Direct e-Sealing offers the CAdES profile only, and this - // node holds the seal on operators' behalf, so `ProviderSeal` is the - // one mode. `verify()` is unsupported and no capability claims it. - Backend::Eideasy(_) => SealCapabilities { - supported_formats: vec![SealFormat::Cades], - supported_modes: vec![SealMode::ProviderSeal], - }, - } + self.backend.capabilities() } } #[cfg(test)] mod tests { use super::*; - use crate::eideasy::config::{SANDBOX_BASE_URL, test_config}; + use crate::ghost::GhostSeal; #[test] fn unconfigured_reports_ghost_capabilities() { - let caps = QtspSealAdapter::ghost().capabilities(); + let caps = QtspSealAdapter::new(GhostSeal).capabilities(); assert!(!caps.supported_formats.is_empty()); } - #[test] - fn the_eideasy_backend_advertises_cades_only() { - let caps = QtspSealAdapter::eideasy(test_config(SANDBOX_BASE_URL)) - .unwrap() - .capabilities(); - assert_eq!(caps.supported_formats, vec![SealFormat::Cades]); - assert_eq!(caps.supported_modes, vec![SealMode::ProviderSeal]); + /// A backend that implements only what the trait requires must not verify. + /// + /// The default is what stops a new backend from silently inheriting a + /// "valid" answer it never computed — so it is checked through the adapter, + /// where the refusal has to survive the conversion to `DppError` to reach a + /// caller at all. + #[tokio::test] + async fn a_backend_that_says_nothing_about_verify_refuses() { + struct Minimal; + + #[async_trait] + impl SealBackend for Minimal { + async fn seal(&self, _req: SealRequest) -> Result { + unreachable!("this test never seals") + } + fn capabilities(&self) -> SealCapabilities { + SealCapabilities { + supported_formats: Vec::new(), + supported_modes: Vec::new(), + } + } + } + + let env = SealedEnvelope { + format: dpp_domain::ports::seal::SealFormat::Cades, + seal_value: "p7s".into(), + signing_cert_ref: None, + sealed_at: chrono::Utc::now(), + placeholder: false, + }; + let err = QtspSealAdapter::new(Minimal) + .verify(&env) + .await + .expect_err("the default must refuse rather than answer"); + assert!(err.to_string().contains("not implemented"), "{err}"); } } diff --git a/crates/dpp-seal/src/backend.rs b/crates/dpp-seal/src/backend.rs new file mode 100644 index 0000000..50464d0 --- /dev/null +++ b/crates/dpp-seal/src/backend.rs @@ -0,0 +1,57 @@ +//! `SealBackend` — the one thing every sealing backend has to be able to do. +//! +//! [`crate::adapter::QtspSealAdapter`] holds one of these and does nothing but +//! forward the port's calls to it. The seam exists so that adding, swapping or +//! dropping a backend touches that backend's module and the selector, and +//! nothing else — in particular, not the `SealPort` implementation, which has +//! no business knowing which one it holds. +//! +//! Two things separate this from `SealPort` itself, and they are the reason it +//! is a distinct trait rather than a rename: +//! +//! - **The error type.** A backend returns [`SealError`], which still carries +//! the classification made where the failure happened. The adapter collapses +//! it into `DppError` once, at the port boundary, so no backend has to. +//! - **`verify` refuses by default.** Claiming a seal valid on a check that was +//! never performed is the failure this crate is most exposed to, so silence is +//! the default and a backend that can genuinely verify has to say so. + +use async_trait::async_trait; +use dpp_domain::ports::seal::{SealCapabilities, SealRequest, SealVerification, SealedEnvelope}; + +use crate::error::SealError; + +/// What [`SealBackend::verify`] says instead of guessing. +const VERIFY_UNSUPPORTED: &str = "seal verification is not implemented for this backend: a qualified seal is only meaningfully \ + validated by a party independent of whoever produced it, and this adapter will not report a \ + seal valid on a check it did not perform"; + +/// One way of producing a seal: a hosted trust service, a local key, a placeholder. +#[async_trait] +pub trait SealBackend: Send + Sync { + /// Produce a seal over the request's payload digest. + async fn seal(&self, req: SealRequest) -> Result; + + /// Which formats and modes this backend can actually produce. + /// + /// It must answer for what it does, not for what the port allows: a + /// capability nothing here can deliver is a promise the trust report will + /// carry outward. + fn capabilities(&self) -> SealCapabilities; + + /// Verify a seal — by default, refuse to. + /// + /// The default is refusal because the interesting case cannot be answered + /// here: a *qualified* seal is worth what the independence of its validator + /// is worth, so a verdict this node issues on a seal this node bought + /// attests nothing a relying party should accept. Rust AdES tooling exists + /// and will improve; that was never what made the answer unavailable. + /// + /// A backend that can genuinely check its own output — one whose seals make + /// no trust claim beyond the key, so that a cryptographic check *is* the + /// whole truth about them — should override this and say so. + async fn verify(&self, env: &SealedEnvelope) -> Result { + let _ = env; + Err(SealError::Unsupported(VERIFY_UNSUPPORTED)) + } +} diff --git a/crates/dpp-seal/src/eideasy/client.rs b/crates/dpp-seal/src/eideasy/client.rs index a81991b..6495c63 100644 --- a/crates/dpp-seal/src/eideasy/client.rs +++ b/crates/dpp-seal/src/eideasy/client.rs @@ -16,12 +16,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use async_trait::async_trait; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::Utc; +use dpp_domain::ports::seal::{ + SealCapabilities, SealFormat, SealMode, SealRequest, SealedEnvelope, +}; use hmac::{Hmac, Mac}; use sha2::Sha256; use super::config::EideasyConfig; +use super::error::EideasyError; +use crate::backend::SealBackend; use crate::error::SealError; use super::types::{EsealFile, EsealRequest, EsealResponse}; @@ -51,10 +58,10 @@ struct SignedBody { } impl SignedBody { - fn new(req: &EsealRequest, hmac_key: &str, timestamp: u64) -> Result { + fn new(req: &EsealRequest, hmac_key: &str, timestamp: u64) -> Result { // The one and only serialization. let body = serde_json::to_string(req) - .map_err(|e| SealError::Protocol(format!("request body serialization: {e}")))?; + .map_err(|e| EideasyError::Protocol(format!("request body serialization: {e}")))?; let message = hmac_message(METHOD, ESEAL_PATH, timestamp, &body); let mut mac = @@ -85,10 +92,6 @@ impl EideasyClient { Ok(Self { http, config }) } - pub fn config(&self) -> &EideasyConfig { - &self.config - } - /// Seal one hex SHA-256 digest, returning the base64 detached CAdES `.p7s`. /// /// `file_name` is the correlation label eID Easy echoes back as @@ -115,7 +118,10 @@ impl EideasyClient { // `.body()`, never `.json()` — these are the bytes the HMAC covers. .body(signed.body) .send() - .await?; + .await + // Nothing reached the far side, so there is no status and no body to + // classify against — this is transport, not a provider answer. + .map_err(|e| SealError::Transport(e.to_string()))?; let status = response.status(); if !status.is_success() { @@ -138,26 +144,70 @@ impl EideasyClient { "eID Easy e-seal request failed" ); return Err(match status.as_u16() { - 429 => SealError::RateLimited { retry_after_secs }, - s @ (401 | 403) => SealError::Auth { + 429 => EideasyError::RateLimited { retry_after_secs }, + s @ (401 | 403) => EideasyError::Auth { status: s, hint: clock_hint(signed.timestamp, their_time), }, - s => SealError::Provider { status: s }, - }); + s => EideasyError::Provider { status: s }, + } + .into()); } - let parsed: EsealResponse = response.json().await?; + // A body that fails to decode is a protocol fault, not a transport one: + // the exchange completed, the bytes are just not what was promised. + let parsed: EsealResponse = response + .json() + .await + .map_err(|e| EideasyError::Protocol(e.to_string()))?; Ok(parsed.single_signature()?.file_content.clone()) } } +#[async_trait] +impl SealBackend for EideasyClient { + async fn seal(&self, req: SealRequest) -> Result { + // The digest doubles as the correlation label: eID Easy echoes `fileName` + // back, so deriving it from the digest makes a mismatched response visible + // without holding request state. No extension — see `EsealFile::file_name`. + let file_name = format!( + "dpp-{}", + &req.payload_hash[..req.payload_hash.len().min(16)] + ); + let seal_value = self.seal_digest(&file_name, &req.payload_hash).await?; + + Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value, + // The signing certificate travels *inside* the detached CAdES, and + // reading it out needs the CMS parser this adapter deliberately does + // not have. Left `None` rather than filled with a guess. + signing_cert_ref: None, + sealed_at: Utc::now(), + placeholder: false, + }) + } + + fn capabilities(&self) -> SealCapabilities { + // eID Easy Direct e-Sealing offers the CAdES profile only, and this node + // holds the seal on operators' behalf, so `ProviderSeal` is the one mode. + // `verify()` takes the trait's refusing default and no capability claims + // otherwise. + SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::ProviderSeal], + } + } +} + /// Current UNIX seconds for `X-Timestamp` (eID Easy allows 5 minutes of skew). fn unix_now() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) - .map_err(|e| SealError::Protocol(format!("system clock is before the UNIX epoch: {e}"))) + // The node's clock, not the provider's answer — nothing about eID Easy + // classifies this one. + .map_err(|e| SealError::Config(format!("system clock is before the UNIX epoch: {e}"))) } /// The provider's clock, from the RFC 9110 `Date` header every response carries. @@ -185,12 +235,12 @@ const MAX_SKEW_SECS: u64 = 300; /// timestamp was acceptable and the rejection means something else, so pointing /// at the clock would send the operator down the wrong path just as surely as /// staying silent sends them to rotate a good key. -fn clock_hint(ours: u64, theirs: Option) -> crate::error::AuthHint { +fn clock_hint(ours: u64, theirs: Option) -> super::error::AuthHint { match theirs { Some(theirs) if ours.abs_diff(theirs) > MAX_SKEW_SECS => { - crate::error::AuthHint::ClockSkew { ours, theirs } + super::error::AuthHint::ClockSkew { ours, theirs } } - _ => crate::error::AuthHint::None, + _ => super::error::AuthHint::None, } } @@ -241,7 +291,7 @@ mod tests { #[test] fn a_clock_inside_the_window_is_not_blamed() { - use crate::error::AuthHint; + use super::super::error::AuthHint; // Inside the tolerance, the timestamp was fine — the 401 means something // else, and blaming the clock would misdirect exactly as badly. assert_eq!(clock_hint(1710000000, Some(1710000000)), AuthHint::None); @@ -252,7 +302,7 @@ mod tests { #[test] fn a_drifted_clock_is_named_in_the_error() { - use crate::error::AuthHint; + use super::super::error::AuthHint; let hint = clock_hint(1710000000, Some(1710000901)); assert!(matches!(hint, AuthHint::ClockSkew { .. })); // Drift in either direction counts. @@ -261,7 +311,7 @@ mod tests { AuthHint::ClockSkew { .. } )); - let rendered = SealError::Auth { status: 401, hint }.to_string(); + let rendered = EideasyError::Auth { status: 401, hint }.to_string(); assert!(rendered.contains("clock"), "{rendered}"); assert!( rendered.contains("before rotating"), diff --git a/crates/dpp-seal/src/eideasy/error.rs b/crates/dpp-seal/src/eideasy/error.rs new file mode 100644 index 0000000..50a8ab4 --- /dev/null +++ b/crates/dpp-seal/src/eideasy/error.rs @@ -0,0 +1,80 @@ +//! Failures that only mean something in terms of this provider's contract. +//! +//! Each variant here encodes something eID Easy specifically does — a +//! five-minute timestamp window, an unpublished rate limit, a status field that +//! can say `ERROR` inside a 200. Classified at the HTTP boundary, where the +//! status and the response headers are still in hand, then handed to the rest of +//! the crate as [`SealError::Backend`] — a rendered message, carrying no +//! provider vocabulary onward. + +use crate::error::SealError; + +/// Why a 401 most likely happened, when the response gives us enough to say. +/// +/// Derived by comparing our `X-Timestamp` against the provider's own `Date` +/// header — the one external clock reference we get for free on every response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthHint { + /// Nothing to add: the clock agreed, or the response carried no usable `Date`. + None, + /// Our clock is outside eID Easy's five-minute window, which alone explains + /// the rejection. Rotating the key here would be chasing the wrong fault. + ClockSkew { ours: u64, theirs: u64 }, +} + +impl std::fmt::Display for AuthHint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => Ok(()), + Self::ClockSkew { ours, theirs } => write!( + f, + " — but this node's clock reads {ours} and the provider's reads \ + {theirs}, a drift of {}s beyond the 5-minute window. Fix the clock \ + (NTP) before rotating the HMAC key; a skewed timestamp fails \ + identically to a bad key", + ours.abs_diff(*theirs) + ), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EideasyError { + /// 401/403 — the HMAC key is wrong or rotated, e-seal credentials were never + /// enabled for this `client_id`, **or the node's clock has drifted**. + /// + /// The last one is why `hint` exists. `X-Timestamp` is inside the signed + /// message and eID Easy allows five minutes of skew, so a drifted clock + /// produces a 401 that is byte-for-byte the same as a bad key. Without a hint, + /// the operator rotates a perfectly good credential and the failure persists. + #[error("eID Easy rejected our credentials (HTTP {status}){hint}")] + Auth { status: u16, hint: AuthHint }, + + /// 429 — too many requests. + /// + /// Typed separately from [`Self::Provider`] because it is the one non-success + /// status that is neither our fault nor a provider fault, and the operator + /// response is different: not "check the credentials", but "the volume is + /// above what this contract allows". eID Easy publishes no rate limits, so + /// the drain's existing exponential backoff (to one hour) is the response; + /// this exists so the cause is nameable in the logs rather than buried in a + /// generic provider error. + #[error("eID Easy rate-limited us (HTTP 429){}", retry_after_secs.map(|s| format!("; Retry-After: {s}s")).unwrap_or_default())] + RateLimited { retry_after_secs: Option }, + + /// The call completed but the answer was not one we can act on — a non-`OK` + /// status field, an unparseable body, or a signature count that does not + /// match the digests we sent. + #[error("eID Easy protocol: {0}")] + Protocol(String), + + /// Any other non-success HTTP status. + #[error("eID Easy returned HTTP {status}")] + Provider { status: u16 }, +} + +impl From for SealError { + fn from(e: EideasyError) -> Self { + SealError::Backend(e.to_string()) + } +} diff --git a/crates/dpp-seal/src/eideasy/mod.rs b/crates/dpp-seal/src/eideasy/mod.rs index 48f3da2..66d8919 100644 --- a/crates/dpp-seal/src/eideasy/mod.rs +++ b/crates/dpp-seal/src/eideasy/mod.rs @@ -15,10 +15,13 @@ //! certificates. The entity gates *production* only. //! //! - [`types`] — request/response wire types. -//! - [`client`] — the HMAC-signed POST, and the sign-the-exact-bytes invariant. +//! - [`client`] — the HMAC-signed POST, the sign-the-exact-bytes invariant, and +//! this backend's `SealBackend` implementation. +//! - [`error`] — the failures that only mean something against this contract. pub mod client; pub mod config; +pub mod error; pub mod types; #[cfg(test)] @@ -26,4 +29,5 @@ mod tests; pub use client::EideasyClient; 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/eideasy/tests.rs b/crates/dpp-seal/src/eideasy/tests.rs index 853e206..5d76a76 100644 --- a/crates/dpp-seal/src/eideasy/tests.rs +++ b/crates/dpp-seal/src/eideasy/tests.rs @@ -19,6 +19,7 @@ use sha2::{Digest, Sha256}; use crate::adapter::QtspSealAdapter; use crate::eideasy::client::{ESEAL_PATH, hmac_message}; +use crate::eideasy::config::{SANDBOX_BASE_URL, test_config}; /// Base64 standing in for a real detached CAdES `.p7s`. const MOCK_P7S: &str = "TU9DSy1DQWRFUy1QN1M="; @@ -129,12 +130,12 @@ use mock_server::MockState; fn adapter_for(base_url: &str, hmac_key: &str) -> QtspSealAdapter { let mut cfg = crate::eideasy::config::test_config(base_url); cfg.hmac_key = zeroize::Zeroizing::new(hmac_key.to_owned()); - QtspSealAdapter::eideasy(cfg).unwrap() + QtspSealAdapter::new(crate::eideasy::EideasyClient::new(cfg).unwrap()) } #[tokio::test] async fn unconfigured_adapter_delegates_to_ghost() { - let env = QtspSealAdapter::ghost() + let env = QtspSealAdapter::new(crate::ghost::GhostSeal) .seal(seal_request(fixture_digest())) .await .unwrap(); @@ -142,6 +143,16 @@ async fn unconfigured_adapter_delegates_to_ghost() { assert!(env.seal_value.starts_with("GHOST-SEAL-")); } +#[test] +fn this_backend_advertises_cades_only() { + let caps = QtspSealAdapter::new( + crate::eideasy::EideasyClient::new(test_config(SANDBOX_BASE_URL)).unwrap(), + ) + .capabilities(); + assert_eq!(caps.supported_formats, vec![SealFormat::Cades]); + assert_eq!(caps.supported_modes, vec![SealMode::ProviderSeal]); +} + #[tokio::test] async fn a_real_seal_round_trips_into_a_non_placeholder_envelope() { let state = Arc::new(MockState::default()); diff --git a/crates/dpp-seal/src/eideasy/types.rs b/crates/dpp-seal/src/eideasy/types.rs index 5b9ff3c..ed82bc9 100644 --- a/crates/dpp-seal/src/eideasy/types.rs +++ b/crates/dpp-seal/src/eideasy/types.rs @@ -15,7 +15,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; -use crate::error::SealError; +use super::error::EideasyError; /// The `mimeType` we declare. eID Easy built Direct e-Sealing for PDF, and both /// their docs and their support answer use this value; it is what we send. @@ -65,11 +65,11 @@ impl EsealFile { pub fn from_hex_digest( file_name: impl Into, hex_digest: &str, - ) -> Result { + ) -> Result { let raw = hex::decode(hex_digest) - .map_err(|e| SealError::Protocol(format!("payload hash is not hex: {e}")))?; + .map_err(|e| EideasyError::Protocol(format!("payload hash is not hex: {e}")))?; if raw.len() != 32 { - return Err(SealError::Protocol(format!( + return Err(EideasyError::Protocol(format!( "payload hash must be a 32-byte SHA-256 digest, got {} bytes", raw.len() ))); @@ -155,16 +155,16 @@ impl EsealResponse { /// A non-`OK` status, or a count other than one, means the response does not /// answer the request we made — a protocol fault, rather than something to /// reach into `signatures[0]` and hope about. - pub fn single_signature(&self) -> Result<&EsealSignatureOut, SealError> { + pub fn single_signature(&self) -> Result<&EsealSignatureOut, EideasyError> { if !self.is_ok() { - return Err(SealError::Protocol(format!( + return Err(EideasyError::Protocol(format!( "status was {:?}, expected \"OK\"", self.status ))); } match self.signatures.as_slice() { [one] => Ok(one), - other => Err(SealError::Protocol(format!( + other => Err(EideasyError::Protocol(format!( "expected exactly 1 signature for 1 digest, got {}", other.len() ))), diff --git a/crates/dpp-seal/src/error.rs b/crates/dpp-seal/src/error.rs index 64728a7..b450111 100644 --- a/crates/dpp-seal/src/error.rs +++ b/crates/dpp-seal/src/error.rs @@ -1,83 +1,39 @@ -//! Typed errors for the eID Easy backend. +//! The crate's error type — what is true of sealing regardless of backend. //! //! `SealPort` returns `DppError`, which is deliberately coarse — every adapter //! collapses into `DppError::Internal` at that boundary. This type exists on the -//! near side of it so a failure is classified *once*, where the HTTP status and -//! the response body are still in hand, rather than reconstructed from a string -//! by whoever reads the log. +//! near side of it so a failure is classified *once*, where it happened, rather +//! than reconstructed from a string by whoever reads the log. +//! +//! A backend's own failure modes are its own business and live in its module; +//! they arrive here already classified and already rendered, as +//! [`SealError::Backend`]. What stays here is only what any backend can hit: +//! configuration, transport, and an operation nobody implements. use dpp_domain::domain::error::DppError; -/// Why a 401 most likely happened, when the response gives us enough to say. -/// -/// Derived by comparing our `X-Timestamp` against the provider's own `Date` -/// header — the one external clock reference we get for free on every response. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthHint { - /// Nothing to add: the clock agreed, or the response carried no usable `Date`. - None, - /// Our clock is outside eID Easy's five-minute window, which alone explains - /// the rejection. Rotating the key here would be chasing the wrong fault. - ClockSkew { ours: u64, theirs: u64 }, -} - -impl std::fmt::Display for AuthHint { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => Ok(()), - Self::ClockSkew { ours, theirs } => write!( - f, - " — but this node's clock reads {ours} and the provider's reads \ - {theirs}, a drift of {}s beyond the 5-minute window. Fix the clock \ - (NTP) before rotating the HMAC key; a skewed timestamp fails \ - identically to a bad key", - ours.abs_diff(*theirs) - ), - } - } -} - #[derive(Debug, thiserror::Error)] pub enum SealError { - /// Configuration is absent, partial, or names a host that is not eID Easy. - #[error("eID Easy configuration: {0}")] + /// Configuration is absent, partial, or names something unusable. + #[error("seal configuration: {0}")] Config(String), - /// 401/403 — the HMAC key is wrong or rotated, e-seal credentials were never - /// enabled for this `client_id`, **or the node's clock has drifted**. - /// - /// The last one is why `hint` exists. `X-Timestamp` is inside the signed - /// message and eID Easy allows five minutes of skew, so a drifted clock - /// produces a 401 that is byte-for-byte the same as a bad key. Without a hint, - /// the operator rotates a perfectly good credential and the failure persists. - #[error("eID Easy rejected our credentials (HTTP {status}){hint}")] - Auth { status: u16, hint: AuthHint }, - - /// 429 — too many requests. - /// - /// Typed separately from [`Self::Provider`] because it is the one non-success - /// status that is neither our fault nor a provider fault, and the operator - /// response is different: not "check the credentials", but "the volume is - /// above what this contract allows". eID Easy publishes no rate limits, so - /// the drain's existing exponential backoff (to one hour) is the response; - /// this exists so the cause is nameable in the logs rather than buried in a - /// generic provider error. - #[error("eID Easy rate-limited us (HTTP 429){}", retry_after_secs.map(|s| format!("; Retry-After: {s}s")).unwrap_or_default())] - RateLimited { retry_after_secs: Option }, - /// The request never completed: DNS, connection, TLS, or timeout. - #[error("eID Easy transport: {0}")] + /// + /// Not a backend fault and not ours — nothing reached the far side, so + /// there is no status and no body to classify against. + #[error("seal transport: {0}")] Transport(String), - /// The call completed but the answer was not one we can act on — a non-`OK` - /// status field, an unparseable body, or a signature count that does not - /// match the digests we sent. - #[error("eID Easy protocol: {0}")] - Protocol(String), - - /// Any other non-success HTTP status. - #[error("eID Easy returned HTTP {status}")] - Provider { status: u16 }, + /// A backend failed in a way only that backend can describe. + /// + /// Carried as its rendered message rather than as a type: nothing outside + /// this crate matches on the cause, and lifting every backend's variants + /// into a shared enum would put each provider's vocabulary — status codes, + /// skew windows, rate limits — in front of code that must stay ignorant of + /// which backend is wired. + #[error("{0}")] + Backend(String), /// An operation this adapter does not implement, stated rather than faked. #[error("{0}")] @@ -89,15 +45,3 @@ impl From for DppError { DppError::Internal(e.to_string()) } } - -impl From for SealError { - fn from(e: reqwest::Error) -> Self { - // A body that fails to decode is a protocol fault, not a transport one: - // the exchange completed, the bytes are just not what was promised. - if e.is_decode() { - SealError::Protocol(e.to_string()) - } else { - SealError::Transport(e.to_string()) - } - } -} diff --git a/crates/dpp-seal/src/ghost.rs b/crates/dpp-seal/src/ghost.rs new file mode 100644 index 0000000..a89d428 --- /dev/null +++ b/crates/dpp-seal/src/ghost.rs @@ -0,0 +1,47 @@ +//! The placeholder backend: synthetic envelopes, no legal validity. +//! +//! `GhostSeal` is the port's own placeholder, so there is nothing to implement +//! here beyond making it a [`SealBackend`] like any other. That it satisfies the +//! same trait as a live provider is the point — a node with no provider takes +//! the identical path, and the difference shows up as `placeholder: true` on the +//! envelope and a `Ghost` trust tier at boot, which a production profile refuses +//! to start on. + +use async_trait::async_trait; +use dpp_domain::ports::seal::{ + SealCapabilities, SealPort, SealRequest, SealVerification, SealedEnvelope, +}; +use tracing::warn; + +pub use dpp_domain::ports::seal::GhostSeal; + +use crate::backend::SealBackend; +use crate::error::SealError; + +#[async_trait] +impl SealBackend for GhostSeal { + async fn seal(&self, req: SealRequest) -> Result { + warn!( + "no seal provider configured — sealing with GhostSeal (placeholder, no legal validity)" + ); + SealPort::seal(self, req).await.map_err(backend_err) + } + + fn capabilities(&self) -> SealCapabilities { + // The placeholder genuinely produces (synthetic) seals — report what + // `GhostSeal` actually does rather than claiming nothing. + SealPort::capabilities(self) + } + + /// Overrides the refusing default: `GhostSeal` verifies, and its answer is + /// honest — `valid: false`, `placeholder: true`. Refusing here would hide + /// the one thing a caller most needs to learn from a ghost. + async fn verify(&self, env: &SealedEnvelope) -> Result { + SealPort::verify(self, env).await.map_err(backend_err) + } +} + +/// `GhostSeal` speaks the port's coarse error; nothing here can re-classify it. +fn backend_err(e: dpp_domain::domain::error::DppError) -> SealError { + SealError::Backend(e.to_string()) +} diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index 81dbda0..cd15be5 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -3,9 +3,10 @@ //! # The sealing model //! //! A qualified electronic seal is produced by a Qualified Trust Service -//! Provider (QTSP) — this node never holds the seal's private key and never -//! assembles an AdES signature in-process (no Rust AdES library exists; the -//! provider's response *is* the seal). Until a provider is configured, +//! Provider (QTSP) — for that seal this node never holds the private key and +//! never assembles the signature in-process; the provider's response *is* the +//! seal. The local backend does assemble a CMS structure in-process, which is +//! precisely why it is not qualified. Until a provider is configured, //! [`adapter::QtspSealAdapter`] delegates to `GhostSeal` — a placeholder with //! no legal validity, which is why a production node's trust report refuses //! to boot while the seal port resolves to a ghost. @@ -21,22 +22,29 @@ //! //! # Structure //! -//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl +//! - [`backend`] — `SealBackend`, the seam every backend implements +//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl over one of them //! - [`config`] — which backend this node runs, and nothing about any of them -//! - [`eideasy`] — a hosted QTSP backend: its config, wire types and client +//! - [`eideasy`] — a hosted QTSP backend: its config, wire types, client and errors //! - [`local`] — in-process signing for development -//! - [`error`] — `SealError`, classified once at the HTTP boundary +//! - [`ghost`] — the placeholder, as a backend like any other +//! - [`error`] — `SealError`, what is true of sealing regardless of backend //! //! Each backend owns its own module: its configuration, its variables, its -//! failure messages and its wire types. Nothing outside a backend's module -//! names it, so one can be added or dropped without touching the others. +//! failure messages and its wire types, and it constructs itself. Nothing +//! outside a backend's module names it — the adapter holds a `dyn SealBackend` +//! and the selector maps one environment value to one module — so a backend can +//! be added or dropped without touching the others. pub mod adapter; +pub mod backend; pub mod config; pub mod eideasy; pub mod error; +pub mod ghost; pub mod local; pub use adapter::QtspSealAdapter; +pub use backend::SealBackend; pub use config::{SEAL_PROVIDER, SealProvider}; pub use error::SealError; diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index aed27ad..5d91277 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -21,6 +21,8 @@ use std::path::Path; +use async_trait::async_trait; +use base64::Engine as _; use chrono::Utc; use cms::cert::{CertificateChoices, IssuerAndSerialNumber}; use cms::content_info::ContentInfo; @@ -30,9 +32,13 @@ use cms::signed_data::{ }; use const_oid::db::rfc5911::ID_DATA; use der::{Any, Decode as _, Encode}; +use dpp_domain::ports::seal::{ + SealCapabilities, SealFormat, SealMode, SealRequest, SealedEnvelope, +}; use p256::ecdsa::{DerSignature, SigningKey}; use x509_cert::Certificate; +use crate::backend::SealBackend; use crate::error::SealError; /// A locally generated signing identity: one key, one self-signed certificate. @@ -177,6 +183,35 @@ impl LocalIdentity { } } +#[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)?; + + Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value: base64::engine::general_purpose::STANDARD.encode(&der), + signing_cert_ref: Some(self.cert_thumbprint()), + sealed_at: Utc::now(), + // Not a placeholder: these bytes verify. Legal standing is the trust + // tier's business, not the envelope's. + placeholder: false, + }) + } + + fn capabilities(&self) -> SealCapabilities { + // Real detached CAdES bytes. `OperatorSeal` because the certificate is + // generated per node rather than held centrally on operators' behalf — + // this backend rehearses the shape the hosted arrangement takes. + SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::OperatorSeal], + } + } +} + /// Generate a P-256 key and a self-signed certificate for it. fn generate() -> Result<(Vec, Vec), SealError> { let mut params = rcgen::CertificateParams::new(vec!["odal-local-seal".to_owned()]) @@ -286,6 +321,59 @@ mod tests { assert_ne!(a, b, "the seal must depend on what it covers"); } + fn seal_request(payload_hash: &str) -> SealRequest { + SealRequest { + payload_hash: payload_hash.to_owned(), + mode: SealMode::OperatorSeal, + key_ref: dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: "local".into(), + credential_id: "dev".into(), + }, + sig_format: SealFormat::Cades, + } + } + + /// The envelope this backend hands the port carries the real signature and + /// says so. + /// + /// `placeholder: false` is the load-bearing field: the drain, the trust + /// report and the passport all read it, and marking these bytes as a + /// placeholder would hide a seal that genuinely verifies — while marking a + /// ghost's bytes as real would do far worse. + #[tokio::test] + async fn the_envelope_carries_the_signature_and_the_certificate() { + use base64::engine::general_purpose::STANDARD as BASE64; + + let (id, _dir) = identity(); + let digest = [0x33u8; 32]; + let env = SealBackend::seal(&id, seal_request(&hex::encode(digest))) + .await + .expect("the local backend seals"); + + assert_eq!(env.format, SealFormat::Cades); + assert!(!env.placeholder, "these bytes verify — they are not a stub"); + assert_eq!( + env.signing_cert_ref.as_deref(), + Some(id.cert_thumbprint().as_str()), + "the envelope must name the certificate that signed it" + ); + + // The seal value is the base64 of the same CMS the direct path produces. + let der = BASE64.decode(&env.seal_value).expect("base64 seal value"); + ContentInfo::from_der(&der).expect("a CMS ContentInfo"); + assert_eq!(der, id.sign_detached(&digest).expect("sign")); + } + + /// A digest that is not hex fails before any signing happens. + #[tokio::test] + async fn a_payload_hash_that_is_not_hex_is_refused() { + let (id, _dir) = identity(); + let err = SealBackend::seal(&id, seal_request("not-a-digest")) + .await + .expect_err("a malformed digest must not be signed over"); + assert!(err.to_string().contains("not hex"), "{err}"); + } + /// The identity survives a restart. /// /// A seal produced before a restart must still verify against the same From 0cffa14c07ac7fe07692183c5a3f4487fdb0dcf8 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 01:51:27 +0200 Subject: [PATCH 09/20] chore(seal): correct the description and rsa note --- crates/dpp-seal/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/dpp-seal/Cargo.toml b/crates/dpp-seal/Cargo.toml index f3d10b7..8c2724a 100644 --- a/crates/dpp-seal/Cargo.toml +++ b/crates/dpp-seal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dpp-seal" -description = "eIDAS qualified seal adapter — eID Easy Cloud Direct e-Sealing backend for Odal Node" +description = "eIDAS qualified seal adapter for Odal Node — a hosted QTSP, a local dev sealer, or a ghost" version.workspace = true edition.workspace = true authors.workspace = true @@ -32,8 +32,8 @@ zeroize = { workspace = true } # signed by a self-signed key. Not qualified — the certificate is on no Trusted # List — but structurally the shape a provider returns, so the whole pipeline is # exercised without a provider account. -# keeps out of the tree: the builder feature -# pulls it in, we sign with P-256, and 0.9 carries RUSTSEC-2023-0071 (Marvin +# `default-features = false` keeps `rsa` out of the tree: the `builder` feature +# pulls it in, we sign with P-256, and `rsa` 0.9 carries RUSTSEC-2023-0071 (Marvin # timing sidechannel) with no fixed upgrade available. cms = { version = "0.2", default-features = false } x509-cert = "0.2" From 1b53f4218e482f21a243a1ac8f6898be055cd1ec Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 01:59:35 +0200 Subject: [PATCH 10/20] docs(vault): drop the stale no-Rust-validator claim --- crates/dpp-vault/src/handlers/seal.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index 582ee6d..11a4b1d 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -7,10 +7,11 @@ //! signature it actually attests to, and with the digest a verifier needs to //! check it against. //! -//! What this route does **not** do is validate the CAdES. No Rust AdES validator -//! exists, and a seal is worth exactly as much as the independence of whoever -//! checked it — so the response carries everything an external validator needs -//! and states plainly what has and has not been verified. +//! What this route does **not** do is validate the CAdES. A seal is worth +//! exactly as much as the independence of whoever checked it, so a verdict from +//! the node that bought the seal would attest nothing — the response instead +//! carries everything an external validator needs and states plainly what has +//! and has not been verified. use axum::{ Json, From e9a1d2a9f8b41d2b911e49fa9e398aa4ce3bc407 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 02:44:43 +0200 Subject: [PATCH 11/20] feat(vault): report whether a seal covers the current JWS --- api/openapi.yaml | 46 +++++++-- crates/dpp-dal/src/pg/repo_seal.rs | 20 ++++ crates/dpp-dal/tests/pg_seal_outbox.rs | 116 ++++++++++++++++++++++ crates/dpp-node/src/infra/seal_drain.rs | 4 + crates/dpp-types/src/seal.rs | 23 +++++ crates/dpp-vault/src/handlers/seal.rs | 122 +++++++++++++++++++++--- 6 files changed, 310 insertions(+), 21 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 2b887d5..d63d7f9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2019,12 +2019,18 @@ paths: verifies against nothing they received. **This node does not validate the seal.** A detached CAdES must be - checked by an independent AdES validator against the EU Trusted List, and - that check is also what establishes whether the seal covers - `currentPayloadHash` — the validator extracts the signed message digest - and compares. A mismatch means the passport was re-published after - sealing and a seal over the new signature has not landed yet; the seal - remains valid for the signature it does cover. + checked by an independent AdES validator against the EU Trusted List. A + verdict from the node that bought the seal would attest nothing, so none + is offered. + + `coverage` answers a narrower question that the node *can* answer, from + its own records: `sealedPayloadHash` is the digest it asked the backend + to seal, so a passport re-published after sealing shows as `superseded` + without any AdES tooling. That is a record of what was requested, not + proof of what the CAdES covers — the validator's extracted message + digest is the cross-check. A `superseded` seal remains valid for the + signature it does cover; a seal over the new signature has not landed + yet. `404` when the passport has no seal — it may be unpublished, its seal may still be queued, or the node may have no QTSP configured. An unsealed @@ -2054,6 +2060,8 @@ paths: placeholder, currentJws, currentPayloadHash, + sealedPayloadHash, + coverage, verification, ] properties: @@ -2078,11 +2086,29 @@ paths: currentPayloadHash: type: string description: | - Hex SHA-256 of `currentJws`. Labelled "current" rather than - "sealed" deliberately: the seal envelope does not record its - own preimage, so this is one half of the comparison and the - external validator supplies the other. + Hex SHA-256 of `currentJws` — the digest a seal over this + passport's present signature would be taken over. pattern: "^[0-9a-f]{64}$" + sealedPayloadHash: + type: string + nullable: true + description: | + Hex SHA-256 this node asked the backend to seal, from the + outbox row that bought `sealValue`. `null` when the node + holds no such row — a seal restored from a backup or + produced elsewhere. + pattern: "^[0-9a-f]{64}$" + coverage: + type: string + enum: [current, superseded, unknown] + description: | + Whether the stored seal covers the passport's current + signature, per this node's own records. + + `current` — the requested digest is the passport's current + one. `superseded` — the passport was re-published after + this seal was bought. `unknown` — no record; only the + external validator can answer. verification: type: string description: What was and was not checked by this node. diff --git a/crates/dpp-dal/src/pg/repo_seal.rs b/crates/dpp-dal/src/pg/repo_seal.rs index ba21252..4878a69 100644 --- a/crates/dpp-dal/src/pg/repo_seal.rs +++ b/crates/dpp-dal/src/pg/repo_seal.rs @@ -201,6 +201,26 @@ impl SealOutbox for PgSealOutboxRepo { Ok(()) } + async fn sealed_digest(&self, passport_id: PassportId) -> Result, DppError> { + // A re-published passport accumulates one `sealed` row per signature it + // has carried, so "which seal is on the passport" is the newest of them. + // `id` breaks a `sealed_at` tie: it is UUID v7, so ties order by + // insertion, and `sealed_at` is `now()` — transaction start — which two + // rows closed in the same transaction would share. + let row = sqlx::query( + r#"SELECT payload_hash FROM odal.seal_outbox + WHERE passport_id = $1 AND status = 'sealed' + ORDER BY sealed_at DESC, id DESC + LIMIT 1"#, + ) + .bind(passport_id.0) + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + + Ok(row.map(|r| r.get::("payload_hash"))) + } + async fn mark_attempt_failed(&self, id: Uuid, message: String) -> Result<(), DppError> { // Exponential backoff on the *new* attempt count, capped at 1h, with // 0.75–1.25× jitter — identical to the registry-sync, webhook and diff --git a/crates/dpp-dal/tests/pg_seal_outbox.rs b/crates/dpp-dal/tests/pg_seal_outbox.rs index 4b24b12..7e477fd 100644 --- a/crates/dpp-dal/tests/pg_seal_outbox.rs +++ b/crates/dpp-dal/tests/pg_seal_outbox.rs @@ -15,6 +15,11 @@ //! drained row is a paid QTSP call, so this key is a billing control. //! 4. **`mark_sealed` is atomic** and lands the envelope on the right passport. //! 5. **The `payload_hash` CHECK** refuses anything that is not a SHA-256 digest. +//! 6. **The sealed digest survives the row being closed**, and follows the newest +//! seal across a re-publish. `SealedEnvelope` records no preimage, so this row +//! is the only thing that lets the node tell a current seal from a stale one +//! without an external validator — and it is only worth anything if it keeps +//! pointing at the seal actually on the passport. #![cfg(feature = "integration-tests")] @@ -189,6 +194,117 @@ async fn a_seal_lands_on_a_retention_locked_published_passport() { assert_eq!(counts.pending, 0); } +/// The preimage survives the drain, so a stale seal is detectable without a +/// validator. +/// +/// This is the whole point of reading it back out of the outbox: `SealedEnvelope` +/// records no preimage, but the row that bought the seal does and is never +/// deleted. Without this the node cannot distinguish a current seal from one +/// taken over a signature the passport no longer carries. +#[tokio::test] +async fn the_sealed_digest_is_recoverable_after_the_row_is_closed() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let jws = "header.payload.signature"; + let passport = published_passport(jws); + let id = passport.id; + repo.create(passport).await.expect("insert passport"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + None, + "nothing is sealed yet, so there is nothing to report" + ); + + let hash = digest_of(jws); + outbox.enqueue(id, &hash).await.expect("enqueue"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("BASE64-CADES-P7S")) + .await + .expect("seal"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + Some(hash), + "the digest that was sealed must outlive the row being closed" + ); +} + +/// After a re-publish, the answer is the seal that is actually on the passport. +/// +/// A re-published passport accumulates one `sealed` row per signature it has +/// carried. Returning the older one would report a current seal as stale and a +/// stale one as current — worse than returning nothing. +#[tokio::test] +async fn the_sealed_digest_follows_the_newest_seal() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let passport = published_passport("a.b.first"); + let id = passport.id; + repo.create(passport).await.expect("insert passport"); + + let first = digest_of("a.b.first"); + outbox.enqueue(id, &first).await.expect("first publish"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL-OVER-FIRST")) + .await + .expect("seal the first signature"); + + let second = digest_of("a.b.second"); + outbox.enqueue(id, &second).await.expect("re-publish"); + let due = outbox.due(10).await.expect("due"); + assert_eq!(due.len(), 1, "only the new digest is due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL-OVER-SECOND")) + .await + .expect("seal the new signature"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + Some(second), + "the reported digest must match the envelope now on the passport" + ); +} + +/// One passport's seal is never reported for another. +#[tokio::test] +async fn the_sealed_digest_does_not_leak_across_passports() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let sealed = published_passport("a.b.sealed"); + let sealed_id = sealed.id; + repo.create(sealed).await.expect("insert sealed"); + let unsealed = published_passport("a.b.unsealed"); + let unsealed_id = unsealed.id; + repo.create(unsealed).await.expect("insert unsealed"); + + let hash = digest_of("a.b.sealed"); + outbox.enqueue(sealed_id, &hash).await.expect("enqueue"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL")) + .await + .expect("seal"); + + assert_eq!( + outbox.sealed_digest(sealed_id).await.expect("lookup"), + Some(hash) + ); + assert_eq!( + outbox.sealed_digest(unsealed_id).await.expect("lookup"), + None, + "an unsealed passport must not inherit a neighbour's digest" + ); +} + /// Widening `mutable_keys` must not have widened it for anything else. #[tokio::test] async fn the_retention_guard_still_refuses_content_changes() { diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index 2f5d831..c882db5 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -179,6 +179,10 @@ mod tests { self.sealed.lock().unwrap().push(id); Ok(()) } + async fn sealed_digest(&self, _p: PassportId) -> Result, DppError> { + // Read back by the seal route, never by the drain. + Ok(None) + } async fn mark_attempt_failed( &self, _id: uuid::Uuid, diff --git a/crates/dpp-types/src/seal.rs b/crates/dpp-types/src/seal.rs index 08546ef..2837edd 100644 --- a/crates/dpp-types/src/seal.rs +++ b/crates/dpp-types/src/seal.rs @@ -130,6 +130,29 @@ pub trait SealOutbox: Send + Sync { /// next drain pass would buy it again. async fn mark_sealed(&self, id: uuid::Uuid, envelope: &SealedEnvelope) -> Result<(), DppError>; + /// The digest the passport's stored seal was requested over. + /// + /// [`SealedEnvelope`] carries no preimage, so without this a node cannot tell + /// a current seal from one superseded by a later re-publish — it can only + /// hand both values to an external validator and let that validator extract + /// the signed message digest. But the row that bought the seal *does* carry + /// the preimage, and rows are never deleted, so the answer is already held + /// here and was only ever a query away. + /// + /// The latest `sealed` row is the one whose envelope is on the passport: + /// [`Self::mark_sealed`] writes the envelope and closes the row in the same + /// transaction, so the two cannot disagree about which seal is current. + /// + /// This is the node's own record, not proof — it says what was *asked* for, + /// not what the CAdES actually covers. Only an independent validator + /// establishes the latter, and it is the cross-check for this value rather + /// than a substitute for it. + /// + /// `None` when this node holds no sealed row: a seal restored from a backup + /// or produced elsewhere is one whose preimage this node cannot vouch for, + /// and saying so beats guessing. + async fn sealed_digest(&self, passport_id: PassportId) -> Result, DppError>; + /// Transient failure: increment `attempts`, back `next_attempt_at` off /// exponentially, keep the row `pending`. async fn mark_attempt_failed(&self, id: uuid::Uuid, message: String) -> Result<(), DppError>; diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index 11a4b1d..d126337 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -12,6 +12,14 @@ //! the node that bought the seal would attest nothing — the response instead //! carries everything an external validator needs and states plainly what has //! and has not been verified. +//! +//! It does answer one narrower question, because it can: **is this seal stale?** +//! The envelope carries no preimage, but the outbox row that bought it does, and +//! those rows are never deleted — so a passport re-published after sealing is +//! detectable here with a lookup and a string comparison, no AdES tooling +//! involved. That is a record of what was *requested*, not proof of what the +//! CAdES covers; the validator's extracted digest is the cross-check, and +//! `coverage` never pretends to be the verdict. use axum::{ Json, @@ -41,24 +49,63 @@ pub struct SealResponse { /// The passport's **current** compact JWS. pub current_jws: String, /// Hex SHA-256 of `currentJws` — the digest a seal over this passport's - /// present signature would have been taken over. - /// - /// Deliberately labelled "current" rather than "sealed": `SealedEnvelope` - /// does not record its own preimage, so this node cannot assert that the - /// seal above was taken over *this* digest. It is one half of the comparison, - /// and the external validator supplies the other half by extracting the - /// signed message digest from the CAdES itself. + /// present signature would be taken over. pub current_payload_hash: String, + + /// Hex SHA-256 this node **asked** the backend to seal, from the outbox row + /// that bought `sealValue`. + /// + /// `null` when this node holds no such row — a seal restored from a backup + /// or produced elsewhere. This is a record, not proof: it says what was + /// requested, and the validator's extracted message digest is what says what + /// 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. + pub coverage: Coverage, /// 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. That check also establishes whether this seal covers \ - currentPayloadHash: compare the validator's extracted message digest against it. A mismatch \ - means the passport was re-published after sealing and a seal over the new signature has not \ - landed yet — the seal remains valid for the signature it does cover."; + 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."; + +/// Whether the stored seal covers the passport's current signature. +/// +/// Answered from `sealedPayloadHash`, which is this node's record of what it +/// asked for. That is weaker than a validator's verdict and stronger than +/// nothing: it cannot confirm the CAdES, but a passport re-published after +/// sealing is knowable here without any AdES tooling at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Coverage { + /// The requested digest is the passport's current one. + Current, + /// The passport was re-published after this seal was bought. The seal stays + /// valid for the signature it does cover; a seal over the new signature has + /// not landed yet. + Superseded, + /// No record of what was sealed — restored from a backup, produced by + /// another node, or sealed before this node kept the row. Only the external + /// validator can answer. + Unknown, +} + +/// The coverage rule, as a pure function over the two digests. +/// +/// Split out from the handler so it is testable without a database: the whole +/// rule is which of three answers a pair of digests warrants, and that should not +/// need Postgres and an `AppState` to exercise. +fn coverage_of(sealed: Option<&str>, current: &str) -> Coverage { + match sealed { + Some(sealed) if sealed == current => Coverage::Current, + Some(_) => Coverage::Superseded, + None => Coverage::Unknown, + } +} /// `GET /api/v1/dpp/{dppId}/seal` — return the qualified seal and its preimage. /// @@ -96,6 +143,18 @@ pub async fn seal_handler( }; let payload_hash = seal_digest(&passport).unwrap_or_default(); + // A node with no outbox wired (no seal provider selected) can still be + // serving seals it bought earlier, so an absent outbox is `Unknown` rather + // than an error — the same answer as a row this node never had. + let sealed_payload_hash = match &state.service.seal_outbox { + Some(outbox) => match outbox.sealed_digest(passport_id).await { + Ok(h) => h, + Err(e) => return internal_error(e), + }, + None => None, + }; + let coverage = coverage_of(sealed_payload_hash.as_deref(), &payload_hash); + ( StatusCode::OK, Json(SealResponse { @@ -108,8 +167,49 @@ pub async fn seal_handler( placeholder: seal.placeholder, current_jws: jws, current_payload_hash: payload_hash, + sealed_payload_hash, + coverage, verification: NOT_VALIDATED, }), ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aa"; + const B: &str = "bb"; + + #[test] + fn a_matching_digest_is_current() { + assert_eq!(coverage_of(Some(A), A), Coverage::Current); + } + + /// The case the whole lookup exists for: the passport was re-published, so + /// the stored seal covers a signature it no longer carries. + #[test] + fn a_differing_digest_is_superseded() { + assert_eq!(coverage_of(Some(A), B), Coverage::Superseded); + } + + /// No record is not the same as no coverage. + /// + /// A seal restored from a backup is very likely current; this node simply + /// cannot say so, and reporting `superseded` would brand a sound passport as + /// stale on the strength of a missing row. + #[test] + fn no_record_is_unknown_rather_than_superseded() { + assert_eq!(coverage_of(None, A), Coverage::Unknown); + } + + /// The wire values are part of the published contract. + #[test] + fn coverage_serialises_to_the_documented_strings() { + let rendered = |c: Coverage| serde_json::to_string(&c).expect("serialise"); + assert_eq!(rendered(Coverage::Current), "\"current\""); + assert_eq!(rendered(Coverage::Superseded), "\"superseded\""); + assert_eq!(rendered(Coverage::Unknown), "\"unknown\""); + } +} From 63e708a214e2feac8817f3ac6623e0ca65ac9c39 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 02:44:44 +0200 Subject: [PATCH 12/20] feat(seal): verify locally produced seals --- crates/dpp-seal/README.md | 11 + crates/dpp-seal/src/local/sealer.rs | 342 +++++++++++++++++++++++++--- 2 files changed, 325 insertions(+), 28 deletions(-) diff --git a/crates/dpp-seal/README.md b/crates/dpp-seal/README.md index ea0dd06..a3f7419 100644 --- a/crates/dpp-seal/README.md +++ b/crates/dpp-seal/README.md @@ -38,6 +38,17 @@ a seal this node bought attests nothing a relying party should accept — valida those elsewhere. Rust AdES libraries exist and are improving; none of that changes the answer. +**The local backend does verify**, and overrides the default to say so, because +neither objection applies to it: its seals make no trust claim beyond "this key +signed this digest", so a cryptographic check is the whole truth about them and +there is no authority whose independence could be borrowed. It carries the sealed +digest in CMS `signedAttrs` — as CAdES requires — which is what makes the +envelope self-checking; a verifier holding only the bytes can confirm the +signature over those attributes against the certificate travelling inside. That +establishes internal consistency and nothing about trust: the certificate is +self-signed and on no EU Trusted List, which the node states structurally by +resolving this backend to the `Ghost` trust tier. + ## The one rule The HMAC covers `METHOD + PATH + X-Timestamp + RAW_REQUEST_BODY`, and eID Easy diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index 5d91277..1dab56d 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -4,7 +4,10 @@ //! //! It **is** a genuine CMS signature: the bytes verify against the certificate, //! the structure is what a provider returns, and every stage of the pipeline -//! that handles a seal handles this one identically. +//! that handles a seal handles this one identically. The digest travels in +//! `signedAttrs`, as CAdES requires, so the envelope is self-checking — a holder +//! of the bytes alone can confirm the signature, which is what lets this backend +//! answer `verify()` when the hosted one cannot. //! //! It is **not** qualified, and cannot become so. The certificate is //! self-signed and on no EU Trusted List, which is a property of the @@ -27,16 +30,17 @@ use chrono::Utc; use cms::cert::{CertificateChoices, IssuerAndSerialNumber}; use cms::content_info::ContentInfo; use cms::signed_data::{ - CertificateSet, DigestAlgorithmIdentifiers, EncapsulatedContentInfo, SignedData, - SignerIdentifier, SignerInfo, SignerInfos, + CertificateSet, DigestAlgorithmIdentifiers, EncapsulatedContentInfo, SignedAttributes, + SignedData, SignerIdentifier, SignerInfo, SignerInfos, }; use const_oid::db::rfc5911::ID_DATA; use der::{Any, Decode as _, Encode}; use dpp_domain::ports::seal::{ - SealCapabilities, SealFormat, SealMode, SealRequest, SealedEnvelope, + SealCapabilities, SealFormat, SealMode, SealRequest, SealVerification, SealedEnvelope, }; use p256::ecdsa::{DerSignature, SigningKey}; use x509_cert::Certificate; +use x509_cert::attr::Attribute; use crate::backend::SealBackend; use crate::error::SealError; @@ -109,8 +113,11 @@ impl LocalIdentity { // no use for RSA, so the dependency would be pure advisory surface on // the one crate that produces seals. // - // Detached: `eContent` is absent, so the structure commits to a digest - // it does not carry — the arrangement a provider returns. + // Detached: `eContent` is absent, so what was signed travels separately + // from the signature — the arrangement a provider returns. The *digest* + // does travel, in `signedAttrs` below; that is what detached CAdES does, + // and it is the difference between a seal that can be checked and one + // that cannot. let econtent = EncapsulatedContentInfo { econtent_type: ID_DATA, econtent: None, @@ -121,11 +128,23 @@ impl LocalIdentity { parameters: None, }; - // No signed attributes: with `signedAttrs` absent, the signature is over - // the content itself, which for a detached signature is the digest the - // caller hands us. One fewer place for the bound value to disagree with - // the value actually signed. - let signature: DerSignature = self.key.sign(digest); + // Signed attributes carry the digest *inside* the signature, which is + // what makes the seal checkable at all. With `signedAttrs` absent the + // signature covers the digest directly, and a verifier holding only the + // envelope — which is all `SealPort::verify` is given — has no way to + // 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)?; + + // RFC 5652 §5.4: the signature is computed over the DER **SET OF** + // encoding of the signed attributes, not over the `[0] IMPLICIT` form + // they take inside `SignerInfo`. Encoding the wrong one produces a + // signature that verifies nowhere, including here. + let to_sign = signed_attrs + .to_der() + .map_err(|e| SealError::Config(format!("cannot encode the signed attributes: {e}")))?; + let signature: DerSignature = self.key.sign(&to_sign); let signer_info = SignerInfo { version: cms::content_info::CmsVersion::V1, @@ -134,7 +153,7 @@ impl LocalIdentity { serial_number: self.cert.tbs_certificate.serial_number.clone(), }), digest_alg: digest_algorithm.clone(), - signed_attrs: None, + signed_attrs: Some(signed_attrs), signature_algorithm: x509_cert::spki::AlgorithmIdentifierOwned { oid: const_oid::db::rfc5912::ECDSA_WITH_SHA_256, parameters: None, @@ -210,6 +229,147 @@ impl SealBackend for LocalIdentity { supported_modes: vec![SealMode::OperatorSeal], } } + + /// Overrides the refusing default, because this backend can answer honestly. + /// + /// The default exists so that no backend claims a verdict it did not + /// compute, and the reason a hosted QTSP's seal cannot be answered here is + /// independence: a verdict from the node that bought the seal attests + /// nothing. Neither objection applies to this backend. Its seals make no + /// trust claim at all beyond "this key signed this digest", so a + /// cryptographic check *is* the whole truth about them, and there is no + /// authority whose independence could be borrowed or faked. + /// + /// So `valid: true` here means exactly what [`SealVerification::valid`] + /// documents — the seal cryptographically verifies — and nothing more. It + /// carries no legal weight, because the certificate is self-signed. The node + /// says that separately and structurally, by resolving this backend to the + /// `Ghost` trust tier so a production profile refuses to boot on it. + async fn verify(&self, env: &SealedEnvelope) -> Result { + use base64::engine::general_purpose::STANDARD as BASE64; + + let der = BASE64 + .decode(&env.seal_value) + .map_err(|e| SealError::Backend(format!("seal value is not base64: {e}")))?; + + Ok(SealVerification { + valid: verify_detached(&der)?, + placeholder: env.placeholder, + }) + } +} + +/// The attributes the signature covers: what was signed, and its digest. +/// +/// Two, both mandatory under RFC 5652 §11 for a signature carrying signed +/// attributes: `contentType`, and `messageDigest` holding the digest the caller +/// 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 { + use der::asn1::{OctetString, SetOfVec}; + + let attr = |oid, value: Any| -> Result { + let mut values = SetOfVec::new(); + values + .insert(value) + .map_err(|e| SealError::Config(format!("cannot build a signed attribute: {e}")))?; + Ok(Attribute { oid, values }) + }; + + let content_type = attr( + const_oid::db::rfc5911::ID_CONTENT_TYPE, + Any::encode_from(&ID_DATA) + .map_err(|e| SealError::Config(format!("cannot encode the content type: {e}")))?, + )?; + let message_digest = attr( + const_oid::db::rfc5911::ID_MESSAGE_DIGEST, + Any::encode_from( + &OctetString::new(digest) + .map_err(|e| SealError::Config(format!("cannot encode the digest: {e}")))?, + ) + .map_err(|e| SealError::Config(format!("cannot encode the digest attribute: {e}")))?, + )?; + + let mut attrs = SetOfVec::new(); + for a in [content_type, message_digest] { + attrs + .insert(a) + .map_err(|e| SealError::Config(format!("cannot collect signed attributes: {e}")))?; + } + Ok(attrs) +} + +/// Check a detached CMS `SignedData` against the certificate it carries. +/// +/// A free function, and deliberately so: it takes bytes and nothing else, +/// because a verifier only ever has bytes. It cannot reach the signing identity +/// and does not need to. +/// +/// What a `true` here means, exactly: the signature over the signed attributes +/// verifies under the public key in the certificate travelling inside the seal, +/// and that certificate is therefore self-consistent with the signature. It says +/// **nothing** about trust — the certificate is self-signed and on no EU Trusted +/// List, so there is no chain to build and no authority behind it. For this +/// backend that is the whole truth available, which is why reporting it is +/// honest here and would not be for a qualified seal. +fn verify_detached(seal_der: &[u8]) -> Result { + use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier as _; + + let backend = |m: String| SealError::Backend(m); + + let info = ContentInfo::from_der(seal_der) + .map_err(|e| backend(format!("not a CMS ContentInfo: {e}")))?; + let sd: SignedData = info + .content + .decode_as() + .map_err(|e| backend(format!("not CMS SignedData: {e}")))?; + + let signers = sd.signer_infos.0.as_slice(); + let [signer] = signers else { + return Err(backend(format!( + "expected exactly one signer, found {}", + signers.len() + ))); + }; + + // Absent signed attributes means the digest is not inside the seal, so + // nothing can be checked without the original payload — which this function + // is not given. That is a different answer from "invalid", and conflating + // the two would brand an older seal as broken. + let Some(signed_attrs) = signer.signed_attrs.as_ref() else { + return Err(backend( + "this seal carries no signed attributes, so the digest it covers is not inside it \ + and cannot be checked from the envelope alone" + .to_owned(), + )); + }; + + let certs = sd + .certificates + .as_ref() + .ok_or_else(|| backend("the seal carries no certificate to verify against".to_owned()))?; + let Some(CertificateChoices::Certificate(cert)) = certs.0.as_slice().first() else { + return Err(backend("the seal carries no X.509 certificate".to_owned())); + }; + + let spki = &cert.tbs_certificate.subject_public_key_info; + let key_bits = spki + .subject_public_key + .as_bytes() + .ok_or_else(|| backend("the certificate's public key is not whole bytes".to_owned()))?; + let vk = VerifyingKey::from_sec1_bytes(key_bits) + .map_err(|e| backend(format!("the certificate holds no P-256 key: {e}")))?; + + // Re-encode as SET OF, matching what was signed (RFC 5652 §5.4). + let signed = signed_attrs + .to_der() + .map_err(|e| backend(format!("cannot re-encode the signed attributes: {e}")))?; + let sig = DerSignature::from_bytes(signer.signature.as_bytes()) + .map_err(|e| backend(format!("not a DER ECDSA signature: {e}")))?; + + Ok(vk.verify(&signed, &sig).is_ok()) } /// Generate a P-256 key and a self-signed certificate for it. @@ -278,36 +438,109 @@ mod tests { /// being. #[test] fn the_signature_verifies_against_the_embedded_certificate() { - use p256::ecdsa::signature::Verifier as _; - use p256::ecdsa::{DerSignature, VerifyingKey}; + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x7u8; 32]).expect("sign"); + + // Checked through the same function production uses, which is handed + // bytes and nothing else — the identity in memory is not consulted. + assert!( + verify_detached(&der).expect("the seal is well-formed"), + "the seal must verify against the certificate it ships" + ); + } + + /// The seal commits to the digest it was asked to seal, not to some other. + /// + /// `verify_detached` proves the signature covers the signed attributes; this + /// proves the signed attributes carry the right value. Without it the + /// backend could sign a constant attribute set perfectly and attest nothing + /// about the passport — internally valid, externally meaningless. + #[test] + fn the_signed_attributes_carry_the_requested_digest() { + use der::asn1::OctetString; let (id, _dir) = identity(); - let digest = [0x7u8; 32]; + let digest = [0x5u8; 32]; let der = id.sign_detached(&digest).expect("sign"); let info = ContentInfo::from_der(&der).expect("ContentInfo"); let sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); let si = sd.signer_infos.0.as_slice().first().expect("one signer"); + let attrs = si.signed_attrs.as_ref().expect("signed attributes present"); + + let found = attrs + .as_slice() + .iter() + .find(|a| a.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST) + .expect("a messageDigest attribute"); + let carried: OctetString = found + .values + .as_slice() + .first() + .expect("one value") + .decode_as() + .expect("an OCTET STRING"); - // The verifying key comes out of the certificate inside the seal, not - // from the identity in memory — a verifier only ever has the bytes. - let spki = &id.cert.tbs_certificate.subject_public_key_info; - let vk = VerifyingKey::from_sec1_bytes( - spki.subject_public_key.as_bytes().expect("public key bits"), - ) - .expect("P-256 key from the certificate"); + assert_eq!( + carried.as_bytes(), + digest, + "the seal must commit to the digest it was handed" + ); + } + + /// A tampered seal does not verify. + /// + /// The check that makes the others mean something: if flipping a byte of the + /// signature still verified, `verify_detached` would be reporting a constant + /// rather than performing a check. + #[test] + fn a_tampered_signature_does_not_verify() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x9u8; 32]).expect("sign"); - let sig = DerSignature::from_bytes(si.signature.as_bytes()).expect("DER signature"); - vk.verify(&digest, &sig) - .expect("the seal must verify against the certificate it ships"); + // Flip a byte deep inside the structure. Some positions corrupt the DER + // and produce a parse error rather than `false`; both are refusals, and + // neither may be a pass. + let mut tampered = der.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0xff; - // And must not verify against something it did not cover. assert!( - vk.verify(&[0x8u8; 32], &sig).is_err(), - "a seal that verifies over any digest attests nothing" + !matches!(verify_detached(&tampered), Ok(true)), + "a tampered seal must never verify" ); } + /// A seal with no signed attributes is refused, not called invalid. + /// + /// The digest is not inside such a seal, so nothing can be checked from the + /// envelope alone. Reporting `valid: false` would brand it broken on the + /// strength of a check that never ran — the distinction this crate exists to + /// keep. + #[test] + fn a_seal_without_signed_attributes_is_refused() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x1u8; 32]).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("ContentInfo"); + let mut sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + signers[0].signed_attrs = None; + let mut rebuilt = der::asn1::SetOfVec::new(); + rebuilt.insert(signers.remove(0)).expect("one signer"); + sd.signer_infos = SignerInfos::from(rebuilt); + + let stripped = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: Any::encode_from(&sd).expect("re-encode"), + } + .to_der() + .expect("DER"); + + let err = verify_detached(&stripped).expect_err("must refuse rather than answer"); + assert!(err.to_string().contains("signed attributes"), "{err}"); + } + /// Two different digests produce two different seals. /// /// Guards the failure that would make every other test here vacuous: a @@ -364,6 +597,59 @@ mod tests { assert_eq!(der, id.sign_detached(&digest).expect("sign")); } + /// Seal, then verify, through the port — the round trip an operator gets. + /// + /// Every other test here reaches into the structure. This one only uses what + /// a caller has: a request in, an envelope out, and that envelope back in. + /// It is the whole point of a development backend — the pipeline is + /// exercised end to end without a provider account. + #[tokio::test] + async fn a_sealed_envelope_verifies_through_the_port() { + let (id, _dir) = identity(); + let digest = hex::encode([0x2Au8; 32]); + + let env = SealBackend::seal(&id, seal_request(&digest)) + .await + .expect("seal"); + let verdict = SealBackend::verify(&id, &env).await.expect("verify"); + + assert!( + verdict.valid, + "a seal this backend just produced must verify" + ); + assert!( + !verdict.placeholder, + "these bytes are real; only the trust behind them is absent" + ); + } + + /// A seal whose value was altered in storage fails the round trip. + /// + /// Without this, `a_sealed_envelope_verifies_through_the_port` would pass + /// against a `verify` that returned `true` unconditionally. + #[tokio::test] + async fn a_corrupted_envelope_does_not_verify_through_the_port() { + use base64::engine::general_purpose::STANDARD as BASE64; + + let (id, _dir) = identity(); + let mut env = SealBackend::seal(&id, seal_request(&hex::encode([0x2Au8; 32]))) + .await + .expect("seal"); + + let mut raw = BASE64.decode(&env.seal_value).expect("base64"); + let last = raw.len() - 1; + raw[last] ^= 0xff; + env.seal_value = BASE64.encode(&raw); + + assert!( + !matches!( + SealBackend::verify(&id, &env).await.map(|v| v.valid), + Ok(true) + ), + "a corrupted seal must never verify" + ); + } + /// A digest that is not hex fails before any signing happens. #[tokio::test] async fn a_payload_hash_that_is_not_hex_is_refused() { From 12be848fca66f1f2229ba44e8dd95f2c40704d95 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 03:03:07 +0200 Subject: [PATCH 13/20] docs(seal): record the backends and profile split --- CHANGELOG.md | 93 ++++++++++++++++++++++++---- README.md | 2 +- docs/architecture/DESIGN-PATTERNS.md | 2 +- docs/architecture/OVERVIEW.md | 6 +- docs/ops/PRODUCTION-RUNBOOK.md | 4 +- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d57aed4..6926d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,12 +140,13 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): `(passportId, payloadHash)` rather than by passport alone, because a re-publish re-signs and the new signature needs its own attestation. - **`verify()` is not implemented and says so**, returning a typed error. A - detached CAdES must be validated by an independent AdES validator against the - EU Trusted List — no Rust implementation exists — and this adapter will not - report a seal valid on a check it did not perform. The new route and the - dossier both state the same thing rather than letting their output read as a - verdict. + **`verify()` is not implemented for the hosted backend and says so**, + returning a typed error. The reason is independence, not tooling: a qualified + seal is worth exactly as much as the independence of whoever checked it, so a + verdict issued by the node that bought the seal attests nothing a relying party + should accept. Those seals are checked by an independent AdES validator against + the EU Trusted List. The route and the dossier both state this plainly rather + than letting their output read as a verdict. - **`GET /vault/api/v1/dpp/{dppId}/seal`** returns the qualified seal together with the JWS it covers and that JWS's digest. The seal needs its own route @@ -171,6 +172,44 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): seal at all, so it cannot double-bill. Exhausted rows are held back for six hours so sweep and drain do not hammer an outage together. +- **A local development sealing backend** (`SEAL_PROVIDER=local`). Signs + in-process with a generated P-256 key under a self-signed certificate, + producing a real detached CMS `SignedData` — the same shape a provider returns, + so the whole pipeline is exercised without a provider account, a contract, or a + sandbox credential. The key is persisted between runs, because a restart that + silently invalidated every seal it had produced would teach the wrong thing + about how seals behave. + + **It is not qualified and cannot become so.** The certificate is on no EU + Trusted List, which is a property of the certificate rather than of this code — + nothing in the signing or verification path differs between a self-signed key + and a QTSP-held one. What differs is the legal weight, which is none. The node + states that structurally by resolving this backend to the `Ghost` trust tier, + so a production profile refuses to boot on it while the envelope still drains. + + **This backend does verify its own seals**, and is the only one that does. Its + seals make no trust claim beyond "this key signed this digest", so a + cryptographic check is the whole truth about them and there is no authority + whose independence could be borrowed. It carries the digest in CMS + `signedAttrs` and signs over their DER `SET OF` encoding, as CAdES requires, + which is what makes the envelope self-checking: a holder of the bytes alone + confirms the signature against the certificate travelling inside. `valid: true` + from it means exactly that and nothing about trust. + +- **`sealedPayloadHash` and `coverage` on the seal route.** The seal envelope + records no preimage, so a node could not previously tell a current seal from + one superseded by a later re-publish — it could only hand both digests to an + external validator. But the outbox row that bought the seal does carry the + preimage and is never deleted, so the answer was already held and only ever a + query away. `coverage` reports `current`, `superseded`, or `unknown`. + + This is the node's own record of what it *asked* to seal, not proof of what the + CAdES covers; only an independent validator establishes the latter, and the two + agreeing is the cross-check. `unknown` is deliberately distinct from + `superseded`: a seal restored from a backup is very likely current, and + branding it stale on the strength of a missing row would be the same error in + the opposite direction. + - **A clock-skew hint on authentication failures.** `X-Timestamp` is inside the signed message and eID Easy allows five minutes of drift, so a wrong node clock produces a 401 byte-for-byte identical to a bad key. The adapter now compares @@ -179,15 +218,43 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): good credential and the failure persists. `429` is likewise typed separately from a generic provider error, with `Retry-After` surfaced when present. +### Breaking + +- **`NODE_PROFILE=production` now refuses a `Sandbox` trust tier, not only a + ghost.** A production node asserts that its passports are backed by real + authorities; admitting a sandbox tier made that untrue, and a provider's test + certificate could seal a passport claiming to be real. The tiers are ordered + `Ghost < Sandbox < Live` so a profile states a floor rather than enumerating + what it rejects — adding a tier later cannot silently pass an existing guard. + + **Migration.** A node running `NODE_PROFILE=production` against a provider's + test environment now fails to boot, naming the ports that resolved too low. + Either set the new **`NODE_PROFILE=sandbox`** — a full node in every respect + except that the authorities behind it are test ones, and still a hard boot + failure on ghosts — or point the backend at its production endpoint with + production credentials. Sandbox is deliberately a property of the *deployment* + rather than a tier a production node may quietly carry: running it as its own + environment is the closest rehearsal of production there is, and keeping the + two apart is what stops a test certificate ever sealing a real passport. + ### Changed -- **Seal configuration is provider-neutral**: `SEAL_PROVIDER=eideasy|none` plus - `SEAL_EIDEASY_*`, replacing the bare `EIDEASY_*` names. Env var names are a - published interface, so a second QTSP should be a new value rather than a - config migration for every self-hoster. An unrecognised provider fails the - boot, as does setting the credentials without selecting the provider — both - would otherwise downgrade a node configured for qualified sealing into one - with none. +- **Seal configuration is provider-neutral**: `SEAL_PROVIDER=eideasy|local|none` + plus `SEAL_EIDEASY_*` / `SEAL_LOCAL_*`, replacing the bare `EIDEASY_*` names. + Env var names are a published interface, so a second QTSP should be a new value + rather than a config migration for every self-hoster. An unrecognised provider + fails the boot, as does setting one backend's credentials without selecting it + — both would otherwise downgrade a node configured for qualified sealing into + one with none. + + Backends sit behind a `SealBackend` trait, each owning its own module, + variables, validation, wire types and failure classification. Nothing outside a + backend's module names it: the adapter holds one `dyn SealBackend` and the + selector maps one environment value to one module, so adding a provider is + additive and removing one leaves nothing behind. `SealProvider` is deliberately + not `#[non_exhaustive]` — adding a backend should break every wiring site + rather than fall into a `_` arm, which is how a node silently seals with + something other than what it was asked for. - **`seal` joins the retention guard's mutable keys** (`0028_seal_outbox.sql`) and `MUTABLE_FIELDS`. Without it the drain's write to an already-published, diff --git a/README.md b/README.md index 88d20a9..3159dac 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The engine ships as a **single binary** (`dpp-node`) that fuses all services und | `dpp-plugin-host` | lib | wasmtime sandbox — fuel metering, memory cap, deny-all WASI, signed-plugin policy | | `dpp-node` | bin | **The single binary — fuses all services**, boot trust-report, registry outbox drain, signed-ruleset loader | | `dpp-cli` (`cli/`) | bin | `odal` — the operator control plane, from bootstrap to evidence dossier generation and verification | -| `dpp-seal` | lib | eIDAS qualified-seal adapter (CSC/QTSP client scaffold) — resolves to a clearly-marked Ghost until a QTSP is configured; a production-profile node **refuses to boot** on ghost trust adapters | +| `dpp-seal` | lib | eIDAS qualified-seal adapter over one `SealBackend`, chosen by `SEAL_PROVIDER`: a hosted QTSP, a local development sealer (real CMS, no legal weight), or a clearly-marked Ghost when unset. Only the first carries legal weight — a production-profile node **refuses to boot** on the others | | `dpp-factor-data` | lib | Licensed LCI factor store — ghost provider until a dataset licence is signed; any ghost-derived result is marked `dataset_id="ghost"` | ### Dependencies on dpp-core diff --git a/docs/architecture/DESIGN-PATTERNS.md b/docs/architecture/DESIGN-PATTERNS.md index 5a0e79f..5ec1ff9 100644 --- a/docs/architecture/DESIGN-PATTERNS.md +++ b/docs/architecture/DESIGN-PATTERNS.md @@ -125,7 +125,7 @@ Infrastructure dependencies that are optional use a NoOp implementation: | Event bus | `NatsEventBus` | `NoOpEventBus` (discards events) | | Compliance | Wasm sector plugin | `PassthroughRegistry` (accepts all) | | EU Registry | `EuRegistrySync` (HTTP, when the Commission publishes) | `GhostRegistrySync` — but registration intent is **never lost**: it lives in the durable outbox regardless of adapter tier | -| Qualified seal | CSC/QTSP adapter (`dpp-seal`) | `GhostSeal` — clearly marked placeholder | +| Qualified seal | A `SealBackend` selected by `SEAL_PROVIDER` (`dpp-seal`): a hosted QTSP, or a local dev sealer that is real CMS and no legal weight | `GhostSeal` — clearly marked placeholder | **Why:** Self-hosted single-node deployments should work without NATS. The NoOp pattern means the code paths are identical — no `if nats_enabled { ... }` branches. The trait dispatch handles it. diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index 22cc8af..14c2e45 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -127,8 +127,10 @@ dpp-types <-- dpp-dal <-- dpp-vault <-- dpp-node dpp-common (event bus trait, telemetry) <-- dpp-vault, dpp-node dpp-plugin-host <-- dpp-node -dpp-seal (CSC/QTSP adapter scaffold — resolves to Ghost until a QTSP is - configured; a NODE_PROFILE=production node refuses to boot on it) +dpp-seal (eIDAS seal adapter over one SealBackend, chosen by SEAL_PROVIDER: + a hosted QTSP, a local development sealer, or Ghost when unset. + Only the first carries legal weight — a NODE_PROFILE=production + node refuses to boot on either of the others) dpp-factor-data (licensed-LCI store — ghost provider until a dataset licence is signed; ghost-derived results are marked dataset_id="ghost") ``` diff --git a/docs/ops/PRODUCTION-RUNBOOK.md b/docs/ops/PRODUCTION-RUNBOOK.md index 1744524..a0ce8ce 100644 --- a/docs/ops/PRODUCTION-RUNBOOK.md +++ b/docs/ops/PRODUCTION-RUNBOOK.md @@ -10,11 +10,13 @@ | Tier | Profile | What it honestly claims | Available | |---|---|---|---| | **T1 Pilot-grade** | default (`NODE_PROFILE` unset) | Full lifecycle, signed + verified passports, hash-chained audit, outbox-durable registry intent; **trust ports run Ghost and say so** in `/health.trust_mode` | **Now** | -| **T2 Sealed-grade** | `NODE_PROFILE=production` | Everything above + real qualified seals (QTSP via CSC) | After the seal adapter is wired | +| **T2 Sealed-grade** | `NODE_PROFILE=production` | Everything above + real qualified seals from a hosted QTSP | After a QTSP credential exists — the adapter is wired, the account is not | | **T3 Registry-grade** | `NODE_PROFILE=production` | + real EU registry registration | After the Commission publishes its registry spec | **Blocker A — deliberate:** `NODE_PROFILE=production` **refuses to boot** while seal/registry resolve to Ghost (the honesty invariant working as designed). So every deployment today is **T1 by definition**: run the default profile, point monitoring at `/health`, and make no sealed/registered claims. Do not weaken the guard to "get to production" — the guard *is* the product's credibility. +**`NODE_PROFILE=sandbox`** is the third profile, and it is a property of the *deployment*, not a tier a production node may quietly carry. It is a full node in every respect except that the authorities behind it are test ones: ghosts on required ports are a hard boot failure exactly as in production, but `Sandbox` tiers are accepted, so the environment can be exercised end to end without a production credential. Run it as its own environment — it is the closest rehearsal of production available, and keeping the two profiles apart is what stops a test certificate ever sealing a passport that claims to be real. A `production` node refuses a sandbox tier for that reason. + **Blocker B — operational:** engine `main` now pins **core 0.4.0, which is unpublished** (0.3.0 is the latest on crates.io). The compose `pull` and plain `--build` modes resolve crates.io and **will fail**. Until 0.4.0 is published: build with the local-core overlay (`--build` + `-f docker/docker-compose.local.yml`, i.e. `just up-local`) and record the image digest you deployed. **Before the first external operator deploy: publish core 0.4.0** — your own release rule (CI/release = crates.io) exists precisely so a deploy is reproducible from public sources. --- From c607813fc36cbbb05d3176778eba684bbbaaeca4 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 03:25:57 +0200 Subject: [PATCH 14/20] feat(seal): name the certificate a seal was made with --- CHANGELOG.md | 18 ++ api/openapi.yaml | 15 ++ .../dpp-render/dpp-render-preview-live.html | 84 +++++++ .../dpp-render-preview-snapshot.html | 84 +++++++ crates/dpp-seal/src/cades.rs | 211 ++++++++++++++++++ crates/dpp-seal/src/eideasy/client.rs | 21 +- crates/dpp-seal/src/eideasy/tests.rs | 59 +++++ crates/dpp-seal/src/lib.rs | 3 + crates/dpp-seal/src/local/sealer.rs | 89 +------- crates/dpp-vault/src/handlers/seal.rs | 12 + 10 files changed, 514 insertions(+), 82 deletions(-) create mode 100644 crates/dpp-render/dpp-render-preview-live.html create mode 100644 crates/dpp-render/dpp-render-preview-snapshot.html create mode 100644 crates/dpp-seal/src/cades.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6926d08..65654c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,24 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): confirms the signature against the certificate travelling inside. `valid: true` from it means exactly that and nothing about trust. +- **`signingCertRef` names the certificate a seal was made with.** Read out of + the returned CAdES and surfaced on the seal route, so the question "which + certificate signed this, and was it on the EU Trusted List at the time?" no + longer requires being handed the `.p7s` and parsing it by hand. + + **Reported by the seal, never verified.** It answers *which* certificate to ask + about and nothing else — no chain is built, no Trusted List consulted, no + revocation checked. A convenience field that read as verification while + verifying nothing would be worse than an absent one, because an absent field + prompts the question and a populated one settles it wrongly. + + A hex SHA-256 thumbprint rather than issuer+serial or a subject key identifier: + one fixed-length value naming exactly one certificate, comparable without + parsing, and the same thing the local backend already reports — a test asserts + the two agree, since a field that means different things per backend is not a + key anyone can match on. A seal the parser cannot read still stores, with the + reference left `null`. + - **`sealedPayloadHash` and `coverage` on the seal route.** The seal envelope records no preimage, so a node could not previously tell a current seal from one superseded by a later re-publish — it could only hand both digests to an diff --git a/api/openapi.yaml b/api/openapi.yaml index d63d7f9..43f4664 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2057,6 +2057,7 @@ paths: format, sealValue, sealedAt, + signingCertRef, placeholder, currentJws, currentPayloadHash, @@ -2075,6 +2076,20 @@ paths: sealedAt: type: string format: date-time + signingCertRef: + type: string + nullable: true + description: | + Hex SHA-256 of the certificate the seal names as its + signer, **as reported by the seal** — read out of the CAdES + structure, never verified. + + It answers *which* certificate to ask about, not whether + that certificate was qualified or on the EU Trusted List + when the seal was made; both are the independent + validator's question. `null` when the seal predates + extraction or could not be parsed. + pattern: "^[0-9a-f]{64}$" placeholder: type: boolean description: | diff --git a/crates/dpp-render/dpp-render-preview-live.html b/crates/dpp-render/dpp-render-preview-live.html new file mode 100644 index 0000000..8b30a2f --- /dev/null +++ b/crates/dpp-render/dpp-render-preview-live.html @@ -0,0 +1,84 @@ + + + + + + + + + + DPP — Organic Cotton T-Shirt + + + +
+ +

Organic Cotton T-Shirt

+ active + +

Product Information

+ + + + + +
Passport ID0190a9f0-1234-7abc-8def-0123456789ab
ManufacturerSample Textiles Co.
GTIN09506000134352
Batch ID-
+ +

Textile Information

+ + + + + + + + + +
Country of ManufacturingGermany
Care InstructionsMachine wash cold, tumble dry low
Chemical ComplianceOEKO-TEX Standard 100
Recycled Content32.5%
Fibre Composition + +
Organic Cotton 80.0%Recycled Polyester 15.0%Elastane 5.0%
+
+ +
+ + QR code: https://id.odal-node.io/01/09506000134352/21/7abc8def0123456789ab + + + +

Scan to verify

+
+ + +
+ + \ No newline at end of file diff --git a/crates/dpp-render/dpp-render-preview-snapshot.html b/crates/dpp-render/dpp-render-preview-snapshot.html new file mode 100644 index 0000000..196815d --- /dev/null +++ b/crates/dpp-render/dpp-render-preview-snapshot.html @@ -0,0 +1,84 @@ + + + + + + + + + + DPP — Organic Cotton T-Shirt + + + +
+
This is a saved copy of this passport as of 2026-08-13 21:56 UTC. The live service is temporarily unavailable, so some details may have changed since. The copy is still signed and can be verified.
+

Organic Cotton T-Shirt

+ active + +

Product Information

+ + + + + +
Passport ID0190a9f0-1234-7abc-8def-0123456789ab
ManufacturerSample Textiles Co.
GTIN09506000134352
Batch ID-
+ +

Textile Information

+ + + + + + + + + +
Country of ManufacturingGermany
Care InstructionsMachine wash cold, tumble dry low
Chemical ComplianceOEKO-TEX Standard 100
Recycled Content32.5%
Fibre Composition + +
Organic Cotton 80.0%Recycled Polyester 15.0%Elastane 5.0%
+
+ +
+ + QR code: https://id.odal-node.io/01/09506000134352/21/7abc8def0123456789ab + + + +

Scan to verify

+
+ + +
+ + \ No newline at end of file diff --git a/crates/dpp-seal/src/cades.rs b/crates/dpp-seal/src/cades.rs new file mode 100644 index 0000000..9af2ed7 --- /dev/null +++ b/crates/dpp-seal/src/cades.rs @@ -0,0 +1,211 @@ +//! Reading what a detached CAdES says about itself. +//! +//! Shared by the backends because the parsing is plain CMS and belongs to +//! neither: a backend module may not reach into another's, and duplicating +//! 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* +//! +//! 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. +//! +//! 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 cms::cert::CertificateChoices; +use cms::content_info::ContentInfo; +use cms::signed_data::{SignedData, SignerInfo}; +use der::{Decode as _, Encode as _}; +use x509_cert::Certificate; + +use crate::error::SealError; + +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. +struct Signed { + signer: SignerInfo, + certificate: Certificate, +} + +/// Parse a detached CMS `SignedData` down to its single signer and certificate. +/// +/// 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 +/// that was made. Reaching into `[0]` and hoping would turn that into a silent +/// mismatch. +fn parse(seal_der: &[u8]) -> Result { + let info = ContentInfo::from_der(seal_der).map_err(|e| malformed(format!("not CMS: {e}")))?; + let sd: SignedData = info + .content + .decode_as() + .map_err(|e| malformed(format!("not SignedData: {e}")))?; + + let signers = sd.signer_infos.0.as_slice(); + let [signer] = signers else { + return Err(malformed(format!( + "expected exactly one signer, found {}", + signers.len() + ))); + }; + + let certs = sd + .certificates + .as_ref() + .ok_or_else(|| malformed("it carries no certificate"))?; + let Some(CertificateChoices::Certificate(certificate)) = certs.0.as_slice().first() else { + return Err(malformed("it carries no X.509 certificate")); + }; + + Ok(Signed { + signer: signer.clone(), + certificate: certificate.clone(), + }) +} + +/// Hex SHA-256 over the DER of the certificate the seal carries. +/// +/// **Reported by the seal, not verified.** This identifies *which* certificate +/// the seal names, so an auditor can ask whether it was on the EU Trusted List +/// at the sealing time without first being handed the `.p7s` and parsing it. +/// Answering that question is the validator's job, not this function's. +/// +/// A thumbprint rather than issuer+serial or a subject key identifier: it is one +/// fixed-length value that names exactly one certificate, needs no parsing to +/// compare, and is what the local backend already reports — so the two backends +/// put the same kind of thing in the same field. +/// +/// `Ok(None)` when the bytes are not a seal this module can read. A seal that +/// arrived and stored fine must not be lost to a parse failure on a convenience +/// field, so the caller degrades to an unpopulated reference rather than failing +/// the seal. +pub fn signer_certificate_thumbprint(seal_der: &[u8]) -> Result, SealError> { + use sha2::{Digest as _, Sha256}; + + let Ok(signed) = parse(seal_der) else { + return Ok(None); + }; + let der = signed + .certificate + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the certificate: {e}")))?; + Ok(Some(hex::encode(Sha256::digest(&der)))) +} + +/// Check the signature against the certificate the seal carries. +/// +/// A `true` means the signature over the signed attributes verifies under the +/// public key in the certificate travelling inside the seal — the structure is +/// internally consistent. It says **nothing** about trust: no chain was built and +/// no authority was consulted. Whether that is the whole truth about a seal or +/// 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 _; + + let signed = parse(seal_der)?; + + // Absent signed attributes means the digest is not inside the seal, so + // nothing can be checked without the original payload — which this function + // is not given. That is a different answer from "invalid", and conflating the + // two would brand a seal broken on the strength of a check that never ran. + let Some(signed_attrs) = signed.signer.signed_attrs.as_ref() else { + return Err(SealError::Backend( + "this seal carries no signed attributes, so the digest it covers is not inside it \ + and cannot be checked from the envelope alone" + .to_owned(), + )); + }; + + let spki = &signed.certificate.tbs_certificate.subject_public_key_info; + let key_bits = spki + .subject_public_key + .as_bytes() + .ok_or_else(|| malformed("the certificate's public key is not whole bytes"))?; + let vk = VerifyingKey::from_sec1_bytes(key_bits) + .map_err(|e| malformed(format!("the certificate holds no P-256 key: {e}")))?; + + // Re-encode as SET OF, matching what was signed (RFC 5652 §5.4). + 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}")))?; + + Ok(vk.verify(&to_verify, &sig).is_ok()) +} + +#[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" + ); + } + + /// A seal signed by a different identity reports a different certificate. + /// + /// Without this, the function could return a constant and the test above + /// would still pass. + #[test] + fn a_different_signer_yields_a_different_thumbprint() { + let make = || { + 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"); + (signer_certificate_thumbprint(&seal).unwrap(), dir) + }; + + let (a, _da) = make(); + let (b, _db) = make(); + assert!(a.is_some() && b.is_some()); + assert_ne!(a, b, "two certificates must not report the same thumbprint"); + } +} diff --git a/crates/dpp-seal/src/eideasy/client.rs b/crates/dpp-seal/src/eideasy/client.rs index 6495c63..2d24589 100644 --- a/crates/dpp-seal/src/eideasy/client.rs +++ b/crates/dpp-seal/src/eideasy/client.rs @@ -176,13 +176,26 @@ impl SealBackend for EideasyClient { ); let seal_value = self.seal_digest(&file_name, &req.payload_hash).await?; + // Which certificate the provider actually sealed with, read out of the + // `.p7s` it returned — **as reported by the seal**, never as verified. + // Whether that certificate was qualified, and on the EU Trusted List at + // this moment, is the independent validator's question; recording which + // one to ask about is what stops an auditor having to be handed the + // bytes and parse them by hand. + // + // A seal that cannot be parsed still stores: the envelope is what was + // bought, and losing it over an unfillable convenience field would + // trade something that matters for something that does not. + let signing_cert_ref = BASE64 + .decode(&seal_value) + .ok() + .and_then(|der| crate::cades::signer_certificate_thumbprint(&der).ok()) + .flatten(); + Ok(SealedEnvelope { format: SealFormat::Cades, seal_value, - // The signing certificate travels *inside* the detached CAdES, and - // reading it out needs the CMS parser this adapter deliberately does - // not have. Left `None` rather than filled with a guess. - signing_cert_ref: None, + signing_cert_ref, sealed_at: Utc::now(), placeholder: false, }) diff --git a/crates/dpp-seal/src/eideasy/tests.rs b/crates/dpp-seal/src/eideasy/tests.rs index 5d76a76..e8b78b3 100644 --- a/crates/dpp-seal/src/eideasy/tests.rs +++ b/crates/dpp-seal/src/eideasy/tests.rs @@ -210,6 +210,65 @@ async fn a_wrong_hmac_key_is_a_clean_error_not_a_panic() { ); } +/// The certificate the provider sealed with is read back off the wire. +/// +/// The mock returns a genuine CMS structure rather than the placeholder string +/// the other tests use, because that is the only way to exercise the extraction +/// at all — and the value asserted is the signing identity's own thumbprint, so +/// this fails if the adapter ever reports a certificate other than the one the +/// seal actually names. +#[tokio::test] +async fn the_signing_certificate_is_read_out_of_the_returned_seal() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let real_p7s = BASE64.encode(id.sign_detached(&[0x33; 32]).expect("sign")); + + let state = Arc::new(MockState::default()); + *state.force_response.lock().unwrap() = Some(( + axum::http::StatusCode::OK, + serde_json::json!({ + "status": "OK", + "signatures": [{ + "fileName": "dpp-abc.p7s", + "mimeType": "application/pkcs7-signature", + "fileContent": real_p7s, + }], + }) + .to_string(), + )); + let base_url = mock_server::spawn(state.clone()).await; + + let env = adapter_for(&base_url, MOCK_KEY) + .seal(seal_request(fixture_digest())) + .await + .expect("seal"); + + assert_eq!( + env.signing_cert_ref.as_deref(), + Some(id.cert_thumbprint().as_str()), + "the envelope must name the certificate inside the seal it received" + ); +} + +/// A seal the parser cannot read still produces an envelope. +/// +/// The seal was bought and is the thing that matters; an unfillable convenience +/// field must not cost it. Every other test in this file returns a placeholder +/// `.p7s` that is not CMS at all, so this is also what keeps them meaningful. +#[tokio::test] +async fn an_unparseable_seal_still_stores_without_a_certificate_reference() { + let state = Arc::new(MockState::default()); + let base_url = mock_server::spawn(state.clone()).await; + + let env = adapter_for(&base_url, MOCK_KEY) + .seal(seal_request(fixture_digest())) + .await + .expect("an unreadable seal is still a seal"); + + assert_eq!(env.seal_value, MOCK_P7S); + assert_eq!(env.signing_cert_ref, None); +} + #[tokio::test] async fn a_non_ok_status_body_does_not_become_a_seal() { let state = Arc::new(MockState::default()); diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index cd15be5..f018c05 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -23,6 +23,8 @@ //! # Structure //! //! - [`backend`] — `SealBackend`, the seam every backend implements +//! - [`cades`] — reading what a detached CAdES reports about itself, shared by +//! the backends and never claiming a check it did not perform //! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl over one of them //! - [`config`] — which backend this node runs, and nothing about any of them //! - [`eideasy`] — a hosted QTSP backend: its config, wire types, client and errors @@ -38,6 +40,7 @@ pub mod adapter; pub mod backend; +pub mod cades; pub mod config; pub mod eideasy; pub mod error; diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs index 1dab56d..e34b66c 100644 --- a/crates/dpp-seal/src/local/sealer.rs +++ b/crates/dpp-seal/src/local/sealer.rs @@ -253,7 +253,7 @@ impl SealBackend for LocalIdentity { .map_err(|e| SealError::Backend(format!("seal value is not base64: {e}")))?; Ok(SealVerification { - valid: verify_detached(&der)?, + valid: crate::cades::verify_against_embedded_certificate(&der)?, placeholder: env.placeholder, }) } @@ -300,78 +300,6 @@ fn signed_attributes(digest: &[u8]) -> Result { Ok(attrs) } -/// Check a detached CMS `SignedData` against the certificate it carries. -/// -/// A free function, and deliberately so: it takes bytes and nothing else, -/// because a verifier only ever has bytes. It cannot reach the signing identity -/// and does not need to. -/// -/// What a `true` here means, exactly: the signature over the signed attributes -/// verifies under the public key in the certificate travelling inside the seal, -/// and that certificate is therefore self-consistent with the signature. It says -/// **nothing** about trust — the certificate is self-signed and on no EU Trusted -/// List, so there is no chain to build and no authority behind it. For this -/// backend that is the whole truth available, which is why reporting it is -/// honest here and would not be for a qualified seal. -fn verify_detached(seal_der: &[u8]) -> Result { - use p256::ecdsa::VerifyingKey; - use p256::ecdsa::signature::Verifier as _; - - let backend = |m: String| SealError::Backend(m); - - let info = ContentInfo::from_der(seal_der) - .map_err(|e| backend(format!("not a CMS ContentInfo: {e}")))?; - let sd: SignedData = info - .content - .decode_as() - .map_err(|e| backend(format!("not CMS SignedData: {e}")))?; - - let signers = sd.signer_infos.0.as_slice(); - let [signer] = signers else { - return Err(backend(format!( - "expected exactly one signer, found {}", - signers.len() - ))); - }; - - // Absent signed attributes means the digest is not inside the seal, so - // nothing can be checked without the original payload — which this function - // is not given. That is a different answer from "invalid", and conflating - // the two would brand an older seal as broken. - let Some(signed_attrs) = signer.signed_attrs.as_ref() else { - return Err(backend( - "this seal carries no signed attributes, so the digest it covers is not inside it \ - and cannot be checked from the envelope alone" - .to_owned(), - )); - }; - - let certs = sd - .certificates - .as_ref() - .ok_or_else(|| backend("the seal carries no certificate to verify against".to_owned()))?; - let Some(CertificateChoices::Certificate(cert)) = certs.0.as_slice().first() else { - return Err(backend("the seal carries no X.509 certificate".to_owned())); - }; - - let spki = &cert.tbs_certificate.subject_public_key_info; - let key_bits = spki - .subject_public_key - .as_bytes() - .ok_or_else(|| backend("the certificate's public key is not whole bytes".to_owned()))?; - let vk = VerifyingKey::from_sec1_bytes(key_bits) - .map_err(|e| backend(format!("the certificate holds no P-256 key: {e}")))?; - - // Re-encode as SET OF, matching what was signed (RFC 5652 §5.4). - let signed = signed_attrs - .to_der() - .map_err(|e| backend(format!("cannot re-encode the signed attributes: {e}")))?; - let sig = DerSignature::from_bytes(signer.signature.as_bytes()) - .map_err(|e| backend(format!("not a DER ECDSA signature: {e}")))?; - - Ok(vk.verify(&signed, &sig).is_ok()) -} - /// Generate a P-256 key and a self-signed certificate for it. fn generate() -> Result<(Vec, Vec), SealError> { let mut params = rcgen::CertificateParams::new(vec!["odal-local-seal".to_owned()]) @@ -444,14 +372,15 @@ mod tests { // Checked through the same function production uses, which is handed // bytes and nothing else — the identity in memory is not consulted. assert!( - verify_detached(&der).expect("the seal is well-formed"), + crate::cades::verify_against_embedded_certificate(&der) + .expect("the seal is well-formed"), "the seal must verify against the certificate it ships" ); } /// The seal commits to the digest it was asked to seal, not to some other. /// - /// `verify_detached` proves the signature covers the signed attributes; this + /// `cades::verify_against_embedded_certificate` proves the signature covers the signed attributes; this /// proves the signed attributes carry the right value. Without it the /// backend could sign a constant attribute set perfectly and attest nothing /// about the passport — internally valid, externally meaningless. @@ -491,7 +420,7 @@ mod tests { /// A tampered seal does not verify. /// /// The check that makes the others mean something: if flipping a byte of the - /// signature still verified, `verify_detached` would be reporting a constant + /// signature still verified, `cades::verify_against_embedded_certificate` would be reporting a constant /// rather than performing a check. #[test] fn a_tampered_signature_does_not_verify() { @@ -506,7 +435,10 @@ mod tests { tampered[last] ^= 0xff; assert!( - !matches!(verify_detached(&tampered), Ok(true)), + !matches!( + crate::cades::verify_against_embedded_certificate(&tampered), + Ok(true) + ), "a tampered seal must never verify" ); } @@ -537,7 +469,8 @@ mod tests { .to_der() .expect("DER"); - let err = verify_detached(&stripped).expect_err("must refuse rather than answer"); + let err = crate::cades::verify_against_embedded_certificate(&stripped) + .expect_err("must refuse rather than answer"); assert!(err.to_string().contains("signed attributes"), "{err}"); } diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index d126337..c628d70 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -44,6 +44,17 @@ pub struct SealResponse { pub seal_value: String, /// When the QTSP produced it. pub sealed_at: chrono::DateTime, + + /// 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 when the seal was made. Both of + /// those are the independent validator's question. Without this an auditor + /// has to be handed the `.p7s` and parse it by hand to learn even the first. + /// + /// `null` when the seal predates extraction or could not be parsed. + pub signing_cert_ref: Option, /// True when this is a `GhostSeal` placeholder with no legal validity. pub placeholder: bool, /// The passport's **current** compact JWS. @@ -164,6 +175,7 @@ pub async fn seal_handler( .unwrap_or_default(), seal_value: seal.seal_value.clone(), sealed_at: seal.sealed_at, + signing_cert_ref: seal.signing_cert_ref.clone(), placeholder: seal.placeholder, current_jws: jws, current_payload_hash: payload_hash, From f26f7f4e4f2b86edf51af290bc8313e4a09814db Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 03:37:59 +0200 Subject: [PATCH 15/20] fix(node): stop naming one provider in the seal drain --- crates/dpp-node/src/boot/tasks.rs | 4 +-- crates/dpp-node/src/infra/seal_drain.rs | 36 +++++++++++++++---------- crates/dpp-node/src/main.rs | 30 ++++++++++++++++----- crates/dpp-node/tests/seal_outbox.rs | 21 +++++++++++---- 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/crates/dpp-node/src/boot/tasks.rs b/crates/dpp-node/src/boot/tasks.rs index 82fd8d5..de858ac 100644 --- a/crates/dpp-node/src/boot/tasks.rs +++ b/crates/dpp-node/src/boot/tasks.rs @@ -314,7 +314,7 @@ fn set_seal_gauges(c: &SealOutboxCounts) { pub async fn spawn_seal_drain( outbox: Arc, seal: Arc, - client_id: String, + key_ref: dpp_domain::ports::seal::SealCredentialRef, ) { match outbox.status_counts().await { Ok(c) => { @@ -332,7 +332,7 @@ pub async fn spawn_seal_drain( tokio::spawn(async move { loop { tokio::time::sleep(DRAIN_INTERVAL).await; - dpp_node::infra::seal_drain::drain_once(&outbox, &seal, &client_id, DRAIN_BATCH).await; + dpp_node::infra::seal_drain::drain_once(&outbox, &seal, &key_ref, DRAIN_BATCH).await; if let Ok(c) = outbox.status_counts().await { set_seal_gauges(&c); if c.exhausted > 0 { diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index c882db5..e52f10a 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -71,7 +71,7 @@ async fn back_off_or_exhaust( pub async fn drain_once( outbox: &Arc, seal: &Arc, - client_id: &str, + key_ref: &SealCredentialRef, batch: i64, ) -> DrainStats { let mut stats = DrainStats::default(); @@ -87,14 +87,12 @@ pub async fn drain_once( let req = SealRequest { payload_hash: row.payload_hash.clone(), mode: SealMode::ProviderSeal, - // CSC-shaped and unused by the eID Easy backend, whose credential is - // adapter config rather than a per-request reference. Filled with - // what actually identifies the sealing client so the field carries - // provenance instead of a placeholder. - key_ref: SealCredentialRef { - qtsp_id: "eideasy".to_owned(), - credential_id: client_id.to_owned(), - }, + // CSC-shaped, and no backend here reads it — each one's credential + // is adapter config rather than a per-request reference. Passed in + // from the composition root rather than named here: the drain is + // provider-agnostic, and a literal in this file would put a false + // provider name on every request of whichever backend it was not. + key_ref: key_ref.clone(), sig_format: SealFormat::Cades, }; @@ -232,6 +230,16 @@ mod tests { } } + /// Stands in for whatever the composition root resolved the backend to. + /// Nothing in the drain reads it, which is exactly why it must not be named + /// here in production code either. + fn test_key_ref() -> SealCredentialRef { + SealCredentialRef { + qtsp_id: "test-provider".to_owned(), + credential_id: "test-client".to_owned(), + } + } + fn row(attempts: i32) -> SealRow { SealRow { id: uuid::Uuid::now_v7(), @@ -260,7 +268,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal.clone() as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -279,7 +287,7 @@ mod tests { drain_once( &(outbox as Arc), &(seal.clone() as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -293,7 +301,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -309,7 +317,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -327,7 +335,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index fd27153..52eff37 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -184,9 +184,13 @@ async fn main() -> anyhow::Result<()> { // signed seal answers yes to the second while answering `Ghost` to the // first. Collapsing them either strands the local backend unexercised or // lets a self-signed certificate satisfy a production boot. - let (seal, seal_client_id, seal_trust, seal_drains): ( + // The credential reference travels from here rather than being named in the + // drain: this is the only place that knows which backend was selected, and + // each backend supplies its own name through the constant the selector + // already matches on — so the two can never disagree. + let (seal, seal_key_ref, seal_trust, seal_drains): ( Arc, - String, + dpp_domain::ports::seal::SealCredentialRef, TrustMode, bool, ) = match dpp_seal::SealProvider::from_env().context("seal provider")? { @@ -199,7 +203,10 @@ async fn main() -> anyhow::Result<()> { dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, }; - let client_id = cfg.client_id.clone(); + let key_ref = dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), + credential_id: cfg.client_id.clone(), + }; tracing::info!( base_url = %cfg.base_url, mode = mode.as_str(), @@ -209,7 +216,7 @@ async fn main() -> anyhow::Result<()> { .context("Failed to build the QTSP seal adapter")?; ( Arc::new(dpp_seal::QtspSealAdapter::new(backend)), - client_id, + key_ref, mode, true, ) @@ -228,7 +235,11 @@ async fn main() -> anyhow::Result<()> { // refuses to boot on it. But the envelope is real, so it drains. ( Arc::new(dpp_seal::QtspSealAdapter::new(backend)), - String::new(), + dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + // No credential to reference: the key is this node's own. + credential_id: String::new(), + }, TrustMode::Ghost, true, ) @@ -237,7 +248,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); ( Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)), - String::new(), + // Never used: `seal_drains` is false, so no drain is spawned and + // nothing builds a request from this. + dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: String::new(), + credential_id: String::new(), + }, TrustMode::Ghost, false, ) @@ -457,7 +473,7 @@ async fn main() -> anyhow::Result<()> { // would stamp synthetic placeholder seals onto published passports and // report them sealed. if sealing_live { - boot::tasks::spawn_seal_drain(db.seal_outbox.clone(), seal.clone(), seal_client_id).await; + boot::tasks::spawn_seal_drain(db.seal_outbox.clone(), seal.clone(), seal_key_ref).await; // The backstop for seals the event-driven path never queued, or that gave // up during an outage. Without it a published passport can stay unsealed // forever with nothing to notice. diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index a0cc09e..688ecb3 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -250,6 +250,17 @@ fn eideasy_config(base_url: &str) -> dpp_seal::eideasy::EideasyConfig { } } +/// What the composition root resolves for this backend: the provider's own +/// selector value, plus the client the seal is billed to. No backend reads it — +/// it is carried as provenance — but it is built here rather than in the drain +/// so the drain stays provider-agnostic. +fn mock_key_ref() -> dpp_domain::ports::seal::SealCredentialRef { + dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), + credential_id: MOCK_CLIENT_ID.to_owned(), + } +} + /// The real `SealPort` over the real provider backend, pointed at the mock. fn eideasy_adapter(cfg: dpp_seal::eideasy::EideasyConfig) -> Arc { Arc::new(QtspSealAdapter::new( @@ -333,7 +344,7 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { // ── 3. Drain: the real adapter against the mock ────────────────────────── let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); - let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats.sealed, 1, "the drain must seal the queued row"); assert_eq!(stats.retried, 0); @@ -403,7 +414,7 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { assert_eq!(counts.sealed, 1); assert_eq!(counts.pending, 0); - let stats2 = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats2 = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats2.sealed, 0, "a closed row must not be re-sealed"); assert_eq!( mock.requests.lock().unwrap().len(), @@ -457,7 +468,7 @@ async fn a_republish_needs_and_gets_its_own_seal() { let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); - drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; // Suspend → publish again: the signing path runs afresh. service @@ -483,7 +494,7 @@ async fn a_republish_needs_and_gets_its_own_seal() { hex::encode(Sha256::digest(second_jws.as_bytes())) ); - drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; let counts = seal_outbox.status_counts().await.expect("counts"); assert_eq!(counts.sealed, 2, "one seal per distinct signature"); assert_eq!( @@ -535,7 +546,7 @@ async fn a_wrong_key_is_rejected_and_the_row_stays_pending() { let adapter = eideasy_adapter(bad); let outbox_dyn: Arc = seal_outbox.clone(); - let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats.sealed, 0); assert_eq!(stats.retried, 1, "a rejected call must back off, not drop"); assert_eq!(*mock.rejected.lock().unwrap(), 1); From d517b9eb60c3cae38514bd57ae5e553a8c3ab2c1 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 03:52:15 +0200 Subject: [PATCH 16/20] test(vault): pin that a seal never ships without a declarer --- crates/dpp-vault/src/public_view.rs | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/dpp-vault/src/public_view.rs b/crates/dpp-vault/src/public_view.rs index 10b21fc..6a32aa6 100644 --- a/crates/dpp-vault/src/public_view.rs +++ b/crates/dpp-vault/src/public_view.rs @@ -350,6 +350,50 @@ pub(crate) mod tests { assert_eq!(served["publicJwsSignature"], json!(jws)); } + /// No audience is ever served a seal without a legible declaring party. + /// + /// A seal proves that a document came from whoever holds the certificate. It + /// says nothing about *scope* — "we vouch for this content" and "we + /// transmitted this intact" look identical. So a view carrying a seal and no + /// legible declarer invites the reader to conclude the sealer authored the + /// content, whatever anyone intended. + /// + /// Today the invariant holds the easy way, by stripping `seal` from every + /// audience. This test is written against the property rather than the + /// mechanism, so it keeps meaning if that ever changes: whoever serves a seal + /// to an audience must serve a declarer with it. + #[test] + fn no_audience_gets_a_seal_without_a_declarer() { + let passport = stub_passport(); + let mut full = serde_json::to_value(&passport).expect("serialise"); + full["seal"] = json!({ + "format": "CADES", + "sealValue": "p7s", + "sealedAt": "2026-08-14T00:00:00Z", + "placeholder": false, + }); + + for audience in [ + Audience::Public, + Audience::LegitimateInterest, + Audience::Authority, + ] { + // The passport's own version, as every production caller passes it. + let view = audience_view(&full, "battery", &passport.schema_version, audience); + let declarer = view + .get("manufacturer") + .and_then(|m| m.get("name")) + .and_then(Value::as_str) + .filter(|n| !n.is_empty()); + + assert!( + view.get("seal").is_none() || declarer.is_some(), + "{audience:?} received a seal with no legible declaring party — a reader \ + has no way to tell who vouched for this content from who sealed it" + ); + } + } + /// A published passport with no public proof is a corrupt row: fail closed /// rather than fall back to the live view and silently restore the drift. #[test] From a95022f16cb00e5c340a43f002a2ecf7e45f097a Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 16:38:12 +0200 Subject: [PATCH 17/20] feat(cli): add odal seal status --- cli/src/cli_args.rs | 19 ++++++ cli/src/commands/mod.rs | 1 + cli/src/commands/seal.rs | 34 ++++++++++ cli/src/core/mod.rs | 1 + cli/src/core/seal.rs | 132 ++++++++++++++++++++++++++++++++++++ cli/src/dispatch.rs | 7 +- cli/src/stateless/render.rs | 78 +++++++++++++++++++++ 7 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/seal.rs create mode 100644 cli/src/core/seal.rs diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index e44b521..f392264 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -122,6 +122,12 @@ pub enum Commands { /// Stored dossier id, or path to a dossier JSON file target: String, }, + // ── Qualified seals ────────────────────────────────────────────────────── + /// eIDAS qualified seal inspection + Seal { + #[command(subcommand)] + command: SealCommands, + }, // ── Insight ────────────────────────────────────────────────────────────── /// Operator-wide scan telemetry — how often your passports were resolved /// (per-passport detail: `odal passport stats `) @@ -426,3 +432,16 @@ pub enum WebhookCommands { id: String, }, } + +#[derive(Subcommand)] +pub enum SealCommands { + /// Show a passport's seal: format, signing certificate, and whether it + /// still covers the passport's current signature + Status { + /// Passport ID + id: String, + /// Output the raw route response instead of a summary + #[arg(long)] + json: bool, + }, +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index e455120..8299c6a 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod plugin; pub mod profile; pub mod publish; pub mod schema; +pub mod seal; pub mod stats; pub mod status; pub mod up; diff --git a/cli/src/commands/seal.rs b/cli/src/commands/seal.rs new file mode 100644 index 0000000..f2b1f48 --- /dev/null +++ b/cli/src/commands/seal.rs @@ -0,0 +1,34 @@ +//! `odal seal status ` — what the node knows about a passport's qualified +//! seal. + +use anyhow::Result; + +use crate::{ + config::Config, + core::seal::{SealStatus, action_seal_status}, + http::OdalClient, + stateless::render::{render_seal_absent, render_seal_status}, +}; + +pub async fn run_seal_status(id: &str, json: bool) -> Result<()> { + let cfg = Config::load()?; + let client = OdalClient::new(&cfg.api_key); + + match action_seal_status(id, &client, &cfg).await? { + SealStatus::Present(seal) => { + if json { + println!("{}", serde_json::to_string_pretty(&seal)?); + } else { + render_seal_status(&seal, id); + } + } + SealStatus::Absent => { + if json { + println!("{}", serde_json::json!({ "seal": null })); + } else { + render_seal_absent(id); + } + } + } + Ok(()) +} diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 57d3dd7..8d73f48 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod passport; pub mod plugin; pub mod registry_identity; pub mod schema; +pub mod seal; pub mod types; pub mod verify; pub mod webhook; diff --git a/cli/src/core/seal.rs b/cli/src/core/seal.rs new file mode 100644 index 0000000..e955f46 --- /dev/null +++ b/cli/src/core/seal.rs @@ -0,0 +1,132 @@ +//! 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. + +use anyhow::{Context, Result, bail}; +use reqwest::StatusCode; +use serde_json::Value; + +use crate::{ + config::Config, + http::{OdalClient, describe_error}, +}; + +/// The state of a passport's seal, as far as the node can honestly report it. +/// +/// `Absent` is a first-class answer, not an error. The route returns `404` both +/// for "no such passport" and "this passport has no seal", and those are very +/// different facts to a reader — the first is a mistake, the second is the +/// normal state of a draft, or of a passport whose seal has not drained yet. +pub enum SealStatus { + /// The passport carries a seal. The raw route body, rendered by the caller. + Present(Box), + /// The passport exists but has no seal. + Absent, +} + +/// What a pair of route outcomes means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Verdict { + /// Read and render the seal body. + Sealed, + /// The passport is real and simply has no seal. + Unsealed, + /// No passport with that id. + NoSuchPassport, + /// Anything else — report the transport error. + Failed, +} + +/// The disambiguation rule, as a pure function over the two status codes. +/// +/// Split out from the request path for the same reason the seal route's own +/// `coverage_of` is: the whole rule is which of four answers a pair of statuses +/// warrants, and that should not need a running node to exercise. +/// +/// `passport` is `None` when the seal route did not `404`, because in that case +/// the second request is never made. +/// +/// Keyed on status codes, never on the error prose. The route's wording is +/// written for a human reading it and is free to change; matching a substring +/// of it would make this command break on a copy edit. +fn classify(seal: StatusCode, passport: Option) -> Verdict { + match (seal, passport) { + (s, _) if s.is_success() => Verdict::Sealed, + (StatusCode::NOT_FOUND, Some(p)) if p.is_success() => Verdict::Unsealed, + (StatusCode::NOT_FOUND, Some(StatusCode::NOT_FOUND)) => Verdict::NoSuchPassport, + _ => Verdict::Failed, + } +} + +/// `GET /api/v1/dpp/{id}/seal` — the seal, its preimage, and its coverage. +pub async fn action_seal_status(id: &str, client: &OdalClient, cfg: &Config) -> Result { + let (status, body) = client + .get(&format!("{}/api/v1/dpp/{id}/seal", cfg.vault_url)) + .await?; + + // Only ask about the passport when the seal route 404s — the one case where + // the answer is ambiguous. + let passport_status = if status == StatusCode::NOT_FOUND { + let (s, _) = client + .get(&format!("{}/api/v1/dpp/{id}", cfg.vault_url)) + .await?; + Some(s) + } else { + None + }; + + match classify(status, passport_status) { + Verdict::Sealed => { + let doc: Value = serde_json::from_str(&body).context("seal response was not JSON")?; + Ok(SealStatus::Present(Box::new(doc))) + } + Verdict::Unsealed => Ok(SealStatus::Absent), + Verdict::NoSuchPassport => bail!("No passport with id {id}."), + Verdict::Failed => bail!( + "Failed to fetch the seal: {}", + describe_error(status, &body) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_seal_body_is_a_seal() { + assert_eq!(classify(StatusCode::OK, None), Verdict::Sealed); + } + + /// The distinction this command exists to make: both are `404` from the + /// seal route, and they mean opposite things to the operator reading it. + #[test] + fn the_two_404s_are_told_apart_by_the_passport_not_the_prose() { + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::OK)), + Verdict::Unsealed + ); + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::NOT_FOUND)), + Verdict::NoSuchPassport + ); + } + + /// An expired key 401s on both routes. Reporting that as "no such passport" + /// would send the operator hunting for a passport that is sitting right + /// there — so anything that is not a clean 404 pair is a transport failure. + #[test] + fn an_auth_failure_is_not_a_missing_passport() { + assert_eq!(classify(StatusCode::UNAUTHORIZED, None), Verdict::Failed); + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::UNAUTHORIZED)), + Verdict::Failed + ); + assert_eq!( + classify(StatusCode::INTERNAL_SERVER_ERROR, None), + Verdict::Failed + ); + } +} diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index 0aa5994..62fe4a7 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -2,7 +2,8 @@ use crate::cli_args::{ Commands, FacilityCommands, KeyCommands, OperatorCommands, OperatorIdCommands, - PassportCommands, PluginCommands, ProfileCommands, SchemaCommands, WebhookCommands, + PassportCommands, PluginCommands, ProfileCommands, SchemaCommands, SealCommands, + WebhookCommands, }; use crate::commands::{ bootstrap::run_bootstrap, @@ -29,6 +30,7 @@ use crate::commands::{ }, publish::run_publish, schema::run_schema, + seal::run_seal_status, stats::{run_operator_stats, run_passport_stats}, status::run_status, up::run_up, @@ -264,6 +266,9 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { command: SchemaCommands::Check, } => run_schema().await, Commands::Verify { target } => run_verify(&target).await, + Commands::Seal { + command: SealCommands::Status { id, json }, + } => run_seal_status(&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 cc7bc00..f4de8f7 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -405,3 +405,81 @@ pub fn render_schema_check(result: &SchemaCheckResult) { if result.update_available { "yes" } else { "no" } ); } + +/// Render a passport's qualified-seal status. +/// +/// Two things this deliberately does not do. It does not say "valid": the node +/// did not validate the CAdES and the route says so, so printing a verdict here +/// would invent one the API refused to give. And it does not collapse +/// `coverage` into a pass/fail — `superseded` is not a failure, it means the +/// 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 placeholder = seal + .get("placeholder") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + println!("Seal for {id}"); + + if placeholder { + println!( + " {} no QTSP is configured, so this is a ghost placeholder with no legal weight", + style("PLACEHOLDER").yellow().bold() + ); + } + + println!(" Format : {}", s("format")); + println!(" Sealed at : {}", s("sealedAt")); + + // 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 seal + .get("signingCertRef") + .and_then(serde_json::Value::as_str) + { + Some(cert) => println!(" Signing cert : {cert}"), + None => println!(" Signing cert : not recorded (predates extraction, or unparseable)"), + } + + let coverage = s("coverage"); + let note = match coverage.as_str() { + "current" => "covers the passport's current signature".to_owned(), + "superseded" => "the passport was re-published after sealing — the seal still covers the \ + signature it was bought for, and a seal over the new one has not landed yet" + .to_owned(), + "unknown" => "this node has no record of what was sealed — restored from a backup, \ + or produced elsewhere" + .to_owned(), + other => format!("unrecognised coverage value `{other}`"), + }; + println!(" Coverage : {coverage} — {note}"); + + println!(" Current hash : {}", s("currentPayloadHash")); + println!( + " Sealed hash : {}", + seal.get("sealedPayloadHash") + .and_then(serde_json::Value::as_str) + .unwrap_or("(no record)") + ); + + println!("\n Verification : {}", s("verification")); +} + +/// Render the "this passport has no seal" case. +/// +/// Its own function rather than a branch inside [`render_seal_status`], because +/// there is no seal document to render and the useful content is entirely +/// different: why there might not be one yet. +pub fn render_seal_absent(id: &str) { + println!("Seal for {id}"); + println!(" None. The passport may be unpublished, or its seal may still be queued."); + println!(" Sealing runs off a drain after publish — it is not part of the publish call."); +} From d49a55174e494afeb996d1f8c9dd60b7272ba160 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 18:18:21 +0200 Subject: [PATCH 18/20] fix(api): use 3.1 null syntax in the seal schema --- api/openapi.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 43f4664..e64bd2a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2077,8 +2077,7 @@ paths: type: string format: date-time signingCertRef: - type: string - nullable: true + type: [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 @@ -2105,8 +2104,7 @@ paths: passport's present signature would be taken over. pattern: "^[0-9a-f]{64}$" sealedPayloadHash: - type: string - nullable: true + type: [string, "null"] description: | Hex SHA-256 this node asked the backend to seal, from the outbox row that bought `sealValue`. `null` when the node From 268d178017ab0ab1d46f54ecb410da7784dfe423 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 18:18:41 +0200 Subject: [PATCH 19/20] refactor(node): select the seal backend in infra --- crates/dpp-node/src/infra/mod.rs | 1 + crates/dpp-node/src/infra/seal.rs | 125 ++++++++++++++++++++++++++++++ crates/dpp-node/src/main.rs | 98 +++-------------------- 3 files changed, 135 insertions(+), 89 deletions(-) create mode 100644 crates/dpp-node/src/infra/seal.rs diff --git a/crates/dpp-node/src/infra/mod.rs b/crates/dpp-node/src/infra/mod.rs index 77459f2..468b57a 100644 --- a/crates/dpp-node/src/infra/mod.rs +++ b/crates/dpp-node/src/infra/mod.rs @@ -10,6 +10,7 @@ pub mod ruleset; pub mod s3_archive; #[cfg(feature = "s3")] pub mod s3_snapshot; +pub mod seal; pub mod seal_drain; pub mod snapshot_drain; pub mod snapshot_store; diff --git a/crates/dpp-node/src/infra/seal.rs b/crates/dpp-node/src/infra/seal.rs new file mode 100644 index 0000000..9e48c88 --- /dev/null +++ b/crates/dpp-node/src/infra/seal.rs @@ -0,0 +1,125 @@ +//! Selects the eIDAS qualified-seal backend from the environment. +//! +//! The composition root for sealing, in the same place every other adapter's +//! lives — `s3_archive`, `snapshot_store` and `credential_issuers` all resolve +//! themselves here rather than in `main`, so the binary asks for a port and does +//! not learn how any of them are built. +//! +//! # Configuration +//! +//! | Variable | Values | Meaning | +//! |-----------------|----------------------------|--------------------------------------------| +//! | `SEAL_PROVIDER` | unset / `qtsp` / `local` | Which backend to build | +//! +//! Each backend then reads its own variables — see `dpp_seal::eideasy::config` +//! and `dpp_seal::local::config`. A partial or unrecognised configuration is an +//! error rather than a silent ghost: dropping to no sealing because one variable +//! was misspelled is exactly the downgrade the trust report exists to prevent, +//! so it fails the boot. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use dpp_domain::ports::seal::{SealCredentialRef, SealPort}; +use dpp_types::trust::TrustMode; + +/// A selected seal backend, and the three facts about it the node needs. +/// +/// A struct rather than the 4-tuple this replaces: `(port, ref, mode, bool)` is +/// two fields past the point where positional returns stay readable at the call +/// site, and the last two are both "about trust" without being interchangeable. +pub struct SealWiring { + /// The port the drain seals through. + pub port: Arc, + /// Which credential the backend signs with. + /// + /// Travels from here rather than being named in the drain: this is the only + /// place that knows which backend was selected, and each backend supplies + /// its own name through the constant the selector already matches on — so + /// the two can never disagree. + pub credential: SealCredentialRef, + /// **Legal** standing — decides whether a profile will boot. + pub trust: TrustMode, + /// **Mechanical** — whether the backend emits an envelope worth draining. + /// + /// Deliberately not the same question as [`Self::trust`], and not derivable + /// from it. A locally signed seal answers yes here while answering `Ghost` + /// there. Collapsing the two either strands the local backend unexercised or + /// lets a self-signed certificate satisfy a production boot. + pub drains: bool, +} + +/// Build the seal backend named by `SEAL_PROVIDER`. +/// +/// # Errors +/// Propagates a backend's own configuration error, and an unrecognised +/// `SEAL_PROVIDER` value. Both fail the boot rather than degrading to a ghost. +pub fn from_env() -> Result { + match dpp_seal::SealProvider::from_env().context("seal provider")? { + dpp_seal::SealProvider::Qtsp => { + let cfg = + dpp_seal::eideasy::EideasyConfig::from_env().context("QTSP seal configuration")?; + // 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 { + dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, + dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, + }; + let credential = SealCredentialRef { + qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), + credential_id: cfg.client_id.clone(), + }; + tracing::info!( + base_url = %cfg.base_url, + mode = trust.as_str(), + "eIDAS seal: QTSP adapter active" + ); + let backend = dpp_seal::eideasy::EideasyClient::new(cfg) + .context("Failed to build the QTSP seal adapter")?; + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + credential, + trust, + drains: true, + }) + } + dpp_seal::SealProvider::Local => { + let cfg = + dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; + let backend = dpp_seal::local::LocalIdentity::load_or_create(&cfg.key_path) + .context("Failed to build the local seal adapter")?; + tracing::warn!( + key_path = %cfg.key_path.display(), + "eIDAS seal: LOCAL development backend — a real CMS signature under a \ + self-signed certificate, on no EU Trusted List and of no legal weight" + ); + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + credential: SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + // No credential to reference: the key is this node's own. + credential_id: String::new(), + }, + // Ghost as a trust tier, because no authority stands behind a + // self-signed certificate — so a sandbox or production profile + // refuses to boot on it. But the envelope is real, so it drains. + trust: TrustMode::Ghost, + drains: true, + }) + } + dpp_seal::SealProvider::None => { + tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)), + // Never used: `drains` is false, so no drain is spawned and + // nothing builds a request from this. + credential: SealCredentialRef { + qtsp_id: String::new(), + credential_id: String::new(), + }, + trust: TrustMode::Ghost, + drains: false, + }) + } + } +} diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 52eff37..592434f 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -19,7 +19,6 @@ use dpp_common::{ use dpp_crypto::keystore::KeyStore; use dpp_domain::ports::{ archive::ArchivePort, compliance::ComplianceRegistry, registry_sync::RegistrySyncPort, - seal::SealPort, }; use dpp_identity_service::state::AppState as IdentityState; use dpp_integrator::{infra::vault_client::VaultHttpClient, state::AppState as IntegratorState}; @@ -174,95 +173,11 @@ async fn main() -> anyhow::Result<()> { let credentials_live = credential_trust != TrustMode::Ghost; // ── eIDAS qualified seal ───────────────────────────────────────────────── - // The backend is selected explicitly by SEAL_PROVIDER. A partial or - // unrecognised configuration is an error rather than a silent ghost: - // dropping to no sealing because one variable was misspelled is exactly the - // downgrade the trust report exists to prevent, so it fails the boot. - // Two questions, deliberately not one flag. `seal_trust` is *legal* standing - // and decides whether a profile will boot. `seal_drains` is *mechanical* — - // whether the backend emits an envelope worth draining — and a locally - // signed seal answers yes to the second while answering `Ghost` to the - // first. Collapsing them either strands the local backend unexercised or - // lets a self-signed certificate satisfy a production boot. - // The credential reference travels from here rather than being named in the - // drain: this is the only place that knows which backend was selected, and - // each backend supplies its own name through the constant the selector - // already matches on — so the two can never disagree. - let (seal, seal_key_ref, seal_trust, seal_drains): ( - Arc, - dpp_domain::ports::seal::SealCredentialRef, - TrustMode, - bool, - ) = match dpp_seal::SealProvider::from_env().context("seal provider")? { - dpp_seal::SealProvider::Qtsp => { - let cfg = - dpp_seal::eideasy::EideasyConfig::from_env().context("QTSP seal configuration")?; - // 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 mode = match cfg.environment { - dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, - dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, - }; - let key_ref = dpp_domain::ports::seal::SealCredentialRef { - qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), - credential_id: cfg.client_id.clone(), - }; - tracing::info!( - base_url = %cfg.base_url, - mode = mode.as_str(), - "eIDAS seal: QTSP adapter active" - ); - let backend = dpp_seal::eideasy::EideasyClient::new(cfg) - .context("Failed to build the QTSP seal adapter")?; - ( - Arc::new(dpp_seal::QtspSealAdapter::new(backend)), - key_ref, - mode, - true, - ) - } - dpp_seal::SealProvider::Local => { - let cfg = - dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; - let backend = dpp_seal::local::LocalIdentity::load_or_create(&cfg.key_path) - .context("Failed to build the local seal adapter")?; - tracing::warn!( - key_path = %cfg.key_path.display(), - "eIDAS seal: LOCAL development backend — a real CMS signature under a self-signed certificate, on no EU Trusted List and of no legal weight" - ); - // Ghost as a trust tier, because no authority stands behind a - // self-signed certificate — so a sandbox or production profile - // refuses to boot on it. But the envelope is real, so it drains. - ( - Arc::new(dpp_seal::QtspSealAdapter::new(backend)), - dpp_domain::ports::seal::SealCredentialRef { - qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), - // No credential to reference: the key is this node's own. - credential_id: String::new(), - }, - TrustMode::Ghost, - true, - ) - } - dpp_seal::SealProvider::None => { - tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); - ( - Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)), - // Never used: `seal_drains` is false, so no drain is spawned and - // nothing builds a request from this. - dpp_domain::ports::seal::SealCredentialRef { - qtsp_id: String::new(), - credential_id: String::new(), - }, - TrustMode::Ghost, - false, - ) - } - }; - let sealing_live = seal_drains; + let seal_wiring = dpp_node::infra::seal::from_env()?; + let sealing_live = seal_wiring.drains; let trust = boot::trust::build_and_enforce( - seal_trust, + seal_wiring.trust, registry_trust, archive_trust, credential_trust, @@ -473,7 +388,12 @@ async fn main() -> anyhow::Result<()> { // would stamp synthetic placeholder seals onto published passports and // report them sealed. if sealing_live { - boot::tasks::spawn_seal_drain(db.seal_outbox.clone(), seal.clone(), seal_key_ref).await; + boot::tasks::spawn_seal_drain( + db.seal_outbox.clone(), + seal_wiring.port.clone(), + seal_wiring.credential, + ) + .await; // The backstop for seals the event-driven path never queued, or that gave // up during an outage. Without it a published passport can stay unsealed // forever with nothing to notice. From 94169e915b8ae9dcfc42107d53cc130f028b43d3 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 18:18:50 +0200 Subject: [PATCH 20/20] feat(vault): report operator-wide sealing state --- CHANGELOG.md | 24 +++++++++ CLAUDE.md | 1 + api/openapi.yaml | 72 +++++++++++++++++++++++++ cli/src/cli_args.rs | 9 ++-- cli/src/commands/seal.rs | 20 +++++-- cli/src/core/seal.rs | 14 +++++ cli/src/dispatch.rs | 2 +- cli/src/stateless/render.rs | 54 +++++++++++++++++++ crates/dpp-dal/src/pg/repo_seal.rs | 23 ++++++++ crates/dpp-node/src/infra/seal_drain.rs | 6 +++ crates/dpp-types/src/seal.rs | 18 +++++++ crates/dpp-vault/src/handlers/seal.rs | 71 ++++++++++++++++++++++++ crates/dpp-vault/src/router.rs | 5 +- crates/dpp-vault/tests/helpers/mod.rs | 6 ++- crates/dpp-vault/tests/seal_route.rs | 62 +++++++++++++++++++++ 15 files changed, 375 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65654c8..66a4fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,30 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Added +- **Sealing is answerable from the control plane** — `GET /vault/api/v1/seal` + and `odal seal status [id]`. With an id: that passport's seal, its signing + certificate, and whether it still covers the current signature. Without one: + how many published passports carry no seal, operator-wide. + + Both facts were previously reachable only through Prometheus (`seal_outbox_*` + gauges) or by curling the per-passport route with an id you had to already + know. Neither answers "is anything unsealed", which is the question an + operator actually has. + + **The summary counts passports, not outbox rows**, and the distinction is the + reason for the new `SealOutbox::unsealed_published_count`. The row counts + cannot answer it: `enqueue` runs after the publish commits, so a crash in that + window publishes a passport that no row will ever cover, and an outbox + reporting `pending: 0, exhausted: 0` is consistent with any number of unsealed + passports. A summary built on rows alone would have shown all clear while the + obligation went unmet. It shares the repair sweep's predicate so the two + cannot drift on what "unsealed" means. + + The CLI prints no verdict on the seal itself. The node does not validate the + CAdES and says so; inventing "valid" in the client would manufacture a claim + the API declined to make. `superseded` renders as a fact with its explanation, + not a failure — the seal remains valid for the signature it does cover. + - **eIDAS qualified sealing, end to end** (migration `0028_seal_outbox.sql`). `dpp-seal` becomes a real adapter against eID Easy Cloud Direct e-Sealing, which aggregates qualified QTSPs and returns diff --git a/CLAUDE.md b/CLAUDE.md index 70903d7..ae1efbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -317,6 +317,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | POST | `/vault/api/v1/dpp/{dppId}/archive` | Bearer | Archive | | 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 | | 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/api/openapi.yaml b/api/openapi.yaml index e64bd2a..4266637 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2005,6 +2005,78 @@ paths: "404": $ref: "#/components/responses/NotFound" + /vault/api/v1/seal: + get: + operationId: getSealSummary + summary: Operator-wide sealing state + description: | + How many published passports carry no seal, plus the outbox totals + behind that number. + + `unsealedPublished` is the headline; the three row counts are context. + They answer different questions and can legitimately disagree: the + counts describe outbox **rows**, while the obligation is about + **passports**. Enqueueing happens after the publish commits, so a crash + in that window publishes a passport that no row will ever cover — + `pending: 0, exhausted: 0` is therefore consistent with any number of + unsealed passports, and a summary built on rows alone would report all + clear. A repair sweep queues those passports on its next pass. + + A passport whose seal covers a *superseded* signature is not counted + here — it carries a seal, and that seal remains a valid attestation of + the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal` + reports that case per passport as `coverage`. + + When `sealingConfigured` is `false` no seal provider is selected, so + every count is `0` because this node has no outbox — not because it has + nothing outstanding. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + "200": + description: Operator-wide sealing state. + content: + application/json: + schema: + type: object + required: + [ + unsealedPublished, + pending, + sealed, + exhausted, + sealingConfigured, + ] + properties: + unsealedPublished: + type: integer + format: int64 + description: >- + Published passports carrying no seal at all. `0` is the + healthy state. + example: 0 + pending: + type: integer + format: int64 + description: Outbox rows awaiting a sealing attempt. + sealed: + type: integer + format: int64 + description: Outbox rows whose seal is on the passport. + exhausted: + type: integer + format: int64 + description: Outbox rows that gave up after exhausting retries. + sealingConfigured: + type: boolean + description: >- + False when no seal provider is configured, in which case + every count above is `0` for that reason alone. + "401": + $ref: "#/components/responses/Unauthorized" + /vault/api/v1/dpp/{dppId}/seal: get: operationId: getDppSeal diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index f392264..65f2679 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -435,11 +435,12 @@ pub enum WebhookCommands { #[derive(Subcommand)] pub enum SealCommands { - /// Show a passport's seal: format, signing certificate, and whether it - /// still covers the passport's current signature + /// Show sealing state. With no ID: how many published passports are + /// unsealed, operator-wide. With an ID: that passport's seal, its signing + /// certificate, and whether it still covers the current signature Status { - /// Passport ID - id: String, + /// Passport ID. Omit for the operator-wide summary. + id: Option, /// 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 f2b1f48..7e5a4c4 100644 --- a/cli/src/commands/seal.rs +++ b/cli/src/commands/seal.rs @@ -1,19 +1,29 @@ -//! `odal seal status ` — what the node knows about a passport's qualified -//! seal. +//! `odal seal status [id]` — what the node knows about qualified sealing, +//! operator-wide or for one passport. use anyhow::Result; use crate::{ config::Config, - core::seal::{SealStatus, action_seal_status}, + core::seal::{SealStatus, action_seal_status, action_seal_summary}, http::OdalClient, - stateless::render::{render_seal_absent, render_seal_status}, + stateless::render::{render_seal_absent, render_seal_status, render_seal_summary}, }; -pub async fn run_seal_status(id: &str, json: bool) -> Result<()> { +pub async fn run_seal_status(id: Option<&str>, json: bool) -> Result<()> { let cfg = Config::load()?; let client = OdalClient::new(&cfg.api_key); + let Some(id) = id else { + let summary = action_seal_summary(&client, &cfg).await?; + if json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + render_seal_summary(&summary); + } + return Ok(()); + }; + match action_seal_status(id, &client, &cfg).await? { SealStatus::Present(seal) => { if json { diff --git a/cli/src/core/seal.rs b/cli/src/core/seal.rs index e955f46..95de67a 100644 --- a/cli/src/core/seal.rs +++ b/cli/src/core/seal.rs @@ -91,6 +91,20 @@ pub async fn action_seal_status(id: &str, client: &OdalClient, cfg: &Config) -> } } +/// `GET /api/v1/seal` — operator-wide sealing state. +pub async fn action_seal_summary(client: &OdalClient, cfg: &Config) -> Result { + let (status, body) = client + .get(&format!("{}/api/v1/seal", cfg.vault_url)) + .await?; + if !status.is_success() { + bail!( + "Failed to fetch the sealing summary: {}", + describe_error(status, &body) + ); + } + serde_json::from_str(&body).context("sealing summary was not JSON") +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index 62fe4a7..0711b56 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -268,7 +268,7 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { Commands::Verify { target } => run_verify(&target).await, Commands::Seal { command: SealCommands::Status { id, json }, - } => run_seal_status(&id, json).await, + } => run_seal_status(id.as_deref(), 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 f4de8f7..5c4a026 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -483,3 +483,57 @@ pub fn render_seal_absent(id: &str) { println!(" None. The passport may be unpublished, or its seal may still be queued."); println!(" Sealing runs off a drain after publish — it is not part of the publish call."); } + +/// Render the operator-wide sealing summary. +/// +/// Leads with the passport count, not the row counts. An operator asking about +/// sealing is asking whether a published passport is missing its seal; the +/// outbox totals are how that came about, which is the second question. +pub fn render_seal_summary(summary: &serde_json::Value) { + let n = |key: &str| { + summary + .get(key) + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + }; + + if !summary + .get("sealingConfigured") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + println!("Sealing is not configured on this node."); + println!(" No seal provider is selected, so nothing is queued and nothing is sealed."); + println!(" Set SEAL_PROVIDER to enable it."); + return; + } + + let unsealed = n("unsealedPublished"); + println!("Sealing"); + if unsealed == 0 { + println!( + " {} every published passport carries a seal", + style("OK").green().bold() + ); + } else { + println!( + " {} {unsealed} published passport(s) carry no seal", + style("UNSEALED").red().bold() + ); + } + println!( + " Outbox: {} pending, {} sealed, {} exhausted", + n("pending"), + n("sealed"), + n("exhausted") + ); + + // Worth saying out loud: these two can disagree, and the direction of the + // disagreement is the diagnosis. + if unsealed > 0 && n("pending") == 0 && n("exhausted") == 0 { + println!( + "\n Unsealed with an empty outbox — those passports have no row at all, so no\n \ + drain will pick them up. The repair sweep queues them on its next pass." + ); + } +} diff --git a/crates/dpp-dal/src/pg/repo_seal.rs b/crates/dpp-dal/src/pg/repo_seal.rs index 4878a69..f8e8541 100644 --- a/crates/dpp-dal/src/pg/repo_seal.rs +++ b/crates/dpp-dal/src/pg/repo_seal.rs @@ -279,4 +279,27 @@ impl SealOutbox for PgSealOutboxRepo { exhausted: row.get::("exhausted"), }) } + + async fn unsealed_published_count(&self) -> Result { + // The same three conditions `enqueue_unsealed` selects on, and only + // those. Its two `NOT EXISTS` guards hold back rows the drain already + // owns or that failed recently — they shape *when to retry*, not whether + // a passport is unsealed, and applying them here would report a queued + // passport as sealed. + // + // No digest comparison, matching the sweep: a seal over a superseded + // signature is still a valid attestation of the signature it covers, and + // `/dpp/{id}/seal` reports that per passport as `coverage`. + let row = sqlx::query( + r#"SELECT count(*) AS unsealed + FROM odal.passport p + WHERE p.published_at IS NOT NULL + AND p.doc->'seal' IS NULL + AND p.doc->>'jwsSignature' IS NOT NULL"#, + ) + .fetch_one(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(row.get::("unsealed")) + } } diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index e52f10a..da99cbe 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -196,6 +196,12 @@ mod tests { async fn status_counts(&self) -> Result { Ok(SealOutboxCounts::default()) } + + 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. + Ok(0) + } } struct FakeSeal { diff --git a/crates/dpp-types/src/seal.rs b/crates/dpp-types/src/seal.rs index 2837edd..7ceeca5 100644 --- a/crates/dpp-types/src/seal.rs +++ b/crates/dpp-types/src/seal.rs @@ -164,4 +164,22 @@ pub trait SealOutbox: Send + Sync { /// Counts by status, for boot logs and gauges. async fn status_counts(&self) -> Result; + + /// How many **published passports carry no seal at all**, right now. + /// + /// Not derivable from [`Self::status_counts`], and this is the whole reason + /// it exists. Those counts describe *rows*, and the failure that matters + /// most leaves no row: [`Self::enqueue`] runs after commit, so a crash in + /// that window publishes a passport that nothing will ever seal. An outbox + /// reporting `pending: 0, exhausted: 0` is consistent with any number of + /// unsealed passports, so a status view built on rows alone would show all + /// clear while the obligation went unmet. + /// + /// Counts passports, not rows, and takes no `limit`: it answers "is anything + /// unsealed", which a capped query cannot. + /// + /// Read-only. [`Self::enqueue_unsealed`] is the repair for the same + /// 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; } diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index c628d70..bb95ed6 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -187,6 +187,77 @@ pub async fn seal_handler( .into_response() } +/// Operator-wide sealing state. +/// +/// `unsealedPublished` is the headline and the other three are context, not the +/// other way round. The counts describe outbox *rows*; the obligation is about +/// *passports*, and the two come apart exactly where it matters most — a crash +/// between commit and enqueue publishes a passport that no row will ever cover, +/// so `pending: 0, exhausted: 0` is consistent with any number of unsealed +/// passports. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SealSummaryResponse { + /// Published passports carrying no seal at all. `0` is the healthy state. + pub unsealed_published: i64, + /// Rows awaiting a sealing attempt. + pub pending: i64, + /// Rows whose seal is on the passport. + pub sealed: i64, + /// Rows that gave up after exhausting their retries. + pub exhausted: i64, + /// False when no seal provider is configured, in which case every number + /// above is `0` because this node has no outbox — not because it has + /// nothing outstanding. Stated so a reader cannot mistake "not sealing" for + /// "all sealed". + pub sealing_configured: bool, +} + +/// `GET /api/v1/seal` — operator-wide sealing state. +/// +/// Exists because the per-passport route cannot answer "is anything unsealed" +/// without the caller already knowing which passport to ask about, and the +/// gauges that do answer it are only reachable through Prometheus. +pub async fn seal_summary_handler( + State(state): State, + Extension(_auth): Extension, +) -> impl IntoResponse { + let Some(outbox) = state.service.seal_outbox.as_ref() else { + return ( + StatusCode::OK, + Json(SealSummaryResponse { + unsealed_published: 0, + pending: 0, + sealed: 0, + exhausted: 0, + sealing_configured: false, + }), + ) + .into_response(); + }; + + let counts = match outbox.status_counts().await { + Ok(c) => c, + Err(e) => return internal_error(e), + }; + let unsealed_published = match outbox.unsealed_published_count().await { + Ok(n) => n, + Err(e) => return internal_error(e), + }; + + ( + StatusCode::OK, + Json(SealSummaryResponse { + unsealed_published, + pending: counts.pending, + sealed: counts.sealed, + exhausted: counts.exhausted, + sealing_configured: true, + }), + ) + .into_response() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index dd4a14d..907765a 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -48,7 +48,7 @@ use crate::{ }, registry_status::{passport_registry_handler, registry_rollup_handler}, scan_ingest::{scan_ingest_handler, scan_ingest_mtls}, - seal::seal_handler, + seal::{seal_handler, seal_summary_handler}, stats::{operator_stats_handler, passport_stats_handler}, suspend::suspend_handler, transfer::{transfer_accept_handler, transfer_initiate_handler}, @@ -93,6 +93,9 @@ 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)) + // 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)) // ── Scan telemetry (aggregate, privacy-safe) ────────────────── .route("/dpp/{dppId}/stats", get(passport_stats_handler)) .route("/stats", get(operator_stats_handler)) diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index b566bed..dac7836 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -20,7 +20,7 @@ use testcontainers::{ use dpp_dal::pg::{ PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, PgRegistrySyncRepo, PgRegistryTransferRepo, PgScanTelemetryRepo, - PgWebhookRepo, sqlx, + PgSealOutboxRepo, PgWebhookRepo, sqlx, }; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -317,6 +317,10 @@ async fn start_vault_with_identity( // 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()))) .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))), ); let operator_service = Arc::new(OperatorService::new(operator_repo)); diff --git a/crates/dpp-vault/tests/seal_route.rs b/crates/dpp-vault/tests/seal_route.rs index a4fe12f..3b915f5 100644 --- a/crates/dpp-vault/tests/seal_route.rs +++ b/crates/dpp-vault/tests/seal_route.rs @@ -175,3 +175,65 @@ async fn the_route_requires_authentication() { .expect("request"); assert_eq!(resp.status(), 401); } + +/// The summary counts **passports**, not outbox rows, and this is the case that +/// makes the distinction load-bearing. +/// +/// `enqueue` runs after the publish commits, so a crash in that window leaves a +/// published passport with no row anywhere. An empty outbox is therefore fully +/// consistent with unsealed passports — and a summary built on row counts alone +/// would report all clear while the obligation went unmet. Seeding a published, +/// unsealed passport and no rows at all reproduces exactly that state. +#[tokio::test(flavor = "multi_thread")] +async fn the_summary_counts_unsealed_passports_not_outbox_rows() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + seed(&pg.dal, None, 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_eq!( + body["unsealedPublished"], 1, + "a published passport with no seal must be counted, with or without a row" + ); + assert_eq!( + body["pending"], 0, + "no row was ever enqueued — this is the lost-enqueue state" + ); + assert_eq!(body["exhausted"], 0); + assert_eq!( + body["sealingConfigured"], true, + "the harness wires an outbox, as any node with a provider does" + ); +} + +/// A sealed passport is not counted, and the count is what a reader acts on. +#[tokio::test(flavor = "multi_thread")] +async fn a_sealed_passport_is_not_reported_as_unsealed() { + 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_eq!(body["unsealedPublished"], 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_summary_route_requires_authentication() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{base}/api/v1/seal")) + .send() + .await + .expect("request"); + assert_eq!(resp.status(), 401); +}