use std::fs;
use base64::Engine;
use xml_sec::c14n::{C14nAlgorithm, C14nMode};
use xml_sec::xmldsig::{
BASE64_TRANSFORM_URI, DefaultKeyResolver, DigestAlgorithm, DsigError, DsigStatus,
FailureReason, ReferenceBuilder, ReferenceProcessingError, RsaSigningKey, SignContext,
SignatureAlgorithm, SignatureBuilder, SigningDigestError, SigningError, Transform,
TransformError, VerifyContext, X509CertificateKeyInfoWriter, XPathExpression, XPathFilter,
XPathFilterOperation,
};
const ENCODED_XML: &str = r#"<root>
<Encoded ID="payload">ZXh0<!-- split --><Chunk>ZXJuYWwg</Chunk><?ignored data?>dmVyaWZpZXIgY29udHJhY3Q=</Encoded>
</root>"#;
const DECODED_XML_BASE64: &str =
"PHBheWxvYWQ+PGtlZXA+Y292ZXJlZDwva2VlcD48ZHJvcD5tdXRhYmxlPC9kcm9wPjwvcGF5bG9hZD4=";
const DECODED_EXCLUDED_TAMPER_BASE64: &str =
"PHBheWxvYWQ+PGtlZXA+Y292ZXJlZDwva2VlcD48ZHJvcD5jaGFuZ2VkPC9kcm9wPjwvcGF5bG9hZD4=";
const DECODED_INCLUDED_TAMPER_BASE64: &str =
"PHBheWxvYWQ+PGtlZXA+dGFtcGVyZWQ8L2tlZXA+PGRyb3A+bXV0YWJsZTwvZHJvcD48L3BheWxvYWQ+";
fn exclusive_c14n() -> C14nAlgorithm {
C14nAlgorithm::new(C14nMode::Exclusive1_0, false)
}
fn signing_material() -> (RsaSigningKey, X509CertificateKeyInfoWriter) {
let private_key = fs::read_to_string("tests/fixtures/keys/rsa/rsa-2048-key.pem")
.expect("RSA private-key fixture must load");
let certificate = fs::read_to_string("tests/fixtures/keys/rsa/rsa-2048-cert.pem")
.expect("RSA certificate fixture must load");
(
RsaSigningKey::from_pkcs8_pem(&private_key).expect("RSA private key must parse"),
X509CertificateKeyInfoWriter::from_pem(&certificate).expect("RSA certificate must parse"),
)
}
fn base64_signature_builder() -> SignatureBuilder {
SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode),
)
.key_info(true)
}
fn sign_encoded_xml(xml: &str) -> String {
let (key, key_info) = signing_material();
SignContext::new(&key)
.key_info_writer(&key_info)
.sign_with_builder(xml, &base64_signature_builder())
.expect("Base64 reference must sign")
}
#[test]
fn base64_reference_round_trips_through_signing_and_verification() {
let signed = sign_encoded_xml(ENCODED_XML);
assert!(signed.contains(&format!("Algorithm=\"{BASE64_TRANSFORM_URI}\"")));
let resolver = DefaultKeyResolver::default();
let verified = VerifyContext::new()
.key_resolver(&resolver)
.allowed_transforms([BASE64_TRANSFORM_URI])
.store_pre_digest(true)
.verify(&signed)
.expect("signed Base64 reference must verify");
assert_eq!(verified.status, DsigStatus::Valid);
assert_eq!(verified.signed_info_references.len(), 1);
assert_eq!(
verified.signed_info_references[0]
.pre_digest_data
.as_deref(),
Some(b"external verifier contract".as_slice())
);
}
#[test]
fn base64_reference_tampering_is_a_digest_mismatch() {
let signed = sign_encoded_xml(ENCODED_XML);
let tampered = signed.replacen(
"dmVyaWZpZXIgY29udHJhY3Q=",
"dGFtcGVyZWQgY29udGVudCAgICAgICA=",
1,
);
let resolver = DefaultKeyResolver::default();
let verified = VerifyContext::new()
.key_resolver(&resolver)
.verify(&tampered)
.expect("well-formed tampered Base64 must complete verification");
assert_eq!(
verified.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
);
}
#[test]
fn malformed_base64_reference_fails_before_digest_comparison() {
let signed = sign_encoded_xml(ENCODED_XML);
let malformed = signed.replacen("dmVyaWZpZXIgY29udHJhY3Q=", "not!base64", 1);
let resolver = DefaultKeyResolver::default();
let error = VerifyContext::new()
.key_resolver(&resolver)
.verify(&malformed)
.expect_err("malformed Base64 transform input must fail closed");
assert!(matches!(
error,
DsigError::Reference(ReferenceProcessingError::Transform(TransformError::Base64(
_
)))
));
}
#[test]
fn decoded_xml_is_adapted_to_a_node_set_for_xpath() {
let document = format!(r#"<root><Encoded ID="payload">{DECODED_XML_BASE64}</Encoded></root>"#);
let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode)
.transform(Transform::XPath(XPathExpression::new(
"not(self::drop or ancestor::drop)",
))),
)
.key_info(true);
let (key, key_info) = signing_material();
let signed = SignContext::new(&key)
.key_info_writer(&key_info)
.sign_with_builder(&document, &builder)
.expect("Base64-to-XPath transform chain must sign");
let resolver = DefaultKeyResolver::default();
let verified = VerifyContext::new()
.key_resolver(&resolver)
.verify(&signed)
.expect("Base64-to-XPath signature must verify");
assert_eq!(verified.status, DsigStatus::Valid);
let excluded = signed.replacen(DECODED_XML_BASE64, DECODED_EXCLUDED_TAMPER_BASE64, 1);
let excluded_result = VerifyContext::new()
.key_resolver(&resolver)
.verify(&excluded)
.expect("excluded decoded XML tampering remains processable");
assert_eq!(excluded_result.status, DsigStatus::Valid);
let included = signed.replacen(DECODED_XML_BASE64, DECODED_INCLUDED_TAMPER_BASE64, 1);
let included_result = VerifyContext::new()
.key_resolver(&resolver)
.verify(&included)
.expect("included decoded XML tampering remains processable");
assert_eq!(
included_result.status,
DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 })
);
}
#[test]
fn decoded_xml_is_adapted_for_xpath_filter2() {
let document = format!(r#"<root><Encoded ID="payload">{DECODED_XML_BASE64}</Encoded></root>"#);
let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode)
.transform(Transform::XPathFilter2(vec![XPathFilter::new(
XPathFilterOperation::Intersect,
XPathExpression::new("/payload/keep"),
)])),
)
.key_info(true);
let (key, key_info) = signing_material();
let signed = SignContext::new(&key)
.key_info_writer(&key_info)
.sign_with_builder(&document, &builder)
.expect("Base64-to-Filter2 transform chain must sign");
let resolver = DefaultKeyResolver::default();
let result = VerifyContext::new()
.key_resolver(&resolver)
.verify(&signed)
.expect("Base64-to-Filter2 signature must verify");
assert_eq!(result.status, DsigStatus::Valid);
}
#[test]
fn decoded_utf16_xml_is_adapted_to_a_node_set() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><payload><keep>covered</keep></payload>";
let code_units = xml.encode_utf16().collect::<Vec<_>>();
let encoded_inputs = [
std::iter::once(0xff)
.chain(std::iter::once(0xfe))
.chain(code_units.iter().flat_map(|unit| unit.to_le_bytes()))
.collect::<Vec<_>>(),
std::iter::once(0xfe)
.chain(std::iter::once(0xff))
.chain(code_units.iter().flat_map(|unit| unit.to_be_bytes()))
.collect::<Vec<_>>(),
];
let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode)
.transform(Transform::XPath(XPathExpression::new("true()"))),
)
.key_info(true);
let (key, key_info) = signing_material();
let resolver = DefaultKeyResolver::default();
for bytes in encoded_inputs {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let document = format!(r#"<root><Encoded ID="payload">{encoded}</Encoded></root>"#);
let signed = SignContext::new(&key)
.key_info_writer(&key_info)
.sign_with_builder(&document, &builder)
.expect("BOM-marked UTF-16 XML must enter the XPath node-set pipeline");
let verified = VerifyContext::new()
.key_resolver(&resolver)
.verify(&signed)
.expect("UTF-16 transform chain must verify");
assert_eq!(verified.status, DsigStatus::Valid);
}
}
#[test]
fn binary_to_node_set_adapter_rejects_malformed_xml() {
let document = r#"<root><Encoded ID="payload">PHBheWxvYWQ+</Encoded></root>"#;
let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode)
.transform(Transform::XPath(XPathExpression::new("true()"))),
);
let (key, _) = signing_material();
let error = SignContext::new(&key)
.sign_with_builder(document, &builder)
.expect_err("malformed decoded XML must fail closed");
assert!(matches!(
error,
SigningError::Digest(SigningDigestError::Transform(TransformError::XmlParse(_)))
));
}
#[test]
fn decoded_xml_rejects_here_from_the_signature_document() {
let document = format!(r#"<root><Encoded ID="payload">{DECODED_XML_BASE64}</Encoded></root>"#);
let builder = SignatureBuilder::new(exclusive_c14n(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#payload")
.transform(Transform::Base64Decode)
.transform(Transform::XPath(XPathExpression::new(
"count(. | here()) = 1",
))),
);
let (key, _) = signing_material();
let error = SignContext::new(&key)
.sign_with_builder(&document, &builder)
.expect_err("cross-document here() must fail closed");
let SigningError::Digest(SigningDigestError::Transform(TransformError::XPath(message))) = error
else {
panic!("expected cross-document XPath error");
};
assert!(
message.contains("same XML document"),
"error must state the normative cross-document here() requirement: {message}"
);
}