From 620c649f4497d627c22948f115feb28faa22682b Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 03:51:02 -0600 Subject: [PATCH 1/2] feat(sip): per-endpoint auth selection (M6 CP4) Replace the all-credentials UNION challenge with per-endpoint auth SELECTION: match the inbound request to an endpoint (via type=identify) and issue a digest challenge ONLY if the MATCHED endpoint has an auth section. An unauthenticated trunk (matched, no auth) and an authenticated bridge (matched, auth) now coexist on one transport. Falls back to the union only when no identify match exists (no type=identify configured), preserving the pre-CP4 default. Selection is strictly narrower than the old union (which accepted ANY configured credential), so an authed endpoint is never weakened. Receiver-side proof (e2e_per_endpoint_auth.rs): a trunk request (source 127.0.0.2, no auth) is accepted with NO 401 datagram; a bridge request (source 127.0.0.3, auth) WITHOUT creds gets a 401 datagram and is rejected, and WITH a valid digest is accepted. RED: revert to the union -> the trunk is challenged (returns None) -> scenario (a) goes RED. --- .../tests/e2e_per_endpoint_auth.rs | 250 ++++++++++++++++++ crates/asterisk-sip/src/event_handler.rs | 63 +++-- 2 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs diff --git a/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs b/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs new file mode 100644 index 0000000..b0e040f --- /dev/null +++ b/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs @@ -0,0 +1,250 @@ +//! End-to-end acceptance for M6 CP4: per-endpoint auth SELECTION. +//! +//! rustisk used to challenge an inbound request against the UNION of every +//! configured credential — so if ANY endpoint had auth, EVERY request was +//! challenged. That breaks the load-bearing topology where an UNAUTHENTICATED +//! trunk (a carrier, matched by source IP, no digest) and an AUTHENTICATED +//! bridge (matched by source IP, digest required) share one transport. +//! +//! CP4 matches the inbound request to an endpoint (via `type=identify`) and +//! issues a digest challenge ONLY if the MATCHED endpoint has an auth section. +//! +//! Receiver-side proofs (assert on the actual response datagram + accept/reject +//! outcome, never a log line): +//! (a) a request matched to the unauth trunk endpoint is NOT challenged and is +//! accepted (no 401 datagram; handler accepts it); +//! (b) a request matched to the authed bridge endpoint IS challenged (a 401 +//! datagram) and is rejected without valid creds, and accepted with them. +//! +//! RED control (captured in the PR body): revert to the all-credentials union +//! (challenge whenever any endpoint has auth) -> the unauth trunk is challenged +//! with a 401 -> scenario (a) goes RED. + +use std::net::SocketAddr; +use std::time::{Duration, Instant}; +use std::sync::Arc; + +use asterisk_apps::adapter::register_all_apps; +use asterisk_codecs::codecs; +use asterisk_core::channel::tech_registry::TECH_REGISTRY; +use asterisk_core::pbx::{Context, Dialplan, Extension, Priority}; +use asterisk_sip::auth::{create_digest_response, DigestChallenge, DigestCredentials}; +use asterisk_sip::channel_driver::SipChannelDriver; +use asterisk_sip::event_handler::SipEventHandler; +use asterisk_sip::parser::{header_names, SipMessage}; +use asterisk_sip::pjsip_config::{ + set_global_pjsip_config, AuthConfig, EndpointConfig, IdentifyConfig, PjsipConfig, +}; +use asterisk_sip::sdp::SessionDescription; +use asterisk_sip::session::SipSession; +use asterisk_sip::transport::UdpTransport; +use tokio::net::UdpSocket; + +const EXTEN: &str = "100"; +const BRIDGE_USER: &str = "bridgeuser"; +const BRIDGE_PASS: &str = "bridgepass"; + +/// Collect every distinct status code seen within the budget. +async fn collect_status_codes(sock: &UdpSocket, budget: Duration) -> Vec { + let deadline = Instant::now() + budget; + let mut buf = [0u8; 4096]; + let mut seen = Vec::new(); + while Instant::now() < deadline { + if let Ok(Ok((len, _))) = + tokio::time::timeout(Duration::from_millis(250), sock.recv_from(&mut buf)).await + { + if let Ok(msg) = SipMessage::parse(&buf[..len]) { + if let Some(code) = msg.status_code() { + if !seen.contains(&code) { + seen.push(code); + } + } + } + } + } + seen +} + +/// Wait for a specific status code (skipping others). +async fn recv_status(sock: &UdpSocket, status: u16, budget: Duration) -> Option { + let deadline = Instant::now() + budget; + let mut buf = [0u8; 4096]; + while Instant::now() < deadline { + if let Ok(Ok((len, _))) = + tokio::time::timeout(Duration::from_millis(250), sock.recv_from(&mut buf)).await + { + if let Ok(msg) = SipMessage::parse(&buf[..len]) { + if msg.status_code() == Some(status) { + return Some(msg); + } + } + } + } + None +} + +fn invite_request(call_id: &str, contact_port: u16, sdp: &str, auth: Option<&str>) -> SipMessage { + let auth_line = auth + .map(|a| format!("Authorization: {a}\r\n")) + .unwrap_or_default(); + let raw = format!( + "INVITE sip:{EXTEN}@127.0.0.1 SIP/2.0\r\n\ + Via: SIP/2.0/UDP 127.0.0.1:{contact_port};branch=z9hG4bK{call_id}inv\r\n\ + From: \"Caller\" ;tag=caller{call_id}\r\n\ + To: \r\n\ + Call-ID: {call_id}\r\n\ + CSeq: 1 INVITE\r\n\ + Contact: \r\n\ + {auth_line}\ + Content-Type: application/sdp\r\n\ + Content-Length: {len}\r\n\ + \r\n\ + {sdp}", + len = sdp.len() + ); + SipMessage::parse(raw.as_bytes()).unwrap() +} + +/// A trunk (no auth) and a bridge (digest auth) identified by distinct source +/// IPs on one transport. +fn coexist_config() -> PjsipConfig { + PjsipConfig { + endpoints: vec![ + EndpointConfig { + name: "trunk".to_string(), + context: "default".to_string(), + auth: None, + ..Default::default() + }, + EndpointConfig { + name: "bridge".to_string(), + context: "default".to_string(), + auth: Some("bridgeauth".to_string()), + ..Default::default() + }, + ], + auths: vec![AuthConfig { + name: "bridgeauth".to_string(), + auth_type: "userpass".to_string(), + username: BRIDGE_USER.to_string(), + password: BRIDGE_PASS.to_string(), + ..Default::default() + }], + identifies: vec![ + IdentifyConfig { + name: "id-trunk".to_string(), + endpoint: "trunk".to_string(), + matches: vec!["127.0.0.2/32".to_string()], + match_header: None, + }, + IdentifyConfig { + name: "id-bridge".to_string(), + endpoint: "bridge".to_string(), + matches: vec!["127.0.0.3/32".to_string()], + match_header: None, + }, + ], + ..Default::default() + } +} + +fn dialplan() -> Dialplan { + let mut dp = Dialplan::new(); + let mut ctx = Context::new("default"); + let mut ext = Extension::new(EXTEN); + ext.add_priority(Priority { + priority: 1, + app: "Answer".to_string(), + app_data: String::new(), + label: None, + }); + ctx.add_extension(ext); + dp.add_context(ctx); + dp +} + +#[tokio::test] +async fn unauth_trunk_and_authed_bridge_coexist() { + register_all_apps(); + set_global_pjsip_config(coexist_config()); + + let handler_transport: Arc = Arc::new( + UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(), + ); + let sip_local: SocketAddr = handler_transport.local_addr().unwrap(); + let driver = Arc::new(SipChannelDriver::new(sip_local)); + driver.set_transport(handler_transport.clone()); + TECH_REGISTRY.register(driver.clone()); + let handler = Arc::new(SipEventHandler::new(Arc::new(dialplan()), handler_transport)); + handler.set_channel_driver(driver.clone()); + + let offer = SessionDescription::create_offer("127.0.0.1", 40000, &[codecs::pcmu()]); + + // ---- (a) trunk (source 127.0.0.2, no auth) -> NOT challenged ---------- + let trunk_sock = UdpSocket::bind("127.0.0.2:0").await.unwrap(); + let trunk_addr = trunk_sock.local_addr().unwrap(); + let inv = invite_request("cp4-trunk", trunk_addr.port(), &offer.to_string(), None); + let session = SipSession::new_inbound(&inv, sip_local, trunk_addr).expect("session"); + let accepted = handler.handle_incoming_invite(&inv, trunk_addr, session).await; + assert_eq!( + accepted.as_deref(), + Some("cp4-trunk"), + "the unauth trunk endpoint must be accepted without a challenge" + ); + let codes = collect_status_codes(&trunk_sock, Duration::from_secs(2)).await; + assert!( + !codes.contains(&401) && !codes.contains(&407), + "the unauth trunk endpoint must NOT be challenged; saw {codes:?}" + ); + println!("[E2E] (a) unauth trunk (127.0.0.2) accepted, NOT challenged; responses={codes:?}"); + + // ---- (b) bridge (source 127.0.0.3, auth) WITHOUT creds -> 401 --------- + let bridge_sock = UdpSocket::bind("127.0.0.3:0").await.unwrap(); + let bridge_addr = bridge_sock.local_addr().unwrap(); + let inv = invite_request("cp4-bridge-noauth", bridge_addr.port(), &offer.to_string(), None); + let session = SipSession::new_inbound(&inv, sip_local, bridge_addr).expect("session"); + let accepted = handler.handle_incoming_invite(&inv, bridge_addr, session).await; + assert_eq!( + accepted, None, + "the authed bridge endpoint must NOT be accepted without valid creds" + ); + let challenge_resp = recv_status(&bridge_sock, 401, Duration::from_secs(2)) + .await + .expect("the authed bridge endpoint must be challenged with a 401 datagram"); + let www = challenge_resp + .get_header(header_names::WWW_AUTHENTICATE) + .expect("401 must carry WWW-Authenticate"); + let challenge = DigestChallenge::parse(www).expect("challenge must parse"); + println!("[E2E] (b) authed bridge (127.0.0.3) without creds -> 401 challenge"); + + // ---- (c) bridge WITH valid creds -> accepted -------------------------- + let creds = DigestCredentials { + username: BRIDGE_USER.to_string(), + password: BRIDGE_PASS.to_string(), + realm: challenge.realm.clone(), + }; + let auth = create_digest_response(&challenge, &creds, "INVITE", &format!("sip:{EXTEN}@127.0.0.1")); + let inv = invite_request( + "cp4-bridge-auth", + bridge_addr.port(), + &offer.to_string(), + Some(&auth), + ); + let session = SipSession::new_inbound(&inv, sip_local, bridge_addr).expect("session"); + let accepted = handler.handle_incoming_invite(&inv, bridge_addr, session).await; + assert_eq!( + accepted.as_deref(), + Some("cp4-bridge-auth"), + "the authed bridge endpoint must be accepted WITH valid digest creds" + ); + let codes = collect_status_codes(&bridge_sock, Duration::from_secs(2)).await; + assert!( + !codes.contains(&401) && !codes.contains(&407), + "a valid-digest bridge request must NOT be re-challenged; saw {codes:?}" + ); + println!("[E2E] (c) authed bridge WITH valid creds -> accepted, not re-challenged"); + + set_global_pjsip_config(PjsipConfig::default()); +} diff --git a/crates/asterisk-sip/src/event_handler.rs b/crates/asterisk-sip/src/event_handler.rs index 567ee03..2bc2f5e 100644 --- a/crates/asterisk-sip/src/event_handler.rs +++ b/crates/asterisk-sip/src/event_handler.rs @@ -489,30 +489,59 @@ impl SipEventHandler { .unwrap_or(true); if let Some(ref cfg) = pjsip_config { - // Collect all auth credentials and their associated endpoint names - let mut all_creds: Vec<(String, AuthCredentials)> = Vec::new(); - for ep in &cfg.endpoints { - if let Some(ref auth_name) = ep.auth { - if let Some(auth) = cfg.find_auth(auth_name) { - all_creds.push(( - ep.name.clone(), - AuthCredentials::new(&auth.username, &auth.password, ""), - )); - } - } - } + // Per-endpoint auth SELECTION (M6 CP4). Challenge against the + // credential of the endpoint the request was MATCHED to (via + // type=identify), NOT the union of every configured credential. + // This lets an UNAUTHENTICATED trunk (matched, no auth section) and + // an AUTHENTICATED bridge (matched, auth section) coexist on one + // transport: + // * matched a specific endpoint -> challenge ONLY if THAT endpoint + // has an auth section; no auth section => no challenge, proceed. + // * no identify match (no type=identify configured) -> fall back + // to the union of all authed endpoints' credentials, preserving + // the pre-CP4 default and still challenging a digest endpoint. + // An authed endpoint is never weakened: it still requires a valid + // digest against its OWN credential (selection is strictly narrower + // than the old union, which accepted any configured credential). + let creds_with_names: Vec<(String, AuthCredentials)> = + match matched_endpoint_name.as_deref() { + Some(name) => cfg + .find_endpoint(name) + .and_then(|ep| ep.auth.as_ref()) + .and_then(|auth_name| cfg.find_auth(auth_name)) + .map(|auth| { + vec![( + name.to_string(), + AuthCredentials::new(&auth.username, &auth.password, ""), + )] + }) + .unwrap_or_default(), + None => cfg + .endpoints + .iter() + .filter_map(|ep| { + let auth = cfg.find_auth(ep.auth.as_ref()?)?; + Some(( + ep.name.clone(), + AuthCredentials::new(&auth.username, &auth.password, ""), + )) + }) + .collect(), + }; - if !all_creds.is_empty() { - let creds: Vec = all_creds.iter().map(|(_, c)| c.clone()).collect(); + if !creds_with_names.is_empty() { + let creds: Vec = + creds_with_names.iter().map(|(_, c)| c.clone()).collect(); let authenticator = crate::authenticator::InboundAuthenticator::new(); match authenticator.verify(request, &creds, false) { Ok(()) => { debug!(call_id = %call_id, "Auth succeeded"); - // Auth succeeded -- identify the endpoint from the auth username. - // Extract username from the Authorization header to find the matching endpoint. + // Identify the endpoint from the auth username. For the + // matched case this re-confirms the same endpoint; for + // the union fallback it selects the authenticated one. if let Some(auth_hdr) = request.get_header(crate::parser::header_names::AUTHORIZATION) { if let Some(parsed) = crate::authenticator::parse_authorization(auth_hdr) { - for (ep_name, cred) in &all_creds { + for (ep_name, cred) in &creds_with_names { if cred.username == parsed.username { if let Some(ep) = cfg.find_endpoint(ep_name) { identified_endpoint_name = Some(ep_name.clone()); From f9f8a64a83e15cadbf5c32a86266720f83d9f83d Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 04:30:52 -0600 Subject: [PATCH 2/2] fix(sip): fail closed on unresolvable matched-endpoint auth (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-endpoint selection used unwrap_or_default() on the matched endpoint's auth lookup, which collapsed two misconfigurations to an EMPTY credential set and then SKIPPED the challenge — accepting a credential-less caller (a fail-open regression vs the old union, which still challenged via other endpoints' creds): (a) the matched endpoint has auth= but no auth section named resolves (typo / removed section, dangling reference); (b) an identify's endpoint= names a non-existent endpoint. Both now fail CLOSED with 403 Forbidden. A matched endpoint with NO auth section still correctly proceeds unchallenged (the unauthenticated trunk). Adds fail-closed scenarios to e2e_per_endpoint_auth (folded into the single test since the binary shares process-global config). RED: revert to unwrap_or_default -> the dangling-auth endpoint accepts a credential-less INVITE (Some(call_id)). --- .../tests/e2e_per_endpoint_auth.rs | 88 +++++++++++++++++++ crates/asterisk-sip/src/event_handler.rs | 54 +++++++++--- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs b/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs index b0e040f..1de88bb 100644 --- a/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs +++ b/crates/asterisk-integration-tests/tests/e2e_per_endpoint_auth.rs @@ -246,5 +246,93 @@ async fn unauth_trunk_and_authed_bridge_coexist() { ); println!("[E2E] (c) authed bridge WITH valid creds -> accepted, not re-challenged"); + // Runs sequentially in the same test — this binary shares process-global + // pjsip config + tech registry, so a second #[tokio::test] would race it. + fail_closed_scenarios().await; + + set_global_pjsip_config(PjsipConfig::default()); +} + +/// Fail-CLOSED regression (adversarial review): a source matched (via identify) +/// to an endpoint that REQUIRES auth whose auth section is missing/unresolvable +/// must be REJECTED, not silently accepted with no challenge. Same for an +/// identify that names a non-existent endpoint. +async fn fail_closed_scenarios() { + register_all_apps(); + + // Endpoint "bridge" references auth "ghost-auth" which does NOT exist; an + // identify "id-ghost" names endpoint "nonexistent" which does NOT exist. + set_global_pjsip_config(PjsipConfig { + endpoints: vec![EndpointConfig { + name: "bridge".to_string(), + context: "default".to_string(), + auth: Some("ghost-auth".to_string()), + ..Default::default() + }], + auths: vec![], + identifies: vec![ + IdentifyConfig { + name: "id-bridge".to_string(), + endpoint: "bridge".to_string(), + matches: vec!["127.0.0.4/32".to_string()], + match_header: None, + }, + IdentifyConfig { + name: "id-ghost".to_string(), + endpoint: "nonexistent".to_string(), + matches: vec!["127.0.0.5/32".to_string()], + match_header: None, + }, + ], + ..Default::default() + }); + + let handler_transport: Arc = Arc::new( + UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(), + ); + let sip_local: SocketAddr = handler_transport.local_addr().unwrap(); + let driver = Arc::new(SipChannelDriver::new(sip_local)); + driver.set_transport(handler_transport.clone()); + TECH_REGISTRY.register(driver.clone()); + let handler = Arc::new(SipEventHandler::new(Arc::new(dialplan()), handler_transport)); + handler.set_channel_driver(driver.clone()); + + let offer = SessionDescription::create_offer("127.0.0.1", 40000, &[codecs::pcmu()]); + + // (a) endpoint requires auth but its auth section is unresolvable -> reject. + let sock = UdpSocket::bind("127.0.0.4:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + let inv = invite_request("cp4-dangling-auth", addr.port(), &offer.to_string(), None); + let session = SipSession::new_inbound(&inv, sip_local, addr).expect("session"); + let accepted = handler.handle_incoming_invite(&inv, addr, session).await; + assert_eq!( + accepted, None, + "an endpoint whose auth section is unresolvable must fail closed, not accept" + ); + let codes = collect_status_codes(&sock, Duration::from_secs(2)).await; + assert!( + codes.contains(&403) || codes.contains(&401), + "unresolvable matched auth must be rejected (403/401), not accepted; saw {codes:?}" + ); + + // (b) identify names a non-existent endpoint -> reject. + let sock = UdpSocket::bind("127.0.0.5:0").await.unwrap(); + let addr = sock.local_addr().unwrap(); + let inv = invite_request("cp4-ghost-ep", addr.port(), &offer.to_string(), None); + let session = SipSession::new_inbound(&inv, sip_local, addr).expect("session"); + let accepted = handler.handle_incoming_invite(&inv, addr, session).await; + assert_eq!( + accepted, None, + "an identify pointing at a non-existent endpoint must fail closed" + ); + let codes = collect_status_codes(&sock, Duration::from_secs(2)).await; + assert!( + codes.contains(&403), + "a phantom-endpoint identify match must be rejected 403; saw {codes:?}" + ); + println!("[E2E] fail-closed: unresolvable matched auth + phantom endpoint both rejected"); + set_global_pjsip_config(PjsipConfig::default()); } diff --git a/crates/asterisk-sip/src/event_handler.rs b/crates/asterisk-sip/src/event_handler.rs index 2bc2f5e..00d2810 100644 --- a/crates/asterisk-sip/src/event_handler.rs +++ b/crates/asterisk-sip/src/event_handler.rs @@ -505,17 +505,49 @@ impl SipEventHandler { // than the old union, which accepted any configured credential). let creds_with_names: Vec<(String, AuthCredentials)> = match matched_endpoint_name.as_deref() { - Some(name) => cfg - .find_endpoint(name) - .and_then(|ep| ep.auth.as_ref()) - .and_then(|auth_name| cfg.find_auth(auth_name)) - .map(|auth| { - vec![( - name.to_string(), - AuthCredentials::new(&auth.username, &auth.password, ""), - )] - }) - .unwrap_or_default(), + Some(name) => match cfg.find_endpoint(name) { + // A type=identify matched the source to an endpoint that + // does NOT exist (dangling `endpoint=` reference). Fail + // CLOSED — never accept an unauthenticated call under a + // phantom endpoint (adversarial review). + None => { + warn!(call_id = %call_id, endpoint = %name, + "Rejecting INVITE: identify matched a non-existent endpoint"); + if let Ok(resp) = request.create_response(403, "Forbidden") { + if self.may_send_invite_final(request, &resp) { + let _ = self.transport.send(&resp, remote_addr).await; + } + } + return None; + } + Some(ep) => match ep.auth.as_deref() { + // Matched endpoint has NO auth section -> no + // challenge (the unauthenticated trunk). + None => Vec::new(), + Some(auth_name) => match cfg.find_auth(auth_name) { + Some(auth) => vec![( + name.to_string(), + AuthCredentials::new(&auth.username, &auth.password, ""), + )], + // The endpoint REQUIRES auth but its auth section + // is missing/unresolvable. Fail CLOSED rather + // than collapse to an empty credential set and + // accept a credential-less caller (adversarial + // review — the exact fail-open the old + // `unwrap_or_default()` introduced). + None => { + warn!(call_id = %call_id, endpoint = %name, auth = %auth_name, + "Rejecting INVITE: matched endpoint's auth section is unresolvable"); + if let Ok(resp) = request.create_response(403, "Forbidden") { + if self.may_send_invite_final(request, &resp) { + let _ = self.transport.send(&resp, remote_addr).await; + } + } + return None; + } + }, + }, + }, None => cfg .endpoints .iter()