From 2471031755a288b09a3ede29ce74eb4623fd36b1 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 00:28:30 +0200 Subject: [PATCH 1/2] test(cms): execute the CMS provider in WebAssembly The cms feature was only compiled for targets without std. wasm-smoke now runs an RFC 3161 token through CmsProvider in the compiled module: the token verifies, and the same token with an altered signature value is an invalid signature rather than an error. The check enumerates all exports, so a missing one fails it. Closes #344 --- tests/wasm-smoke/Cargo.toml | 5 +- tests/wasm-smoke/src/lib.rs | 70 +++++++++++++++++++++ tools/xml-sec-tools/src/wasm_smoke.rs | 13 ++-- tools/xml-sec-tools/src/wasm_smoke/tests.rs | 40 ++++++++---- 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/tests/wasm-smoke/Cargo.toml b/tests/wasm-smoke/Cargo.toml index 782d63c3..e70616b0 100644 --- a/tests/wasm-smoke/Cargo.toml +++ b/tests/wasm-smoke/Cargo.toml @@ -10,4 +10,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -xml-sec = { path = "../..", default-features = false, features = ["c14n", "xmldsig", "xml-backend-roxmltree"] } +xml-sec = { path = "../..", default-features = false, features = ["c14n", "cms", "xml-backend-roxmltree"] } +structured-asn1 = { version = "0.18.0", default-features = false, features = ["alloc"] } +structured-cms = { version = "0.18.0", default-features = false, features = ["alloc"] } +structured-pkix = { version = "0.18.0", default-features = false, features = ["alloc"] } diff --git a/tests/wasm-smoke/src/lib.rs b/tests/wasm-smoke/src/lib.rs index b8b77e35..75907468 100644 --- a/tests/wasm-smoke/src/lib.rs +++ b/tests/wasm-smoke/src/lib.rs @@ -1,12 +1,28 @@ //! Executable qualification of the alloc-only WASM product paths. //! This separate consumer excludes native-only development dependencies. +use std::sync::Arc; + +use structured_asn1::Parser; +use structured_asn1::memory::{Memory, MemoryAccount}; +use structured_cms::provider::SignatureAnswer; +use structured_cms::timestamp::{CheckContext, ImprintAgreement, Token, TokenSignature}; +use structured_pkix::Certificate; +use structured_pkix::path::WorkingKey; +use structured_pkix::temporal::{Instant, LeapTable}; use xml_sec::c14n::{C14nAlgorithm, C14nMode, canonicalize_xml}; +use xml_sec::policy::{CertificateSignatureAlgorithms, VerificationPolicy}; +use xml_sec::provider::RustCryptoProvider; +use xml_sec::xmldsig::cms::CmsProvider; use xml_sec::xmldsig::{DsigStatus, FailureReason, verify_signature_with_pem_key}; /// A donor SAML 2.0 response signed by its identity provider. const SIGNED_RESPONSE: &str = include_str!("../../fixtures/saml/response_signed_by_idp_ecdsa.xml"); const SIGNER_KEY: &str = include_str!("../../fixtures/keys/ec/saml-idp-ecdsa-pubkey.pem"); +/// An RFC 3161 token from an independent authority, with its certificate. +const TOKEN: &[u8] = include_bytes!("../../fixtures/cms/timestamp/ed25519.tst"); +const TOKEN_CERTIFICATE: &[u8] = include_bytes!("../../fixtures/cms/timestamp/ed25519.der"); +const TOKEN_PAYLOAD: &[u8] = include_bytes!("../../fixtures/cms/timestamp/payload.txt"); /// Called in the compiled WASM module, not a native substitute. #[unsafe(no_mangle)] @@ -35,3 +51,57 @@ pub extern "C" fn xml_sec_wasm_verify() -> u32 { ); u32::from(valid && rejected) } + +/// The signature answer for `token`, if its check completed with a matching +/// imprint and, for a valid signature, retained evidence. +fn check_token(token: &[u8]) -> Option { + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::ber(memory.clone()); + let certificate_parser = Parser::der(memory); + let token = Token::read(&parser, token, 0).ok()?; + let parent = parser.read(&[], 0).ok()?; + let certificate = Certificate::read(&certificate_parser, TOKEN_CERTIFICATE, 0).ok()?; + let certificate_parent = certificate_parser.read(&[], 0).ok()?; + let utc = LeapTable::new(0, 2_000_000_000, &[]).ok()?; + let mut policy = VerificationPolicy::default(); + policy.key_trust.certificate_signature_algorithms = + Some(CertificateSignatureAlgorithms::AllSupported); + let provider = RustCryptoProvider; + let cms = CmsProvider::new(&provider, &policy); + let context = CheckContext { + certificate: &certificate, + certificate_parent: &certificate_parent, + key: WorkingKey::standing(certificate.tbs().public_key()), + requested: None, + utc: &utc, + reference: Some(Instant::new(1_800_000_000, 0)?), + window: None, + }; + let checked = token + .check(&cms, &parent, &context, &mut [TOKEN_PAYLOAD], |_, _| { + Ok::<_, ()>(false) + }) + .ok()?; + let TokenSignature::Checked(signature) = &checked.signature else { + return None; + }; + let answer = signature.signature()?; + let consistent = checked.imprint == ImprintAgreement::Matches + && checked.evidence().is_some() == (answer == SignatureAnswer::Valid); + consistent.then_some(answer) +} + +/// RFC 3161 timestamp verification through the selected provider in the +/// compiled WASM module: the token verifies, and the same token with an +/// altered signature value is an invalid signature rather than an error. +#[unsafe(no_mangle)] +pub extern "C" fn xml_sec_wasm_cms() -> u32 { + let mut tampered = TOKEN.to_vec(); + if let Some(last) = tampered.last_mut() { + *last ^= 0x01; + } + u32::from( + check_token(TOKEN) == Some(SignatureAnswer::Valid) + && check_token(&tampered) == Some(SignatureAnswer::Invalid), + ) +} diff --git a/tools/xml-sec-tools/src/wasm_smoke.rs b/tools/xml-sec-tools/src/wasm_smoke.rs index 6d4e911c..5525bf45 100644 --- a/tools/xml-sec-tools/src/wasm_smoke.rs +++ b/tools/xml-sec-tools/src/wasm_smoke.rs @@ -1,6 +1,6 @@ -//! `wasm-smoke`: builds the WebAssembly consumer and executes its C14N and -//! signature verification paths, so the check is a run of the compiled module -//! rather than a successful compilation. +//! `wasm-smoke`: builds the WebAssembly consumer and executes its C14N, +//! signature verification and CMS timestamp paths, so the check is a run of +//! the compiled module rather than a successful compilation. use crate::repo::{Failure, Run, Step, System, require, root}; use std::path::Path; @@ -10,7 +10,7 @@ mod tests; /// Each export, the failure it reports when it does not return 1, and the /// line printed when it does. -const EXPORTS: [(&str, &str, &str); 2] = [ +const EXPORTS: [(&str, &str, &str); 3] = [ ( "xml_sec_wasm_smoke", "WASM canonicalization must produce exact expected bytes", @@ -21,6 +21,11 @@ const EXPORTS: [(&str, &str, &str); 2] = [ "WASM XMLDSig verification must accept the signed response and reject tampering", "WASM XMLDSig verification passed", ), + ( + "xml_sec_wasm_cms", + "WASM CMS timestamp check must accept the token and reject tampering", + "WASM CMS timestamp check passed", + ), ]; /// The build of the smoke consumer for `wasm32-unknown-unknown`. diff --git a/tools/xml-sec-tools/src/wasm_smoke/tests.rs b/tools/xml-sec-tools/src/wasm_smoke/tests.rs index e5255600..7281ad1d 100644 --- a/tools/xml-sec-tools/src/wasm_smoke/tests.rs +++ b/tools/xml-sec-tools/src/wasm_smoke/tests.rs @@ -25,25 +25,43 @@ fn module(exports: &[(&str, u8)]) -> Vec { bytes } -const BOTH: [&str; 2] = ["xml_sec_wasm_smoke", "xml_sec_wasm_verify"]; +const ALL: [&str; 3] = [ + "xml_sec_wasm_smoke", + "xml_sec_wasm_verify", + "xml_sec_wasm_cms", +]; + +/// A module where the export at `failing`, if any, reports failure. +fn answering(failing: Option) -> Vec { + let exports = [0, 1, 2].map(|index| (ALL[index], u8::from(Some(index) != failing))); + module(&exports) +} #[test] -fn both_compiled_exports_must_report_success() { +fn every_compiled_export_must_report_success() { // The check is each export's own answer, not that the module loaded. - assert!(check_module(&module(&[(BOTH[0], 1), (BOTH[1], 1)])).is_ok()); - let error = check_module(&module(&[(BOTH[0], 0), (BOTH[1], 1)])).unwrap_err(); - assert!(error.contains("canonicalization"), "{error}"); - let error = check_module(&module(&[(BOTH[0], 1), (BOTH[1], 0)])).unwrap_err(); - assert!(error.contains("XMLDSig verification"), "{error}"); + assert!(check_module(&answering(None)).is_ok()); + for (index, failure) in ["canonicalization", "XMLDSig verification", "CMS timestamp"] + .into_iter() + .enumerate() + { + let error = check_module(&answering(Some(index))).unwrap_err(); + assert!(error.contains(failure), "{error}"); + } } #[test] fn a_module_without_an_export_fails() { // A renamed or stripped export cannot pass as an executed check. - let error = check_module(&module(&[(BOTH[0], 1)])).unwrap_err(); - assert!(error.contains("xml_sec_wasm_verify"), "{error}"); - let error = check_module(&module(&[(BOTH[1], 1)])).unwrap_err(); - assert!(error.contains("xml_sec_wasm_smoke"), "{error}"); + for missing in ALL { + let exports: Vec<_> = ALL + .into_iter() + .filter(|&name| name != missing) + .map(|name| (name, 1)) + .collect(); + let error = check_module(&module(&exports)).unwrap_err(); + assert!(error.contains(missing), "{error}"); + } assert!(check_module(b"not wasm").is_err()); } From 0b51a7ebdf2581ede375af59921f5ace63d0050e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 00:28:46 +0200 Subject: [PATCH 2/2] docs(ci): describe the alloc-only and CMS WASM gates --- docs/ci-hardening.md | 4 ++-- docs/features.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/ci-hardening.md b/docs/ci-hardening.md index 0da710a8..b028cb68 100644 --- a/docs/ci-hardening.md +++ b/docs/ci-hardening.md @@ -149,8 +149,8 @@ with their original independent validators and separate evidence artifacts. Shared mechanisms are resolved as one registry version across ASN.1, PKIX, CMS, LTV and XAdES. The `shared-pkix` tool command rejects duplicate generations and path/git substitutes, then independently checks the PKIX and XAdES alloc graphs -on bare-metal and WASM targets. Core XMLDSig still requires `std`; the shared -dependency qualification is not a root alloc-only claim. +on bare-metal and WASM targets. The root package's own alloc-only checks are +separate; see [feature profiles](features.md). Custom resolvers and other consumers using the exposed shared memory or PKIX types must upgrade their direct shared dependencies together with xml-sec. diff --git a/docs/features.md b/docs/features.md index 52e49b15..803404c5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -132,8 +132,8 @@ Passing the portable Windows core does not claim Windows native-provider or oracle qualification. The required aggregate test check includes the feature-contract jobs. The -alloc-only job is an independent mandatory gate: it lints XML input, C14N and -XMLDSig verification for `thumbv7em-none-eabihf` with each XML backend, runs the +alloc-only job is an independent mandatory gate: it lints XML input, C14N, +XMLDSig verification and the `cms` provider for `thumbv7em-none-eabihf`, runs the target-coverage tests against the build without `std`, checks the shared PKIX -packages, and executes C14N and a real XMLDSig verification in WASM. This is a meaningful feature/provider -matrix, not a claim that every subset of all Cargo features has been tested. +packages, and executes C14N, a real XMLDSig verification and a real RFC 3161 +timestamp check in WASM. This is a meaningful feature/provider matrix, not a claim that every subset of all Cargo features has been tested.