1use std::fmt;
9
10use thiserror::Error;
11
12use crate::api::headers::options::{HeaderNameDiagnostic, HeaderNamesDiagnostic, MethodDiagnostic};
13use rvoip_sip_core::types::sdp::CryptoSuite;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum SdesNegotiationStage {
19 RemoteOffer,
21 RemoteAnswer,
23}
24
25impl fmt::Display for SdesNegotiationStage {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 formatter.write_str(match self {
28 Self::RemoteOffer => "remote-offer",
29 Self::RemoteAnswer => "remote-answer",
30 })
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum SdesNegotiationFailureClass {
38 InvalidBase64,
40 DecodedLength,
42}
43
44impl fmt::Display for SdesNegotiationFailureClass {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter.write_str(match self {
47 Self::InvalidBase64 => "invalid-base64",
48 Self::DecodedLength => "decoded-length",
49 })
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum SdesBase64Padding {
57 CanonicalPadded,
59 CanonicalUnpadded,
61 OmittedTrailing,
63 Malformed,
65}
66
67impl fmt::Display for SdesBase64Padding {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 formatter.write_str(match self {
70 Self::CanonicalPadded => "canonical-padded",
71 Self::CanonicalUnpadded => "canonical-unpadded",
72 Self::OmittedTrailing => "omitted-trailing",
73 Self::Malformed => "malformed",
74 })
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct SdesNegotiationDiagnostic {
85 pub stage: SdesNegotiationStage,
87 pub failure_class: SdesNegotiationFailureClass,
89 pub tag: u32,
91 pub suite: CryptoSuite,
93 pub encoded_bytes: usize,
95 pub padding: SdesBase64Padding,
97 pub expected_decoded_bytes: usize,
99 pub actual_decoded_bytes: Option<usize>,
101}
102
103impl fmt::Display for SdesNegotiationDiagnostic {
104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105 let actual = self
106 .actual_decoded_bytes
107 .map(|bytes| bytes.to_string())
108 .unwrap_or_else(|| "unknown".to_string());
109 write!(
110 formatter,
111 "SDES negotiation failed (stage={}, class={}, tag={}, suite={:?}, encoded_bytes={}, padding={}, expected_decoded_bytes={}, actual_decoded_bytes={actual})",
112 self.stage,
113 self.failure_class,
114 self.tag,
115 self.suite,
116 self.encoded_bytes,
117 self.padding,
118 self.expected_decoded_bytes
119 )
120 }
121}
122
123pub type Result<T> = std::result::Result<T, SessionError>;
125
126struct TextDiagnostic<'a>(&'a str);
127
128impl fmt::Display for TextDiagnostic<'_> {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 write!(formatter, "redacted(bytes={})", self.0.len())
131 }
132}
133
134impl fmt::Debug for TextDiagnostic<'_> {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 fmt::Display::fmt(self, formatter)
137 }
138}
139
140#[derive(Error)]
142pub enum SessionError {
143 #[error("Session not found: {}", TextDiagnostic(.0))]
145 SessionNotFound(String),
146
147 #[error("Invalid state transition: {}", TextDiagnostic(.0))]
149 InvalidTransition(String),
150
151 #[error("Dialog error: {}", TextDiagnostic(.0))]
153 DialogError(String),
154
155 #[error("Media error: {}", TextDiagnostic(.0))]
157 MediaError(String),
158
159 #[error("Media integration error: {}", TextDiagnostic(.reason))]
161 MediaIntegration {
162 reason: String,
164 },
165
166 #[error("SDP negotiation failed: {}", TextDiagnostic(.0))]
168 SDPNegotiationFailed(String),
169
170 #[error("Configuration error: {}", TextDiagnostic(.0))]
172 ConfigurationError(String),
173
174 #[error("Config error: {}", TextDiagnostic(.0))]
176 ConfigError(String),
177
178 #[error("Invalid input: {}", TextDiagnostic(.0))]
180 InvalidInput(String),
181
182 #[error("Timeout: {}", TextDiagnostic(.0))]
184 Timeout(String),
185
186 #[error("Network error: {}", TextDiagnostic(.0))]
188 NetworkError(String),
189
190 #[error("Protocol error: {}", TextDiagnostic(.0))]
192 ProtocolError(String),
193
194 #[error("peer did not advertise 100rel; cannot send reliable 183")]
199 UnreliableProvisionalsNotSupported,
200
201 #[error("server challenged INVITE but no credentials are on file")]
206 MissingCredentialsForInviteAuth,
207
208 #[error(
211 "server challenged {} but no credentials are on file",
212 MethodDiagnostic(.method)
213 )]
214 MissingCredentialsForRequestAuth {
215 method: rvoip_sip_core::Method,
217 },
218
219 #[error("INVITE auth retry limit exceeded")]
223 InviteAuthRetryExhausted,
224
225 #[error("{} auth retry limit exceeded", MethodDiagnostic(.method))]
228 RequestAuthRetryExhausted {
229 method: rvoip_sip_core::Method,
231 },
232
233 #[error("Internal error: {}", TextDiagnostic(.0))]
235 InternalError(String),
236
237 #[error("I/O operation failed")]
239 IoError(#[from] std::io::Error),
240
241 #[error("Not implemented: {}", TextDiagnostic(.0))]
243 NotImplemented(String),
244
245 #[error("Transfer failed: {}", TextDiagnostic(.0))]
247 TransferFailed(String),
248
249 #[error("Authentication error: {}", AuthErrorDiagnostic(.0))]
256 AuthError(String),
257
258 #[error("outbound INVITE authentication failed (class=challenge-response)")]
261 InviteAuthConstructionFailed,
262
263 #[error("outbound request authentication failed (class=challenge-response)")]
266 RequestAuthConstructionFailed,
267
268 #[error("outbound REGISTER authentication failed (class=challenge-response)")]
271 RegisterAuthConstructionFailed,
272
273 #[error("Registration failed: {}", TextDiagnostic(.0))]
275 RegistrationFailed(String),
276
277 #[error("Other error: {}", TextDiagnostic(.0))]
279 Other(String),
280
281 #[error(
287 "header policy violation on {}: {} — {reason}",
288 MethodDiagnostic(.method),
289 HeaderNameDiagnostic(.header)
290 )]
291 HeaderPolicy {
292 method: rvoip_sip_core::Method,
294 header: rvoip_sip_core::types::headers::HeaderName,
296 reason: crate::api::headers::ViolationReason,
298 },
299
300 #[error(
304 "required application header(s) missing for {}: {:?}",
305 MethodDiagnostic(.method),
306 HeaderNamesDiagnostic(.names)
307 )]
308 MissingRequiredHeader {
309 method: rvoip_sip_core::Method,
311 names: Vec<rvoip_sip_core::types::headers::HeaderName>,
313 },
314
315 #[error(
322 "another {} is already in flight on this session",
323 MethodDiagnostic(.method)
324 )]
325 Conflict {
326 method: rvoip_sip_core::Method,
328 },
329}
330
331impl fmt::Debug for SessionError {
335 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336 match self {
337 Self::SessionNotFound(value) => formatter
338 .debug_tuple("SessionNotFound")
339 .field(&TextDiagnostic(value))
340 .finish(),
341 Self::InvalidTransition(value) => formatter
342 .debug_tuple("InvalidTransition")
343 .field(&TextDiagnostic(value))
344 .finish(),
345 Self::DialogError(value) => formatter
346 .debug_tuple("DialogError")
347 .field(&TextDiagnostic(value))
348 .finish(),
349 Self::MediaError(value) => formatter
350 .debug_tuple("MediaError")
351 .field(&TextDiagnostic(value))
352 .finish(),
353 Self::MediaIntegration { reason } => formatter
354 .debug_struct("MediaIntegration")
355 .field("reason", &TextDiagnostic(reason))
356 .finish(),
357 Self::SDPNegotiationFailed(value) => formatter
358 .debug_tuple("SDPNegotiationFailed")
359 .field(&TextDiagnostic(value))
360 .finish(),
361 Self::ConfigurationError(value) => formatter
362 .debug_tuple("ConfigurationError")
363 .field(&TextDiagnostic(value))
364 .finish(),
365 Self::ConfigError(value) => formatter
366 .debug_tuple("ConfigError")
367 .field(&TextDiagnostic(value))
368 .finish(),
369 Self::InvalidInput(value) => formatter
370 .debug_tuple("InvalidInput")
371 .field(&TextDiagnostic(value))
372 .finish(),
373 Self::Timeout(value) => formatter
374 .debug_tuple("Timeout")
375 .field(&TextDiagnostic(value))
376 .finish(),
377 Self::NetworkError(value) => formatter
378 .debug_tuple("NetworkError")
379 .field(&TextDiagnostic(value))
380 .finish(),
381 Self::ProtocolError(value) => formatter
382 .debug_tuple("ProtocolError")
383 .field(&TextDiagnostic(value))
384 .finish(),
385 Self::UnreliableProvisionalsNotSupported => {
386 formatter.write_str("UnreliableProvisionalsNotSupported")
387 }
388 Self::MissingCredentialsForInviteAuth => {
389 formatter.write_str("MissingCredentialsForInviteAuth")
390 }
391 Self::MissingCredentialsForRequestAuth { method } => formatter
392 .debug_struct("MissingCredentialsForRequestAuth")
393 .field("method", &MethodDiagnostic(method))
394 .finish(),
395 Self::InviteAuthRetryExhausted => formatter.write_str("InviteAuthRetryExhausted"),
396 Self::RequestAuthRetryExhausted { method } => formatter
397 .debug_struct("RequestAuthRetryExhausted")
398 .field("method", &MethodDiagnostic(method))
399 .finish(),
400 Self::InternalError(value) => formatter
401 .debug_tuple("InternalError")
402 .field(&TextDiagnostic(value))
403 .finish(),
404 Self::IoError(value) => formatter
405 .debug_struct("IoError")
406 .field("kind", &value.kind())
407 .finish(),
408 Self::NotImplemented(value) => formatter
409 .debug_tuple("NotImplemented")
410 .field(&TextDiagnostic(value))
411 .finish(),
412 Self::TransferFailed(value) => formatter
413 .debug_tuple("TransferFailed")
414 .field(&TextDiagnostic(value))
415 .finish(),
416 Self::AuthError(value) => formatter
417 .debug_tuple("AuthError")
418 .field(&AuthErrorDiagnostic(value))
419 .finish(),
420 Self::InviteAuthConstructionFailed => {
421 formatter.write_str("InviteAuthConstructionFailed")
422 }
423 Self::RequestAuthConstructionFailed => {
424 formatter.write_str("RequestAuthConstructionFailed")
425 }
426 Self::RegisterAuthConstructionFailed => {
427 formatter.write_str("RegisterAuthConstructionFailed")
428 }
429 Self::RegistrationFailed(value) => formatter
430 .debug_tuple("RegistrationFailed")
431 .field(&TextDiagnostic(value))
432 .finish(),
433 Self::Other(value) => formatter
434 .debug_tuple("Other")
435 .field(&TextDiagnostic(value))
436 .finish(),
437 Self::HeaderPolicy {
438 method,
439 header,
440 reason,
441 } => formatter
442 .debug_struct("HeaderPolicy")
443 .field("method", &MethodDiagnostic(method))
444 .field("header", &HeaderNameDiagnostic(header))
445 .field("reason", reason)
446 .finish(),
447 Self::MissingRequiredHeader { method, names } => formatter
448 .debug_struct("MissingRequiredHeader")
449 .field("method", &MethodDiagnostic(method))
450 .field("names", &HeaderNamesDiagnostic(names))
451 .finish(),
452 Self::Conflict { method } => formatter
453 .debug_struct("Conflict")
454 .field("method", &MethodDiagnostic(method))
455 .finish(),
456 }
457 }
458}
459
460#[derive(Clone, Copy, Debug, Eq, PartialEq)]
461pub(crate) enum OutboundAuthOperation {
462 Invite,
463 Request,
464 Register,
465}
466
467#[derive(Clone, Copy, Debug, Eq, PartialEq)]
469pub(crate) enum AuthFailureStage {
470 CorePrimitive,
471 AkaClientProvider,
472 PrincipalProjection,
473 RateLimitCheck,
474 RateLimitRecord,
475 AuditSink,
476 BearerValidator,
477 BasicVerifier,
478 AkaVectorProvider,
479 DigestSecretProvider,
480 ReplayNonceRecord,
481 ReplayNonceStatus,
482 ReplayNonceCount,
483 ErasedCredentialProvider,
484}
485
486impl AuthFailureStage {
487 const ALL: [Self; 14] = [
488 Self::CorePrimitive,
489 Self::AkaClientProvider,
490 Self::PrincipalProjection,
491 Self::RateLimitCheck,
492 Self::RateLimitRecord,
493 Self::AuditSink,
494 Self::BearerValidator,
495 Self::BasicVerifier,
496 Self::AkaVectorProvider,
497 Self::DigestSecretProvider,
498 Self::ReplayNonceRecord,
499 Self::ReplayNonceStatus,
500 Self::ReplayNonceCount,
501 Self::ErasedCredentialProvider,
502 ];
503
504 pub(crate) const fn message(self) -> &'static str {
505 match self {
506 Self::CorePrimitive => "authentication failed (stage=core-primitive)",
507 Self::AkaClientProvider => "authentication failed (stage=aka-client-provider)",
508 Self::PrincipalProjection => "authentication failed (stage=principal-projection)",
509 Self::RateLimitCheck => "authentication failed (stage=rate-limit-check)",
510 Self::RateLimitRecord => "authentication failed (stage=rate-limit-record)",
511 Self::AuditSink => "authentication failed (stage=audit-sink)",
512 Self::BearerValidator => "authentication failed (stage=bearer-validator)",
513 Self::BasicVerifier => "authentication failed (stage=basic-verifier)",
514 Self::AkaVectorProvider => "authentication failed (stage=aka-vector-provider)",
515 Self::DigestSecretProvider => "authentication failed (stage=digest-secret-provider)",
516 Self::ReplayNonceRecord => "authentication failed (stage=replay-nonce-record)",
517 Self::ReplayNonceStatus => "authentication failed (stage=replay-nonce-status)",
518 Self::ReplayNonceCount => "authentication failed (stage=replay-nonce-count)",
519 Self::ErasedCredentialProvider => {
520 "authentication failed (stage=erased-credential-provider)"
521 }
522 }
523 }
524}
525
526pub(crate) fn redacted_auth_failure<E>(stage: AuthFailureStage, _source: E) -> SessionError {
529 SessionError::AuthError(stage.message().to_string())
530}
531
532struct AuthErrorDiagnostic<'a>(&'a str);
533
534impl fmt::Display for AuthErrorDiagnostic<'_> {
535 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
536 formatter.write_str(safe_auth_error_message(self.0))
537 }
538}
539
540impl fmt::Debug for AuthErrorDiagnostic<'_> {
541 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542 fmt::Debug::fmt(safe_auth_error_message(self.0), formatter)
543 }
544}
545
546fn safe_auth_error_message(value: &str) -> &str {
547 if AuthFailureStage::ALL
548 .iter()
549 .any(|stage| stage.message() == value)
550 || matches!(
551 value,
552 "Digest credentials cannot answer a non-Digest challenge"
553 | "Bearer token cannot answer a non-Bearer challenge"
554 | "Bearer authentication over cleartext SIP is disabled"
555 | "Bearer token cannot be empty"
556 | "Basic credentials cannot answer a non-Basic challenge"
557 | "Basic authentication over cleartext SIP is disabled"
558 | "AKA credentials cannot answer a non-AKA challenge"
559 | "generated SIP authorization header failed wire-safety validation"
560 | "no configured auth option can answer the challenge"
561 | "unsupported outbound SIP authorization header name"
562 | "outbound SIP authorization header failed wire-safety validation"
563 )
564 {
565 value
566 } else {
567 "authentication failed (stage=opaque-source)"
568 }
569}
570
571pub(crate) fn redacted_outbound_auth_error<E>(
574 operation: OutboundAuthOperation,
575 _source: E,
576) -> SessionError {
577 match operation {
578 OutboundAuthOperation::Invite => SessionError::InviteAuthConstructionFailed,
579 OutboundAuthOperation::Request => SessionError::RequestAuthConstructionFailed,
580 OutboundAuthOperation::Register => SessionError::RegisterAuthConstructionFailed,
581 }
582}
583
584impl From<crate::api::headers::HeaderPolicyViolation> for SessionError {
585 fn from(v: crate::api::headers::HeaderPolicyViolation) -> Self {
586 SessionError::HeaderPolicy {
587 method: v.method,
588 header: v.header,
589 reason: v.reason,
590 }
591 }
592}
593
594impl SessionError {
595 pub fn is_session_gone(&self) -> bool {
603 matches!(self, SessionError::SessionNotFound(_))
604 || matches!(self, SessionError::Other(msg) if msg.starts_with("Session not found"))
605 || matches!(self, SessionError::Other(msg) if msg.starts_with("Session ") && msg.ends_with(" not found"))
606 }
607}
608
609impl From<Box<dyn std::error::Error>> for SessionError {
610 fn from(err: Box<dyn std::error::Error>) -> Self {
611 let err = match err.downcast::<SessionError>() {
612 Ok(error) => return *error,
613 Err(error) => error,
614 };
615 let err = match err.downcast::<rvoip_auth_core::AuthError>() {
616 Ok(error) => return SessionError::from(*error),
617 Err(error) => error,
618 };
619 if err
620 .downcast_ref::<rvoip_auth_core::CredentialAuthError>()
621 .is_some()
622 {
623 return redacted_auth_failure(AuthFailureStage::ErasedCredentialProvider, ());
624 }
625 SessionError::Other("lower-layer operation failed (class=opaque-erased)".to_string())
626 }
627}
628
629impl From<Box<dyn std::error::Error + Send + Sync>> for SessionError {
630 fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
631 let err = match err.downcast::<SessionError>() {
632 Ok(error) => return *error,
633 Err(error) => error,
634 };
635 let err = match err.downcast::<rvoip_auth_core::AuthError>() {
636 Ok(error) => return SessionError::from(*error),
637 Err(error) => error,
638 };
639 if err
640 .downcast_ref::<rvoip_auth_core::CredentialAuthError>()
641 .is_some()
642 {
643 return redacted_auth_failure(AuthFailureStage::ErasedCredentialProvider, ());
644 }
645 SessionError::Other("lower-layer operation failed (class=opaque-erased)".to_string())
646 }
647}
648
649impl From<rvoip_auth_core::AuthError> for SessionError {
650 fn from(err: rvoip_auth_core::AuthError) -> Self {
651 redacted_auth_failure(AuthFailureStage::CorePrimitive, err)
652 }
653}
654
655#[cfg(test)]
656mod method_diagnostic_tests {
657 use super::*;
658 use crate::api::headers::ViolationReason;
659 use rvoip_sip_core::types::headers::HeaderName;
660 use rvoip_sip_core::Method;
661
662 const METHOD_CANARY: &str = "CUSTOM\r\nX-Method-Canary: exposed";
663 const HEADER_CANARY: &str = "X-Header-Canary\r\nInjected";
664
665 fn assert_redacted(error: &SessionError) {
666 for rendered in [error.to_string(), format!("{error:?}")] {
667 assert!(
668 !rendered.contains(METHOD_CANARY),
669 "extension method leaked: {rendered}"
670 );
671 assert!(
672 !rendered.contains(HEADER_CANARY),
673 "custom header leaked: {rendered}"
674 );
675 }
676 }
677
678 #[test]
679 fn every_method_bearing_error_redacts_extension_spelling() {
680 let extension = || Method::Extension(METHOD_CANARY.to_string());
681 let errors = [
682 SessionError::MissingCredentialsForRequestAuth {
683 method: extension(),
684 },
685 SessionError::RequestAuthRetryExhausted {
686 method: extension(),
687 },
688 SessionError::HeaderPolicy {
689 method: extension(),
690 header: HeaderName::Other(HEADER_CANARY.to_string()),
691 reason: ViolationReason::StackManaged,
692 },
693 SessionError::MissingRequiredHeader {
694 method: extension(),
695 names: vec![HeaderName::Other(HEADER_CANARY.to_string())],
696 },
697 SessionError::Conflict {
698 method: extension(),
699 },
700 ];
701
702 for error in &errors {
703 assert_redacted(error);
704 let display = error.to_string();
705 let debug = format!("{error:?}");
706 assert!(display.contains(&format!("extension(len={})", METHOD_CANARY.len())));
707 assert!(debug.contains(&format!("value_len: {}", METHOD_CANARY.len())));
708 }
709
710 match &errors[2] {
711 SessionError::HeaderPolicy { method, header, .. } => {
712 assert_eq!(method, &Method::Extension(METHOD_CANARY.to_string()));
713 assert_eq!(header, &HeaderName::Other(HEADER_CANARY.to_string()));
714 }
715 other => panic!("unexpected error: {other:?}"),
716 }
717 }
718
719 #[test]
720 fn fixed_error_debug_shapes_remain_derived_compatible() {
721 assert_eq!(
722 format!("{:?}", SessionError::SessionNotFound("call-1".to_string())),
723 "SessionNotFound(redacted(bytes=6))"
724 );
725 assert_eq!(
726 format!(
727 "{:?}",
728 SessionError::MissingCredentialsForRequestAuth {
729 method: Method::Invite,
730 }
731 ),
732 "MissingCredentialsForRequestAuth { method: Invite }"
733 );
734 assert_eq!(
735 SessionError::Conflict {
736 method: Method::Message,
737 }
738 .to_string(),
739 "another MESSAGE is already in flight on this session"
740 );
741 }
742}
743
744#[cfg(test)]
745mod auth_error_diagnostic_tests {
746 use super::*;
747
748 const SOURCE_CANARY: &str = "provider-secret\r\nX-Auth-Canary: exposed";
749
750 #[test]
751 fn arbitrary_public_auth_error_value_is_matchable_but_never_rendered() {
752 let error = SessionError::AuthError(SOURCE_CANARY.to_string());
753 assert_eq!(
754 error.to_string(),
755 "Authentication error: authentication failed (stage=opaque-source)"
756 );
757 assert_eq!(
758 format!("{error:?}"),
759 "AuthError(\"authentication failed (stage=opaque-source)\")"
760 );
761 assert!(!error.to_string().contains(SOURCE_CANARY));
762 assert!(!format!("{error:?}").contains(SOURCE_CANARY));
763 match error {
764 SessionError::AuthError(value) => assert_eq!(value, SOURCE_CANARY),
765 other => panic!("unexpected error: {other:?}"),
766 }
767 }
768
769 #[test]
770 fn auth_core_conversion_discards_lower_diagnostic_content() {
771 let error = SessionError::from(rvoip_auth_core::AuthError::ProviderError(
772 SOURCE_CANARY.to_string(),
773 ));
774 assert_eq!(
775 error.to_string(),
776 "Authentication error: authentication failed (stage=core-primitive)"
777 );
778 assert!(!error.to_string().contains(SOURCE_CANARY));
779 assert!(!format!("{error:?}").contains(SOURCE_CANARY));
780 assert!(matches!(
781 error,
782 SessionError::AuthError(value)
783 if value == AuthFailureStage::CorePrimitive.message()
784 ));
785 }
786
787 #[test]
788 fn fixed_library_auth_policy_messages_remain_actionable() {
789 let error = SessionError::AuthError(
790 "Basic authentication over cleartext SIP is disabled".to_string(),
791 );
792 assert!(error.to_string().contains("cleartext SIP"));
793 assert!(format!("{error:?}").contains("cleartext SIP"));
794 }
795}
796
797#[cfg(test)]
798mod outbound_auth_redaction_tests {
799 use super::*;
800 use crate::auth::{
801 AkaClientConfig, AkaClientProvider, SipClientAuth, SipTransportSecurityContext,
802 };
803 use std::sync::Arc;
804
805 const ALGORITHM_SECRET: &str = "MALICIOUS-ALGORITHM-CANARY";
806 const PROVIDER_SECRET: &str = "ARBITRARY-AKA-PROVIDER-CANARY";
807
808 struct FailingAkaProvider;
809
810 impl AkaClientProvider for FailingAkaProvider {
811 fn authorization(
812 &self,
813 _challenge_header: &str,
814 _method: &str,
815 _request_uri: &str,
816 _nonce_count: u32,
817 ) -> Result<String> {
818 Err(SessionError::AuthError(format!(
819 "AKA provider failure: {PROVIDER_SECRET}"
820 )))
821 }
822 }
823
824 fn assert_redacted(error: SessionError, secret: &str) {
825 let display = error.to_string();
826 let debug = format!("{error:?}");
827 for rendered in [&display, &debug] {
828 assert!(!rendered.contains(secret), "auth source leaked: {rendered}");
829 }
830 assert!(display.contains("challenge-response"));
831 assert!(matches!(error, SessionError::InviteAuthConstructionFailed));
832 }
833
834 #[test]
835 fn malicious_digest_algorithm_is_collapsed_before_retry_dispatch() {
836 let challenge = format!(
837 "Digest realm=\"pbx\", nonce=\"n1\", algorithm={ALGORITHM_SECRET}, qop=\"auth\""
838 );
839 let lower = SipClientAuth::digest("alice", "secret")
840 .authorization_for_challenge_with_transport_context(
841 &challenge,
842 "INVITE",
843 "sip:bob@example.test",
844 1,
845 None,
846 &SipTransportSecurityContext::from_transport_name("TLS"),
847 )
848 .expect_err("unsupported peer algorithm must fail");
849 assert!(!lower.to_string().contains(ALGORITHM_SECRET));
850 assert!(lower.to_string().contains("stage=core-primitive"));
851
852 assert_redacted(
853 redacted_outbound_auth_error(OutboundAuthOperation::Invite, lower),
854 ALGORITHM_SECRET,
855 );
856 }
857
858 #[test]
859 fn arbitrary_aka_provider_error_is_collapsed_before_retry_dispatch() {
860 let lower = SipClientAuth::aka(AkaClientConfig::new(Arc::new(FailingAkaProvider)))
861 .authorization_for_challenge_with_transport_context(
862 r#"Digest realm="ims", nonce="n1", algorithm=AKAv1-MD5"#,
863 "INVITE",
864 "sip:bob@example.test",
865 1,
866 None,
867 &SipTransportSecurityContext::from_transport_name("TLS"),
868 )
869 .expect_err("provider failure must fail auth construction");
870 assert!(!lower.to_string().contains(PROVIDER_SECRET));
871 assert!(lower.to_string().contains("stage=aka-client-provider"));
872
873 assert_redacted(
874 redacted_outbound_auth_error(OutboundAuthOperation::Invite, lower),
875 PROVIDER_SECRET,
876 );
877 }
878
879 #[test]
880 fn every_outbound_auth_operation_maps_to_a_fixed_typed_class() {
881 for (operation, expected) in [
882 (
883 OutboundAuthOperation::Invite,
884 SessionError::InviteAuthConstructionFailed,
885 ),
886 (
887 OutboundAuthOperation::Request,
888 SessionError::RequestAuthConstructionFailed,
889 ),
890 (
891 OutboundAuthOperation::Register,
892 SessionError::RegisterAuthConstructionFailed,
893 ),
894 ] {
895 let mapped = redacted_outbound_auth_error(
896 operation,
897 SessionError::AuthError(PROVIDER_SECRET.to_string()),
898 );
899 assert_eq!(mapped.to_string(), expected.to_string());
900 assert!(!mapped.to_string().contains(PROVIDER_SECRET));
901 }
902 }
903}
904
905#[cfg(test)]
906mod erased_error_boundary_tests {
907 use super::*;
908
909 const CANARY: &str = "erased-lower-error-canary\r\nAuthorization: exposed";
910
911 #[derive(Debug)]
912 struct ArbitraryLowerError;
913
914 impl fmt::Display for ArbitraryLowerError {
915 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
916 formatter.write_str(CANARY)
917 }
918 }
919
920 impl std::error::Error for ArbitraryLowerError {}
921
922 fn assert_no_canary(error: &SessionError) {
923 for rendered in [error.to_string(), format!("{error:?}")] {
924 assert!(!rendered.contains(CANARY), "lower error leaked: {rendered}");
925 }
926 }
927
928 #[test]
929 fn arbitrary_erased_errors_collapse_to_fixed_class() {
930 let plain: SessionError =
931 (Box::new(ArbitraryLowerError) as Box<dyn std::error::Error>).into();
932 let send_sync: SessionError =
933 (Box::new(ArbitraryLowerError) as Box<dyn std::error::Error + Send + Sync>).into();
934 for error in [plain, send_sync] {
935 assert_no_canary(&error);
936 assert!(matches!(
937 error,
938 SessionError::Other(ref value)
939 if value == "lower-layer operation failed (class=opaque-erased)"
940 ));
941 }
942 }
943
944 #[test]
945 fn erased_auth_errors_are_downcast_and_classified() {
946 let core: SessionError =
947 (Box::new(rvoip_auth_core::AuthError::ProviderError(CANARY.into()))
948 as Box<dyn std::error::Error>)
949 .into();
950 let provider = Box::new(rvoip_auth_core::CredentialAuthError::Unavailable(
951 CANARY.into(),
952 )) as Box<dyn std::error::Error + Send + Sync>;
953 let provider = SessionError::from(provider);
954
955 assert_no_canary(&core);
956 assert_no_canary(&provider);
957 assert!(matches!(
958 core,
959 SessionError::AuthError(ref value)
960 if value == "authentication failed (stage=core-primitive)"
961 ));
962 assert!(matches!(
963 provider,
964 SessionError::AuthError(ref value)
965 if value == "authentication failed (stage=erased-credential-provider)"
966 ));
967 }
968
969 #[test]
970 fn boxed_session_errors_keep_their_typed_variant() {
971 let error: SessionError = (Box::new(SessionError::SessionNotFound("call-1".into()))
972 as Box<dyn std::error::Error + Send + Sync>)
973 .into();
974 assert!(matches!(error, SessionError::SessionNotFound(ref id) if id == "call-1"));
975 }
976}