#![cfg(feature = "weak-algos")]
#[path = "common/mod.rs"]
mod common;
use std::time::{Duration, SystemTime};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use saml::binding::SsoResponseBinding;
use saml::binding::{Endpoint, SsoResponseEndpoint};
use saml::crypto::cert::X509Certificate;
#[cfg(feature = "xmlenc")]
use saml::crypto::keypair::KeyPair;
use saml::descriptor::IdpDescriptor;
use saml::dsig::algorithms::{DigestAlgorithm, PeerCryptoPolicy, SignatureAlgorithm};
use saml::error::Error;
use saml::nameid::NameIdFormat;
use saml::replay::ReplayMode;
use saml::sp::{
ConsumeResponse, LoginTracker, ServiceProvider, ServiceProviderConfig, SpWantSigned,
};
use saml::time::parse_xs_datetime;
#[derive(Debug, Clone, Copy)]
enum Expected {
Ok,
Reject,
}
#[derive(Debug, Clone, Copy)]
struct Fixture {
path: &'static str,
b64_wrap: bool,
expected: Expected,
weak_algos: bool,
allow_unsolicited: bool,
label: &'static str,
sp_entity_id_override: Option<&'static str>,
idp_cert_pem_path: Option<&'static str>,
sp_decryption_key_pkcs1_pem_path: Option<&'static str>,
acs_url_override: Option<&'static str>,
}
impl Fixture {
const fn pos(path: &'static str, label: &'static str) -> Self {
Self {
path,
b64_wrap: false,
expected: Expected::Ok,
weak_algos: true,
allow_unsolicited: true,
label,
sp_entity_id_override: None,
idp_cert_pem_path: None,
sp_decryption_key_pkcs1_pem_path: None,
acs_url_override: None,
}
}
const fn neg(path: &'static str, label: &'static str) -> Self {
Self {
path,
b64_wrap: false,
expected: Expected::Reject,
weak_algos: true,
allow_unsolicited: true,
label,
sp_entity_id_override: None,
idp_cert_pem_path: None,
sp_decryption_key_pkcs1_pem_path: None,
acs_url_override: None,
}
}
const fn b64(mut self) -> Self {
self.b64_wrap = true;
self
}
const fn strong(mut self) -> Self {
self.weak_algos = false;
self
}
#[cfg(feature = "xmlenc")]
const fn with_audience(mut self, audience: &'static str) -> Self {
self.sp_entity_id_override = Some(audience);
self
}
#[cfg(feature = "xmlenc")]
const fn with_idp_cert(mut self, path: &'static str) -> Self {
self.idp_cert_pem_path = Some(path);
self
}
#[cfg(feature = "xmlenc")]
const fn with_decryption_key(mut self, path: &'static str) -> Self {
self.sp_decryption_key_pkcs1_pem_path = Some(path);
self
}
const fn with_acs(mut self, url: &'static str) -> Self {
self.acs_url_override = Some(url);
self
}
}
#[cfg(feature = "xmlenc")]
const PY3_IDP_CERT: &str = "python3-saml/certs/idp.crt";
#[cfg(feature = "xmlenc")]
const PY3_SP_KEY: &str = "python3-saml/certs/sp.key";
const FIXTURES: &[Fixture] = &[
Fixture::pos(
"ruby-saml/responses/adfs_response_sha256.xml",
"adfs_sha256",
)
.strong(),
Fixture::pos(
"ruby-saml/responses/adfs_response_sha384.xml",
"adfs_sha384",
)
.strong(),
Fixture::pos(
"ruby-saml/responses/adfs_response_sha512.xml",
"adfs_sha512",
)
.strong(),
Fixture::pos("ruby-saml/responses/adfs_response_sha1.xml", "adfs_sha1"),
Fixture::neg(
"ruby-saml/responses/response_wrapped.xml.base64",
"xsw_wrapped",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/response_assertion_wrapped.xml.base64",
"xsw_assertion_wrapped",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/response_node_text_attack.xml.base64",
"node_text_attack_1",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/response_node_text_attack2.xml.base64",
"node_text_attack_2",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/response_node_text_attack3.xml.base64",
"node_text_attack_3",
)
.b64(),
Fixture::neg("ruby-saml/responses/attackxee.xml", "xxe"),
Fixture::neg("ruby-saml/responses/no_signature_ns.xml", "no_signature_ns"),
Fixture::neg(
"ruby-saml/responses/response_unsigned_xml_base64",
"unsigned",
)
.b64(),
Fixture::neg(
"python3-saml/responses/expired_response.xml.base64",
"expired",
)
.b64(),
Fixture::neg(
"python3-saml/responses/no_audience.xml.base64",
"no_audience",
)
.b64(),
Fixture::pos(
"python3-saml/responses/double_signed_response.xml.base64",
"double_signed",
)
.b64(),
Fixture::pos(
"python3-saml/responses/signed_message_response.xml.base64",
"signed_message_response",
)
.b64(),
Fixture::pos(
"python3-saml/responses/signed_assertion_response.xml.base64",
"signed_assertion_response",
)
.b64(),
#[cfg(feature = "xmlenc")]
Fixture::neg(
"python3-saml/responses/signed_encrypted_assertion.xml.base64",
"py3_encrypted_missing_recipient",
)
.b64()
.with_idp_cert(PY3_IDP_CERT)
.with_decryption_key(PY3_SP_KEY)
.with_audience("http://stuff.com/endpoints/metadata.php")
.with_acs("http://stuff.com/endpoints/endpoints/acs.php"),
#[cfg(feature = "xmlenc")]
Fixture::neg(
"python3-saml/responses/double_signed_encrypted_assertion.xml.base64",
"py3_encrypted_issuer_mismatch",
)
.b64()
.with_idp_cert(PY3_IDP_CERT)
.with_decryption_key(PY3_SP_KEY)
.with_audience("http://stuff.com/endpoints/metadata.php")
.with_acs("https://pitbulk.no-ip.org/newonelogin/demo1/index.php?acs"),
Fixture::pos(
"ruby-saml/responses/response_with_ds_namespace_at_the_root.xml.base64",
"ds_namespace_at_root",
)
.b64(),
Fixture::pos(
"ruby-saml/responses/response_with_signed_assertion_2.xml.base64",
"signed_assertion_2",
)
.b64(),
Fixture::neg(
"python3-saml/responses/invalids/no_signature.xml.base64",
"no_signature_explicit",
)
.b64(),
Fixture::neg(
"python3-saml/responses/invalids/multiple_assertions.xml.base64",
"multiple_assertions",
)
.b64(),
Fixture::neg(
"python3-saml/responses/invalids/empty_destination.xml.base64",
"empty_destination",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/invalids/multiple_signed.xml.base64",
"ruby_multiple_signed",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/invalids/response_invalid_signed_element.xml.base64",
"ruby_invalid_signed_element",
)
.b64(),
Fixture::neg(
"ruby-saml/responses/invalids/response_with_concealed_signed_assertion.xml",
"ruby_concealed_signed_assertion",
),
Fixture::neg(
"ruby-saml/responses/invalids/response_with_doubled_signed_assertion.xml",
"ruby_doubled_signed_assertion",
),
Fixture::neg(
"python3-saml/responses/invalids/signature_wrapping_attack.xml.base64",
"py3_sig_wrap_attack",
)
.b64(),
Fixture::neg(
"python3-saml/responses/invalids/signature_wrapping_attack2.xml.base64",
"py3_sig_wrap_attack2",
)
.b64(),
Fixture::neg(
"python3-saml/responses/invalids/bad_reference.xml.base64",
"py3_bad_reference",
)
.b64(),
Fixture::neg(
"synthetic/xsw_pattern_5_assertion_in_signature_object.xml.base64",
"xsw_pattern_5_assertion_in_signature_object",
)
.b64(),
Fixture::neg(
"synthetic/xsw_pattern_6_substituted_subject.xml.base64",
"xsw_pattern_6_substituted_subject",
)
.b64(),
Fixture::neg(
"synthetic/xsw_pattern_8_namespace_injection.xml.base64",
"xsw_pattern_8_namespace_injection",
)
.b64(),
];
fn permissive_policy() -> PeerCryptoPolicy {
let mut allowed = SignatureAlgorithm::DEFAULTS.to_vec();
allowed.push(SignatureAlgorithm::RsaSha1);
PeerCryptoPolicy {
allowed_signature_algorithms: allowed,
#[cfg(feature = "xmlenc")]
allowed_data_encryption_algorithms: vec![
saml::xmlenc::algorithms::DataEncryptionAlgorithm::Aes128Gcm,
saml::xmlenc::algorithms::DataEncryptionAlgorithm::Aes256Gcm,
saml::xmlenc::algorithms::DataEncryptionAlgorithm::Aes128Cbc,
saml::xmlenc::algorithms::DataEncryptionAlgorithm::Aes256Cbc,
],
#[cfg(feature = "xmlenc")]
allowed_key_transport_algorithms: vec![
saml::xmlenc::algorithms::KeyTransportAlgorithm::RsaOaep,
saml::xmlenc::algorithms::KeyTransportAlgorithm::RsaOaepMgf1Sha1,
saml::xmlenc::algorithms::KeyTransportAlgorithm::RsaPkcs1V15,
],
}
}
struct Extracted {
issuer: String,
audience: Option<String>,
destination: Option<String>,
in_response_to: Option<String>,
issue_instant: SystemTime,
cert: Option<X509Certificate>,
}
fn extract(xml: &[u8]) -> Result<Extracted, String> {
let s = std::str::from_utf8(xml).map_err(|e| format!("utf8: {e}"))?;
let issuer =
first_element_text(s, "Issuer").ok_or_else(|| "missing <Issuer> text".to_string())?;
let audience = first_element_text(s, "Audience");
let destination = first_attribute(s, "Destination");
let in_response_to = first_attribute(s, "InResponseTo");
let issue_instant_raw = first_attribute(s, "IssueInstant")
.ok_or_else(|| "missing IssueInstant attr".to_string())?;
let issue_instant = parse_xs_datetime(&issue_instant_raw)
.map_err(|e| format!("parse IssueInstant {issue_instant_raw}: {e:?}"))?;
let cert = if let Some(cert_b64) = first_element_text(s, "X509Certificate") {
let cert_b64_clean: String = cert_b64.chars().filter(|c| !c.is_whitespace()).collect();
let parsed = match X509Certificate::from_base64_x509(&cert_b64_clean) {
Ok(c) => c,
Err(first_err) => {
let inner = BASE64
.decode(cert_b64_clean.as_bytes())
.map_err(|e| format!("X509 first={first_err:?} b64 outer: {e:?}"))?;
if inner.starts_with(b"-----BEGIN") {
X509Certificate::from_pem(&inner).map_err(|e| format!("X509 PEM: {e:?}"))?
} else {
X509Certificate::from_der(&inner).map_err(|e| format!("X509 DER: {e:?}"))?
}
}
};
Some(parsed)
} else {
None
};
Ok(Extracted {
issuer,
audience,
destination,
in_response_to,
issue_instant,
cert,
})
}
fn first_element_text(s: &str, local: &str) -> Option<String> {
let needle_a = format!(":{local}>");
let needle_b = format!("<{local}>");
let pos = s
.match_indices(&needle_a)
.map(|(i, _)| i.saturating_add(needle_a.len()))
.next()
.or_else(|| {
s.match_indices(&needle_b)
.map(|(i, _)| i.saturating_add(needle_b.len()))
.next()
})?;
let close = format!("{local}>");
let rest = s.get(pos..)?;
let end = rest.find("</")?;
let inner = rest.get(..end)?;
let after_end = rest.get(end..)?;
if !after_end.contains(&close) {
return None;
}
Some(inner.trim().to_string())
}
fn first_attribute(s: &str, name: &str) -> Option<String> {
let needle = format!(" {name}=\"");
let start = s.find(&needle)?.saturating_add(needle.len());
let rest = s.get(start..)?;
let end = rest.find('"')?;
Some(rest.get(..end)?.to_string())
}
fn corpus_path(rel: &str) -> String {
format!("{}/tests/corpus/{}", env!("CARGO_MANIFEST_DIR"), rel)
}
fn run_fixture(fx: &Fixture) -> Result<saml::response::Identity, String> {
let abs_path = corpus_path(fx.path);
let raw = std::fs::read(&abs_path).map_err(|e| format!("read {abs_path}: {e}"))?;
let xml = if fx.b64_wrap {
let s = std::str::from_utf8(&raw).map_err(|e| format!("utf8 wrap: {e}"))?;
let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect();
BASE64
.decode(clean.as_bytes())
.map_err(|e| format!("b64 wrap decode: {e}"))?
} else {
raw
};
let meta = extract(&xml).map_err(|e| format!("extract: {e}"))?;
let idp_cert = match (fx.idp_cert_pem_path, meta.cert.clone()) {
(Some(rel), _) => {
let pem =
std::fs::read(corpus_path(rel)).map_err(|e| format!("read idp cert {rel}: {e}"))?;
X509Certificate::from_pem(&pem).map_err(|e| format!("idp cert PEM {rel}: {e:?}"))?
}
(None, Some(c)) => c,
(None, None) => {
return Err(
"no cleartext <X509Certificate> and no idp_cert_pem_path override".to_string(),
);
}
};
let acs_url = fx
.acs_url_override
.map(str::to_owned)
.or_else(|| meta.destination.clone())
.unwrap_or_else(|| "https://sp.example.com/acs".to_string());
let audience = fx
.sp_entity_id_override
.map(str::to_owned)
.or_else(|| meta.audience.clone())
.unwrap_or_else(|| "https://sp.example.com/metadata".to_string());
let policy = if fx.weak_algos {
permissive_policy()
} else {
PeerCryptoPolicy::strong_defaults()
};
let idp = IdpDescriptor {
entity_id: meta.issuer.clone(),
sso_endpoints: vec![Endpoint::post("https://example.invalid/sso", 0, true)],
slo_endpoints: vec![],
artifact_resolution_endpoints: vec![],
signing_certs: vec![idp_cert],
encryption_certs: vec![],
supported_name_id_formats: vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress],
want_authn_requests_signed: false,
valid_until: None,
cache_duration: None,
};
#[cfg(feature = "xmlenc")]
let decryption_key = match fx.sp_decryption_key_pkcs1_pem_path {
Some(rel) => {
let pem =
std::fs::read(corpus_path(rel)).map_err(|e| format!("read sp key {rel}: {e}"))?;
Some(KeyPair::from_pkcs1_pem(&pem).map_err(|e| format!("sp key PKCS#1 {rel}: {e:?}"))?)
}
None => None,
};
#[cfg(not(feature = "xmlenc"))]
let decryption_key: Option<saml::crypto::keypair::KeyPair> = {
let _ = fx.sp_decryption_key_pkcs1_pem_path;
None
};
let sp_cfg = ServiceProviderConfig {
entity_id: audience,
acs: vec![SsoResponseEndpoint::post(acs_url.as_str(), 0, true)],
slo: vec![],
name_id_formats: vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress],
signing_key: None,
decryption_key,
sign_authn_requests: false,
want_signed: SpWantSigned {
response: false,
assertions: false,
},
allow_unsolicited: fx.allow_unsolicited,
#[cfg(feature = "slo")]
logout_signing: saml::SpLogoutSigning::default(),
#[cfg(feature = "slo")]
logout_want_signed: saml::SpLogoutWantSigned::default(),
default_peer_crypto_policy: policy,
outbound_signature_algorithm: SignatureAlgorithm::RsaSha256,
outbound_digest_algorithm: DigestAlgorithm::Sha256,
};
let sp = ServiceProvider::new(sp_cfg).map_err(|e| format!("sp build: {e:?}"))?;
let now = meta
.issue_instant
.checked_add(Duration::from_secs(1))
.ok_or_else(|| "issue_instant + 1s overflowed SystemTime".to_string())?;
let tracker_owned = meta
.in_response_to
.as_deref()
.map(|in_response_to| LoginTracker {
request_id: in_response_to.to_owned(),
issued_at: meta.issue_instant,
idp_entity_id: meta.issuer.clone(),
acs_endpoint: SsoResponseEndpoint::post(acs_url.as_str(), 0, true),
requested_authn_context: None,
requested_name_id_format: None,
});
sp.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: tracker_owned.as_ref(),
expected_destination: acs_url.as_str(),
now,
clock_skew: Duration::from_mins(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
})
.map_err(|e| format!("consume_response: {e:?}"))
}
macro_rules! corpus_test {
($name:ident, $idx:expr) => {
#[test]
fn $name() {
let fx = &FIXTURES[$idx];
let result = run_fixture(fx);
match (fx.expected, result) {
(Expected::Ok, Ok(_)) => {}
(Expected::Reject, Err(_)) => {}
(Expected::Ok, Err(e)) => panic!(
"[{}] expected Ok, got Err: {e}\n fixture: {}",
fx.label, fx.path
),
(Expected::Reject, Ok(_)) => panic!(
"[{}] expected Reject, got Ok — SECURITY ISSUE if the \
fixture is an XSW / XXE / expired / audience-mismatch \
case\n fixture: {}",
fx.label, fx.path
),
}
}
};
}
corpus_test!(c01_adfs_sha256, 0);
corpus_test!(c02_adfs_sha384, 1);
corpus_test!(c03_adfs_sha512, 2);
corpus_test!(c04_adfs_sha1, 3);
corpus_test!(c05_xsw_wrapped, 4);
corpus_test!(c06_xsw_assertion_wrapped, 5);
corpus_test!(c07_node_text_attack_1, 6);
corpus_test!(c08_node_text_attack_2, 7);
corpus_test!(c09_node_text_attack_3, 8);
corpus_test!(c10_xxe, 9);
corpus_test!(c11_no_signature_ns, 10);
corpus_test!(c12_unsigned, 11);
corpus_test!(c13_expired, 12);
corpus_test!(c14_no_audience, 13);
corpus_test!(c15_double_signed, 14);
corpus_test!(c16_signed_message_response, 15);
corpus_test!(c17_signed_assertion_response, 16);
#[cfg(feature = "xmlenc")]
corpus_test!(c18_py3_encrypted_missing_recipient, 17);
#[cfg(feature = "xmlenc")]
corpus_test!(c19_py3_encrypted_issuer_mismatch, 18);
const POST_ENC: usize = if cfg!(feature = "xmlenc") { 19 } else { 17 };
corpus_test!(c21_ds_namespace_at_root, POST_ENC);
corpus_test!(c22_signed_assertion_2, POST_ENC + 1);
corpus_test!(c24_no_signature_explicit, POST_ENC + 2);
corpus_test!(c25_multiple_assertions, POST_ENC + 3);
corpus_test!(c26_empty_destination, POST_ENC + 4);
corpus_test!(c27_ruby_multiple_signed, POST_ENC + 5);
corpus_test!(c28_ruby_invalid_signed_element, POST_ENC + 6);
corpus_test!(c29_ruby_concealed_signed_assertion, POST_ENC + 7);
corpus_test!(c30_ruby_doubled_signed_assertion, POST_ENC + 8);
corpus_test!(c31_py3_sig_wrap_attack, POST_ENC + 9);
corpus_test!(c32_py3_sig_wrap_attack2, POST_ENC + 10);
corpus_test!(c33_py3_bad_reference, POST_ENC + 11);
corpus_test!(
c34_xsw_pattern_5_assertion_in_signature_object,
POST_ENC + 12
);
corpus_test!(c35_xsw_pattern_6_substituted_subject, POST_ENC + 13);
corpus_test!(c36_xsw_pattern_8_namespace_injection, POST_ENC + 14);
#[test]
#[ignore = "diagnostic-only: prints rejection reasons for synthetic XSW patterns; invoke with --ignored --nocapture"]
fn xsw_pattern_rejection_reasons() {
for fx in FIXTURES {
if !fx.label.starts_with("xsw_pattern_") {
continue;
}
let outcome = run_fixture(fx);
println!("[{}] outcome: {:?}", fx.label, outcome);
}
}
#[test]
fn attacker_keyinfo_cert_rejected_when_idp_trusts_different_cert() {
let abs_path = corpus_path("ruby-saml/responses/adfs_response_sha256.xml");
let xml = std::fs::read(&abs_path).unwrap_or_else(|e| panic!("read {abs_path}: {e}"));
let meta = extract(&xml).unwrap_or_else(|e| panic!("extract: {e}"));
let attacker_cert_pem = std::fs::read(corpus_path("ruby-saml/certificates/ruby-saml.crt"))
.unwrap_or_else(|e| panic!("read ruby-saml.crt: {e}"));
let trusted_cert = X509Certificate::from_pem(&attacker_cert_pem)
.unwrap_or_else(|e| panic!("parse ruby-saml.crt: {e:?}"));
let wire_cert = meta
.cert
.as_ref()
.unwrap_or_else(|| panic!("ADFS fixture is supposed to embed a cert"));
assert!(
wire_cert.to_der() != trusted_cert.to_der(),
"test pre-condition broken: trusted cert equals wire cert"
);
let acs_url = meta
.destination
.clone()
.unwrap_or_else(|| "https://sp.example.com/acs".to_string());
let audience = meta
.audience
.clone()
.unwrap_or_else(|| "https://sp.example.com/metadata".to_string());
let idp = IdpDescriptor {
entity_id: meta.issuer.clone(),
sso_endpoints: vec![Endpoint::post("https://example.invalid/sso", 0, true)],
slo_endpoints: vec![],
artifact_resolution_endpoints: vec![],
signing_certs: vec![trusted_cert],
encryption_certs: vec![],
supported_name_id_formats: vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress],
want_authn_requests_signed: false,
valid_until: None,
cache_duration: None,
};
let sp_cfg = ServiceProviderConfig {
entity_id: audience,
acs: vec![SsoResponseEndpoint::post(acs_url.as_str(), 0, true)],
slo: vec![],
name_id_formats: vec![NameIdFormat::Persistent, NameIdFormat::EmailAddress],
signing_key: None,
decryption_key: None,
sign_authn_requests: false,
want_signed: SpWantSigned {
response: false,
assertions: false,
},
allow_unsolicited: true,
#[cfg(feature = "slo")]
logout_signing: saml::SpLogoutSigning::default(),
#[cfg(feature = "slo")]
logout_want_signed: saml::SpLogoutWantSigned::default(),
default_peer_crypto_policy: PeerCryptoPolicy::strong_defaults(),
outbound_signature_algorithm: SignatureAlgorithm::RsaSha256,
outbound_digest_algorithm: DigestAlgorithm::Sha256,
};
let sp = ServiceProvider::new(sp_cfg).unwrap_or_else(|e| panic!("sp build: {e:?}"));
let now = meta
.issue_instant
.checked_add(Duration::from_secs(1))
.unwrap_or_else(|| panic!("issue_instant + 1s overflowed SystemTime"));
let tracker_owned = meta
.in_response_to
.as_deref()
.map(|in_response_to| LoginTracker {
request_id: in_response_to.to_owned(),
issued_at: meta.issue_instant,
idp_entity_id: meta.issuer.clone(),
acs_endpoint: SsoResponseEndpoint::post(acs_url.as_str(), 0, true),
requested_authn_context: None,
requested_name_id_format: None,
});
let result = sp.consume_response(ConsumeResponse {
idp: &idp,
peer_crypto_policy: None,
saml_response: &xml,
binding: SsoResponseBinding::HttpPost,
relay_state: None,
tracker: tracker_owned.as_ref(),
expected_destination: acs_url.as_str(),
now,
clock_skew: Duration::from_mins(30),
replay_cache: None,
replay_mode: ReplayMode::All,
holder_of_key_cert: None,
});
match result {
Ok(_) => panic!(
"CVE: SP accepted a Response signed by a cert NOT in \
IdpDescriptor.signing_certs. The KeyInfo cert was trusted \
implicitly, defeating the trust anchor."
),
Err(
Error::SignatureVerification { .. }
| Error::NoPeerSigningCert
| Error::DisallowedAlgorithm { .. },
) => {
}
Err(other) => panic!(
"expected SignatureVerification / NoPeerSigningCert / \
DisallowedAlgorithm, got: {other:?}"
),
}
}