Commit 3acf8e9a1a for openssl.org

commit 3acf8e9a1ac045988afdeb548013b6008aaaf0e1
Author: Viktor Dukhovni <openssl-users@dukhovni.org>
Date:   Wed Feb 18 21:59:04 2026 +1100

    Added -expected-rpks s_client/server option

    Reviewed-by: Matt Caswell <matt@openssl.org>
    Reviewed-by: Tomas Mraz <tomas@openssl.org>
    MergeDate: Sat Feb 28 09:17:23 2026
    (Merged from https://github.com/openssl/openssl/pull/30089)

diff --git a/CHANGES.md b/CHANGES.md
index b54920d1df..cb00639fff 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -31,6 +31,12 @@ OpenSSL Releases

 ### Changes between 3.6 and 4.0 [xx XXX xxxx]

+ * New `-expected-rpks` option in the `openssl-s_client(1)` and `openssl-s_server(1)`
+   command line utilities.  This makes it possible to specify one more public keys
+   expected from the remote peer that are then used to authenticate the connection.
+
+   *Viktor Dukhovni*
+
  * Fixed bug that allowed TLS 1.2 ciphers to be added to the TLS 1.3
    ciphersuites list, and for that list to contain duplicates.
    Cipher configuration strings for both TLS 1.2 and 1.3 are now
diff --git a/apps/include/apps.h b/apps/include/apps.h
index f677f74bf5..670e66c161 100644
--- a/apps/include/apps.h
+++ b/apps/include/apps.h
@@ -163,6 +163,7 @@ int load_key_certs_crls(const char *uri, int format, int maybe_stdin,
     EVP_SKEY **pskey);
 EVP_SKEY *load_skey(const char *uri, int format, int maybe_stdin,
     const char *pass, int quiet);
+int load_rpk_file(SSL *ssl, const char *file);
 X509_STORE *setup_verify(const char *CAfile, int noCAfile,
     const char *CApath, int noCApath,
     const char *CAstore, int noCAstore);
diff --git a/apps/lib/apps.c b/apps/lib/apps.c
index 211301b747..0e9e1bf7b6 100644
--- a/apps/lib/apps.c
+++ b/apps/lib/apps.c
@@ -1165,6 +1165,112 @@ end:
     return 1;
 }

+int load_rpk_file(SSL *ssl, const char *file)
+{
+    BIO *in = BIO_new_file(file, "r");
+    char *name = NULL;
+    char *header = NULL;
+    unsigned char *buf = NULL;
+    long buflen;
+    int ret = 1;
+    int found = 0;
+
+    if (in == NULL)
+        return ret;
+
+    ERR_set_mark();
+    while (ret != 0) {
+        PKCS8_PRIV_KEY_INFO *p8;
+        EVP_PKEY *pkey = NULL;
+        X509 *cert = NULL;
+        const unsigned char *p;
+        int pkey_type = NID_undef;
+        X509 *(*d2i)(X509 **, const unsigned char **, long);
+
+        OPENSSL_free(name);
+        OPENSSL_free(header);
+        OPENSSL_free(buf);
+        name = header = NULL;
+        buf = NULL;
+
+        if (!PEM_read_bio(in, &name, &header, &buf, &buflen)) {
+            if (ERR_GET_REASON(ERR_peek_last_error()) != PEM_R_NO_START_LINE)
+                ret = 0;
+            break;
+        }
+        p = buf;
+
+#define WHEN_PEM_X509(name, f)                \
+    ((d2i = f),                               \
+        (strcmp((name), PEM_STRING_X509) == 0 \
+            || strcmp((name), PEM_STRING_X509_OLD) == 0))
+
+#define WHEN_PEM_X509_AUX(name, f) \
+    ((d2i = f),                    \
+        (strcmp((name), PEM_STRING_X509_TRUSTED) == 0))
+
+#define WHEN_PKEY_TYPE(name, str, type) \
+    ((pkey_type = type), (strcmp((name), str) == 0))
+
+        if (strcmp(name, PEM_STRING_PUBLIC) == 0) {
+            pkey = d2i_PUBKEY(NULL, &p, buflen);
+            if (p - buf != buflen || pkey == NULL) {
+                BIO_printf(bio_err, "Error reading %s in %s\n", name, file);
+                ret = 0;
+            } else if (!SSL_add_expected_rpk(ssl, pkey)) {
+                BIO_printf(bio_err, "Error adding RPK from %s in %s\n", name, file);
+                ret = 0;
+            } else {
+                found = 1;
+            }
+            EVP_PKEY_free(pkey);
+        } else if (WHEN_PEM_X509(name, d2i_X509)
+            || WHEN_PEM_X509_AUX(name, d2i_X509_AUX)) {
+            cert = d2i(NULL, &p, buflen);
+            if (p - buf != buflen || cert == NULL) {
+                BIO_printf(bio_err, "Error reading %s in %s\n", name, file);
+                ret = 0;
+            } else if ((pkey = X509_get0_pubkey(cert)) == NULL
+                || !SSL_add_expected_rpk(ssl, pkey)) {
+                BIO_printf(bio_err, "Error adding RPK from %s in %s\n", name, file);
+                ret = 0;
+            } else {
+                found = 1;
+            }
+            X509_free(cert);
+        } else if (WHEN_PKEY_TYPE(name, PEM_STRING_PKCS8INF, NID_undef) == 0
+            || WHEN_PKEY_TYPE(name, PEM_STRING_RSA, EVP_PKEY_RSA)
+            || WHEN_PKEY_TYPE(name, PEM_STRING_ECPRIVATEKEY, EVP_PKEY_EC)
+            || WHEN_PKEY_TYPE(name, PEM_STRING_DSA, EVP_PKEY_DSA)) {
+            if (pkey_type != NID_undef) {
+                pkey = d2i_PrivateKey(pkey_type, 0, &p, buflen);
+            } else {
+                if ((p8 = d2i_PKCS8_PRIV_KEY_INFO(NULL, &p, buflen)) != NULL) {
+                    pkey = EVP_PKCS82PKEY(p8);
+                    PKCS8_PRIV_KEY_INFO_free(p8);
+                }
+            }
+            if (p - buf != buflen || pkey == NULL) {
+                BIO_printf(bio_err, "Error reading %s in %s\n", name, file);
+                ret = 0;
+            } else if (!SSL_add_expected_rpk(ssl, pkey)) {
+                BIO_printf(bio_err, "Error adding RPK from %s in %s\n", name, file);
+                ret = 0;
+            } else {
+                found = 1;
+            }
+            EVP_PKEY_free(pkey);
+        }
+    }
+
+    OPENSSL_free(name);
+    OPENSSL_free(header);
+    OPENSSL_free(buf);
+    ERR_pop_to_mark();
+    BIO_free(in);
+    return found && ret;
+}
+
 #define X509V3_EXT_UNKNOWN_MASK (0xfL << 16)
 #define X509V3_EXT_DEFAULT 0 /* Return error for unknown exts */
 #define X509V3_EXT_ERROR_UNKNOWN (1L << 16) /* Print error for unknown exts */
diff --git a/apps/s_client.c b/apps/s_client.c
index 037790a21a..fe90dacf44 100644
--- a/apps/s_client.c
+++ b/apps/s_client.c
@@ -614,6 +614,7 @@ typedef enum OPTION_choice {
     OPT_ENABLE_PHA,
     OPT_ENABLE_SERVER_RPK,
     OPT_ENABLE_CLIENT_RPK,
+    OPT_EXPECTED_RPK,
     OPT_SCTP_LABEL_BUG,
     OPT_KTLS,
 #ifndef OPENSSL_NO_ECH
@@ -694,6 +695,8 @@ const OPTIONS s_client_options[] = {
         "Do not load certificates from the default certificates store" },
     { "requestCAfile", OPT_REQCAFILE, '<',
         "PEM format file of CA names to send to the server" },
+    { "expected-rpks", OPT_EXPECTED_RPK, '<',
+        "PEM file with expected server public key(s)" },
 #if defined(TCP_FASTOPEN) && !defined(OPENSSL_NO_TFO)
     { "tfo", OPT_TFO, '-', "Connect using TCP Fast Open" },
 #endif
@@ -986,6 +989,7 @@ int s_client_main(int argc, char **argv)
     SSL_EXCERT *exc = NULL;
     SSL_CONF_CTX *cctx = NULL;
     STACK_OF(OPENSSL_STRING) *ssl_args = NULL;
+    STACK_OF(OPENSSL_STRING) *rpk_files = NULL;
     char *dane_tlsa_domain = NULL;
     STACK_OF(OPENSSL_STRING) *dane_tlsa_rrset = NULL;
     int dane_ee_no_name = 0;
@@ -1727,6 +1731,13 @@ int s_client_main(int argc, char **argv)
         case OPT_ENABLE_CLIENT_RPK:
             enable_client_rpk = 1;
             break;
+        case OPT_EXPECTED_RPK:
+            if ((rpk_files == NULL
+                    && (rpk_files = sk_OPENSSL_STRING_new_null()) == NULL)
+                || !sk_OPENSSL_STRING_push(rpk_files, opt_arg()))
+                goto end;
+            enable_server_rpk = 1;
+            break;
         }
     }

@@ -2190,9 +2201,12 @@ int s_client_main(int argc, char **argv)

     if (dane_tlsa_domain != NULL) {
         if (SSL_CTX_dane_enable(ctx) <= 0) {
-            BIO_printf(bio_err,
-                "%s: Error enabling DANE TLSA authentication.\n",
-                prog);
+            BIO_printf(bio_err, "%s: Error enabling DANE TLSA authentication.\n", prog);
+            goto end;
+        }
+    } else if (rpk_files != NULL) {
+        if (SSL_CTX_dane_enable(ctx) <= 0) {
+            BIO_printf(bio_err, "%s: Error enabling RPK verification\n", prog);
             goto end;
         }
     }
@@ -2236,16 +2250,15 @@ int s_client_main(int argc, char **argv)
     if (enable_pha)
         SSL_set_post_handshake_auth(con, 1);

-    if (enable_client_rpk)
-        if (!SSL_set1_client_cert_type(con, cert_type_rpk, sizeof(cert_type_rpk))) {
-            BIO_puts(bio_err, "Error setting client certificate types\n");
-            goto end;
-        }
-    if (enable_server_rpk) {
-        if (!SSL_set1_server_cert_type(con, cert_type_rpk, sizeof(cert_type_rpk))) {
-            BIO_puts(bio_err, "Error setting server certificate types\n");
-            goto end;
-        }
+    if (enable_client_rpk
+        && !SSL_set1_client_cert_type(con, cert_type_rpk, sizeof(cert_type_rpk))) {
+        BIO_puts(bio_err, "Error setting client certificate types\n");
+        goto end;
+    }
+    if (enable_server_rpk
+        && !SSL_set1_server_cert_type(con, cert_type_rpk, sizeof(cert_type_rpk))) {
+        BIO_puts(bio_err, "Error setting server certificate types\n");
+        goto end;
     }

 #ifndef OPENSSL_NO_ECH
@@ -2356,26 +2369,39 @@ int s_client_main(int argc, char **argv)
                 prog);
             goto end;
         }
-        if (dane_tlsa_rrset == NULL) {
+        if (dane_tlsa_rrset != NULL) {
+            if (tlsa_import_rrset(con, dane_tlsa_rrset) <= 0) {
+                BIO_printf(bio_err, "%s: Failed to import any TLSA "
+                                    "records.\n",
+                    prog);
+                goto end;
+            }
+        } else if (rpk_files == NULL) {
             BIO_printf(bio_err, "%s: DANE TLSA authentication requires at "
-                                "least one -dane_tlsa_rrdata option.\n",
-                prog);
-            goto end;
-        }
-        if (tlsa_import_rrset(con, dane_tlsa_rrset) <= 0) {
-            BIO_printf(bio_err, "%s: Failed to import any TLSA "
-                                "records.\n",
+                                "least one -dane_tlsa_rrdata option, or else "
+                                "at least one -expected_rpks option.\n",
                 prog);
             goto end;
         }
         if (dane_ee_no_name)
             SSL_dane_set_flags(con, DANE_FLAG_NO_DANE_EE_NAMECHECKS);
-    } else if (dane_tlsa_rrset != NULL) {
+    } else if (rpk_files == NULL && dane_tlsa_rrset != NULL) {
         BIO_printf(bio_err, "%s: DANE TLSA authentication requires the "
                             "-dane_tlsa_domain option.\n",
             prog);
         goto end;
     }
+
+    if (rpk_files != NULL) {
+        if (dane_tlsa_domain == NULL
+            && !SSL_dane_enable(con, noservername ? NULL : servername)) {
+            BIO_puts(bio_err, "Error enabling server RPK verification\n");
+            goto end;
+        }
+        for (i = 0; i < sk_OPENSSL_STRING_num(rpk_files); ++i)
+            if (!load_rpk_file(con, sk_OPENSSL_STRING_value(rpk_files, i)))
+                goto end;
+    }
 #ifndef OPENSSL_NO_DTLS
     if (isdtls && tfo) {
         BIO_printf(bio_err, "%s: DTLS does not support the -tfo option\n", prog);
@@ -3535,6 +3561,7 @@ end:
     X509_VERIFY_PARAM_free(vpm);
     ssl_excert_free(exc);
     sk_OPENSSL_STRING_free(ssl_args);
+    sk_OPENSSL_STRING_free(rpk_files);
     sk_OPENSSL_STRING_free(dane_tlsa_rrset);
     SSL_CONF_CTX_free(cctx);
     OPENSSL_clear_free(cbuf, BUFSIZZ);
diff --git a/apps/s_server.c b/apps/s_server.c
index 903e436e23..b59e7f8bfc 100644
--- a/apps/s_server.c
+++ b/apps/s_server.c
@@ -116,6 +116,7 @@ static int s_nbio_test = 0;
 static int s_crlf = 0;
 static SSL_CTX *ctx = NULL;
 static SSL_CTX *ctx2 = NULL;
+static STACK_OF(OPENSSL_STRING) *rpk_files = NULL;
 static int www = 0;

 static BIO *bio_s_out = NULL;
@@ -1287,6 +1288,7 @@ typedef enum OPTION_choice {
     OPT_CERT_COMP,
     OPT_ENABLE_SERVER_RPK,
     OPT_ENABLE_CLIENT_RPK,
+    OPT_EXPECTED_RPK,
 #ifndef OPENSSL_NO_ECH
     OPT_ECH_PEM,
     OPT_ECH_DIR,
@@ -1407,6 +1409,8 @@ const OPTIONS s_server_options[] = {
         "use dir as certificate store path to verify CA certificate" },
     { "verifyCAstore", OPT_VERIFYCASTORE, ':',
         "use URI as certificate store to verify CA certificate" },
+    { "expected-rpks", OPT_EXPECTED_RPK, '<',
+        "PEM file with expected client public key(s)" },
     { "no_cache", OPT_NO_CACHE, '-', "Disable session cache" },
     { "ext_cache", OPT_EXT_CACHE, '-',
         "Disable internal cache, set up and use external cache" },
@@ -2378,6 +2382,13 @@ int s_server_main(int argc, char *argv[])
         case OPT_ENABLE_CLIENT_RPK:
             enable_client_rpk = 1;
             break;
+        case OPT_EXPECTED_RPK:
+            if ((rpk_files == NULL
+                    && (rpk_files = sk_OPENSSL_STRING_new_null()) == NULL)
+                || !sk_OPENSSL_STRING_push(rpk_files, opt_arg()))
+                goto end;
+            enable_client_rpk = 1;
+            break;
         }
     }

@@ -3075,16 +3086,20 @@ int s_server_main(int argc, char *argv[])
         if (ctx2 != NULL && !SSL_CTX_compress_certs(ctx2, 0))
             BIO_puts(bio_s_out, "Error compressing certs on ctx2\n");
     }
-    if (enable_server_rpk)
-        if (!SSL_CTX_set1_server_cert_type(ctx, cert_type_rpk, sizeof(cert_type_rpk))) {
-            BIO_puts(bio_s_out, "Error setting server certificate types\n");
-            goto end;
-        }
-    if (enable_client_rpk)
-        if (!SSL_CTX_set1_client_cert_type(ctx, cert_type_rpk, sizeof(cert_type_rpk))) {
-            BIO_puts(bio_s_out, "Error setting server certificate types\n");
-            goto end;
-        }
+    if (enable_server_rpk
+        && !SSL_CTX_set1_server_cert_type(ctx, cert_type_rpk, sizeof(cert_type_rpk))) {
+        BIO_puts(bio_err, "Error setting server certificate types\n");
+        goto end;
+    }
+    if (enable_client_rpk
+        && !SSL_CTX_set1_client_cert_type(ctx, cert_type_rpk, sizeof(cert_type_rpk))) {
+        BIO_puts(bio_err, "Error setting client certificate types\n");
+        goto end;
+    }
+    if (rpk_files != NULL && SSL_CTX_dane_enable(ctx) <= 0) {
+        BIO_puts(bio_err, "Error enabling RPK verification\n");
+        goto end;
+    }

     if (rev)
         server_cb = rev_body;
@@ -3133,6 +3148,7 @@ end:
     OPENSSL_free(alpn_ctx.data);
     ssl_excert_free(exc);
     sk_OPENSSL_STRING_free(ssl_args);
+    sk_OPENSSL_STRING_free(rpk_files);
     SSL_CONF_CTX_free(cctx);
     BIO_free(bio_s_out);
     bio_s_out = NULL;
@@ -3197,6 +3213,19 @@ static long int count_reads_callback(BIO *bio, int cmd, const char *argp, size_t
     return ret;
 }

+static int rpk_enable(SSL *con)
+{
+    if (!SSL_dane_enable(con, NULL))
+        return 0;
+    for (int i = 0; i < sk_OPENSSL_STRING_num(rpk_files); ++i) {
+        const char *file = sk_OPENSSL_STRING_value(rpk_files, i);
+
+        if (!load_rpk_file(con, file))
+            return 0;
+    }
+    return 1;
+}
+
 static int sv_body(int s, int stype, int prot, unsigned char *context)
 {
     char *buf = NULL;
@@ -3250,6 +3279,13 @@ static int sv_body(int s, int stype, int prot, unsigned char *context)
         ret = -1;
         goto err;
     }
+
+    if (rpk_files != NULL && !rpk_enable(con)) {
+        BIO_puts(bio_err, "Error enabling client RPK verification\n");
+        ret = -1;
+        goto err;
+    }
+
 #ifndef OPENSSL_NO_DTLS
     if (isdtls) {
 #ifndef OPENSSL_NO_SCTP
@@ -3964,6 +4000,11 @@ static int www_body(int s, int stype, int prot, unsigned char *context)
         goto err;
     }

+    if (rpk_files != NULL && !rpk_enable(con)) {
+        BIO_puts(bio_err, "Error enabling client RPK verification\n");
+        goto err;
+    }
+
     sbio = BIO_new_socket(s, BIO_NOCLOSE);
     if (sbio == NULL) {
         SSL_free(con);
@@ -4481,6 +4522,12 @@ static int rev_body(int s, int stype, int prot, unsigned char *context)
         goto err;
     }

+    if (rpk_files != NULL && !rpk_enable(con)) {
+        BIO_puts(bio_err, "Error enabling client RPK verification\n");
+        ERR_print_errors(bio_err);
+        goto err;
+    }
+
     sbio = BIO_new_socket(s, BIO_NOCLOSE);
     if (sbio == NULL) {
         SSL_free(con);
diff --git a/doc/man1/openssl-s_client.pod.in b/doc/man1/openssl-s_client.pod.in
index 87d540aee1..05326b5a13 100644
--- a/doc/man1/openssl-s_client.pod.in
+++ b/doc/man1/openssl-s_client.pod.in
@@ -122,6 +122,7 @@ B<openssl> B<s_client>
 {- $OpenSSL::safe::opt_v_synopsis -}
 [B<-enable_server_rpk>]
 [B<-enable_client_rpk>]
+[B<-expected-rpks>]
 [I<host>:I<port>]
 [B<-ech_config_list>]
 [B<-ech_outer_alpn> I<protocols>]
@@ -374,7 +375,8 @@ Enable RFC6698/RFC7671 DANE TLSA authentication and specify the
 TLSA base domain which becomes the default SNI hint and the primary
 reference identifier for hostname checks.  This must be used in
 combination with at least one instance of the B<-dane_tlsa_rrdata>
-option below.
+option below, or else at least one B<-expected-rpks> option (from
+which associated TLSA "3 1 0" records are synthesised internally).

 When DANE authentication succeeds, the diagnostic output will include
 the lowest (closest to 0) depth at which a TLSA record authenticated
@@ -816,6 +818,28 @@ provided a suitable key and public certificate pair is configured.
 Some servers may nevertheless not request any client credentials,
 or may request a certificate.

+=item B<-expected-rpks> I<file>
+
+This option implies the B<-enable_server_rpk> option and can be specified
+multiple times.
+Each PEM I<file> should contain one or more public keys, private keys or
+certificates.
+With a private key or certificate an attempt is made to extract the associated
+public key.
+Each resulting public key is added (via a call to L<SSL_add_expected_rpk(3)> as
+a valid raw public key that the server may present to be considered verified.
+If a server nevertheless presents an X.509 certificate, the enclosed public key
+is validated as though it were presented as a raw public key instead.
+
+If the B<-dane_tlsa_domain> and B<-dane_tlsa_rrdata> options (with certificate
+usage B<DANE-EE(3)> and selector B<SPKI(1)>) are also used then the specified
+TLSA records are used in combination with the keys found via the
+B<-expected-rpks> option(s).
+
+Verification success or failure is reported as a DANE verification success or
+failure, because verification of raw public keys is internally mapped to
+verification of DANE TLSA records derived from the specified keys.
+
 =item I<host>:I<port>

 Rather than providing B<-connect>, the target host and optional port may
@@ -1109,6 +1133,8 @@ options were added in OpenSSL 3.6.

 The B<ech> options were added in OpenSSL 4.0.

+The B<-expected-rpks> option was added in OpenSSL 4.0.
+
 =head1 COPYRIGHT

 Copyright 2000-2025 The OpenSSL Project Authors. All Rights Reserved.
diff --git a/doc/man1/openssl-s_server.pod.in b/doc/man1/openssl-s_server.pod.in
index 55c17d9511..2eea0ceb68 100644
--- a/doc/man1/openssl-s_server.pod.in
+++ b/doc/man1/openssl-s_server.pod.in
@@ -135,6 +135,7 @@ B<openssl> B<s_server>
 {- $OpenSSL::safe::opt_provider_synopsis -}
 [B<-enable_server_rpk>]
 [B<-enable_client_rpk>]
+[B<-expected-rpks>]
 [B<-ech_key> I<filename>]
 [B<-ech_dir> I<dirname>]
 [B<-ech_noretry_dir> I<dirname>]
@@ -820,7 +821,24 @@ support raw public keys may elect to use them.
 Clients that don't support raw public keys or prefer to use X.509
 certificates can still elect to send X.509 certificates as usual.

-Raw public keys are extracted from the configured certificate/private key.
+=item B<-expected-rpks> I<file>
+
+This option implies the B<-enable_client_rpk> option and can be specified
+multiple times.
+Each PEM I<file> should contain one or more public keys, private keys or
+certificates.
+With a private key or certificate an attempt is made to extract the associated
+public key.
+Each resulting public key is added (via a call to L<SSL_add_expected_rpk(3)> as
+a valid raw public key that the client may present to be considered verified.
+If a client nevertheless presents an X.509 certificate, the enclosed public key
+is validated as though it were presented as a raw public key instead.
+
+Verification success or failure is reported as a DANE verification success or
+failure, because verification of raw public keys is internally mapped to
+verification of DANE TLSA records derived from the specified keys.
+
+=item I<host>:I<port>

 =item B<-ech_key> I<filename>

@@ -964,7 +982,9 @@ The B<-status_all> option was added in OpenSSL 3.6.

 The B<ech> options were added in OpenSSL 4.0.

-The B<engine> option was removed in OpenSSL 4.0.
+The B<-engine> option was removed in OpenSSL 4.0.
+
+The B<-expected-rpks> option was added in OpenSSL 4.0.

 =head1 COPYRIGHT

diff --git a/ssl/t1_trce.c b/ssl/t1_trce.c
index 0f10dda34f..973d899c5e 100644
--- a/ssl/t1_trce.c
+++ b/ssl/t1_trce.c
@@ -1330,33 +1330,46 @@ static int ssl_print_certificate(BIO *bio, const SSL_CONNECTION *sc, int indent,
     return 1;
 }

-static int ssl_print_raw_public_key(BIO *bio, const SSL *ssl, int server,
-    int indent, const unsigned char **pmsg,
-    size_t *pmsglen)
+static int ssl_print_raw_public_key(BIO *bio, const SSL_CONNECTION *sc,
+    int server, int indent, const unsigned char **pmsg, size_t *pmsglen)
 {
     EVP_PKEY *pkey;
     size_t clen;
     const unsigned char *msg = *pmsg;
     size_t msglen = *pmsglen;
+    int has_spki_len;

-    if (msglen < 3)
-        return 0;
-    clen = (msg[0] << 16) | (msg[1] << 8) | msg[2];
-    if (msglen < clen + 3)
-        return 0;
-
-    msg += 3;
+    /*
+     * In TLS 1.2 and prior the SPKI is the entire payload of the extension,
+     * and does not have a separate length prefix
+     */
+    has_spki_len = SSL_CONNECTION_IS_DTLS(sc)
+        ? DTLS_VERSION_GT(sc->version, DTLS1_2_VERSION)
+        : sc->version > TLS1_2_VERSION;
+    if (has_spki_len) {
+        if (msglen < 3)
+            return 0;
+        clen = (msg[0] << 16) | (msg[1] << 8) | msg[2];
+        if (msglen < clen + 3)
+            return 0;
+        msg += 3;
+        *pmsg += clen + 3;
+        *pmsglen -= clen + 3;
+    } else {
+        clen = msglen;
+        *pmsg += msglen;
+        *pmsglen -= msglen;
+    }

     BIO_indent(bio, indent, 80);
     BIO_printf(bio, "raw_public_key, length=%d\n", (int)clen);

-    pkey = d2i_PUBKEY_ex(NULL, &msg, (long)clen, ssl->ctx->libctx, ssl->ctx->propq);
+    pkey = d2i_PUBKEY_ex(NULL, &msg, (long)clen,
+        sc->ssl.ctx->libctx, sc->ssl.ctx->propq);
     if (pkey == NULL)
         return 0;
     EVP_PKEY_print_public(bio, pkey, indent + 2, NULL);
     EVP_PKEY_free(pkey);
-    *pmsg += clen + 3;
-    *pmsglen -= clen + 3;
     return 1;
 }

@@ -1378,7 +1391,7 @@ static int ssl_print_certificates(BIO *bio, const SSL_CONNECTION *sc, int server
     msg += 3;
     if ((server && sc->ext.server_cert_type == TLSEXT_cert_type_rpk)
         || (!server && sc->ext.client_cert_type == TLSEXT_cert_type_rpk)) {
-        if (!ssl_print_raw_public_key(bio, &sc->ssl, server, indent, &msg, &clen))
+        if (!ssl_print_raw_public_key(bio, sc, server, indent, &msg, &clen))
             return 0;
         if (SSL_CONNECTION_IS_TLS13(sc)
             && !ssl_print_extensions(bio, indent + 2, server,
diff --git a/test/recipes/70-test_expected_rpk.t b/test/recipes/70-test_expected_rpk.t
new file mode 100644
index 0000000000..1fb3a67ca6
--- /dev/null
+++ b/test/recipes/70-test_expected_rpk.t
@@ -0,0 +1,61 @@
+#! /usr/bin/env perl
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License").  You may not use
+# this file except in compliance with the License.  You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+use strict;
+use OpenSSL::Test qw/:DEFAULT cmdstr srctop_file bldtop_dir/;
+use OpenSSL::Test::Utils;
+use TLSProxy::Proxy;
+use Cwd qw(abs_path);
+
+my $test_name = "test_expected_rpk";
+setup($test_name);
+
+$ENV{OPENSSL_MODULES} = abs_path(bldtop_dir("test"));
+
+plan skip_all => "TLSProxy isn't usable on $^O"
+    if $^O =~ /^(VMS)$/;
+
+plan skip_all => "$test_name needs the module feature enabled"
+    if disabled("module");
+
+plan skip_all => "$test_name needs the sock feature enabled"
+    if disabled("sock");
+
+plan tests => 2;
+
+my $proxy = TLSProxy::Proxy->new(
+    sub { return; },
+    cmdstr(app(["openssl"]), display => 1),
+    srctop_file("apps", "server.pem"),
+    (!$ENV{HARNESS_ACTIVE} || $ENV{HARNESS_VERBOSE})
+);
+
+SKIP: {
+    skip "No TLS 1.2 support in this OpenSSL build", 1 if disabled("tls1_2");
+    $proxy->clear();
+    $proxy->clientflags("-tls1_2 -verify 1 -verify_return_error -enable_client_rpk".
+                        " -cert ". srctop_file("apps", "server.pem").
+                        " -expected-rpks ". srctop_file("apps", "server.pem"));
+    $proxy->serverflags("-tls1_2 -Verify 1 -verify_return_error -enable_server_rpk".
+                        " -expected-rpks ". srctop_file("apps", "server.pem"));
+
+    $proxy->start() or plan skip_all => "Unable to start up Proxy for tests";
+    ok(TLSProxy::Message->success, "Verified TLS 1.2 mutual RPK");
+}
+
+SKIP: {
+    skip "No TLS 1.3 support in this OpenSSL build", 1 if disabled("tls1_3");
+    $proxy->clear();
+    $proxy->clientflags("-tls1_3 -verify 1 -verify_return_error -enable_client_rpk".
+                        " -cert ". srctop_file("apps", "server.pem").
+                        " -expected-rpks ". srctop_file("apps", "server.pem"));
+    $proxy->serverflags("-tls1_3 -Verify 1 -verify_return_error -enable_server_rpk".
+                        " -expected-rpks ". srctop_file("apps", "server.pem"));
+    $proxy->start();
+    ok(TLSProxy::Message->success, "Verified TLS 1.3 mutual RPK");
+}
diff --git a/util/perl/TLSProxy/Certificate.pm b/util/perl/TLSProxy/Certificate.pm
index a0c01bd97e..6d4fe0f326 100644
--- a/util/perl/TLSProxy/Certificate.pm
+++ b/util/perl/TLSProxy/Certificate.pm
@@ -1,4 +1,4 @@
-# Copyright 2016-2025 The OpenSSL Project Authors. All Rights Reserved.
+# Copyright 2016-2026 The OpenSSL Project Authors. All Rights Reserved.
 #
 # Licensed under the Apache License 2.0 (the "License").  You may not use
 # this file except in compliance with the License.  You can obtain a copy
@@ -12,6 +12,14 @@ package TLSProxy::Certificate;
 use vars '@ISA';
 push @ISA, 'TLSProxy::Message';

+use constant {
+    TLSEXT_cert_type_x509 => 0,
+    TLSEXT_cert_type_rpk  => 2,
+};
+
+my $client_cert_type = TLSEXT_cert_type_x509;
+my $server_cert_type = TLSEXT_cert_type_x509;
+
 sub new
 {
     my $class = shift;
@@ -40,6 +48,7 @@ sub new
     $self->{first_certificate} = "";
     $self->{extension_data} = "";
     $self->{remaining_certdata} = "";
+    $self->{rpk} = "";

     return $self;
 }
@@ -48,6 +57,9 @@ sub parse
 {
     my $self = shift;

+    my $type = $self->server() ?
+        $self->server_type() : $self->client_type();
+
     if (TLSProxy::Proxy->is_tls13()) {
         my $context_len = unpack('C', $self->data);
         my $context = substr($self->data, 1, $context_len);
@@ -97,13 +109,26 @@ sub parse
         $remdata = substr($remdata, $extensions_len);

         $self->context($context);
-        $self->first_certificate($certdata);
         $self->extension_data(\%extensions);
-        $self->remaining_certdata($remdata);
+        if ($type == TLSEXT_cert_type_x509) {
+            $self->first_certificate($certdata);
+            $self->remaining_certdata($remdata);
+        } elsif ($type == TLSEXT_cert_type_rpk) {
+            die "Post %s RPK content\n", $self->server() ? "server" : "client"
+                if ($remdata ne "");
+            $self->rpk($certdata);
+        } else {
+            die "Unsupported %s certificate type: %d\n",
+                $self->server() ? "server" : "client", $type;
+        }

         print "    Context:".$context."\n";
         print "    Certificate List Len:".$certlistlen."\n";
-        print "    Certificate Len:".$certlen."\n";
+        if ($type == TLSEXT_cert_type_x509) {
+            print "    Certificate Len:".$certlen."\n";
+        } else {
+            print "    RPK Len:".$certlen."\n";
+        }
         print "    Extensions Len:".$extensions_len."\n";
     } else {
         my ($hicertlistlen, $certlistlen) = unpack('Cn', $self->data);
@@ -114,22 +139,33 @@ sub parse
         die "Invalid Certificate List length"
             if length($remdata) != $certlistlen;

-        my ($hicertlen, $certlen) = unpack('Cn', $remdata);
-        $certlen += ($hicertlen << 16);
+        if ($type == TLSEXT_cert_type_x509) {
+            # X.509 Chain
+            my ($hicertlen, $certlen) = unpack('Cn', $remdata);
+            $certlen += ($hicertlen << 16);

-        die "Certificate too long" if ($certlen + 3) > $certlistlen;
+            die "Certificate too long" if ($certlen + 3) > $certlistlen;

-        $remdata = substr($remdata, 3);
+            $remdata = substr($remdata, 3);

-        my $certdata = substr($remdata, 0, $certlen);
+            my $certdata = substr($remdata, 0, $certlen);

-        $remdata = substr($remdata, $certlen);
+            $remdata = substr($remdata, $certlen);

-        $self->first_certificate($certdata);
-        $self->remaining_certdata($remdata);
+            $self->first_certificate($certdata);
+            $self->remaining_certdata($remdata);

-        print "    Certificate List Len:".$certlistlen."\n";
-        print "    Certificate Len:".$certlen."\n";
+            print "    Certificate List Len:".$certlistlen."\n";
+            print "    Certificate Len:".$certlen."\n";
+        } elsif ($type == TLSEXT_cert_type_rpk) {
+            # RFC7250 RPK
+            $self->rpk($remdata);
+
+            print "    RPK LEN:".$certlistlen."\n";
+        } else {
+            die "Unsupported %s certificate type: %d\n",
+                $self->server() ? "server" : "client", $type;
+        }
     }
 }

@@ -163,16 +199,28 @@ sub set_message_contents
         $data .= $self->remaining_certdata();
         $self->data($data);
     } else {
-        my $certlen = length($self->first_certificate);
-        my $certlistlen = $certlen + length($self->remaining_certdata);
-        my $hi = $certlistlen >> 16;
-        $certlistlen = $certlistlen & 0xffff;
-        $data .= pack('Cn', $hi, $certlistlen);
-        $hi = $certlen >> 16;
-        $certlen = $certlen & 0xffff;
-        $data .= pack('Cn', $hi, $certlen);
-        $data .= $self->remaining_certdata();
-        $self->data($data);
+        my $type = $self->server() ? $self->server_type() : $self->client_type();
+        if ($type == TLSEXT_cert_type_x509) {
+            # X.509 chain
+            my $certlen = length($self->first_certificate);
+            my $certlistlen = $certlen + length($self->remaining_certdata);
+            my $hi = $certlistlen >> 16;
+            $certlistlen = $certlistlen & 0xffff;
+            $data .= pack('Cn', $hi, $certlistlen);
+            $hi = $certlen >> 16;
+            $certlen = $certlen & 0xffff;
+            $data .= pack('Cn', $hi, $certlen);
+            $data .= $self->remaining_certdata();
+            $self->data($data);
+        } elsif ($type == TLSEXT_cert_type_rpk) {
+            # RFC7250 RPK
+            my $len = length($self->rpk);
+            my $hi = $len >> 16;
+            $len = $len & 0xffff;
+            $data .= pack('Cn', $hi, $len);
+            $data .= $self->rpk();
+            $self->data($data);
+        }
     }
 }

@@ -219,4 +267,23 @@ sub delete_extension
     my ($self, $ext_type) = @_;
     delete $self->{extension_data}{$ext_type};
 }
+
+sub client_type {
+    shift;
+    $client_cert_type = shift if (@_);
+    return $client_cert_type;
+}
+
+sub server_type {
+    shift;
+    $server_cert_type = shift if (@_);
+    return $server_cert_type;
+}
+
+sub rpk {
+    my $self = shift;
+    $self->{rpk} = shift if (@_);
+    return $self->{rpk};
+}
+
 1;
diff --git a/util/perl/TLSProxy/EncryptedExtensions.pm b/util/perl/TLSProxy/EncryptedExtensions.pm
index 362e0c1082..9eb73dd332 100644
--- a/util/perl/TLSProxy/EncryptedExtensions.pm
+++ b/util/perl/TLSProxy/EncryptedExtensions.pm
@@ -1,4 +1,4 @@
-# Copyright 2016-2025 The OpenSSL Project Authors. All Rights Reserved.
+# Copyright 2016-2026 The OpenSSL Project Authors. All Rights Reserved.
 #
 # Licensed under the Apache License 2.0 (the "License").  You may not use
 # this file except in compliance with the License.  You can obtain a copy
@@ -103,6 +103,17 @@ sub extension_data
     if (@_) {
         $self->{extension_data} = shift;
     }
+    my $exts = $self->{extension_data};
+    if (defined($exts) && defined(my $data = $exts->{TLSProxy::Message::EXT_CLIENT_CERT_TYPE})) {
+        die "Invalid client certificate type extension\n"
+            if length($data) != 1;
+        TLSProxy::Certificate->client_type(unpack("C", $data));
+    }
+    if (defined($exts) && defined(my $data = $exts->{TLSProxy::Message::EXT_SERVER_CERT_TYPE})) {
+        die "Invalid server certificate type extension\n"
+            if length($data) != 1;
+        TLSProxy::Certificate->server_type(unpack("C", $data));
+    }
     return $self->{extension_data};
 }
 sub set_extension
diff --git a/util/perl/TLSProxy/ServerHello.pm b/util/perl/TLSProxy/ServerHello.pm
index a76ef571ef..2d35105d75 100644
--- a/util/perl/TLSProxy/ServerHello.pm
+++ b/util/perl/TLSProxy/ServerHello.pm
@@ -1,4 +1,4 @@
-# Copyright 2016-2025 The OpenSSL Project Authors. All Rights Reserved.
+# Copyright 2016-2026 The OpenSSL Project Authors. All Rights Reserved.
 #
 # Licensed under the Apache License 2.0 (the "License").  You may not use
 # this file except in compliance with the License.  You can obtain a copy
@@ -10,6 +10,7 @@ use strict;
 package TLSProxy::ServerHello;

 use TLSProxy::Record;
+use TLSProxy::Certificate;

 use vars '@ISA';
 push @ISA, 'TLSProxy::Message';
@@ -128,6 +129,7 @@ sub parse
     $self->extension_data(\%extensions);

     $self->process_data();
+    $self->process_extensions();


     print "    Server Version:".$TLSProxy::Record::tls_version{$server_version}."\n";
@@ -145,6 +147,28 @@ sub process_data
     TLSProxy::Message->ciphersuite($self->ciphersuite);
 }

+#Perform any actions necessary based on the extensions we've seen
+sub process_extensions
+{
+    my $self = shift;
+    my %extensions = %{$self->extension_data};
+
+    #Clear any state from a previous run
+    TLSProxy::Certificate->client_type(TLSProxy::Certificate::TLSEXT_cert_type_x509);
+    TLSProxy::Certificate->server_type(TLSProxy::Certificate::TLSEXT_cert_type_x509);
+
+    if (defined(my $data = $extensions{TLSProxy::Message::EXT_CLIENT_CERT_TYPE})) {
+        die "Invalid client certificate type extension\n"
+            if length($data) != 1;
+        TLSProxy::Certificate->client_type(unpack("C", $data));
+    }
+    if (defined(my $data = $extensions{TLSProxy::Message::EXT_SERVER_CERT_TYPE})) {
+        die "Invalid server certificate type extension\n"
+            if length($data) != 1;
+        TLSProxy::Certificate->server_type(unpack("C", $data));
+    }
+}
+
 #Reconstruct the on-the-wire message data following changes
 sub set_message_contents
 {