use std::{
fs,
io::Write as _,
path::Path,
process::{Command, Stdio},
};
use base64::Engine as _;
use der::{Encode as _, asn1::UintRef};
use p256::SecretKey as P256SecretKey;
use p384::SecretKey as P384SecretKey;
use p521::SecretKey as P521SecretKey;
use pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _, EncodePublicKey as _};
use rand_chacha::{ChaCha8Rng, rand_core::SeedableRng as _};
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, IsCa, Issuer,
KeyIdMethod, KeyPair, KeyUsagePurpose, RevokedCertParams, SerialNumber,
};
use rsa::{RsaPrivateKey, RsaPublicKey, traits::PublicKeyParts as _};
use time::{Duration, OffsetDateTime};
use x509_parser::{extensions::ParsedExtension, prelude::FromDer as _};
use xml_sec::rsa_encoding::{RsaPrivateKeyEncoding as _, RsaPublicKeyEncoding as _};
use xml_sec::{
c14n::{C14nAlgorithm, C14nMode},
policy::{EncryptionPolicy, HmacPolicy, VerificationPolicy},
provider::default_provider,
xmldsig::{
DigestAlgorithm, HmacVerificationKey, ReferenceBuilder, RsaSigningKey, SignContext,
SignatureAlgorithm, SignatureBuilder, Transform, VerifyContext, XPathExpression,
mutation::append_signature_to_root,
},
xmlenc::{
DataEncryptionAlgorithm, EncryptedDataBuilder, EncryptionRecipient, KeyCandidateBudget,
},
};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_xmlsec1")
}
#[test]
fn opc_relationship_cli_uses_the_2012_contract() {
// The native sign/verify boundary must select case-sensitive 2012 semantics,
// not silently inherit the library's ASCII-insensitive 2021 default.
use sha2::{Digest as _, Sha256};
let temp = tempfile::tempdir().unwrap();
let root = project_root();
let source = root.join("tools/xmlsec1/tests/fixtures/upstream/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256-relationship.tmpl");
let namespace = "http://schemas.openxmlformats.org/package/2006/relationships";
let template = fs::read_to_string(source)
.unwrap()
.replace("relationship/xml-base-input.xml", "")
.replace("SourceId=\"rId1\"", "SourceId=\"RID1\"")
.replace("<Transforms>", &format!("<Transforms><Transform Algorithm=\"http://www.w3.org/TR/1999/REC-xpath-19991116\"><XPath xmlns:r=\"{namespace}\">ancestor-or-self::r:Relationships</XPath></Transform>"))
.replace("</Signature>", &format!("<Object><Relationships xmlns=\"{namespace}\"><Relationship Id=\"rId1\" Type=\"urn:t\" Target=\"a\"/></Relationships></Object></Signature>"));
fs::write(temp.path().join("template.xml"), template).unwrap();
let signed = Command::new(binary())
.current_dir(temp.path())
.args(["sign", "--lax-key-search", "--privkey-pem"])
.arg(root.join("tests/fixtures/keys/rsa/rsa-2048-key.pem"))
.args(["--output", "signed.xml", "template.xml"])
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
let empty = format!("<Relationships xmlns=\"{namespace}\"></Relationships>");
let digest = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(empty.as_bytes()));
assert!(
fs::read_to_string(temp.path().join("signed.xml"))
.unwrap()
.contains(&format!("<DigestValue>{digest}</DigestValue>"))
);
let verified = Command::new(binary())
.current_dir(temp.path())
.args(["verify", "--pubkey-pem"])
.arg(root.join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.arg("signed.xml")
.output()
.unwrap();
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
}
#[test]
fn url_maps_do_not_authorize_external_key_retrieval() {
// Reference payload authorization must not grant key-source authorization.
// Same-document X509Data retrieval remains a supported metadata path.
let directory = tempfile::tempdir().unwrap();
let root = project_root();
let key = RsaSigningKey::from_pkcs8_pem(
&fs::read_to_string(root.join("tests/fixtures/keys/rsa/rsa-2048-key.pem")).unwrap(),
)
.unwrap();
let signed = SignContext::new(&key)
.sign_template(signature_template_without_key_info())
.unwrap();
let certificate =
pem::parse(fs::read(root.join("tests/fixtures/keys/rsa/rsa-2048-cert.pem")).unwrap())
.unwrap();
let encoded = base64::engine::general_purpose::STANDARD.encode(certificate.contents());
let mapped = directory.path().join("payload.der");
fs::write(&mapped, certificate.contents()).unwrap();
for (uri, kind, accepted) in [
("urn:payload", "rawX509Certificate", false),
("#keys", "X509Data", true),
] {
let xml = signed
.replace("</SignatureValue>", &format!("</SignatureValue><KeyInfo><RetrievalMethod URI=\"{uri}\" Type=\"http://www.w3.org/2000/09/xmldsig#{kind}\"/></KeyInfo>"))
.replace("</Signature>", &format!("<Object><X509Data Id=\"keys\"><X509Certificate>{encoded}</X509Certificate></X509Data></Object></Signature>"));
let input = directory.path().join("signed.xml");
fs::write(&input, xml).unwrap();
let output = Command::new(binary())
.args(["verify", "--insecure", "--url-map:urn:payload"])
.arg(&mapped)
.arg(&input)
.output()
.unwrap();
assert_eq!(
output.status.success(),
accepted,
"{uri}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !accepted {
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("retrieval method URI class is not permitted")
);
}
}
}
#[test]
fn opc_detached_parts_require_explicit_url_maps() {
// Explicit maps must work with the original detached donor template;
// no map may fall back to opening a URI-controlled local filename.
let temp = tempfile::tempdir().unwrap();
let root = project_root();
let fixture = root.join("tools/xmlsec1/tests/fixtures/upstream/aleksey-xmldsig-01");
let template = fixture.join("enveloping-sha256-rsa-sha256-relationship.tmpl");
let part = fixture.join("relationship/xml-base-input.xml");
let signed = temp.path().join("signed.xml");
let sign = |mapped: bool| {
let mut command = Command::new(binary());
command
.current_dir(&fixture)
.args(["sign", "--lax-key-search", "--privkey-pem"])
.arg(root.join("tests/fixtures/keys/rsa/rsa-2048-key.pem"))
.arg("--output")
.arg(&signed);
if mapped {
command
.arg("--url-map:relationship/xml-base-input.xml")
.arg(&part);
}
command.arg(&template).output().unwrap()
};
assert!(!sign(false).status.success());
assert!(!signed.exists());
let result = sign(true);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let verify = |mapped: bool| {
let mut command = Command::new(binary());
command
.current_dir(&fixture)
.args(["verify", "--pubkey-pem"])
.arg(root.join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"));
if mapped {
command
.arg("--url-map:relationship/xml-base-input.xml")
.arg(&part);
}
command.arg(&signed).output().unwrap()
};
assert!(!verify(false).status.success());
let result = verify(true);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let corrupted = fs::read_to_string(&part)
.unwrap()
.replace("word/document.xml", "word/tampered.xml");
let changed = temp.path().join("changed.xml");
fs::write(&changed, corrupted).unwrap();
let result = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(root.join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.arg("--url-map:relationship/xml-base-input.xml")
.arg(changed)
.arg(signed)
.output()
.unwrap();
assert!(!result.status.success());
}
#[test]
fn decrypt_nested_wrapping_keys_through_the_cli() {
// The native process must recover an intermediate KEK, not mistake it for
// the content key; authentication failure must preserve existing output.
let directory = tempfile::tempdir().unwrap();
let root = [9; 16];
let intermediate = [7; 16];
let content = [3; 16];
let wrap = xml_sec::xmlenc::KeyWrapAlgorithm::AesKw128;
let inner = default_provider()
.wrap_key(wrap, &root, &intermediate)
.unwrap();
let outer = default_provider()
.wrap_key(wrap, &intermediate, &content)
.unwrap();
let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes);
let inner = format!(
"<xenc:EncryptedKey><xenc:EncryptionMethod Algorithm='{}'/><ds:KeyInfo><ds:KeyName>root</ds:KeyName></ds:KeyInfo><xenc:CipherData><xenc:CipherValue>{}</xenc:CipherValue></xenc:CipherData></xenc:EncryptedKey>",
wrap.uri(),
encode(&inner)
);
let keys = format!(
"<ds:KeyInfo><xenc:EncryptedKey><xenc:EncryptionMethod Algorithm='{}'/><ds:KeyInfo>{inner}</ds:KeyInfo><xenc:CipherData><xenc:CipherValue>{}</xenc:CipherValue></xenc:CipherData></xenc:EncryptedKey></ds:KeyInfo>",
wrap.uri(),
encode(&outer)
);
let generated = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm)
.direct_key(content)
.encrypt_binary(b"nested CLI recipient")
.unwrap();
let wire = generated.encrypted_data_xml.replacen(
"<xenc:CipherData>",
&format!("{keys}<xenc:CipherData>"),
1,
);
let input = directory.path().join("input.xml");
let key = directory.path().join("root.bin");
let output = directory.path().join("output.bin");
fs::write(&input, wire).unwrap();
fs::write(&key, root).unwrap();
let run = || {
Command::new(binary())
.args(["decrypt", "--aeskey:root"])
.arg(&key)
.arg("--output")
.arg(&output)
.arg(&input)
.output()
.unwrap()
};
let result = run();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(fs::read(&output).unwrap(), b"nested CLI recipient");
fs::write(&key, [8; 16]).unwrap();
fs::write(&output, b"preserved").unwrap();
assert!(!run().status.success());
assert_eq!(fs::read(&output).unwrap(), b"preserved");
}
fn project_root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
#[test]
fn parameterized_rsa_pss_cli_preserves_effective_parameters() {
// Real commands must read the full SignatureMethod, not recover the URI's
// defaults and silently replace a non-default MGF hash/salt length.
let temp = tempfile::tempdir().unwrap();
let key = RsaSigningKey::from_pkcs8_pem(include_str!(
"../../../tests/fixtures/keys/rsa/rsa-2048-key.pem"
))
.unwrap();
// Compatibility permission must already cover legacy parameter tuples before
// metadata parsing, not be minted from the selected document afterward.
for (digest, mgf_digest) in [
(DigestAlgorithm::Sha512, DigestAlgorithm::Sha256),
(DigestAlgorithm::Sha256, DigestAlgorithm::Sha1),
(DigestAlgorithm::Sha1, DigestAlgorithm::Sha256),
#[cfg(feature = "legacy-algorithms")]
(DigestAlgorithm::Md5, DigestAlgorithm::Ripemd160),
] {
let algorithm = SignatureAlgorithm::RsaPss(xml_sec::xmldsig::RsaPssParameters {
digest,
mgf_digest,
salt_len: 17,
});
let builder =
SignatureBuilder::new(C14nAlgorithm::new(C14nMode::Exclusive1_0, false), algorithm)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("")
.transform(Transform::Enveloped),
);
let signed = SignContext::new(&key)
.policy(xml_sec::policy::SigningPolicy {
parameterized_rsa_pss: xml_sec::policy::RsaPssPermission::AllSupported,
..Default::default()
})
.sign_with_builder("<root>CLI PSS</root>", &builder)
.unwrap();
let template = temp.path().join("template.xml");
let output = temp.path().join("signed.xml");
fs::write(&template, signed).unwrap();
let result = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem"))
.arg("--output")
.arg(&output)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let result = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.arg(&output)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let mut policy = xml_sec::policy::VerificationPolicy::default();
policy.parameterized_rsa_pss = xml_sec::policy::RsaPssPermission::AllSupported;
policy.key_trust.parameterized_rsa_pss = xml_sec::policy::RsaPssPermission::AllSupported;
let verified = VerifyContext::new()
.policy(policy)
.key(&xml_sec::xmldsig::VerificationKey {
algorithm,
public_key_bytes: pem::parse(include_str!(
"../../../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"
))
.unwrap()
.into_contents(),
certificate_der: None,
name: None,
})
.verify(&fs::read_to_string(output).unwrap())
.unwrap();
assert_eq!(verified.status, xml_sec::xmldsig::DsigStatus::Valid);
}
}
#[cfg(feature = "legacy-algorithms")]
#[test]
fn symmetric_recipient_wraps_are_executable_through_cli() {
// Advertised transforms must be reachable through real commands, including
// a KEK family different from the content cipher and named recipient keys.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plain.bin");
let key = temp.path().join("kek.bin");
fs::write(&plaintext, b"wrapped content").unwrap();
fs::write(&key, [0x31; 24]).unwrap();
for (wrap, option) in [
("kw-aes192", "--aes-key:kek"),
("kw-tripledes", "--des-key:kek"),
] {
let template = temp.path().join("template.xml");
let encrypted = temp.path().join("encrypted.xml");
let decrypted = temp.path().join("decrypted.bin");
fs::write(&template, format!(
"<EncryptedData xmlns='http://www.w3.org/2001/04/xmlenc#'><EncryptionMethod Algorithm='http://www.w3.org/2009/xmlenc11#aes128-gcm'/><ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'><EncryptedKey><EncryptionMethod Algorithm='http://www.w3.org/2001/04/xmlenc#{wrap}'/><ds:KeyInfo><ds:KeyName>kek</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"
)).unwrap();
let result = Command::new(binary())
.args(["encrypt", option])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{wrap}: {}",
String::from_utf8_lossy(&result.stderr)
);
let result = Command::new(binary())
.args(["decrypt", option])
.arg(&key)
.arg("--output")
.arg(&decrypted)
.arg(&encrypted)
.output()
.unwrap();
assert!(
result.status.success(),
"{wrap}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(fs::read(&decrypted).unwrap(), b"wrapped content");
let wrong_option = if option == "--aes-key:kek" {
"--des-key:kek"
} else {
"--aes-key:kek"
};
let rejected = Command::new(binary())
.args(["decrypt", wrong_option])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
!rejected.status.success(),
"{wrap} must reject the wrong key family"
);
// Stored keys must follow the same KEK path as explicitly named files.
let store = temp.path().join("keys.xml");
let value_name = if option == "--aes-key:kek" {
"AESKeyValue"
} else {
"DESKeyValue"
};
let encoded = base64::engine::general_purpose::STANDARD.encode([0x31; 24]);
fs::write(&store, format!("<Keys xmlns='http://www.aleksey.com/xmlsec/2002'><ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'><ds:KeyName>kek</ds:KeyName><ds:KeyValue><{value_name}>{encoded}</{value_name}></ds:KeyValue></ds:KeyInfo></Keys>")).unwrap();
let result = Command::new(binary())
.args(["encrypt", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{wrap} store encrypt: {}",
String::from_utf8_lossy(&result.stderr)
);
let result = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&store)
.arg(&encrypted)
.output()
.unwrap();
assert!(
result.status.success(),
"{wrap} store decrypt: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(result.stdout, b"wrapped content");
}
}
#[cfg(feature = "aws-lc-fips")]
#[test]
fn aws_lc_selection_signs_and_verifies_through_cli() {
// Real CLI selection must import native handles and interoperate with the default engine.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let public = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
for signer in ["rustcrypto", "aws-lc-fips"] {
let signed = temp.path().join(format!("{signer}.xml"));
let result = Command::new(binary())
.args(["sign", "--crypto", signer, "--privkey-pem"])
.arg(&private)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
for verifier in ["rustcrypto", "aws-lc-fips"] {
let result = Command::new(binary())
.args(["verify", "--crypto", verifier, "--pubkey-pem"])
.arg(&public)
.arg(&signed)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
}
let result = Command::new(binary())
.args(["version", "--crypto", "unavailable"])
.output()
.unwrap();
assert!(!result.status.success());
}
#[cfg(feature = "aws-lc-fips")]
#[test]
fn aws_lc_capability_queries_do_not_advertise_other_engine_mechanisms() {
// Selection must constrain discovery as well as actual cryptographic execution.
for (name, available) in [
("rsa-sha256", true),
("sha3-224", false),
("eddsa-ed25519", false),
] {
let output = Command::new(binary())
.args(["check-transforms", "--crypto", "aws-lc-fips", name])
.output()
.unwrap();
assert_eq!(output.status.success(), available, "{name}");
}
let output = Command::new(binary())
.args(["check-key-data", "--crypto", "aws-lc-fips", "eddsa"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[cfg(feature = "aws-lc-fips")]
#[test]
fn aws_lc_cli_encrypts_and_decrypts_without_provider_fallback() {
// Process-level encryption exercises option forwarding and XML ciphertext framing.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("input.bin");
let key = temp.path().join("key.bin");
fs::write(&template, r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#).unwrap();
fs::write(&plaintext, b"native payload\0").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
for encryptor in ["rustcrypto", "aws-lc-fips"] {
let encrypted = temp.path().join(format!("{encryptor}.xml"));
let result = Command::new(binary())
.args(["encrypt", "--crypto", encryptor, "--aeskey"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
for decryptor in ["rustcrypto", "aws-lc-fips"] {
let output = temp.path().join(format!("{encryptor}-{decryptor}.bin"));
let result = Command::new(binary())
.args(["decrypt", "--crypto", decryptor, "--aeskey"])
.arg(&key)
.arg("--output")
.arg(&output)
.arg(&encrypted)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(fs::read(output).unwrap(), b"native payload\0");
}
}
}
#[test]
fn modern_eddsa_signing_completes_cli_process_pipeline() {
// Exercise actual process arguments, key loading, SHA-3 reference digests,
// and signature-context forwarding, not only the key decoder in isolation.
for (algorithm, stem) in [
(SignatureAlgorithm::Ed25519, "ed25519"),
(SignatureAlgorithm::Ed25519Ctx, "ed25519"),
(SignatureAlgorithm::Ed25519Ph, "ed25519"),
(SignatureAlgorithm::Ed448, "ed448"),
(SignatureAlgorithm::Ed448Ph, "ed448"),
] {
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let signed = temp.path().join("signed.xml");
let mut builder =
SignatureBuilder::new(C14nAlgorithm::new(C14nMode::Exclusive1_0, false), algorithm)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha3_256)
.uri("")
.transform(Transform::Enveloped),
);
if algorithm != SignatureAlgorithm::Ed25519 {
builder =
builder.signature_context(xml_sec::xmldsig::SignatureContext::new(b"foo").unwrap());
}
let xml =
append_signature_to_root("<root>payload</root>", &builder.build_template().unwrap())
.unwrap();
fs::write(&template, xml).unwrap();
let key = project_root().join(format!(
"tests/fixtures/xmldsig/keys/eddsa/eddsa-{stem}-key.der"
));
let result = Command::new(binary())
.args(["sign", "--pkcs8-der"])
.arg(key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{algorithm:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
let result = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(project_root().join(format!(
"tests/fixtures/xmldsig/keys/eddsa/eddsa-{stem}-pubkey.pem"
)))
.arg(&signed)
.output()
.unwrap();
assert!(
result.status.success(),
"{algorithm:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
let xml = fs::read_to_string(&signed)
.unwrap()
.replace("payload", "tampered");
fs::write(&signed, xml).unwrap();
let rejected = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(project_root().join(format!(
"tests/fixtures/xmldsig/keys/eddsa/eddsa-{stem}-pubkey.pem"
)))
.arg(&signed)
.output()
.unwrap();
assert!(!rejected.status.success());
}
}
#[cfg(feature = "experimental-pq")]
#[test]
fn pq_signing_and_verification_complete_cli_process_pipeline() {
use xml_sec::xmldsig::PqAlgorithm as P;
// Capability alone must not enable core PQ verification, while the
// explicit compatibility CLI must opt in consistently for both commands.
for (parameter, directory, stem) in [
(P::MlDsa44, "ml-dsa", "ml-dsa-44"),
(P::MlDsa65, "ml-dsa", "ml-dsa-65"),
(P::MlDsa87, "ml-dsa", "ml-dsa-87"),
(P::SlhDsaSha2_128s, "slh-dsa", "slh-dsa-sha2-128s"),
(P::SlhDsaSha2_128f, "slh-dsa", "slh-dsa-sha2-128f"),
(P::SlhDsaSha2_192s, "slh-dsa", "slh-dsa-sha2-192s"),
(P::SlhDsaSha2_192f, "slh-dsa", "slh-dsa-sha2-192f"),
(P::SlhDsaSha2_256s, "slh-dsa", "slh-dsa-sha2-256s"),
(P::SlhDsaSha2_256f, "slh-dsa", "slh-dsa-sha2-256f"),
] {
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let signed = temp.path().join("signed.xml");
let builder = SignatureBuilder::new(
C14nAlgorithm::new(C14nMode::Exclusive1_0, false),
SignatureAlgorithm::PostQuantum(parameter),
)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("")
.transform(Transform::Enveloped),
);
let policy = xml_sec::policy::SigningPolicy {
signature_algorithms: Some([SignatureAlgorithm::PostQuantum(parameter)].into()),
..Default::default()
};
fs::write(
&template,
append_signature_to_root(
"<root>payload</root>",
&builder.build_template_with_policy(&policy).unwrap(),
)
.unwrap(),
)
.unwrap();
let keys = project_root().join(format!("tests/fixtures/xmldsig/keys/{directory}"));
let result = Command::new(binary())
.args(["sign", "--pkcs8-der"])
.arg(keys.join(format!("{stem}-key.der")))
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{parameter:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
let verify = || {
Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(keys.join(format!("{stem}-pubkey.pem")))
.arg(&signed)
.output()
.unwrap()
};
let result = verify();
assert!(
result.status.success(),
"{parameter:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
fs::write(
&signed,
fs::read_to_string(&signed)
.unwrap()
.replace("payload", "tampered"),
)
.unwrap();
assert!(
!verify().status.success(),
"tampering must fail for {parameter:?}"
);
}
}
#[cfg(feature = "experimental-pq")]
#[test]
fn compatibility_cli_verifies_a_pq_signed_certificate_path() {
use der::{
Decode as _,
asn1::{Any, BitString, ObjectIdentifier},
};
use xml_sec::xmldsig::{
EcdsaP256SigningKey, PostQuantumSigningKey, PqAlgorithm, SignatureContext, SigningKey,
};
// A trusted root with a PQ key must authenticate the classical XML
// signer's certificate; XML and certificate algorithms are independent.
let temp = tempfile::tempdir().unwrap();
let mut params = CertificateParams::new(Vec::new()).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "PQ CLI root");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
let root = rcgen::CertifiedIssuer::self_signed(params, KeyPair::generate().unwrap()).unwrap();
let leaf_key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(Vec::new()).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "PQ CLI leaf");
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
let leaf = params.signed_by(&leaf_key, &root).unwrap();
let pq = PostQuantumSigningKey::from_pkcs8_der(
PqAlgorithm::MlDsa44,
include_bytes!("../../../tests/fixtures/xmldsig/keys/ml-dsa/ml-dsa-44-key.der"),
)
.unwrap();
let mut root = Vec::<Any>::from_der(root.der()).unwrap();
let mut tbs = Vec::<Any>::from_der(&root[0].to_der().unwrap()).unwrap();
tbs[6] = Any::from_der(pq.public_key_info().unwrap().spki_der().unwrap()).unwrap();
root[0] = Any::from_der(&tbs.to_der().unwrap()).unwrap();
let root_path = temp.path().join("root.der");
fs::write(&root_path, root.to_der().unwrap()).unwrap();
let mut leaf = Vec::<Any>::from_der(leaf.der()).unwrap();
let mut tbs = Vec::<Any>::from_der(&leaf[0].to_der().unwrap()).unwrap();
let identifier = Any::from_der(
&vec![
Any::encode_from(
&PqAlgorithm::MlDsa44
.oid()
.parse::<ObjectIdentifier>()
.unwrap(),
)
.unwrap(),
]
.to_der()
.unwrap(),
)
.unwrap();
tbs[2] = identifier.clone();
leaf[1] = identifier;
let tbs = tbs.to_der().unwrap();
let signature = default_provider()
.sign_with_context(
&pq,
SignatureAlgorithm::PostQuantum(PqAlgorithm::MlDsa44),
&SignatureContext::default(),
&tbs,
)
.unwrap();
leaf[0] = Any::from_der(&tbs).unwrap();
leaf[2] = Any::encode_from(&BitString::from_bytes(&signature).unwrap()).unwrap();
let leaf_path = temp.path().join("leaf.der");
fs::write(&leaf_path, leaf.to_der().unwrap()).unwrap();
let key = EcdsaP256SigningKey::from_pkcs8_der(&leaf_key.serialize_der()).unwrap();
let builder = SignatureBuilder::new(
C14nAlgorithm::new(C14nMode::Exclusive1_0, false),
SignatureAlgorithm::EcdsaSha256,
)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("")
.transform(Transform::Enveloped),
);
let signed = SignContext::new(&key)
.sign_with_builder("<root>payload</root>", &builder)
.unwrap();
let signed_path = temp.path().join("signed.xml");
fs::write(&signed_path, &signed).unwrap();
let verify = || {
Command::new(binary())
.args(["verify", "--pubkey-cert-der"])
.arg(&leaf_path)
.arg("--trusted-der")
.arg(&root_path)
.arg(&signed_path)
.output()
.unwrap()
};
let accepted = verify();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
fs::write(&signed_path, signed.replace("payload", "tampered")).unwrap();
assert!(!verify().status.success());
}
#[test]
fn mislabeled_encrypted_pem_cannot_sign() {
// Generic private-key loading must honor PEM protection labels, not retry
// plaintext DER; rejection must leave no signed output on disk.
let temp = tempfile::tempdir().unwrap();
let private = temp.path().join("private.pem");
let template = temp.path().join("template.xml");
let signed = temp.path().join("signed.xml");
let block = pem::parse(
fs::read(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem")).unwrap(),
)
.unwrap();
fs::write(
&private,
pem::encode(&pem::Pem::new(
"ENCRYPTED PRIVATE KEY",
block.into_contents(),
)),
)
.unwrap();
fs::write(&template, signature_template_without_key_info()).unwrap();
for password in [None, Some("unused-password")] {
let mut command = Command::new(binary());
command.args(["sign", "--privkey-pem"]).arg(&private);
if let Some(password) = password {
command.args(["--pwd", password]);
}
let result = command
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success());
assert!(!signed.exists());
assert!(!String::from_utf8_lossy(&result.stderr).contains("unused-password"));
}
}
#[test]
fn donor_pkcs12_decrypts_and_wrong_password_fails_closed() {
// The PHAOS bundle and ciphertext are independent xmlsec1 oracle inputs.
let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3");
let encrypted = fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml");
let key = fixture.join("rsa-priv-key.p12");
let run = |password: &str| {
Command::new(binary())
.arg("decrypt")
.arg("--pkcs12:my-rsa-key")
.arg(&key)
.arg("--pwd")
.arg(password)
.arg(&encrypted)
.output()
.unwrap()
};
let success = run("secret");
assert!(
success.status.success(),
"{}",
String::from_utf8_lossy(&success.stderr)
);
assert!(String::from_utf8_lossy(&success.stdout).contains("CreditCard"));
let failure = run("wrong-password");
assert!(!failure.status.success());
assert!(!String::from_utf8_lossy(&failure.stderr).contains("wrong-password"));
}
#[test]
fn donor_pkcs12_signs_without_exposing_password() {
// The PKCS#12 importer must feed the normal signing pipeline, not just RSA
// transport decryption, and a wrong password must not retry plaintext DER.
let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3");
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let signed = temp.path().join("signed.xml");
let public = temp.path().join("public.der");
fs::write(&template, signature_template_without_key_info()).unwrap();
let private =
RsaPrivateKey::from_pkcs1_der(&fs::read(fixture.join("rsa-priv-key.der")).unwrap())
.unwrap();
fs::write(
&public,
private
.to_public_key()
.to_public_key_der()
.unwrap()
.as_bytes(),
)
.unwrap();
let key = fixture.join("rsa-priv-key.p12");
let sign = |password: &str| {
Command::new(binary())
.arg("sign")
.arg("--pkcs12")
.arg(&key)
.arg("--pwd")
.arg(password)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap()
};
let success = sign("secret");
assert!(
success.status.success(),
"{}",
String::from_utf8_lossy(&success.stderr)
);
let verified = Command::new(binary())
.arg("verify")
.arg("--pubkey-der")
.arg(&public)
.arg(&signed)
.output()
.unwrap();
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
let failed = sign("wrong-password");
assert!(!failed.status.success());
assert!(!String::from_utf8_lossy(&failed.stderr).contains("wrong-password"));
}
#[test]
fn lax_signing_stops_on_protected_pkcs12_failure() {
// A wrong container password is an invocation failure, not permission to
// use a later unprotected signing key from the lax candidate list.
let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3");
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
fs::write(&template, signature_template_without_key_info()).unwrap();
let result = Command::new(binary())
.args(["sign", "--lax-key-search", "--pkcs12:first"])
.arg(fixture.join("rsa-priv-key.p12"))
.args(["--privkey-pem:second"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.args(["--pwd", "wrong-password"])
.arg(&template)
.output()
.unwrap();
assert!(
!result.status.success(),
"wrong PKCS#12 password was skipped"
);
assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password"));
}
#[test]
fn lax_decryption_stops_on_protected_pkcs12_failure() {
// A protected-container authentication failure must not be bypassed by
// decrypting with a later plaintext private-key candidate.
let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3");
let result = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--pkcs12:my-rsa-key"])
.arg(fixture.join("rsa-priv-key.p12"))
.args(["--privkey-der:second"])
.arg(fixture.join("rsa-priv-key.der"))
.args(["--pwd", "wrong-password"])
.arg(fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml"))
.output()
.unwrap();
assert!(
!result.status.success(),
"wrong PKCS#12 password was skipped"
);
assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password"));
}
#[test]
fn lax_decryption_stops_on_traditional_encrypted_rsa_pem_failure() {
// A wrong password for the first protected RSA candidate cannot authorize
// fallback to a later unprotected key for the same recipient.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let keys = project_root().join("tests/fixtures/keys/rsa");
fs::write(&template, r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#).unwrap();
fs::write(&plaintext, b"protected RSA recipient").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(keys.join("rsa-2048-pubkey.pem"))
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let providers = ["rustcrypto"]
.into_iter()
.chain(cfg!(feature = "aws-lc-fips").then_some("aws-lc-fips"));
for provider in providers {
let decrypt = Command::new(binary())
.args(["decrypt", "--crypto", provider, "--privkey-pem"])
.arg(keys.join("rsa-2048-key-traditional-encrypted.pem"))
.args(["--pwd", "legacy-rsa-password"])
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{provider}: {}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"protected RSA recipient");
let rejected = Command::new(binary())
.args([
"decrypt",
"--crypto",
provider,
"--lax-key-search",
"--privkey-pem:first",
])
.arg(keys.join("rsa-2048-key-traditional-encrypted.pem"))
.arg("--privkey-pem:second")
.arg(keys.join("rsa-2048-key.pem"))
.args(["--pwd", "wrong-legacy-password-sentinel"])
.arg(&encrypted)
.output()
.unwrap();
assert!(!rejected.status.success(), "{provider}");
assert!(rejected.stdout.is_empty(), "{provider}");
assert!(
!String::from_utf8_lossy(&rejected.stderr).contains("wrong-legacy-password-sentinel")
);
}
}
#[test]
fn donor_xml_store_private_dsa_signs_and_public_dsa_verifies() {
// The upstream xmlsec extension carries DSA X only in the store. The
// signature document names the key but does not contain secret material.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("dsa-template.xml");
let signed = temp.path().join("dsa-signed.xml");
let source = signature_template_without_key_info()
.replace(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"http://www.w3.org/2000/09/xmldsig#dsa-sha1",
)
.replace(
"http://www.w3.org/2001/04/xmlenc#sha256",
"http://www.w3.org/2000/09/xmldsig#sha1",
)
.replace(
"<SignatureValue/>",
"<SignatureValue/><KeyInfo><KeyName>test-dsa</KeyName></KeyInfo>",
);
fs::write(&template, source).unwrap();
let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml");
let sign = Command::new(binary())
.arg("sign")
.arg("--keys-file")
.arg(&store)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.arg("verify")
.arg("--keys-file")
.arg(&store)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[derive(der::Sequence)]
struct TraditionalDsaPrivateKey<'a> {
version: u8,
p: UintRef<'a>,
q: UintRef<'a>,
g: UintRef<'a>,
y: UintRef<'a>,
x: UintRef<'a>,
}
fn traditional_dsa_private_key_der(key: &dsa::SigningKey) -> Vec<u8> {
let verifying_key = key.verifying_key();
let components = verifying_key.components();
let p = components.p().to_be_bytes_trimmed_vartime();
let q = components.q().to_be_bytes_trimmed_vartime();
let g = components.g().to_be_bytes_trimmed_vartime();
let y = verifying_key.y().to_be_bytes_trimmed_vartime();
let x = key.x().to_be_bytes_trimmed_vartime();
TraditionalDsaPrivateKey {
version: 0,
p: UintRef::new(p.as_ref()).unwrap(),
q: UintRef::new(q.as_ref()).unwrap(),
g: UintRef::new(g.as_ref()).unwrap(),
y: UintRef::new(y.as_ref()).unwrap(),
x: UintRef::new(x.as_ref()).unwrap(),
}
.to_der()
.unwrap()
}
fn traditional_dsa_private_key_pem(der: &[u8]) -> String {
pem_text("DSA PRIVATE KEY", der)
}
fn pem_text(label: &str, der: &[u8]) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(der);
let body = encoded
.as_bytes()
.chunks(64)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect::<Vec<_>>()
.join("\n");
format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n")
}
fn signature_template_without_key_info() -> &'static str {
r##"<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#object"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue/></Reference>
</SignedInfo>
<SignatureValue/>
<Object Id="object">payload</Object>
</Signature>"##
}
fn rsa_sha1_signature_template() -> String {
signature_template_without_key_info()
.replace(
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"http://www.w3.org/2000/09/xmldsig#rsa-sha1",
)
.replace(
"http://www.w3.org/2001/04/xmlenc#sha256",
"http://www.w3.org/2000/09/xmldsig#sha1",
)
}
#[test]
fn compatibility_cli_signs_and_verifies_legacy_sha1_templates() {
// The xmlsec1-compatible binary is an explicit legacy boundary. It must
// accept donor SHA-1 templates without weakening the core library defaults.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("rsa-sha1-template.xml");
let signed = temp.path().join("rsa-sha1-signed.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(&template, rsa_sha1_signature_template()).unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[cfg(all(feature = "xml-backend-xmloxide", feature = "xml-backend-roxmltree"))]
#[test]
fn runtime_xml_backend_selector_drives_every_security_command() {
// A fat binary must carry one explicit backend through metadata parsing,
// the core operation, recursive parsing, and output validation.
let temp = tempfile::tempdir().unwrap();
let signature_template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let encryption_template = temp.path().join("encryption-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let symmetric_key = temp.path().join("key.bin");
fs::write(
&encryption_template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"runtime backend payload").unwrap();
fs::write(&symmetric_key, b"0123456789abcdef").unwrap();
for backend in ["xmloxide", "roxmltree", "differential"] {
let signed = temp.path().join(format!("signed-{backend}.xml"));
let sign = Command::new(binary())
.args(["sign", "--xml-backend", backend, "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&signature_template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{backend} sign: {}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--xml-backend", backend, "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{backend} verify: {}",
String::from_utf8_lossy(&verify.stderr)
);
let encrypted = temp.path().join(format!("encrypted-{backend}.xml"));
let decrypted = temp.path().join(format!("decrypted-{backend}.bin"));
let encrypt = Command::new(binary())
.args(["encrypt", "--xml-backend", backend, "--aeskey"])
.arg(&symmetric_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&encryption_template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{backend} encrypt: {}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--xml-backend", backend, "--aeskey"])
.arg(&symmetric_key)
.arg("--output")
.arg(&decrypted)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{backend} decrypt: {}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap());
}
}
#[test]
fn pinned_direct_verification_ignores_unused_key_info_references() {
// A caller-pinned direct key is the complete verification identity. An
// unrelated document KeyInfo must not become a failure surface merely
// because compatibility metadata is collected before core verification.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("rsa-sha1-template.xml");
let signed = temp.path().join("rsa-sha1-signed.xml");
let unused_reference = temp.path().join("rsa-sha1-unused-reference.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(&template, rsa_sha1_signature_template()).unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let mut modified = fs::read_to_string(&signed).unwrap();
let object_start = modified.find("<Object").unwrap();
modified.insert_str(
object_start,
r##"<KeyInfo><dsig11:KeyInfoReference xmlns:dsig11="http://www.w3.org/2009/xmldsig11#" URI="#missing"/></KeyInfo>"##,
);
fs::write(&unused_reference, modified).unwrap();
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&unused_reference)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn compatibility_cli_resolves_key_info_reference_before_signing_key_selection() {
// The donor template puts its KeyName behind a same-document
// KeyInfoReference. Compatibility key selection must resolve that identity
// before matching a named private-key option.
let temp = tempfile::tempdir().unwrap();
let template = project_root().join(
"tests/fixtures/xmldsig/xmldsig11-interop-2012/signature-enveloping-keyinforeference-rsa.tmpl",
);
let private_key = temp.path().join("rsa-4096-private.der");
let fixture_key = RsaPrivateKey::from_pkcs8_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.unwrap(),
)
.unwrap();
fs::write(&private_key, fixture_key.to_pkcs8_der().unwrap().as_bytes()).unwrap();
let signed = temp.path().join("key-info-reference-signed.xml");
let sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-rsa-4096"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem:TestKeyName-rsa-4096"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let unnamed_signed = temp.path().join("key-info-reference-unnamed-signed.xml");
let unnamed_sign = Command::new(binary())
.args(["sign", "--pkcs8-der"])
.arg(&private_key)
.arg("--output")
.arg(&unnamed_signed)
.arg(&template)
.output()
.unwrap();
assert!(
unnamed_sign.status.success(),
"{}",
String::from_utf8_lossy(&unnamed_sign.stderr)
);
// Materialized KeyName constrains named selection. An unrelated name must
// fail rather than falling back to the only loaded private key.
let mismatched = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-unrelated"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!mismatched.status.success());
}
#[test]
fn compatibility_cli_signs_hmac_templates_with_named_raw_keys() {
// The global hmac-key option is part of the xmlsec1 sign contract. The CLI
// consumes its file verbatim and applies the compatibility HMAC minima.
let temp = tempfile::tempdir().unwrap();
let template = project_root().join(
"tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl",
);
let key = project_root().join("tests/fixtures/keys/hmackey.bin");
let signed = temp.path().join("hmac-signed.xml");
let sign = Command::new(binary())
.args(["sign", "--hmac-key:TeskKeyName-Hmac"])
.arg(&key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let secret = fs::read(&key).unwrap();
let verification_key = HmacVerificationKey::new(secret).unwrap();
let mut policy = VerificationPolicy {
hmac: HmacPolicy {
minimum_key_bits: 40,
minimum_output_bits: 40,
},
..VerificationPolicy::default()
};
policy
.key_trust
.allowed_legacy_signature_algorithms
.insert(SignatureAlgorithm::HmacSha1);
let verified = VerifyContext::new()
.key(&verification_key)
.policy(policy)
.verify(&fs::read_to_string(&signed).unwrap())
.expect("CLI-generated HMAC signature must verify");
assert_eq!(verified.status, xml_sec::xmldsig::DsigStatus::Valid);
let cli_verified = Command::new(binary())
.args(["verify", "--hmac-key:TeskKeyName-Hmac"])
.arg(&key)
.arg(&signed)
.output()
.unwrap();
assert!(
cli_verified.status.success(),
"{}",
String::from_utf8_lossy(&cli_verified.stderr)
);
let wrong_key = temp.path().join("wrong-hmac.bin");
fs::write(&wrong_key, b"wrong-secret").unwrap();
let rejected_wrong_key = Command::new(binary())
.args(["verify", "--hmac-key:TeskKeyName-Hmac"])
.arg(&wrong_key)
.arg(&signed)
.output()
.unwrap();
assert!(!rejected_wrong_key.status.success());
let short_key = temp.path().join("short-hmac.bin");
fs::write(&short_key, [0_u8; 4]).unwrap();
let rejected = Command::new(binary())
.args(["sign", "--hmac-key:TeskKeyName-Hmac"])
.arg(&short_key)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("configured minimum"));
let rejected_verify = Command::new(binary())
.args(["verify", "--hmac-key:TeskKeyName-Hmac"])
.arg(&short_key)
.arg(&signed)
.output()
.unwrap();
assert!(!rejected_verify.status.success());
assert!(String::from_utf8_lossy(&rejected_verify.stderr).contains("configured minimum"));
}
#[test]
fn key_store_supplies_named_hmac_key_for_sign_and_verify() {
// A libxmlsec1 key store is an input key source, not merely output of the
// `keys` command. A missing or malformed store must not fall back to an
// unrelated key.
let temp = tempfile::tempdir().unwrap();
let template = project_root().join(
"tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl",
);
let key_store = temp.path().join("keys.xml");
let signed = temp.path().join("signed.xml");
let secret = fs::read(project_root().join("tests/fixtures/keys/hmackey.bin")).unwrap();
let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, secret);
fs::write(
&key_store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>TeskKeyName-Hmac</KeyName><KeyValue><HMACKeyValue xmlns=\"http://www.aleksey.com/xmlsec/2002\">{encoded}</HMACKeyValue></KeyValue></KeyInfo></Keys>"
),
)
.unwrap();
let sign = Command::new(binary())
.args(["sign", "--keys-file"])
.arg(&key_store)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--keys-file"])
.arg(&key_store)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let duplicate = Command::new(binary())
.args(["verify", "--keys-file"])
.arg(&key_store)
.arg("--keys-file")
.arg(&key_store)
.arg(&signed)
.output()
.unwrap();
assert!(!duplicate.status.success());
assert!(String::from_utf8_lossy(&duplicate.stderr).contains("duplicate key name"));
let malformed = temp.path().join("malformed.xml");
fs::write(&malformed, "<Keys><KeyInfo/></Keys>").unwrap();
let rejected = Command::new(binary())
.args(["verify", "--keys-file"])
.arg(&malformed)
.arg(&signed)
.output()
.unwrap();
assert!(!rejected.status.success());
}
#[test]
fn key_store_accepts_mixed_upstream_key_types() {
// The upstream keys.xml intentionally mixes symmetric, RSA, DSA, and
// additional key families. An unsupported entry cannot invalidate a
// separately usable HMAC entry in the same store.
let template = project_root().join(
"tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl",
);
let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml");
let output = Command::new(binary())
.args(["sign", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn lax_key_store_verification_skips_incompatible_family() {
// The first named store entry is DSA; the RSA signature must be checked
// against the later compatible entry instead of failing at the first key.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let donor =
fs::read_to_string(project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml"))
.unwrap();
let dsa_name = donor.find("<KeyName>test-dsa</KeyName>").unwrap();
let dsa_start = donor[..dsa_name].rfind("<KeyInfo").unwrap();
let dsa_end = donor[dsa_name..].find("</KeyInfo>").unwrap() + dsa_name + "</KeyInfo>".len();
let public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"))
.unwrap();
let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap();
let base64 = base64::engine::general_purpose::STANDARD;
let store = temp.path().join("keys.xml");
fs::write(
&store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\">{}<KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>rsa</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo></Keys>",
&donor[dsa_start..dsa_end],
base64.encode(public.n().to_be_bytes_trimmed_vartime()),
base64.encode(public.e().to_be_bytes_trimmed_vartime()),
),
)
.unwrap();
let verify = Command::new(binary())
.args(["verify", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
#[expect(
deprecated,
reason = "the compatibility CLI must retain coverage for legacy DSA 1024/160 keys"
)]
fn compatibility_cli_decodes_dsa_and_p521_pkcs8_signing_keys() {
// The CLI decoder must cover every asymmetric family admitted by its
// compatibility policy, not just the historically implemented subset.
let temp = tempfile::tempdir().unwrap();
let encrypted_dsa =
fs::read(project_root().join("tests/fixtures/xmldsig/keys/dsa/dsa-2048-key.p8-der"))
.unwrap();
let dsa_key = dsa::SigningKey::from_pkcs8_encrypted_der(&encrypted_dsa, b"secret123").unwrap();
let dsa_public = temp.path().join("dsa-public.der");
fs::write(
&dsa_public,
dsa_key
.verifying_key()
.to_public_key_der()
.unwrap()
.as_bytes(),
)
.unwrap();
let dsa_template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-dsa2048-sha256.tmpl");
let dsa_signed = temp.path().join("dsa-signed.xml");
let dsa_sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-dsa-2048"])
.arg(project_root().join("tests/fixtures/xmldsig/keys/dsa/dsa-2048-key.p8-der"))
.args(["--pwd", "secret123"])
.arg("--output")
.arg(&dsa_signed)
.arg(&dsa_template)
.output()
.unwrap();
assert!(
dsa_sign.status.success(),
"{}",
String::from_utf8_lossy(&dsa_sign.stderr)
);
let wrong_password_output = temp.path().join("dsa-wrong-password.xml");
let wrong_password = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-dsa-2048"])
.arg(project_root().join("tests/fixtures/xmldsig/keys/dsa/dsa-2048-key.p8-der"))
.args(["--pwd", "not-secret123-sentinel"])
.arg("--output")
.arg(&wrong_password_output)
.arg(&dsa_template)
.output()
.unwrap();
assert!(!wrong_password.status.success());
assert!(!wrong_password_output.exists());
assert!(!String::from_utf8_lossy(&wrong_password.stderr).contains("not-secret123-sentinel"));
let dsa_verify = Command::new(binary())
.args(["verify", "--pubkey-der:TestKeyName-dsa-2048"])
.arg(&dsa_public)
.arg(&dsa_signed)
.output()
.unwrap();
assert!(
dsa_verify.status.success(),
"{}",
String::from_utf8_lossy(&dsa_verify.stderr)
);
let traditional_dsa_der = traditional_dsa_private_key_der(&dsa_key);
for (option, extension, contents) in [
(
"--privkey-der:TestKeyName-dsa-2048",
"der",
traditional_dsa_der.clone(),
),
(
"--privkey-pem:TestKeyName-dsa-2048",
"pem",
traditional_dsa_private_key_pem(&traditional_dsa_der).into_bytes(),
),
] {
let private = temp.path().join(format!("dsa-traditional.{extension}"));
let signed = temp.path().join(format!("dsa-traditional-{extension}.xml"));
fs::write(&private, contents).unwrap();
let sign = Command::new(binary())
.args(["sign", option])
.arg(&private)
.arg("--output")
.arg(&signed)
.arg(&dsa_template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{option}: {}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-der:TestKeyName-dsa-2048"])
.arg(&dsa_public)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{option}: {}",
String::from_utf8_lossy(&verify.stderr)
);
}
let mut legacy_rng = ChaCha8Rng::seed_from_u64(0xD5A1_1024);
let legacy_components = dsa::Components::try_generate_from_rng_with_key_size(
&mut legacy_rng,
dsa::KeySize::DSA_1024_160,
)
.expect("legacy DSA parameters must generate");
let legacy_dsa =
dsa::SigningKey::try_generate_from_rng_with_components(&mut legacy_rng, legacy_components)
.expect("legacy DSA key must generate");
let legacy_private = temp.path().join("dsa-1024-private.der");
let legacy_public = temp.path().join("dsa-1024-public.der");
fs::write(
&legacy_private,
legacy_dsa.to_pkcs8_der().unwrap().as_bytes(),
)
.unwrap();
fs::write(
&legacy_public,
legacy_dsa
.verifying_key()
.to_public_key_der()
.unwrap()
.as_bytes(),
)
.unwrap();
let compatible_private = temp.path().join("dsa-2048-private.der");
fs::write(
&compatible_private,
dsa_key.to_pkcs8_der().unwrap().as_bytes(),
)
.unwrap();
let lax_signed = temp.path().join("dsa-lax-signed.xml");
let lax_sign = Command::new(binary())
.args(["sign", "--lax-key-search", "--pkcs8-der:wrong"])
.arg(&legacy_private)
.arg("--pkcs8-der:TestKeyName-dsa-2048")
.arg(&compatible_private)
.arg("--output")
.arg(&lax_signed)
.arg(&dsa_template)
.output()
.unwrap();
assert!(
lax_sign.status.success(),
"{}",
String::from_utf8_lossy(&lax_sign.stderr)
);
let donor_legacy_template = project_root()
.join("tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl");
let legacy_template = temp.path().join("dsa-1024-template.xml");
let verifying_key = legacy_dsa.verifying_key();
let components = verifying_key.components();
let encode_component = |value: &[u8]| base64::engine::general_purpose::STANDARD.encode(value);
let dsa_key_value = format!(
" <KeyValue><DSAKeyValue><P>{}</P><Q>{}</Q><G>{}</G><Y>{}</Y></DSAKeyValue></KeyValue>",
encode_component(components.p().to_be_bytes_trimmed_vartime().as_ref()),
encode_component(components.q().to_be_bytes_trimmed_vartime().as_ref()),
encode_component(components.g().to_be_bytes_trimmed_vartime().as_ref()),
encode_component(verifying_key.y().to_be_bytes_trimmed_vartime().as_ref()),
);
fs::write(
&legacy_template,
fs::read_to_string(donor_legacy_template)
.unwrap()
.replace(" <KeyValue>\n </KeyValue>", &dsa_key_value),
)
.unwrap();
let legacy_signed = temp.path().join("dsa-1024-signed.xml");
let legacy_sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-dsa-1024"])
.arg(&legacy_private)
.arg("--output")
.arg(&legacy_signed)
.arg(&legacy_template)
.output()
.unwrap();
assert!(
legacy_sign.status.success(),
"{}",
String::from_utf8_lossy(&legacy_sign.stderr)
);
let legacy_verify = Command::new(binary())
.args(["verify", "--pubkey-der:TestKeyName-dsa-1024"])
.arg(&legacy_public)
.arg(&legacy_signed)
.output()
.unwrap();
assert!(
legacy_verify.status.success(),
"{}",
String::from_utf8_lossy(&legacy_verify.stderr)
);
let y_only_template = temp.path().join("dsa-1024-y-only-template.xml");
let y_only_key_value = format!(
" <KeyValue><DSAKeyValue><Y>{}</Y></DSAKeyValue></KeyValue>",
encode_component(verifying_key.y().to_be_bytes_trimmed_vartime().as_ref()),
);
fs::write(
&y_only_template,
fs::read_to_string(&legacy_template)
.unwrap()
.replace(&dsa_key_value, &y_only_key_value),
)
.unwrap();
let y_only_sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-dsa-1024"])
.arg(&legacy_private)
.arg(&y_only_template)
.output()
.unwrap();
assert!(
y_only_sign.status.success(),
"{}",
String::from_utf8_lossy(&y_only_sign.stderr)
);
let mismatched_template = temp.path().join("dsa-1024-mismatched-template.xml");
let mut mismatched_y = verifying_key.y().to_be_bytes_trimmed_vartime().to_vec();
mismatched_y[0] ^= 1;
let mismatched_key_value = dsa_key_value.replace(
&encode_component(verifying_key.y().to_be_bytes_trimmed_vartime().as_ref()),
&encode_component(&mismatched_y),
);
fs::write(
&mismatched_template,
fs::read_to_string(&legacy_template)
.unwrap()
.replace(&dsa_key_value, &mismatched_key_value),
)
.unwrap();
let mismatched_sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-dsa-1024"])
.arg(&legacy_private)
.arg(&mismatched_template)
.output()
.unwrap();
assert!(!mismatched_sign.status.success());
assert!(
String::from_utf8_lossy(&mismatched_sign.stderr)
.contains("preserved KeyInfo does not match the selected signing key")
);
let p521_template = temp.path().join("p521-template.xml");
fs::write(
&p521_template,
ecdsa_signature_template().replace(
"<SignatureValue/>",
"<SignatureValue/><KeyInfo><KeyName>TestKeyName-ec-prime521v1</KeyName></KeyInfo>",
),
)
.unwrap();
let p521_private =
project_root().join("tests/fixtures/xmldsig/xmldsig11-interop-2012/keys/p521-key-orig.der");
let p521_certificate =
project_root().join("tests/fixtures/xmldsig/xmldsig11-interop-2012/keys/p521-key.crt");
let p521_signed = temp.path().join("p521-signed.xml");
let p521_sign = Command::new(binary())
.args(["sign", "--pkcs8-der:TestKeyName-ec-prime521v1"])
.arg(&p521_private)
.arg("--output")
.arg(&p521_signed)
.arg(&p521_template)
.output()
.unwrap();
assert!(
p521_sign.status.success(),
"{}",
String::from_utf8_lossy(&p521_sign.stderr)
);
let p521_verify = Command::new(binary())
.args(["verify", "--insecure", "--pubkey-cert-der"])
.arg(&p521_certificate)
.arg(&p521_signed)
.output()
.unwrap();
assert!(
p521_verify.status.success(),
"{}",
String::from_utf8_lossy(&p521_verify.stderr)
);
}
#[test]
fn compatibility_cli_decrypts_rsa_and_ecdsa_pkcs8_signing_keys() {
// --pwd describes PKCS#8 key-container encryption, so every asymmetric
// family accepted by the CLI must decrypt the same PEM and DER formats.
let temp = tempfile::tempdir().unwrap();
let password = b"shared-pkcs8-password";
let mut rng = ChaCha8Rng::seed_from_u64(0x504B_4353_3800_0001);
let rsa = RsaPrivateKey::new(&mut rng, 2048).unwrap();
let rsa_plain_private = temp.path().join("rsa-plain.der");
let rsa_private = temp.path().join("rsa-encrypted.der");
let rsa_public = temp.path().join("rsa-public.der");
let rsa_der = rsa.to_pkcs8_der().unwrap();
fs::write(&rsa_plain_private, rsa_der.as_bytes()).unwrap();
let encrypted_rsa = pkcs8::PrivateKeyInfoRef::try_from(rsa_der.as_bytes())
.unwrap()
.encrypt_with_rng(&mut rng, password)
.unwrap();
fs::write(&rsa_private, encrypted_rsa.as_bytes()).unwrap();
fs::write(
&rsa_public,
RsaPublicKey::from(&rsa)
.to_public_key_der()
.unwrap()
.as_bytes(),
)
.unwrap();
assert_pkcs8_cli_round_trip(
temp.path(),
"rsa-plain",
signature_template_without_key_info(),
&rsa_plain_private,
&rsa_public,
b"unused-for-plain-container",
false,
);
assert_pkcs8_cli_round_trip(
temp.path(),
"rsa",
signature_template_without_key_info(),
&rsa_private,
&rsa_public,
password,
true,
);
let p256 = P256SecretKey::from_pkcs8_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/ec/ec-prime256v1-key.pem"))
.unwrap(),
)
.unwrap();
let p256_plain_private = temp.path().join("p256-plain.pem");
let p256_private = temp.path().join("p256-encrypted.pem");
let p256_public = temp.path().join("p256-public.der");
let p256_der = p256.to_pkcs8_der().unwrap();
fs::write(
&p256_plain_private,
p256.to_pkcs8_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
)
.unwrap();
let encrypted_p256 = pkcs8::PrivateKeyInfoRef::try_from(p256_der.as_bytes())
.unwrap()
.encrypt_with_rng(&mut rng, password)
.unwrap();
fs::write(
&p256_private,
encrypted_p256
.to_pem("ENCRYPTED PRIVATE KEY", pkcs8::LineEnding::LF)
.unwrap()
.as_bytes(),
)
.unwrap();
fs::write(
&p256_public,
p256.public_key().to_public_key_der().unwrap().as_bytes(),
)
.unwrap();
assert_pkcs8_cli_round_trip(
temp.path(),
"p256-plain",
ecdsa_signature_template(),
&p256_plain_private,
&p256_public,
b"unused-for-plain-container",
false,
);
assert_pkcs8_cli_round_trip(
temp.path(),
"p256",
ecdsa_signature_template(),
&p256_private,
&p256_public,
password,
true,
);
}
#[test]
fn compatibility_cli_signs_with_traditional_encrypted_rsa_pem() {
// The generic PEM option mirrors libxmlsec1's OpenSSL legacy RSA envelope
// support; explicit PKCS#8 options retain their narrower format contract.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("rsa-traditional-template.xml");
let signed = temp.path().join("rsa-traditional-signed.xml");
let private_key =
project_root().join("tests/fixtures/keys/rsa/rsa-2048-key-traditional-encrypted.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
fs::write(&template, signature_template_without_key_info()).unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--pwd", "legacy-rsa-password"])
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let rejected_output = temp.path().join("rsa-traditional-rejected.xml");
let wrong_password = "wrong-legacy-password-sentinel";
let rejected = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--pwd", wrong_password])
.arg("--output")
.arg(&rejected_output)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(!rejected_output.exists());
assert!(!String::from_utf8_lossy(&rejected.stderr).contains(wrong_password));
}
#[test]
fn compatibility_cli_signs_with_traditional_encrypted_dsa_pem() {
// Generic PEM loading applies the password before DSA ASN.1 validation;
// encrypted input must never fall back to password-blind plain decoding.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-dsa2048-sha256.tmpl");
let signed = temp.path().join("dsa-traditional-signed.xml");
let private_key =
project_root().join("tests/fixtures/keys/dsa/dsa-2048-key-traditional-encrypted.pem");
let public_key = project_root().join("tests/fixtures/keys/dsa/dsa-2048-public.pem");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem:TestKeyName-dsa-2048"])
.arg(&private_key)
.args(["--pwd", "legacy-dsa-password"])
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem:TestKeyName-dsa-2048"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
for password in [None, Some("wrong-legacy-password-sentinel")] {
let rejected_output = temp.path().join(format!(
"dsa-traditional-rejected-{}.xml",
password.unwrap_or("missing")
));
let mut rejected = Command::new(binary());
rejected
.args(["sign", "--privkey-pem:TestKeyName-dsa-2048"])
.arg(&private_key);
if let Some(password) = password {
rejected.args(["--pwd", password]);
}
let rejected = rejected
.arg("--output")
.arg(&rejected_output)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(!rejected_output.exists());
if let Some(password) = password {
assert!(!String::from_utf8_lossy(&rejected.stderr).contains(password));
}
}
}
#[test]
fn compatibility_cli_accepts_generic_sec1_ecdsa_keys() {
// Generic private-key options accept the traditional ECPrivateKey container,
// while the explicitly named PKCS#8 options remain container-strict.
let temp = tempfile::tempdir().unwrap();
let p256 = P256SecretKey::from_slice(&[0x11; 32]).unwrap();
let p384 = P384SecretKey::from_slice(&[0x22; 48]).unwrap();
let p521 = P521SecretKey::from_slice(&[0x01; 66]).unwrap();
assert_sec1_cli_round_trip(
temp.path(),
"rustcrypto",
"p256",
p256.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p256.to_sec1_der().unwrap().as_ref(),
p256.public_key().to_public_key_der().unwrap().as_bytes(),
);
assert_sec1_cli_round_trip(
temp.path(),
"rustcrypto",
"p384",
p384.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p384.to_sec1_der().unwrap().as_ref(),
p384.public_key().to_public_key_der().unwrap().as_bytes(),
);
assert_sec1_cli_round_trip(
temp.path(),
"rustcrypto",
"p521",
p521.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p521.to_sec1_der().unwrap().as_ref(),
p521.public_key().to_public_key_der().unwrap().as_bytes(),
);
}
#[cfg(feature = "aws-lc-fips")]
#[test]
fn aws_lc_accepts_generic_sec1_ecdsa_keys() {
// Provider selection changes cryptographic execution, not accepted containers.
let temp = tempfile::tempdir().unwrap();
let p256 = P256SecretKey::from_slice(&[0x11; 32]).unwrap();
let p384 = P384SecretKey::from_slice(&[0x22; 48]).unwrap();
let p521 = P521SecretKey::from_slice(&[0x01; 66]).unwrap();
assert_sec1_cli_round_trip(
temp.path(),
"aws-lc-fips",
"p256",
p256.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p256.to_sec1_der().unwrap().as_ref(),
p256.public_key().to_public_key_der().unwrap().as_bytes(),
);
assert_sec1_cli_round_trip(
temp.path(),
"aws-lc-fips",
"p384",
p384.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p384.to_sec1_der().unwrap().as_ref(),
p384.public_key().to_public_key_der().unwrap().as_bytes(),
);
assert_sec1_cli_round_trip(
temp.path(),
"aws-lc-fips",
"p521",
p521.to_sec1_pem(pkcs8::LineEnding::LF).unwrap().as_bytes(),
p521.to_sec1_der().unwrap().as_ref(),
p521.public_key().to_public_key_der().unwrap().as_bytes(),
);
}
#[cfg(feature = "aws-lc-fips")]
#[test]
fn aws_lc_signs_with_traditional_encrypted_private_keys() {
// Protected RSA and EC envelopes normalize before native import; wrong or
// absent passwords must stay terminal and never produce an output file.
let temp = tempfile::tempdir().unwrap();
for (name, template, private, public, password, public_option) in [
(
"rsa",
signature_template_without_key_info(),
"tests/fixtures/keys/rsa/rsa-2048-key-traditional-encrypted.pem",
"tests/fixtures/keys/rsa/rsa-2048-pubkey.pem",
"legacy-rsa-password",
"--pubkey-pem",
),
(
"ec",
ecdsa_signature_template(),
"tests/fixtures/keys/ec/p256-key-traditional-encrypted.pem",
"tests/fixtures/xmldsig/xmldsig11-interop-2012/keys/p256-key.der",
"legacy-ec-password",
"--pubkey-der",
),
] {
let source = temp.path().join(format!("{name}-template.xml"));
fs::write(&source, template).unwrap();
for (index, supplied) in [Some(password), None, Some("wrong-password-sentinel")]
.into_iter()
.enumerate()
{
let signed = temp.path().join(format!("{name}-{index}.xml"));
let mut sign = Command::new(binary());
sign.args(["sign", "--crypto", "aws-lc-fips", "--privkey-pem"])
.arg(project_root().join(private));
if let Some(supplied) = supplied {
sign.args(["--pwd", supplied]);
}
let result = sign
.arg("--output")
.arg(&signed)
.arg(&source)
.output()
.unwrap();
if index == 0 {
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let verified = Command::new(binary())
.args(["verify", public_option])
.arg(project_root().join(public))
.arg(&signed)
.output()
.unwrap();
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
} else {
assert!(!result.status.success());
assert!(!signed.exists());
assert!(
String::from_utf8_lossy(&result.stderr).contains("protected key container")
);
assert!(
!String::from_utf8_lossy(&result.stderr).contains("wrong-password-sentinel")
);
}
}
}
}
#[test]
fn compatibility_cli_signs_with_traditional_encrypted_sec1_pem() {
// The generic PEM option applies the password before SEC1 curve decoding;
// no password-blind plaintext fallback may accept a malformed envelope.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("ec-traditional-template.xml");
let signed = temp.path().join("ec-traditional-signed.xml");
let private_key =
project_root().join("tests/fixtures/keys/ec/p256-key-traditional-encrypted.pem");
let public_key =
project_root().join("tests/fixtures/xmldsig/xmldsig11-interop-2012/keys/p256-key.der");
fs::write(&template, ecdsa_signature_template()).unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--pwd", "legacy-ec-password"])
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-der"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
for password in [None, Some("wrong-legacy-password-sentinel")] {
let rejected_output = temp.path().join(format!(
"ec-traditional-rejected-{}.xml",
password.unwrap_or("missing")
));
let mut rejected = Command::new(binary());
rejected.args(["sign", "--privkey-pem"]).arg(&private_key);
if let Some(password) = password {
rejected.args(["--pwd", password]);
}
let rejected = rejected
.arg("--output")
.arg(&rejected_output)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(!rejected_output.exists());
if let Some(password) = password {
assert!(!String::from_utf8_lossy(&rejected.stderr).contains(password));
}
}
}
fn assert_sec1_cli_round_trip(
directory: &Path,
provider: &str,
name: &str,
private_pem: &[u8],
private_der: &[u8],
public_der: &[u8],
) {
let template = directory.join(format!("{name}-template.xml"));
let public_key = directory.join(format!("{name}-public.der"));
// Exercise each curve with a digest supported by the selected native engine.
let algorithm = if provider == "aws-lc-fips" && name == "p384" {
"ecdsa-sha384"
} else {
"ecdsa-sha256"
};
fs::write(
&template,
ecdsa_signature_template().replace("ecdsa-sha256", algorithm),
)
.unwrap();
fs::write(&public_key, public_der).unwrap();
for (encoding, bytes) in [("pem", private_pem), ("der", private_der)] {
let private_key = directory.join(format!("{name}-private.{encoding}"));
let signed = directory.join(format!("{name}-{encoding}-signed.xml"));
fs::write(&private_key, bytes).unwrap();
let sign = Command::new(binary())
.args(["sign", "--crypto", provider])
.arg(format!("--privkey-{encoding}"))
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{name} SEC1 {encoding}: {}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-der"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{name} SEC1 {encoding}: {}",
String::from_utf8_lossy(&verify.stderr)
);
let rejected = Command::new(binary())
.args(["sign", "--crypto", provider])
.arg(format!("--pkcs8-{encoding}"))
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
}
}
fn assert_pkcs8_cli_round_trip(
directory: &Path,
name: &str,
template: &str,
private_key: &Path,
public_key: &Path,
password: &[u8],
reject_wrong_password: bool,
) {
let template_path = directory.join(format!("{name}-template.xml"));
let signed_path = directory.join(format!("{name}-signed.xml"));
fs::write(&template_path, template).unwrap();
let private_key_option = if private_key
.extension()
.is_some_and(|extension| extension == "pem")
{
"--pkcs8-pem"
} else {
"--pkcs8-der"
};
let sign = Command::new(binary())
.arg("sign")
.arg(private_key_option)
.arg(private_key)
.arg("--pwd")
.arg(std::str::from_utf8(password).unwrap())
.arg("--output")
.arg(&signed_path)
.arg(&template_path)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
if reject_wrong_password {
let wrong_password_output = directory.join(format!("{name}-wrong-password.xml"));
let wrong_password = "wrong-password-sentinel";
let rejected = Command::new(binary())
.arg("sign")
.arg(private_key_option)
.arg(private_key)
.args(["--pwd", wrong_password])
.arg("--output")
.arg(&wrong_password_output)
.arg(&template_path)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(!wrong_password_output.exists());
assert!(!String::from_utf8_lossy(&rejected.stderr).contains(wrong_password));
}
let verify = Command::new(binary())
.args(["verify", "--pubkey-der"])
.arg(public_key)
.arg(&signed_path)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
fn ecdsa_signature_template() -> &'static str {
r##"<root><payload ID="payload">payload</payload><Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<Reference URI="#payload"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue/></Reference>
</SignedInfo>
<SignatureValue/>
</Signature></root>"##
}
fn visa3d_signature_template() -> &'static str {
r##"<root><payload ID="visa'3d">payload</payload><Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#visa'3d"><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue/></Reference>
</SignedInfo>
<SignatureValue/>
</Signature></root>"##
}
fn signature_template_with_key_info(key_info: &str) -> String {
signature_template_without_key_info()
.replacen(
"<Signature ",
"<Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\" ",
1,
)
.replace(
"<SignatureValue/>",
&format!("<SignatureValue/><KeyInfo>{key_info}</KeyInfo>"),
)
}
fn rsa_key_value_with_leading_zeroes(
public_key: &RsaPublicKey,
modulus_zeroes: usize,
exponent_zeroes: usize,
) -> String {
let base64 = &base64::engine::general_purpose::STANDARD;
let mut modulus = vec![0; modulus_zeroes];
modulus.extend(public_key.n().to_be_bytes_trimmed_vartime());
let mut exponent = vec![0; exponent_zeroes];
exponent.extend(public_key.e().to_be_bytes_trimmed_vartime());
format!(
"<ds:KeyValue><ds:RSAKeyValue><ds:Modulus>{}</ds:Modulus><ds:Exponent>{}</ds:Exponent></ds:RSAKeyValue></ds:KeyValue>",
base64.encode(modulus),
base64.encode(exponent)
)
}
fn der_encoded_key_value(public_key: &RsaPublicKey) -> String {
let der = public_key
.to_public_key_der()
.expect("fixture public key must encode as SPKI");
format!(
"<dsig11:DEREncodedKeyValue xmlns:dsig11=\"http://www.w3.org/2009/xmldsig11#\">{}</dsig11:DEREncodedKeyValue>",
base64::engine::general_purpose::STANDARD.encode(der.as_bytes())
)
}
fn x509_certificate_value(path: &Path) -> String {
let (_, pem) = x509_parser::pem::parse_x509_pem(&fs::read(path).unwrap()).unwrap();
format!(
"<ds:X509Data><ds:X509Certificate>{}</ds:X509Certificate></ds:X509Data>",
base64::engine::general_purpose::STANDARD.encode(pem.contents)
)
}
fn pem_der_base64(path: &Path) -> String {
let (_, pem) = x509_parser::pem::parse_x509_pem(&fs::read(path).unwrap()).unwrap();
base64::engine::general_purpose::STANDARD.encode(pem.contents)
}
#[test]
fn signs_verifies_and_rejects_tampering_through_process_api() {
// Exercise the process boundary and prove a post-signature content change
// is classified as invalid rather than accepted or reported as a CLI error.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let tampered = temp.path().join("tampered.xml");
let xml = fs::read_to_string(&signed).unwrap();
fs::write(&tampered, xml.replace("some text", "tampered text")).unwrap();
let rejected = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&tampered)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("invalid"));
}
#[test]
fn cli_asn1_signature_mode_requires_the_explicit_donor_flag() {
// The compatibility option controls both emitted and accepted framing;
// standards-default verification must not auto-detect DER.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("ecdsa-template.xml");
let signed = temp.path().join("ecdsa-signed.xml");
fs::write(&template, ecdsa_signature_template()).unwrap();
let private_key = project_root().join("tests/fixtures/keys/ec/ec-prime256v1-key.pem");
let public_key = project_root().join("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem");
let sign = Command::new(binary())
.args(["sign", "--enable-asn1-signatures-hack", "--privkey-pem"])
.arg(&private_key)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let signed_xml = fs::read_to_string(&signed).unwrap();
let document = roxmltree::Document::parse(&signed_xml).unwrap();
let encoded = document
.descendants()
.find(|node| node.has_tag_name("SignatureValue"))
.and_then(|node| node.text())
.unwrap();
let signature = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
assert_eq!(signature.first(), Some(&0x30));
assert_ne!(signature.len(), 64);
let rejected = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("invalid ECDSA signature encoding"));
let accepted = Command::new(binary())
.args(["verify", "--enable-asn1-signatures-hack", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
}
#[test]
fn cli_visa3d_mode_bypasses_xpointer_for_registered_ids() {
// The apostrophe makes libxmlsec1's default xpointer(id('...')) wrapper
// invalid. Visa3D mode performs direct registered-ID lookup instead.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("visa3d-template.xml");
let signed = temp.path().join("visa3d-signed.xml");
fs::write(&template, visa3d_signature_template()).unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let rejected = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
let rejected_stderr = String::from_utf8_lossy(&rejected.stderr);
assert!(
rejected_stderr.contains("unsupported URI: #visa'3d"),
"unexpected standards-mode diagnostic: {rejected_stderr}"
);
let sign = Command::new(binary())
.args(["sign", "--enable-visa3d-hack", "--privkey-pem"])
.arg(&private_key)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let default_verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(!default_verify.status.success());
let compatible_verify = Command::new(binary())
.args(["verify", "--enable-visa3d-hack", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
compatible_verify.status.success(),
"{}",
String::from_utf8_lossy(&compatible_verify.stderr)
);
}
#[test]
fn cli_default_xpointer_mode_accepts_numeric_registered_ids() {
// libxmlsec1's default wraps a bare ID in xpointer(id('...')), so a numeric
// registered value works without enabling the direct Visa3D lookup path.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("numeric-id-template.xml");
let signed = temp.path().join("numeric-id-signed.xml");
fs::write(
&template,
visa3d_signature_template().replace("visa'3d", "12345"),
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let sign = Command::new(binary())
.args(["sign", "--add-id-attr", "ID", "--privkey-pem"])
.arg(&private_key)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--add-id-attr", "ID", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn signing_rejects_preserved_key_info_for_another_key() {
// A successful signature must not retain a cryptographic identity that
// directs ordinary KeyInfo resolution to a different public key.
let temp = tempfile::tempdir().unwrap();
let source = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let template = temp.path().join("stale-key-info.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let stale_public = RsaPublicKey::from_public_key_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap(),
)
.unwrap();
let identities = [
rsa_key_value_with_leading_zeroes(&stale_public, 0, 0).replace("ds:", ""),
der_encoded_key_value(&stale_public),
x509_certificate_value(
&project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem"),
)
.replace("ds:", ""),
"<KeyValue><ECKeyValue xmlns=\"http://www.w3.org/2009/xmldsig11#\"><NamedCurve URI=\"urn:oid:1.2.840.10045.3.1.7\"/><PublicKey>BJ/yaXNlq4FRObyJCBhb5jAz8GVzinK3bBGLjSDfjbJwNfydtgjnlS4EsDmxSRhWyJWq6GIqy5wvnaiARK04uB4=</PublicKey></ECKeyValue></KeyValue>".into(),
];
let source_xml = fs::read_to_string(source).unwrap();
for (index, identity) in identities.iter().enumerate() {
fs::write(&template, source_xml.replace("<X509Data/>", identity)).unwrap();
let output = Command::new(binary())
.args(["sign", "--privkey-pem:TestKeyName-rsa-2048"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success(), "identity {index} was accepted");
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("does not match the selected signing key"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
let matching_public = RsaPublicKey::from(
&RsaPrivateKey::from_pkcs8_pem(&fs::read_to_string(&private_key).unwrap()).unwrap(),
);
fs::write(
&template,
source_xml.replace(
"<X509Data/>",
&rsa_key_value_with_leading_zeroes(&matching_public, 0, 0).replace("ds:", ""),
),
)
.unwrap();
let matching = Command::new(binary())
.args(["sign", "--privkey-pem:TestKeyName-rsa-2048"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(
matching.status.success(),
"{}",
String::from_utf8_lossy(&matching.stderr)
);
}
#[test]
fn direct_public_key_verification_rejects_invalid_trust_inputs() {
// Explicit trust inputs are configuration, not optional resolver hints:
// validate them even when a raw public key verifies the signature.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let malformed_trust = temp.path().join("malformed-trust.pem");
let signed = temp.path().join("signed.xml");
fs::write(&malformed_trust, b"not a certificate").unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg("--trusted-pem")
.arg(&malformed_trust)
.arg(&signed)
.output()
.unwrap();
assert!(!verify.status.success());
assert!(String::from_utf8_lossy(&verify.stderr).contains("certificate"));
}
#[test]
fn verification_without_a_selector_uses_the_first_signature() {
// libxmlsec1 treats the document root as the operation start node and verifies
// its first descendant Signature rather than imposing global cardinality.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("multiple-signatures.xml");
let signed = temp.path().join("signed.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
format!(
"<root>{}<Signature xmlns=\"http://www.w3.org/2000/09/xmldsig#\"/></root>",
signature_template_without_key_info()
),
)
.unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn binary_encryption_round_trips_a_custom_type_uri() {
// Custom Type URIs classify opaque application data, so binary mode must
// preserve the URI and reciprocal decryption must return the original bytes.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("custom-type.xml");
let plaintext = temp.path().join("payload.bin");
let encrypted = temp.path().join("encrypted.xml");
let key = temp.path().join("content.key");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="urn:example:opaque"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"opaque application bytes").unwrap();
fs::write(&key, [0x57_u8; 16]).unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
assert!(
fs::read_to_string(&encrypted)
.unwrap()
.contains("Type=\"urn:example:opaque\"")
);
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"opaque application bytes");
}
#[test]
fn lax_signing_preserves_order_across_private_key_option_kinds() {
// The first compatible key is determined by CLI occurrence order, not by
// the PEM/DER spelling used to supply it.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let first_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.unwrap();
let first = RsaPrivateKey::from_pkcs8_pem(&first_pem).unwrap();
let first_der = temp.path().join("first.der");
fs::write(&first_der, first.to_pkcs8_der().unwrap().as_bytes()).unwrap();
let second = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let first_public = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-der"])
.arg(&first_der)
.arg("--privkey-pem")
.arg(&second)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&first_public)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn signing_writes_requested_diagnostics_separately_from_output() {
// Diagnostic flags describe the completed signing context; the signed XML
// remains exclusively in --output so shell callers can consume both streams.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
for (flag, expected) in [
("--print-debug", "Status: succeeded"),
("--print-xml-debug", "<SignatureContext"),
] {
let signed = temp.path().join(format!("signed-{flag}.xml"));
let output = Command::new(binary())
.args(["sign", flag, "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
fs::read_to_string(&signed)
.unwrap()
.contains("SignatureValue")
);
let diagnostics = String::from_utf8(output.stdout).unwrap();
assert!(diagnostics.contains(expected), "{diagnostics}");
if flag == "--print-xml-debug" {
let document = roxmltree::Document::parse(&diagnostics)
.expect("signing XML diagnostics must be well-formed");
assert_eq!(
document.root_element().attribute("status"),
Some("SUCCEEDED")
);
}
}
}
#[test]
fn debug_options_are_rejected_for_non_crypto_commands() {
// Diagnostic contexts exist only for DSig and XMLEnc operations. Accepting
// the flags on keys would silently discard a recognized caller request.
let output = Command::new(binary())
.args(["keys", "--print-debug", "--gen-key:test", "aes-128"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(
"option --print-debug is recognized but is not applicable to the keys command"
),
"{stderr}"
);
}
#[test]
fn signing_without_output_preserves_donor_stdout_order() {
// libxmlsec1 multiplexes a stdout result and requested debug context when
// --output is absent; preserve the result-first order for drop-in callers.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output = Command::new(binary())
.args(["sign", "--print-debug", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let diagnostics = stdout
.find("== Signature Context")
.expect("debug context must follow the signed result");
let signed_xml = stdout[..diagnostics].trim_end();
let document = roxmltree::Document::parse(signed_xml)
.expect("the result prefix before diagnostics must remain valid XML");
assert_eq!(document.root_element().tag_name().name(), "Signature");
assert!(stdout[diagnostics..].contains("Status: succeeded"));
}
#[test]
fn scoped_id_attribute_selects_and_signs_the_registered_element() {
// An unqualified libxmlsec1 --id-attr element name is namespace-agnostic;
// the custom attribute must drive selection and Reference resolution.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("scoped-id.xml");
let signed = temp.path().join("scoped-id-signed.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r##"<root xmlns:app="urn:application"><app:Envelope Token="selected"><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><Reference URI="#selected"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue/></Reference></SignedInfo><SignatureValue/></Signature><payload>registered</payload></app:Envelope></root>"##,
)
.unwrap();
let sign = Command::new(binary())
.args([
"sign",
"--id-attr:Token",
"Envelope",
"--node-id",
"selected",
])
.args(["--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args([
"verify",
"--id-attr:Token",
"Envelope",
"--node-id",
"selected",
])
.arg("--pubkey-pem")
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let wrong_element = Command::new(binary())
.args(["sign", "--id-attr:Token", "Other", "--node-id", "selected"])
.arg("--privkey-pem")
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!wrong_element.status.success());
assert!(String::from_utf8_lossy(&wrong_element.stderr).contains("missing or ambiguous"));
}
#[test]
fn xml_debug_verification_output_matches_the_donor_xml_contract() {
// The upstream runner parses --print-xml-debug output with xmllint, so the
// diagnostic mode must not share the plain-text debug renderer.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(sign.status.success());
let verify = Command::new(binary())
.args(["verify", "--print-xml-debug", "--pubkey-pem"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
let debug_xml = String::from_utf8(verify.stdout).unwrap();
let document = roxmltree::Document::parse(&debug_xml).expect("debug output must be XML");
let root = document.root_element();
assert_eq!(root.tag_name().name(), "VerificationContext");
assert_eq!(root.attribute("status"), Some("OK"));
assert_eq!(root.attribute("failureReason"), Some("UNKNOWN"));
let tampered = temp.path().join("tampered.xml");
fs::write(
&tampered,
fs::read_to_string(&signed)
.unwrap()
.replace("some text", "tampered text"),
)
.unwrap();
let invalid = Command::new(binary())
.args(["verify", "--print-xml-debug", "--pubkey-pem"])
.arg(&public_key)
.arg(&tampered)
.output()
.unwrap();
assert!(!invalid.status.success());
let invalid_xml = String::from_utf8(invalid.stdout).unwrap();
let invalid_document =
roxmltree::Document::parse(&invalid_xml).expect("invalid debug output must still be XML");
let invalid_root = invalid_document.root_element();
assert_eq!(invalid_root.attribute("status"), Some("FAILED"));
assert_eq!(invalid_root.attribute("failureReason"), Some("REFERENCE"));
}
#[test]
fn xml_debug_reports_authenticated_manifest_reference_failure_at_the_root() {
// A Manifest digest failure invalidates the CLI operation even when the
// outer SignedInfo and SignatureValue remain cryptographically valid.
let temp = tempfile::tempdir().unwrap();
let private_key_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem"))
.unwrap();
let private_key = RsaSigningKey::from_pkcs8_pem(&private_key_pem).unwrap();
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let c14n = C14nAlgorithm::new(C14nMode::Exclusive1_0, false);
let digest_probe = SignatureBuilder::new(c14n.clone(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#manifest-payload")
.transform(Transform::C14n(c14n.clone())),
);
let probe = SignContext::new(&private_key)
.sign_with_builder(
"<root><payload Id=\"manifest-payload\">original</payload></root>",
&digest_probe,
)
.unwrap();
let probe_document = roxmltree::Document::parse(&probe).unwrap();
let manifest_digest = probe_document
.descendants()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "DigestValue")))
.and_then(|node| node.text())
.unwrap();
let template = SignatureBuilder::new(c14n.clone(), SignatureAlgorithm::RsaSha256)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("#manifest")
.transform(Transform::C14n(c14n)),
)
.build_template()
.unwrap()
.replace(
"</Signature>",
&format!(
"<Object><Manifest Id=\"manifest\"><Reference URI=\"#manifest-payload\"><DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"/><DigestValue>{manifest_digest}</DigestValue></Reference></Manifest></Object></Signature>"
),
);
let unsigned = append_signature_to_root(
"<root><payload Id=\"manifest-payload\">original</payload></root>",
&template,
)
.unwrap();
let signed = SignContext::new(&private_key)
.sign_template(&unsigned)
.unwrap();
let tampered = temp.path().join("manifest-tampered.xml");
fs::write(&tampered, signed.replace(">original<", ">tampered<")).unwrap();
let output = Command::new(binary())
.args(["verify", "--print-xml-debug", "--pubkey-pem"])
.arg(&public_key)
.arg(&tampered)
.output()
.unwrap();
assert!(!output.status.success());
let diagnostics = String::from_utf8(output.stdout).unwrap();
let document = roxmltree::Document::parse(&diagnostics).unwrap();
let root = document.root_element();
assert_eq!(root.attribute("status"), Some("FAILED"));
assert_eq!(root.attribute("failureReason"), Some("REFERENCE"));
let signed_info_statuses = root
.children()
.find(|node| node.has_tag_name("SignedInfoReferences"))
.expect("SignedInfo diagnostics")
.children()
.filter(|node| node.has_tag_name("ReferenceVerificationContext"))
.filter_map(|node| node.attribute("status"))
.collect::<Vec<_>>();
let manifest_statuses = root
.children()
.find(|node| node.has_tag_name("ManifestReferences"))
.expect("Manifest diagnostics")
.children()
.filter(|node| node.has_tag_name("ReferenceVerificationContext"))
.filter_map(|node| node.attribute("status"))
.collect::<Vec<_>>();
assert_eq!(signed_info_statuses, ["OK"]);
assert_eq!(manifest_statuses, ["FAILED"]);
}
#[test]
fn short_command_help_alias_reaches_process_dispatch() {
// Parsing an alias is insufficient if command validation later rejects its
// canonical option, so exercise the complete process route for donor `-h`.
let output = Command::new(binary())
.args(["verify", "-h"])
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).starts_with("Usage:"));
}
#[test]
fn donor_help_command_targets_one_runtime_command() {
// libxmlsec1 accepts help-<cmd>, not topic aliases such as help-dsig.
let output = Command::new(binary()).arg("help-sign").output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).starts_with("Usage: xmlsec1 sign"));
let output = Command::new(binary()).arg("help-dsig").output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("Unknown command"));
assert!(!String::from_utf8_lossy(&output.stdout).contains("Usage: xmlsec1 sign"));
for command in ["---h", "---?"] {
let output = Command::new(binary()).arg(command).output().unwrap();
assert!(!output.status.success(), "unexpectedly accepted {command}");
}
}
#[test]
fn signs_and_verifies_libxmlsec_legacy_here_semantics_through_process_api() {
// The expression selects different nodes under specification and libxmlsec
// semantics, so this round trip proves both CLI policies reach transforms.
let temp = tempfile::tempdir().unwrap();
let template_path = temp.path().join("legacy-here-template.xml");
let signed_path = temp.path().join("legacy-here.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let builder = SignatureBuilder::new(
C14nAlgorithm::new(C14nMode::Exclusive1_0, false),
SignatureAlgorithm::RsaSha256,
)
.add_reference(
ReferenceBuilder::new(DigestAlgorithm::Sha256)
.uri("")
.transform(Transform::XPath(XPathExpression::new(
"count(. | here()) = 1",
))),
);
let template = append_signature_to_root(
"<root><payload>legacy here</payload></root>",
&builder.build_template().unwrap(),
)
.unwrap();
fs::write(&template_path, template).unwrap();
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed_path)
.arg(&template_path)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(public_key)
.arg(signed_path)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn named_public_key_obeys_signature_key_name_unless_lax() {
// Named direct keys participate in the same strict KeyName contract as a
// key manager; --lax-key-search is the explicit compatibility escape hatch.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(sign.status.success());
let strict = Command::new(binary())
.args(["verify", "--pubkey-pem:wrong"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("KeyName"));
let lax = Command::new(binary())
.args(["verify", "--lax-key-search", "--pubkey-pem:wrong"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
}
#[test]
fn lax_verification_searches_past_incompatible_key_types() {
// Lax lookup relaxes KeyName matching, but it must still search by the
// signature algorithm instead of treating the first registry entry as final.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-ecdsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/ec/ec-prime256v1-key.pem");
let rsa_public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let ec_public_key = project_root().join("tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--lax-key-search", "--pubkey-pem:rsa"])
.arg(&rsa_public_key)
.arg("--pubkey-pem:ec")
.arg(&ec_public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn lax_verification_does_not_recover_from_aggregate_material_exhaustion() {
// Lax search may skip malformed key candidates, but it must not turn the
// invocation-wide 32 MiB material ceiling into a per-candidate limit.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg("--output")
.arg(&signed)
.arg(template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let mut malformed = Vec::new();
for (index, size) in [8, 8, 8, 1, 8].into_iter().enumerate() {
let path = temp.path().join(format!("malformed-{index}.pem"));
fs::File::create(&path)
.unwrap()
.set_len(size * 1024 * 1024)
.unwrap();
malformed.push(path);
}
let mut verify = Command::new(binary());
verify.args(["verify", "--lax-key-search"]);
for (index, path) in malformed.iter().enumerate() {
verify.arg(format!("--pubkey-pem:malformed-{index}"));
verify.arg(path);
}
let output = verify
.arg("--pubkey-pem:valid")
.arg(public_key)
.arg(signed)
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("configured external key/certificate material exceeds policy limit")
);
}
#[test]
fn strict_verification_searches_each_distinct_template_key_name() {
// Separate KeyNames identify separate key-manager entries. A unique match
// for each name is a search order, not an ambiguity across the whole set.
let temp = tempfile::tempdir().unwrap();
let source_template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let template_path = temp.path().join("template.xml");
let signed_path = temp.path().join("signed.xml");
let template = fs::read_to_string(source_template).unwrap().replace(
"<KeyName>TestKeyName-rsa-2048</KeyName>",
"<KeyName>wrong</KeyName><KeyName>valid</KeyName>",
);
fs::write(&template_path, template).unwrap();
let valid_private = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let wrong_public = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let valid_public = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(valid_private)
.arg("--output")
.arg(&signed_path)
.arg(&template_path)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem:wrong"])
.arg(wrong_public)
.arg("--pubkey-pem:valid")
.arg(valid_public)
.arg(signed_path)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn strict_verification_rejects_a_selected_key_load_failure() {
// Every strict named option is explicit configuration and must load. Lax
// search alone may skip an unreadable candidate and continue to a valid key.
let temp = tempfile::tempdir().unwrap();
let source_template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let template_path = temp.path().join("template.xml");
let signed_path = temp.path().join("signed.xml");
let missing_key = temp.path().join("missing-public-key.pem");
let template = fs::read_to_string(source_template).unwrap().replace(
"<KeyName>TestKeyName-rsa-2048</KeyName>",
"<KeyName>missing</KeyName><KeyName>valid</KeyName>",
);
fs::write(&template_path, template).unwrap();
let valid_private = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let valid_public = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(valid_private)
.arg("--output")
.arg(&signed_path)
.arg(&template_path)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = |lax: bool| {
let mut command = Command::new(binary());
command.arg("verify");
if lax {
command.arg("--lax-key-search");
}
command
.arg("--pubkey-pem:missing")
.arg(&missing_key)
.arg("--pubkey-pem:valid")
.arg(&valid_public)
.arg(&signed_path)
.output()
.unwrap()
};
let strict = verify(false);
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("missing-public-key.pem"));
let lax = verify(true);
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
let incompatible_certificate =
project_root().join("tests/fixtures/keys/ec/ec-prime256v1-cert.pem");
let verify_certificate = |lax: bool| {
let mut command = Command::new(binary());
command.arg("verify");
if lax {
command.arg("--lax-key-search");
}
command
.arg("--pubkey-cert-pem:missing")
.arg(&incompatible_certificate)
.arg("--pubkey-pem:valid")
.arg(&valid_public)
.arg(&signed_path)
.output()
.unwrap()
};
let strict_certificate = verify_certificate(false);
assert!(!strict_certificate.status.success());
let lax_certificate = verify_certificate(true);
assert!(
lax_certificate.status.success(),
"{}",
String::from_utf8_lossy(&lax_certificate.stderr)
);
}
#[test]
fn named_verification_certificate_obeys_signature_key_name_unless_lax() {
// Explicit certificates are pinned verification identities, so naming one
// must use the same strict lookup contract as naming a raw public key.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let certificate_der = temp.path().join("certificate.der");
let certificate_pem = fs::read(&certificate).unwrap();
let (_, certificate_contents) = x509_parser::pem::parse_x509_pem(&certificate_pem).unwrap();
fs::write(&certificate_der, certificate_contents.contents).unwrap();
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(sign.status.success());
for (option, path) in [
("pubkey-cert-pem", certificate.as_path()),
("pubkey-cert-der", certificate_der.as_path()),
] {
let matching = Command::new(binary())
.args(["verify", &format!("--{option}:TestKeyName-rsa-2048")])
.arg(path)
.arg(&signed)
.output()
.unwrap();
assert!(
matching.status.success(),
"{}",
String::from_utf8_lossy(&matching.stderr)
);
let strict = Command::new(binary())
.args(["verify", &format!("--{option}:wrong")])
.arg(path)
.arg(&signed)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("KeyName"));
let lax = Command::new(binary())
.args(["verify", "--lax-key-search", &format!("--{option}:wrong")])
.arg(path)
.arg(&signed)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
}
}
#[test]
fn pinned_verification_certificate_ignores_crls_without_trust_anchors() {
// CRL processing belongs to path validation. A caller-pinned certificate
// without separate anchors remains a direct verification key even when a
// generic compatibility invocation also supplies --verify-crls.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let signed = temp.path().join("signed.xml");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg("--output")
.arg(&signed)
.arg(template)
.output()
.unwrap();
assert!(sign.status.success());
let verify = Command::new(binary())
.args(["verify", "--verify-crls", "--pubkey-cert-pem"])
.arg(certificate)
.arg(signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn asymmetric_option_names_enforce_their_declared_encoding() {
// Automation must fail closed when a PEM file is wired to a DER option;
// accepting it would make the public xmlsec1 option contract misleading.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let rejected_sign = Command::new(binary())
.args(["sign", "--privkey-der"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!rejected_sign.status.success());
let signed = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(signed.status.success());
let temp = tempfile::tempdir().unwrap();
let signed_path = temp.path().join("signed.xml");
fs::write(&signed_path, signed.stdout).unwrap();
for (option, path) in [
("pubkey-der", public_key.as_path()),
("pubkey-cert-der", certificate.as_path()),
] {
let rejected = Command::new(binary())
.args(["verify", &format!("--{option}")])
.arg(path)
.arg(&signed_path)
.output()
.unwrap();
assert!(
!rejected.status.success(),
"unexpectedly accepted --{option}"
);
}
}
#[test]
fn verification_reads_the_conventional_stdin_marker() {
// A lone dash is input data, not an option name; this is the process-level
// contract used by shell pipelines and the donor CLI.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let signed = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(signed.status.success());
let mut verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
verify
.stdin
.take()
.unwrap()
.write_all(&signed.stdout)
.unwrap();
let output = verify.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn verification_reports_output_as_inapplicable() {
// Verification produces status and diagnostics, not a transformed document;
// reject the shared donor option with a command-specific diagnostic.
let output = Command::new(binary())
.args(["verify", "--output", "unused.xml", "input.xml"])
.output()
.unwrap();
assert!(!output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"Error: option --output is recognized but is not applicable to the verify command\n"
);
}
#[test]
fn signing_processes_manifests_unless_explicitly_ignored() {
// libxmlsec1 fills direct Object/Manifest references before SignedInfo;
// --ignore-manifests is the explicit opt-out, not an accepted no-op.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("manifest-template.xml");
fs::write(
&template,
r##"<root><payload Id="payload">manifest payload</payload><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#manifest"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue/></ds:Reference></ds:SignedInfo><ds:SignatureValue/><ds:Object><ds:Manifest Id="manifest"><ds:Reference URI="#payload"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>stale</ds:DigestValue></ds:Reference></ds:Manifest></ds:Object></ds:Signature></root>"##,
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let sign = |ignore: bool| {
let mut command = Command::new(binary());
command.arg("sign");
if ignore {
command.arg("--ignore-manifests");
}
command
.arg("--privkey-pem")
.arg(&private_key)
.arg(&template)
.output()
.unwrap()
};
let processed = sign(false);
assert!(
processed.status.success(),
"{}",
String::from_utf8_lossy(&processed.stderr)
);
assert!(!String::from_utf8_lossy(&processed.stdout).contains(">stale</ds:DigestValue>"));
let ignored = sign(true);
assert!(
ignored.status.success(),
"{}",
String::from_utf8_lossy(&ignored.stderr)
);
assert!(String::from_utf8_lossy(&ignored.stdout).contains(">stale</ds:DigestValue>"));
}
#[test]
fn ignore_manifests_preserves_signed_info_dependency_checks() {
// The CLI flag opts out of Manifest traversal, not the dependency safety
// required to keep SignedInfo references valid after SignatureValue fill.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("signature-value-cycle.xml");
fs::write(
&template,
r##"<root><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>not(ancestor-or-self::ds:DigestValue)</ds:XPath></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue/></ds:Reference></ds:SignedInfo><ds:SignatureValue/></ds:Signature></root>"##,
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output = Command::new(binary())
.args(["sign", "--ignore-manifests", "--privkey-pem"])
.arg(private_key)
.arg(template)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("SignatureValue") && stderr.contains("cycle"),
"{stderr}"
);
}
#[test]
fn signing_processes_nested_manifests_in_dependency_order() {
// The process API must expose the same dependency-aware Manifest pipeline
// as SignContext rather than emitting a signature with a stale outer digest.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("nested-manifest-template.xml");
let signed = temp.path().join("nested-manifest-signed.xml");
fs::write(
&template,
r##"<root><payload Id="payload">nested payload</payload><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#outer"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue/></ds:Reference></ds:SignedInfo><ds:SignatureValue/><ds:Object><ds:Manifest Id="outer"><ds:Reference URI="#inner"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue/></ds:Reference></ds:Manifest><ds:Manifest Id="inner"><ds:Reference URI="#payload"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue/></ds:Reference></ds:Manifest></ds:Object></ds:Signature></root>"##,
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg("--output")
.arg(&signed)
.arg(template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(public_key)
.arg(signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn signing_accepts_utf16_xml_and_rejects_malformed_code_units() {
// XML 1.0 requires UTF-16 support at the byte-oriented process boundary.
let temp = tempfile::tempdir().unwrap();
let source = fs::read_to_string(
project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"),
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
for (name, bom, encode, declaration) in [
(
"le",
[0xff, 0xfe],
u16::to_le_bytes as fn(u16) -> [u8; 2],
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>",
),
(
"be",
[0xfe, 0xff],
u16::to_be_bytes as fn(u16) -> [u8; 2],
"<?xml version='1.0' encoding='utf-16'?>",
),
] {
let path = temp.path().join(format!("template-{name}.xml"));
let mut bytes = bom.to_vec();
let declaration_end = source.find("?>").expect("fixture XML declaration") + 2;
let encoded_source = format!("{declaration}{}", &source[declaration_end..]);
bytes.extend(encoded_source.encode_utf16().flat_map(encode));
fs::write(&path, bytes).unwrap();
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(path)
.output()
.unwrap();
assert!(
output.status.success(),
"{name}: {}",
String::from_utf8_lossy(&output.stderr)
);
let signed = String::from_utf8(output.stdout).unwrap();
let declaration_end = signed.find("?>").expect("signed XML declaration") + 2;
let declaration = &signed[..declaration_end];
assert!(declaration.contains("UTF-8"), "{name}: {declaration}");
assert!(!declaration.contains("UTF-16"), "{name}: {declaration}");
roxmltree::Document::parse(&signed).expect("transcoded output must remain byte-parseable");
}
let malformed = temp.path().join("malformed.xml");
fs::write(&malformed, [0xff, 0xfe, 0x00]).unwrap();
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg(malformed)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("odd byte length"));
}
#[test]
fn verification_node_id_selects_one_signature_subtree() {
// libxmlsec1 resolves --node-id to a start node and finds the Signature
// below it; unrelated signatures elsewhere in the document are ignored.
let temp = tempfile::tempdir().unwrap();
let original = fs::read_to_string(
project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"),
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let mut signatures = Vec::new();
for id in ["first", "second"] {
let template = temp.path().join(format!("{id}.xml"));
fs::write(
&template,
original
.replace("#object", &format!("#{id}-object"))
.replace("Id=\"object\"", &format!("Id=\"{id}-object\"")),
)
.unwrap();
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(output.status.success());
let signed = String::from_utf8(output.stdout).unwrap();
let signed = signed
.split_once("?>")
.map_or(signed.as_str(), |(_, body)| body)
.replace(
"<Signature xmlns=",
&format!("<Signature Id=\"{id}\" xmlns="),
);
signatures.push(signed);
}
signatures[1] = signatures[1].replace("some text", "tampered text");
let document = temp.path().join("multiple.xml");
fs::write(
&document,
format!(
"<Document><Envelope Id=\"both\">{}{}</Envelope></Document>",
signatures[0], signatures[1]
),
)
.unwrap();
let valid = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.args(["--node-id", "first"])
.arg(&document)
.output()
.unwrap();
assert!(
valid.status.success(),
"{}",
String::from_utf8_lossy(&valid.stderr)
);
let first_descendant = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.args(["--node-id", "both"])
.arg(&document)
.output()
.unwrap();
assert!(
first_descendant.status.success(),
"{}",
String::from_utf8_lossy(&first_descendant.stderr)
);
let invalid = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.args(["--node-id", "second"])
.arg(&document)
.output()
.unwrap();
assert!(!invalid.status.success());
assert!(String::from_utf8_lossy(&invalid.stderr).contains("signature is invalid"));
let missing = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.args(["--node-id", "missing"])
.arg(&document)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("selected node"));
let duplicate = temp.path().join("duplicate.xml");
fs::write(
&duplicate,
fs::read_to_string(&document)
.unwrap()
.replace("Id=\"second\"", "Id=\"first\""),
)
.unwrap();
let ambiguous = Command::new(binary())
.args(["verify", "--pubkey-pem"])
.arg(&public_key)
.args(["--node-id", "first"])
.arg(&duplicate)
.output()
.unwrap();
assert!(!ambiguous.status.success());
assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("ambiguous"));
}
#[test]
fn signing_node_id_selects_one_signature_subtree() {
// The donor performs a depth-first search from the --node-id start node.
// Signing must fill only the first descendant Signature template.
let temp = tempfile::tempdir().unwrap();
let original = fs::read_to_string(
project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"),
)
.unwrap();
let body = original
.split_once("?>")
.map_or(original.as_str(), |(_, body)| body);
let template = temp.path().join("multiple-templates.xml");
let first = body
.replace("#object", "#first-object")
.replace("Id=\"object\"", "Id=\"first-object\"");
let second = body
.replace("#object", "#second-object")
.replace("Id=\"object\"", "Id=\"second-object\"");
fs::write(
&template,
format!(
"<Document><Envelope Id=\"first\">{first}{second}</Envelope><Envelope Id=\"second\"/></Document>"
),
)
.unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--node-id", "first"])
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let signed = String::from_utf8(output.stdout).unwrap();
let document = roxmltree::Document::parse(&signed).unwrap();
let values = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "SignatureValue")))
.map(|node| node.text().unwrap_or_default())
.collect::<Vec<_>>();
assert!(!values[0].trim().is_empty());
assert!(values[1].trim().is_empty());
let missing = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--node-id", "missing"])
.arg(&template)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("missing or ambiguous"));
let duplicate = temp.path().join("duplicate-start.xml");
fs::write(
&duplicate,
fs::read_to_string(&template)
.unwrap()
.replace("Id=\"second\"", "Id=\"first\""),
)
.unwrap();
let ambiguous = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.args(["--node-id", "first"])
.arg(&duplicate)
.output()
.unwrap();
assert!(!ambiguous.status.success());
assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("missing or ambiguous"));
}
#[test]
fn signing_without_node_selector_uses_first_signature_template() {
// Default sign selection follows document order, matching verification and
// libxmlsec1 rather than unexpectedly targeting the last Signature.
let temp = tempfile::tempdir().unwrap();
let body = signature_template_without_key_info();
let first = body
.replace("#object", "#first")
.replace("Id=\"object\"", "Id=\"first\"");
let second = body
.replace("#object", "#second")
.replace("Id=\"object\"", "Id=\"second\"");
let template = temp.path().join("default-signature-selection.xml");
fs::write(&template, format!("<Document>{first}{second}</Document>")).unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let signed = String::from_utf8(output.stdout).unwrap();
let document = roxmltree::Document::parse(&signed).unwrap();
let values = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "SignatureValue")))
.map(|node| node.text().unwrap_or_default().trim().to_owned())
.collect::<Vec<_>>();
assert!(!values[0].is_empty());
assert!(values[1].is_empty());
}
#[test]
fn output_template_expands_the_extensionless_input_basename() {
// libxmlsec1 automation uses one output template across many input files;
// only the first placeholder is replaced and the input extension is removed.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output_template = temp.path().join("signed-{inputfile}-{inputfile}.xml");
let expected = temp
.path()
.join("signed-enveloping-sha256-rsa-sha256-{inputfile}.xml");
let signed = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg("--output")
.arg(output_template)
.arg(template)
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
assert!(expected.is_file());
}
#[test]
fn repeated_output_options_fail_before_creating_files() {
// Canonical and alias spellings identify one donor singleton; accepting
// both would silently redirect output through last-value wins behavior.
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.xml");
let second = temp.path().join("second.xml");
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg("--output")
.arg(&first)
.arg("-o")
.arg(&second)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be repeated"));
assert!(!first.exists());
assert!(!second.exists());
}
#[test]
fn encrypts_decrypts_and_rejects_wrong_symmetric_key() {
// A reciprocal binary round trip must preserve non-UTF-8 bytes, while an
// authenticated GCM decrypt with the wrong key must fail.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
let wrong_key = temp.path().join("wrong-key.bin");
let encrypted = temp.path().join("encrypted.xml");
let decrypted = temp.path().join("decrypted.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"process-level binary payload\0\xff").unwrap();
// xmlsec1 treats --aeskey input as raw bytes even when all bytes happen to
// be valid Base64 characters.
fs::write(&key, b"0123456789abcdef").unwrap();
fs::write(&wrong_key, b"fedcba9876543210").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aeskey:content"])
.arg(&key)
.args(["--binary-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--aeskey"])
.arg(&key)
.args(["--output"])
.arg(&decrypted)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap());
let rejected = Command::new(binary())
.args(["decrypt", "--aeskey"])
.arg(&wrong_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(!rejected.status.success());
}
#[test]
fn key_store_supplies_aes_key_for_encryption_and_decryption() {
// The same named key store must serve both sides of a binary encryption
// round trip; a second store with different material must not decrypt it.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let store = temp.path().join("keys.xml");
let wrong_store = temp.path().join("wrong.xml");
let encrypted = temp.path().join("encrypted.xml");
let decrypted = temp.path().join("decrypted.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><KeyName>content</KeyName></KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"key-store round trip\0\xff").unwrap();
for (path, key) in [
(&store, b"0123456789abcdef"),
(&wrong_store, b"fedcba9876543210"),
] {
let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
fs::write(path, format!("<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>content</KeyName><KeyValue><AESKeyValue xmlns=\"http://www.aleksey.com/xmlsec/2002\">{encoded}</AESKeyValue></KeyValue></KeyInfo></Keys>")).unwrap();
}
let encrypt = Command::new(binary())
.args(["encrypt", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&store)
.arg("--output")
.arg(&decrypted)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap());
// An embedded recipient does not invalidate a separate direct content key.
// Both explicit and stored direct keys must take the same selection path.
let with_recipient = temp.path().join("encrypted-with-recipient.xml");
let direct_key = temp.path().join("content.key");
fs::write(&direct_key, b"0123456789abcdef").unwrap();
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
assert!(encrypted_xml.contains("</KeyInfo>"));
let encrypted_xml = encrypted_xml.replacen(
"</KeyInfo>",
"<EncryptedKey xmlns=\"http://www.w3.org/2001/04/xmlenc#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>recipient</KeyName></KeyInfo><CipherData><CipherValue>AA==</CipherValue></CipherData></EncryptedKey></KeyInfo>",
1,
);
fs::write(&with_recipient, encrypted_xml).unwrap();
let explicit = Command::new(binary())
.args(["decrypt", "--aes-key:content"])
.arg(&direct_key)
.arg(&with_recipient)
.output()
.unwrap();
assert!(
explicit.status.success(),
"{}",
String::from_utf8_lossy(&explicit.stderr)
);
assert_eq!(explicit.stdout, fs::read(&plaintext).unwrap());
let stored = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&store)
.arg(&with_recipient)
.output()
.unwrap();
assert!(
stored.status.success(),
"{}",
String::from_utf8_lossy(&stored.stderr)
);
assert_eq!(stored.stdout, fs::read(&plaintext).unwrap());
let wrong = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&wrong_store)
.arg(&encrypted)
.output()
.unwrap();
assert!(!wrong.status.success());
let mixed_store = temp.path().join("mixed.xml");
let wrong_encoded = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b"fedcba9876543210",
);
let right_encoded = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b"0123456789abcdef",
);
fs::write(
&mixed_store,
format!("<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>wrong</KeyName><KeyValue><AESKeyValue xmlns=\"http://www.aleksey.com/xmlsec/2002\">{wrong_encoded}</AESKeyValue></KeyValue></KeyInfo><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>content</KeyName><KeyValue><AESKeyValue xmlns=\"http://www.aleksey.com/xmlsec/2002\">{right_encoded}</AESKeyValue></KeyValue></KeyInfo></Keys>"),
)
.unwrap();
let lax = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--keys-file"])
.arg(&mixed_store)
.arg(&encrypted)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
assert_eq!(lax.stdout, fs::read(&plaintext).unwrap());
}
#[test]
fn key_store_rsa_recipient_round_trips_with_explicit_private_key() {
// A named RSAKeyValue in the imported store must serve an EncryptedKey
// recipient without an additional public-key file option.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let store = temp.path().join("keys.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"))
.unwrap();
let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap();
let base64 = base64::engine::general_purpose::STANDARD;
fs::write(
&store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>recipient</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo></Keys>",
base64.encode(public.n().to_be_bytes_trimmed_vartime()),
base64.encode(public.e().to_be_bytes_trimmed_vartime()),
),
)
.unwrap();
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>recipient</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"named RSA recipient payload").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, fs::read(&plaintext).unwrap());
// Lax lookup must still prefer the recipient's exact name over an earlier
// usable-but-wrong RSA key in the same store.
let wrong_public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap();
let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap();
fs::write(
&store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>wrong</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>recipient</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo></Keys>",
base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()),
base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()),
base64.encode(public.n().to_be_bytes_trimmed_vartime()),
base64.encode(public.e().to_be_bytes_trimmed_vartime()),
),
)
.unwrap();
let lax_encrypted = temp.path().join("lax-encrypted.xml");
let lax_encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&lax_encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
lax_encrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_encrypt.stderr)
);
let lax_decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.arg(&lax_encrypted)
.output()
.unwrap();
assert!(
lax_decrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_decrypt.stderr)
);
assert_eq!(lax_decrypt.stdout, fs::read(&plaintext).unwrap());
let unsupported_store_decrypt = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&store)
.arg(&encrypted)
.output()
.unwrap();
assert!(!unsupported_store_decrypt.status.success());
assert!(
String::from_utf8_lossy(&unsupported_store_decrypt.stderr)
.contains("--keys-file does not supply RSA recipient private keys")
);
// Public-key store rejection identifies the unavailable private material
// and the applicable input options, rather than implying failed recovery.
assert!(
String::from_utf8_lossy(&unsupported_store_decrypt.stderr).contains(
"RSAKeyValue imports are public-only; use --privkey-pem, --privkey-der, or --pkcs12"
)
);
let wrong_public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap();
let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap();
let conflicting = format!(
"<EncryptedData xmlns=\"http://www.w3.org/2001/04/xmlenc#\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/><ds:KeyInfo><ds:KeyName>recipient</ds:KeyName><ds:KeyValue><ds:RSAKeyValue><ds:Modulus>{}</ds:Modulus><ds:Exponent>{}</ds:Exponent></ds:RSAKeyValue></ds:KeyValue></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>",
base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()),
base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()),
);
fs::write(&template, conflicting).unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
}
#[test]
fn lax_store_encryption_replaces_stale_content_key_name() {
// Lax fallback must publish the selected content-key identity so a strict
// decryptor can select that same key from the resulting document.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let store = temp.path().join("keys.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ds:KeyName>selected<!--split text-->-old</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
let encoded = base64::engine::general_purpose::STANDARD.encode(b"0123456789abcdef");
fs::write(
&store,
format!("<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\"><KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>selected</KeyName><KeyValue><AESKeyValue xmlns=\"http://www.aleksey.com/xmlsec/2002\">{encoded}</AESKeyValue></KeyValue></KeyInfo></Keys>"),
)
.unwrap();
fs::write(&plaintext, b"lax content key fallback").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
let content_key_name = document
.root_element()
.children()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyInfo")))
.and_then(|key_info| {
key_info
.children()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
})
.and_then(|node| node.text());
assert_eq!(content_key_name, Some("selected"));
let decrypt = Command::new(binary())
.args(["decrypt", "--keys-file"])
.arg(&store)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"lax content key fallback");
}
#[test]
fn lax_store_rsa_recipients_consume_distinct_candidates() {
// Lax selection consumes each entry once, including exact and singleton
// matches; every recipient must decrypt and exhaustion must emit no output.
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("keys.xml");
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let base64 = base64::engine::general_purpose::STANDARD;
let mut entries = Vec::new();
for (name, bits) in [("a", 2048), ("b", 4096)] {
let pem = fs::read_to_string(
project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-pubkey.pem")),
)
.unwrap();
let public = RsaPublicKey::from_public_key_pem(&pem).unwrap();
entries.push(format!(
"<KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>{name}</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo>",
base64.encode(public.n().to_be_bytes_trimmed_vartime()),
base64.encode(public.e().to_be_bytes_trimmed_vartime()),
));
}
fs::write(
&store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\">{}</Keys>",
entries.join("")
),
)
.unwrap();
fs::write(&plaintext, b"distinct store recipients").unwrap();
let write_template = |names: &[Option<&str>]| {
let recipients = names.iter().map(|name| {
let key_info = name.map_or_else(String::new, |name| format!("<ds:KeyInfo><ds:KeyName>{name}</ds:KeyName></ds:KeyInfo>"));
format!("<EncryptedKey><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/>{key_info}<CipherData><CipherValue/></CipherData></EncryptedKey>")
}).collect::<String>();
fs::write(&template, format!("<EncryptedData xmlns=\"http://www.w3.org/2001/04/xmlenc#\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><ds:KeyInfo>{recipients}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>")).unwrap();
};
let encrypt = |output: &Path| {
Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(output)
.arg(&template)
.output()
.unwrap()
};
for names in [
[None, None],
[Some("unknown-a"), Some("unknown-b")],
[Some("a"), None],
[None, Some("a")],
] {
write_template(&names);
let result = encrypt(&encrypted);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
// A fallback cannot steal the key requested by a later named slot.
if names == [None, Some("a")] {
let xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
let assigned = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.map(|node| node.text().unwrap())
.collect::<Vec<_>>();
assert_eq!(assigned, ["b", "a"]);
}
for bits in [2048, 4096] {
let result = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem"])
.arg(project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-key.pem")))
.arg(&encrypted)
.output()
.unwrap();
assert!(
result.status.success(),
"recipient {bits}, names {names:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(result.stdout, b"distinct store recipients");
}
}
// A stale later name must not reserve a key contradicted by its RSA
// metadata: the unnamed first slot needs a, and the later slot needs b.
let first_info = entries[0].replace("<KeyName>a</KeyName>", "");
let second_info = entries[1].replace("<KeyName>b</KeyName>", "<KeyName>a</KeyName>");
let recipients = [first_info, second_info].into_iter().map(|info| format!(
"<EncryptedKey><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/>{info}<CipherData><CipherValue/></CipherData></EncryptedKey>"
)).collect::<String>();
fs::write(&template, format!("<EncryptedData xmlns=\"http://www.w3.org/2001/04/xmlenc#\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><ds:KeyInfo>{recipients}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>")).unwrap();
let result = encrypt(&encrypted);
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.map(|node| node.text().unwrap())
.collect::<Vec<_>>(),
["a", "b"]
);
for bits in [2048, 4096] {
let decrypted = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem"])
.arg(project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-key.pem")))
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypted.status.success(),
"{}",
String::from_utf8_lossy(&decrypted.stderr)
);
assert_eq!(decrypted.stdout, b"distinct store recipients");
}
for (names, single_key) in [(vec![None, None, None], false), (vec![None, None], true)] {
if single_key {
fs::write(
&store,
format!(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\">{}</Keys>",
entries[0]
),
)
.unwrap();
}
write_template(&names);
let output = temp.path().join(if single_key {
"singleton.xml"
} else {
"exhausted.xml"
});
let result = encrypt(&output);
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr)
.contains("no compatible RSA key in --keys-file")
);
assert!(!output.exists());
assert!(result.stdout.is_empty());
}
}
#[test]
fn lax_rsa_recipients_charge_only_attempted_store_keys() {
// Two exact recipients must not each consume the 33 unused lax fallbacks.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let store = temp.path().join("keys.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"))
.unwrap();
let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap();
let base64 = base64::engine::general_purpose::STANDARD;
let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime());
let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime());
let mut key_store = String::from("<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\">");
for index in 0..33 {
key_store.push_str(&format!(
"<KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>recipient-{index}</KeyName><KeyValue><RSAKeyValue><Modulus>{modulus}</Modulus><Exponent>{exponent}</Exponent></RSAKeyValue></KeyValue></KeyInfo>"
));
}
key_store.push_str("</Keys>");
fs::write(&store, key_store).unwrap();
let mut recipient_nodes = String::new();
for index in 0..2 {
recipient_nodes.push_str(&format!(
"<EncryptedKey><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/><ds:KeyInfo><ds:KeyName>recipient-{index}</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey>"
));
}
fs::write(
&template,
format!(
"<EncryptedData xmlns=\"http://www.w3.org/2001/04/xmlenc#\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><ds:KeyInfo>{recipient_nodes}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"
),
)
.unwrap();
fs::write(&plaintext, b"two named recipients").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.arg(&encrypted)
.output()
.unwrap();
assert!(decrypt.status.success());
assert_eq!(decrypt.stdout, b"two named recipients");
}
#[test]
fn lax_cached_recipients_charge_decoding_once() {
// Reservation validates immutable metadata and retains the decoded key.
// Reusing it must not consume another candidate, including at the full cap.
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("keys.xml");
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap();
let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap();
let base64 = base64::engine::general_purpose::STANDARD;
let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime());
let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime());
let key_value = format!(
"<ds:KeyValue><ds:RSAKeyValue><ds:Modulus>{modulus}</ds:Modulus><ds:Exponent>{exponent}</ds:Exponent></ds:RSAKeyValue></ds:KeyValue>"
);
fs::write(&plaintext, b"cached recipient boundary").unwrap();
for count in [33, 64] {
let mut key_store = String::from(
"<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">",
);
let mut recipients = String::new();
for index in 0..count {
key_store.push_str(&format!(
"<ds:KeyInfo><ds:KeyName>recipient-{index}</ds:KeyName>{key_value}</ds:KeyInfo>"
));
recipients.push_str(&format!(
"<EncryptedKey><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#rsa-oaep\"/><ds:KeyInfo><ds:KeyName>recipient-{index}</ds:KeyName>{key_value}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey>"
));
}
key_store.push_str("</Keys>");
fs::write(&store, key_store).unwrap();
fs::write(&template, format!(
"<EncryptedData xmlns=\"http://www.w3.org/2001/04/xmlenc#\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><ds:KeyInfo>{recipients}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"
)).unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{count} recipients: {}",
String::from_utf8_lossy(&encrypt.stderr)
);
let xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
let actual_names: Vec<_> = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.map(|node| node.text().unwrap())
.collect();
let expected_names: Vec<_> = (0..count)
.map(|index| format!("recipient-{index}"))
.collect();
assert_eq!(actual_names, expected_names);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:recipient-0"])
.arg(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem"))
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"cached recipient boundary");
}
}
#[test]
fn encryption_writes_requested_diagnostics_and_rejects_duplicate_methods() {
// Encryption diagnostics are a separate stdout contract, and malformed
// templates must fail before either diagnostics or ciphertext is emitted.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(&plaintext, b"diagnostic payload").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
for (flag, expected) in [
("--print-debug", "Status: succeeded"),
("--print-xml-debug", "<DataEncryptionContext"),
] {
let template = temp.path().join(format!("template-{flag}.xml"));
let encrypted = temp.path().join(format!("encrypted-{flag}.xml"));
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
let output = Command::new(binary())
.args(["encrypt", flag, "--aeskey"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
fs::read_to_string(&encrypted)
.unwrap()
.contains("CipherValue")
);
let diagnostics = String::from_utf8(output.stdout).unwrap();
assert!(diagnostics.contains(expected), "{diagnostics}");
if flag == "--print-xml-debug" {
let document = roxmltree::Document::parse(&diagnostics)
.expect("encryption XML diagnostics must be well-formed");
assert_eq!(
document.root_element().attribute("status"),
Some("replaced")
);
}
}
let malformed = temp.path().join("duplicate-method.xml");
let rejected_output = temp.path().join("must-not-exist.xml");
fs::write(
&malformed,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--aeskey"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&rejected_output)
.arg(&malformed)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr).contains("more than one direct EncryptionMethod")
);
assert!(!rejected_output.exists());
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
for (case, malformed_xml, key_args) in [
(
"duplicate-cipher-data",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData><CipherData><CipherValue/></CipherData></EncryptedData>"#,
None,
),
(
"key-info-after-cipher-data",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData><ds:KeyInfo><ds:KeyName>late</ds:KeyName></ds:KeyInfo></EncryptedData>"#,
None,
),
(
"recipient-without-value",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/><CipherData/></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
Some(&public_key),
),
(
"recipient-cipher-reference",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/><CipherData><CipherReference URI="urn:cipher"/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
Some(&public_key),
),
] {
let malformed = temp.path().join(format!("{case}.xml"));
let rejected_output = temp.path().join(format!("{case}-output.xml"));
fs::write(&malformed, malformed_xml).unwrap();
let mut command = Command::new(binary());
command.arg("encrypt");
if let Some(public_key) = key_args {
command.arg("--pubkey-pem").arg(public_key);
} else {
command.arg("--aeskey").arg(&key);
}
let rejected = command
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&rejected_output)
.arg(&malformed)
.output()
.unwrap();
assert!(!rejected.status.success(), "{case} unexpectedly succeeded");
assert!(!rejected_output.exists(), "{case} emitted partial output");
}
}
#[test]
fn xml_debug_decryption_writes_diagnostics_separately_from_plaintext() {
// The unmodified donor runner redirects diagnostics from stdout while
// --output receives the decrypted payload, then parses stdout as XML.
let temp = tempfile::tempdir().unwrap();
let fixtures = project_root().join("tools/xmlsec1/tests/fixtures/upstream");
let vector = fixtures.join("xmlenc11-interop-2012/xenc11-example-AES128-GCM");
let decrypted = temp.path().join("decrypted.data");
let text_decrypted = temp.path().join("text-decrypted.data");
let text_output = Command::new(binary())
.args(["decrypt", "--print-debug", "--lax-key-search", "--aeskey"])
.arg(vector.with_extension("key"))
.arg("--output")
.arg(&text_decrypted)
.arg(vector.with_extension("xml"))
.output()
.unwrap();
assert!(
text_output.status.success(),
"{}",
String::from_utf8_lossy(&text_output.stderr)
);
assert_eq!(
fs::read(&text_decrypted).unwrap(),
fs::read(vector.with_extension("data")).unwrap()
);
let text_diagnostics = String::from_utf8(text_output.stdout).unwrap();
assert!(text_diagnostics.contains("== Data Decryption Context"));
assert!(text_diagnostics.contains("Status: succeeded"));
let output = Command::new(binary())
.args([
"decrypt",
"--print-xml-debug",
"--lax-key-search",
"--aeskey",
])
.arg(vector.with_extension("key"))
.arg("--output")
.arg(&decrypted)
.arg(vector.with_extension("xml"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read(&decrypted).unwrap(),
fs::read(vector.with_extension("data")).unwrap()
);
let diagnostics = String::from_utf8(output.stdout).unwrap();
let document = roxmltree::Document::parse(&diagnostics)
.expect("--print-xml-debug stdout must be well-formed XML");
let root = document.root_element();
assert_eq!(root.tag_name().name(), "DataDecryptionContext");
assert_eq!(root.attribute("status"), Some("not-replaced"));
assert_eq!(root.attribute("failureReason"), Some("UNKNOWN"));
assert!(root.descendants().any(|node| {
node.has_tag_name("Transform")
&& node.attribute("href") == Some("http://www.w3.org/2009/xmlenc11#aes128-gcm")
}));
}
#[test]
fn encryption_preserves_template_metadata_and_supports_id_selection() {
// Encryption templates are output contracts. Only CipherValue is mutable;
// caller-owned identifiers, KeyInfo, properties, and extension attributes remain.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
let encrypted = temp.path().join("encrypted.xml");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Id="payload" Type="http://www.w3.org/2001/04/xmlenc#Element" MimeType="application/xml">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><KeyName>content</KeyName></KeyInfo>
<CipherData><CipherValue/></CipherData>
<EncryptionProperties><EncryptionProperty Id="audit"><meta xmlns="urn:test">kept</meta></EncryptionProperty></EncryptionProperties>
</EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"<secret>template metadata payload</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let result = Command::new(binary())
.args(["encrypt", "--aeskey:content"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let output = fs::read_to_string(&encrypted).unwrap();
assert!(output.contains("Id=\"payload\""));
assert!(output.contains("MimeType=\"application/xml\""));
assert!(output.contains("<KeyName>content</KeyName>"));
assert!(output.contains("<meta xmlns=\"urn:test\">kept</meta>"));
let decrypted = Command::new(binary())
.args(["decrypt", "--aeskey"])
.arg(&key)
.args(["--node-id", "payload"])
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypted.status.success(),
"{}",
String::from_utf8_lossy(&decrypted.stderr)
);
assert_eq!(
decrypted.stdout,
b"<secret>template metadata payload</secret>"
);
}
#[test]
fn encryption_node_id_selects_one_template_subtree() {
// --node-id selects the operation start node, not EncryptedData/@Id. The
// first descendant is filled and later templates remain untouched.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("multiple-templates.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
let encrypted_data = |id: &str| {
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Id="{id}" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#
)
};
fs::write(
&template,
format!(
"<Document><Envelope Id=\"first\">{}{}</Envelope><Envelope Id=\"second\">{}</Envelope></Document>",
encrypted_data("first-template"),
encrypted_data("later-first-subtree-template"),
encrypted_data("second-template")
),
)
.unwrap();
fs::write(&plaintext, b"<selected>payload</selected>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let output = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--node-id", "first"])
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let encrypted = String::from_utf8(output.stdout).unwrap();
let document = roxmltree::Document::parse(&encrypted).unwrap();
let values = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "CipherValue")))
.map(|node| node.text().unwrap_or_default())
.collect::<Vec<_>>();
assert!(!values[0].trim().is_empty());
assert!(values[1].trim().is_empty());
assert!(values[2].trim().is_empty());
let missing = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--node-id", "missing"])
.arg(&template)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("missing or ambiguous"));
let duplicate = temp.path().join("duplicate-start.xml");
fs::write(
&duplicate,
fs::read_to_string(&template)
.unwrap()
.replace("Id=\"second\"", "Id=\"first\""),
)
.unwrap();
let ambiguous = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--node-id", "first"])
.arg(&duplicate)
.output()
.unwrap();
assert!(!ambiguous.status.success());
assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("missing or ambiguous"));
}
#[test]
fn binary_encryption_rejects_xml_typed_templates() {
// Binary payloads cannot truthfully carry the XML Element or Content type;
// otherwise decryption routes arbitrary bytes through XML validation.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("typed-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, [0xff, 0x00, 0xfe]).unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let result = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.args(["--binary-data"])
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr).contains("binary-data"),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn binary_encryption_rejects_embedded_opaque_templates() {
// Embedded decryption replaces the selected node in its containing XML
// document, so accepting opaque bytes here would create output that the
// reciprocal CLI path cannot represent.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(&plaintext, [0xff, 0x00, 0xfe]).unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
for encrypted_type in [None, Some("urn:example:opaque")] {
let type_attribute = encrypted_type
.map(|value| format!(" Type=\"{value}\""))
.unwrap_or_default();
let template = temp.path().join(format!(
"embedded-{}.xml",
encrypted_type.map_or("untyped", |_| "custom")
));
fs::write(
&template,
format!(
r#"<Document><EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"{type_attribute}><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData></Document>"#
),
)
.unwrap();
let result = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success(), "{encrypted_type:?}");
assert!(
String::from_utf8_lossy(&result.stderr).contains("embedded"),
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
}
#[test]
fn encryption_rejects_simultaneous_binary_and_xml_payloads() {
// Selecting one option by branch order can encrypt the wrong input while
// reporting success, so payload mode must be an exclusive choice.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let binary_payload = temp.path().join("payload.bin");
let xml = temp.path().join("payload.xml");
let key = temp.path().join("content.key");
let output = temp.path().join("encrypted.xml");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&binary_payload, b"binary payload").unwrap();
fs::write(&xml, b"<payload>xml payload</payload>").unwrap();
fs::write(&key, [0x31; 16]).unwrap();
let result = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(key)
.args(["--binary-data"])
.arg(binary_payload)
.args(["--xml-data"])
.arg(xml)
.args(["--output"])
.arg(&output)
.arg(template)
.output()
.unwrap();
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("exactly one"));
assert!(!output.exists());
}
#[test]
fn untyped_xml_template_round_trips_when_embedded() {
// --xml-data infers Element semantics. The rendered wire document must
// retain that inference so embedded decryption performs XML replacement.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let encrypted = temp.path().join("encrypted.xml");
let document = temp.path().join("document.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Id="quoted>delimiter"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"<secret>inferred XML</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encryption = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encryption.status.success(),
"{}",
String::from_utf8_lossy(&encryption.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
assert!(
encrypted_xml.contains("Id=\"quoted>delimiter\" Type="),
"{encrypted_xml}"
);
fs::write(
&document,
format!(
"<root><scope Id=\"selected\">{}</scope></root>",
encrypted_xml
),
)
.unwrap();
let decryption = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.args(["--node-id", "selected"])
.arg(&document)
.output()
.unwrap();
assert!(
decryption.status.success(),
"{}",
String::from_utf8_lossy(&decryption.stderr)
);
assert!(
String::from_utf8(decryption.stdout)
.unwrap()
.contains("<scope Id=\"selected\"><secret>inferred XML</secret></scope>")
);
}
#[test]
fn xml_data_encrypts_the_parsed_document_node() {
// libxmlsec1 parses --xml-data and serializes either the document element
// or its children. The declaration and document-boundary trivia are not
// plaintext nodes in either encryption mode.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.xml");
let key = temp.path().join("key.bin");
fs::write(
&plaintext,
b"<?xml version=\"1.0\"?>\n<!--outside--><secret xmlns=\"urn:default\" xmlns:p=\"urn:prefixed\"><value>payload</value><p:item/></secret>\n",
)
.unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
for encrypted_type in ["Element", "Content"] {
let template = temp.path().join(format!("template-{encrypted_type}.xml"));
let encrypted = temp.path().join(format!("encrypted-{encrypted_type}.xml"));
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#{encrypted_type}"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
),
)
.unwrap();
let encryption = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encryption.status.success(),
"{encrypted_type}: {}",
String::from_utf8_lossy(&encryption.stderr)
);
let decryption = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decryption.status.success(),
"{encrypted_type}: {}",
String::from_utf8_lossy(&decryption.stderr)
);
let decrypted = String::from_utf8(decryption.stdout).unwrap();
let wrapped = if encrypted_type == "Element" {
decrypted
} else {
format!("<probe>{decrypted}</probe>")
};
let document = roxmltree::Document::parse(&wrapped)
.expect("decrypted XML must preserve inherited namespace bindings");
let value = document
.descendants()
.find(|node| node.tag_name().name() == "value")
.expect("default-namespaced child must survive");
let item = document
.descendants()
.find(|node| node.tag_name().name() == "item")
.expect("prefixed child must survive");
assert_eq!(value.tag_name().namespace(), Some("urn:default"));
assert_eq!(item.tag_name().namespace(), Some("urn:prefixed"));
}
}
#[test]
fn xml_data_content_preserves_cdata_as_text() {
// CDATA is exposed as a text node, but its source range retains the complete
// lexical construct. Content serialization must preserve both that form and
// its text semantics rather than accidentally turning payload into markup.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let encrypted = temp.path().join("encrypted.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Content"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(
&plaintext,
b"<wrapper><![CDATA[<secret/> & text]]></wrapper>",
)
.unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encryption = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encryption.status.success(),
"{}",
String::from_utf8_lossy(&encryption.stderr)
);
let decryption = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decryption.status.success(),
"{}",
String::from_utf8_lossy(&decryption.stderr)
);
let decrypted = String::from_utf8(decryption.stdout).unwrap();
assert_eq!(decrypted, "<![CDATA[<secret/> & text]]>");
let wrapped = format!("<probe>{decrypted}</probe>");
let document = roxmltree::Document::parse(&wrapped)
.expect("decrypted Content plaintext must remain well-formed XML content");
let root = document.root_element();
assert_eq!(root.text(), Some("<secret/> & text"));
assert!(!root.children().any(|node| node.is_element()));
}
#[test]
fn xml_data_applies_plaintext_limit_after_document_node_serialization() {
// The source document and encrypted plaintext are distinct resources. A
// large wrapper may fit the document ceiling while its tiny child content
// remains far below the encryption plaintext ceiling.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let key = temp.path().join("key.bin");
let policy = EncryptionPolicy::default();
let padding = "x".repeat(policy.resources.max_encryption_plaintext_bytes);
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Content"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(
&plaintext,
format!("<wrapper padding=\"{padding}\"><child/></wrapper>"),
)
.unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let output = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn direct_aes_encryption_rejects_recipient_templates() {
// A direct content key cannot refresh an EncryptedKey recipient. Emitting
// the untouched wrapped key would create internally inconsistent XML.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("recipient-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><CipherData><CipherValue>c3RhbGU=</CipherValue></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"recipient mismatch").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let output = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("recipient"));
}
#[test]
fn direct_aes_key_name_must_match_the_template_unless_lax() {
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("named-template.xml");
let plaintext = temp.path().join("plaintext.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><KeyName>expected</KeyName></KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, "<secret/>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let strict = Command::new(binary())
.args(["encrypt", "--aes-key:wrong"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("KeyName"));
let lax = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--aes-key:wrong"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
}
#[test]
fn encryption_rejects_duplicate_content_key_names() {
// The core XMLEnc parser permits at most one direct KeyName. Reject the
// template before encryption rather than emitting ciphertext we cannot read.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("duplicate-key-name.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ds:KeyName>first</ds:KeyName><ds:KeyName>second</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"duplicate content key names").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("more than one direct KeyName"));
}
#[test]
fn encryption_rejects_duplicate_encrypted_data_key_info() {
// A generated document must satisfy the same singleton KeyInfo structure
// that the reciprocal parser enforces during decryption.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("duplicate-key-info.xml");
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo/><ds:KeyInfo/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"duplicate encrypted data key info").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("EncryptedData contains more than one direct KeyInfo")
);
}
#[test]
fn named_aes_decryption_obeys_encrypted_data_key_name_unless_lax() {
// Decryption key selection must enforce the same document identity contract
// as encryption rather than discarding the CLI option's registry name.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("named-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><KeyName>expected</KeyName></KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"named decrypt").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key:expected"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
let strict = Command::new(binary())
.args(["decrypt", "--aes-key:wrong"])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("KeyName"));
let lax = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--aes-key:wrong"])
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
assert_eq!(lax.stdout, b"named decrypt");
}
#[test]
fn named_aes_key_ring_selects_one_key_for_encryption_and_decryption() {
// Repeatable AES options form a key ring. The document name must select
// exactly one entry rather than making every multi-key invocation invalid.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("named-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let wrong_key = temp.path().join("wrong.bin");
let matching_key = temp.path().join("matching.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><KeyName>selected</KeyName></KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"selected AES key").unwrap();
fs::write(&wrong_key, b"fedcba9876543210").unwrap();
fs::write(&matching_key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key:wrong"])
.arg(&wrong_key)
.args(["--aes-key:selected"])
.arg(&matching_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key:wrong"])
.arg(&wrong_key)
.args(["--aes-key:selected"])
.arg(&matching_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"selected AES key");
let ambiguous = Command::new(binary())
.args(["decrypt", "--aes-key:selected"])
.arg(&wrong_key)
.args(["--aes-key:selected"])
.arg(&matching_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(!ambiguous.status.success());
// The shared selector identifies symmetric keys, including DES, rather
// than labeling every ambiguous candidate ring as AES-only.
assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("multiple symmetric key"));
// Lax lookup intentionally ignores duplicate identity metadata and searches
// by key kind, so the first compatible entry can complete the round trip.
let lax_encrypted = temp.path().join("lax-encrypted.xml");
let lax_encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--aes-key:selected"])
.arg(&matching_key)
.args(["--aes-key:selected"])
.arg(&wrong_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&lax_encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
lax_encrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_encrypt.stderr)
);
let lax_decrypt = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--aes-key:selected"])
.arg(&wrong_key)
.args(["--aes-key:selected"])
.arg(&matching_key)
.arg(&lax_encrypted)
.output()
.unwrap();
assert!(
lax_decrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_decrypt.stderr)
);
assert_eq!(lax_decrypt.stdout, b"selected AES key");
}
#[test]
fn lax_aes_key_ring_is_bounded_before_filesystem_reads() {
// Candidate work is an operation ceiling, so oversized rings must fail
// before even the first attacker-selected path is opened.
let temp = tempfile::tempdir().unwrap();
let encrypted = temp.path().join("encrypted.xml");
fs::write(
&encrypted,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue>AAAAAAAAAAAAAAAAAAAAAA==</CipherValue></CipherData></EncryptedData>"#,
)
.unwrap();
let maximum = KeyCandidateBudget::for_operation().remaining();
let mut command = Command::new(binary());
command.args(["decrypt", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--aes-key");
command.arg(temp.path().join(format!("missing-{index}.key")));
}
let output = command.arg(&encrypted).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("key candidates exceeds policy maximum"),
"{stderr}"
);
assert!(stderr.contains(&format!("got {}", maximum + 1)), "{stderr}");
assert!(!stderr.contains("missing-0.key"), "{stderr}");
}
#[test]
fn lax_verification_key_ring_is_bounded_before_filesystem_reads() {
// The compiled verification candidate ceiling must reject oversized rings
// before opening even the first attacker-selected public-key path.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let signed = temp.path().join("signed.xml");
fs::write(&template, signature_template_without_key_info()).unwrap();
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(private_key)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let maximum = VerificationPolicy::default()
.key_trust
.max_x509_candidate_paths;
let mut command = Command::new(binary());
command.args(["verify", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--pubkey-pem");
command.arg(temp.path().join(format!("missing-{index}.pem")));
}
let output = command.arg(&signed).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("verification key candidates"), "{stderr}");
assert!(
stderr.contains(&format!(
"exceeds policy maximum {maximum}: got {}",
maximum + 1
)),
"{stderr}"
);
assert!(!stderr.contains("missing-0.pem"), "{stderr}");
}
#[test]
fn lax_rsa_key_ring_is_bounded_before_filesystem_reads() {
// RSA decryption must apply the shared candidate-work ceiling before
// decoding any private-key source, matching the direct AES path.
let temp = tempfile::tempdir().unwrap();
let encrypted = temp.path().join("encrypted.xml");
fs::write(
&encrypted,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue>AAAAAAAAAAAAAAAAAAAAAA==</CipherValue></CipherData></EncryptedData>"#,
)
.unwrap();
let maximum = KeyCandidateBudget::for_operation().remaining();
let mut command = Command::new(binary());
command.args(["decrypt", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--privkey-pem");
command.arg(temp.path().join(format!("missing-{index}.pem")));
}
let output = command.arg(&encrypted).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("key candidates exceeds policy maximum"),
"{stderr}"
);
assert!(stderr.contains(&format!("got {}", maximum + 1)), "{stderr}");
assert!(!stderr.contains("missing-0.pem"), "{stderr}");
}
#[test]
fn lax_signing_key_ring_is_bounded_before_filesystem_reads() {
// Candidate work is bounded independently from successful byte accounting:
// unreadable or oversized sources must not permit unbounded read attempts.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
fs::write(&template, signature_template_without_key_info()).unwrap();
let maximum = KeyCandidateBudget::for_operation().remaining();
let mut command = Command::new(binary());
command.args(["sign", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--privkey-pem");
command.arg(temp.path().join(format!("missing-{index}.pem")));
}
let output = command.arg(&template).output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("key candidates exceeds policy maximum"),
"{stderr}"
);
assert!(stderr.contains(&format!("got {}", maximum + 1)), "{stderr}");
assert!(!stderr.contains("missing-0.pem"), "{stderr}");
}
#[test]
fn lax_rsa_encryption_key_ring_is_bounded_before_filesystem_reads() {
// RSA recipient lookup has the same operation ceiling as direct AES and
// RSA decryption, so oversized rings must fail before candidate decoding.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"bounded RSA encryption candidates").unwrap();
let maximum = KeyCandidateBudget::for_operation().remaining();
let mut command = Command::new(binary());
command.args(["encrypt", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--pubkey-pem");
command.arg(temp.path().join(format!("missing-{index}.pem")));
}
let output = command
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("key candidates exceeds policy maximum"),
"{stderr}"
);
assert!(stderr.contains(&format!("got {}", maximum + 1)), "{stderr}");
assert!(!stderr.contains("missing-0.pem"), "{stderr}");
}
#[test]
fn lax_aes_encryption_key_ring_is_bounded_before_filesystem_reads() {
// Encryption shares the operation-wide key candidate ceiling and must reject
// an oversized ring before opening any attacker-selected path.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"bounded encryption candidates").unwrap();
let maximum = KeyCandidateBudget::for_operation().remaining();
let mut command = Command::new(binary());
command.args(["encrypt", "--lax-key-search"]);
for index in 0..=maximum {
command.arg("--aes-key");
command.arg(temp.path().join(format!("missing-{index}.key")));
}
let output = command
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("key candidates exceeds policy maximum"),
"{stderr}"
);
assert!(stderr.contains(&format!("got {}", maximum + 1)), "{stderr}");
assert!(!stderr.contains("missing-0.key"), "{stderr}");
}
#[test]
fn named_direct_aes_key_populates_an_empty_key_info() {
// An empty placeholder reserves KeyInfo's schema position. The generated
// KeyName must survive so strict multi-key decryption can select the key.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("empty-key-info.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let wrong = temp.path().join("wrong.bin");
let selected = temp.path().join("selected.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"empty KeyInfo identity").unwrap();
fs::write(&wrong, b"fedcba9876543210").unwrap();
fs::write(&selected, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key:selected"])
.arg(&selected)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let encrypted_document = roxmltree::Document::parse(&encrypted_xml).unwrap();
assert_eq!(
encrypted_document
.descendants()
.find(|node| { node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")) })
.and_then(|node| node.text()),
Some("selected")
);
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key:wrong"])
.arg(&wrong)
.args(["--aes-key:selected"])
.arg(&selected)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"empty KeyInfo identity");
}
#[test]
fn named_direct_aes_key_extends_extension_only_key_info() {
// Foreign KeyInfo extensions are preserved, but they cannot suppress the
// generated KeyName needed for strict recipient selection during decrypt.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("extension-key-info.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let wrong = temp.path().join("wrong.bin");
let selected = temp.path().join("selected.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:recipient"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ext:Metadata>keep</ext:Metadata></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"extension KeyInfo identity").unwrap();
fs::write(&wrong, b"fedcba9876543210").unwrap();
fs::write(&selected, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key:selected"])
.arg(&selected)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
assert!(encrypted_xml.contains("<ext:Metadata>keep</ext:Metadata>"));
assert!(encrypted_xml.contains(">selected</"));
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key:wrong"])
.arg(&wrong)
.args(["--aes-key:selected"])
.arg(&selected)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"extension KeyInfo identity");
}
#[test]
fn encryption_rejects_invalid_content_encryption_method_structure() {
// Encrypt must apply the same EncryptionMethod invariants as decrypt;
// otherwise it can emit ciphertext that its reciprocal parser rejects.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let key = temp.path().join("key.bin");
fs::write(&plaintext, b"method validation").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
for (case, children, expected) in [
(
"mismatched-key-size",
"<KeySize>192</KeySize>",
"requires KeySize 128",
),
(
"duplicate-key-size",
"<KeySize>128</KeySize><KeySize>128</KeySize>",
"KeySize",
),
(
"oaep-on-aes",
"<OAEPparams>YQ==</OAEPparams>",
"only valid for RSA-OAEP",
),
] {
let template = temp.path().join(format!("{case}.xml"));
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm">{children}</EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedData>"#
),
)
.unwrap();
let output = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success(), "{case} unexpectedly succeeded");
assert!(
String::from_utf8_lossy(&output.stderr).contains(expected),
"{case}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn standalone_binary_decryption_accepts_its_root_node_id() {
// --node-id selects an operation start point; selecting the standalone root
// must not route opaque bytes through XML document replacement validation.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Id="payload"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, [0xff, 0x00, 0xfe]).unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.args(["--node-id", "payload"])
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, [0xff, 0x00, 0xfe]);
let missing = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.args(["--node-id", "missing"])
.arg(&encrypted)
.output()
.unwrap();
assert!(!missing.status.success());
}
#[test]
fn embedded_decryption_node_id_selects_an_operation_subtree() {
// The selected ID belongs to an ancestor operation node; EncryptedData is
// intentionally anonymous, matching libxmlsec1's start-node contract.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let encrypted = temp.path().join("encrypted.xml");
let document = temp.path().join("document.xml");
let key = temp.path().join("key.bin");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"<secret>selected subtree</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encryption = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encryption.status.success());
fs::write(
&document,
format!(
"<Document><Envelope Id=\"selected\">{}</Envelope></Document>",
fs::read_to_string(&encrypted).unwrap()
),
)
.unwrap();
let output = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.args(["--node-id", "selected"])
.arg(&document)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("<Envelope Id=\"selected\"><secret>selected subtree</secret></Envelope>")
);
}
#[test]
fn rsa_recipient_name_must_match_the_template_unless_lax() {
// A named RSA key selects the nested EncryptedKey recipient identity, not
// the direct content-encryption key metadata on EncryptedData.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("named-rsa-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>recipient</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo>
<CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"named recipient").unwrap();
let matching = Command::new(binary())
.args(["encrypt", "--pubkey-pem:recipient"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
matching.status.success(),
"{}",
String::from_utf8_lossy(&matching.stderr)
);
fs::write(&encrypted, &matching.stdout).unwrap();
let strict = Command::new(binary())
.args(["encrypt", "--pubkey-pem:wrong"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("KeyName"));
let lax = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--pubkey-pem:wrong"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
let strict_decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:wrong"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(!strict_decrypt.status.success());
assert!(String::from_utf8_lossy(&strict_decrypt.stderr).contains("KeyName"));
let lax_decrypt = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem:wrong"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
lax_decrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_decrypt.stderr)
);
assert_eq!(lax_decrypt.stdout, b"named recipient");
}
#[test]
fn rsa_encryption_rejects_duplicate_recipient_key_names() {
// Recipient identity is a singleton selection hint. Silently selecting the
// first value would emit ciphertext that the reciprocal parser rejects.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("duplicate-recipient-name.xml");
let plaintext = temp.path().join("plaintext.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>first</ds:KeyName><ds:KeyName>second</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"ambiguous recipient").unwrap();
let output = Command::new(binary())
.args(["encrypt", "--pubkey-pem:first"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty(), "ciphertext must not be emitted");
assert!(
String::from_utf8_lossy(&output.stderr).contains("more than one direct KeyName"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn rsa_encryption_rejects_duplicate_recipient_key_info() {
// Each EncryptedKey has one optional KeyInfo identity. Selecting the first
// duplicate would preserve malformed output that decryption rejects.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("duplicate-recipient-key-info.xml");
let plaintext = temp.path().join("plaintext.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo/><ds:KeyInfo/><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"duplicate recipient key info").unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("EncryptedKey contains more than one direct KeyInfo")
);
}
#[test]
fn rsa_encryption_rejects_stale_recipient_key_value_metadata() {
// A template KeyValue is a cryptographic identity, not a selection hint.
// Preserving it while wrapping for another key would emit contradictory XML.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("rsa-key-value-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let matching_path = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let mismatching_path = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let matching =
RsaPublicKey::from_public_key_pem(&fs::read_to_string(&matching_path).unwrap()).unwrap();
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo>{}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
rsa_key_value_with_leading_zeroes(&matching, 1, 2)
),
)
.unwrap();
fs::write(&plaintext, b"recipient identity").unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&matching_path)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&mismatching_path)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr).contains("recipient key metadata"),
"{}",
String::from_utf8_lossy(&rejected.stderr)
);
}
#[test]
fn rsa_encryption_validates_der_encoded_recipient_metadata() {
// DEREncodedKeyValue identifies the wrapping recipient just like
// RSAKeyValue and X509Data: matching SPKI is accepted, stale SPKI is not.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("der-recipient-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let matching_path = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let mismatching_path = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let matching =
RsaPublicKey::from_public_key_pem(&fs::read_to_string(&matching_path).unwrap()).unwrap();
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo>{}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
der_encoded_key_value(&matching)
),
)
.unwrap();
fs::write(&plaintext, b"DER recipient identity").unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&matching_path)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&mismatching_path)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("recipient key metadata"));
}
#[test]
fn rsa_encryption_rejects_stale_recipient_certificate_metadata() {
// X509Data identifies the same recipient as the wrapped content key. A
// certificate for another key must fail before any contradictory output.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("x509-recipient-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let matching_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let mismatching_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo>{}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
x509_certificate_value(&certificate)
),
)
.unwrap();
fs::write(&plaintext, b"certificate recipient identity").unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&matching_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&mismatching_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr).contains("recipient key metadata"),
"{}",
String::from_utf8_lossy(&rejected.stderr)
);
}
#[test]
fn rsa_encryption_rejects_embedded_certificate_with_stale_selectors() {
// An embedded certificate and its selectors form one identity assertion.
// A matching wrapping key must not make contradictory selectors ignorable.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("x509-recipient-selector-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let matching_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let certificate_with_stale_selector = x509_certificate_value(&certificate).replace(
"</ds:X509Data>",
"<ds:X509SubjectName>CN=not-the-recipient</ds:X509SubjectName></ds:X509Data>",
);
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo>{certificate_with_stale_selector}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
),
)
.unwrap();
fs::write(&plaintext, b"certificate selector identity").unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&matching_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("lookup identifiers do not match the embedded certificate chain"),
"{}",
String::from_utf8_lossy(&rejected.stderr)
);
}
#[test]
fn rsa_encryption_matches_selector_only_x509data_to_configured_certificate() {
// Selector-only X509Data resolves against --pubkey-cert-pem certificate
// metadata; a bare public key cannot satisfy that certificate identity.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("x509-selector-recipient.xml");
let plaintext = temp.path().join("plaintext.bin");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let bare_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let (_, certificate_pem) =
x509_parser::pem::parse_x509_pem(&fs::read(&certificate).unwrap()).unwrap();
let (_, parsed_certificate) =
x509_parser::certificate::X509Certificate::from_der(&certificate_pem.contents).unwrap();
let ski = parsed_certificate
.extensions()
.iter()
.find_map(|extension| match extension.parsed_extension() {
ParsedExtension::SubjectKeyIdentifier(value) => Some(value.0),
_ => None,
})
.unwrap();
let digest = default_provider()
.digest(DigestAlgorithm::Sha256, &certificate_pem.contents)
.unwrap();
fs::write(&plaintext, b"selector recipient identity").unwrap();
let selectors = [
(
"subject",
"<ds:X509SubjectName>CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US</ds:X509SubjectName>".to_owned(),
),
(
"issuer-serial",
"<ds:X509IssuerSerial><ds:X509IssuerName>Email=xmlsec@aleksey.com,CN=Aleksey Sanin,OU=Second level CA,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US</ds:X509IssuerName><ds:X509SerialNumber>680572598617295163017172295025714171905498632019</ds:X509SerialNumber></ds:X509IssuerSerial>".to_owned(),
),
(
"ski",
format!(
"<ds:X509SKI>{}</ds:X509SKI>",
base64::engine::general_purpose::STANDARD.encode(ski)
),
),
(
"digest",
format!(
"<dsig11:X509Digest Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\">{}</dsig11:X509Digest>",
base64::engine::general_purpose::STANDARD.encode(digest)
),
),
];
for (name, selector) in selectors {
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:dsig11="http://www.w3.org/2009/xmldsig11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:X509Data>{selector}</ds:X509Data></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
),
)
.unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{name}: {}",
String::from_utf8_lossy(&accepted.stderr)
);
}
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:X509Data><ds:X509SubjectName>CN=Test Key rsa-2048,O=XML Security Library (http://www.aleksey.com/xmlsec),ST=California,C=US</ds:X509SubjectName></ds:X509Data></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&bare_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("certificate"));
}
#[test]
fn named_rsa_key_ring_selects_one_recipient_for_encryption_and_decryption() {
// Recipient KeyName selects one public/private key pair from repeatable
// options on both sides of the process boundary.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("named-rsa-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let wrong_public = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let matching_public = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let wrong_private = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let matching_private = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>selected</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"selected RSA recipient").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem:wrong"])
.arg(&wrong_public)
.args(["--pubkey-pem:selected"])
.arg(&matching_public)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:wrong"])
.arg(&wrong_private)
.args(["--privkey-pem:selected"])
.arg(&matching_private)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"selected RSA recipient");
}
#[test]
fn named_rsa_option_identifies_a_generated_recipient() {
// With no EncryptedKey skeleton, the selected option name is the only
// recipient identity available to the reciprocal strict key-ring lookup.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("generated-named-recipient.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"generated named recipient").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem:recipient"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&encrypted_xml).unwrap();
assert!(document.descendants().any(|node| {
node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))
&& node.text() == Some("recipient")
}));
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:recipient"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"generated named recipient");
}
#[test]
fn named_rsa_option_populates_existing_unnamed_recipients() {
// Existing recipient skeletons keep their template metadata, but a missing
// nested identity must receive the selected option name for strict decrypt.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(&plaintext, b"existing named recipient").unwrap();
for (case, nested_key_info) in [("absent", ""), ("empty", "<ds:KeyInfo/>")] {
let template = temp.path().join(format!("{case}-recipient-template.xml"));
let encrypted = temp.path().join(format!("{case}-recipient.xml"));
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/>{nested_key_info}<CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#
),
)
.unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem:recipient"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{case}: {}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&encrypted_xml).unwrap();
let encrypted_key = document
.descendants()
.find(|node| node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "EncryptedKey")))
.unwrap();
assert!(encrypted_key.descendants().any(|node| {
node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))
&& node.text() == Some("recipient")
}));
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:recipient"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{case}: {}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"existing named recipient");
}
}
#[test]
fn lax_rsa_decryption_skips_malformed_private_key_candidates() {
// Lax lookup is an ordered compatibility search. A malformed entry must not
// prevent a later or earlier usable key from unwrapping the recipient.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("lax-private-key-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let malformed = temp.path().join("malformed.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"skip malformed private key").unwrap();
fs::write(&malformed, b"not a private key").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
for candidates in [[&malformed, &private_key], [&private_key, &malformed]] {
let decrypt = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem:first"])
.arg(candidates[0])
.args(["--privkey-pem:second"])
.arg(candidates[1])
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"skip malformed private key");
}
let rejected = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem:first"])
.arg(&malformed)
.args(["--privkey-pem:second"])
.arg(&malformed)
.arg(&encrypted)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("private key"));
}
#[test]
fn lax_rsa_decryption_stops_after_aggregate_material_exhaustion() {
// Candidate-local decode failures remain skippable, but once cumulative
// source bytes cross the invocation ceiling a later small valid key must
// not revive the search using the unchanged pre-failure budget total.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("budget-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let malformed_8m = temp.path().join("malformed-8m.pem");
let malformed_1m = temp.path().join("malformed-1m.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"aggregate budget must be terminal").unwrap();
fs::File::create(&malformed_8m)
.unwrap()
.set_len(8 * 1024 * 1024)
.unwrap();
fs::File::create(&malformed_1m)
.unwrap()
.set_len(1024 * 1024)
.unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
let decrypt = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem:first"])
.arg(&malformed_8m)
.args(["--privkey-pem:second"])
.arg(&malformed_8m)
.args(["--privkey-pem:third"])
.arg(&malformed_8m)
.args(["--privkey-pem:fourth"])
.arg(&malformed_1m)
.args(["--privkey-pem:fifth"])
.arg(&malformed_8m)
.args(["--privkey-pem:valid"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(!decrypt.status.success());
assert!(
String::from_utf8_lossy(&decrypt.stderr)
.contains("configured external key/certificate material exceeds policy limit")
);
}
#[test]
fn lax_rsa_search_skips_candidates_that_conflict_with_recipient_metadata() {
// Lax name matching does not make preserved KeyValue metadata advisory: a
// syntactically valid but mismatching key must be skipped in favor of the
// later candidate whose public components satisfy the template identity.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("lax-recipient-metadata.xml");
let plaintext = temp.path().join("plaintext.bin");
let wrong = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let matching = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let matching_key =
RsaPublicKey::from_public_key_pem(&fs::read_to_string(&matching).unwrap()).unwrap();
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo>{}</ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
rsa_key_value_with_leading_zeroes(&matching_key, 0, 0)
),
)
.unwrap();
fs::write(&plaintext, b"metadata-selected recipient").unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--pubkey-pem:first"])
.arg(&wrong)
.args(["--pubkey-pem:second"])
.arg(&matching)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
}
#[test]
fn lax_stored_rsa_encryption_stops_on_policy_denial() {
// A stored weak recipient is a typed policy failure, not a candidate miss;
// a later strong key must not suppress it or produce encrypted output.
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("keys.xml");
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plain.bin");
let output = temp.path().join("encrypted.xml");
let weak = RsaPrivateKey::new(&mut ChaCha8Rng::from_seed([0x72; 32]), 1024)
.unwrap()
.to_public_key();
let strong = RsaPublicKey::from_public_key_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap(),
)
.unwrap();
let mut xml = String::from("<Keys xmlns=\"http://www.aleksey.com/xmlsec/2002\">");
for (name, key) in [("weak", weak), ("strong", strong)] {
xml.push_str(&format!("<KeyInfo xmlns=\"http://www.w3.org/2000/09/xmldsig#\"><KeyName>{name}</KeyName><KeyValue><RSAKeyValue><Modulus>{}</Modulus><Exponent>{}</Exponent></RSAKeyValue></KeyValue></KeyInfo>", base64::engine::general_purpose::STANDARD.encode(key.n().to_be_bytes_trimmed_vartime()), base64::engine::general_purpose::STANDARD.encode(key.e().to_be_bytes_trimmed_vartime())));
}
xml.push_str("</Keys>");
fs::write(&store, xml).unwrap();
fs::write(&template, r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>missing</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#).unwrap();
fs::write(&plaintext, b"policy denial is terminal").unwrap();
let result = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--keys-file"])
.arg(&store)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&output)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success());
assert!(
String::from_utf8_lossy(&result.stderr)
.contains("requires RSA keys between 2048 and 8192 bits: got 1024"),
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert!(!output.exists());
}
#[test]
fn lax_rsa_encryption_skips_keys_rejected_by_policy() {
// Lax lookup searches for a usable RSA recipient. A parseable weak key must
// not prevent a later policy-compliant key from reaching encryption.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("lax-recipient-policy.xml");
let plaintext = temp.path().join("plaintext.bin");
let weak_public_key = temp.path().join("rsa-1024-pubkey.pem");
let weak_key = RsaPrivateKey::new(&mut ChaCha8Rng::from_seed([0x72; 32]), 1024).unwrap();
fs::write(
&weak_public_key,
weak_key
.to_public_key()
.to_public_key_pem(Default::default())
.unwrap(),
)
.unwrap();
let compliant_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"policy-selected recipient").unwrap();
let accepted = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--pubkey-pem:weak"])
.arg(&weak_public_key)
.args(["--pubkey-pem:compliant"])
.arg(&compliant_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&weak_public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(
String::from_utf8_lossy(&rejected.stderr)
.contains("requires RSA keys between 2048 and 8192 bits: got 1024")
);
}
#[test]
fn rsa_encryption_populates_an_existing_key_info_container() {
// A template may reserve KeyInfo for non-cryptographic metadata without
// pre-creating EncryptedKey. Encryption must add the generated recipient
// while preserving those sibling sources.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("key-info-container-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ds:KeyName>content-key</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"existing KeyInfo container").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&encrypted_xml).unwrap();
assert!(document.descendants().any(|node| {
node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))
&& node.text() == Some("content-key")
}));
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "EncryptedKey")))
.count(),
1
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"existing KeyInfo container");
}
#[test]
fn rsa_encryption_preserves_extension_cipher_values() {
// CipherValue is meaningful only along an EncryptedKey recipient path.
// Extension-owned values must remain caller metadata, not placeholders.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("extension-cipher-value.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:test"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ext:Metadata><CipherValue>ZXh0ZW5zaW9u</CipherValue></ext:Metadata></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"extension metadata").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&encrypted_xml).unwrap();
assert_eq!(
document
.descendants()
.find(|node| node.has_tag_name(("urn:test", "Metadata")))
.and_then(|node| {
node.children().find(|child| {
child.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "CipherValue"))
})
})
.and_then(|node| node.text()),
Some("ZXh0ZW5zaW9u")
);
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "EncryptedKey")))
.count(),
1
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"extension metadata");
}
#[test]
fn rsa_encryption_builds_every_named_recipient() {
// Repeatable public keys map one-for-one to template recipients. Every
// generated wrapped key must remain decryptable, while missing or duplicate
// matches fail before emitting a partial recipient set.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("multi-recipient-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_a = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let public_b = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_a = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let private_b = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/><ds:KeyInfo><ds:KeyName>a</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><xenc11:MGF Algorithm="http://www.w3.org/2009/xmlenc11#mgf1sha256"/></EncryptionMethod><ds:KeyInfo><ds:KeyName>b</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"multiple RSA recipients").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem:a"])
.arg(&public_a)
.args(["--pubkey-pem:b"])
.arg(&public_b)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
let document = roxmltree::Document::parse(&encrypted_xml).unwrap();
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "EncryptedKey")))
.count(),
2
);
for (name, private_key) in [("a", &private_a), ("b", &private_b)] {
let decrypt = Command::new(binary())
.arg("decrypt")
.arg(format!("--privkey-pem:{name}"))
.arg(private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"multiple RSA recipients");
}
let decrypt_with_key_ring = Command::new(binary())
.args(["decrypt", "--privkey-pem:a"])
.arg(&private_a)
.arg("--privkey-pem:b")
.arg(&private_b)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt_with_key_ring.status.success(),
"{}",
String::from_utf8_lossy(&decrypt_with_key_ring.stderr)
);
assert_eq!(decrypt_with_key_ring.stdout, b"multiple RSA recipients");
let missing = Command::new(binary())
.args(["encrypt", "--pubkey-pem:a"])
.arg(&public_a)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("unknown KeyName"));
let duplicate = Command::new(binary())
.args(["encrypt", "--pubkey-pem:a"])
.arg(&public_a)
.args(["--pubkey-pem:a"])
.arg(&public_b)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!duplicate.status.success());
assert!(String::from_utf8_lossy(&duplicate.stderr).contains("multiple RSA recipient key"));
let lax_encrypted = temp.path().join("lax-encrypted.xml");
let lax_encrypt = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--pubkey-pem:ignored-a"])
.arg(&public_a)
.arg("--pubkey-pem:ignored-b")
.arg(&public_b)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&lax_encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
lax_encrypt.status.success(),
"{}",
String::from_utf8_lossy(&lax_encrypt.stderr)
);
for (name, private_key) in [("a", &private_a), ("b", &private_b)] {
let decrypt = Command::new(binary())
.arg("decrypt")
.arg(format!("--privkey-pem:{name}"))
.arg(private_key)
.arg(&lax_encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"lax recipient {name}: {}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"multiple RSA recipients");
}
let insufficient_output = temp.path().join("insufficient-lax.xml");
let insufficient = Command::new(binary())
.args(["encrypt", "--lax-key-search", "--pubkey-pem:ignored"])
.arg(&public_a)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&insufficient_output)
.arg(&template)
.output()
.unwrap();
assert!(!insufficient.status.success());
assert!(!insufficient_output.exists());
}
#[test]
fn mixed_named_and_unnamed_rsa_recipients_keep_distinct_key_identities() {
// A mixed template must retain the unnamed slot through both encryption and
// decryption selection instead of collapsing all recipients to KeyName.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("mixed-recipient-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_a = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let public_b = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_a = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let private_b = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName>a</ds:KeyName></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"mixed recipient identities").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem:a"])
.arg(&public_a)
.arg("--pubkey-pem")
.arg(&public_b)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem:a"])
.arg(&private_b)
.arg("--privkey-pem")
.arg(&private_b)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"mixed recipient identities");
let ambiguous = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_b)
.arg("--privkey-pem")
.arg(&private_b)
.arg(&encrypted)
.output()
.unwrap();
assert!(!ambiguous.status.success());
assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("recipient identity"));
let wrong_identity = Command::new(binary())
.args(["decrypt", "--privkey-pem:a"])
.arg(&private_b)
.arg("--privkey-pem")
.arg(&private_a)
.arg(&encrypted)
.output()
.unwrap();
assert!(
!wrong_identity.status.success(),
"an unnamed key must not act as a wildcard for named recipient a"
);
}
#[test]
fn encryption_node_id_accepts_namespaced_id_attributes() {
// CLI node selection shares local-name ID semantics with reference
// resolution, including profile-standard qualified wsu:Id attributes.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("namespaced-node-id-template.xml");
let plaintext = temp.path().join("plaintext.bin");
let aes_key = temp.path().join("aes.key");
fs::write(
&template,
r#"<root xmlns:wsu="urn:wsu"><scope wsu:Id="target"><EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData></scope></root>"#,
)
.unwrap();
fs::write(&plaintext, b"<selected>namespaced node ID</selected>").unwrap();
fs::write(&aes_key, [7_u8; 16]).unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--node-id", "target", "--aes-key"])
.arg(&aes_key)
.arg("--xml-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
assert!(String::from_utf8_lossy(&encrypt.stdout).contains("wsu:Id=\"target\""));
}
#[test]
fn global_id_attribute_selects_encrypt_and_decrypt_subtrees() {
// --add-id-attr applies one local attribute name to every element, and the
// registration must survive both CLI-side template parsing and core decrypt.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("custom-id-template.xml");
let plaintext = temp.path().join("plaintext.xml");
let encrypted = temp.path().join("encrypted.xml");
let key = temp.path().join("aes.key");
fs::write(
&template,
r#"<root><scope Token="target"><EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData></scope></root>"#,
)
.unwrap();
fs::write(&plaintext, b"<secret>registered</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--add-id-attr", "Token", "--node-id", "target"])
.arg("--aes-key")
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--add-id-attr", "Token", "--node-id", "target"])
.arg("--aes-key")
.arg(&key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
let output = String::from_utf8(decrypt.stdout).unwrap();
assert!(output.contains("<scope Token=\"target\"><secret>registered</secret></scope>"));
}
#[test]
fn named_decryption_key_selects_a_later_recipient() {
// A document KeyName selects among all EncryptedKey recipients. Checking
// only the first recipient would reject a valid key before core decryption
// can reach the matching wrapped content key.
let temp = tempfile::tempdir().unwrap();
let encrypted = temp.path().join("encrypted.xml");
let matching_private = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let matching_public = RsaPublicKey::from_public_key_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"))
.unwrap(),
)
.unwrap();
let other_public = RsaPublicKey::from_public_key_pem(
&fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"))
.unwrap(),
)
.unwrap();
let generated = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm)
.add_recipient(EncryptionRecipient::rsa_oaep(other_public).key_name("other"))
.add_recipient(EncryptionRecipient::rsa_oaep(matching_public).key_name("matching"))
.encrypt_binary(b"later recipient")
.unwrap();
fs::write(&encrypted, generated.encrypted_data_xml).unwrap();
let output = Command::new(binary())
.args(["decrypt", "--privkey-pem:matching"])
.arg(&matching_private)
.arg(&encrypted)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, b"later recipient");
}
#[test]
fn encrypts_and_decrypts_with_an_rsa_oaep_recipient() {
// The advertised RSA path must emit XML Encryption 1.1 OAEP and unwrap its
// generated content key through a separate CLI invocation.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"RSA recipient payload").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.args(["--binary-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
assert!(
fs::read_to_string(&encrypted)
.unwrap()
.contains("http://www.w3.org/2009/xmlenc11#rsa-oaep")
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, fs::read(&plaintext).unwrap());
}
#[test]
fn encrypts_with_rsa_recipient_certificates() {
// Donor certificate options extract the recipient public key from either
// PEM or DER X.509 input and feed the same RSA-OAEP encryption path.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate_pem = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let certificate_der = temp.path().join("certificate.der");
let pem = fs::read(&certificate_pem).unwrap();
let (_, certificate) = x509_parser::pem::parse_x509_pem(&pem).unwrap();
fs::write(&certificate_der, certificate.contents).unwrap();
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"certificate recipient").unwrap();
for (option, certificate) in [
("--pubkey-cert-pem", certificate_pem.as_path()),
("--pubkey-cert-der", certificate_der.as_path()),
] {
let encrypted = temp.path().join(format!("{option}.xml"));
let encryption = Command::new(binary())
.args(["encrypt", option])
.arg(certificate)
.args(["--binary-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encryption.status.success(),
"{option}: {}",
String::from_utf8_lossy(&encryption.stderr)
);
let decryption = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decryption.status.success(),
"{option}: {}",
String::from_utf8_lossy(&decryption.stderr)
);
assert_eq!(decryption.stdout, b"certificate recipient");
}
}
#[test]
fn rsa_decryption_accepts_private_key_certificate_companions() {
// libxmlsec private-key options permit certificate companions after the
// key path; decryption consumes the key while retaining that syntax.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"certificate companion").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
let compound = format!("{},{}", private_key.display(), certificate.display());
// The companion's algorithm is part of SPKI identity (RFC 5280 §4.1.2.7).
// Keep the exact RSA bits but change rsaEncryption to RSASSA-PSS; merely
// comparing the BIT STRING must not accept this structurally valid certificate.
let certificate_pem = pem::parse(fs::read(&certificate).unwrap()).unwrap();
let mut altered = certificate_pem.contents().to_vec();
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(&altered).unwrap();
let spki = parsed.public_key().raw;
let original_bits = parsed.public_key().subject_public_key.data.to_vec();
let offset = altered
.windows(spki.len())
.position(|bytes| bytes == spki)
.unwrap();
let oid = [
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
];
let algorithm_offset = spki
.windows(oid.len())
.position(|bytes| bytes == oid)
.unwrap();
altered[offset + algorithm_offset + oid.len() - 1] = 0x0a;
let (_, changed) = x509_parser::certificate::X509Certificate::from_der(&altered).unwrap();
assert_eq!(
changed.public_key().subject_public_key.data.as_ref(),
original_bits
);
let altered_certificate = temp.path().join("changed-algorithm.pem");
fs::write(
&altered_certificate,
pem::encode(&pem::Pem::new("CERTIFICATE", altered)),
)
.unwrap();
let check_companion = |provider: &str| {
let rejected = Command::new(binary())
.args(["decrypt", "--crypto", provider, "--privkey-pem"])
.arg(format!(
"{},{}",
private_key.display(),
altered_certificate.display()
))
.arg(&encrypted)
.output()
.unwrap();
assert!(
!rejected.status.success(),
"{provider} accepted a different SPKI algorithm"
);
assert!(String::from_utf8_lossy(&rejected.stderr).contains("does not match"));
assert!(rejected.stdout.is_empty());
let accepted = Command::new(binary())
.args(["decrypt", "--crypto", provider, "--privkey-pem"])
.arg(&compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(
accepted.status.success(),
"{}",
String::from_utf8_lossy(&accepted.stderr)
);
assert_eq!(accepted.stdout, b"certificate companion");
};
check_companion("rustcrypto");
#[cfg(feature = "aws-lc-fips")]
check_companion("aws-lc-fips");
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"certificate companion");
let unrelated_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let mismatched_compound = format!(
"{},{}",
private_key.display(),
unrelated_certificate.display()
);
let mismatched = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&mismatched_compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(!mismatched.status.success());
assert!(String::from_utf8_lossy(&mismatched.stderr).contains("does not match"));
let valid_compound = format!("{},{}", private_key.display(), certificate.display());
let lax = Command::new(binary())
.args(["decrypt", "--lax-key-search", "--privkey-pem:first"])
.arg(mismatched_compound)
.args(["--privkey-pem:second"])
.arg(valid_compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
assert_eq!(lax.stdout, b"certificate companion");
let malformed = temp.path().join("malformed.pem");
fs::write(&malformed, "not a certificate").unwrap();
let malformed_compound = format!("{},{}", private_key.display(), malformed.display());
let rejected = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(malformed_compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("certificate"));
let missing_compound = format!(
"{},{}",
private_key.display(),
temp.path().join("missing.pem").display()
);
let missing = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(missing_compound)
.arg(&encrypted)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("missing.pem"));
}
#[test]
fn encryption_rejects_incomplete_or_ambiguous_recipient_methods() {
// Template metadata must describe exactly one self-consistent wrapping
// method; first-match parsing can otherwise emit output our parser rejects.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(&plaintext, b"recipient metadata").unwrap();
let method = |children: &str| {
format!(
r#"<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep">{children}</EncryptionMethod>"#
)
};
let digest = r#"<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>"#;
let mgf = r#"<xenc11:MGF Algorithm="http://www.w3.org/2009/xmlenc11#mgf1sha256"/>"#;
let label = "<OAEPparams>YQ==</OAEPparams>";
for (case, recipient_method, expected) in [
("missing", String::new(), "EncryptionMethod"),
(
"duplicate-method",
format!("{}{}", method(""), method("")),
"EncryptionMethod",
),
(
"duplicate-digest",
method(&format!("{digest}{digest}")),
"DigestMethod",
),
("duplicate-mgf", method(&format!("{mgf}{mgf}")), "MGF"),
(
"duplicate-label",
method(&format!("{label}{label}")),
"OAEPparams",
),
] {
let template = temp.path().join(format!("{case}.xml"));
fs::write(
&template,
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey>{recipient_method}<CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#
),
)
.unwrap();
let output = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success(), "{case} unexpectedly succeeded");
assert!(
String::from_utf8_lossy(&output.stderr).contains(expected),
"{case}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[test]
fn encryption_rejects_templates_the_decryption_parser_cannot_consume() {
// Encryption preserves template metadata, so it must reject every structure
// that the reciprocal parser would reject instead of emitting dead output.
let temp = tempfile::tempdir().unwrap();
let plaintext = temp.path().join("plaintext.bin");
let aes_key = temp.path().join("aes-key.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(&plaintext, b"template validation").unwrap();
fs::write(&aes_key, b"0123456789abcdef").unwrap();
let oversized_id = "x".repeat(4 * 1024 + 1);
let cases = [
(
"recipient-child-order",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><ds:KeyInfo><ds:KeyName>recipient</ds:KeyName></ds:KeyInfo><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#.to_owned(),
true,
),
(
"unsupported-recipient-method-child",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:ext="urn:test"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"><ext:Parameters/></EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#.to_owned(),
true,
),
(
"empty-content-key-name",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><ds:KeyName/></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#.to_owned(),
false,
),
(
"empty-recipient-key-name",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"/><ds:KeyInfo><ds:KeyName/></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#.to_owned(),
true,
),
(
"oversized-encrypted-data-id",
format!(
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Id="{oversized_id}"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#
),
false,
),
(
"agreement-only-key-info",
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><AgreementMethod Algorithm="urn:test:agreement"/></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#.to_owned(),
false,
),
];
for (case, xml, uses_rsa_recipient) in cases {
let template = temp.path().join(format!("{case}.xml"));
let output_path = temp.path().join(format!("{case}-output.xml"));
fs::write(&template, xml).unwrap();
let mut command = Command::new(binary());
command.arg("encrypt");
if uses_rsa_recipient {
command.arg("--pubkey-pem").arg(&public_key);
} else {
command.arg("--aes-key").arg(&aes_key);
}
let output = command
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&output_path)
.arg(&template)
.output()
.unwrap();
assert!(!output.status.success(), "{case} unexpectedly succeeded");
assert!(!output_path.exists(), "{case} emitted partial output");
}
}
#[test]
fn rsa_oaep_label_split_by_comment_round_trips() {
// XML simple content spans every direct text node. Keeping only the first
// side of a comment makes encryption and decryption use different labels.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("split-label.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(&plaintext, b"comment-split OAEP label").unwrap();
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"><OAEPparams>YWJj<!--split-->ZA==</OAEPparams></EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"comment-split OAEP label");
}
#[test]
fn honors_legacy_rsa_oaep_parameters_from_the_template() {
// Advertising rsa-oaep-mgf1p requires an actual process round trip, and
// template parameters must drive key wrapping rather than only survive as XML.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("legacy-oaep.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<ds:KeyInfo><EncryptedKey Recipient="legacy">
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/></EncryptionMethod>
<CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo>
<CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"legacy OAEP payload").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.args(["--binary-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let xml = fs::read_to_string(&encrypted).unwrap();
assert!(xml.contains("rsa-oaep-mgf1p"));
assert!(xml.contains("Recipient=\"legacy\""));
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"legacy OAEP payload");
}
#[test]
fn rsa_oaep_accepts_the_xmldsig_more_sha384_digest_uri() {
// libxmlsec1 emits the XMLDSig-more spelling for SHA-384. Encryption and
// decryption must share one alias mapping for this interoperable template.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("sha384-alias.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#rsa-oaep"><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#sha384"/><xenc11:MGF Algorithm="http://www.w3.org/2009/xmlenc11#mgf1sha384"/></EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"SHA-384 OAEP alias").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"SHA-384 OAEP alias");
}
#[test]
fn legacy_rsa_oaep_accepts_explicit_xmlenc11_mgf_parameters() {
// libxmlsec1 treats MGF1-SHA1 as the absent-child default for the legacy
// URI, while preserving and executing an explicit XMLEnc 1.1 MGF child.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("legacy-oaep-with-mgf.xml");
let plaintext = temp.path().join("plaintext.bin");
let encrypted = temp.path().join("encrypted.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"><xenc11:MGF Algorithm="http://www.w3.org/2009/xmlenc11#mgf1sha256"/></EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"explicit legacy OAEP MGF").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
assert!(
fs::read_to_string(&encrypted)
.unwrap()
.contains("http://www.w3.org/2009/xmlenc11#mgf1sha256")
);
let decrypt = Command::new(binary())
.args(["decrypt", "--privkey-pem"])
.arg(&private_key)
.arg(&encrypted)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
assert_eq!(decrypt.stdout, b"explicit legacy OAEP MGF");
}
#[test]
fn legacy_rsa_oaep_rejects_unknown_mgf_algorithm() {
// Accepting the XMLEnc 1.1 child does not make its Algorithm extensible:
// an unknown URI must fail before key transport or output creation.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("legacy-oaep-with-unknown-mgf.xml");
let plaintext = temp.path().join("plaintext.bin");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><ds:KeyInfo><EncryptedKey><EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"><xenc11:MGF Algorithm="urn:unsupported:mgf"/></EncryptionMethod><CipherData><CipherValue/></CipherData></EncryptedKey></ds:KeyInfo><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, b"unsupported MGF").unwrap();
let rejected = Command::new(binary())
.args(["encrypt", "--pubkey-pem"])
.arg(&public_key)
.arg("--binary-data")
.arg(&plaintext)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("urn:unsupported:mgf"));
}
#[test]
fn decrypts_encrypted_data_embedded_in_a_document() {
// libxmlsec1 decrypt replaces EncryptedData in its containing document; a
// standalone-only implementation would reject this process contract.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let key = temp.path().join("key.bin");
let encrypted = temp.path().join("encrypted.xml");
let document = temp.path().join("document.xml");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element">
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, "<secret>document payload</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aeskey:content"])
.arg(&key)
.args(["--xml-data"])
.arg(&plaintext)
.args(["--output"])
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(encrypt.status.success());
fs::write(
&document,
format!(
"<root><before/>{}<after/></root>",
fs::read_to_string(&encrypted).unwrap()
),
)
.unwrap();
let decrypted = Command::new(binary())
.args(["decrypt", "--aeskey"])
.arg(&key)
.arg(&document)
.output()
.unwrap();
assert!(
decrypted.status.success(),
"{}",
String::from_utf8_lossy(&decrypted.stderr)
);
let output = String::from_utf8(decrypted.stdout).unwrap();
let parsed = roxmltree::Document::parse(&output).unwrap();
assert_eq!(parsed.root_element().tag_name().name(), "root");
assert_eq!(
parsed
.descendants()
.find(|node| node.has_tag_name("secret"))
.and_then(|node| node.text()),
Some("document payload")
);
}
#[test]
fn embedded_decryption_replaces_only_the_first_encrypted_data() {
// libxmlsec1 starts at the selected operation node and decrypts its first
// EncryptedData descendant rather than rejecting later encrypted siblings.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("template.xml");
let plaintext = temp.path().join("plaintext.xml");
let key = temp.path().join("key.bin");
let encrypted = temp.path().join("encrypted.xml");
let document = temp.path().join("document.xml");
fs::write(
&template,
r#"<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element"><EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/><CipherData><CipherValue/></CipherData></EncryptedData>"#,
)
.unwrap();
fs::write(&plaintext, "<secret>first payload</secret>").unwrap();
fs::write(&key, b"0123456789abcdef").unwrap();
let encrypt = Command::new(binary())
.args(["encrypt", "--aes-key"])
.arg(&key)
.arg("--xml-data")
.arg(&plaintext)
.arg("--output")
.arg(&encrypted)
.arg(&template)
.output()
.unwrap();
assert!(
encrypt.status.success(),
"{}",
String::from_utf8_lossy(&encrypt.stderr)
);
let encrypted_xml = fs::read_to_string(&encrypted).unwrap();
fs::write(
&document,
format!("<root><first>{encrypted_xml}</first><second>{encrypted_xml}</second></root>"),
)
.unwrap();
let decrypt = Command::new(binary())
.args(["decrypt", "--aes-key"])
.arg(&key)
.arg(&document)
.output()
.unwrap();
assert!(
decrypt.status.success(),
"{}",
String::from_utf8_lossy(&decrypt.stderr)
);
let output = String::from_utf8(decrypt.stdout).unwrap();
let parsed = roxmltree::Document::parse(&output).unwrap();
let first = parsed
.descendants()
.find(|node| node.has_tag_name("first"))
.unwrap();
let second = parsed
.descendants()
.find(|node| node.has_tag_name("second"))
.unwrap();
assert!(first.descendants().any(|node| node.has_tag_name("secret")));
assert!(
second.descendants().any(|node| {
node.has_tag_name(("http://www.w3.org/2001/04/xmlenc#", "EncryptedData"))
})
);
}
#[test]
fn generated_key_store_uses_the_libxmlsec1_xml_shape() {
// Validate namespaces and element layout, not just well-formedness, because
// libxmlsec1's key manager depends on this exact interoperable structure.
let temp = tempfile::tempdir().unwrap();
let key_store = temp.path().join("keys.xml");
let generated = Command::new(binary())
.args(["keys", "--gen-key:integration<&", "aes-128"])
.arg(&key_store)
.output()
.unwrap();
assert!(
generated.status.success(),
"{}",
String::from_utf8_lossy(&generated.stderr)
);
let xml = fs::read_to_string(key_store).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
let elements = document
.descendants()
.filter(|node| node.is_element())
.map(|node| (node.tag_name().namespace(), node.tag_name().name()))
.collect::<Vec<_>>();
assert_eq!(
elements,
vec![
(Some("http://www.aleksey.com/xmlsec/2002"), "Keys"),
(Some("http://www.w3.org/2000/09/xmldsig#"), "KeyInfo"),
(Some("http://www.w3.org/2000/09/xmldsig#"), "KeyName"),
(Some("http://www.w3.org/2000/09/xmldsig#"), "KeyValue"),
(Some("http://www.aleksey.com/xmlsec/2002"), "AESKeyValue"),
]
);
assert_eq!(
document
.descendants()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.and_then(|node| node.text()),
Some("integration<&")
);
}
#[test]
fn generated_key_store_rejects_non_xml_key_names_before_writing() {
// Escaping handles markup but cannot make XML 1.0-forbidden characters
// serializable; a failed command must not leave a malformed secret file.
let temp = tempfile::tempdir().unwrap();
let key_store = temp.path().join("invalid.xml");
let output = Command::new(binary())
.arg("keys")
.arg("--gen-key:invalid\u{1}name")
.arg("aes-128")
.arg(&key_store)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("XML"));
assert!(!key_store.exists());
}
#[test]
fn generated_key_store_allows_an_unnamed_key() {
// The optional --gen-key parameter controls KeyName presence; omitting it
// must still generate usable AES material rather than rejecting the command.
let generated = Command::new(binary())
.args(["keys", "--gen-key", "aes-128"])
.output()
.unwrap();
assert!(
generated.status.success(),
"{}",
String::from_utf8_lossy(&generated.stderr)
);
let xml = String::from_utf8(generated.stdout).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
assert!(
!document
.descendants()
.any(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
);
let value = document
.descendants()
.find(|node| node.has_tag_name(("http://www.aleksey.com/xmlsec/2002", "AESKeyValue")))
.and_then(|node| node.text())
.unwrap();
assert_eq!(
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
.unwrap()
.len(),
16
);
}
#[cfg(unix)]
#[test]
fn generated_key_store_is_private_on_create_and_overwrite() {
use std::os::unix::fs::PermissionsExt as _;
// Key stores contain raw symmetric keys; both a new file and an existing
// permissive file must end with owner-only permissions.
let temp = tempfile::tempdir().unwrap();
let created = temp.path().join("created.xml");
let create = Command::new(binary())
.args(["keys", "--gen-key:private", "aes-128"])
.arg(&created)
.output()
.unwrap();
assert!(create.status.success());
assert_eq!(
fs::metadata(&created).unwrap().permissions().mode() & 0o777,
0o600
);
let key_store = temp.path().join("keys.xml");
fs::write(&key_store, b"old").unwrap();
fs::set_permissions(&key_store, fs::Permissions::from_mode(0o666)).unwrap();
let generated = Command::new(binary())
.args(["keys", "--gen-key:private", "aes-128"])
.arg(&key_store)
.output()
.unwrap();
assert!(generated.status.success());
assert_eq!(
fs::metadata(&key_store).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn explicit_certificate_pins_the_verification_identity() {
// An embedded certificate must not override an explicit certificate passed
// by the caller, even when both certificates are structurally valid.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let wrong_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let signed = temp.path().join("signed.xml");
let compound = format!("{},{}", private_key.display(), certificate.display());
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
assert!(
Command::new(binary())
.args(["verify", "--pubkey-cert-pem"])
.arg(&certificate)
.arg(&signed)
.status()
.unwrap()
.success()
);
assert!(
!Command::new(binary())
.args(["verify", "--pubkey-cert-pem"])
.arg(&wrong_certificate)
.arg(&signed)
.status()
.unwrap()
.success()
);
assert_eq!(
Command::new(binary())
.arg("check-key-data")
.status()
.unwrap()
.code(),
Some(0)
);
assert_eq!(
Command::new(binary())
.args(["check-key-data", "unknown-key-data"])
.status()
.unwrap()
.code(),
Some(1)
);
}
#[test]
fn embedded_certificate_requires_trust_unless_insecure() {
// Document-controlled X509Data is an identity claim, not a trust anchor.
// Only an explicit insecure opt-out may verify it without caller trust.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let signed = temp.path().join("signed.xml");
let compound = format!("{},{}", private_key.display(), certificate.display());
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let untrusted = Command::new(binary())
.arg("verify")
.arg(&signed)
.output()
.unwrap();
assert!(!untrusted.status.success());
assert!(String::from_utf8_lossy(&untrusted.stderr).contains("trusted"));
let insecure = Command::new(binary())
.args(["verify", "--insecure"])
.arg(&signed)
.output()
.unwrap();
assert!(
insecure.status.success(),
"{}",
String::from_utf8_lossy(&insecure.stderr)
);
let insecure_with_crls = Command::new(binary())
.args(["verify", "--insecure", "--verify-crls"])
.arg(&signed)
.output()
.unwrap();
assert!(
insecure_with_crls.status.success(),
"{}",
String::from_utf8_lossy(&insecure_with_crls.stderr)
);
}
#[test]
fn signing_embeds_every_certificate_from_the_private_key_option() {
// libxmlsec1 treats every comma-separated path after the private key as a
// certificate to embed, so recipients can reconstruct the supplied chain.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let leaf = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let issuer = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let signed = temp.path().join("signed.xml");
let compound = format!(
"{},{},{}",
private_key.display(),
leaf.display(),
issuer.display()
);
let result = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.args(["--output"])
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let xml = fs::read_to_string(signed).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
assert_eq!(
document
.descendants()
.filter(
|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "X509Certificate"))
)
.count(),
2
);
}
#[test]
fn certificate_embedding_preserves_existing_key_info_sources() {
// Populating X509Data must not erase the KeyName that selected the signing
// key or create a second X509Data beside the template placeholder.
let temp = tempfile::tempdir().unwrap();
let source = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let template = temp.path().join("key-info-template.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let public_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let signed = temp.path().join("signed.xml");
let template_xml = fs::read_to_string(source).unwrap().replace(
"<X509Data/>",
"<X509Data Id=\"caller\"><X509CRL>Y3Js</X509CRL></X509Data>",
);
fs::write(&template, template_xml).unwrap();
let compound = format!("{},{}", private_key.display(), certificate.display());
let result = Command::new(binary())
.args(["sign", "--privkey-pem:TestKeyName-rsa-2048"])
.arg(compound)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let xml = fs::read_to_string(&signed).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.filter_map(|node| node.text())
.collect::<Vec<_>>(),
["TestKeyName-rsa-2048"]
);
assert_eq!(
document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "X509Data")))
.count(),
1
);
assert_eq!(
document
.descendants()
.filter(|node| {
node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "X509Certificate"))
})
.count(),
1
);
let x509_data = document
.descendants()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "X509Data")))
.unwrap();
assert_eq!(x509_data.attribute("Id"), Some("caller"));
assert_eq!(
x509_data
.children()
.find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "X509CRL")))
.and_then(|node| node.text()),
Some("Y3Js")
);
let verify = Command::new(binary())
.args(["verify", "--pubkey-pem:TestKeyName-rsa-2048"])
.arg(&public_key)
.arg(&signed)
.output()
.unwrap();
assert!(
verify.status.success(),
"{}",
String::from_utf8_lossy(&verify.stderr)
);
}
#[test]
fn signing_rejects_a_malformed_secondary_certificate() {
// Every certificate path is parsed before signing; a malformed trailing
// chain member must not be silently omitted from the emitted KeyInfo.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let leaf = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let malformed = temp.path().join("malformed.pem");
fs::write(&malformed, "not a certificate").unwrap();
let compound = format!(
"{},{},{}",
private_key.display(),
leaf.display(),
malformed.display()
);
let result = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("certificate"));
}
#[test]
fn signing_with_certificate_companions_does_not_require_key_info() {
// Certificate companions are always validated, but optional output metadata
// must only be written when the signature template provides its placeholder.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("without-key-info.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
fs::write(&template, signature_template_without_key_info()).unwrap();
let compound = format!("{},{}", private_key.display(), certificate.display());
let result = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg(&template)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let signed = String::from_utf8(result.stdout).unwrap();
assert!(signed.contains("<SignatureValue>"));
assert!(!signed.contains("<KeyInfo"));
let malformed = temp.path().join("malformed.pem");
fs::write(&malformed, "not a certificate").unwrap();
let malformed_compound = format!("{},{}", private_key.display(), malformed.display());
let rejected = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(malformed_compound)
.arg(&template)
.output()
.unwrap();
assert!(!rejected.status.success());
assert!(String::from_utf8_lossy(&rejected.stderr).contains("certificate"));
}
#[test]
fn explicit_certificate_obeys_trust_anchor_policy() {
// An explicit leaf pins identity but does not establish trust when callers
// also supply anchors; --insecure is the explicit compatibility opt-out.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let wrong_anchor = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let signed = temp.path().join("signed.xml");
let compound = format!("{},{}", private_key.display(), certificate.display());
assert!(
Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg("--output")
.arg(&signed)
.arg(&template)
.status()
.unwrap()
.success()
);
assert!(
Command::new(binary())
.args(["verify", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--trusted-pem")
.arg(&certificate)
.arg(&signed)
.status()
.unwrap()
.success()
);
assert!(
!Command::new(binary())
.args(["verify", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--trusted-pem")
.arg(&wrong_anchor)
.arg(&signed)
.status()
.unwrap()
.success()
);
assert!(
Command::new(binary())
.args(["verify", "--insecure", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--trusted-pem")
.arg(&wrong_anchor)
.arg(&signed)
.status()
.unwrap()
.success()
);
}
#[test]
fn explicit_certificate_verification_honors_embedded_crls() {
// The explicit certificate pins identity, while document X509CRL entries
// still provide revocation evidence when the caller enables CRL checking.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let intermediate = project_root().join("tests/fixtures/keys/ca2cert.pem");
let anchor = project_root().join("tests/fixtures/keys/cacert.pem");
let crl = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem");
let signed = temp.path().join("signed.xml");
let with_crl = temp.path().join("signed-with-crl.xml");
let compound = format!("{},{}", private_key.display(), certificate.display());
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg("--output")
.arg(&signed)
.arg(&template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let signed_xml = fs::read_to_string(&signed).unwrap();
let signed_xml = signed_xml.replacen(
"</X509Data>",
&format!("<X509CRL>{}</X509CRL></X509Data>", pem_der_base64(&crl)),
1,
);
fs::write(&with_crl, signed_xml).unwrap();
let unchecked = Command::new(binary())
.args(["verify", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--trusted-pem")
.arg(&anchor)
.arg("--untrusted-pem")
.arg(&intermediate)
.arg(&with_crl)
.output()
.unwrap();
assert!(
unchecked.status.success(),
"{}",
String::from_utf8_lossy(&unchecked.stderr)
);
let checked = Command::new(binary())
.args(["verify", "--verify-crls", "--pubkey-cert-pem"])
.arg(&certificate)
.arg("--trusted-pem")
.arg(&anchor)
.arg("--untrusted-pem")
.arg(&intermediate)
.arg(&with_crl)
.output()
.unwrap();
assert!(!checked.status.success());
assert!(
// RFC 10007 section 4 rejects this legacy v3 issuer's absent KeyUsage
// before consulting the authenticated CRL's revoked serials.
String::from_utf8_lossy(&checked.stderr)
.contains("certificate at chain position 1 does not permit cRLSign"),
"{}",
String::from_utf8_lossy(&checked.stderr)
);
}
#[test]
fn explicit_certificate_verification_reports_revoked_leaf() {
// Exercise the CLI's revoked-serial path with an issuer that is authorized to sign CRLs;
// this remains independent from the legacy fixture's cRLSign rejection above.
let temp = tempfile::tempdir().unwrap();
let mut root_params = CertificateParams::new(Vec::new()).unwrap();
root_params
.distinguished_name
.push(rcgen::DnType::CommonName, "CLI revocation root");
root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let root = rcgen::CertifiedIssuer::self_signed(root_params, KeyPair::generate().unwrap())
.expect("root should be self-signable");
let issuer_key = KeyPair::generate().expect("issuer key generation should succeed");
let mut issuer_params = CertificateParams::new(Vec::new()).unwrap();
issuer_params
.distinguished_name
.push(rcgen::DnType::CommonName, "CLI revocation issuer");
issuer_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
issuer_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let issuer_certificate = issuer_params
.signed_by(&issuer_key, &root)
.expect("root should sign issuer certificate");
let issuer = Issuer::new(issuer_params, issuer_key);
let mut leaf_params = CertificateParams::new(Vec::new()).unwrap();
leaf_params.serial_number = Some(SerialNumber::from(42_u64));
leaf_params
.distinguished_name
.push(rcgen::DnType::CommonName, "CLI revoked signer");
leaf_params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
let leaf_key = KeyPair::generate().expect("leaf key generation should succeed");
let leaf = leaf_params
.signed_by(&leaf_key, &issuer)
.expect("issuer should sign leaf certificate");
let verification_window = OffsetDateTime::now_utc();
let crl = CertificateRevocationListParams {
this_update: verification_window - Duration::days(1),
next_update: verification_window + Duration::days(1),
crl_number: SerialNumber::from(1_u64),
issuing_distribution_point: None,
revoked_certs: vec![RevokedCertParams {
serial_number: SerialNumber::from(42_u64),
revocation_time: verification_window - Duration::hours(1),
reason_code: None,
invalidity_date: None,
}],
key_identifier_method: KeyIdMethod::Sha256,
}
.signed_by(&issuer)
.expect("issuer should sign CRL");
let root_path = temp.path().join("root.pem");
let issuer_path = temp.path().join("issuer.pem");
let leaf_path = temp.path().join("leaf.pem");
let leaf_key_path = temp.path().join("leaf-key.pem");
fs::write(&root_path, pem_text("CERTIFICATE", root.der())).unwrap();
fs::write(
&issuer_path,
pem_text("CERTIFICATE", issuer_certificate.der()),
)
.unwrap();
fs::write(&leaf_path, pem_text("CERTIFICATE", leaf.der())).unwrap();
fs::write(&leaf_key_path, leaf_key.serialize_pem()).unwrap();
let signed = temp.path().join("signed.xml");
let with_crl = temp.path().join("signed-with-crl.xml");
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-ecdsa-sha256.tmpl");
let compound = format!("{},{}", leaf_key_path.display(), leaf_path.display());
let sign = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(compound)
.arg("--output")
.arg(&signed)
.arg(template)
.output()
.unwrap();
assert!(
sign.status.success(),
"{}",
String::from_utf8_lossy(&sign.stderr)
);
let signed_xml = fs::read_to_string(&signed).unwrap();
let signed_xml = signed_xml.replacen(
"</X509Data>",
&format!(
"<X509CRL>{}</X509CRL></X509Data>",
base64::engine::general_purpose::STANDARD.encode(crl.der())
),
1,
);
fs::write(&with_crl, signed_xml).unwrap();
let checked = Command::new(binary())
.args(["verify", "--verify-crls", "--pubkey-cert-pem"])
.arg(&leaf_path)
.arg("--trusted-pem")
.arg(&root_path)
.arg("--untrusted-pem")
.arg(&issuer_path)
.arg(&with_crl)
.output()
.unwrap();
assert!(!checked.status.success());
assert!(
String::from_utf8_lossy(&checked.stderr)
.contains("certificate at chain position 0 is revoked"),
"{}",
String::from_utf8_lossy(&checked.stderr)
);
}
#[test]
fn der_private_key_option_decodes_its_der_companion_certificate() {
// The comma-separated companion uses the same encoding family as the key
// option; treating DER certificate bytes as UTF-8 would reject valid input.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"))
.unwrap();
let private_der = temp.path().join("private.der");
fs::write(
&private_der,
RsaPrivateKey::from_pkcs8_pem(&private_pem)
.unwrap()
.to_pkcs8_der()
.unwrap()
.as_bytes(),
)
.unwrap();
let certificate_pem =
fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem"))
.unwrap();
let (_, certificate) = x509_parser::pem::parse_x509_pem(certificate_pem.as_bytes()).unwrap();
let certificate_der = temp.path().join("certificate.der");
fs::write(&certificate_der, certificate.contents).unwrap();
let compound = format!("{},{}", private_der.display(), certificate_der.display());
let output = Command::new(binary())
.args(["sign", "--privkey-p8-der"])
.arg(compound)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("X509Certificate")
);
}
#[test]
fn multiple_signing_keys_require_the_matching_template_name() {
// Repeated key options are a key set, not first-one-wins. A template name
// selects exactly one named key and an unknown name fails deterministically.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let wrong_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let selected = Command::new(binary())
.args(["sign", "--privkey-pem:wrong"])
.arg(&wrong_key)
.args(["--privkey-pem:TestKeyName-rsa-2048"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(
selected.status.success(),
"{}",
String::from_utf8_lossy(&selected.stderr)
);
let missing = Command::new(binary())
.args(["sign", "--privkey-pem:first"])
.arg(&private_key)
.args(["--privkey-pem:second"])
.arg(&wrong_key)
.arg(&template)
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("unknown KeyName"));
// Lax lookup is type-based rather than identity-only and therefore remains
// effective when the explicit key set contains more than one RSA key.
let lax = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:first"])
.arg(&private_key)
.args(["--privkey-pem:second"])
.arg(&wrong_key)
.arg(&template)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
}
#[test]
fn lax_signing_skips_keys_rejected_by_the_signing_policy() {
// Lax lookup is an ordered search for a usable key, not permission to select
// a weak key and fail before trying a later policy-compliant candidate.
let temp = tempfile::tempdir().unwrap();
let weak_key_path = temp.path().join("rsa-1024.pem");
let weak_key = RsaPrivateKey::new(&mut ChaCha8Rng::from_seed([0x71; 32]), 1024).unwrap();
fs::write(
&weak_key_path,
weak_key
.to_pkcs8_pem(Default::default())
.unwrap()
.as_bytes(),
)
.unwrap();
let compliant_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let signed = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:weak"])
.arg(&weak_key_path)
.args(["--privkey-pem:compliant"])
.arg(&compliant_key)
.arg(&template)
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
}
#[test]
fn lax_signing_skips_a_key_with_a_mismatched_certificate_companion() {
// A comma-separated key and certificate chain is one candidate. Lax search
// must reject the complete candidate before considering the next option.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let matching_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
let unrelated_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let mismatched = format!(
"{},{}",
private_key.display(),
unrelated_certificate.display()
);
let matching = format!(
"{},{}",
private_key.display(),
matching_certificate.display()
);
let signed = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:first"])
.arg(mismatched)
.args(["--privkey-pem:second"])
.arg(matching)
.arg(&template)
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
}
#[test]
fn lax_signing_skips_a_key_that_conflicts_with_preserved_rsa_key_value() {
// Preserved public identity is part of candidate suitability. Lax search
// must continue after a valid private key whose public components differ.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("key-value-template.xml");
let wrong = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let matching = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let matching_key =
RsaPrivateKey::from_pkcs8_pem(&fs::read_to_string(&matching).unwrap()).unwrap();
fs::write(
&template,
signature_template_with_key_info(&rsa_key_value_with_leading_zeroes(
&matching_key.to_public_key(),
0,
0,
)),
)
.unwrap();
let signed = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:wrong"])
.arg(&wrong)
.args(["--privkey-pem:matching"])
.arg(&matching)
.arg(&template)
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
}
#[test]
fn lax_signing_skips_a_key_that_conflicts_with_preserved_x509_identity() {
// Each key/certificate option can be internally consistent yet contradict
// the template's X509Data; lax search must validate that identity per try.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("x509-template.xml");
let wrong_key = project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem");
let wrong_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-2048-cert.pem");
let matching_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let matching_certificate = project_root().join("tests/fixtures/keys/rsa/rsa-4096-cert.pem");
fs::write(
&template,
signature_template_with_key_info(&x509_certificate_value(&matching_certificate)),
)
.unwrap();
let wrong = format!("{},{}", wrong_key.display(), wrong_certificate.display());
let matching = format!(
"{},{}",
matching_key.display(),
matching_certificate.display()
);
let signed = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:wrong"])
.arg(wrong)
.args(["--privkey-pem:matching"])
.arg(matching)
.arg(&template)
.output()
.unwrap();
assert!(
signed.status.success(),
"{}",
String::from_utf8_lossy(&signed.stderr)
);
}
#[test]
fn verification_selects_a_later_named_key_from_every_template_key_name() {
// Repeated public-key inputs model a key manager, and every direct
// KeyName is an ordered lookup source rather than only the first one.
let temp = tempfile::tempdir().unwrap();
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let matching_public = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let wrong_public = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let signed = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(signed.status.success());
let signed_path = temp.path().join("multiple-key-names.xml");
let signed_xml = String::from_utf8(signed.stdout).unwrap().replace(
"<KeyName>TestKeyName-rsa-2048</KeyName>",
"<KeyName>stale</KeyName><KeyName>selected</KeyName>",
);
fs::write(&signed_path, signed_xml).unwrap();
let verified = Command::new(binary())
.args(["verify", "--pubkey-pem:wrong"])
.arg(&wrong_public)
.args(["--pubkey-pem:selected"])
.arg(&matching_public)
.arg(&signed_path)
.output()
.unwrap();
assert!(
verified.status.success(),
"{}",
String::from_utf8_lossy(&verified.stderr)
);
}
#[test]
fn singleton_named_signing_key_obeys_template_key_name() {
// Naming one key enables strict KeyName lookup even when the key set has a
// single entry; lax lookup is the explicit donor-compatible escape hatch.
let template = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
let strict = Command::new(binary())
.args(["sign", "--privkey-pem:unexpected"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!strict.status.success());
assert!(String::from_utf8_lossy(&strict.stderr).contains("unknown KeyName"));
let lax = Command::new(binary())
.args(["sign", "--lax-key-search", "--privkey-pem:unexpected"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(
lax.status.success(),
"{}",
String::from_utf8_lossy(&lax.stderr)
);
}
#[test]
fn singleton_named_signing_key_reports_missing_template_key_name() {
// A registry-named signing key without a template lookup name is a distinct
// failure from ambiguity among multiple keys and must report that contract.
let temp = tempfile::tempdir().unwrap();
let template = temp.path().join("without-key-info.xml");
let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem");
fs::write(&template, signature_template_without_key_info()).unwrap();
let result = Command::new(binary())
.args(["sign", "--privkey-pem:named"])
.arg(&private_key)
.arg(&template)
.output()
.unwrap();
assert!(!result.status.success());
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(stderr.contains("named private key requires a template KeyName"));
assert!(!stderr.contains("multiple private keys"));
}
#[cfg(target_os = "linux")]
#[test]
fn signs_through_non_utf8_filesystem_paths() {
use std::os::unix::ffi::OsStringExt as _;
// Unix paths are byte strings; CLI parsing must not reject a valid file
// solely because its name cannot be represented as UTF-8.
let temp = tempfile::tempdir().unwrap();
let template = temp
.path()
.join(std::ffi::OsString::from_vec(b"template-\xff.xml".to_vec()));
let key = temp
.path()
.join(std::ffi::OsString::from_vec(b"key-\xfe.pem".to_vec()));
fs::copy(
project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"),
&template,
)
.unwrap();
fs::copy(
project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"),
&key,
)
.unwrap();
let output = Command::new(binary())
.args(["sign", "--privkey-pem"])
.arg(&key)
.arg(&template)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn generated_key_store_contains_every_requested_key() {
// Repeated --gen-key options are independent requests and must never be
// silently collapsed to the first parsed value.
let generated = Command::new(binary())
.args(["keys", "-g:first", "aes-128", "--gen-key:second", "aes-256"])
.output()
.unwrap();
assert!(generated.status.success());
let xml = String::from_utf8(generated.stdout).unwrap();
let document = roxmltree::Document::parse(&xml).unwrap();
let names = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName")))
.filter_map(|node| node.text())
.collect::<Vec<_>>();
assert_eq!(names, ["first", "second"]);
let key_lengths = document
.descendants()
.filter(|node| node.has_tag_name(("http://www.aleksey.com/xmlsec/2002", "AESKeyValue")))
.map(|node| {
base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
node.text().unwrap(),
)
.unwrap()
.len()
})
.collect::<Vec<_>>();
assert_eq!(key_lengths, [16, 32]);
}
#[test]
fn capability_queries_report_supported_and_unsupported_names() {
assert!(
Command::new(binary())
.args(["check-transforms", "c14n", "rsa-sha256"])
.status()
.unwrap()
.success()
);
assert_eq!(
Command::new(binary())
.arg("check-transforms")
.status()
.unwrap()
.code(),
Some(0)
);
assert_eq!(
Command::new(binary())
.args(["check-transforms", "rsa-oaep-mgf1p"])
.status()
.unwrap()
.code(),
Some(0)
);
assert!(
!Command::new(binary())
.args(["check-transforms", "xslt"])
.status()
.unwrap()
.success()
);
}
#[test]
fn duplicate_named_verification_keys_are_rejected_as_ambiguous() {
// Repeatable keys are a lookup set, but one KeyName must never select two
// entries because silently choosing by option order would be unstable.
let signed = project_root()
.join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.xml");
let first = project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem");
let second = project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem");
let conflicting_keys = Command::new(binary())
.args(["verify", "--pubkey-pem:TestKeyName-rsa-2048"])
.arg(&first)
.arg("--pubkey-pem:TestKeyName-rsa-2048")
.arg(&second)
.arg(&signed)
.output()
.unwrap();
assert!(!conflicting_keys.status.success());
assert!(
String::from_utf8_lossy(&conflicting_keys.stderr)
.contains("multiple verification key inputs match template KeyNames")
);
}
#[test]
fn foreign_encryption_namespace_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let malformed = temp.path().join("malformed.xml");
let foreign_template = temp.path().join("foreign-template.xml");
fs::write(&malformed, "<Signature>").unwrap();
fs::write(
&foreign_template,
"<EncryptedData xmlns=\"urn:not-xmlenc\"><EncryptionMethod Algorithm=\"http://www.w3.org/2009/xmlenc11#aes128-gcm\"/><CipherData><CipherValue/></CipherData></EncryptedData>",
)
.unwrap();
let foreign = Command::new(binary())
.args(["encrypt", "--aeskey", "missing.key", "--binary-data"])
.arg(&malformed)
.arg(&foreign_template)
.output()
.unwrap();
assert!(!foreign.status.success());
assert!(String::from_utf8_lossy(&foreign.stderr).contains("no EncryptedData"));
}
#[test]
fn parser_input_and_output_failures_are_nonzero() {
let temp = tempfile::tempdir().unwrap();
let malformed = temp.path().join("malformed.xml");
fs::write(&malformed, "<Signature>").unwrap();
assert_eq!(
Command::new(binary())
.arg("unknown-command")
.status()
.unwrap()
.code(),
Some(1)
);
let invalid_xml = Command::new(binary())
.args(["verify", "--pubkey-pem", "missing.pem"])
.arg(&malformed)
.output()
.unwrap();
assert!(!invalid_xml.status.success());
assert!(String::from_utf8_lossy(&invalid_xml.stderr).contains("signature"));
assert!(
!Command::new(binary())
.args(["verify", "--unknown-option", "missing.xml"])
.status()
.unwrap()
.success()
);
assert!(
!Command::new(binary())
.args([
"keys",
"--gen-key:test",
"aes-128",
"/missing/output/keys.xml"
])
.status()
.unwrap()
.success()
);
}
#[test]
fn positional_sentinel_after_input_is_not_silently_discarded() {
// Once a filename has ended option parsing, a later `--` is a second
// filename and must fail the one-input process contract before file access.
let output = Command::new(binary())
.args(["verify", "signed.xml", "--"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&output.stderr).contains("expects exactly one input file"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn nonempty_crypto_config_is_rejected_with_the_expected_diagnostic() {
let temp = tempfile::tempdir().unwrap();
let config = temp.path().join("crypto-config");
fs::create_dir(&config).unwrap();
fs::write(config.join("backend.conf"), "unsupported").unwrap();
let output = Command::new(binary())
.args(["check-transforms", "--crypto-config"])
.arg(&config)
.arg("c14n")
.output()
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("unsupported option for this command: --crypto-config")
);
}