Skip to content

Commit 265152b

Browse files
Copilotcodingjoe
andcommitted
Try sips: first, fall back to sip:;transport=tls on 403 per RFC 5630
Co-authored-by: codingjoe <[email protected]>
1 parent 9ad135c commit 265152b

File tree

3 files changed

+132
-20
lines changed

3 files changed

+132
-20
lines changed

tests/sip/test_protocol.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -682,15 +682,16 @@ async def test_answer__no_address_logs_error(self, caplog):
682682

683683
@pytest.mark.asyncio
684684
async def test_answer__includes_contact_header(self, fake_rtp_transport):
685-
"""Include a Contact header with the local SIP address in 200 OK."""
685+
"""Include a Contact header with the local SIPS address in 200 OK."""
686686
protocol = FakeProtocol()
687687
addr = ("192.0.2.1", 5060)
688688
invite = self._make_invite("answer-contact-1")
689689
protocol.request_received(invite, addr)
690690
await self._run_answer(protocol, invite, fake_rtp_transport)
691691
response, _ = protocol._sent_responses[-1]
692692
assert "Contact" in response.headers
693-
assert response.headers["Contact"].startswith("<sip:")
693+
# FakeProtocol uses FakeTransport (TLS, not downgraded) → sips:
694+
assert response.headers["Contact"].startswith("<sips:")
694695

695696
@pytest.mark.asyncio
696697
async def test_answer__includes_allow_header(self, fake_rtp_transport):
@@ -1631,16 +1632,31 @@ class TestRegistration:
16311632
def test_registrar_uri__strips_user_from_aor(self):
16321633
"""Derive registrar URI from AOR by stripping the user part."""
16331634
p = make_register_session(aor="sip:[email protected]")
1635+
# _is_tls=False by default (no connection_made called)
16341636
assert p.registrar_uri == "sip:example.com"
16351637

16361638
def test_registrar_uri__preserves_port(self):
16371639
"""Preserve a non-default port in the derived registrar URI."""
16381640
p = make_register_session(aor="sip:[email protected]:5080")
16391641
assert p.registrar_uri == "sip:example.com:5080"
16401642

1641-
def test_registrar_uri__normalises_sips_to_sip(self):
1642-
"""sips: AOR is normalised to sip: in the registrar URI (RFC 5630 §4)."""
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
1647+
assert p.registrar_uri == "sips:example.com"
1648+
1649+
def test_registrar_uri__uses_sip_when_downgraded(self):
1650+
"""Fall back to sip: after a sips: rejection (RFC 5630 §4 downgrade)."""
1651+
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."""
16431658
p = make_register_session(aor="sips:[email protected]")
1659+
# _is_tls=False → always sip:
16441660
assert p.registrar_uri == "sip:example.com"
16451661

16461662
async def test_connection_made__sends_register(self):
@@ -1662,7 +1678,8 @@ async def _initialize(self):
16621678
await asyncio.sleep(0.05)
16631679
transport.write.assert_called()
16641680
(data,) = transport.write.call_args[0]
1665-
assert b"REGISTER sip:example.com SIP/2.0" in data
1681+
# make_mock_transport() simulates TLS → sips: is preferred first attempt.
1682+
assert b"REGISTER sips:example.com SIP/2.0" in data
16661683

16671684
async def test_register__includes_required_headers(self):
16681685
"""REGISTER request includes From, To, Call-ID, CSeq, Contact and Expires."""
@@ -1675,7 +1692,8 @@ async def test_register__includes_required_headers(self):
16751692
(data,) = transport.write.call_args[0]
16761693
assert b"From: sip:[email protected]" in data
16771694
assert b"To: sip:[email protected]" in data
1678-
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
1695+
# TLS and not downgraded → sips: Contact
1696+
assert b"Contact: <sips:[email protected]:5061>" in data
16791697
assert b"Expires: 3600" in data
16801698

16811699
async def test_register__increments_cseq(self):
@@ -1855,16 +1873,68 @@ async def test_register__via_branch_is_unique_per_request(self):
18551873
assert branch1 != branch2
18561874

18571875
async def test_register__contact_uses_local_addr(self):
1858-
"""Contact header uses sip: URI with transport=tls and the local socket address."""
1876+
"""Contact header uses sips: URI when TLS is active (RFC 5630 §4, first attempt)."""
1877+
p = make_register_session()
1878+
p.local_address = "10.0.0.5", 5061
1879+
transport = make_mock_transport("10.0.0.5", 5061)
1880+
p.transport = transport
1881+
p._is_tls = True
1882+
await p.register()
1883+
(data,) = transport.write.call_args[0]
1884+
assert b"Contact: <sips:[email protected]:5061>" in data
1885+
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."""
18591888
p = make_register_session()
18601889
p.local_address = "10.0.0.5", 5061
18611890
transport = make_mock_transport("10.0.0.5", 5061)
18621891
p.transport = transport
18631892
p._is_tls = True
1893+
p._sips_downgraded = True
18641894
await p.register()
18651895
(data,) = transport.write.call_args[0]
18661896
assert b"Contact: <sip:[email protected]:5061;transport=tls>" in data
18671897

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
1919+
1920+
async def test_response_received__403_not_retriggered_after_downgrade(self):
1921+
"""A second 403 after downgrading does not loop — raises NotImplementedError."""
1922+
p = make_register_session()
1923+
transport = make_mock_transport()
1924+
p.transport = transport
1925+
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):
1929+
p.response_received(
1930+
Response(
1931+
status_code=403,
1932+
reason="Forbidden",
1933+
headers={"CSeq": "1 REGISTER"},
1934+
),
1935+
("192.0.2.2", 5061),
1936+
)
1937+
18681938
async def test_data_received__sip_response__calls_response_received(self):
18691939
"""data_received routes SIP messages to response_received via TCP stream."""
18701940
received = []

tests/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def test_transcribe__missing_server_exits_with_error(self):
8888
assert "server" in result.output.lower() or "SIP_SERVER" in result.output
8989

9090
def test_transcribe__missing_aor_defaults_to_username_at_host(self):
91-
"""Default AOR to sips:{username}@{server_host} when SIP_AOR is not provided."""
91+
"""Default AOR to sip:{username}@{server_host} when SIP_AOR is not provided."""
9292
from voip.__main__ import voip
9393

9494
runner = make_runner()

voip/sip/protocol.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ def call_received(self, request: Request) -> None:
126126
transport: asyncio.Transport | None = dataclasses.field(init=False, default=None)
127127
#: True when the underlying transport is TLS-wrapped; False for plain TCP.
128128
_is_tls: bool = dataclasses.field(init=False, default=False)
129+
#: True once a 403 Forbidden forces us to fall back from ``sips:`` to
130+
#: ``sip:`` with ``transport=tls`` (RFC 5630 §4).
131+
_sips_downgraded: bool = dataclasses.field(init=False, default=False)
129132

130133
def __post_init__(self):
131134
self.call_id = f"{uuid.uuid4()}@{socket.gethostname()}"
@@ -345,8 +348,9 @@ def response_received(
345348
346349
Only processes responses when registration parameters are configured.
347350
"""
348-
if response.status_code == Status["OK"] and "REGISTER" in response.headers.get(
349-
"CSeq", ""
351+
if (
352+
response.status_code == Status["OK"]
353+
and response.headers.get("CSeq", "").split()[-1:] == ["REGISTER"]
350354
):
351355
logger.info("Registration successful")
352356
self.registered()
@@ -405,6 +409,22 @@ def response_received(
405409
else:
406410
asyncio.create_task(self.register(authorization=auth_value))
407411
return
412+
if (
413+
response.status_code == Status["Forbidden"]
414+
and response.headers.get("CSeq", "").split()[-1:] == ["REGISTER"]
415+
and self._is_tls
416+
and not self._sips_downgraded
417+
):
418+
# RFC 5630 §4: some servers reject ``sips:`` URIs even over TLS.
419+
# Downgrade to ``sip:`` with ``transport=tls`` and retry once.
420+
logger.warning(
421+
"REGISTER rejected with 403 Forbidden while using sips: URIs; "
422+
"server may not support sips: — retrying with sip:;transport=tls "
423+
"(RFC 5630 §4 fallback)"
424+
)
425+
self._sips_downgraded = True
426+
asyncio.create_task(self.register())
427+
return
408428
logger.warning(
409429
"Unexpected REGISTER response: %s %s", response.status_code, response.reason
410430
)
@@ -573,7 +593,7 @@ async def _answer(self, request: Request, call_class: type[Call]) -> None:
573593
call_id,
574594
),
575595
**({"Record-Route": record_route} if record_route else {}),
576-
"Contact": f"<sip:{self.local_address[0]}:{self.local_address[1]}{';transport=tls' if self._is_tls else ''}>",
596+
"Contact": self._build_contact(),
577597
"Allow": self.ALLOW,
578598
"Supported": "replaces",
579599
"Content-Type": "application/sdp",
@@ -616,6 +636,26 @@ def _with_to_tag(self, headers: dict[str, str], call_id: str) -> dict[str, str]:
616636
"To": headers.get("To", "") + (f";tag={tag}" if tag else ""),
617637
}
618638

639+
def _build_contact(self, user: str | None = None) -> str:
640+
"""Return a ``Contact:`` header value for this UA.
641+
642+
Prefers the ``sips:`` URI scheme when connected over TLS and the server
643+
has not yet rejected it (RFC 5630 §4 first attempt). After a
644+
403-driven downgrade (:attr:`_sips_downgraded`) the compatible
645+
``sip:`` form with ``transport=tls`` is used instead.
646+
647+
Args:
648+
user: SIP user part (e.g. ``"alice"``). When provided the Contact
649+
is of the form ``<scheme:user@host:port>``; otherwise just
650+
``<scheme:host:port>``.
651+
"""
652+
host_port = f"{self.local_address[0]}:{self.local_address[1]}"
653+
addr = f"{user}@{host_port}" if user else host_port
654+
if self._is_tls and not self._sips_downgraded:
655+
return f"<sips:{addr}>"
656+
tls_param = ";transport=tls" if self._is_tls else ""
657+
return f"<sip:{addr}{tls_param}>"
658+
619659
def ringing(self, request: Request) -> None:
620660
"""Send a 180 Ringing provisional response to the caller.
621661
@@ -707,19 +747,21 @@ def reject(
707747

708748
@property
709749
def registrar_uri(self) -> str:
710-
"""Registrar Request-URI derived from the AOR (e.g. sip:example.com).
711-
712-
Per RFC 5630 §4, ``sips:`` URIs require end-to-end TLS on every hop and
713-
are not supported by many carriers. When the AOR uses the ``sips:``
714-
scheme we normalise to ``sip:`` here — TLS transport is already signalled
715-
by ``Via: SIP/2.0/TLS``, so the scheme conversion is safe and RFC-
716-
compliant.
750+
"""Registrar Request-URI derived from the AOR.
751+
752+
Over a TLS connection the most secure form, ``sips:host``, is preferred
753+
(RFC 5630 §4). If the server rejects this with ``403 Forbidden``,
754+
:meth:`response_received` sets :attr:`_sips_downgraded` to ``True`` and
755+
retries; subsequent calls then return the compatible ``sip:host`` form.
756+
TLS transport is already signalled by ``Via: SIP/2.0/TLS``, so this
757+
downgrade is safe and interoperable.
717758
"""
718759
if not self.aor:
719760
raise ValueError("AOR is not configured; cannot derive registrar URI")
720761
_, _, rest = self.aor.partition(":")
721762
_, _, hostport = rest.partition("@")
722-
return f"sip:{hostport}"
763+
scheme = "sips" if (self._is_tls and not self._sips_downgraded) else "sip"
764+
return f"{scheme}:{hostport}"
723765

724766
async def register(
725767
self,
@@ -745,7 +787,7 @@ async def register(
745787
"To": self.aor,
746788
"Call-ID": self.call_id,
747789
"CSeq": f"{self.cseq} REGISTER",
748-
"Contact": f"<sip:{user}@{self.local_address[0]}:{self.local_address[1]}{';transport=tls' if self._is_tls else ''}>",
790+
"Contact": self._build_contact(user),
749791
"Expires": "3600", # 1 hour
750792
"Max-Forwards": "70",
751793
}

0 commit comments

Comments
 (0)