Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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"
        ))),
    }
}