crates/nornir-auth-saml/src/sp/dsig.rs
//! **One enveloped XML-DSig signature, verified strictly** — the shape a SAML
//! IdP produces and nothing else.
//!
//! A `ds:Signature` is accepted only when it is exactly: one `SignedInfo`
//! (exclusive C14N, RSA-SHA256/384/512 or ECDSA-SHA256), exactly one
//! `Reference` whose `URI` is `#` + the ID of the element the signature is a
//! direct child of, the enveloped-signature transform followed by exclusive
//! C14N (or exclusive C14N alone), a SHA-256/384/512 digest, a
//! `SignatureValue`, and optionally a `KeyInfo` — which is **ignored**: the
//! key is always one of the IdP certificates the SP was configured with.
use base64::Engine as _;
use roxmltree::{Document, Node};
use sha2::Digest as _;
use super::{c14n, child_elements, SpError, DSIG};
pub(crate) const EXC_C14N: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
pub(crate) const EXC_C14N_WITH_COMMENTS: &str =
"http://www.w3.org/2001/10/xml-exc-c14n#WithComments";
pub(crate) const ENVELOPED: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
pub(crate) const RSA_SHA256: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
pub(crate) const RSA_SHA384: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384";
pub(crate) const RSA_SHA512: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512";
pub(crate) const ECDSA_SHA256: &str = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256";
pub(crate) const RSA_SHA1: &str = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
pub(crate) const SHA256: &str = "http://www.w3.org/2001/04/xmlenc#sha256";
pub(crate) const SHA384: &str = "http://www.w3.org/2001/04/xmldsig-more#sha384";
pub(crate) const SHA512: &str = "http://www.w3.org/2001/04/xmlenc#sha512";
pub(crate) const SHA1: &str = "http://www.w3.org/2000/09/xmldsig#sha1";
/// The smallest RSA modulus accepted, in bytes (2048 bits).
const MIN_RSA_BYTES: usize = 256;
/// A public key out of a configured IdP certificate.
pub(crate) enum PublicKey {
Rsa(rsa::RsaPublicKey),
P256(p256::ecdsa::VerifyingKey),
}
/// The public key of an X.509 certificate (DER): RSA of at least 2048 bits, or
/// EC P-256. Anything else is refused.
pub(crate) fn public_key(der: &[u8]) -> Result<PublicKey, SpError> {
use rsa::pkcs1::DecodeRsaPublicKey as _;
use rsa::traits::PublicKeyParts as _;
use x509_cert::der::asn1::ObjectIdentifier;
use x509_cert::der::Decode as _;
const RSA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
const EC: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1");
const P256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7");
let cert = x509_cert::Certificate::from_der(der)
.map_err(|e| SpError::Malformed(format!("an IdP certificate is not X.509 DER: {e}")))?;
let spki = cert.tbs_certificate().subject_public_key_info();
let raw = spki.subject_public_key.raw_bytes();
if spki.algorithm.oid == RSA {
let key = rsa::RsaPublicKey::from_pkcs1_der(raw).map_err(|e| {
SpError::Malformed(format!("the certificate's RSA key does not parse: {e}"))
})?;
if key.size() < MIN_RSA_BYTES {
return Err(SpError::Unsupported(format!(
"the certificate's RSA key is {} bits; at least 2048 are required",
key.size() * 8
)));
}
return Ok(PublicKey::Rsa(key));
}
if spki.algorithm.oid == EC {
let curve = spki
.algorithm
.parameters
.as_ref()
.and_then(|p| p.decode_as::<ObjectIdentifier>().ok());
if curve != Some(P256) {
return Err(SpError::Unsupported(
"the certificate's EC key is not on P-256".into(),
));
}
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(raw).map_err(|e| {
SpError::Malformed(format!("the certificate's P-256 key does not parse: {e}"))
})?;
return Ok(PublicKey::P256(key));
}
Err(SpError::Unsupported(format!(
"the certificate's key algorithm {} is not RSA or EC P-256",
spki.algorithm.oid
)))
}
/// Base64 with any whitespace in it (signatures are often line-wrapped).
pub(crate) fn b64(text: &str, what: &str) -> Result<Vec<u8>, SpError> {
let clean: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
base64::engine::general_purpose::STANDARD
.decode(clean.as_bytes())
.map_err(|_| SpError::Malformed(format!("{what} is not base64")))
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn algorithm<'a>(node: Node<'a, '_>) -> Result<&'a str, SpError> {
node.attribute("Algorithm").ok_or_else(|| {
SpError::Malformed(format!(
"ds:{} carries no Algorithm",
node.tag_name().name()
))
})
}
fn is_ds(node: &Node, local: &str) -> bool {
node.tag_name().namespace() == Some(DSIG) && node.tag_name().name() == local
}
/// The `PrefixList` of an `ec:InclusiveNamespaces` child, if any. Any other
/// child of a C14N method element is refused.
fn inclusive_prefixes<'a>(method: Node<'a, '_>) -> Result<Vec<&'a str>, SpError> {
let mut out = Vec::new();
for child in child_elements(method) {
if child.tag_name().namespace() == Some(EXC_C14N)
&& child.tag_name().name() == "InclusiveNamespaces"
{
if let Some(list) = child.attribute("PrefixList") {
out.extend(list.split_ascii_whitespace());
}
} else {
return Err(SpError::Unsupported(format!(
"an unexpected <{}> inside a canonicalization method",
child.tag_name().name()
)));
}
}
Ok(out)
}
fn c14n_method(method: Node) -> Result<(), SpError> {
match algorithm(method)? {
EXC_C14N => Ok(()),
EXC_C14N_WITH_COMMENTS => Err(SpError::Unsupported(
"exclusive C14N WITH comments is refused (comments must not be signed content)".into(),
)),
other => Err(SpError::Unsupported(format!(
"canonicalization {other} is not exclusive C14N"
))),
}
}
/// Verify `sig`, a `ds:Signature` that is a direct child of `signed`, against
/// the configured certificates. `Ok(())` only when the digest of `signed`
/// matches and the signature over `SignedInfo` verifies with one of them.
pub(crate) fn verify_enveloped(
doc: &Document,
signed: Node,
sig: Node,
certificates: &[Vec<u8>],
) -> Result<(), SpError> {
let id = signed
.attribute("ID")
.filter(|id| !id.is_empty())
.ok_or_else(|| {
SpError::Malformed(format!(
"the signed <{}> has no ID",
signed.tag_name().name()
))
})?;
// ── the Signature's own shape ────────────────────────────────────────
let children: Vec<Node> = child_elements(sig).collect();
let mut it = children.iter();
let signed_info = *it
.next()
.filter(|n| is_ds(n, "SignedInfo"))
.ok_or_else(|| {
SpError::Malformed("ds:Signature does not start with ds:SignedInfo".into())
})?;
let signature_value = *it
.next()
.filter(|n| is_ds(n, "SignatureValue"))
.ok_or_else(|| {
SpError::Malformed("ds:SignedInfo is not followed by ds:SignatureValue".into())
})?;
for rest in it {
if !is_ds(rest, "KeyInfo") {
return Err(SpError::Malformed(format!(
"ds:Signature carries <{}>; only SignedInfo, SignatureValue and KeyInfo are accepted",
rest.tag_name().name()
)));
}
}
// ── SignedInfo ───────────────────────────────────────────────────────
let parts: Vec<Node> = child_elements(signed_info).collect();
if parts.len() != 3
|| !is_ds(&parts[0], "CanonicalizationMethod")
|| !is_ds(&parts[1], "SignatureMethod")
|| !is_ds(&parts[2], "Reference")
{
return Err(SpError::Malformed(
"ds:SignedInfo must hold exactly CanonicalizationMethod, SignatureMethod and ONE Reference".into(),
));
}
let (canon, method, reference) = (parts[0], parts[1], parts[2]);
c14n_method(canon)?;
let signed_info_prefixes = inclusive_prefixes(canon)?;
let method = algorithm(method)?;
match method {
RSA_SHA256 | RSA_SHA384 | RSA_SHA512 | ECDSA_SHA256 => {}
RSA_SHA1 => {
return Err(SpError::Unsupported(
"RSA-SHA1 signatures are refused (SHA-1 is broken)".into(),
))
}
other => {
return Err(SpError::Unsupported(format!(
"signature method {other} is not accepted"
)))
}
}
// ── the Reference ────────────────────────────────────────────────────
let uri = reference.attribute("URI").unwrap_or("");
if uri != format!("#{id}") {
return Err(SpError::Signature(format!(
"the signature's Reference is {uri:?}, not the element it signs (#{id})"
)));
}
let rparts: Vec<Node> = child_elements(reference).collect();
let (transforms, digest_method, digest_value) = match rparts.as_slice() {
[t, m, v]
if is_ds(t, "Transforms") && is_ds(m, "DigestMethod") && is_ds(v, "DigestValue") =>
{
(Some(*t), *m, *v)
}
[m, v] if is_ds(m, "DigestMethod") && is_ds(v, "DigestValue") => (None, *m, *v),
_ => {
return Err(SpError::Malformed(
"ds:Reference must hold Transforms, DigestMethod and DigestValue".into(),
))
}
};
let Some(transforms) = transforms else {
return Err(SpError::Unsupported(
"a Reference without transforms (inclusive C14N by default) is not accepted".into(),
));
};
let steps: Vec<Node> = child_elements(transforms).collect();
if steps.iter().any(|s| !is_ds(s, "Transform")) {
return Err(SpError::Malformed(
"ds:Transforms holds something that is not a ds:Transform".into(),
));
}
let (enveloped, c14n_step) = match steps.as_slice() {
[e, c] if algorithm(*e)? == ENVELOPED => (true, *c),
[c] => (false, *c),
_ => {
return Err(SpError::Unsupported(
"the transforms must be enveloped-signature then exclusive C14N".into(),
))
}
};
if algorithm(c14n_step)? == ENVELOPED {
return Err(SpError::Unsupported(
"the transforms must end in exclusive C14N".into(),
));
}
c14n_method(c14n_step)?;
let reference_prefixes = inclusive_prefixes(c14n_step)?;
let digest_alg = algorithm(digest_method)?;
// ── the digest ───────────────────────────────────────────────────────
let canonical = c14n::exclusive(
doc,
signed,
&reference_prefixes,
enveloped.then(|| sig.id()),
);
let digest = match digest_alg {
SHA256 => sha2::Sha256::digest(canonical.as_bytes()).to_vec(),
SHA384 => sha2::Sha384::digest(canonical.as_bytes()).to_vec(),
SHA512 => sha2::Sha512::digest(canonical.as_bytes()).to_vec(),
SHA1 => {
return Err(SpError::Unsupported(
"SHA-1 digests are refused (SHA-1 is broken)".into(),
))
}
other => {
return Err(SpError::Unsupported(format!(
"digest method {other} is not accepted"
)))
}
};
let presented = b64(&super::text_of(digest_value), "the DigestValue")?;
if !ct_eq(&digest, &presented) {
return Err(SpError::Signature(format!(
"the digest of #{id} does not match its signature: the signed content was changed"
)));
}
// ── the signature over SignedInfo ────────────────────────────────────
let signed_info_c14n = c14n::exclusive(doc, signed_info, &signed_info_prefixes, None);
let value = b64(&super::text_of(signature_value), "the SignatureValue")?;
if certificates.is_empty() {
return Err(SpError::Signature(
"no IdP certificate is configured".into(),
));
}
let mut last = SpError::Signature("no configured certificate verified the signature".into());
for der in certificates {
let key = match public_key(der) {
Ok(key) => key,
Err(e) => {
last = e;
continue;
}
};
match verify_with(&key, method, signed_info_c14n.as_bytes(), &value) {
Ok(()) => return Ok(()),
Err(e) => last = e,
}
}
Err(last)
}
fn verify_with(key: &PublicKey, method: &str, message: &[u8], value: &[u8]) -> Result<(), SpError> {
use rsa::signature::Verifier as _;
let refused =
|| SpError::Signature("the signature does not verify with the IdP's certificate".into());
match (key, method) {
(PublicKey::Rsa(k), RSA_SHA256 | RSA_SHA384 | RSA_SHA512) => {
let sig = rsa::pkcs1v15::Signature::try_from(value).map_err(|_| refused())?;
let ok = match method {
RSA_SHA256 => rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(k.clone())
.verify(message, &sig),
RSA_SHA384 => rsa::pkcs1v15::VerifyingKey::<sha2::Sha384>::new(k.clone())
.verify(message, &sig),
_ => rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(k.clone())
.verify(message, &sig),
};
ok.map_err(|_| refused())
}
(PublicKey::P256(k), ECDSA_SHA256) => {
// XMLDSig 1.1 writes ECDSA as raw r||s; a DER one is read too.
let sig = p256::ecdsa::Signature::from_slice(value)
.or_else(|_| p256::ecdsa::Signature::from_der(value))
.map_err(|_| refused())?;
let sig = sig.normalize_s();
k.verify(message, &sig).map_err(|_| refused())
}
_ => Err(SpError::Signature(format!(
"the signature method {method} does not match the certificate's key type"
))),
}
}