Skip to content

Commit 5415777

Browse files
Copilotcodingjoe
andcommitted
Follow AOR scheme for sip:/sips: URIs; raise RegistrationError on failure
Co-authored-by: codingjoe <[email protected]>
1 parent 6fdb78f commit 5415777

File tree

2 files changed

+81
-104
lines changed

2 files changed

+81
-104
lines changed

tests/sip/test_protocol.py

Lines changed: 53 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
from voip.sdp.messages import SessionDescription
1313
from voip.sdp.types import Timing
1414
from voip.sip.messages import Message, Request, Response
15-
from voip.sip.protocol import SIP, SessionInitiationProtocol, _mask_caller
15+
from voip.sip.protocol import (
16+
SIP,
17+
RegistrationError,
18+
SessionInitiationProtocol,
19+
_mask_caller,
20+
)
1621
from voip.sip.types import CallerID
1722
from voip.types import DigestAlgorithm
1823

@@ -682,16 +687,18 @@ async def test_answer__no_address_logs_error(self, caplog):
682687

683688
@pytest.mark.asyncio
684689
async def test_answer__includes_contact_header(self, fake_rtp_transport):
685-
"""Include a Contact header with the local SIPS address in 200 OK."""
690+
"""Include a Contact header with the local SIP address in 200 OK."""
686691
protocol = FakeProtocol()
687692
addr = ("192.0.2.1", 5060)
688693
invite = self._make_invite("answer-contact-1")
689694
protocol.request_received(invite, addr)
690695
await self._run_answer(protocol, invite, fake_rtp_transport)
691696
response, _ = protocol._sent_responses[-1]
692697
assert "Contact" in response.headers
693-
# FakeProtocol uses FakeTransport (TLS, not downgraded) → sips:
694-
assert response.headers["Contact"].startswith("<sips:")
698+
# FakeProtocol AOR is sip:[email protected] → sip: Contact.
699+
assert response.headers["Contact"].startswith("<sip:")
700+
# FakeTransport is TLS-wrapped, so transport=tls param should be present.
701+
assert ";transport=tls" in response.headers["Contact"]
695702

696703
@pytest.mark.asyncio
697704
async def test_answer__includes_allow_header(self, fake_rtp_transport):
@@ -1632,31 +1639,22 @@ class TestRegistration:
16321639
def test_registrar_uri__strips_user_from_aor(self):
16331640
"""Derive registrar URI from AOR by stripping the user part."""
16341641
p = make_register_session(aor="sip:[email protected]")
1635-
# _is_tls=False by default (no connection_made called)
16361642
assert p.registrar_uri == "sip:example.com"
16371643

16381644
def test_registrar_uri__preserves_port(self):
16391645
"""Preserve a non-default port in the derived registrar URI."""
16401646
p = make_register_session(aor="sip:[email protected]:5080")
16411647
assert p.registrar_uri == "sip:example.com:5080"
16421648

1643-
def test_registrar_uri__uses_sips_when_tls(self):
1644-
"""Prefer sips: scheme over TLS before any downgrade (RFC 5630 §4)."""
1645-
p = make_register_session(aor="sip:[email protected]")
1646-
p._is_tls = True
1649+
def test_registrar_uri__preserves_sips_scheme(self):
1650+
"""sips: AOR produces sips: registrar URI (RFC 3261 §10.2)."""
1651+
p = make_register_session(aor="sips:[email protected]")
16471652
assert p.registrar_uri == "sips:example.com"
16481653

1649-
def test_registrar_uri__uses_sip_when_downgraded(self):
1650-
"""Fall back to sip: after a sips: rejection (RFC 5630 §4 downgrade)."""
1654+
def test_registrar_uri__preserves_sip_scheme(self):
1655+
"""sip: AOR produces sip: registrar URI regardless of transport."""
16511656
p = make_register_session(aor="sip:[email protected]")
1652-
p._is_tls = True
1653-
p._sips_downgraded = True
1654-
assert p.registrar_uri == "sip:example.com"
1655-
1656-
def test_registrar_uri__normalises_sips_aor_when_not_tls(self):
1657-
"""sips: AOR normalised to sip: in the registrar URI when not on TLS."""
1658-
p = make_register_session(aor="sips:[email protected]")
1659-
# _is_tls=False → always sip:
1657+
p._is_tls = True # TLS transport should not change the scheme
16601658
assert p.registrar_uri == "sip:example.com"
16611659

16621660
async def test_connection_made__sends_register(self):
@@ -1678,8 +1676,8 @@ async def _initialize(self):
16781676
await asyncio.sleep(0.05)
16791677
transport.write.assert_called()
16801678
(data,) = transport.write.call_args[0]
1681-
# make_mock_transport() simulates TLS → sips: is preferred first attempt.
1682-
assert b"REGISTER sips:example.com SIP/2.0" in data
1679+
# sip: AOR → sip: registrar URI even over TLS.
1680+
assert b"REGISTER sip:example.com SIP/2.0" in data
16831681

16841682
async def test_register__includes_required_headers(self):
16851683
"""REGISTER request includes From, To, Call-ID, CSeq, Contact and Expires."""
@@ -1692,8 +1690,8 @@ async def test_register__includes_required_headers(self):
16921690
(data,) = transport.write.call_args[0]
16931691
assert b"From: sip:[email protected]" in data
16941692
assert b"To: sip:[email protected]" in data
1695-
# TLS and not downgradedsips: Contact
1696-
assert b"Contact: <sips:[email protected]:5061>" in data
1693+
# sip: AOR over TLSsip:;transport=tls Contact
1694+
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
16971695
assert b"Expires: 3600" in data
16981696

16991697
async def test_register__increments_cseq(self):
@@ -1749,10 +1747,10 @@ def registered(self):
17491747
assert calls == [True]
17501748

17511749
async def test_response_received__200_non_register_raises(self):
1752-
"""Receiving 200 OK for a non-REGISTER method raises NotImplementedError."""
1750+
"""Receiving 200 OK for a non-REGISTER method raises RegistrationError."""
17531751
p = make_register_session()
17541752
p.connection_made(make_mock_transport())
1755-
with pytest.raises(NotImplementedError):
1753+
with pytest.raises(RegistrationError):
17561754
p.response_received(
17571755
Response(status_code=200, reason="OK", headers={"CSeq": "1 INVITE"}),
17581756
("192.0.2.2", 5060),
@@ -1873,59 +1871,33 @@ async def test_register__via_branch_is_unique_per_request(self):
18731871
assert branch1 != branch2
18741872

18751873
async def test_register__contact_uses_local_addr(self):
1876-
"""Contact header uses sips: URI when TLS is active (RFC 5630 §4, first attempt)."""
1874+
"""Contact header uses sip:;transport=tls when AOR is sip: over TLS."""
18771875
p = make_register_session()
18781876
p.local_address = "10.0.0.5", 5061
18791877
transport = make_mock_transport("10.0.0.5", 5061)
18801878
p.transport = transport
18811879
p._is_tls = True
18821880
await p.register()
18831881
(data,) = transport.write.call_args[0]
1884-
assert b"Contact: <sips:[email protected]:5061>" in data
1882+
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
18851883

1886-
async def test_register__contact_uses_sip_transport_tls_when_downgraded(self):
1887-
"""Contact header falls back to sip:;transport=tls after a 403 downgrade."""
1888-
p = make_register_session()
1884+
async def test_register__contact_uses_sips_when_aor_is_sips(self):
1885+
"""Contact header uses sips: when AOR scheme is sips:."""
1886+
p = make_register_session(aor="sips:[email protected]")
18891887
p.local_address = "10.0.0.5", 5061
18901888
transport = make_mock_transport("10.0.0.5", 5061)
18911889
p.transport = transport
18921890
p._is_tls = True
1893-
p._sips_downgraded = True
18941891
await p.register()
18951892
(data,) = transport.write.call_args[0]
1896-
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
1897-
1898-
async def test_response_received__403_for_sips_retries_with_sip(self):
1899-
"""403 Forbidden on REGISTER when using sips: triggers a sip:;transport=tls retry."""
1900-
p = make_register_session(username="alice", password="secret") # noqa: S106
1901-
transport = make_mock_transport()
1902-
p.transport = transport
1903-
p.local_address = ("127.0.0.1", 5061)
1904-
p._is_tls = True
1905-
p.response_received(
1906-
Response(
1907-
status_code=403,
1908-
reason="Forbidden (sips URI unsupported)",
1909-
headers={"CSeq": "1 REGISTER"},
1910-
),
1911-
("192.0.2.2", 5061),
1912-
)
1913-
await asyncio.sleep(0.05)
1914-
(data,) = transport.write.call_args[0]
1915-
# After downgrade, REGISTER request-URI must use sip: scheme.
1916-
assert b"REGISTER sip:" in data
1917-
# Contact must use sip: with transport=tls, not sips:.
1918-
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
1893+
assert b"Contact: <sips:[email protected]:5061>" in data
19191894

1920-
async def test_response_received__403_not_retriggered_after_downgrade(self):
1921-
"""A second 403 after downgrading does not loop — raises NotImplementedError."""
1895+
async def test_response_received__403_raises_registration_error(self):
1896+
"""403 Forbidden for REGISTER raises RegistrationError with the response message."""
19221897
p = make_register_session()
1923-
transport = make_mock_transport()
1924-
p.transport = transport
19251898
p.local_address = ("127.0.0.1", 5061)
1926-
p._is_tls = True
1927-
p._sips_downgraded = True # already downgraded
1928-
with pytest.raises(NotImplementedError):
1899+
p.transport = make_mock_transport()
1900+
with pytest.raises(RegistrationError, match="403 Forbidden"):
19291901
p.response_received(
19301902
Response(
19311903
status_code=403,
@@ -1935,6 +1907,21 @@ async def test_response_received__403_not_retriggered_after_downgrade(self):
19351907
("192.0.2.2", 5061),
19361908
)
19371909

1910+
async def test_response_received__unexpected_raises_registration_error(self):
1911+
"""Any unexpected REGISTER response raises RegistrationError."""
1912+
p = make_register_session()
1913+
p.local_address = ("127.0.0.1", 5061)
1914+
p.transport = make_mock_transport()
1915+
with pytest.raises(RegistrationError, match="500 Server Error"):
1916+
p.response_received(
1917+
Response(
1918+
status_code=500,
1919+
reason="Server Error",
1920+
headers={"CSeq": "1 REGISTER"},
1921+
),
1922+
("192.0.2.2", 5061),
1923+
)
1924+
19381925
async def test_data_received__sip_response__calls_response_received(self):
19391926
"""data_received routes SIP messages to response_received via TCP stream."""
19401927
received = []
@@ -1992,14 +1979,16 @@ async def test_response_received__200_ok__logs_info(self, caplog):
19921979
)
19931980
assert any("Registration successful" in r.message for r in caplog.records)
19941981

1995-
async def test_response_received__unexpected_status__logs_warning(self, caplog):
1996-
"""An unhandled status code logs a warning and raises NotImplementedError."""
1982+
async def test_response_received__unexpected_status__raises_registration_error(
1983+
self, caplog
1984+
):
1985+
"""An unhandled status code raises RegistrationError with status and reason."""
19971986
import logging
19981987

19991988
p = make_register_session()
20001989
p.connection_made(make_mock_transport())
20011990
with caplog.at_level(logging.WARNING, logger="voip.sip"):
2002-
with pytest.raises(NotImplementedError):
1991+
with pytest.raises(RegistrationError, match="500 Server Error"):
20031992
p.response_received(
20041993
Response(
20051994
status_code=500,
@@ -2008,7 +1997,6 @@ async def test_response_received__unexpected_status__logs_warning(self, caplog):
20081997
),
20091998
("192.0.2.2", 5060),
20101999
)
2011-
assert any("500" in r.message for r in caplog.records)
20122000

20132001

20142002
# ---------------------------------------------------------------------------

voip/sip/protocol.py

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@
3737

3838
logger = logging.getLogger("voip.sip")
3939

40-
__all__ = ["SIP", "SessionInitiationProtocol"]
40+
__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol"]
41+
42+
43+
class RegistrationError(Exception):
44+
"""Raised when a SIP REGISTER request fails with an unexpected response.
45+
46+
The exception message includes the response status code and reason phrase
47+
from the server, e.g. ``"403 Forbidden"`` or ``"500 Server Error"``.
48+
"""
4149

4250

4351
def _mask_caller(header: str) -> str:
@@ -147,9 +155,6 @@ def call_received(self, request: Request) -> None:
147155
transport: asyncio.Transport | None = dataclasses.field(init=False, default=None)
148156
#: True when the underlying transport is TLS-wrapped; False for plain TCP.
149157
_is_tls: bool = dataclasses.field(init=False, default=False)
150-
#: True once a 403 Forbidden forces us to fall back from ``sips:`` to
151-
#: ``sip:`` with ``transport=tls`` (RFC 5630 §4).
152-
_sips_downgraded: bool = dataclasses.field(init=False, default=False)
153158

154159
def __post_init__(self):
155160
self.call_id = f"{uuid.uuid4()}@{socket.gethostname()}"
@@ -429,26 +434,7 @@ def response_received(
429434
else:
430435
asyncio.create_task(self.register(authorization=auth_value))
431436
return
432-
if (
433-
response.status_code == Status["Forbidden"]
434-
and response.headers.get("CSeq", "").split()[-1:] == ["REGISTER"]
435-
and self._is_tls
436-
and not self._sips_downgraded
437-
):
438-
# RFC 5630 §4: some servers reject ``sips:`` URIs even over TLS.
439-
# Downgrade to ``sip:`` with ``transport=tls`` and retry once.
440-
logger.warning(
441-
"REGISTER rejected with 403 Forbidden while using sips: URIs; "
442-
"server may not support sips: — retrying with sip:;transport=tls "
443-
"(RFC 5630 §4 fallback)"
444-
)
445-
self._sips_downgraded = True
446-
asyncio.create_task(self.register())
447-
return
448-
logger.warning(
449-
"Unexpected REGISTER response: %s %s", response.status_code, response.reason
450-
)
451-
raise NotImplementedError("Unexpected REGISTER response")
437+
raise RegistrationError(f"{response.status_code} {response.reason}")
452438

453439
def call_received(self, request: Request) -> None:
454440
"""Handle an incoming call.
@@ -659,19 +645,20 @@ def _with_to_tag(self, headers: dict[str, str], call_id: str) -> dict[str, str]:
659645
def _build_contact(self, user: str | None = None) -> str:
660646
"""Return a ``Contact:`` header value for this UA.
661647
662-
Prefers the ``sips:`` URI scheme when connected over TLS and the server
663-
has not yet rejected it (RFC 5630 §4 first attempt). After a
664-
403-driven downgrade (:attr:`_sips_downgraded`) the compatible
665-
``sip:`` form with ``transport=tls`` is used instead.
648+
The URI scheme mirrors :attr:`aor`: a ``sips:`` AOR produces a
649+
``sips:`` Contact (the strongest TLS guarantee); a ``sip:`` AOR over
650+
TLS produces ``sip:`` with ``transport=tls``; plain TCP produces plain
651+
``sip:``.
666652
667653
Args:
668654
user: SIP user part (e.g. ``"alice"``). When provided the Contact
669655
is of the form ``<scheme:user@host:port>``; otherwise just
670656
``<scheme:host:port>``.
671657
"""
658+
aor_scheme = self.aor.partition(":")[0] # "sip" or "sips"
672659
host_port = f"{self.local_address[0]}:{self.local_address[1]}"
673660
addr = f"{user}@{host_port}" if user else host_port
674-
if self._is_tls and not self._sips_downgraded:
661+
if aor_scheme == "sips":
675662
return f"<sips:{addr}>"
676663
tls_param = ";transport=tls" if self._is_tls else ""
677664
return f"<sip:{addr}{tls_param}>"
@@ -767,20 +754,22 @@ def reject(
767754

768755
@property
769756
def registrar_uri(self) -> str:
770-
"""Registrar Request-URI derived from the AOR.
771-
772-
Over a TLS connection the most secure form, ``sips:host``, is preferred
773-
(RFC 5630 §4). If the server rejects this with ``403 Forbidden``,
774-
:meth:`response_received` sets :attr:`_sips_downgraded` to ``True`` and
775-
retries; subsequent calls then return the compatible ``sip:host`` form.
776-
TLS transport is already signalled by ``Via: SIP/2.0/TLS``, so this
777-
downgrade is safe and interoperable.
757+
"""Registrar Request-URI derived from the AOR, preserving its scheme.
758+
759+
The scheme (``sip:`` or ``sips:``) is taken directly from :attr:`aor`
760+
so the client honours whatever security contract the administrator has
761+
configured. The user part is stripped; only the host (and optional
762+
port) is kept, per RFC 3261 §10.2.
763+
764+
Examples::
765+
766+
sip:[email protected] → sip:example.com
767+
sips:[email protected] → sips:example.com
778768
"""
779769
if not self.aor:
780770
raise ValueError("AOR is not configured; cannot derive registrar URI")
781-
_, _, rest = self.aor.partition(":")
771+
scheme, _, rest = self.aor.partition(":")
782772
_, _, hostport = rest.partition("@")
783-
scheme = "sips" if (self._is_tls and not self._sips_downgraded) else "sip"
784773
return f"{scheme}:{hostport}"
785774

786775
async def register(

0 commit comments

Comments
 (0)