[Openvpn-devel] [M] Change in openvpn[master]: oob: Unwrap tls-crypt-v2 server probes (P_CONTROL_OOB_WKC_V1)

"stipa \(Code Review\) via Openvpn-devel" <[email protected]> Tue, 28 Jul 2026 15:03:23 +0000
Newsgroups net.sourceforge.lists.openvpn-devel
Message-ID <7eede3d0c1f4ceef51fa64e962676f8de6e16b35-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/+/1758?usp=email

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

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


Change subject: oob: Unwrap tls-crypt-v2 server probes (P_CONTROL_OOB_WKC_V1)
......................................................................

oob: Unwrap tls-crypt-v2 server probes (P_CONTROL_OOB_WKC_V1)

A tls-crypt-v2 server learns the per-client key only from the wrapped
client key (WKc). An out-of-band probe belongs to no session, so the
client must carry the WKc with the probe for the server to unwrap it.

Add the receive/protocol side of this:

  - new opcode P_CONTROL_OOB_WKC_V1 (13): like P_CONTROL_OOB_V1 but with
    the WKc appended, mirroring CONTROL_HARD_RESET_CLIENT_V3/CONTROL_WKC_V1
  - tls_wrap_control() appends the WKc for opcode 13; read_control_auth()
    runs tls_crypt_v2_extract_client_key() for it, loading the per-client
    key before unwrapping the message
  - tls_pre_decrypt_lite() returns the new VERDICT_VALID_OOB_WKC_V1, one
    verdict per opcode as elsewhere in the enum; do_pre_decrypt_check()
    groups it with VERDICT_VALID_OOB_V1 and derives the resend-WKc reply
    flag from it
  - P_LAST_OPCODE becomes 13 and opcode_is_oob() covers both OOB opcodes
  - tls_wrap_oob_standalone() takes the opcode; the server reply stays a
    plain P_CONTROL_OOB_V1 (the server already holds the key after unwrap)
  - test_pkt: round-trip test of the wrap/extract/unwrap path

The client still sends plain probes; sending P_CONTROL_OOB_WKC_V1 is added
in a following commit.

Change-Id: Idb8c0c660a98de8ec549b69f6692a7b41b092eaf
Signed-off-by: Lev Stipakov <[email protected]>
---
M src/openvpn/mudp.c
M src/openvpn/oob_client.c
M src/openvpn/ssl_pkt.c
M src/openvpn/ssl_pkt.h
M tests/unit_tests/openvpn/test_pkt.c
5 files changed, 164 insertions(+), 13 deletions(-)


  git pull ssh://gerrit.openvpn.net:29418/openvpn refs/changes/58/1758/11

diff --git a/src/openvpn/mudp.c b/src/openvpn/mudp.c
index 3e50994..2f48fff 100644
--- a/src/openvpn/mudp.c
+++ b/src/openvpn/mudp.c
@@ -109,7 +109,12 @@
     reset_packet_id_send(&state->tls_wrap_tmp.opt.packet_id.send);
     state->tls_wrap_tmp.opt.packet_id.rec.initialized = true;
 
-    struct buffer buf = tls_wrap_oob_standalone(&state->tls_wrap_tmp, tas, own_sid, &payload);
+    /* The reply carries no WKc: with tls-crypt-v2 the server has already
+     * recovered the per-client key from the request's WKc (it is loaded into
+     * state->tls_wrap_tmp), so the reply is wrapped with that key as a plain
+     * P_CONTROL_OOB_V1 message. */
+    struct buffer buf =
+        tls_wrap_oob_standalone(&state->tls_wrap_tmp, tas, own_sid, &payload, P_CONTROL_OOB_V1);
     send_standalone_reply(m, &buf, "Server Probe", "Server probe from client, sending probe reply",
                           sock);
 
@@ -135,7 +140,7 @@
     int handwindow = m->top.options.handshake_window;
 
     if (verdict == VERDICT_VALID_RESET_V3 || verdict == VERDICT_VALID_RESET_V2
-        || verdict == VERDICT_VALID_OOB_V1)
+        || verdict == VERDICT_VALID_OOB_V1 || verdict == VERDICT_VALID_OOB_WKC_V1)
     {
         /* Check if we are still below our limit for sending out
          * responses */
@@ -233,7 +238,7 @@
 
         return ret;
     }
-    else if (verdict == VERDICT_VALID_OOB_V1)
+    else if (verdict == VERDICT_VALID_OOB_V1 || verdict == VERDICT_VALID_OOB_WKC_V1)
     {
         /* Out-of-band server probe. state->newbuf points at the TLV payload
          * (read_control_auth has stripped the opcode, session id and any
diff --git a/src/openvpn/oob_client.c b/src/openvpn/oob_client.c
index 8c67da5..5700149 100644
--- a/src/openvpn/oob_client.c
+++ b/src/openvpn/oob_client.c
@@ -493,7 +493,8 @@
         return;
     }
 
-    struct buffer probe = tls_wrap_oob_standalone(&tas->tls_wrap, tas, &client_sid, &payload);
+    struct buffer probe =
+        tls_wrap_oob_standalone(&tas->tls_wrap, tas, &client_sid, &payload, P_CONTROL_OOB_V1);
     if (!BLEN(&probe))
     {
         msg(D_LOW, "server-probe: could not wrap probe packet; using configured order");
diff --git a/src/openvpn/ssl_pkt.c b/src/openvpn/ssl_pkt.c
index 335e26f..17e66e5 100644
--- a/src/openvpn/ssl_pkt.c
+++ b/src/openvpn/ssl_pkt.c
@@ -144,7 +144,8 @@
         }
 
         if ((header >> P_OPCODE_SHIFT) == P_CONTROL_HARD_RESET_CLIENT_V3
-            || (header >> P_OPCODE_SHIFT) == P_CONTROL_WKC_V1)
+            || (header >> P_OPCODE_SHIFT) == P_CONTROL_WKC_V1
+            || (header >> P_OPCODE_SHIFT) == P_CONTROL_OOB_WKC_V1)
         {
             if (!buf_copy(&ctx->work, ctx->tls_crypt_v2_wkc))
             {
@@ -198,7 +199,8 @@
     bool ret = false;
 
     const uint8_t opcode = *(BPTR(buf)) >> P_OPCODE_SHIFT;
-    if ((opcode == P_CONTROL_HARD_RESET_CLIENT_V3 || opcode == P_CONTROL_WKC_V1)
+    if ((opcode == P_CONTROL_HARD_RESET_CLIENT_V3 || opcode == P_CONTROL_WKC_V1
+         || opcode == P_CONTROL_OOB_WKC_V1)
         && !tls_crypt_v2_extract_client_key(buf, ctx, opt))
     {
         msg(D_TLS_ERRORS, "TLS Error: can not extract tls-crypt-v2 client key from %s",
@@ -389,10 +391,16 @@
     {
         return VERDICT_VALID_WKC_V1;
     }
-    else if (opcode_is_oob(op))
+    else if (op == P_CONTROL_OOB_V1)
     {
         return VERDICT_VALID_OOB_V1;
     }
+    else if (op == P_CONTROL_OOB_WKC_V1)
+    {
+        /* The WKc was unwrapped by read_control_auth() above, so the per-client
+         * key is already loaded into state->tls_wrap_tmp. */
+        return VERDICT_VALID_OOB_WKC_V1;
+    }
     else
     {
         return VERDICT_VALID_RESET_V2;
@@ -449,8 +457,10 @@
 
 struct buffer
 tls_wrap_oob_standalone(struct tls_wrap_ctx *ctx, struct tls_auth_standalone *tas,
-                        struct session_id *own_sid, const struct buffer *payload)
+                        struct session_id *own_sid, const struct buffer *payload, int opcode)
 {
+    ASSERT(opcode_is_oob(opcode));
+
     /* Copy buffer here to point at the same data but allow tls_wrap_control
      * to potentially change buf to point to another buffer without
      * modifying the buffer in tas */
@@ -461,10 +471,11 @@
      * or ACK fields. */
     ASSERT(buf_copy(&buf, payload));
 
-    uint8_t header = (uint8_t)(P_CONTROL_OOB_V1 << P_OPCODE_SHIFT);
+    uint8_t header = (uint8_t)(opcode << P_OPCODE_SHIFT);
 
     /* Add tls-auth/tls-crypt wrapping, this might replace buf with
-     * ctx->work */
+     * ctx->work. For P_CONTROL_OOB_WKC_V1 the wrapped client key is appended
+     * here too (tls-crypt-v2). */
     tls_wrap_control(ctx, header, &buf, own_sid);
 
     return buf;
diff --git a/src/openvpn/ssl_pkt.h b/src/openvpn/ssl_pkt.h
index 3ab09db..c3a2597 100644
--- a/src/openvpn/ssl_pkt.h
+++ b/src/openvpn/ssl_pkt.h
@@ -67,18 +67,26 @@
  * legal on an established session; see opcode_valid_in_session() below. */
 #define P_CONTROL_OOB_V1 12
 
+/* Variant of P_CONTROL_OOB_V1 with an appended wrapped client key (WKc), like
+ * P_CONTROL_HARD_RESET_CLIENT_V3 / P_CONTROL_WKC_V1. Used when tls-crypt-v2 is
+ * configured: an out-of-band message belongs to no established session, so the
+ * client must carry the WKc for the server to recover the per-client key and
+ * unwrap the message. See doc/tls-crypt-v2.txt and the openvpn-rfc wire
+ * protocol (CONTROL_OOB_WKC_V1). */
+#define P_CONTROL_OOB_WKC_V1 13
+
 /* define the range of defined opcodes, in- and out-of-band. Note this is not
  * the set of opcodes legal on an established session; see
  * opcode_valid_in_session().
  * Since we do no longer support key-method 1 we consider
  * the v1 op codes invalid */
 #define P_FIRST_OPCODE 3
-#define P_LAST_OPCODE  12
+#define P_LAST_OPCODE  13
 
 static inline bool
 opcode_is_oob(int op)
 {
-    return op == P_CONTROL_OOB_V1;
+    return op == P_CONTROL_OOB_V1 || op == P_CONTROL_OOB_WKC_V1;
 }
 
 /**
@@ -140,6 +148,8 @@
     /** This packet is a valid out-of-band control message (e.g. a server
      * probe). It does not belong to a session and must not create one. */
     VERDICT_VALID_OOB_V1,
+    /** as VERDICT_VALID_OOB_V1, with a wrapped client key appended (tls-crypt-v2) */
+    VERDICT_VALID_OOB_WKC_V1,
     /** the packet failed on of the various checks */
     VERDICT_INVALID
 };
@@ -278,10 +288,14 @@
  * @param tas       standalone auth context providing the work buffer
  * @param own_sid   session id to use as our session id in the header
  * @param payload   the OOB message payload (TLV stream) to wrap
+ * @param opcode    the OOB opcode to use: P_CONTROL_OOB_V1, or
+ *                  P_CONTROL_OOB_WKC_V1 to append the tls-crypt-v2 wrapped
+ *                  client key (the context must then carry it).
  * @return          the wrapped packet buffer, ready to send
  */
 struct buffer tls_wrap_oob_standalone(struct tls_wrap_ctx *ctx, struct tls_auth_standalone *tas,
-                                      struct session_id *own_sid, const struct buffer *payload);
+                                      struct session_id *own_sid, const struct buffer *payload,
+                                      int opcode);
 
 
 /**
diff --git a/tests/unit_tests/openvpn/test_pkt.c b/tests/unit_tests/openvpn/test_pkt.c
index cad2ce0..5581957 100644
--- a/tests/unit_tests/openvpn/test_pkt.c
+++ b/tests/unit_tests/openvpn/test_pkt.c
@@ -712,6 +712,125 @@
     free_tas(&tas_server);
 }
 
+/* A matching tls-crypt-v2 server/client key pair (the client key's WKc was
+ * wrapped with this server key), reused from the tls-crypt-v2 unit tests. */
+static const char *oob_v2_server_key =
+    "-----BEGIN OpenVPN tls-crypt-v2 server key-----\n"
+    "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v\n"
+    "MDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5f\n"
+    "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8=\n"
+    "-----END OpenVPN tls-crypt-v2 server key-----\n";
+
+static const char *oob_v2_client_key =
+    "-----BEGIN OpenVPN tls-crypt-v2 client key-----\n"
+    "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v\n"
+    "MDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5f\n"
+    "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6P\n"
+    "kJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/\n"
+    "wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v\n"
+    "8PHy8/T19vf4+fr7/P3+/xd9pcB0qUYZsWvkrLcfGmzPJPM8a7r0mEWdXwbDadSV\n"
+    "LHg5bv2TwlmPR3HgaMr8o9LTh9hxUTkrH3S0PfKRNwcso86ua/dBFTyXsM9tg4aw\n"
+    "3dS6ogH9AkaT+kRRDgNcKWkQCbwmJK2JlfkXHBwbAtmn78AkNuho6QCFqCdqGab3\n"
+    "zh2vheFqGMPdGpukbFrT3rcO3VLxUeG+RdzXiMTCpJSovFBP1lDkYwYJPnz6daEh\n"
+    "j0TzJ3BVru9W3CpotdNt7u09knxAfpCxjtrP3semsDew/gTBtcfQ/OoTFyFHnN5k\n"
+    "RZ+q17SC4nba3Pp8/Fs0+hSbv2tJozoD8SElFq7SIWJsciTYh8q8f5yQxjdt4Wxu\n"
+    "/Z5wtPCAZ0tOzj4ItTI77fBOYRTfEayzHgEr\n"
+    "-----END OpenVPN tls-crypt-v2 client key-----\n";
+
+/* End-to-end check of the tls-crypt-v2 out-of-band probe: the client wraps an
+ * OOB payload as a P_CONTROL_OOB_WKC_V1 message (the payload encrypted with the
+ * per-client key Kc, with the wrapped client key WKc appended), and the server's
+ * stateless first-packet path (tls_pre_decrypt_lite) recovers Kc from the WKc,
+ * decrypts the payload, and returns VERDICT_VALID_OOB_V1. */
+static void
+test_oob_wkc_v1_roundtrip(void **ut_state)
+{
+    now = 0x5f000000;
+
+    struct frame frame = { .buf = { .headroom = 200, .payload_size = 1400 }, 0 };
+
+    /* ---- Client: wrap an OOB probe as P_CONTROL_OOB_WKC_V1 (WKc appended) ---- */
+    struct tls_auth_standalone tas_client = { 0 };
+    tas_client.frame = frame;
+    tas_client.tls_wrap.mode = TLS_WRAP_CRYPT;
+    tas_client.tls_wrap.opt.flags |= (CO_IGNORE_PACKET_ID | CO_PACKET_ID_LONG_FORM);
+    packet_id_init(&tas_client.tls_wrap.opt.packet_id, 0, 0, "UT-CLIENT", 0);
+    tas_client.workbuf = alloc_buf(1600);
+    tas_client.tls_wrap.work = alloc_buf(1600);
+
+    /* Load Kc and obtain the WKc, exactly as a tls-crypt-v2 client does. */
+    struct buffer wkc = { 0 };
+    tls_crypt_v2_init_client_key(&tas_client.tls_wrap.opt.key_ctx_bi,
+                                 &tas_client.tls_wrap.original_wrap_keydata, &wkc, oob_v2_client_key,
+                                 true);
+    tas_client.tls_wrap.tls_crypt_v2_wkc = &wkc;
+
+    const uint8_t payload_bytes[] = { 0x01, 0x00, 0x02, 0x00, 0x0c, 0xde,
+                                      0xad, 0xbe, 0xef, 0x00, 0x11, 0x22 };
+    struct buffer payload = alloc_buf(64);
+    buf_write(&payload, payload_bytes, sizeof(payload_bytes));
+
+    struct session_id client_sid = { { 1, 2, 3, 4, 5, 6, 7, 8 } };
+    reset_packet_id_send(&tas_client.tls_wrap.opt.packet_id.send);
+
+    struct buffer probe = tls_wrap_oob_standalone(&tas_client.tls_wrap, &tas_client, &client_sid,
+                                                  &payload, P_CONTROL_OOB_WKC_V1);
+    assert_true(BLEN(&probe) > 0);
+    assert_int_equal((BPTR(&probe))[0] >> P_OPCODE_SHIFT, P_CONTROL_OOB_WKC_V1);
+
+    /* ---- Server: stateless path recovers Kc from the WKc and decrypts ---- */
+    struct tls_auth_standalone tas_server = { 0 };
+    tas_server.frame = frame;
+    /* No static wrapping, only a tls-crypt-v2 server key (as a v2 server runs). */
+    tas_server.tls_wrap.mode = TLS_WRAP_NONE;
+    tas_server.tls_wrap.opt.flags |= (CO_IGNORE_PACKET_ID | CO_PACKET_ID_LONG_FORM);
+    packet_id_init(&tas_server.tls_wrap.opt.packet_id, 0, 0, "UT-SERVER", 0);
+    tls_crypt_v2_init_server_key(&tas_server.tls_wrap.tls_crypt_v2_server_key, true, oob_v2_server_key,
+                                 true);
+    tas_server.workbuf = alloc_buf(1600);
+
+    struct link_socket_actual from = { 0 };
+    struct buffer srv = alloc_buf(1600);
+    buf_write(&srv, BPTR(&probe), BLEN(&probe));
+
+    struct tls_pre_decrypt_state state = { 0 };
+    enum first_packet_verdict verdict = tls_pre_decrypt_lite(&tas_server, &state, &from, &srv);
+    assert_int_equal(verdict, VERDICT_VALID_OOB_WKC_V1);
+
+    /* The decrypted payload must equal what the client sent, and the client's
+     * session id must be recovered. */
+    assert_int_equal(BLEN(&state.newbuf), (int)sizeof(payload_bytes));
+    assert_memory_equal(BPTR(&state.newbuf), payload_bytes, sizeof(payload_bytes));
+    assert_memory_equal(state.peer_session_id.id, client_sid.id, SID_SIZE);
+    free_tls_pre_decrypt_state(&state);
+
+    /* Flipping any single byte (header, session id, ciphertext or WKc) must make
+     * the probe fail to validate. */
+    for (size_t i = 0; i < (size_t)BLEN(&probe); i++)
+    {
+        struct tls_pre_decrypt_state tstate = { 0 };
+        buf_reset_len(&srv);
+        buf_write(&srv, BPTR(&probe), BLEN(&probe));
+        (BPTR(&srv))[i] ^= 0xff;
+        enum first_packet_verdict v = tls_pre_decrypt_lite(&tas_server, &tstate, &from, &srv);
+        assert_int_equal(v, VERDICT_INVALID);
+        free_tls_pre_decrypt_state(&tstate);
+    }
+
+    free_key_ctx_bi(&tas_client.tls_wrap.opt.key_ctx_bi);
+    packet_id_free(&tas_client.tls_wrap.opt.packet_id);
+    free_buf(&wkc);
+    free_buf(&payload);
+    free_buf(&tas_client.workbuf);
+    free_buf(&tas_client.tls_wrap.work);
+
+    free_key_ctx(&tas_server.tls_wrap.tls_crypt_v2_server_key);
+    packet_id_free(&tas_server.tls_wrap.opt.packet_id);
+    free_buf(&tas_server.workbuf);
+
+    free_buf(&srv);
+}
+
 static void
 test_extract_control_message(void **ut_state)
 {
@@ -762,6 +881,7 @@
         cmocka_unit_test(test_verify_hmac_none_out_of_range_ack),
         cmocka_unit_test(test_generate_reset_packet_plain),
         cmocka_unit_test(test_generate_reset_packet_tls_auth),
+        cmocka_unit_test(test_oob_wkc_v1_roundtrip),
         cmocka_unit_test(test_extract_control_message)
     };
 

-- 
To view, visit http://gerrit.openvpn.net/c/openvpn/+/1758?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: Idb8c0c660a98de8ec549b69f6692a7b41b092eaf
Gerrit-Change-Number: 1758
Gerrit-PatchSet: 11
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