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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ci-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion tests/wasm-smoke/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
70 changes: 70 additions & 0 deletions tests/wasm-smoke/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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<SignatureAnswer> {
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),
)
}
13 changes: 9 additions & 4 deletions tools/xml-sec-tools/src/wasm_smoke.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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",
Expand All @@ -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`.
Expand Down
40 changes: 29 additions & 11 deletions tools/xml-sec-tools/src/wasm_smoke/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,43 @@ fn module(exports: &[(&str, u8)]) -> Vec<u8> {
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<usize>) -> Vec<u8> {
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());
}

Expand Down
Loading