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/tests/sp_interop.rs

//! **Interop: our verifier against an INDEPENDENT signer.**
//!
//! Every fixture here was signed by signxml, i.e. canonicalised by libxml2
//! (`tests/fixtures/gen.py`). A self-signed round trip proves only that our
//! C14N agrees with itself; these prove it agrees with the C library the
//! IdP-side toolkits are tested against — namespace push-down, attribute
//! ordering, escaping, `InclusiveNamespaces`, a default-namespaced Assertion,
//! ECDSA's raw `r||s`.
#![cfg(feature = "sp")]

use base64::Engine as _;
use nornir_auth_saml::sp::{self, c14n, Expect, SpError};

const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
/// 2024-01-01T00:01:00Z — inside every fixture's window.
const NOW: i64 = 1_704_067_260;

fn fixture(name: &str) -> String {
    std::fs::read_to_string(format!("{FIX}/{name}")).unwrap_or_else(|e| panic!("{name}: {e}"))
}

fn cert(name: &str) -> Vec<u8> {
    base64::engine::general_purpose::STANDARD
        .decode(fixture(name).trim())
        .expect("a base64 certificate")
}

fn expect(certs: &[Vec<u8>]) -> Expect<'_> {
    Expect {
        idp_entity_id: "https://idp.example.com/",
        sp_entity_id: "https://sp.example.com/metadata",
        acs_url: "https://sp.example.com/acs",
        request_id: Some("_req1"),
        now_unix: NOW,
        skew_secs: 120,
        certificates_der: certs,
    }
}

fn check(name: &str, certificate: &str) {
    let certs = vec![cert(certificate)];
    let a = sp::verify_response_xml(&fixture(name), &expect(&certs))
        .unwrap_or_else(|e| panic!("{name}: {e}"));
    assert_eq!(a.assertion_id, "_assert1", "{name}");
    assert_eq!(a.issuer, "https://idp.example.com/", "{name}");
    assert_eq!(a.name_id, "alice@example.com", "{name}");
    assert_eq!(
        a.name_id_format.as_deref(),
        Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"),
        "{name}"
    );
    assert_eq!(a.session_index.as_deref(), Some("_sess1"), "{name}");
    assert_eq!(
        a.attributes["email"],
        vec!["alice@example.com".to_owned()],
        "{name}"
    );
    assert_eq!(
        a.attributes["groups"],
        vec!["eng".to_owned(), "ops & on-call".to_owned()],
        "{name}"
    );
    // The bearer confirmation (00:05) is earlier than the Conditions (00:10).
    assert_eq!(a.not_on_or_after_unix, 1_704_067_500, "{name}");
}

#[test]
fn an_assertion_signed_by_libxml2_verifies() {
    check("a_assertion_signed.xml", "idp_rsa.cert.b64");
}

#[test]
fn a_response_signed_by_libxml2_verifies() {
    check("b_response_signed.xml", "idp_rsa.cert.b64");
}

#[test]
fn both_signatures_by_libxml2_verify() {
    check("b2_both_signed.xml", "idp_rsa.cert.b64");
}

#[test]
fn inclusive_namespaces_and_xs_typed_values_verify() {
    check("c_inclusive_prefixes.xml", "idp_rsa.cert.b64");
}

#[test]
fn a_default_namespaced_assertion_verifies() {
    check("d_default_namespace.xml", "idp_rsa.cert.b64");
}

#[test]
fn an_ecdsa_p256_signature_verifies() {
    check("e_ecdsa_p256.xml", "idp_ec.cert.b64");
}

#[test]
fn the_base64_binding_verifies_with_line_breaks() {
    let certs = vec![cert("idp_rsa.cert.b64")];
    let raw = base64::engine::general_purpose::STANDARD.encode(fixture("a_assertion_signed.xml"));
    let wrapped: String = raw
        .as_bytes()
        .chunks(76)
        .map(|c| std::str::from_utf8(c).unwrap())
        .collect::<Vec<_>>()
        .join("\r\n");
    let a = sp::verify_response_b64(&wrapped, &expect(&certs)).expect("the posted form verifies");
    assert_eq!(a.name_id, "alice@example.com");
}

#[test]
fn a_libxml2_signature_does_not_verify_with_another_certificate() {
    let other = vec![cert("idp_ec.cert.b64")];
    let err =
        sp::verify_response_xml(&fixture("a_assertion_signed.xml"), &expect(&other)).unwrap_err();
    assert!(matches!(err, SpError::Signature(_)), "{err}");
}

/// Our exclusive C14N, byte for byte against lxml's, on subtrees chosen for the
/// rules that go wrong: push-down of a default namespace, `xmlns=""`, attribute
/// order across namespaces, escaping of `&<>\r` in text and `"\t\n\r<&` in
/// attributes, comments dropped, processing instructions kept, an inclusive
/// prefix rendered at the apex only.
#[test]
fn exclusive_c14n_matches_libxml2_byte_for_byte() {
    let xml = fixture("c14n_doc.xml");
    let doc = roxmltree::Document::parse(&xml).unwrap();
    let mut n = 0;
    for line in fixture("c14n_cases.txt")
        .lines()
        .filter(|l| !l.trim().is_empty())
    {
        let parts: Vec<&str> = line.split('|').collect();
        let (name, ns, local, prefixes) = (parts[0], parts[1], parts[2], parts[3]);
        let node = doc
            .descendants()
            .find(|n| {
                n.is_element()
                    && n.tag_name().name() == local
                    && n.tag_name().namespace().unwrap_or("") == ns
            })
            .unwrap_or_else(|| panic!("{name}: no <{local}>"));
        let prefixes: Vec<&str> = prefixes.split_whitespace().collect();
        let ours = c14n::exclusive(&doc, node, &prefixes, None);
        let theirs = fixture(&format!("c14n_{name}.expected"));
        assert_eq!(ours, theirs, "case {name}");
        n += 1;
    }
    assert_eq!(n, 8, "every case in the manifest ran");
}