//! `<md:EntityDescriptor>` and `<md:EntitiesDescriptor>` parsing, plus the
//! atomic verify-then-parse helpers per RFC-006 §3 and §5.
//!
//! Shared low-level helpers (`parse_endpoint`, `parse_key_descriptors`,
//! `parse_optional_duration`, etc.) live here as `pub(crate)` items because
//! both `descriptor::idp` and `descriptor::sp` consume them. Centralizing the
//! XML walks means the `<md:KeyDescriptor>` cert-use partitioning rule
//! (RFC-006 §4) and the xs:duration grammar live in exactly one place.
use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use crate::crypto::cert::X509Certificate;
use crate::descriptor::idp::IdpDescriptor;
use crate::descriptor::sp::SpDescriptor;
use crate::dsig::algorithms::SignatureAlgorithm;
use crate::dsig::verify::verify_signature;
use crate::error::Error;
use crate::nameid::NameIdFormat;
use crate::time::parse_xs_datetime;
use crate::xml::parse::{
Document, Element, Node, TreeBuilder, XmlLimits, collect_namespace_decls, configure_reader,
normalize_line_endings, raw_local_name, reject_doctype_or_pi,
};
// =============================================================================
// Namespace constants
// =============================================================================
pub(crate) const MD_NS: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
pub(crate) const DS_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
// =============================================================================
// Federation aggregate type
// =============================================================================
/// Parsed `<md:EntitiesDescriptor>` (or a single `<md:EntityDescriptor>`
/// promoted to an aggregate with one entry, for caller convenience).
pub struct EntitiesDescriptor {
pub name: Option<String>,
pub valid_until: Option<SystemTime>,
pub entities: Vec<MetadataEntry>,
}
/// One entry in a federation aggregate.
#[cfg_attr(
feature = "idp-disco",
expect(
clippy::large_enum_variant,
reason = "the idp-disco endpoint list pushes SpDescriptor over the lint's size \
ratio; Dual (both roles on one entity) is a rare shape and boxing it \
would churn the public parse API for a cold path"
)
)]
pub enum MetadataEntry {
Idp(IdpDescriptor),
Sp(SpDescriptor),
/// Some entities advertise both roles (Shibboleth proxies, for example).
Dual(IdpDescriptor, SpDescriptor),
/// AuthnAuthority, AttributeAuthority, PDP, etc. — out of scope for v0.1.
Other,
}
impl MetadataEntry {
/// The `entityID` this entry describes, or `None` for an [`Other`] entry
/// (an `<md:EntityDescriptor>` carrying only role descriptors this crate
/// does not model, whose `entityID` we never parsed).
///
/// [`Other`]: MetadataEntry::Other
pub fn entity_id(&self) -> Option<&str> {
match self {
// For a Dual entry both halves carry the same entityID by
// construction (they are parsed from the same EntityDescriptor).
MetadataEntry::Idp(idp) | MetadataEntry::Dual(idp, _) => Some(&idp.entity_id),
MetadataEntry::Sp(sp) => Some(&sp.entity_id),
MetadataEntry::Other => None,
}
}
}
impl EntitiesDescriptor {
/// Parse a federation aggregate or a single-entity metadata document.
///
/// Uses an aggregate-sized node ceiling ([`XmlLimits::aggregate`]) so real
/// InCommon / eduGAIN aggregates — which exceed the default ~100k-node
/// limit — parse successfully. To parse under tighter (or looser) limits,
/// use [`from_metadata_xml_with_limits`](Self::from_metadata_xml_with_limits).
pub fn from_metadata_xml(xml: &[u8]) -> Result<Self, Error> {
Self::from_metadata_xml_with_limits(xml, XmlLimits::aggregate())
}
/// Parse a federation aggregate or single-entity metadata document under
/// caller-supplied resource limits.
///
/// [`from_metadata_xml`](Self::from_metadata_xml) calls this with
/// [`XmlLimits::aggregate`]; pass a tighter [`XmlLimits`] when the input is
/// known to be small and you want a smaller worst-case allocation bound.
pub fn from_metadata_xml_with_limits(xml: &[u8], limits: XmlLimits) -> Result<Self, Error> {
let doc = Document::parse_with_limits(xml, limits)?;
Self::from_root_element(doc.root())
}
fn from_root_element(root: &Element) -> Result<Self, Error> {
if !is_md_element(root, "EntitiesDescriptor") {
// Promote a single EntityDescriptor into a one-entry aggregate.
if is_md_element(root, "EntityDescriptor") {
let entry = parse_entity_descriptor(root)?;
return Ok(Self {
name: None,
valid_until: parse_optional_xs_datetime(root, "validUntil")?,
entities: vec![entry],
});
}
return Err(Error::InvalidConfiguration {
reason: "root is not <md:EntityDescriptor> or <md:EntitiesDescriptor>",
});
}
let name = root.attribute(None, "Name").map(str::to_owned);
let valid_until = parse_optional_xs_datetime(root, "validUntil")?;
let mut entities = Vec::new();
collect_entities(root, &mut entities)?;
Ok(Self {
name,
valid_until,
entities,
})
}
/// Find an entry by its `entityID`, regardless of role. Returns the first
/// match in document order.
///
/// This is a linear scan over [`entities`](Self::entities). For repeated
/// lookups against a large aggregate (InCommon publishes thousands of
/// entities), build an index once with [`index_by_entity_id`] and query
/// that instead.
///
/// [`index_by_entity_id`]: Self::index_by_entity_id
pub fn by_entity_id(&self, entity_id: &str) -> Option<&MetadataEntry> {
self.entities
.iter()
.find(|entry| entry.entity_id() == Some(entity_id))
}
/// Build a `HashMap` from `entityID` to entry for O(1) repeated lookups.
///
/// Federation aggregates with thousands of entities make the linear
/// [`by_entity_id`] / [`find_idp`] / [`find_sp`] scans expensive when a
/// caller resolves many entityIDs; constructing this index once amortizes
/// the cost. [`Other`] entries (no `entityID`) are skipped. When a
/// duplicate `entityID` appears the first entry in document order wins,
/// matching the linear accessors.
///
/// [`by_entity_id`]: Self::by_entity_id
/// [`find_idp`]: Self::find_idp
/// [`find_sp`]: Self::find_sp
/// [`Other`]: MetadataEntry::Other
pub fn index_by_entity_id(&self) -> HashMap<&str, &MetadataEntry> {
let mut index = HashMap::with_capacity(self.entities.len());
for entry in &self.entities {
if let Some(id) = entry.entity_id() {
index.entry(id).or_insert(entry);
}
}
index
}
/// Find an IdP entity by entity ID.
pub fn find_idp(&self, entity_id: &str) -> Option<&IdpDescriptor> {
self.entities.iter().find_map(|entry| match entry {
MetadataEntry::Idp(idp) | MetadataEntry::Dual(idp, _) if idp.entity_id == entity_id => {
Some(idp)
}
_ => None,
})
}
/// Find an SP entity by entity ID.
pub fn find_sp(&self, entity_id: &str) -> Option<&SpDescriptor> {
self.entities.iter().find_map(|entry| match entry {
MetadataEntry::Sp(sp) | MetadataEntry::Dual(_, sp) if sp.entity_id == entity_id => {
Some(sp)
}
_ => None,
})
}
/// Iterate over all IdP descriptors (including the IdP half of Dual entries).
pub fn iter_idps(&self) -> impl Iterator<Item = &IdpDescriptor> {
self.entities.iter().filter_map(|e| match e {
MetadataEntry::Idp(idp) | MetadataEntry::Dual(idp, _) => Some(idp),
_ => None,
})
}
/// Iterate over all SP descriptors (including the SP half of Dual entries).
pub fn iter_sps(&self) -> impl Iterator<Item = &SpDescriptor> {
self.entities.iter().filter_map(|e| match e {
MetadataEntry::Sp(sp) | MetadataEntry::Dual(_, sp) => Some(sp),
_ => None,
})
}
}
// =============================================================================
// Streaming / bounded parse
// =============================================================================
/// Control-flow signal returned by a [`stream_entities`] / [`stream_signed_entities`]
/// visitor: keep going or stop early.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamControl {
/// Continue to the next entity.
Continue,
/// Stop iterating; the stream parser returns `Ok(())` without visiting
/// any further entities (useful for "find the one entityID I care about
/// and bail" over a multi-megabyte aggregate).
Stop,
}
/// Parse a federation aggregate one `<md:EntityDescriptor>` at a time,
/// invoking `visit` for each, **without** building the whole `Vec` of
/// entities or a single DOM over the entire file.
///
/// Memory characteristics: the parser scans the input with a streaming XML
/// reader and materializes a DOM for **one** `<md:EntityDescriptor>` subtree
/// at a time (built by a tree builder whose namespace scope is seeded with the
/// in-scope declarations of the ancestor `<md:EntitiesDescriptor>` wrappers, so
/// the lifted subtree's prefixes resolve), parses it into a [`MetadataEntry`],
/// hands it to the
/// visitor, and drops it before advancing. Peak additional memory is therefore
/// bounded by one normalized copy of the input (XML 1.0 §2.11 line-end
/// normalization, matching the eager path) plus the largest single entity
/// tree — never the full aggregate DOM. The input slice itself is still held by
/// the caller; this API does not stream off a socket.
///
/// Nested `<md:EntitiesDescriptor>` blocks are flattened, matching the
/// eager [`EntitiesDescriptor::from_metadata_xml`] path.
///
/// # Security
///
/// This is the **unverified** entry point — it parses attacker-influenced XML
/// directly. When the aggregate is signed and you are establishing trust off
/// that signature, use [`stream_signed_entities`], which verifies the
/// wrapping signature before any entity is yielded.
pub fn stream_entities<F>(metadata_xml: &[u8], visit: F) -> Result<(), Error>
where
F: FnMut(MetadataEntry) -> StreamControl,
{
stream_entities_inner(metadata_xml, visit)
}
/// Verify the aggregate's wrapping XML-DSig signature, then visit its entities
/// one at a time, stopping early when the visitor returns
/// [`StreamControl::Stop`].
///
/// Mirrors [`parse_signed_entities_descriptor`] for the visitor-driven path:
/// the signature over the `<md:EntitiesDescriptor>` root is checked **before**
/// any child entity is yielded, so a visitor never observes an entity from an
/// unverified aggregate.
///
/// # Trust model & memory
///
/// Verifying an enveloped signature over the aggregate root requires the whole
/// document as a unit (the signature covers the entire tree), so this path
/// parses the full DOM once — the same full-DOM cost the eager
/// [`parse_signed_entities_descriptor`] already pays. It then verifies the
/// wrapper signature on that parsed `Document` (including the XSW root-coverage
/// check in [`verify_metadata_signature`]) and walks the **already-parsed**
/// tree, handing each entity to the visitor. There is no second parser and no
/// raw-byte re-scan: re-streaming the bytes after a full parse would buy no
/// memory saving while forking a parallel parsing path. Peak memory is the
/// parsed aggregate DOM, which is unavoidable for signature verification.
///
/// When unverified, bounded-memory streaming is what you need (you are not
/// establishing trust off the wrapper signature), use [`stream_entities`].
pub fn stream_signed_entities<F>(
metadata_xml: &[u8],
trusted_signing_cert: &X509Certificate,
mut visit: F,
) -> Result<(), Error>
where
F: FnMut(MetadataEntry) -> StreamControl,
{
// Verification happens on the parsed DOM before any entity is yielded; the
// walk below only ever sees a tree whose wrapper signature already checked
// out, so the visitor cannot observe an entity from an unverified
// aggregate.
let doc = Document::parse_with_limits(metadata_xml, XmlLimits::aggregate())?;
verify_metadata_signature_on_document(&doc, trusted_signing_cert)?;
visit_entities(doc.root(), &mut visit).map(|_control| ())
}
/// Walk an already-parsed (and, on the signed path, already-verified) root,
/// flattening nested `<md:EntitiesDescriptor>` blocks and invoking `visit` for
/// each `<md:EntityDescriptor>`. Propagates [`StreamControl::Stop`] outward so
/// the caller halts the whole walk.
fn visit_entities<F>(root: &Element, visit: &mut F) -> Result<StreamControl, Error>
where
F: FnMut(MetadataEntry) -> StreamControl,
{
if is_md_element(root, "EntityDescriptor") {
return Ok(visit(parse_entity_descriptor(root)?));
}
if is_md_element(root, "EntitiesDescriptor") {
for child in root.children() {
let Node::Element(elem) = child else { continue };
let control = if is_md_element(elem, "EntityDescriptor") {
visit(parse_entity_descriptor(elem)?)
} else if is_md_element(elem, "EntitiesDescriptor") {
visit_entities(elem, visit)?
} else {
StreamControl::Continue
};
if control == StreamControl::Stop {
return Ok(StreamControl::Stop);
}
}
return Ok(StreamControl::Continue);
}
Err(Error::InvalidConfiguration {
reason: "root is not <md:EntityDescriptor> or <md:EntitiesDescriptor>",
})
}
/// Upper bound on the number of nested `<md:EntitiesDescriptor>` /
/// `<md:EntityDescriptor>` levels the unverified streaming scan will descend,
/// and on the total entities it will visit. The eager DOM path is bounded by
/// [`XmlLimits`]; this streaming path builds no whole-document DOM, so it
/// carries its own explicit ceilings to keep an adversarial aggregate (deeply
/// nested wrappers, or an unbounded entity count) from exhausting the
/// namespace stack or running unboundedly. Both are generous relative to real
/// federation metadata (eduGAIN nests a handful of levels; InCommon publishes
/// tens of thousands of entities) yet finite.
const MAX_STREAM_DEPTH: usize = 100;
const MAX_STREAM_ENTITIES: usize = 10_000_000;
/// One frame of the unverified streaming reader's namespace stack: the
/// `(key, value)` `xmlns(:prefix)?` declarations literally written on an open
/// `<md:EntitiesDescriptor>` wrapper, plus the element depth at which that
/// wrapper sits (so the frame is popped exactly when the wrapper closes).
type NsFrame = (Vec<(Vec<u8>, Vec<u8>)>, usize);
fn stream_entities_inner<F>(metadata_xml: &[u8], mut visit: F) -> Result<(), Error>
where
F: FnMut(MetadataEntry) -> StreamControl,
{
stream_entity_elements(metadata_xml, |entity_root| {
Ok(visit(parse_entity_descriptor(entity_root)?))
})
}
/// Core of the unverified streaming scan: drive a streaming XML reader over the
/// (line-end-normalized) aggregate, build each `<md:EntityDescriptor>` subtree
/// into an [`Element`] one at a time via a seed-scoped [`TreeBuilder`], and hand
/// the finished entity tree to `visit` before dropping it.
///
/// [`stream_entities_inner`] layers entity parsing on top; tests use it directly
/// to inspect the lifted entity tree (e.g. to prove line-end normalization
/// matches the eager path).
fn stream_entity_elements<F>(metadata_xml: &[u8], mut visit: F) -> Result<(), Error>
where
F: FnMut(&Element) -> Result<StreamControl, Error>,
{
use quick_xml::Reader;
use quick_xml::events::Event;
// XML 1.0 §2.11 end-of-line normalization, applied to the whole input up
// front exactly as the eager DOM path does in `parse_inner`. Without it a
// streamed entity carrying `\r`/`\r\n` in a text node or attribute value
// would build a tree that differs from the eager one — this crate's c14n
// escapes a literal `\r` as `
`, so the divergence is signature-relevant.
// `normalize_line_endings` stays zero-copy on the common LF-only input.
let normalized = normalize_line_endings(metadata_xml);
let mut reader = Reader::from_reader(normalized.as_ref());
configure_reader(&mut reader);
// In-scope namespace declarations, one frame per open
// `<md:EntitiesDescriptor>` wrapper (plus the root). When an entity subtree
// is lifted out for isolated parsing it must see every prefix the original
// document had in scope at that point — not just the outermost root's — so a
// nested wrapper that introduces a prefix a child relies on still resolves
// (RFC-006 §3 nested aggregates). Each frame holds the `(key, value)`
// namespace declarations on that wrapper plus the element depth at which it
// opened, so it is popped exactly when that wrapper closes.
let mut ns_stack: Vec<NsFrame> = Vec::new();
let mut root_seen = false;
let mut root_is_aggregate = false;
// Element depth at which the entity currently being captured started, and
// the in-progress [`TreeBuilder`] accumulating that entity's subtree. Both
// are `None`/empty when not inside an entity. The builder is seeded with the
// ancestor wrappers' in-scope declarations and fed the entity's own events
// (`Start` .. matching `End`) so prefixes resolve through the crate's single
// namespace implementation — no byte-span capture or synthetic re-wrap.
let mut capture_start: Option<usize> = None;
let mut builder: Option<TreeBuilder> = None;
let mut element_depth: usize = 0;
let mut entities_visited: usize = 0;
loop {
let event = reader
.read_event()
.map_err(|e| Error::XmlParse(format!("quick-xml: {e}")))?;
if matches!(event, Event::Eof) {
break;
}
// While inside an entity, every event is fed to the builder verbatim so
// the lifted subtree is constructed identically to the eager DOM path.
if let Some(b) = builder.as_mut() {
b.feed(&event)?;
}
match &event {
Event::DocType(_) | Event::PI(_) => {
// The builder (when active) already rejected these; reject on
// the bare-aggregate path too.
return Err(reject_doctype_or_pi(&event)
.unwrap_or_else(|| Error::XmlParse("unexpected event".to_string())));
}
Event::Start(start) => {
let local = raw_local_name(start.name().into_inner());
if !root_seen {
root_seen = true;
root_is_aggregate = local == b"EntitiesDescriptor";
if !root_is_aggregate && local != b"EntityDescriptor" {
return Err(Error::InvalidConfiguration {
reason: "root is not <md:EntityDescriptor> or <md:EntitiesDescriptor>",
});
}
if root_is_aggregate {
ns_stack.push((collect_namespace_decls(start), element_depth));
} else {
// A single EntityDescriptor root is itself the one
// entry; begin capturing from this opening tag.
capture_start = Some(0);
builder = Some(new_entity_builder(&ns_stack)?);
feed_open(&mut builder, &event)?;
}
element_depth = element_depth.checked_add(1).ok_or_else(depth_overflow)?;
continue;
}
if capture_start.is_none() {
if local == b"EntityDescriptor" {
capture_start = Some(element_depth);
builder = Some(new_entity_builder(&ns_stack)?);
feed_open(&mut builder, &event)?;
} else if local == b"EntitiesDescriptor" {
// Descended into a nested wrapper: push its declarations
// (tagged with this element's sit-depth) so children
// inherit the full in-scope prefix set.
if ns_stack.len() >= MAX_STREAM_DEPTH {
return Err(Error::XmlParse(
"nested EntitiesDescriptor depth limit exceeded".to_string(),
));
}
ns_stack.push((collect_namespace_decls(start), element_depth));
}
}
element_depth = element_depth.checked_add(1).ok_or_else(depth_overflow)?;
}
Event::Empty(empty) => {
let local = raw_local_name(empty.name().into_inner());
if !root_seen {
// An empty-element root carries no entities; nothing to do.
if local != b"EntitiesDescriptor" && local != b"EntityDescriptor" {
return Err(Error::InvalidConfiguration {
reason: "root is not <md:EntityDescriptor> or <md:EntitiesDescriptor>",
});
}
break;
}
}
Event::End(_) => {
element_depth = element_depth
.checked_sub(1)
.ok_or_else(|| Error::XmlParse("unmatched end tag".to_string()))?;
if Some(element_depth) == capture_start {
// The entity's closing tag was just fed to the builder
// above; finalize its tree, hand it to the visitor, and drop
// it before advancing — only one entity is ever materialized
// at once.
let entity_root = builder
.take()
.ok_or_else(|| Error::XmlParse("entity builder missing".to_string()))?
.finish_root()?;
capture_start = None;
entities_visited = entities_visited
.checked_add(1)
.ok_or_else(|| Error::XmlParse("entity count overflow".to_string()))?;
if entities_visited > MAX_STREAM_ENTITIES {
return Err(Error::XmlParse(
"aggregate entity count limit exceeded".to_string(),
));
}
if visit(&entity_root)? == StreamControl::Stop {
return Ok(());
}
if !root_is_aggregate {
// Single-entity root: only ever one entry.
break;
}
} else if capture_start.is_none()
&& ns_stack.len() > 1
&& ns_stack
.last()
.is_some_and(|(_, depth)| *depth == element_depth)
{
// Ascended out of a nested wrapper sitting at this depth;
// its declarations leave scope. The root frame (index 0)
// always stays in scope until EOF.
ns_stack.pop();
}
}
_ => {}
}
}
Ok(())
}
fn depth_overflow() -> Error {
Error::XmlParse("depth overflow".to_string())
}
/// Build a fresh [`TreeBuilder`] for one captured entity, seeded with the
/// in-scope namespace declarations of every open ancestor `<md:EntitiesDescriptor>`
/// wrapper. The declarations are collapsed innermost-wins (a nested wrapper that
/// redeclares a prefix shadows the outer binding, exactly as in the source
/// document) and deduped by key while preserving outermost-first order.
fn new_entity_builder(ns_stack: &[NsFrame]) -> Result<TreeBuilder, Error> {
let mut seed: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for (frame, _depth) in ns_stack {
for (key, value) in frame {
if let Some(slot) = seed.iter_mut().find(|(k, _)| k == key) {
slot.1.clone_from(value);
} else {
seed.push((key.clone(), value.clone()));
}
}
}
TreeBuilder::with_seed_scope(XmlLimits::aggregate(), &seed)
}
/// Feed the opening `Start` event of a just-detected entity into the freshly
/// created builder. Factored out so the root and nested-child capture sites
/// share one place that asserts the builder is present.
fn feed_open(
builder: &mut Option<TreeBuilder>,
event: &quick_xml::events::Event<'_>,
) -> Result<(), Error> {
builder
.as_mut()
.ok_or_else(|| Error::XmlParse("entity builder missing".to_string()))?
.feed(event)
}
/// Recursively flatten nested `<md:EntitiesDescriptor>` blocks (RFC-006 §3).
fn collect_entities(
entities_descriptor: &Element,
out: &mut Vec<MetadataEntry>,
) -> Result<(), Error> {
for child in entities_descriptor.children() {
let Node::Element(elem) = child else { continue };
if is_md_element(elem, "EntityDescriptor") {
out.push(parse_entity_descriptor(elem)?);
} else if is_md_element(elem, "EntitiesDescriptor") {
collect_entities(elem, out)?;
}
// Other md:* extensions (RoleDescriptor, etc.) are ignored.
}
Ok(())
}
fn parse_entity_descriptor(entity: &Element) -> Result<MetadataEntry, Error> {
let has_idp = entity
.child_element(Some(MD_NS), "IDPSSODescriptor")
.is_some();
let has_sp = entity
.child_element(Some(MD_NS), "SPSSODescriptor")
.is_some();
match (has_idp, has_sp) {
(true, true) => {
let idp = IdpDescriptor::from_entity_descriptor_element(entity)?;
let sp = SpDescriptor::from_entity_descriptor_element(entity)?;
Ok(MetadataEntry::Dual(idp, sp))
}
(true, false) => Ok(MetadataEntry::Idp(
IdpDescriptor::from_entity_descriptor_element(entity)?,
)),
(false, true) => Ok(MetadataEntry::Sp(
SpDescriptor::from_entity_descriptor_element(entity)?,
)),
(false, false) => Ok(MetadataEntry::Other),
}
}
// =============================================================================
// Metadata signature verification & verify-then-parse helpers
// =============================================================================
/// Inputs for `verify_metadata_signature`. Bundled into a struct so callers
/// don't accidentally swap the cert / XML arguments.
pub struct VerifyMetadata<'a> {
pub metadata_xml: &'a [u8],
pub trusted_signing_cert: &'a X509Certificate,
}
/// Verify the enveloped XML-DSig on a metadata document.
///
/// The signed element MUST be the document root (the top-level
/// `<md:EntityDescriptor>` or `<md:EntitiesDescriptor>`). Any other arrangement
/// — for example, a signature whose `Reference URI` points at a descendant
/// while the attacker wraps the document in an outer envelope — is rejected
/// here. This is the structural XSW defense documented in RFC-002 §3.2 applied
/// at the metadata layer.
pub fn verify_metadata_signature(input: VerifyMetadata<'_>) -> Result<(), Error> {
let doc = Document::parse(input.metadata_xml)?;
verify_metadata_signature_on_document(&doc, input.trusted_signing_cert)
}
fn verify_metadata_signature_on_document(
doc: &Document,
trusted_signing_cert: &X509Certificate,
) -> Result<(), Error> {
let signature_elem = doc
.root()
.child_element(Some(DS_NS), "Signature")
.ok_or(Error::SignatureMissing)?;
let verified = verify_signature(
doc,
signature_elem,
std::slice::from_ref(trusted_signing_cert),
SignatureAlgorithm::DEFAULTS,
)?;
if verified.signed_element != doc.root().id() {
return Err(Error::SignatureVerification {
reason: "metadata signature does not cover the document root",
});
}
Ok(())
}
/// Verify the XML-DSig signature on a federation metadata document, then parse
/// it. Per RFC-006 §5, the verify-then-parse ordering is enforced atomically
/// here so attacker-supplied XML is never parsed into a usable descriptor
/// before the signature check runs.
pub fn parse_signed_entities_descriptor(
metadata_xml: &[u8],
trusted_signing_cert: &X509Certificate,
) -> Result<EntitiesDescriptor, Error> {
// Aggregate-sized node ceiling: a signed InCommon / eduGAIN aggregate
// exceeds the default ~100k-node limit, and the signature covers the whole
// wrapper so the document must be parsed as a unit before verification.
let doc = Document::parse_with_limits(metadata_xml, XmlLimits::aggregate())?;
verify_metadata_signature_on_document(&doc, trusted_signing_cert)?;
EntitiesDescriptor::from_root_element(doc.root())
}
/// Verify-then-parse helper for a single-entity IdP metadata document.
pub fn parse_signed_idp_descriptor(
metadata_xml: &[u8],
trusted_signing_cert: &X509Certificate,
) -> Result<IdpDescriptor, Error> {
let doc = Document::parse(metadata_xml)?;
verify_metadata_signature_on_document(&doc, trusted_signing_cert)?;
let entity = find_entity_descriptor(doc.root(), |e| {
e.child_element(Some(MD_NS), "IDPSSODescriptor").is_some()
})
.ok_or(Error::InvalidConfiguration {
reason: "metadata does not contain an IdP entity",
})?;
IdpDescriptor::from_entity_descriptor_element(entity)
}
/// Verify-then-parse helper for a single-entity SP metadata document.
pub fn parse_signed_sp_descriptor(
metadata_xml: &[u8],
trusted_signing_cert: &X509Certificate,
) -> Result<SpDescriptor, Error> {
let doc = Document::parse(metadata_xml)?;
verify_metadata_signature_on_document(&doc, trusted_signing_cert)?;
let entity = find_entity_descriptor(doc.root(), |e| {
e.child_element(Some(MD_NS), "SPSSODescriptor").is_some()
})
.ok_or(Error::InvalidConfiguration {
reason: "metadata does not contain an SP entity",
})?;
SpDescriptor::from_entity_descriptor_element(entity)
}
// =============================================================================
// Shared parsing helpers (consumed by descriptor::idp and descriptor::sp)
// =============================================================================
pub(crate) fn is_md_element(element: &Element, local: &str) -> bool {
element.qname().local() == local && element.qname().namespace() == Some(MD_NS)
}
/// Locate an `<md:EntityDescriptor>` in `root` (which may itself be one or be
/// an `<md:EntitiesDescriptor>` aggregate) that satisfies `pred`.
///
/// For aggregates the search is in document order, flattening any nested
/// `<md:EntitiesDescriptor>` blocks (RFC-006 §3).
pub(crate) fn find_entity_descriptor<F>(root: &Element, pred: F) -> Option<&Element>
where
F: Fn(&Element) -> bool + Copy,
{
if is_md_element(root, "EntityDescriptor") {
if pred(root) {
return Some(root);
}
return None;
}
if is_md_element(root, "EntitiesDescriptor") {
for child in root.children() {
let Node::Element(elem) = child else { continue };
if is_md_element(elem, "EntityDescriptor") {
if pred(elem) {
return Some(elem);
}
} else if is_md_element(elem, "EntitiesDescriptor")
&& let Some(found) = find_entity_descriptor(elem, pred)
{
return Some(found);
}
}
}
None
}
/// Parse a `Binding=` / `Location=` / `index=` / `isDefault=` SAML endpoint.
pub(crate) fn parse_endpoint(element: &Element) -> Result<crate::binding::Endpoint, Error> {
let binding_uri = element
.attribute(None, "Binding")
.ok_or(Error::InvalidConfiguration {
reason: "endpoint missing Binding",
})?;
let binding = crate::binding::Binding::from_uri(binding_uri)?;
let location = element
.attribute(None, "Location")
.ok_or(Error::InvalidConfiguration {
reason: "endpoint missing Location",
})?
.to_owned();
let index = match element.attribute(None, "index") {
Some(s) => Some(
s.parse::<u16>()
.map_err(|_parse_err| Error::InvalidConfiguration {
reason: "endpoint index is not a u16",
})?,
),
None => None,
};
let is_default =
parse_optional_bool_value(element.attribute(None, "isDefault"))?.unwrap_or(false);
Ok(crate::binding::Endpoint {
url: location,
binding,
index,
is_default,
})
}
/// Partition `<md:KeyDescriptor>` children into `(signing_certs,
/// encryption_certs)` per RFC-006 §4. A `KeyDescriptor` with no `use`
/// attribute lands in *both* lists.
pub(crate) fn parse_key_descriptors(
role_descriptor: &Element,
) -> Result<(Vec<X509Certificate>, Vec<X509Certificate>), Error> {
let mut signing = Vec::new();
let mut encryption = Vec::new();
for kd in role_descriptor.all_child_elements(Some(MD_NS), "KeyDescriptor") {
let use_attr = kd.attribute(None, "use");
let goes_to_signing = use_attr == Some("signing") || use_attr.is_none();
let goes_to_encryption = use_attr == Some("encryption") || use_attr.is_none();
// Reject explicit but unrecognized `use` values to surface metadata
// typos rather than silently dropping the cert from both lists.
if let Some(value) = use_attr
&& value != "signing"
&& value != "encryption"
{
return Err(Error::InvalidConfiguration {
reason: "KeyDescriptor use attribute must be signing or encryption",
});
}
let key_info =
kd.child_element(Some(DS_NS), "KeyInfo")
.ok_or(Error::InvalidConfiguration {
reason: "KeyDescriptor missing KeyInfo",
})?;
for x509_data in key_info.all_child_elements(Some(DS_NS), "X509Data") {
for cert_elem in x509_data.all_child_elements(Some(DS_NS), "X509Certificate") {
let b64 = cert_elem.text_content();
let cert = X509Certificate::from_base64_x509(&b64)?;
if goes_to_signing {
signing.push(cert.clone());
}
if goes_to_encryption {
encryption.push(cert);
}
}
}
}
Ok((signing, encryption))
}
/// Collect every `<md:NameIDFormat>` child of a role descriptor and map each
/// to a [`NameIdFormat`]. Whitespace-only entries are dropped silently — the
/// SAML schema permits them but they carry no information.
pub(crate) fn parse_name_id_formats(role_descriptor: &Element) -> Vec<NameIdFormat> {
let mut out = Vec::new();
for child in role_descriptor.all_child_elements(Some(MD_NS), "NameIDFormat") {
let uri = child.text_content();
let trimmed = uri.trim();
if !trimmed.is_empty() {
out.push(NameIdFormat::from_uri(trimmed));
}
}
out
}
/// Parse a `validUntil` (xs:dateTime) attribute on `element` if present.
pub(crate) fn parse_optional_xs_datetime(
element: &Element,
attr: &str,
) -> Result<Option<SystemTime>, Error> {
match element.attribute(None, attr) {
Some(s) => Ok(Some(parse_xs_datetime(s)?)),
None => Ok(None),
}
}
/// Parse a `cacheDuration` (xs:duration) attribute on `element` if present.
pub(crate) fn parse_optional_duration(
element: &Element,
attr: &str,
) -> Result<Option<Duration>, Error> {
match element.attribute(None, attr) {
Some(s) => Ok(Some(parse_xs_duration(s)?)),
None => Ok(None),
}
}
/// Parse a `WantAuthnRequestsSigned` / `AuthnRequestsSigned` /
/// `WantAssertionsSigned` style boolean attribute on `element`.
pub(crate) fn parse_optional_bool(element: &Element, attr: &str) -> Result<Option<bool>, Error> {
parse_optional_bool_value(element.attribute(None, attr))
}
pub(crate) fn parse_optional_bool_value(value: Option<&str>) -> Result<Option<bool>, Error> {
match value {
None => Ok(None),
// xs:boolean lexical space.
Some("true" | "1") => Ok(Some(true)),
Some("false" | "0") => Ok(Some(false)),
Some(_) => Err(Error::InvalidConfiguration {
reason: "invalid xs:boolean attribute",
}),
}
}
/// Parse an xs:duration of the common subset supported by this crate.
///
/// Accepted grammar (state-machine; no regex dependency):
///
/// ```text
/// P [ <digits> D ] [ T [ <digits> H ] [ <digits> M ] [ <digits> S ] ]
/// ```
///
/// Anything else (`Y` / `M` for years/months, negative durations, fractional
/// digits, or whitespace) is rejected with
/// `Error::InvalidConfiguration { reason: "unsupported xs:duration" }`.
pub(crate) fn parse_xs_duration(s: &str) -> Result<Duration, Error> {
let unsupported = || Error::InvalidConfiguration {
reason: "unsupported xs:duration",
};
let bytes = s.as_bytes();
if bytes.first() != Some(&b'P') {
return Err(unsupported());
}
// We require at least one component (P alone, PT alone are invalid).
if bytes.len() < 3 {
return Err(unsupported());
}
// Phase tracking: 0 = before T (D allowed), 1 = after T (H, M, S allowed).
// Within each phase we require designators in canonical order: D, then T,
// then H, M, S. A repeated or out-of-order designator is an error.
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
enum Slot {
Days,
Hours,
Minutes,
Seconds,
}
let mut i = 1usize;
let mut after_t = false;
let mut last_slot: Option<Slot> = None;
let mut days: u64 = 0;
let mut hours: u64 = 0;
let mut minutes: u64 = 0;
let mut seconds: u64 = 0;
let mut saw_any = false;
while let Some(&b) = bytes.get(i) {
if b == b'T' {
if after_t {
return Err(unsupported());
}
after_t = true;
i = i.checked_add(1).ok_or_else(unsupported)?;
// After T we require at least one designator.
if i >= bytes.len() {
return Err(unsupported());
}
continue;
}
if !b.is_ascii_digit() {
return Err(unsupported());
}
let start = i;
while let Some(byte) = bytes.get(i)
&& byte.is_ascii_digit()
{
i = i.checked_add(1).ok_or_else(unsupported)?;
}
let designator = *bytes.get(i).ok_or_else(unsupported)?;
let digit_slice = bytes.get(start..i).ok_or_else(unsupported)?;
let value: u64 = std::str::from_utf8(digit_slice)
.map_err(|_utf8_err| unsupported())?
.parse::<u64>()
.map_err(|_parse_err| unsupported())?;
let (slot, target) = match (after_t, designator) {
(false, b'D') => (Slot::Days, &mut days),
(true, b'H') => (Slot::Hours, &mut hours),
(true, b'M') => (Slot::Minutes, &mut minutes),
(true, b'S') => (Slot::Seconds, &mut seconds),
// Years / months (before T) and anything else are rejected.
_ => return Err(unsupported()),
};
// Designators must appear at most once, and in canonical order.
if let Some(prev) = last_slot
&& slot <= prev
{
return Err(unsupported());
}
last_slot = Some(slot);
*target = value;
saw_any = true;
i = i.checked_add(1).ok_or_else(unsupported)?;
}
if !saw_any {
return Err(unsupported());
}
// Compose into total seconds. None of the supported components can
// realistically overflow u64 seconds for sane SAML metadata.
let total_secs = days
.checked_mul(86_400)
.and_then(|d| d.checked_add(hours.checked_mul(3600)?))
.and_then(|d| d.checked_add(minutes.checked_mul(60)?))
.and_then(|d| d.checked_add(seconds))
.ok_or_else(unsupported)?;
Ok(Duration::from_secs(total_secs))
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::{Binding, SsoResponseBinding};
use crate::crypto::cert::X509Certificate;
use crate::crypto::cert::test_vectors::{RSA_CERT_PEM, RSA_KEY_PKCS8_PEM};
use crate::crypto::keypair::KeyPair;
use crate::dsig::algorithms::{C14nAlgorithm, DigestAlgorithm, SignatureAlgorithm};
use crate::dsig::c14n::canonicalize;
use crate::dsig::reference::ancestor_chain;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
fn rsa_cert_b64() -> String {
X509Certificate::from_pem(RSA_CERT_PEM)
.unwrap()
.to_base64_x509()
}
fn rsa_cert() -> X509Certificate {
X509Certificate::from_pem(RSA_CERT_PEM).unwrap()
}
// ---- xs:duration ----
#[test]
fn duration_pt1h() {
assert_eq!(parse_xs_duration("PT1H").unwrap(), Duration::from_hours(1));
}
#[test]
fn duration_pt15m() {
assert_eq!(parse_xs_duration("PT15M").unwrap(), Duration::from_mins(15));
}
#[test]
fn duration_p1d() {
assert_eq!(parse_xs_duration("P1D").unwrap(), Duration::from_hours(24));
}
#[test]
fn duration_pt3600s() {
assert_eq!(
parse_xs_duration("PT3600S").unwrap(),
Duration::from_hours(1)
);
}
#[test]
fn duration_compound_hms() {
assert_eq!(
parse_xs_duration("PT1H30M15S").unwrap(),
Duration::from_secs(3600 + 30 * 60 + 15)
);
}
#[test]
fn duration_p1d_pt1h() {
assert_eq!(
parse_xs_duration("P1DT1H").unwrap(),
Duration::from_hours(25)
);
}
#[test]
fn duration_rejects_years() {
assert!(matches!(
parse_xs_duration("P1Y"),
Err(Error::InvalidConfiguration { .. })
));
}
#[test]
fn duration_rejects_months() {
assert!(matches!(
parse_xs_duration("P1M"),
Err(Error::InvalidConfiguration { .. })
));
}
#[test]
fn duration_rejects_negative() {
assert!(matches!(
parse_xs_duration("-PT1H"),
Err(Error::InvalidConfiguration { .. })
));
}
#[test]
fn duration_rejects_empty_payload() {
assert!(matches!(
parse_xs_duration("P"),
Err(Error::InvalidConfiguration { .. })
));
assert!(matches!(
parse_xs_duration("PT"),
Err(Error::InvalidConfiguration { .. })
));
}
#[test]
fn duration_rejects_repeated_designator() {
assert!(matches!(
parse_xs_duration("PT1H1H"),
Err(Error::InvalidConfiguration { .. })
));
}
// ---- EntitiesDescriptor ----
fn idp_entity_xml(entity_id: &str) -> String {
format!(
r#"<md:EntityDescriptor entityID="{eid}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><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.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#,
eid = entity_id,
cert = rsa_cert_b64()
)
}
fn sp_entity_xml(entity_id: &str) -> String {
format!(
r#"<md:EntityDescriptor entityID="{entity_id}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"
AuthnRequestsSigned="true"
WantAssertionsSigned="true">
<md:AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://sp.example.com/acs"/>
<md:SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://sp.example.com/slo"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>"#
)
}
fn dual_entity_xml(entity_id: &str) -> String {
format!(
r#"<md:EntityDescriptor entityID="{eid}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><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://entity.example.com/sso"/>
</md:IDPSSODescriptor>
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://entity.example.com/acs"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>"#,
eid = entity_id,
cert = rsa_cert_b64()
)
}
#[test]
fn aggregate_with_mixed_children() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Name="urn:example:federation">
{idp}
{sp}
</md:EntitiesDescriptor>"#,
idp = idp_entity_xml("https://idp.example.com/saml"),
sp = sp_entity_xml("https://sp.example.com/saml")
);
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).expect("parse ok");
assert_eq!(fed.name.as_deref(), Some("urn:example:federation"));
assert_eq!(fed.entities.len(), 2);
assert!(matches!(fed.entities[0], MetadataEntry::Idp(_)));
assert!(matches!(fed.entities[1], MetadataEntry::Sp(_)));
assert!(fed.find_idp("https://idp.example.com/saml").is_some());
assert!(fed.find_sp("https://sp.example.com/saml").is_some());
assert!(fed.find_idp("does-not-exist").is_none());
assert_eq!(fed.iter_idps().count(), 1);
assert_eq!(fed.iter_sps().count(), 1);
}
#[test]
fn aggregate_flattens_nested_entities_descriptor() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{idp_outer}
<md:EntitiesDescriptor>
{idp_inner}
</md:EntitiesDescriptor>
</md:EntitiesDescriptor>"#,
idp_outer = idp_entity_xml("https://idp.outer.example.com/saml"),
idp_inner = idp_entity_xml("https://idp.inner.example.com/saml")
);
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
assert_eq!(fed.entities.len(), 2);
assert!(fed.find_idp("https://idp.outer.example.com/saml").is_some());
assert!(fed.find_idp("https://idp.inner.example.com/saml").is_some());
}
#[test]
fn dual_role_entity_classified_as_dual() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{dual}
</md:EntitiesDescriptor>"#,
dual = dual_entity_xml("https://shib.example.com/saml")
);
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
assert_eq!(fed.entities.len(), 1);
assert!(matches!(fed.entities[0], MetadataEntry::Dual(_, _)));
let idp = fed.find_idp("https://shib.example.com/saml").unwrap();
let sp = fed.find_sp("https://shib.example.com/saml").unwrap();
assert_eq!(idp.entity_id, sp.entity_id);
assert_eq!(idp.sso_endpoints.len(), 1);
assert_eq!(sp.assertion_consumer_services.len(), 1);
}
#[test]
fn unknown_role_descriptor_becomes_other_variant() {
let xml = r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
<md:EntityDescriptor entityID="https://aa.example.com/saml">
<!-- An entity without IDP/SP role descriptors, e.g. an AttributeAuthority. -->
<md:AttributeAuthorityDescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
</md:EntityDescriptor>
</md:EntitiesDescriptor>"#;
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
assert_eq!(fed.entities.len(), 1);
assert!(matches!(fed.entities[0], MetadataEntry::Other));
}
#[test]
fn single_entity_descriptor_root_is_promoted_to_aggregate() {
let xml = format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/saml">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><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.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#,
cert = rsa_cert_b64()
);
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
assert_eq!(fed.entities.len(), 1);
assert!(matches!(fed.entities[0], MetadataEntry::Idp(_)));
}
// ---- Endpoint helpers ----
#[test]
fn parse_endpoint_handles_index_and_default() {
let xml = r#"<md:Wrapper xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
<md:E Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://x/acs" index="3" isDefault="true"/>
</md:Wrapper>"#;
let doc = Document::parse(xml.as_bytes()).unwrap();
let e = doc.root().child_element(Some(MD_NS), "E").unwrap();
let parsed = parse_endpoint(e).unwrap();
assert_eq!(parsed.binding, Binding::HttpPost);
assert_eq!(parsed.url, "https://x/acs");
assert_eq!(parsed.index, Some(3));
assert!(parsed.is_default);
}
// ---- Signed metadata ----
/// Sign a metadata document the same way `crate::dsig::verify` tests do.
fn sign_metadata(target_id: &str, body_xml: &str) -> (String, X509Certificate) {
let kp = KeyPair::from_pkcs8_pem(RSA_KEY_PKCS8_PEM).unwrap();
let cert = rsa_cert();
let c14n_alg = C14nAlgorithm::ExclusiveCanonical;
let sig_alg = SignatureAlgorithm::RsaSha256;
let stage_1_xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ID="{target_id}">{body_xml}</md:EntitiesDescriptor>"#
);
let stage_1_doc = Document::parse(stage_1_xml.as_bytes()).unwrap();
let chain_1 = ancestor_chain(&stage_1_doc, stage_1_doc.root().id()).unwrap();
let canonical_root =
canonicalize(&stage_1_doc, stage_1_doc.root(), &chain_1, c14n_alg, &[]).unwrap();
let reference_digest = DigestAlgorithm::Sha256.digest(&canonical_root);
let digest_b64 = BASE64_STANDARD.encode(&reference_digest);
let signed_info_inner = format!(
r##"<ds:CanonicalizationMethod Algorithm="{c14n}"/><ds:SignatureMethod Algorithm="{sig}"/><ds:Reference URI="#{id}"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="{c14n}"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference>"##,
c14n = c14n_alg.uri(),
sig = sig_alg.uri(),
id = target_id,
digest = digest_b64,
);
let signed_info_xml = format!(
r#"<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">{signed_info_inner}</ds:SignedInfo>"#,
);
let signed_info_doc = Document::parse(signed_info_xml.as_bytes()).unwrap();
let si_chain = ancestor_chain(&signed_info_doc, signed_info_doc.root().id()).unwrap();
let si_canonical = canonicalize(
&signed_info_doc,
signed_info_doc.root(),
&si_chain,
c14n_alg,
&[],
)
.unwrap();
let sig_bytes = kp.sign(sig_alg, &si_canonical).unwrap();
let sig_b64 = BASE64_STANDARD.encode(&sig_bytes);
let cert_b64 = cert.to_base64_x509();
let body = body_xml;
let si_inner = signed_info_inner.as_str();
let sig = sig_b64.as_str();
let cert_text = cert_b64.as_str();
let final_xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ID="{target_id}">{body}<ds:Signature><ds:SignedInfo>{si_inner}</ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_text}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature></md:EntitiesDescriptor>"#,
);
(final_xml, cert)
}
#[test]
fn verify_metadata_signature_happy_path() {
let body = idp_entity_xml("https://idp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
verify_metadata_signature(VerifyMetadata {
metadata_xml: xml.as_bytes(),
trusted_signing_cert: &cert,
})
.expect("signature verifies");
}
#[test]
fn verify_metadata_signature_missing_signature() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{idp}
</md:EntitiesDescriptor>"#,
idp = idp_entity_xml("https://idp.example.com/saml")
);
let cert = rsa_cert();
let err = verify_metadata_signature(VerifyMetadata {
metadata_xml: xml.as_bytes(),
trusted_signing_cert: &cert,
})
.unwrap_err();
assert!(matches!(err, Error::SignatureMissing));
}
#[test]
fn parse_signed_entities_descriptor_round_trip() {
let body = idp_entity_xml("https://idp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
let fed = parse_signed_entities_descriptor(xml.as_bytes(), &cert).unwrap();
assert_eq!(fed.entities.len(), 1);
assert!(fed.find_idp("https://idp.example.com/saml").is_some());
}
#[test]
fn parse_signed_idp_descriptor_via_aggregate() {
let body = idp_entity_xml("https://idp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
let idp = parse_signed_idp_descriptor(xml.as_bytes(), &cert).unwrap();
assert_eq!(idp.entity_id, "https://idp.example.com/saml");
let _ = SsoResponseBinding::HttpPost; // import sanity
}
#[test]
fn parse_signed_sp_descriptor_via_aggregate() {
let body = sp_entity_xml("https://sp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
let sp = parse_signed_sp_descriptor(xml.as_bytes(), &cert).unwrap();
assert_eq!(sp.entity_id, "https://sp.example.com/saml");
}
// ---- entityID index ----
#[test]
fn by_entity_id_and_index_resolve_all_roles() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{idp}
{sp}
{dual}
</md:EntitiesDescriptor>"#,
idp = idp_entity_xml("https://idp.example.com/saml"),
sp = sp_entity_xml("https://sp.example.com/saml"),
dual = dual_entity_xml("https://shib.example.com/saml"),
);
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
// Linear accessor.
assert!(matches!(
fed.by_entity_id("https://idp.example.com/saml"),
Some(MetadataEntry::Idp(_))
));
assert!(matches!(
fed.by_entity_id("https://sp.example.com/saml"),
Some(MetadataEntry::Sp(_))
));
assert!(matches!(
fed.by_entity_id("https://shib.example.com/saml"),
Some(MetadataEntry::Dual(_, _))
));
assert!(fed.by_entity_id("nope").is_none());
// HashMap index returns the same entries.
let index = fed.index_by_entity_id();
assert_eq!(index.len(), 3);
assert!(matches!(
index.get("https://idp.example.com/saml"),
Some(MetadataEntry::Idp(_))
));
assert!(matches!(
index.get("https://shib.example.com/saml"),
Some(MetadataEntry::Dual(_, _))
));
}
#[test]
fn other_entry_has_no_entity_id() {
let xml = r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
<md:EntityDescriptor entityID="https://aa.example.com/saml">
<md:AttributeAuthorityDescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
</md:EntityDescriptor>
</md:EntitiesDescriptor>"#;
let fed = EntitiesDescriptor::from_metadata_xml(xml.as_bytes()).unwrap();
assert_eq!(fed.entities[0].entity_id(), None);
// `Other` entries are skipped by the index.
assert!(fed.index_by_entity_id().is_empty());
}
// ---- streaming parse ----
#[test]
fn stream_entities_visits_each_child_lazily() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Name="urn:example:federation">
{idp}
{sp}
{dual}
</md:EntitiesDescriptor>"#,
idp = idp_entity_xml("https://idp.example.com/saml"),
sp = sp_entity_xml("https://sp.example.com/saml"),
dual = dual_entity_xml("https://shib.example.com/saml"),
);
let mut ids = Vec::new();
stream_entities(xml.as_bytes(), |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.expect("stream ok");
assert_eq!(
ids,
vec![
Some("https://idp.example.com/saml".to_owned()),
Some("https://sp.example.com/saml".to_owned()),
Some("https://shib.example.com/saml".to_owned()),
]
);
}
#[test]
fn stream_entities_stop_short_circuits() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{a}
{b}
{c}
</md:EntitiesDescriptor>"#,
a = idp_entity_xml("https://a.example.com/saml"),
b = idp_entity_xml("https://b.example.com/saml"),
c = idp_entity_xml("https://c.example.com/saml"),
);
let mut count = 0usize;
let mut found = None;
stream_entities(xml.as_bytes(), |entry| {
count = count.checked_add(1).unwrap();
if entry.entity_id() == Some("https://b.example.com/saml") {
found = entry.entity_id().map(str::to_owned);
StreamControl::Stop
} else {
StreamControl::Continue
}
})
.unwrap();
assert_eq!(found.as_deref(), Some("https://b.example.com/saml"));
// Visited a and b only — c was never parsed.
assert_eq!(count, 2);
}
#[test]
fn stream_entities_flattens_nested_aggregate() {
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{outer}
<md:EntitiesDescriptor>
{inner}
</md:EntitiesDescriptor>
</md:EntitiesDescriptor>"#,
outer = idp_entity_xml("https://outer.example.com/saml"),
inner = idp_entity_xml("https://inner.example.com/saml"),
);
let mut ids = Vec::new();
stream_entities(xml.as_bytes(), |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&Some("https://outer.example.com/saml".to_owned())));
assert!(ids.contains(&Some("https://inner.example.com/saml".to_owned())));
}
#[test]
fn stream_entities_single_entity_root() {
let xml = format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://solo.example.com/saml">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><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://solo.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#,
cert = rsa_cert_b64()
);
let mut ids = Vec::new();
stream_entities(xml.as_bytes(), |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.unwrap();
assert_eq!(ids, vec![Some("https://solo.example.com/saml".to_owned())]);
}
#[test]
fn stream_entities_entity_containing_entitydescriptor_comment_parses() {
// A legal comment inside an entity that contains a literal
// `<md:EntityDescriptor` token must NOT confuse span capture: the
// entity's real opening `<` is recorded when capture starts, so the
// comment text is just bytes inside the span, never a new start.
let xml = r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<md:EntityDescriptor entityID="https://commented.example.com/saml">
<!-- spoof: <md:EntityDescriptor entityID="https://evil.example/saml"> -->
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://commented.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
</md:EntitiesDescriptor>"#;
let mut ids = Vec::new();
stream_entities(xml.as_bytes(), |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.expect("comment-spoof entity must still parse");
assert_eq!(
ids,
vec![Some("https://commented.example.com/saml".to_owned())]
);
}
#[test]
fn stream_entities_nested_aggregate_inherits_inner_namespace_prefix() {
// The `ds:` prefix a child relies on is declared on the *inner*
// EntitiesDescriptor, not the root. The entity builder's seed scope must
// inherit the inner wrapper's declarations too (RFC-006 §3 nested
// aggregates), otherwise the child's `ds:KeyInfo` fails to resolve its
// prefix.
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
<md:EntitiesDescriptor xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<md:EntityDescriptor entityID="https://nested.example.com/saml">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><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://nested.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
</md:EntitiesDescriptor>
</md:EntitiesDescriptor>"#,
cert = rsa_cert_b64()
);
let mut ids = Vec::new();
stream_entities(xml.as_bytes(), |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.expect("nested-namespace entity must parse");
assert_eq!(
ids,
vec![Some("https://nested.example.com/saml".to_owned())]
);
}
#[test]
fn stream_entities_normalizes_carriage_returns_like_eager_path() {
// An entity whose certificate text node carries `\r\n` and a bare `\r`.
// XML 1.0 §2.11 requires both to normalize to `\n` before parsing; the
// eager DOM path does this in `parse_inner`, and the streaming path must
// too — otherwise a literal `\r` survives into the entity tree's text
// node and this crate's c14n escapes it as `
`, producing a
// different digest than the eager tree the signer's bytes match.
let cert = rsa_cert_b64();
let cert_with_cr = format!("{cert}\r\nTRAILER\rEND");
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><md:EntityDescriptor entityID="https://cr.example.com/saml"><md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{cert_with_cr}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://cr.example.com/sso"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>"#
);
// Pull the cert text node out of the eager DOM entity tree.
let eager_doc = Document::parse(xml.as_bytes()).unwrap();
let eager_cert_text = eager_doc
.find_first(Some(DS_NS), "X509Certificate")
.unwrap()
.text_content();
// And out of the streamed entity tree, via the same streaming machinery
// `stream_entities` uses.
let mut streamed_cert_text = None;
stream_entity_elements(xml.as_bytes(), |entity| {
let text = find_first_in(entity, Some(DS_NS), "X509Certificate")
.unwrap()
.text_content();
streamed_cert_text = Some(text);
Ok(StreamControl::Continue)
})
.expect("stream ok");
let streamed_cert_text = streamed_cert_text.expect("one entity visited");
// Both paths normalized `\r\n` and bare `\r` to `\n`, identically.
assert!(
!eager_cert_text.contains('\r'),
"eager path left a carriage return"
);
assert!(
!streamed_cert_text.contains('\r'),
"streaming path left a carriage return"
);
assert!(streamed_cert_text.contains("\nTRAILER\nEND"));
assert_eq!(
streamed_cert_text, eager_cert_text,
"streamed entity text node must match the eager tree byte-for-byte"
);
}
/// Test-only recursive search for the first descendant element with the
/// given expanded name, mirroring `Document::find_first` but rooted at an
/// arbitrary [`Element`] (the streamed entity tree has no surrounding
/// `Document`).
fn find_first_in<'a>(
element: &'a Element,
namespace: Option<&str>,
local: &str,
) -> Option<&'a Element> {
if element.qname().local() == local && element.qname().namespace() == namespace {
return Some(element);
}
for child in element.child_elements() {
if let Some(found) = find_first_in(child, namespace, local) {
return Some(found);
}
}
None
}
#[test]
fn stream_entities_unescapes_seeded_ancestor_namespace_uri() {
// An ancestor wrapper binds prefix `foo` to a URI containing an XML
// entity (`&`). A child element inside the entity uses that prefix.
// The seeded-scope path must unescape the URI to `urn:a&b` exactly as
// the eager path would for an element's own namespace decls — otherwise
// the child resolves to the raw `urn:a&b`, diverging from eager.
let cert = rsa_cert_b64();
let xml = format!(
r#"<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><md:EntitiesDescriptor xmlns:foo="urn:a&b"><md:EntityDescriptor entityID="https://esc.example.com/saml"><md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><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://esc.example.com/sso"/><foo:Marker/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor></md:EntitiesDescriptor>"#
);
// Eager path: the entity tree resolves `foo:Marker` to the unescaped URI.
let eager_doc = Document::parse(xml.as_bytes()).unwrap();
let eager_marker_ns = eager_doc
.find_first(Some("urn:a&b"), "Marker")
.expect("eager path must resolve foo: to unescaped urn:a&b")
.qname()
.namespace()
.map(str::to_owned);
assert_eq!(eager_marker_ns.as_deref(), Some("urn:a&b"));
// Streaming path: the seed scope inherits the inner wrapper's `foo`
// binding and must unescape it identically.
let mut streamed_marker_ns = None;
stream_entity_elements(xml.as_bytes(), |entity| {
streamed_marker_ns = find_first_in(entity, Some("urn:a&b"), "Marker")
.map(|m| m.qname().namespace().map(str::to_owned));
Ok(StreamControl::Continue)
})
.expect("stream ok");
assert_eq!(
streamed_marker_ns,
Some(Some("urn:a&b".to_owned())),
"streamed seed scope must unescape the ancestor URI to match eager"
);
}
#[test]
fn stream_signed_entities_verifies_before_yield() {
let body = idp_entity_xml("https://idp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
let mut ids = Vec::new();
stream_signed_entities(xml.as_bytes(), &cert, |entry| {
ids.push(entry.entity_id().map(str::to_owned));
StreamControl::Continue
})
.expect("verify + stream");
assert_eq!(ids, vec![Some("https://idp.example.com/saml".to_owned())]);
}
#[test]
fn stream_signed_entities_rejects_bad_signature_without_yielding() {
let body = idp_entity_xml("https://idp.example.com/saml");
let (xml, cert) = sign_metadata("md-1", &body);
let tampered = xml.replacen(
"https://idp.example.com/sso",
"https://idp.evil.example/sso",
1,
);
assert_ne!(tampered, xml);
let mut visited = false;
let err = stream_signed_entities(tampered.as_bytes(), &cert, |_entry| {
visited = true;
StreamControl::Continue
})
.unwrap_err();
assert!(matches!(err, Error::SignatureVerification { .. }));
assert!(
!visited,
"no entity may be yielded from an unverified aggregate"
);
}
}