xml-sec 0.1.21

Pure Rust XML Security: XMLDSig, XMLEnc, C14N. Drop-in replacement for libxmlsec1.
Documentation
#![cfg(feature = "xmlenc")]

use base64::{Engine as _, engine::general_purpose::STANDARD};
use xml_sec::policy::{DecryptionPolicy, KeyAgreementAlgorithm};
use xml_sec::provider::{CryptoProvider, EcdhCurve, RUST_CRYPTO_PROVIDER, RustCryptoEcdhKey};
use xml_sec::xmlenc::{
    AgreementDecryptor, AgreementMethod, DataEncryptionAlgorithm, DecryptContext, DecryptedContent,
    EncryptedDataBuilder, KeyEstablishmentBudget, SymmetricKeyDecryptor, parse_encrypted_data,
    parse_key_derivation_method,
};

fn method() -> xml_sec::xmlenc::KeyDerivationMethod {
    parse_key_derivation_method(
        r#"<x:KeyDerivationMethod xmlns:x="http://www.w3.org/2009/xmlenc11#" Algorithm="http://www.w3.org/2009/xmlenc11#ConcatKDF"><x:ConcatKDFParams AlgorithmID="00636970686572" PartyUInfo="00616c696365" PartyVInfo="00626f62"><d:DigestMethod xmlns:d="http://www.w3.org/2000/09/xmldsig#" Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/></x:ConcatKDFParams></x:KeyDerivationMethod>"#,
        &DecryptionPolicy::default(),
    ).unwrap()
}

fn expected() -> AgreementMethod {
    AgreementMethod {
        algorithm: KeyAgreementAlgorithm::EcdhEs,
        method: Some(method()),
        nonce: Vec::new(),
        legacy_digest: None,
        originator: None,
        recipient: None,
    }
}

#[test]
fn ecdh_sender_is_single_use_across_builder_clones() {
    // A new ciphertext is a new message, even through a cloned builder;
    // XMLEnc ECDH-ES must not reuse its originator private key.
    let sender = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[3; 32]).unwrap();
    let recipient = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[5; 32]).unwrap();
    let builder = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm).agreement_key(
        method(),
        Box::new(sender),
        KeyAgreementAlgorithm::EcdhEs,
        recipient.public_key(),
    );
    let clone = builder.clone();
    assert!(builder.encrypt_binary(b"first message").is_ok());
    assert!(builder.encrypt_binary(b"second message").is_err());
    assert!(clone.encrypt_binary(b"cloned message").is_err());
}

#[test]
fn ecdh_preflight_rejection_does_not_consume_the_sender() {
    // Policy/resource rejection precedes any key execution; a corrected
    // request may still use the pair, but successful dispatch consumes it.
    let sender = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[3; 32]).unwrap();
    let recipient = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[5; 32]).unwrap();
    let builder = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm).agreement_key(
        method(),
        Box::new(sender),
        KeyAgreementAlgorithm::EcdhEs,
        recipient.public_key(),
    );
    let mut policy = xml_sec::policy::EncryptionPolicy::default();
    policy.key_establishment.agreement_algorithms = Some(Default::default());
    assert!(
        builder
            .clone()
            .policy(policy)
            .encrypt_binary(b"denied")
            .is_err()
    );
    let mut policy = xml_sec::policy::EncryptionPolicy::default();
    policy.key_establishment.max_owned_bytes = 0;
    assert!(
        builder
            .clone()
            .policy(policy)
            .encrypt_binary(b"exhausted")
            .is_err()
    );
    assert!(builder.encrypt_binary(b"permitted").is_ok());
}

#[test]
fn ecdh_failed_dispatch_consumes_the_sender() {
    use std::sync::{
        Arc,
        atomic::{AtomicUsize, Ordering},
    };
    use xml_sec::provider::{KeyAgreementKey, KeyAgreementParameters, ProviderError};
    struct FailingKey(Arc<AtomicUsize>);
    impl KeyAgreementKey for FailingKey {
        fn agree(&self, _: &KeyAgreementParameters<'_>) -> Result<Vec<u8>, ProviderError> {
            self.0.fetch_add(1, Ordering::Relaxed);
            Err(ProviderError::AuthenticationFailed)
        }
    }
    // A failure after dispatch is not permission to repeat the same scalar
    // multiplication. Concurrent clones must attempt the primitive only once.
    let calls = Arc::new(AtomicUsize::new(0));
    let recipient = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[5; 32]).unwrap();
    let builder = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm).agreement_key(
        method(),
        Box::new(FailingKey(calls.clone())),
        KeyAgreementAlgorithm::EcdhEs,
        recipient.public_key(),
    );
    let clone = builder.clone();
    std::thread::scope(|scope| {
        let first = scope.spawn(|| builder.encrypt_binary(b"first"));
        let second = scope.spawn(|| clone.encrypt_binary(b"second"));
        assert!(first.join().unwrap().is_err());
        assert!(second.join().unwrap().is_err());
    });
    assert_eq!(calls.load(Ordering::Relaxed), 1);
}

#[test]
fn dh_agreement_roles_require_complete_parameter_groups() {
    // XMLEnc 1.1 ยง5.6.1 permits application-supplied domain parameters,
    // but never a partial P/Q/Generator or seed/pgenCounter sequence.
    for parameters in [
        "",
        "<x:P>Fw==</x:P><x:Q>Cw==</x:Q><x:Generator>Ag==</x:Generator>",
    ] {
        let wire = format!(
            "<x:EncryptedData xmlns:x='http://www.w3.org/2001/04/xmlenc#' xmlns:d='http://www.w3.org/2000/09/xmldsig#'><x:EncryptionMethod Algorithm='http://www.w3.org/2009/xmlenc11#aes128-gcm'/><d:KeyInfo><x:AgreementMethod Algorithm='http://www.w3.org/2009/xmlenc11#dh-es'><x:OriginatorKeyInfo><d:KeyValue><x:DHKeyValue>{parameters}<x:Public>CA==</x:Public></x:DHKeyValue></d:KeyValue></x:OriginatorKeyInfo></x:AgreementMethod></d:KeyInfo><x:CipherData><x:CipherValue>AQ==</x:CipherValue></x:CipherData></x:EncryptedData>"
        );
        let parsed = parse_encrypted_data(&wire).unwrap();
        let role = parsed.agreement_methods[0].originator.as_ref().unwrap();
        let xml_sec::xmldsig::parse::KeyInfoSource::KeyValue(
            xml_sec::xmldsig::parse::KeyValueInfo::Dh {
                p,
                q,
                generator,
                public,
                seed,
                pgen_counter,
            },
        ) = &role.sources[0]
        else {
            panic!("DH role was not preserved: {role:?}")
        };
        assert_eq!(public, &[8]);
        if parameters.is_empty() {
            assert_eq!((p, q, generator), (&None, &None, &None));
        } else {
            assert_eq!(p.as_deref(), Some([23].as_slice()));
            assert_eq!(q.as_deref(), Some([11].as_slice()));
            assert_eq!(generator.as_deref(), Some([2].as_slice()));
        }
        assert_eq!((seed, pgen_counter), (&None, &None));
        for bad in [
            "<x:P>Fw==</x:P>",
            "<x:Q>Cw==</x:Q>",
            "<x:Generator>Ag==</x:Generator>",
        ] {
            assert!(
                parse_encrypted_data(&wire.replace(
                    &format!("{parameters}<x:Public>"),
                    &format!("{bad}<x:Public>")
                ))
                .is_err()
            );
        }
        if !parameters.is_empty() {
            for suffix in [
                "<x:seed>AQ==</x:seed>",
                "<x:pgenCounter>AQ==</x:pgenCounter>",
            ] {
                assert!(
                    parse_encrypted_data(
                        &wire.replace("</x:Public>", &format!("</x:Public>{suffix}"))
                    )
                    .is_err()
                );
            }
        }
    }
}

#[test]
fn agreement_role_metadata_obeys_encryption_policy_before_retention() {
    // Role KeyName is encryption metadata too; the shared XMLDSig parser's
    // larger safety ceiling must not override this operation's tighter policy.
    let (wire, _, _) = encrypted();
    let wire = wire.replace("</xenc:AgreementMethod>", &format!("<xenc:OriginatorKeyInfo><ds:KeyName>{}</ds:KeyName></xenc:OriginatorKeyInfo></xenc:AgreementMethod>", "k".repeat(81)));
    let mut policy = DecryptionPolicy::default();
    policy.resources.max_encryption_metadata_bytes = 80;
    assert!(matches!(
        DecryptContext::new(&SymmetricKeyDecryptor::new(vec![0; 16]))
            .policy(policy)
            .decrypt(&wire),
        Err(xml_sec::xmlenc::XmlEncError::Policy(
            xml_sec::policy::PolicyViolation::ResourceLimit {
                resource: "encryption metadata bytes",
                ..
            }
        ))
    ));
}

#[test]
fn agreement_roles_share_the_candidate_allowance_with_their_descriptor() {
    // A descriptor plus its embedded public key costs two candidates, not two
    // independent allowances. Failure precedes decoding the malformed key.
    let (wire, _, _) = encrypted();
    let wire = wire.replace("</xenc:AgreementMethod>", "<xenc:OriginatorKeyInfo><ds:KeyValue><ds:RSAKeyValue><ds:Modulus>invalid</ds:Modulus><ds:Exponent>AQAB</ds:Exponent></ds:RSAKeyValue></ds:KeyValue></xenc:OriginatorKeyInfo></xenc:AgreementMethod>");
    let mut policy = DecryptionPolicy::default();
    policy.resources.max_key_candidates = 1;
    assert!(matches!(
        DecryptContext::new(&SymmetricKeyDecryptor::new(vec![0; 16]))
            .policy(policy)
            .decrypt(&wire),
        Err(xml_sec::xmlenc::XmlEncError::Policy(
            xml_sec::policy::PolicyViolation::ResourceLimit {
                maximum: 1,
                actual: 2,
                ..
            }
        ))
    ));
}

fn encrypted() -> (String, RustCryptoEcdhKey, Vec<u8>) {
    let sender = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[3; 32]).unwrap();
    let recipient = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[5; 32]).unwrap();
    let descriptor = expected();
    let policy = DecryptionPolicy::default();
    let mut budget = KeyEstablishmentBudget::new(&policy.key_establishment).unwrap();
    let key = descriptor
        .derive_key(
            DataEncryptionAlgorithm::Aes128Gcm.uri(),
            16,
            &sender,
            &recipient.public_key(),
            &RUST_CRYPTO_PROVIDER,
            &mut budget,
        )
        .unwrap();
    let wire = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm)
        .direct_key(key.to_vec())
        .encrypt_binary(b"agreement XML pipeline")
        .unwrap();
    let agreement = format!(
        "<ds:KeyInfo><xenc:AgreementMethod Algorithm=\"{}\">mixed text{}<!-- permitted trivia --></xenc:AgreementMethod></ds:KeyInfo>",
        descriptor.algorithm.uri(),
        descriptor
            .method
            .as_ref()
            .unwrap()
            .to_xml(&policy.resources)
            .unwrap(),
    );
    (
        wire.encrypted_data_xml.replace(
            "<xenc:CipherData>",
            &format!("{agreement}<xenc:CipherData>"),
        ),
        recipient,
        sender.public_key(),
    )
}

#[test]
fn xml_agreement_gates_content_key_and_preserves_mixed_content() {
    // Optional parties can be application-bound; transported agreement still
    // prevents a raw key resolver from bypassing the required derivation.
    let (wire, recipient, peer) = encrypted();
    let descriptor = expected();
    let parsed = parse_encrypted_data(&wire).unwrap();
    assert_eq!(parsed.agreement_methods, vec![descriptor.clone()]);
    let resolver = AgreementDecryptor::content(
        &descriptor,
        &recipient,
        &peer,
        DataEncryptionAlgorithm::Aes128Gcm,
    );
    assert_eq!(
        DecryptContext::new(&resolver).decrypt(&wire).unwrap(),
        DecryptedContent::Bytes(b"agreement XML pipeline".to_vec())
    );
    assert!(
        DecryptContext::new(&SymmetricKeyDecryptor::new(vec![0; 16]))
            .decrypt(&wire)
            .is_err()
    );
}

#[test]
fn xml_agreement_rejects_party_substitution_and_shared_budget_exhaustion() {
    // Even structurally valid changed KDF party information is not a trusted
    // expectation; candidate and provider work share one operation budget.
    let (wire, recipient, peer) = encrypted();
    let descriptor = expected();
    let resolver = AgreementDecryptor::content(
        &descriptor,
        &recipient,
        &peer,
        DataEncryptionAlgorithm::Aes128Gcm,
    );
    assert!(
        DecryptContext::new(&resolver)
            .decrypt(&wire.replace("00626F62", "00657665"))
            .is_err()
    );
    let mut policy = DecryptionPolicy::default();
    policy.resources.max_key_candidates = 1;
    assert!(
        DecryptContext::new(&resolver)
            .policy(policy)
            .decrypt(&wire)
            .is_err()
    );
}

#[test]
fn xml_agreement_rejects_unordered_or_duplicate_wire_parameters() {
    // AgreementMethod's optional schema fields still have an exact sequence.
    let (wire, _, _) = encrypted();
    for extra in [
        "<xenc:KA-Nonce>AA==</xenc:KA-Nonce>",
        "<xenc:RecipientKeyInfo/><xenc:OriginatorKeyInfo/>",
        "<xenc:RecipientKeyInfo/><xenc:RecipientKeyInfo/>",
    ] {
        let invalid = wire.replace(
            "</xenc:AgreementMethod>",
            &format!("{extra}</xenc:AgreementMethod>"),
        );
        assert!(parse_encrypted_data(&invalid).is_err());
    }
}

#[test]
fn xml_recipient_agreement_derives_a_kek_before_unwrapping_content() {
    // Same-width content and wrapping keys are not interchangeable purposes.
    // The XML descriptor must gate agreement, KDF, and final unwrap together.
    let sender = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[3; 32]).unwrap();
    let recipient = RustCryptoEcdhKey::from_scalar(EcdhCurve::P256, &[5; 32]).unwrap();
    let descriptor = expected();
    let policy = DecryptionPolicy::default();
    let wrap = xml_sec::xmlenc::KeyWrapAlgorithm::AesKw128;
    let mut budget = KeyEstablishmentBudget::new(&policy.key_establishment).unwrap();
    let kek = descriptor
        .derive_key(
            wrap.uri(),
            wrap.key_len(),
            &sender,
            &recipient.public_key(),
            &RUST_CRYPTO_PROVIDER,
            &mut budget,
        )
        .unwrap();
    let content = [3; 16];
    let wrapped = RUST_CRYPTO_PROVIDER.wrap_key(wrap, &kek, &content).unwrap();
    let generated = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm)
        .direct_key(content)
        .encrypt_binary(b"agreement recipient")
        .unwrap();
    let key_info = format!(
        "<ds:KeyInfo><xenc:EncryptedKey><xenc:EncryptionMethod Algorithm='{}'/><ds:KeyInfo><xenc:AgreementMethod Algorithm='{}'>{}</xenc:AgreementMethod></ds:KeyInfo><xenc:CipherData><xenc:CipherValue>{}</xenc:CipherValue></xenc:CipherData></xenc:EncryptedKey></ds:KeyInfo>",
        wrap.uri(),
        descriptor.algorithm.uri(),
        descriptor
            .method
            .as_ref()
            .unwrap()
            .to_xml(&policy.resources)
            .unwrap(),
        STANDARD.encode(wrapped)
    );
    let wire = generated.encrypted_data_xml.replacen(
        "<xenc:CipherData>",
        &format!("{key_info}<xenc:CipherData>"),
        1,
    );
    let peer = sender.public_key();
    let resolver = AgreementDecryptor::wrapping(&descriptor, &recipient, &peer, wrap);
    assert_eq!(
        DecryptContext::new(&resolver).decrypt(&wire).unwrap(),
        DecryptedContent::Bytes(b"agreement recipient".to_vec())
    );
    assert!(
        DecryptContext::new(&xml_sec::xmlenc::KekDecryptor::new(kek.to_vec()))
            .decrypt(&wire)
            .is_err()
    );
    let mut limited = policy;
    limited.resources.max_key_candidates = 2;
    assert!(matches!(
        DecryptContext::new(&resolver)
            .policy(limited)
            .decrypt(&wire),
        Err(xml_sec::xmlenc::XmlEncError::Policy(_))
    ));
}