[M] Change in openvpn[master]: oob: start the client handshake from the server probe

"stipa \(Code Review\) via Openvpn-devel" <[email protected]> Tue, 28 Jul 2026 15:03:23 +0000
Newsgroups gmane.network.openvpn.devel
Message-ID <2c737de915506770b0526eff89126e73641835a2-EmailReplacePatchSet-HTML@gerrit.openvpn.net>
Attention is currently required from: plaisthos, stipa.

Hello plaisthos, 

I'd like you to reexamine a change. Please visit

    http://gerrit.openvpn.net/c/openvpn/+/1770?usp=email

to look at the new patch set (#9).

The following approvals got outdated and were removed:
Code-Review-1 by plaisthos


Change subject: oob: start the client handshake from the server probe
......................................................................

oob: start the client handshake from the server probe

When the best remote answered a probe and advertised a connect_lifetime, its
reply also served as the server's HARD_RESET: its session id is a valid
stateless SYN-cookie. The client therefore starts the handshake from that reply
instead of running its own reset exchange.

Note this does not shorten the handshake itself -- the probe and its reply take
the place of the two reset packets rather than removing them. The saving is one
RTT compared with probing and then connecting; a client that does not probe is
unaffected.

  - oob_client.c: when the winner advertised a connect_lifetime, hand its probe
    socket, the captured server session id (cookie), our probe session id, the
    pinned responder address, and the resend-wkc flag to the connection via c2;
    relinquish that socket (do not close it); gate off for dco-win, which has no
    user-to-kernel socket handoff (probing still works, only starting from the
    reply is skipped).
  - ssl.c session_skip_to_pre_start_client(): seed our session id to the probe's
    (the cookie is an HMAC over it), the remote session id to the cookie, ack
    the phantom server reset (id 0) so the third packet carries the cookie, set
    CO_RESEND_WKC for tls-crypt-v2, and drop to S_PRE_START so tls_process()
    promotes to S_START and sends the ClientHello (no HARD_RESET sent).
  - init.c: invoke the client skip after tls_multi_init_finalize when adopting,
    and count the probe reply as the initial packet received (n_sessions++, as
    the server does before its own session_skip_to_pre_start) so
    check_server_poll_timeout() does not restart the connected session.

Change-Id: I454d5040cbad4d373ee4f90b8d683200d2a4c0e4
Signed-off-by: Lev Stipakov <[email protected]>
---
M Changes.rst
M src/openvpn/init.c
M src/openvpn/oob.h
M src/openvpn/oob_client.c
M src/openvpn/openvpn.h
M src/openvpn/ssl.c
M src/openvpn/ssl.h
7 files changed, 178 insertions(+), 4 deletions(-)


  git pull ssh://gerrit.openvpn.net:29418/openvpn refs/changes/70/1770/9

diff --git a/Changes.rst b/Changes.rst
index 080d928..4d7a16c9 100644
--- a/Changes.rst
+++ b/Changes.rst
@@ -9,6 +9,13 @@
     measured latency and advertised weight with DNS-SRV-like semantics.
     Servers advertise these values with ``--server-probe-reply``.
 
+Probe exchange doubles as the start of the handshake
+    When a probing client picks a server that advertises support for it,
+    the probe and its reply take the place of the client's and server's
+    reset packets, so the client does not need a separate reset exchange
+    before the handshake. This saves one round trip compared with probing
+    and then connecting, and needs no extra configuration.
+
 
 Overview of changes in 2.7
 ==========================
diff --git a/src/openvpn/init.c b/src/openvpn/init.c
index 1598a23..065cc47 100644
--- a/src/openvpn/init.c
+++ b/src/openvpn/init.c
@@ -3507,6 +3507,21 @@
         /* Keep the max mtu also in the frame of tls multi so it can access
          * it in push_peer_info */
         c->c2.tls_multi->opt.frame.tun_max_mtu = c->c2.frame.tun_max_mtu;
+
+        /* OOB server probe: the probe reply already served as the server's
+         * HARD_RESET (it carried a valid SYN-cookie), so the handshake starts
+         * from that reply and we send no reset of our own. Count
+         * the reply as the initial packet received (as the server does before its
+         * own session_skip_to_pre_start), so tls_initial_packet_received() is true
+         * and check_server_poll_timeout() does not restart a connected session. */
+        if (c->c2.oob_probe_adopt)
+        {
+            c->c2.tls_multi->n_sessions++;
+            session_skip_to_pre_start_client(&c->c2.tls_multi->session[TM_ACTIVE],
+                                             &c->c2.oob_probe_client_sid,
+                                             &c->c2.oob_probe_server_sid, &c->c2.oob_probe_remote,
+                                             c->c2.oob_probe_resend_wkc);
+        }
     }
     if (c->c2.tls_auth_standalone)
     {
diff --git a/src/openvpn/oob.h b/src/openvpn/oob.h
index e4af7cb..e29bff0 100644
--- a/src/openvpn/oob.h
+++ b/src/openvpn/oob.h
@@ -201,6 +201,11 @@
     uint16_t weight;
     uint16_t max_latency_diff; /* margin advertised by this server (ms); 0 = use client default */
     unsigned int rtt_ms;       /* probe round-trip time in ms (responders only) */
+    /* Captured from the reply to start the handshake from it (responders only): */
+    struct session_id server_sid;      /* the reply's own session id = server SYN-cookie */
+    uint16_t connect_lifetime;         /* seconds the reply is valid as the reset; 0 = none */
+    uint32_t reply_flags;              /* OOB_PROBE_REPLY_FLAG_* advertised by the server */
+    struct sockaddr_storage responder; /* address that answered (pin the connection to it) */
 };
 
 /**
diff --git a/src/openvpn/oob_client.c b/src/openvpn/oob_client.c
index 694dfbc..28ecde0 100644
--- a/src/openvpn/oob_client.c
+++ b/src/openvpn/oob_client.c
@@ -38,6 +38,7 @@
 #include "otime.h"
 #include "fdmisc.h"
 #include "crypto.h"
+#include "dco.h"
 #include "error.h"
 
 #include "memdbg.h"
@@ -217,6 +218,13 @@
         return;
     }
 
+    /* The reply's own session id (the server's stateless SYN-cookie) follows the
+     * opcode byte. Capture it before read_control_auth() strips it: a client may
+     * reuse it to start the handshake from this reply (the connect_lifetime
+     * advertisement). */
+    struct session_id server_sid;
+    memcpy(server_sid.id, data + 1, SID_SIZE);
+
     struct buffer buf;
     buf_set_read(&buf, data, (size_t)len);
 
@@ -275,6 +283,10 @@
         results[i].weight = reply.weight;
         results[i].max_latency_diff = reply.max_latency_diff;
         results[i].rtt_ms = (ms > 0) ? (unsigned int)ms : 0;
+        results[i].server_sid = server_sid;
+        results[i].connect_lifetime = reply.connect_lifetime;
+        results[i].reply_flags = reply.flags;
+        results[i].responder = *from; /* pin the connection to the address that answered */
         break;
     }
 }
@@ -590,7 +602,6 @@
     {
         oob_probe_collect(&pc, &probe, &client_sid, &tas->tls_wrap, targets, results, l->len);
     }
-    oob_probe_sockets_close(&pc);
 
     /* Log each remote's outcome while results[i] still maps to array[i]. */
     int responded = 0;
@@ -609,10 +620,10 @@
                                      : results[i].max_latency_diff > 0 ? "server-advertised"
                                                                        : "default";
             msg(D_LOW,
-                "server-probe: %s:%s answered (priority %d, weight %d, rtt %u ms;"
-                " latency margin %d ms [%s])",
+                "server-probe: %s:%s answered (priority %d, weight %d, connect-lifetime %d s,"
+                " rtt %u ms; latency margin %d ms [%s])",
                 ce->remote, ce->remote_port, results[i].priority, results[i].weight,
-                results[i].rtt_ms, margin, margin_src);
+                results[i].connect_lifetime, results[i].rtt_ms, margin, margin_src);
         }
         else
         {
@@ -634,6 +645,69 @@
     msg(M_INFO, "server-probe: %d of %d remote(s) answered; connecting best-first", responded,
         l->len);
 
+    /* If the winner advertised a connect_lifetime, its reply also served as the
+     * server's reset: hand its probe socket and the captured cookie to the
+     * connection, which then starts the handshake from that reply (see
+     * session_skip_to_pre_start_client). Reusing that socket keeps the source
+     * IP+port the cookie is bound to. dco-win cannot hand a socket to the
+     * kernel, so it only gets the probe ordering. */
+
+    /* Single-use, so the RFC's connect_lifetime expiry check is not needed yet:
+     * we probe once (c->first_time) and arm only results[0]. */
+    bool probe_start = results[0].responded && results[0].connect_lifetime > 0;
+    bool dco_win_gate = false;
+#if defined(_WIN32)
+    if (dco_enabled(&c->options))
+    {
+        probe_start = false;
+        dco_win_gate = true;
+    }
+#endif
+    if (probe_start)
+    {
+        const int af_idx = probe_af_index(results[0].responder.ss_family);
+        c->c2.oob_probe_sd = pc.sd[af_idx];
+        pc.sd[af_idx] = SOCKET_UNDEFINED; /* relinquish: the connection owns it now */
+
+        CLEAR(c->c2.oob_probe_remote);
+        if (results[0].responder.ss_family == AF_INET)
+        {
+            c->c2.oob_probe_remote.addr.in4 = *(struct sockaddr_in *)(void *)&results[0].responder;
+        }
+        else
+        {
+            c->c2.oob_probe_remote.addr.in6 = *(struct sockaddr_in6 *)(void *)&results[0].responder;
+        }
+        c->c2.oob_probe_client_sid = client_sid;
+        c->c2.oob_probe_server_sid = results[0].server_sid;
+        c->c2.oob_probe_resend_wkc =
+            (results[0].reply_flags & OOB_PROBE_REPLY_FLAG_RESEND_WKC) != 0;
+        c->c2.oob_probe_adopt = true;
+
+        msg(D_LOW, "server-probe: starting handshake from probe reply of %s:%s"
+                   " (connect-lifetime %d s)",
+            l->array[0]->remote, l->array[0]->remote_port, results[0].connect_lifetime);
+    }
+    else if (results[0].responded)
+    {
+        /* A server answered but we won't start the handshake from it -- say why. */
+        if (dco_win_gate)
+        {
+            msg(D_LOW, "server-probe: cannot start the handshake from a probe reply"
+                       " with dco-win;"
+                       " using a full handshake");
+        }
+        else if (results[0].connect_lifetime == 0)
+        {
+            msg(D_LOW, "server-probe: %s:%s did not advertise a connect-lifetime"
+                       " (connect-lifetime 0); using a full handshake",
+                l->array[0]->remote, l->array[0]->remote_port);
+        }
+    }
+
+    /* Close any probe sockets we did not hand off to the connection. */
+    oob_probe_sockets_close(&pc);
+
     tls_auth_standalone_free(tas);
     gc_free(&gc);
 }
diff --git a/src/openvpn/openvpn.h b/src/openvpn/openvpn.h
index 44c598f..d8e02f2 100644
--- a/src/openvpn/openvpn.h
+++ b/src/openvpn/openvpn.h
@@ -249,6 +249,9 @@
     bool oob_probe_adopt;
     socket_descriptor_t oob_probe_sd;
     struct openvpn_sockaddr oob_probe_remote;
+    struct session_id oob_probe_client_sid;  /* our probe session id (cookie was minted over it) */
+    struct session_id oob_probe_server_sid;  /* the reply's session id = server cookie to echo back */
+    bool oob_probe_resend_wkc;               /* reply asked to complete with CONTROL_WKC_V1 (v2) */
 
     struct link_socket_actual *to_link_addr; /* IP address of remote */
     struct link_socket_actual from;          /* address of incoming datagram */
diff --git a/src/openvpn/ssl.c b/src/openvpn/ssl.c
index 9e1a6aa..ee8c5bf 100644
--- a/src/openvpn/ssl.c
+++ b/src/openvpn/ssl.c
@@ -2516,6 +2516,59 @@
     return session_move_pre_start(session, ks, true);
 }
 
+bool
+session_skip_to_pre_start_client(struct tls_session *session, const struct session_id *client_sid,
+                                 const struct session_id *server_sid,
+                                 const struct openvpn_sockaddr *remote, bool resend_wkc)
+{
+    struct key_state *ks = &session->key[KS_PRIMARY];
+
+    /* Our session id must be the one used for the probe: the server's cookie
+     * (server_sid) is an HMAC over it, and the server re-derives and checks it
+     * when it validates our third packet. */
+    session->session_id = *client_sid;
+    ks->session_id_remote = *server_sid;
+
+    struct link_socket_actual act = { 0 };
+    act.dest = *remote;
+    ks->remote_addr = act;
+    session->untrusted_addr = act;
+    session->burst = true;
+
+    /* tls-crypt-v2: the stateless server discarded the WKc after the probe, so
+     * complete the handshake with P_CONTROL_WKC_V1 (drives control_packet_needs_wkc). */
+    if (resend_wkc)
+    {
+        ks->crypto_options.flags |= CO_RESEND_WKC;
+    }
+
+    /* We never received the server's HARD_RESET (id 0) — the probe reply stood
+     * in for it. Acknowledge that phantom id 0 so our third packet carries an
+     * ACK together with the server session id (the cookie): reliable_ack_write()
+     * only emits that session id when at least one ACK is present, and the server
+     * needs it to validate the stateless cookie. */
+    reliable_ack_acknowledge_packet_id(ks->rec_ack, 0);
+
+    /* Skip one (RESET) packet in each direction, so ids start at 1 (see
+     * session_skip_to_pre_start). */
+    ks->rec_reliable->packet_id = 1;
+    session->tls_wrap.opt.packet_id.send.id = 1;
+
+    /* Do not send our own HARD_RESET. Unlike the server's skip, the client must
+     * send first: the stateless server only replies once it sees our third
+     * packet. session_move_pre_start(skip=true) leaves us in S_PRE_START_SKIP,
+     * which only advances when a packet is *received*; instead drop to
+     * S_PRE_START with an empty send-reliable (the initial packet was generated
+     * and immediately deleted), so tls_process() promotes us to S_START and
+     * sends the ClientHello without waiting for a server reset. */
+    if (!session_move_pre_start(session, ks, true))
+    {
+        return false;
+    }
+    ks->state = S_PRE_START;
+    return true;
+}
+
 /**
  * Parses the TLVs (type, length, value) in the early negotiation
  */
diff --git a/src/openvpn/ssl.h b/src/openvpn/ssl.h
index 7ddf965..a534007 100644
--- a/src/openvpn/ssl.h
+++ b/src/openvpn/ssl.h
@@ -576,4 +576,21 @@
 bool session_skip_to_pre_start(struct tls_session *session, struct tls_pre_decrypt_state *state,
                                struct link_socket_actual *from);
 
+/**
+ * Client counterpart of session_skip_to_pre_start(): start the handshake from a
+ * server session id (SYN-cookie) learned earlier from an OOB server probe
+ * reply, which already served as the server's HARD_RESET, so we send none.
+ *
+ * @param session     the (client) TLS session to fast-forward
+ * @param client_sid  session id used for the probe (the cookie is an HMAC over it)
+ * @param server_sid  the server's cookie, echoed back to complete the handshake
+ * @param remote      the probed (pinned) server address to connect to
+ * @param resend_wkc  true to complete with P_CONTROL_WKC_V1 (tls-crypt-v2)
+ * @return true if the session was fast-forwarded, false on failure
+ */
+bool session_skip_to_pre_start_client(struct tls_session *session,
+                                      const struct session_id *client_sid,
+                                      const struct session_id *server_sid,
+                                      const struct openvpn_sockaddr *remote, bool resend_wkc);
+
 #endif /* ifndef OPENVPN_SSL_H */

-- 
To view, visit http://gerrit.openvpn.net/c/openvpn/+/1770?usp=email
To unsubscribe, or for help writing mail filters, visit http://gerrit.openvpn.net/settings?usp=email

Gerrit-MessageType: newpatchset
Gerrit-Project: openvpn
Gerrit-Branch: master
Gerrit-Change-Id: I454d5040cbad4d373ee4f90b8d683200d2a4c0e4
Gerrit-Change-Number: 1770
Gerrit-PatchSet: 9
Gerrit-Owner: stipa <[email protected]>
Gerrit-Reviewer: plaisthos <[email protected]>
Gerrit-CC: openvpn-devel <[email protected]>
Gerrit-Attention: plaisthos <[email protected]>
Gerrit-Attention: stipa <[email protected]>

_______________________________________________
Openvpn-devel mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/openvpn-devel