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_rules.rs

//! **Every rule `sp` enforces, each as a refusal with its kind** — the XML
//! signature-wrapping (XSW) battery first, then the signature's shape, then the
//! SAML conditions, then metadata, certificates and the request.
//!
//! The wrapping shapes are built by surgery on a response libxml2 signed
//! (`fixtures/a_assertion_signed.xml`, `fixtures/b_response_signed.xml`), so
//! the signature in each is genuinely valid for the content it covers — the
//! attack is only ever in WHERE that content sits or what sits beside it.
#![cfg(all(feature = "sp", feature = "test-support"))]

use base64::Engine as _;
use nornir_auth_saml::sp::{self, Expect, SpError};
use nornir_auth_saml::test_support::{self as ts, ResponseParams, Sign};

const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
/// 2024-01-01T00:01:00Z.
const NOW: i64 = 1_704_067_260;
const IDP: &str = "https://idp.example.com/";
const SP: &str = "https://sp.example.com/metadata";
const ACS: &str = "https://sp.example.com/acs";

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

fn fixture_cert() -> Vec<Vec<u8>> {
    vec![base64::engine::general_purpose::STANDARD
        .decode(fixture("idp_rsa.cert.b64").trim())
        .unwrap()]
}

fn expect(certs: &[Vec<u8>]) -> Expect<'_> {
    Expect {
        idp_entity_id: IDP,
        sp_entity_id: SP,
        acs_url: ACS,
        request_id: Some("_req1"),
        now_unix: NOW,
        skew_secs: 120,
        certificates_der: certs,
    }
}

fn verify(xml: &str) -> Result<sp::Assertion, SpError> {
    let certs = fixture_cert();
    sp::verify_response_xml(xml, &expect(&certs))
}

#[track_caller]
fn refused(xml: &str, kind: fn(&SpError) -> bool, what: &str) -> SpError {
    match verify(xml) {
        Ok(a) => panic!("{what}: ACCEPTED (name_id {:?})", a.name_id),
        Err(e) => {
            assert!(kind(&e), "{what}: refused with the wrong kind: {e:?}");
            e
        }
    }
}

fn malformed(e: &SpError) -> bool {
    matches!(e, SpError::Malformed(_))
}
fn signature(e: &SpError) -> bool {
    matches!(e, SpError::Signature(_))
}
fn condition(e: &SpError) -> bool {
    matches!(e, SpError::Condition(_))
}
fn unsupported(e: &SpError) -> bool {
    matches!(e, SpError::Unsupported(_))
}

/// The signed Assertion element of fixture (a), whole.
fn signed_assertion(xml: &str) -> String {
    let start = xml.find("<saml:Assertion ").unwrap();
    let end = xml.find("</saml:Assertion>").unwrap() + "</saml:Assertion>".len();
    xml[start..end].to_owned()
}

/// An unsigned evil twin of the signed assertion: another ID, mallory.
fn evil(xml: &str) -> String {
    let signed = signed_assertion(xml);
    let s = signed.find("<ds:Signature").unwrap();
    let e = signed.find("</ds:Signature>").unwrap() + "</ds:Signature>".len();
    format!("{}{}", &signed[..s], &signed[e..])
        .replace("ID=\"_assert1\"", "ID=\"_evil\"")
        .replace("alice@example.com", "mallory@evil.example")
}

#[test]
fn the_untouched_fixtures_verify() {
    verify(&fixture("a_assertion_signed.xml")).expect("a verifies");
    verify(&fixture("b_response_signed.xml")).expect("b verifies");
}

// ── XSW: signature wrapping ────────────────────────────────────────────────

#[test]
fn xsw1_an_evil_assertion_beside_the_signed_response_is_refused() {
    // Response-signed (b): the attacker adds an unsigned assertion as a second
    // child. The response signature no longer verifies — but the count is what
    // refuses it, before any reader could pick the wrong one.
    let b = fixture("b_response_signed.xml");
    let at = b.find("<saml:Assertion ").unwrap();
    let xml = format!(
        "{}{}{}",
        &b[..at],
        evil(&fixture("a_assertion_signed.xml")),
        &b[at..]
    );
    refused(&xml, malformed, "XSW1");
}

#[test]
fn xsw2_the_signed_response_wrapped_inside_an_evil_one_is_refused() {
    let b = fixture("b_response_signed.xml");
    let inner = b.replace("<samlp:Response ", "<samlp:Response xmlns:x=\"urn:x\" ");
    let start = inner.find("<samlp:Response ").unwrap();
    let wrapped = format!(
        "<samlp:Response xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"_outer\" Version=\"2.0\" IssueInstant=\"2024-01-01T00:00:00Z\"><saml:Issuer>{IDP}</saml:Issuer><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status>{}<samlp:Extensions>{}</samlp:Extensions></samlp:Response>",
        evil(&fixture("a_assertion_signed.xml")),
        &inner[start..]
    );
    refused(&wrapped, malformed, "XSW2");
}

#[test]
fn xsw3_an_evil_assertion_before_the_signed_one_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let at = a.find("<saml:Assertion ").unwrap();
    let xml = format!("{}{}{}", &a[..at], evil(&a), &a[at..]);
    refused(&xml, malformed, "XSW3");
}

#[test]
fn xsw4_the_signed_assertion_inside_an_evil_one_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let signed = signed_assertion(&a);
    let twin = evil(&a).replace("</saml:Assertion>", &format!("{signed}</saml:Assertion>"));
    let xml = a.replace(&signed, &twin);
    refused(&xml, malformed, "XSW4");
}

#[test]
fn xsw5_an_evil_assertion_after_the_signed_one_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let end = a.find("</saml:Assertion>").unwrap() + "</saml:Assertion>".len();
    let xml = format!("{}{}{}", &a[..end], evil(&a), &a[end..]);
    refused(&xml, malformed, "XSW5");
}

#[test]
fn xsw6_the_signed_assertion_in_the_evil_ones_signature_object_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let signed = signed_assertion(&a);
    let twin = evil(&a).replace(
        "<saml:Subject>",
        &format!(
            "<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"><ds:Object>{signed}</ds:Object></ds:Signature><saml:Subject>"
        ),
    );
    let xml = a.replace(&signed, &twin);
    refused(&xml, malformed, "XSW6");
}

#[test]
fn xsw7_the_signed_assertion_moved_into_extensions_is_refused() {
    // One assertion only — but not where an assertion belongs.
    let a = fixture("a_assertion_signed.xml");
    let signed = signed_assertion(&a);
    let without = a.replace(&signed, "");
    let at = without.find("<samlp:Status>").unwrap();
    let xml = format!(
        "{}<samlp:Extensions>{signed}</samlp:Extensions>{}",
        &without[..at],
        &without[at..]
    );
    let e = refused(&xml, malformed, "XSW7 (moved)");
    assert!(e.to_string().contains("direct child"), "{e}");
    // …and with an evil one in its place: two.
    let xml = format!(
        "{}<samlp:Extensions>{signed}</samlp:Extensions>{}{}",
        &without[..at],
        evil(&a),
        &without[at..]
    );
    refused(&xml, malformed, "XSW7 (replaced)");
}

#[test]
fn xsw8_the_signature_lifted_to_the_response_pointing_at_the_assertion_is_refused() {
    // The assertion's (valid) signature moved up to be the Response's: its
    // Reference names #_assert1, not the element it sits on.
    let a = fixture("a_assertion_signed.xml");
    let s = a.find("<ds:Signature").unwrap();
    let e = a.find("</ds:Signature>").unwrap() + "</ds:Signature>".len();
    let sig = &a[s..e];
    let without = format!("{}{}", &a[..s], &a[e..]);
    let at = without.find("</saml:Issuer>").unwrap() + "</saml:Issuer>".len();
    let xml = format!("{}{sig}{}", &without[..at], &without[at..]);
    refused(&xml, signature, "XSW8");
}

#[test]
fn a_signature_anywhere_else_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let xml = a.replace(
        "<samlp:Status>",
        "<samlp:Extensions><ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"/></samlp:Extensions><samlp:Status>",
    );
    refused(&xml, malformed, "a stray Signature");
}

#[test]
fn a_reference_to_the_whole_document_is_refused() {
    let a = fixture("a_assertion_signed.xml").replace("URI=\"#_assert1\"", "URI=\"\"");
    refused(&a, signature, "Reference URI=\"\"");
}

/// Re-sign the (only) `SignedInfo` with the test IdP's key, so a test can change
/// the Reference and still present a signature that genuinely verifies over it —
/// the refusal must then come from the RULE, not from a broken signature.
fn resign(xml: &str) -> String {
    use rsa::pkcs8::DecodePrivateKey as _;
    use rsa::signature::{SignatureEncoding as _, Signer as _};
    let doc = roxmltree::Document::parse(xml).unwrap();
    let signed_info = doc
        .descendants()
        .find(|n| n.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "SignedInfo")))
        .unwrap();
    let canonical = sp::c14n::exclusive(&doc, signed_info, &[], None);
    let (key, _) = ts::idp_key();
    let key = rsa::RsaPrivateKey::from_pkcs8_der(&key).unwrap();
    let value = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(key)
        .sign(canonical.as_bytes())
        .to_vec();
    let s = xml.find("<ds:SignatureValue>").unwrap() + "<ds:SignatureValue>".len();
    let e = xml.find("</ds:SignatureValue>").unwrap();
    format!("{}{}{}", &xml[..s], b64(&value), &xml[e..])
}

#[test]
fn a_reference_to_anything_but_the_signed_element_is_refused_even_when_signed() {
    let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    let signed = ts::sign_response(&ts::response_xml(&p), Sign::Assertion);
    // The helper is sound: re-signing an unchanged SignedInfo still verifies.
    verify_ts(&resign(&signed), |_| {}).expect("a re-signed, unchanged response verifies");
    let aid = format!("URI=\"#{}\"", p.assertion_id);
    for other in ["URI=\"\"".to_owned(), format!("URI=\"#{}\"", p.response_id)] {
        let xml = resign(&signed.replace(&aid, &other));
        let err = verify_ts(&xml, |_| {}).unwrap_err();
        assert!(
            signature(&err) && err.to_string().contains("Reference"),
            "{other}: {err:?}"
        );
    }
}

#[test]
fn a_duplicate_id_is_refused() {
    let a = fixture("a_assertion_signed.xml").replace(
        "<samlp:Status>",
        "<samlp:Extensions><x ID=\"_assert1\"/></samlp:Extensions><samlp:Status>",
    );
    refused(&a, malformed, "a duplicate ID");
}

#[test]
fn a_comment_cannot_split_the_signed_name_id() {
    // The IdP signed "user@evil.example.com". C14N drops comments, so a comment
    // spliced in keeps the signature valid — the reader must still see the
    // WHOLE signed text, never "user@evil.example" (the first text node).
    let mut p = ResponseParams::new(IDP, "user@evil.example.com", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    let signed = ts::sign_response(&ts::response_xml(&p), Sign::Assertion);
    let spliced = signed.replace(
        "user@evil.example.com</saml:NameID>",
        "user@evil.example<!---->.com</saml:NameID>",
    );
    assert_ne!(spliced, signed);
    let certs = vec![ts::idp_cert_der()];
    let a = sp::verify_response_xml(&spliced, &expect(&certs)).expect("the signature still holds");
    assert_eq!(a.name_id, "user@evil.example.com");
}

#[test]
fn the_messages_key_info_is_ignored() {
    // An attacker's certificate in KeyInfo changes nothing: the configured key
    // still verifies the IdP's signature…
    let (_, attacker_cert) = ts::attacker_key();
    let attacker_b64 = base64::engine::general_purpose::STANDARD.encode(&attacker_cert);
    let a = fixture("a_assertion_signed.xml");
    let s = a.find("<ds:X509Certificate>").unwrap() + "<ds:X509Certificate>".len();
    let e = a.find("</ds:X509Certificate>").unwrap();
    let swapped = format!("{}{attacker_b64}{}", &a[..s], &a[e..]);
    verify(&swapped).expect("KeyInfo is not where the key comes from");
    // …and a response the attacker signed, carrying their own certificate, fails.
    let mut p = ResponseParams::new(IDP, "mallory@evil.example", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    let (key, cert) = ts::attacker_key();
    let forged = ts::sign_response_with_key(&ts::response_xml(&p), Sign::Assertion, &key, &cert);
    let certs = vec![ts::idp_cert_der()];
    let err = sp::verify_response_xml(&forged, &expect(&certs)).unwrap_err();
    assert!(signature(&err), "{err:?}");
}

// ── the signature itself ───────────────────────────────────────────────────

#[test]
fn changed_signed_content_is_refused() {
    let a = fixture("a_assertion_signed.xml").replace(
        ">alice@example.com</saml:NameID>",
        ">mallory@example.com</saml:NameID>",
    );
    let e = refused(&a, signature, "a changed NameID");
    assert!(e.to_string().contains("digest"), "{e}");
}

#[test]
fn a_changed_signature_value_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let s = a.find("<ds:SignatureValue>").unwrap() + "<ds:SignatureValue>".len();
    let mut bytes = a.into_bytes();
    bytes[s + 10] = if bytes[s + 10] == b'A' { b'B' } else { b'A' };
    refused(
        &String::from_utf8(bytes).unwrap(),
        signature,
        "a changed SignatureValue",
    );
}

#[test]
fn an_unsigned_response_is_refused() {
    let p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
    let certs = vec![ts::idp_cert_der()];
    let mut e = expect(&certs);
    e.request_id = None;
    let err = sp::verify_response_xml(&ts::response_xml(&p), &e).unwrap_err();
    assert!(signature(&err), "{err:?}");
}

#[test]
fn sha1_is_refused() {
    let a = fixture("a_assertion_signed.xml").replace(
        "http://www.w3.org/2001/04/xmlenc#sha256",
        "http://www.w3.org/2000/09/xmldsig#sha1",
    );
    let e = refused(&a, unsupported, "a SHA-1 digest");
    assert!(e.to_string().contains("SHA-1"), "{e}");
    let a = fixture("a_assertion_signed.xml").replace(
        "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
        "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
    );
    refused(&a, unsupported, "an RSA-SHA1 signature");
}

#[test]
fn transforms_other_than_enveloped_then_exclusive_c14n_are_refused() {
    let a = fixture("a_assertion_signed.xml");
    let inclusive = a.replace(
        "<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
        "<ds:Transform Algorithm=\"http://www.w3.org/TR/2001/REC-xml-c14n-20010315\"/>",
    );
    refused(&inclusive, unsupported, "inclusive C14N");
    let xpath = a.replace(
        "<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
        "<ds:Transform Algorithm=\"http://www.w3.org/TR/1999/REC-xpath-19991116\"><ds:XPath>/</ds:XPath></ds:Transform>",
    );
    refused(&xpath, unsupported, "an XPath transform");
    let three = a.replace(
        "<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
        "<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/><ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
    );
    refused(&three, unsupported, "three transforms");
    let comments = a.replace(
        "<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>",
        "<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#WithComments\"/>",
    );
    refused(&comments, unsupported, "C14N with comments");
}

#[test]
fn a_signature_with_an_object_or_two_references_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let object = a.replace("</ds:Signature>", "<ds:Object/></ds:Signature>");
    refused(&object, malformed, "a ds:Object");
    let s = a.find("<ds:Reference ").unwrap();
    let e = a.find("</ds:Reference>").unwrap() + "</ds:Reference>".len();
    let two = format!("{}{}{}", &a[..e], &a[s..e], &a[e..]);
    refused(&two, malformed, "two References");
    let twice = a.replace(
        "<saml:Subject>",
        "<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"/><saml:Subject>",
    );
    refused(&twice, malformed, "two Signatures on the Assertion");
}

#[test]
fn a_doctype_is_refused() {
    let a = fixture("a_assertion_signed.xml");
    let xml = format!("<!DOCTYPE r [<!ENTITY x \"alice@example.com\">]>{a}");
    refused(&xml, malformed, "a DOCTYPE");
    let bomb = "<!DOCTYPE r [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">]><r>&b;</r>";
    refused(bomb, malformed, "entity expansion");
}

#[test]
fn an_encrypted_assertion_is_refused() {
    let xml = fixture("a_assertion_signed.xml").replace(
        "</samlp:Status>",
        "</samlp:Status><saml:EncryptedAssertion/>",
    );
    refused(&xml, unsupported, "an EncryptedAssertion");
}

#[test]
fn a_status_other_than_success_is_refused() {
    let a = fixture("a_assertion_signed.xml").replace(
        "urn:oasis:names:tc:SAML:2.0:status:Success",
        "urn:oasis:names:tc:SAML:2.0:status:Requester",
    );
    let e = verify(&a).unwrap_err();
    assert!(matches!(e, SpError::Status(_)), "{e:?}");
}

// ── the conditions ─────────────────────────────────────────────────────────

fn signed_with(edit: impl FnOnce(&mut ResponseParams)) -> String {
    let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    edit(&mut p);
    ts::sign_response(&ts::response_xml(&p), Sign::Assertion)
}

fn verify_ts(xml: &str, edit: impl FnOnce(&mut Expect)) -> Result<sp::Assertion, SpError> {
    let certs = vec![ts::idp_cert_der()];
    let mut e = expect(&certs);
    edit(&mut e);
    sp::verify_response_xml(xml, &e)
}

#[test]
fn the_audience_must_be_this_sp() {
    let err = verify_ts(&signed_with(|_| {}), |e| {
        e.sp_entity_id = "https://other-sp.example/"
    })
    .unwrap_err();
    assert!(condition(&err), "{err:?}");
    let err = verify_ts(
        &signed_with(|p| p.audience = "https://other-sp.example/".into()),
        |_| {},
    )
    .unwrap_err();
    assert!(condition(&err), "{err:?}");
}

#[test]
fn the_recipient_must_be_this_acs() {
    let xml = signed_with(|p| {
        p.destination = None;
        p.recipient = "https://evil.example/acs".into();
    });
    let err = verify_ts(&xml, |_| {}).unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("bearer"),
        "{err:?}"
    );
}

#[test]
fn the_destination_must_be_this_acs() {
    let err = verify_ts(
        &signed_with(|p| p.destination = Some("https://evil.example/acs".into())),
        |_| {},
    )
    .unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("Response is for"),
        "{err:?}"
    );
    // No Destination at all is allowed (it is optional on a signed assertion).
    verify_ts(&signed_with(|p| p.destination = None), |_| {}).expect("no Destination");
}

#[test]
fn an_expired_assertion_is_refused_and_skew_is_honoured() {
    let xml = signed_with(|_| {});
    // Valid NOW-60 .. NOW+300.
    let err = verify_ts(&xml, |e| e.now_unix = NOW + 300 + 121).unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("expired"),
        "{err:?}"
    );
    verify_ts(&xml, |e| e.now_unix = NOW + 300 + 119).expect("inside the skew after NotOnOrAfter");
    let err = verify_ts(&xml, |e| e.now_unix = NOW - 60 - 121).unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("not valid yet"),
        "{err:?}"
    );
    verify_ts(&xml, |e| e.now_unix = NOW - 60 - 119).expect("inside the skew before NotBefore");
}

#[test]
fn the_issuer_must_be_the_idp() {
    let err = verify_ts(&signed_with(|_| {}), |e| {
        e.idp_entity_id = "https://other-idp.example/"
    })
    .unwrap_err();
    assert!(condition(&err), "{err:?}");
    let err = verify_ts(
        &signed_with(|p| p.issuer = "https://other-idp.example/".into()),
        |_| {},
    )
    .unwrap_err();
    assert!(condition(&err), "{err:?}");
}

#[test]
fn in_response_to_must_answer_this_request() {
    let xml = signed_with(|_| {});
    let err = verify_ts(&xml, |e| e.request_id = Some("_another")).unwrap_err();
    assert!(condition(&err), "{err:?}");
    verify_ts(&xml, |e| e.request_id = None).expect("an IdP-initiated SP accepts it");
    let unsolicited = signed_with(|p| p.in_response_to = None);
    let err = verify_ts(&unsolicited, |_| {}).unwrap_err();
    assert!(
        condition(&err),
        "an answer to no request, when one was sent: {err:?}"
    );
}

#[test]
fn attributes_and_the_replay_window_come_back() {
    let xml =
        signed_with(|p| p.attributes = vec![("groups".into(), vec!["eng".into(), "a & b".into()])]);
    let a = verify_ts(&xml, |_| {}).unwrap();
    assert_eq!(
        a.attributes["groups"],
        vec!["eng".to_owned(), "a & b".to_owned()]
    );
    assert_eq!(a.not_on_or_after_unix, NOW + 300);
    assert_eq!(a.assertion_id, format!("_a{NOW:x}"));
}

// ── test_support round trip ────────────────────────────────────────────────

#[test]
fn every_signing_shape_round_trips() {
    for shape in [Sign::Assertion, Sign::Response, Sign::Both] {
        let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
        p.in_response_to = Some("_req1".into());
        let xml = ts::sign_response(&ts::response_xml(&p), shape);
        let a = verify_ts(&xml, |_| {}).unwrap_or_else(|e| panic!("{shape:?}: {e}"));
        assert_eq!(a.name_id, "alice@example.com", "{shape:?}");
        let b64 = ts::encode(&xml);
        let certs = vec![ts::idp_cert_der()];
        sp::verify_response_b64(&b64, &expect(&certs))
            .unwrap_or_else(|e| panic!("{shape:?} b64: {e}"));
    }
}

#[test]
fn a_tampered_response_signature_is_refused_when_both_are_signed() {
    let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    let xml = ts::sign_response(&ts::response_xml(&p), Sign::Both);
    // The Response's IssueInstant is covered only by the Response signature.
    let first = xml.find("IssueInstant=\"").unwrap() + "IssueInstant=\"".len();
    let mut bytes = xml.into_bytes();
    bytes[first + 3] = if bytes[first + 3] == b'5' { b'6' } else { b'5' };
    let err = verify_ts(&String::from_utf8(bytes).unwrap(), |_| {}).unwrap_err();
    assert!(signature(&err), "{err:?}");
}

#[test]
fn an_oversized_response_is_refused_before_parsing() {
    let huge = "A".repeat(sp::MAX_RESPONSE_BYTES * 2);
    let certs = vec![ts::idp_cert_der()];
    let err = sp::verify_response_b64(&huge, &expect(&certs)).unwrap_err();
    assert!(malformed(&err), "{err:?}");
}

// ── metadata, certificates, the request ────────────────────────────────────

fn b64(der: &[u8]) -> String {
    base64::engine::general_purpose::STANDARD.encode(der)
}

#[test]
fn okta_style_metadata_parses() {
    let cert = b64(&ts::idp_cert_der());
    let xml = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://www.okta.com/exk1abc"><md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>
{cert}
</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"/></md:IDPSSODescriptor></md:EntityDescriptor>"#
    );
    let m = sp::parse_idp_metadata(&xml).unwrap();
    assert_eq!(m.entity_id, "http://www.okta.com/exk1abc");
    assert_eq!(
        m.sso_redirect_url,
        "https://acme.okta.com/app/acme_gunnar_1/exk1abc/sso/saml"
    );
    assert_eq!(m.certificates_der, vec![ts::idp_cert_der()]);
}

#[test]
fn entra_style_metadata_with_two_signing_certificates_parses() {
    let (_, second) = ts::attacker_key();
    let (one, two) = (b64(&ts::idp_cert_der()), b64(&second));
    let xml = format!(
        r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ID="_e1" entityID="https://sts.windows.net/1234/"><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo/></Signature><RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:fed="http://docs.oasis-open.org/wsfed/federation/200706" xsi:type="fed:SecurityTokenServiceType" protocolSupportEnumeration="http://docs.oasis-open.org/wsfed/federation/200706"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{two}</X509Certificate></X509Data></KeyInfo></KeyDescriptor></RoleDescriptor><IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{one}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><KeyDescriptor use="signing"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{two}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><KeyDescriptor use="encryption"><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><X509Data><X509Certificate>{one}</X509Certificate></X509Data></KeyInfo></KeyDescriptor><SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://login.microsoftonline.com/1234/saml2"/><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://login.microsoftonline.com/1234/saml2"/><SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://login.microsoftonline.com/1234/saml2"/></IDPSSODescriptor></EntityDescriptor>"#
    );
    let m = sp::parse_idp_metadata(&xml).unwrap();
    assert_eq!(m.entity_id, "https://sts.windows.net/1234/");
    assert_eq!(
        m.sso_redirect_url,
        "https://login.microsoftonline.com/1234/saml2"
    );
    assert_eq!(
        m.certificates_der,
        vec![ts::idp_cert_der(), second],
        "two signing certificates, no duplicate"
    );
}

#[test]
fn metadata_without_what_an_sp_needs_is_refused() {
    let cert = b64(&ts::idp_cert_der());
    let no_redirect = format!(
        r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"><md:IDPSSODescriptor><md:KeyDescriptor><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>{cert}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp/sso"/></md:IDPSSODescriptor></md:EntityDescriptor>"#
    );
    assert!(matches!(
        sp::parse_idp_metadata(&no_redirect),
        Err(SpError::Unsupported(_))
    ));
    let no_cert = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"><md:IDPSSODescriptor><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp/sso"/></md:IDPSSODescriptor></md:EntityDescriptor>"#;
    assert!(matches!(
        sp::parse_idp_metadata(no_cert),
        Err(SpError::Malformed(_))
    ));
    let two = format!(
        "<md:EntitiesDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">{}{}</md:EntitiesDescriptor>",
        ts::idp_metadata_xml("https://a/", "https://a/sso").replace(" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"", ""),
        ts::idp_metadata_xml("https://b/", "https://b/sso").replace(" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"", ""),
    );
    assert!(
        matches!(sp::parse_idp_metadata(&two), Err(SpError::Malformed(_))),
        "two IdPs"
    );
    let m = sp::parse_idp_metadata(&ts::idp_metadata_xml("https://a/", "https://a/sso")).unwrap();
    assert_eq!(
        (m.entity_id.as_str(), m.sso_redirect_url.as_str()),
        ("https://a/", "https://a/sso")
    );
    assert!(matches!(
        sp::parse_idp_metadata("<!DOCTYPE x><md:EntityDescriptor/>"),
        Err(SpError::Malformed(_))
    ));
}

#[test]
fn a_pasted_certificate_parses_as_pem_or_base64() {
    let der = base64::engine::general_purpose::STANDARD
        .decode(fixture("idp_rsa.cert.b64").trim())
        .unwrap();
    assert_eq!(
        sp::parse_certificate_pem_or_b64(&fixture("idp_rsa.cert.pem")).unwrap(),
        der
    );
    assert_eq!(
        sp::parse_certificate_pem_or_b64(fixture("idp_rsa.cert.b64").trim()).unwrap(),
        der
    );
    let ec = sp::parse_certificate_pem_or_b64(fixture("idp_ec.cert.b64").trim()).unwrap();
    assert!(!ec.is_empty());
    assert!(sp::parse_certificate_pem_or_b64("not a certificate").is_err());
    assert!(sp::parse_certificate_pem_or_b64("").is_err());
    assert!(sp::parse_certificate_pem_or_b64(&b64(b"\x30\x03\x02\x01\x01")).is_err());
}

fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' {
            out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap());
            i += 3;
        } else {
            out.push(b[i]);
            i += 1;
        }
    }
    String::from_utf8(out).unwrap()
}

#[test]
fn the_authn_request_goes_out_deflated_over_http_redirect() {
    use std::io::Read as _;
    let xml = sp::authn_request_xml(
        "_req1",
        &sp::format_instant(NOW),
        SP,
        ACS,
        "https://idp.example.com/sso?tenant=a&b=c",
    );
    let url =
        sp::redirect_url("https://idp.example.com/sso?tenant=a&b=c", &xml, "st&te/1").unwrap();
    assert!(
        url.starts_with("https://idp.example.com/sso?tenant=a&b=c&SAMLRequest="),
        "{url}"
    );
    let query = url.split_once("&SAMLRequest=").unwrap().1;
    let (request, relay) = query.split_once("&RelayState=").unwrap();
    assert_eq!(percent_decode(relay), "st&te/1");
    let packed = base64::engine::general_purpose::STANDARD
        .decode(percent_decode(request))
        .unwrap();
    let mut inflated = String::new();
    flate2::read::DeflateDecoder::new(&packed[..])
        .read_to_string(&mut inflated)
        .unwrap();
    assert_eq!(inflated, xml);
    let doc = roxmltree::Document::parse(&inflated).unwrap();
    let root = doc.root_element();
    assert_eq!(root.tag_name().name(), "AuthnRequest");
    assert_eq!(root.attribute("ID"), Some("_req1"));
    assert_eq!(root.attribute("AssertionConsumerServiceURL"), Some(ACS));
    assert_eq!(
        root.attribute("Destination"),
        Some("https://idp.example.com/sso?tenant=a&b=c")
    );
    assert_eq!(
        root.attribute("ProtocolBinding"),
        Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
    );
    let issuer = root.children().find(|n| n.has_tag_name("Issuer")).unwrap();
    assert_eq!(issuer.text(), Some(SP));
    assert_eq!(
        sp::parse_instant(root.attribute("IssueInstant").unwrap()).unwrap(),
        NOW
    );

    assert!(sp::redirect_url("javascript:alert(1)", &xml, "").is_err());
    assert!(sp::redirect_url("https://idp/sso#frag", &xml, "").is_err());
    assert!(sp::redirect_url("https://idp/sso", &xml, &"x".repeat(81)).is_err());
    let no_relay = sp::redirect_url("https://idp/sso", &xml, "").unwrap();
    assert!(
        no_relay.starts_with("https://idp/sso?SAMLRequest=") && !no_relay.contains("RelayState")
    );
}

#[test]
fn values_in_the_request_are_escaped() {
    let xml = sp::authn_request_xml(
        "_r\"<",
        "2024-01-01T00:00:00Z",
        "urn:sp&<x>",
        "https://sp/acs?a=1&b=2",
        "https://idp/sso",
    );
    let doc = roxmltree::Document::parse(&xml).expect("still well-formed");
    assert_eq!(doc.root_element().attribute("ID"), Some("_r\"<"));
    assert_eq!(
        doc.root_element().attribute("AssertionConsumerServiceURL"),
        Some("https://sp/acs?a=1&b=2")
    );
}

#[test]
fn sp_metadata_names_the_acs() {
    let xml = sp::sp_metadata_xml(SP, ACS);
    let doc = roxmltree::Document::parse(&xml).unwrap();
    assert_eq!(doc.root_element().attribute("entityID"), Some(SP));
    let acs = doc
        .descendants()
        .find(|n| {
            n.has_tag_name((
                "urn:oasis:names:tc:SAML:2.0:metadata",
                "AssertionConsumerService",
            ))
        })
        .unwrap();
    assert_eq!(acs.attribute("Location"), Some(ACS));
    assert_eq!(
        acs.attribute("Binding"),
        Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
    );
    let sp_desc = doc
        .descendants()
        .find(|n| n.has_tag_name(("urn:oasis:names:tc:SAML:2.0:metadata", "SPSSODescriptor")))
        .unwrap();
    assert_eq!(sp_desc.attribute("WantAssertionsSigned"), Some("true"));
}

// ── A34-34/35/36 (lane NIGHT-SAML) ─────────────────────────────────────────

/// A response from `p`, edited as XML before it is signed, so the signature
/// covers the edit.
fn signed_edited(
    edit_params: impl FnOnce(&mut ResponseParams),
    edit_xml: impl FnOnce(String) -> String,
    shape: Sign,
) -> String {
    let mut p = ResponseParams::new(IDP, "alice@example.com", SP, ACS, NOW);
    p.in_response_to = Some("_req1".into());
    edit_params(&mut p);
    ts::sign_response(&edit_xml(ts::response_xml(&p)), shape)
}

/// A34-34: a SubjectConfirmationData NotBefore that is not an xs:dateTime is
/// refused by name, as a malformed Conditions time is; it is never ignored.
#[test]
fn a_malformed_confirmation_not_before_is_refused_by_name() {
    for bad in ["tomorrow", "2024-01-01T00:00:00", "2024-13-01T00:00:00Z"] {
        let xml = signed_edited(
            |_| {},
            |x| {
                x.replace(
                    "<saml:SubjectConfirmationData NotOnOrAfter=",
                    &format!("<saml:SubjectConfirmationData NotBefore=\"{bad}\" NotOnOrAfter="),
                )
            },
            Sign::Assertion,
        );
        assert!(xml.contains(&format!("NotBefore=\"{bad}\"")), "the edit took");
        let err = verify_ts(&xml, |_| {}).unwrap_err();
        assert!(malformed(&err), "{bad:?}: {err:?}");
        let words = err.to_string();
        assert!(
            words.contains("SubjectConfirmationData") && words.contains("NotBefore"),
            "{bad:?}: the refusal names the field: {words}"
        );
    }
    // A well-formed NotBefore in the window still verifies.
    let xml = signed_edited(
        |_| {},
        |x| {
            x.replace(
                "<saml:SubjectConfirmationData NotOnOrAfter=",
                "<saml:SubjectConfirmationData NotBefore=\"2024-01-01T00:00:00Z\" NotOnOrAfter=",
            )
        },
        Sign::Assertion,
    );
    verify_ts(&xml, |_| {}).expect("a good NotBefore");
}

/// A34-35: the clock skew is capped at 300 s whatever the caller asks for.
#[test]
fn the_skew_is_capped_at_five_minutes() {
    let xml = signed_with(|_| {});
    // Valid NOW-60 .. NOW+300; a day of skew asked for, 300 s given.
    let day = 86_400;
    verify_ts(&xml, |e| {
        e.skew_secs = day;
        e.now_unix = NOW + 300 + 299;
    })
    .expect("inside the 300 s cap after NotOnOrAfter");
    let err = verify_ts(&xml, |e| {
        e.skew_secs = day;
        e.now_unix = NOW + 300 + 301;
    })
    .unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("expired"),
        "past the cap: {err:?}"
    );
    let err = verify_ts(&xml, |e| {
        e.skew_secs = day;
        e.now_unix = NOW - 60 - 301;
    })
    .unwrap_err();
    assert!(
        condition(&err) && err.to_string().contains("not valid yet"),
        "before the cap: {err:?}"
    );
    // A negative skew is no skew.
    let err = verify_ts(&xml, |e| {
        e.skew_secs = -day;
        e.now_unix = NOW + 300;
    })
    .unwrap_err();
    assert!(condition(&err), "{err:?}");
}

/// A34-35: a signed Response must carry a Destination (SAML profiles
/// 4.1.4.5); an assertion signed alone may still leave it out.
#[test]
fn a_signed_response_must_carry_a_destination() {
    for shape in [Sign::Response, Sign::Both] {
        let xml = signed_edited(|p| p.destination = None, |x| x, shape);
        let err = verify_ts(&xml, |_| {}).unwrap_err();
        assert!(
            condition(&err) && err.to_string().contains("Destination"),
            "{shape:?}: {err:?}"
        );
        let ok = signed_edited(|_| {}, |x| x, shape);
        verify_ts(&ok, |_| {}).unwrap_or_else(|e| panic!("{shape:?} with Destination: {e}"));
    }
    let xml = signed_edited(|p| p.destination = None, |x| x, Sign::Assertion);
    verify_ts(&xml, |_| {}).expect("a signed assertion without Destination");
}

/// A34-36: the unsigned StatusMessage (and any unknown status URI) never
/// reaches the words an admin or a user sees: a fixed message by code.
#[test]
fn a_refusal_status_says_only_its_code() {
    let lure = "Your account is locked. Call +1 555 0100 or visit https://evil.example/";
    let a = fixture("a_assertion_signed.xml").replace(
        "<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/>",
        &format!(
            "<samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Requester\">\
             <samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:AuthnFailed\"/>\
             </samlp:StatusCode><samlp:StatusMessage>{lure}</samlp:StatusMessage>"
        ),
    );
    assert!(a.contains(lure), "the edit took");
    let e = verify(&a).unwrap_err();
    assert!(matches!(e, SpError::Status(_)), "{e:?}");
    let words = e.to_string();
    assert!(!words.contains("locked") && !words.contains("evil"), "{words}");
    assert!(
        words.contains("Requester") && words.contains("AuthnFailed"),
        "{words}"
    );

    // A status value that is no SAML status is not echoed either.
    let b = fixture("a_assertion_signed.xml").replace(
        "urn:oasis:names:tc:SAML:2.0:status:Success",
        "https://evil.example/call-us",
    );
    let words = verify(&b).unwrap_err().to_string();
    assert!(!words.contains("evil"), "{words}");
    assert!(words.contains("unknown"), "{words}");
}