Skip to content

Commit 50f3a07

Browse files
authored
Merge pull request #1341 from sumanjeet0012/tls-fix
fix(tls): reject inbound connections that present no client certificate
2 parents 87a305d + 9506041 commit 50f3a07

8 files changed

Lines changed: 173 additions & 49 deletions

File tree

docs/tls-support.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@ Testing TLS Connections
158158
Security Considerations
159159
-----------------------
160160

161+
Mutual authentication (inbound)
162+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
163+
164+
Inbound TLS connections **must** present a client certificate that carries the
165+
libp2p X.509 extension so py-libp2p can derive and verify the remote Peer ID.
166+
The server-side SSL context requests a client certificate (``CERT_OPTIONAL``);
167+
post-handshake logic enforces the requirement. If the remote peer completes the
168+
TLS handshake without sending a certificate, the connection is rejected and no
169+
``SecureSession`` is created.
170+
171+
**AutoTLS exception:** When ``enable_autotls=True``, broker registration may
172+
use the primitive key-exchange side-channel or a placeholder identity instead
173+
of a libp2p client certificate. This path is scoped exclusively to the
174+
AutoTLS bootstrap flow and is not reachable on a standard node.
175+
161176
- Never disable certificate verification in production.
162177
- Use TLS 1.3 or later.
163178
- Pin certificates for critical peers.
@@ -181,6 +196,9 @@ Troubleshooting
181196
* - SSL handshake failure
182197
- TLS version mismatch or clock skew
183198
- Enforce TLS 1.3, sync system clock.
199+
* - Inbound connection rejected / handshake failure
200+
- Remote peer sent no client certificate
201+
- Ensure the dialer uses libp2p TLS with an identity certificate; do not connect with a plain TLS client.
184202
* - Connection refused
185203
- Port blocked or listener not running
186204
- Check firewall rules and listener status.

libp2p/security/tls/exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""TLS security transport exceptions."""
22

33
from libp2p.exceptions import BaseLibp2pError
4+
from libp2p.security.exceptions import HandshakeFailure
45

56

67
class TLSError(BaseLibp2pError):
@@ -19,3 +20,15 @@ class MissingLibp2pExtensionError(TLSError):
1920
"""
2021

2122
pass
23+
24+
25+
class TLSHandshakeFailure(HandshakeFailure):
26+
"""
27+
Raised when the TLS handshake cannot be completed due to an
28+
authentication failure, such as a missing or invalid peer certificate.
29+
30+
This is a hard failure: the connection must be closed immediately and
31+
must not be surfaced as an authenticated SecureSession.
32+
"""
33+
34+
pass

libp2p/security/tls/io.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ async def handshake(self, enable_autotls: bool = False) -> None:
442442
Perform TLS handshake.
443443
444444
Raises:
445-
HandshakeFailure: If handshake fails due to protocol errors
445+
TLSHandshakeFailure: If handshake fails due to protocol errors
446446
RuntimeError: If handshake timeout or connection errors occur
447447
ssl.SSLError: For SSL-specific errors not related to want read/write
448448

libp2p/security/tls/transport.py

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
generate_certificate,
1919
verify_certificate_chain,
2020
)
21-
from libp2p.security.tls.exceptions import MissingLibp2pExtensionError
21+
from libp2p.security.tls.exceptions import (
22+
MissingLibp2pExtensionError,
23+
TLSHandshakeFailure,
24+
)
2225
from libp2p.security.tls.io import TLSReadWriter
2326
import libp2p.utils
2427
import libp2p.utils.paths
@@ -127,15 +130,20 @@ def create_ssl_context(self, server_side: bool = False) -> ssl.SSLContext:
127130
ssl.PROTOCOL_TLS_SERVER if server_side else ssl.PROTOCOL_TLS_CLIENT
128131
)
129132
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
130-
# We do our own verification (like Go's InsecureSkipVerify).
131-
# Python's ssl module can't request a client cert without CA verification.
132-
# TODO: Implement proper mutual TLS with custom verification
133+
# We do our own post-handshake verification of the libp2p extension.
134+
# check_hostname is disabled because we use self-signed peer certificates
135+
# that are not bound to DNS names.
133136
ctx.check_hostname = False
134137

135-
# TODO: Fix this default: no client cert verification
136-
# INBOUND connection can't ask for tls cert from remote peers
137-
# ctx.verify_mode = ssl.CERT_OPTIONAL if server_side else ssl.CERT_NONE
138-
ctx.verify_mode = ssl.CERT_NONE
138+
# For server-side (inbound) contexts we use CERT_OPTIONAL so that the
139+
# TLS engine *requests* a client certificate from the remote peer.
140+
# Without a CA configured Python will not reject an absent cert at the
141+
# TLS layer, but the cert IS made available via getpeercert(binary_form=True)
142+
# if one is sent. Our post-handshake logic then enforces the hard
143+
# requirement.
144+
# For client-side (outbound) contexts CERT_NONE is fine because the
145+
# server always sends a certificate in TLS 1.3.
146+
ctx.verify_mode = ssl.CERT_OPTIONAL if server_side else ssl.CERT_NONE
139147

140148
# Load our cached self-signed certificate bound to libp2p identity
141149
import os
@@ -304,55 +312,66 @@ async def secure_inbound(self, conn: IRawConnection) -> ISecureConn:
304312
# Extract peer information
305313
peer_cert = tls_reader_writer.get_peer_certificate()
306314
if not peer_cert:
307-
logger.warning("[INBOUND] Server couldn't fetch dialer's certificate")
308-
309-
# TODO: Python ssl can't request client cert without CA verification.
310-
# Use placeholder peer ID - client can still verify server identity.
311-
logger.warning("TLS inbound: no peer cert (Python ssl limitation)")
315+
# The libp2p TLS spec mandates mutual authentication: every inbound
316+
# connection MUST present a certificate that carries the libp2p
317+
# extension so we can derive and verify the remote Peer ID.
318+
#
319+
# AutoTLS bootstrap connections are a legitimate exception: when a
320+
# node registers with the ACME broker it uses the primitive
321+
# key-exchange side-channel instead of the certificate extension.
322+
# That path is kept here but is strictly guarded by enable_autotls.
323+
# The normal (non-AutoTLS) path raises a hard TLSHandshakeFailure and
324+
# never returns a synthetic placeholder identity.
312325

313-
# Extaract the keys from primitive key-exchange, if autotls enabled
314326
if self.enable_autotls:
315327
remote_public_key = tls_reader_writer.remote_primitive_pk
316328
remote_peer_id = tls_reader_writer.remote_primitive_peerid
317329
logger.warning(
318-
"TLS inbound: using peerid obtained from primitive key-exchange"
319-
)
320-
else:
321-
placeholder_keypair = libp2p.generate_new_ed25519_identity()
322-
remote_public_key = placeholder_keypair.public_key
323-
remote_peer_id = ID.from_pubkey(remote_public_key)
324-
logger.error(
325-
"TLS inbound: using peerid obtained from placeholder keypair"
330+
"TLS inbound [autotls]: no peer cert; "
331+
"using peer ID from primitive key-exchange"
326332
)
327333

328-
# This is the case when the autotls is enabled, and we did a self-signed
329-
# certificate handshake with AUTO-TLS BROKER, and naturally we didn't do the
330-
# primitive peer-identify exchange, so again use a placeholde
331-
if self.enable_autotls and remote_peer_id is None:
332-
placeholder_keypair = libp2p.generate_new_ed25519_identity()
333-
remote_public_key = placeholder_keypair.public_key
334-
remote_peer_id = ID.from_pubkey(remote_public_key)
334+
# AutoTLS broker registration: primitive exchange also absent.
335+
# Use a placeholder exclusively for the broker session.
336+
if remote_peer_id is None:
337+
placeholder_keypair = libp2p.generate_new_ed25519_identity()
338+
remote_public_key = placeholder_keypair.public_key
339+
remote_peer_id = ID.from_pubkey(remote_public_key)
340+
logger.warning(
341+
"TLS inbound [autotls broker]: no cert and no primitive "
342+
"exchange; using placeholder identity for broker session only"
343+
)
344+
345+
if remote_peer_id is None or remote_public_key is None:
346+
raise TLSHandshakeFailure(
347+
"TLS inbound [autotls]: could not determine remote identity "
348+
"from either certificate or primitive key-exchange."
349+
)
335350

336-
if remote_peer_id is None:
337-
raise ValueError(
338-
"remote peer ID must be known before creating SecureSession"
351+
session = SecureSession(
352+
local_peer=self.local_peer,
353+
local_private_key=self.libp2p_privkey,
354+
remote_peer=remote_peer_id,
355+
remote_permanent_pubkey=remote_public_key,
356+
is_initiator=False,
357+
conn=tls_reader_writer,
339358
)
340-
341-
if remote_public_key is None:
342-
raise ValueError(
343-
"remote public-key must be known before creating SecureSession"
359+
logger.debug(
360+
"TLS secure_inbound [autotls]: returning SecureSession "
361+
"with primitive-exchange identity"
344362
)
363+
return session
345364

346-
session = SecureSession(
347-
local_peer=self.local_peer,
348-
local_private_key=self.libp2p_privkey,
349-
remote_peer=remote_peer_id,
350-
remote_permanent_pubkey=remote_public_key,
351-
is_initiator=False,
352-
conn=tls_reader_writer,
365+
# Normal (non-AutoTLS) path: no certificate → hard failure.
366+
# Do NOT create a session or assign a synthetic identity.
367+
logger.error(
368+
"[INBOUND] Rejecting connection: remote peer sent no TLS certificate. "
369+
"Mutual authentication is required by the libp2p TLS spec."
370+
)
371+
raise TLSHandshakeFailure(
372+
"Inbound TLS connection presented no client certificate. "
373+
"Mutual authentication is required by the libp2p TLS spec."
353374
)
354-
logger.debug("TLS secure_inbound: returning placeholder SecureSession")
355-
return session
356375

357376
# Extract remote public key from certificate
358377
logger.debug("TLS secure_inbound: extracting public key from certificate")

libp2p/transport/webrtc/_aiortc_helpers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from aiortc import (
2121
RTCConfiguration,
22+
RTCIceServer,
2223
RTCPeerConnection,
2324
)
2425
from aiortc.rtcdtlstransport import RTCCertificate
@@ -69,7 +70,10 @@ async def create_peer_connection(
6970
:param ice_servers: Optional STUN/TURN server URLs.
7071
:returns: A new peer connection.
7172
"""
72-
config = RTCConfiguration(iceServers=list(ice_servers) if ice_servers else [])
73+
ice_server_list = (
74+
[RTCIceServer(urls=[url]) for url in ice_servers] if ice_servers else []
75+
)
76+
config = RTCConfiguration(iceServers=ice_server_list)
7377
pc = RTCPeerConnection(configuration=config)
7478
# Replace aiortc's auto-generated cert. Must use the mangled name —
7579
# aiortc reads only `self.__certificates`, which mangles to this.

newsfragments/1340.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed TLS inbound authentication bypass where a remote peer could complete the libp2p TLS handshake without presenting a client certificate and still receive a valid ``SecureSession`` with a synthetic placeholder Peer ID. The server-side SSL context now requests a client certificate, and a missing certificate is treated as a hard handshake failure rather than a recoverable warning.

tests/core/security/tls/test_tls.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,75 @@
1+
from unittest.mock import AsyncMock, MagicMock, patch
2+
13
import pytest
24

35
from libp2p import generate_new_rsa_identity
6+
from libp2p.peer.id import ID
7+
from libp2p.security.exceptions import HandshakeFailure
8+
from libp2p.security.tls.exceptions import TLSHandshakeFailure
49
from libp2p.security.tls.transport import TLSTransport
5-
from tests.utils.factories import tls_conn_factory
10+
from libp2p.transport.exceptions import SecurityUpgradeFailure
11+
from libp2p.transport.upgrader import TransportUpgrader
12+
from tests.utils.factories import (
13+
TLS_PROTOCOL_ID,
14+
default_mplex_muxer_transport_factory,
15+
raw_conn_factory,
16+
security_options_factory_factory,
17+
tls_conn_factory,
18+
)
19+
20+
21+
def test_tls_handshake_failure_extends_central_handshake_failure() -> None:
22+
assert issubclass(TLSHandshakeFailure, HandshakeFailure)
23+
24+
25+
def _mock_tls_reader_writer_no_cert() -> MagicMock:
26+
mock_rw = MagicMock()
27+
mock_rw.handshake = AsyncMock()
28+
mock_rw.get_peer_certificate.return_value = None
29+
return mock_rw
30+
31+
32+
@pytest.mark.trio
33+
async def test_secure_inbound_rejects_missing_client_certificate() -> None:
34+
keypair = generate_new_rsa_identity()
35+
transport = TLSTransport(keypair, enable_autotls=False)
36+
mock_conn = MagicMock()
37+
mock_rw = _mock_tls_reader_writer_no_cert()
38+
39+
with (
40+
patch("libp2p.security.tls.transport.TLSReadWriter", return_value=mock_rw),
41+
pytest.raises(TLSHandshakeFailure, match="no client certificate"),
42+
):
43+
await transport.secure_inbound(mock_conn)
44+
45+
46+
@pytest.mark.trio
47+
async def test_upgrader_rejects_missing_client_certificate(nursery) -> None:
48+
keypair = generate_new_rsa_identity()
49+
sec_opt = security_options_factory_factory(TLS_PROTOCOL_ID)(keypair)
50+
upgrader = TransportUpgrader(sec_opt, default_mplex_muxer_transport_factory())
51+
mock_rw = _mock_tls_reader_writer_no_cert()
52+
53+
with patch("libp2p.security.tls.transport.TLSReadWriter", return_value=mock_rw):
54+
async with raw_conn_factory(nursery) as (_local_conn, remote_conn):
55+
with pytest.raises(SecurityUpgradeFailure):
56+
await upgrader.upgrade_security(remote_conn, False)
57+
58+
59+
@pytest.mark.trio
60+
async def test_secure_inbound_autotls_allows_primitive_exchange_identity() -> None:
61+
server_keypair = generate_new_rsa_identity()
62+
client_keypair = generate_new_rsa_identity()
63+
transport = TLSTransport(server_keypair, enable_autotls=True)
64+
mock_conn = MagicMock()
65+
mock_rw = _mock_tls_reader_writer_no_cert()
66+
mock_rw.remote_primitive_pk = client_keypair.public_key
67+
mock_rw.remote_primitive_peerid = ID.from_pubkey(client_keypair.public_key)
68+
69+
with patch("libp2p.security.tls.transport.TLSReadWriter", return_value=mock_rw):
70+
session = await transport.secure_inbound(mock_conn)
71+
72+
assert session.get_remote_peer() == ID.from_pubkey(client_keypair.public_key)
673

774

875
@pytest.mark.trio

tests/core/transport/webrtc/test_multiaddr_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ def test_valid_ipv4_webrtc_direct(self):
2323
assert is_webrtc_direct_multiaddr(maddr)
2424

2525
def test_valid_ipv4_with_certhash(self):
26+
cert = WebRTCCertificate.generate()
27+
certhash = cert.fingerprint_to_multibase()
2628
maddr = Multiaddr(
27-
"/ip4/192.168.1.1/udp/4001/webrtc-direct/certhash/uEiBkEKoo3S"
29+
f"/ip4/192.168.1.1/udp/4001/webrtc-direct/certhash/{certhash}"
2830
)
2931
assert is_webrtc_direct_multiaddr(maddr)
3032

0 commit comments

Comments
 (0)