Skip to content

Commit b342396

Browse files
committed
test(quic): cover reject-before-exposure for wrong-identity hole punch
Extract the inbound handshake routing from the channelActive handler into QuicTransport.routeInboundHandshake so the hole-punch identity guard is testable without a live QUIC channel. A full network e2e is impractical: dial() binds an ephemeral source port and dialAsListener only waits, so a mismatched peer cannot be forced to connect from the registered target tuple. Add tests asserting a wrong-identity hole punch closes the channel and fails the caller future without ever preparing or exposing the connection, plus the matching and normal-inbound paths.
1 parent a0582cb commit b342396

2 files changed

Lines changed: 184 additions & 42 deletions

File tree

libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,58 @@ class QuicTransport(
8484
* peer id we expect that connection to present. A hole punch always targets a known peer, so this
8585
* is never null (see [dialAsListener]).
8686
*/
87-
private data class PendingHolePunch(
87+
internal data class PendingHolePunch(
8888
val expectedPeerId: PeerId,
8989
val future: CompletableFuture<QuicChannel>
9090
)
9191

92+
/**
93+
* Routes a completed inbound (server-side) handshake, applying the hole-punch identity guard
94+
* before the connection is exposed to application handlers. Extracted from the channelActive
95+
* handler in [serverTransportBuilder] so the reject-before-exposure guarantee is testable without
96+
* a live QUIC channel.
97+
*
98+
* If [pending] is a hole punch whose identity does not match the dial target, [closeChannel] is
99+
* invoked and the pending future is completed exceptionally — neither [prepareConnection] nor
100+
* [exposeConnection] runs, so a wrong-identity peer never reaches inbound protocol handlers.
101+
* Otherwise the connection is prepared and then either handed to the waiting hole-punch caller
102+
* ([holePunchChannel]) or exposed via [exposeConnection].
103+
*/
104+
internal fun routeInboundHandshake(
105+
remoteIdentity: io.libp2p.security.tls.TlsPeerIdentity,
106+
pending: PendingHolePunch?,
107+
prepareConnection: () -> Unit,
108+
closeChannel: () -> Unit,
109+
holePunchChannel: () -> QuicChannel,
110+
exposeConnection: () -> Unit
111+
) {
112+
if (pending != null && !holePunchIdentityMatches(pending.expectedPeerId, remoteIdentity.peerId)) {
113+
logger.warn(
114+
"Hole-punched peer presented libp2p id {} but dial target was {}; closing",
115+
remoteIdentity.peerId,
116+
pending.expectedPeerId
117+
)
118+
closeChannel()
119+
pending.future.completeExceptionally(
120+
Libp2pException(
121+
"Remote peer presented libp2p pubkey for ${remoteIdentity.peerId} " +
122+
"but dial target was ${pending.expectedPeerId}"
123+
)
124+
)
125+
return
126+
}
127+
128+
prepareConnection()
129+
130+
if (pending != null) {
131+
// Route this validated inbound connection to the waiting hole-punch caller.
132+
pending.future.complete(holePunchChannel())
133+
} else {
134+
// Normal path: deliver to the connection handler.
135+
exposeConnection()
136+
}
137+
}
138+
92139
/** Pending hole punches keyed by the remote address we are trying to reach. */
93140
private val pendingHolePunches = ConcurrentHashMap<InetSocketAddress, PendingHolePunch>()
94141

@@ -427,6 +474,7 @@ class QuicTransport(
427474
"quic-handshake-waiter",
428475
object : ChannelInboundHandlerAdapter() {
429476
override fun channelActive(ctx: ChannelHandlerContext) {
477+
val self = this
430478
// Read peer certificates from this connection's own SSL session
431479
// rather than shared TrustManager state, avoiding a race between
432480
// concurrent handshakes. Both remoteId and remotePubKey come from
@@ -455,50 +503,34 @@ class QuicTransport(
455503
// check closes the channel, giving a wrong-identity peer that
456504
// reached the expected UDP tuple an unauthenticated-by-target
457505
// window into inbound protocols.
458-
if (pending != null &&
459-
!holePunchIdentityMatches(pending.expectedPeerId, remoteIdentity.peerId)
460-
) {
461-
logger.warn(
462-
"Hole-punched peer presented libp2p id {} but dial target was {}; closing",
463-
remoteIdentity.peerId,
464-
pending.expectedPeerId
465-
)
466-
ctx.close()
467-
pending.future.completeExceptionally(
468-
Libp2pException(
469-
"Remote peer presented libp2p pubkey for ${remoteIdentity.peerId} " +
470-
"but dial target was ${pending.expectedPeerId}"
506+
routeInboundHandshake(
507+
remoteIdentity = remoteIdentity,
508+
pending = pending,
509+
prepareConnection = {
510+
connection.setSecureSession(
511+
SecureChannel.Session(
512+
PeerId.fromPubKey(localKey.publicKey()),
513+
remoteIdentity.peerId,
514+
remoteIdentity.pubKey,
515+
null
516+
)
471517
)
472-
)
473-
return
474-
}
475518

476-
connection.setSecureSession(
477-
SecureChannel.Session(
478-
PeerId.fromPubKey(localKey.publicKey()),
479-
remoteIdentity.peerId,
480-
remoteIdentity.pubKey,
481-
null
482-
)
519+
// Remove this handler as it's no longer needed
520+
ctx.pipeline().remove(self)
521+
522+
// Warm the address cache while the QuicChannel is still
523+
// live; once closed it frees native state and
524+
// remoteSocketAddress() returns null.
525+
connection.cacheAddresses()
526+
},
527+
closeChannel = { ctx.close() },
528+
holePunchChannel = { ctx.channel() as QuicChannel },
529+
exposeConnection = {
530+
preHandler?.also { visitor -> visitor.visit(connection) }
531+
connHandler.handleConnection(connection)
532+
}
483533
)
484-
485-
// Remove this handler as it's no longer needed
486-
ctx.pipeline().remove(this)
487-
488-
// Warm the address cache while the QuicChannel is still live;
489-
// once closed it frees native state and remoteSocketAddress()
490-
// returns null.
491-
connection.cacheAddresses()
492-
493-
if (pending != null) {
494-
// Route this validated inbound connection to the waiting
495-
// hole-punch caller.
496-
pending.future.complete(ctx.channel() as QuicChannel)
497-
} else {
498-
// Normal path: deliver to the connection handler
499-
preHandler?.also { visitor -> visitor.visit(connection) }
500-
connHandler.handleConnection(connection)
501-
}
502534
} else {
503535
// This should not happen if channelActive is called after handshake
504536
ctx.close()
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package io.libp2p.transport.quic
2+
3+
import io.libp2p.core.PeerId
4+
import io.libp2p.crypto.keys.generateEd25519KeyPair
5+
import io.libp2p.security.tls.TlsPeerIdentity
6+
import io.mockk.mockk
7+
import io.netty.handler.codec.quic.QuicChannel
8+
import org.assertj.core.api.Assertions.assertThat
9+
import org.junit.jupiter.api.Test
10+
import java.util.concurrent.CompletableFuture
11+
12+
/**
13+
* Drives [QuicTransport.routeInboundHandshake] — the inbound (server-side) handshake routing
14+
* extracted from the channelActive handler — to verify the hole-punch identity guard rejects a
15+
* wrong-identity peer BEFORE the connection is exposed to application handlers. A full network
16+
* hole-punch e2e is impractical because the transport's dial binds an ephemeral source port and
17+
* dialAsListener only waits, so a mismatched peer cannot be made to connect from the registered
18+
* target tuple. Testing the seam directly proves the security-critical reject-before-exposure path.
19+
*/
20+
class QuicInboundHandshakeRoutingTest {
21+
22+
private fun transport(): QuicTransport =
23+
QuicTransport.Ed25519(generateEd25519KeyPair().first, emptyList())
24+
25+
private fun identity(): TlsPeerIdentity {
26+
val pubKey = generateEd25519KeyPair().second
27+
return TlsPeerIdentity(PeerId.fromPubKey(pubKey), pubKey)
28+
}
29+
30+
@Test
31+
fun `rejects a wrong-identity hole punch before exposing the connection`() {
32+
val transport = transport()
33+
val inbound = identity()
34+
val future = CompletableFuture<QuicChannel>()
35+
// Pending hole punch targeting a DIFFERENT peer than the one that connected back.
36+
val pending = QuicTransport.PendingHolePunch(PeerId.random(), future)
37+
38+
var prepared = false
39+
var closed = false
40+
var exposed = false
41+
42+
transport.routeInboundHandshake(
43+
remoteIdentity = inbound,
44+
pending = pending,
45+
prepareConnection = { prepared = true },
46+
closeChannel = { closed = true },
47+
holePunchChannel = { error("must not hand a wrong-identity channel to the caller") },
48+
exposeConnection = { exposed = true }
49+
)
50+
51+
// Channel closed, caller future failed, and crucially the connection was never prepared
52+
// nor exposed to application handlers.
53+
assertThat(closed).isTrue()
54+
assertThat(future).isCompletedExceptionally()
55+
assertThat(prepared).isFalse()
56+
assertThat(exposed).isFalse()
57+
}
58+
59+
@Test
60+
fun `hands a matching-identity hole punch to the waiting caller`() {
61+
val transport = transport()
62+
val inbound = identity()
63+
val future = CompletableFuture<QuicChannel>()
64+
// Pending hole punch whose target matches the peer that connected back.
65+
val pending = QuicTransport.PendingHolePunch(inbound.peerId, future)
66+
val channel = mockk<QuicChannel>()
67+
68+
var prepared = false
69+
var closed = false
70+
var exposed = false
71+
72+
transport.routeInboundHandshake(
73+
remoteIdentity = inbound,
74+
pending = pending,
75+
prepareConnection = { prepared = true },
76+
closeChannel = { closed = true },
77+
holePunchChannel = { channel },
78+
exposeConnection = { exposed = true }
79+
)
80+
81+
assertThat(prepared).isTrue()
82+
assertThat(future.getNow(null)).isSameAs(channel)
83+
assertThat(closed).isFalse()
84+
// Hole-punch caller is handed the channel directly; it is not exposed via connHandler.
85+
assertThat(exposed).isFalse()
86+
}
87+
88+
@Test
89+
fun `exposes a normal inbound connection when no hole punch is pending`() {
90+
val transport = transport()
91+
val inbound = identity()
92+
93+
var prepared = false
94+
var closed = false
95+
var exposed = false
96+
97+
transport.routeInboundHandshake(
98+
remoteIdentity = inbound,
99+
pending = null,
100+
prepareConnection = { prepared = true },
101+
closeChannel = { closed = true },
102+
holePunchChannel = { error("no hole punch is pending") },
103+
exposeConnection = { exposed = true }
104+
)
105+
106+
assertThat(prepared).isTrue()
107+
assertThat(exposed).isTrue()
108+
assertThat(closed).isFalse()
109+
}
110+
}

0 commit comments

Comments
 (0)