Skip to content

Commit f01af40

Browse files
tcoratgerclaude
andauthored
fix(quic): implement libp2p-TLS inbound peer-identity verification (leanEthereum#1002)
* fix(quic): implement libp2p-TLS inbound peer-identity verification PR leanEthereum#964 removed the "fabricate a random peer id" fallback from the QUIC transport but never implemented the real identity path meant to replace it, leaving the node unable to form connections: 1. Outbound dials required a /p2p/<peer_id> in the multiaddr but had no way to verify the dialed identity against the peer certificate. 2. Inbound connections were rejected unconditionally because the libp2p-certificate verification was "not implemented". 3. The aioquic server never requested the client certificate, so the server-side peer certificate was always absent. Add verify_libp2p_certificate as the exact inverse of the certificate generator: locate the libp2p extension, ASN.1-decode the SignedKey, enforce the secp256k1 key type, verify the identity signature over the peer's own TLS public key, and derive the PeerId. Wire it into the handshake so inbound connections are keyed by the verified identity and outbound connections assert the verified identity equals the dialed one. Both paths fail closed: an unverified peer is never registered. Make the listener request the client certificate so mutual authentication works. Fix the interop harness to dial /p2p/<peer_id> instead of the bare listen address. Add unit tests for the verifier (positive roundtrip plus negative vectors: tampered signature, missing extension, wrong key type, trailing bytes, off-curve key, signature over a foreign TLS key) and for the inbound fail-closed and outbound identity-mismatch paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(quic): update vulture whitelist for client-cert request The server now wraps aioquic's lazy connection initializer to request the client certificate. aioquic reads the request flag internally, so the assignment looks unused to vulture; whitelist it. Drop the now-stale on_connection entry: inbound peer-identity verification is implemented, so the listener genuinely invokes the callback and vulture sees the use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11b1469 commit f01af40

6 files changed

Lines changed: 764 additions & 62 deletions

File tree

src/lean_spec/node/networking/transport/quic/connection.py

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@
4444
from lean_spec.node.networking.transport.peer_id import PeerId
4545
from lean_spec.node.networking.transport.quic.stream import QuicStream, QuicTransportError
4646
from lean_spec.node.networking.transport.quic.stream_adapter import QuicStreamAdapter
47-
from lean_spec.node.networking.transport.quic.tls import generate_libp2p_certificate
47+
from lean_spec.node.networking.transport.quic.tls import (
48+
generate_libp2p_certificate,
49+
verify_libp2p_certificate,
50+
)
4851
from lean_spec.node.networking.types import ProtocolId
4952

5053
logger = logging.getLogger(__name__)
@@ -207,15 +210,35 @@ def __init__(self, *args, **kwargs) -> None:
207210
"""Initialize the libp2p QUIC protocol handler."""
208211
super().__init__(*args, **kwargs)
209212
self.connection: QuicConnection | None = None
213+
self.verified_peer_id: PeerId | None = None
214+
self.verification_error: QuicTransportError | None = None
210215
self.handshake_complete = asyncio.Event()
211216
self._buffered_events: list[QuicEvent] = []
212217

213218
def quic_event_received(self, event: QuicEvent) -> None:
214219
"""Handle QUIC events."""
215220
if isinstance(event, HandshakeCompleted):
216-
# TODO: extract and verify the peer certificate from the TLS session.
217-
# aioquic stores it in the underlying QUIC connection, but does not
218-
# expose it directly, so the libp2p identity is not yet recovered.
221+
# Recover the remote peer identity from the certificate it presented.
222+
#
223+
# aioquic stores the peer's TLS 1.3 certificate on the TLS context
224+
# once the handshake completes, for both client and server roles.
225+
# The libp2p extension on that certificate proves which identity key
226+
# the peer controls, so we verify it and key the connection by it.
227+
#
228+
# Verification failures are recorded rather than raised here.
229+
# This callback runs inside aioquic's datagram processing, where a
230+
# raised error would not reach the awaiting caller.
231+
# The waiter inspects the recorded error and fails the connection.
232+
try:
233+
peer_certificate = self._quic.tls._peer_certificate
234+
if peer_certificate is None:
235+
raise QuicTransportError(
236+
"Peer completed the QUIC handshake without presenting a certificate."
237+
)
238+
self.verified_peer_id = verify_libp2p_certificate(peer_certificate)
239+
except QuicTransportError as exception:
240+
self.verification_error = exception
241+
219242
self.handshake_complete.set()
220243

221244
# For server-side connections, invoke the handshake callback.
@@ -391,13 +414,11 @@ async def connect(self, multiaddr: str) -> QuicConnection:
391414
if transport != "quic":
392415
raise QuicTransportError(f"Not a QUIC multiaddr: {multiaddr}")
393416

394-
# The remote peer identity must come from the dialed multiaddr.
417+
# The dialed multiaddr must name the peer we expect to reach.
395418
#
396-
# We do not yet extract identity from the verified libp2p
397-
# certificate extension, so the multiaddr is the only trustworthy
398-
# source of the remote peer identity.
399-
# A missing identity means the connection cannot be keyed, so we
400-
# fail cleanly rather than invent a fabricated one.
419+
# After the handshake we verify the certificate's identity against this
420+
# expected value, so a missing identity leaves nothing to check against.
421+
# We fail cleanly rather than accept whichever peer happens to answer.
401422
# This is checked before dialing so it is not re-wrapped as a
402423
# connection failure below.
403424
if expected_peer_id is None:
@@ -426,6 +447,21 @@ async def connect(self, multiaddr: str) -> QuicConnection:
426447
# Wait for handshake to complete.
427448
await protocol.handshake_complete.wait()
428449

450+
# Surface any certificate verification failure from the handshake.
451+
if protocol.verification_error is not None:
452+
raise protocol.verification_error
453+
assert protocol.verified_peer_id is not None
454+
455+
# The peer we reached must be the one the multiaddr named.
456+
#
457+
# Anything else means we connected to an impostor, so reject it.
458+
if protocol.verified_peer_id != expected_peer_id:
459+
raise QuicTransportError(
460+
"Peer identity mismatch: dialed "
461+
f"{expected_peer_id} but the certificate proves "
462+
f"{protocol.verified_peer_id}."
463+
)
464+
429465
connection = QuicConnection(
430466
_protocol=protocol,
431467
_peer_id=expected_peer_id,
@@ -451,19 +487,15 @@ async def listen(
451487
Creates a server using aioquic with libp2p-tls authentication.
452488
Runs until shutdown is requested.
453489
454-
Inbound connections are rejected until peer identity verification
455-
from the libp2p certificate extension is implemented.
456-
Without it the remote peer identity is unknown, and keying a
457-
connection by a fabricated identity is unsafe.
490+
Each inbound connection is keyed by the peer identity proven in its
491+
libp2p certificate extension, then handed to the connection callback.
458492
459493
Args:
460494
multiaddr: Address to listen on (e.g., "/ip4/0.0.0.0/udp/9000/quic-v1").
461-
on_connection: Async callback to invoke once inbound connections are supported.
495+
on_connection: Async callback invoked with each verified inbound connection.
462496
463497
Raises:
464498
QuicTransportError: If the multiaddr is not a QUIC address.
465-
QuicTransportError: When an inbound connection completes its handshake,
466-
because the remote peer identity cannot yet be verified.
467499
"""
468500
host, port, transport, _ = parse_multiaddr(multiaddr)
469501

@@ -485,21 +517,71 @@ async def listen(
485517

486518
# Callback invoked when an inbound TLS handshake completes.
487519
def handle_handshake(protocol_instance: LibP2PQuicProtocol) -> None:
488-
# An inbound connection has no multiaddr to carry the peer identity.
520+
# An inbound connection carries no dialed multiaddr.
521+
#
522+
# The peer identity comes solely from its libp2p certificate, which
523+
# the handshake handler has already verified by this point.
524+
# A verification failure leaves no trustworthy identity, so the
525+
# connection is dropped rather than keyed by an unverified peer.
526+
if protocol_instance.verification_error is not None:
527+
logger.warning(
528+
"[QUIC] Rejecting inbound connection: %s",
529+
protocol_instance.verification_error,
530+
)
531+
protocol_instance._quic.close()
532+
protocol_instance.transmit()
533+
return
534+
535+
verified_peer_id = protocol_instance.verified_peer_id
536+
assert verified_peer_id is not None
537+
538+
# Recover the peer address from the validated network path.
489539
#
490-
# The only trustworthy source is the libp2p certificate extension,
491-
# whose verification is not yet implemented on this side.
492-
# Until that exists we cannot key the connection by a real identity,
493-
# so we fail cleanly rather than invent a fabricated one.
494-
raise QuicTransportError(
495-
"Cannot accept inbound connection: "
496-
"peer identity verification from the libp2p certificate is not implemented"
540+
# The server socket is shared across peers, so the per-connection
541+
# address lives on the QUIC connection rather than the transport.
542+
network_paths = protocol_instance._quic._network_paths
543+
host, udp_port = network_paths[0].addr[:2]
544+
remote_address = f"/ip4/{host}/udp/{udp_port}/quic-v1/p2p/{verified_peer_id}"
545+
546+
connection = QuicConnection(
547+
_protocol=protocol_instance,
548+
_peer_id=verified_peer_id,
549+
_remote_address=remote_address,
497550
)
551+
protocol_instance.connection = connection
552+
protocol_instance._replay_buffered_events()
553+
554+
self._connections[verified_peer_id] = connection
555+
556+
# Hand the connection to the caller on the running event loop.
557+
#
558+
# This callback is synchronous, but the consumer is async, so the
559+
# work is scheduled as a task rather than awaited inline.
560+
asyncio.ensure_future(on_connection(connection))
498561

499562
# Protocol factory that attaches our callback to each new instance.
500563
def create_protocol(*args, **kwargs) -> LibP2PQuicProtocol:
501564
protocol = LibP2PQuicProtocol(*args, **kwargs)
502565
protocol._on_handshake = handle_handshake
566+
567+
# libp2p authenticates both peers, so the server must ask the
568+
# client for its certificate.
569+
#
570+
# aioquic defaults to not requesting a client certificate, which
571+
# would leave the inbound certificate unset and fail verification.
572+
# The TLS context is created lazily the first time aioquic builds
573+
# the connection, so the request flag is set by wrapping that
574+
# initialization rather than touching a context that does not exist
575+
# yet.
576+
quic_connection = protocol._quic
577+
original_initialize = quic_connection._initialize
578+
579+
def initialize_requesting_client_certificate(peer_cid: bytes) -> None:
580+
original_initialize(peer_cid)
581+
quic_connection.tls._request_client_certificate = True
582+
583+
quic_connection._initialize = initialize_requesting_client_certificate # type: ignore[method-assign]
584+
503585
return protocol
504586

505587
await quic_serve(

0 commit comments

Comments
 (0)