use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use crate::crypto::cert::X509Certificate;
use crate::crypto::keypair::KeyPair;
use crate::dsig::algorithms::{C14nAlgorithm, DigestAlgorithm, SignatureAlgorithm};
use crate::dsig::c14n::canonicalize;
use crate::dsig::reference::{DS_NS as DSIG_NS, EC_NS, ENVELOPED_SIGNATURE_URI};
use crate::error::Error;
use crate::xml::parse::{Document, Element, Node, QName};
const SAML_ASSERTION_NS: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
pub(crate) fn sign_element(
element: Element,
document_context: &Document,
opts: SignOptions<'_>,
) -> Result<Element, Error> {
let SignOptions {
signing_key,
sig_alg,
digest_alg,
c14n_alg,
inclusive_namespaces,
include_x509_cert,
} = opts;
let id_value = element
.attribute(None, "ID")
.ok_or(Error::InvalidConfiguration {
reason: "signing target requires ID attribute",
})?
.to_owned();
let target_canonical = canonicalize(
document_context,
&element,
&[],
c14n_alg,
inclusive_namespaces,
)?;
let digest_bytes = digest_alg.digest(&target_canonical);
let digest_b64 = BASE64_STANDARD.encode(&digest_bytes);
let signed_info = build_signed_info(
sig_alg,
digest_alg,
c14n_alg,
&id_value,
&digest_b64,
inclusive_namespaces,
);
let signed_info_root =
clone_with_namespace(&signed_info, Some("ds".to_owned()), DSIG_NS.to_owned());
let signed_info_doc = Document::new(signed_info_root)?;
let signed_info_canonical = canonicalize(
&signed_info_doc,
signed_info_doc.root(),
&[],
c14n_alg,
inclusive_namespaces,
)?;
let signature_bytes = signing_key.sign(sig_alg, &signed_info_canonical)?;
let signature_b64 = BASE64_STANDARD.encode(&signature_bytes);
let signature_element =
build_signature_element(signed_info, &signature_b64, include_x509_cert, signing_key)?;
let position = signature_insertion_index(&element);
let mut element = element;
element.insert_child(position, Node::Element(signature_element));
Ok(element)
}
pub(crate) struct SignOptions<'a> {
pub signing_key: &'a KeyPair,
pub sig_alg: SignatureAlgorithm,
pub digest_alg: DigestAlgorithm,
pub c14n_alg: C14nAlgorithm,
pub inclusive_namespaces: &'a [&'a str],
pub include_x509_cert: bool,
}
pub(crate) fn sign_detached_query(
canonical_query_string: &[u8],
signing_key: &KeyPair,
signature_algorithm: SignatureAlgorithm,
) -> Result<Vec<u8>, Error> {
signing_key.sign(signature_algorithm, canonical_query_string)
}
fn ds(local: &str) -> QName {
QName::new(Some(DSIG_NS.to_owned()), local)
}
fn ec(local: &str) -> QName {
QName::new(Some(EC_NS.to_owned()), local)
}
fn build_signed_info(
signature_algorithm: SignatureAlgorithm,
digest: DigestAlgorithm,
c14n: C14nAlgorithm,
id_value: &str,
digest_b64: &str,
inclusive_namespace_prefixes: &[&str],
) -> Element {
let canonicalization_method = Element::build(ds("CanonicalizationMethod"))
.with_attribute(QName::new(None, "Algorithm"), c14n.uri())
.finish();
let signature_method = Element::build(ds("SignatureMethod"))
.with_attribute(QName::new(None, "Algorithm"), signature_algorithm.uri())
.finish();
let enveloped_transform = Element::build(ds("Transform"))
.with_attribute(QName::new(None, "Algorithm"), ENVELOPED_SIGNATURE_URI)
.finish();
let mut c14n_transform_builder =
Element::build(ds("Transform")).with_attribute(QName::new(None, "Algorithm"), c14n.uri());
if c14n.is_exclusive() && !inclusive_namespace_prefixes.is_empty() {
let prefix_list = inclusive_namespace_prefixes.join(" ");
let inclusive = Element::build(ec("InclusiveNamespaces"))
.with_namespace(Some("ec".to_owned()), EC_NS)
.with_attribute(QName::new(None, "PrefixList"), prefix_list)
.finish();
c14n_transform_builder = c14n_transform_builder.with_child(Node::Element(inclusive));
}
let c14n_transform = c14n_transform_builder.finish();
let transforms = Element::build(ds("Transforms"))
.with_child(Node::Element(enveloped_transform))
.with_child(Node::Element(c14n_transform))
.finish();
let digest_method = Element::build(ds("DigestMethod"))
.with_attribute(QName::new(None, "Algorithm"), digest.uri())
.finish();
let digest_value = Element::build(ds("DigestValue"))
.with_text(digest_b64.to_owned())
.finish();
let reference = Element::build(ds("Reference"))
.with_attribute(QName::new(None, "URI"), format!("#{id_value}"))
.with_child(Node::Element(transforms))
.with_child(Node::Element(digest_method))
.with_child(Node::Element(digest_value))
.finish();
Element::build(ds("SignedInfo"))
.with_child(Node::Element(canonicalization_method))
.with_child(Node::Element(signature_method))
.with_child(Node::Element(reference))
.finish()
}
fn build_signature_element(
signed_info: Element,
signature_b64: &str,
include_x509_cert: bool,
signing_key: &KeyPair,
) -> Result<Element, Error> {
let signature_value = Element::build(ds("SignatureValue"))
.with_text(signature_b64.to_owned())
.finish();
let mut signature_builder = Element::build(ds("Signature"))
.with_namespace(Some("ds".to_owned()), DSIG_NS)
.with_child(Node::Element(signed_info))
.with_child(Node::Element(signature_value));
if include_x509_cert {
let cert = signing_key
.certificate()
.ok_or(Error::InvalidConfiguration {
reason: "include_x509_cert requested but signing key has no certificate",
})?;
let key_info = build_x509_key_info(cert, false);
signature_builder = signature_builder.with_child(Node::Element(key_info));
}
Ok(signature_builder.finish())
}
pub(crate) fn build_x509_key_info(cert: &X509Certificate, declare_ds_ns: bool) -> Element {
let x509_certificate = Element::build(ds("X509Certificate"))
.with_text(cert.to_base64_x509())
.finish();
let x509_data = Element::build(ds("X509Data"))
.with_child(Node::Element(x509_certificate))
.finish();
let mut key_info = Element::build(ds("KeyInfo"));
if declare_ds_ns {
key_info = key_info.with_namespace(Some("ds".to_owned()), DSIG_NS);
}
key_info.with_child(Node::Element(x509_data)).finish()
}
fn clone_with_namespace(element: &Element, prefix: Option<String>, uri: String) -> Element {
let mut cloned = element.clone();
cloned.namespaces_declared_here.insert(0, (prefix, uri));
cloned
}
fn signature_insertion_index(element: &Element) -> usize {
for (idx, child) in element.children().enumerate() {
if let Node::Element(child_elem) = child
&& child_elem.qname().local() == "Issuer"
&& child_elem.qname().namespace() == Some(SAML_ASSERTION_NS)
{
return idx.checked_add(1).unwrap_or(idx);
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{
EC_P256_CERT_PEM, EC_P256_KEY_PKCS8_PEM, RSA_CERT_PEM, RSA_KEY_PKCS8_PEM,
};
fn build_response_with_issuer(id: &str, with_issuer: bool) -> Element {
let samlp_ns = "urn:oasis:names:tc:SAML:2.0:protocol";
let mut builder = Element::build(QName::new(Some(samlp_ns.to_owned()), "Response"))
.with_namespace(Some("samlp".to_owned()), samlp_ns)
.with_namespace(Some("saml".to_owned()), SAML_ASSERTION_NS)
.with_attribute(QName::new(None, "ID"), id)
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(QName::new(None, "IssueInstant"), "2024-01-01T00:00:00Z");
if with_issuer {
let issuer = Element::build(QName::new(Some(SAML_ASSERTION_NS.to_owned()), "Issuer"))
.with_text("https://idp.example.com")
.finish();
builder = builder.with_child(Node::Element(issuer));
}
let status = Element::build(QName::new(Some(samlp_ns.to_owned()), "Status"))
.with_child(Node::Element(
Element::build(QName::new(Some(samlp_ns.to_owned()), "StatusCode"))
.with_attribute(
QName::new(None, "Value"),
"urn:oasis:names:tc:SAML:2.0:status:Success",
)
.finish(),
))
.finish();
builder = builder.with_child(Node::Element(status));
builder.finish()
}
fn rsa_key_with_cert() -> KeyPair {
let kp = KeyPair::from_pkcs8_pem(RSA_KEY_PKCS8_PEM).unwrap();
let cert = X509Certificate::from_pem(RSA_CERT_PEM).unwrap();
kp.with_certificate(cert)
}
fn ecdsa_key_with_cert() -> KeyPair {
let kp = KeyPair::from_pkcs8_pem(EC_P256_KEY_PKCS8_PEM).unwrap();
let cert = X509Certificate::from_pem(EC_P256_CERT_PEM).unwrap();
kp.with_certificate(cert)
}
fn find_signature(element: &Element) -> Option<&Element> {
element.child_element(Some(DSIG_NS), "Signature")
}
fn signed_info_of(signature: &Element) -> &Element {
signature
.child_element(Some(DSIG_NS), "SignedInfo")
.expect("SignedInfo present")
}
fn digest_value_of(signed_info: &Element) -> String {
let reference = signed_info
.child_element(Some(DSIG_NS), "Reference")
.expect("Reference present");
let dv = reference
.child_element(Some(DSIG_NS), "DigestValue")
.expect("DigestValue present");
dv.text_content()
}
#[test]
fn signs_rsa_sha256_and_digest_recomputes() {
let response = build_response_with_issuer("resp-1", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.expect("sign");
let signature = find_signature(&signed).expect("signature embedded");
let mut iter = signed.child_elements();
let first = iter.next().expect("first child");
assert_eq!(first.qname().local(), "Issuer");
let second = iter.next().expect("second child");
assert_eq!(second.qname().local(), "Signature");
let mut sans_signature = signed.clone();
sans_signature.children.retain(|n| match n {
Node::Element(e) => {
!(e.qname().local() == "Signature" && e.qname().namespace() == Some(DSIG_NS))
}
_ => true,
});
let stripped_doc = Document::new(sans_signature).unwrap();
let recomputed_canonical = canonicalize(
&stripped_doc,
stripped_doc.root(),
&[],
C14nAlgorithm::ExclusiveCanonical,
&[],
)
.unwrap();
let recomputed = DigestAlgorithm::Sha256.digest(&recomputed_canonical);
let recomputed_b64 = BASE64_STANDARD.encode(&recomputed);
let signed_info = signed_info_of(signature);
assert_eq!(digest_value_of(signed_info), recomputed_b64);
let key_info = signature
.child_element(Some(DSIG_NS), "KeyInfo")
.expect("KeyInfo present");
let x509_data = key_info
.child_element(Some(DSIG_NS), "X509Data")
.expect("X509Data present");
let x509_cert = x509_data
.child_element(Some(DSIG_NS), "X509Certificate")
.expect("X509Certificate present");
assert!(!x509_cert.text_content().is_empty());
}
#[test]
fn signs_with_ecdsa_p256_sha256() {
let response = build_response_with_issuer("resp-2", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = ecdsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::EcdsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.expect("sign");
let signature = find_signature(&signed).expect("signature embedded");
assert!(
signature.child_element(Some(DSIG_NS), "KeyInfo").is_none(),
"KeyInfo should be absent when include_x509_cert=false"
);
let sig_value = signature
.child_element(Some(DSIG_NS), "SignatureValue")
.expect("SignatureValue present");
let raw = BASE64_STANDARD
.decode(sig_value.text_content().trim().as_bytes())
.expect("base64");
assert_eq!(raw.len(), 64);
let signed_info = signed_info_of(signature);
let method = signed_info
.child_element(Some(DSIG_NS), "SignatureMethod")
.unwrap();
assert_eq!(
method.attribute(None, "Algorithm"),
Some(SignatureAlgorithm::EcdsaSha256.uri())
);
}
#[test]
fn places_signature_after_issuer_when_present() {
let response = build_response_with_issuer("resp-3", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap();
let elements: Vec<&Element> = signed.child_elements().collect();
assert!(elements.len() >= 2);
assert_eq!(elements[0].qname().local(), "Issuer");
assert_eq!(elements[1].qname().local(), "Signature");
}
#[test]
fn places_signature_first_when_no_issuer() {
let response = build_response_with_issuer("resp-4", false);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap();
let elements: Vec<&Element> = signed.child_elements().collect();
assert!(!elements.is_empty());
assert_eq!(elements[0].qname().local(), "Signature");
}
#[test]
fn rejects_target_without_id_attribute() {
let samlp_ns = "urn:oasis:names:tc:SAML:2.0:protocol";
let response = Element::build(QName::new(Some(samlp_ns.to_owned()), "Response"))
.with_namespace(Some("samlp".to_owned()), samlp_ns)
.with_attribute(QName::new(None, "Version"), "2.0")
.finish();
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let err = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert!(reason.contains("ID"), "got: {reason}");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn rejects_include_x509_without_certificate() {
let response = build_response_with_issuer("resp-5", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = KeyPair::from_pkcs8_pem(RSA_KEY_PKCS8_PEM).unwrap();
let err = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: true,
},
)
.unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert!(reason.contains("certificate"), "got: {reason}");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn detached_query_rsa_signature_length() {
let kp = rsa_key_with_cert();
let query = b"SAMLRequest=AAA&RelayState=state&SigAlg=foo";
let sig = sign_detached_query(query, &kp, SignatureAlgorithm::RsaSha256).expect("sign");
assert_eq!(sig.len(), 256);
}
#[test]
fn detached_query_ecdsa_signature_length() {
let kp = ecdsa_key_with_cert();
let query = b"SAMLRequest=AAA&RelayState=state&SigAlg=foo";
let sig = sign_detached_query(query, &kp, SignatureAlgorithm::EcdsaSha256).expect("sign");
assert_eq!(sig.len(), 64);
}
#[test]
fn signature_method_uri_matches_algorithm() {
let response = build_response_with_issuer("resp-6", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha512,
digest_alg: DigestAlgorithm::Sha512,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap();
let signature = find_signature(&signed).unwrap();
let signed_info = signed_info_of(signature);
let method = signed_info
.child_element(Some(DSIG_NS), "SignatureMethod")
.unwrap();
assert_eq!(
method.attribute(None, "Algorithm"),
Some(SignatureAlgorithm::RsaSha512.uri())
);
let digest_method = signed_info
.child_element(Some(DSIG_NS), "Reference")
.unwrap()
.child_element(Some(DSIG_NS), "DigestMethod")
.unwrap();
assert_eq!(
digest_method.attribute(None, "Algorithm"),
Some(DigestAlgorithm::Sha512.uri())
);
}
#[test]
fn reference_uri_is_hash_of_id() {
let response = build_response_with_issuer("my-special-id", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap();
let signature = find_signature(&signed).unwrap();
let signed_info = signed_info_of(signature);
let reference = signed_info
.child_element(Some(DSIG_NS), "Reference")
.unwrap();
assert_eq!(reference.attribute(None, "URI"), Some("#my-special-id"));
}
#[test]
fn enveloped_signature_transform_emitted() {
let response = build_response_with_issuer("resp-7", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &[],
include_x509_cert: false,
},
)
.unwrap();
let signature = find_signature(&signed).unwrap();
let signed_info = signed_info_of(signature);
let transforms = signed_info
.child_element(Some(DSIG_NS), "Reference")
.unwrap()
.child_element(Some(DSIG_NS), "Transforms")
.unwrap();
let xforms: Vec<&Element> = transforms
.all_child_elements(Some(DSIG_NS), "Transform")
.collect();
assert_eq!(xforms.len(), 2);
assert_eq!(
xforms[0].attribute(None, "Algorithm"),
Some(ENVELOPED_SIGNATURE_URI)
);
assert_eq!(
xforms[1].attribute(None, "Algorithm"),
Some(C14nAlgorithm::ExclusiveCanonical.uri())
);
}
#[test]
fn inclusive_namespaces_prefix_list_attached_to_c14n_transform() {
let response = build_response_with_issuer("resp-8", true);
let doc = Document::new(response).unwrap();
let target = doc.root().clone();
let kp = rsa_key_with_cert();
let signed = sign_element(
target,
&doc,
SignOptions {
signing_key: &kp,
sig_alg: SignatureAlgorithm::RsaSha256,
digest_alg: DigestAlgorithm::Sha256,
c14n_alg: C14nAlgorithm::ExclusiveCanonical,
inclusive_namespaces: &["samlp", "saml"],
include_x509_cert: false,
},
)
.unwrap();
let signature = find_signature(&signed).unwrap();
let signed_info = signed_info_of(signature);
let transforms = signed_info
.child_element(Some(DSIG_NS), "Reference")
.unwrap()
.child_element(Some(DSIG_NS), "Transforms")
.unwrap();
let xforms: Vec<&Element> = transforms
.all_child_elements(Some(DSIG_NS), "Transform")
.collect();
let c14n_transform = xforms
.iter()
.find(|t| {
t.attribute(None, "Algorithm") == Some(C14nAlgorithm::ExclusiveCanonical.uri())
})
.expect("c14n transform");
let inclusive = c14n_transform
.child_element(Some(EC_NS), "InclusiveNamespaces")
.expect("InclusiveNamespaces present");
assert_eq!(inclusive.attribute(None, "PrefixList"), Some("samlp saml"));
}
}