Implement RFC 9258 as a client

Follow-up CLs will:

- Address an API edge case around a PSK-or-certs connection that sees a
  CertificateRequest and aims to decline to send a certificate.

- Add server support

Bug: 369963041
Change-Id: Ieef6bd3d2808fb5615dc8aaf66d7c3045440493d
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/89475
Reviewed-by: Lily Chen <chlily@google.com>
Commit-Queue: David Benjamin <davidben@google.com>
diff --git a/crypto/err/ssl.errordata b/crypto/err/ssl.errordata
index 01c4ca6..b2028a4 100644
--- a/crypto/err/ssl.errordata
+++ b/crypto/err/ssl.errordata
@@ -94,6 +94,7 @@
 SSL,159,INVALID_MESSAGE
 SSL,320,INVALID_OUTER_EXTENSION
 SSL,251,INVALID_OUTER_RECORD_TYPE
+SSL,331,INVALID_PSK_FOR_CONNECTION
 SSL,269,INVALID_SCT_LIST
 SSL,295,INVALID_SIGNATURE_ALGORITHM
 SSL,324,INVALID_SPAKE2PLUSV1_VALUE
diff --git a/gen/crypto/err_data.cc b/gen/crypto/err_data.cc
index 1445792..4d7c79a 100644
--- a/gen/crypto/err_data.cc
+++ b/gen/crypto/err_data.cc
@@ -202,51 +202,51 @@
     0x283500f7,
     0x28358cc1,
     0x2836099a,
-    0x2c32341d,
+    0x2c323438,
     0x2c3293f7,
-    0x2c33342b,
-    0x2c33b43d,
-    0x2c343451,
-    0x2c34b463,
-    0x2c35347e,
-    0x2c35b490,
-    0x2c3634c0,
+    0x2c333446,
+    0x2c33b458,
+    0x2c34346c,
+    0x2c34b47e,
+    0x2c353499,
+    0x2c35b4ab,
+    0x2c3634db,
     0x2c36833a,
-    0x2c3734cd,
-    0x2c37b4f9,
-    0x2c383537,
-    0x2c38b54e,
-    0x2c39356c,
-    0x2c39b57c,
-    0x2c3a358e,
-    0x2c3ab5a2,
-    0x2c3b35b3,
-    0x2c3bb5d2,
+    0x2c3734e8,
+    0x2c37b514,
+    0x2c383552,
+    0x2c38b569,
+    0x2c393587,
+    0x2c39b597,
+    0x2c3a35a9,
+    0x2c3ab5bd,
+    0x2c3b35ce,
+    0x2c3bb5ed,
     0x2c3c1409,
     0x2c3c941f,
-    0x2c3d3617,
+    0x2c3d3632,
     0x2c3d9438,
-    0x2c3e3641,
-    0x2c3eb64f,
-    0x2c3f3667,
-    0x2c3fb67f,
-    0x2c4036a9,
+    0x2c3e365c,
+    0x2c3eb66a,
+    0x2c3f3682,
+    0x2c3fb69a,
+    0x2c4036c4,
     0x2c4092ec,
-    0x2c4136ba,
-    0x2c41b6cd,
+    0x2c4136d5,
+    0x2c41b6e8,
     0x2c4212b2,
-    0x2c42b6de,
+    0x2c42b6f9,
     0x2c43076d,
-    0x2c43b5c4,
-    0x2c44350c,
-    0x2c44b68c,
-    0x2c4534a3,
-    0x2c45b4df,
-    0x2c46355c,
-    0x2c46b5e6,
-    0x2c4735fb,
-    0x2c47b634,
-    0x2c48351e,
+    0x2c43b5df,
+    0x2c443527,
+    0x2c44b6a7,
+    0x2c4534be,
+    0x2c45b4fa,
+    0x2c463577,
+    0x2c46b601,
+    0x2c473616,
+    0x2c47b64f,
+    0x2c483539,
     0x30320000,
     0x30328015,
     0x3033001f,
@@ -446,156 +446,156 @@
     0x404ea122,
     0x404f21f5,
     0x404fa26b,
-    0x405022f5,
-    0x4050a309,
-    0x40512356,
-    0x40522366,
-    0x4052a38a,
-    0x405323a2,
-    0x4053a3b5,
-    0x405423ca,
-    0x4054a3ed,
-    0x40552418,
-    0x4055a455,
-    0x4056247a,
-    0x4056a493,
-    0x405724ab,
-    0x4057a4be,
-    0x405824d3,
-    0x4058a4fa,
-    0x40592529,
-    0x4059a569,
-    0x405aa57d,
-    0x405b2595,
-    0x405ba5a6,
-    0x405c25b9,
-    0x405ca5f8,
-    0x405d2605,
-    0x405da62a,
-    0x405e2668,
+    0x40502310,
+    0x4050a324,
+    0x40512371,
+    0x40522381,
+    0x4052a3a5,
+    0x405323bd,
+    0x4053a3d0,
+    0x405423e5,
+    0x4054a408,
+    0x40552433,
+    0x4055a470,
+    0x40562495,
+    0x4056a4ae,
+    0x405724c6,
+    0x4057a4d9,
+    0x405824ee,
+    0x4058a515,
+    0x40592544,
+    0x4059a584,
+    0x405aa598,
+    0x405b25b0,
+    0x405ba5c1,
+    0x405c25d4,
+    0x405ca613,
+    0x405d2620,
+    0x405da645,
+    0x405e2683,
     0x405e8afe,
-    0x405f26b7,
-    0x405fa6c4,
-    0x406026d2,
-    0x4060a6f4,
-    0x40612768,
-    0x4061a7a0,
-    0x406227b7,
-    0x4062a7c8,
-    0x40632815,
-    0x4063a82a,
-    0x40642841,
-    0x4064a86d,
-    0x40652888,
-    0x4065a89f,
-    0x406628b7,
-    0x4066a8e1,
-    0x4067290c,
-    0x4067a951,
-    0x40682999,
-    0x4068a9ba,
-    0x406929ec,
-    0x4069aa1a,
-    0x406a2a3b,
-    0x406aaa5b,
-    0x406b2be3,
-    0x406bac06,
-    0x406c2c1c,
-    0x406caf26,
-    0x406d2f55,
-    0x406daf7d,
-    0x406e2fab,
-    0x406eaff8,
-    0x406f3051,
-    0x406fb089,
-    0x4070309c,
-    0x4070b0b9,
+    0x405f26d2,
+    0x405fa6df,
+    0x406026ed,
+    0x4060a70f,
+    0x40612783,
+    0x4061a7bb,
+    0x406227d2,
+    0x4062a7e3,
+    0x40632830,
+    0x4063a845,
+    0x4064285c,
+    0x4064a888,
+    0x406528a3,
+    0x4065a8ba,
+    0x406628d2,
+    0x4066a8fc,
+    0x40672927,
+    0x4067a96c,
+    0x406829b4,
+    0x4068a9d5,
+    0x40692a07,
+    0x4069aa35,
+    0x406a2a56,
+    0x406aaa76,
+    0x406b2bfe,
+    0x406bac21,
+    0x406c2c37,
+    0x406caf41,
+    0x406d2f70,
+    0x406daf98,
+    0x406e2fc6,
+    0x406eb013,
+    0x406f306c,
+    0x406fb0a4,
+    0x407030b7,
+    0x4070b0d4,
     0x4071084d,
-    0x4071b0cb,
-    0x407230de,
-    0x4072b114,
-    0x4073312c,
+    0x4071b0e6,
+    0x407230f9,
+    0x4072b12f,
+    0x40733147,
     0x40739621,
-    0x40743140,
-    0x4074b15a,
-    0x4075316b,
-    0x4075b17f,
-    0x4076318d,
+    0x4074315b,
+    0x4074b175,
+    0x40753186,
+    0x4075b19a,
+    0x407631a8,
     0x407693af,
-    0x407731b2,
-    0x4077b20e,
-    0x40783229,
-    0x4078b262,
-    0x40793279,
-    0x4079b28f,
-    0x407a32bb,
-    0x407ab2ce,
-    0x407b32e3,
-    0x407bb2f5,
-    0x407c3326,
-    0x407cb32f,
-    0x407d29d5,
+    0x407731cd,
+    0x4077b229,
+    0x40783244,
+    0x4078b27d,
+    0x40793294,
+    0x4079b2aa,
+    0x407a32d6,
+    0x407ab2e9,
+    0x407b32fe,
+    0x407bb310,
+    0x407c3341,
+    0x407cb34a,
+    0x407d29f0,
     0x407da293,
-    0x407e323e,
-    0x407ea50a,
+    0x407e3259,
+    0x407ea525,
     0x407f1e86,
     0x407fa069,
     0x40802205,
     0x40809eae,
-    0x40812378,
+    0x40812393,
     0x4081a170,
-    0x40822f96,
+    0x40822fb1,
     0x40829c01,
-    0x408324e5,
-    0x4083a852,
+    0x40832500,
+    0x4083a86d,
     0x40841ed2,
-    0x4084a542,
-    0x408525ca,
-    0x4085a72f,
-    0x4086264a,
-    0x4086a2ad,
-    0x40872fdc,
-    0x4087a77d,
+    0x4084a55d,
+    0x408525e5,
+    0x4085a74a,
+    0x40862665,
+    0x4086a2c8,
+    0x40872ff7,
+    0x4087a798,
     0x40881c3f,
-    0x4088a964,
+    0x4088a97f,
     0x40891c8e,
     0x40899c1b,
-    0x408a2c54,
+    0x408a2c6f,
     0x408a9a39,
-    0x408b330a,
-    0x408bb066,
-    0x408c25da,
+    0x408b3325,
+    0x408bb081,
+    0x408c25f5,
     0x408d1fba,
     0x408d9f04,
     0x408e20ea,
-    0x408ea435,
-    0x408f2978,
-    0x408fa74b,
-    0x4090292d,
-    0x4090a61c,
-    0x40912c3c,
+    0x408ea450,
+    0x408f2993,
+    0x408fa766,
+    0x40902948,
+    0x4090a637,
+    0x40912c57,
     0x40919a71,
     0x40921cdb,
-    0x4092b017,
-    0x409330f7,
-    0x4093a2be,
+    0x4092b032,
+    0x40933112,
+    0x4093a2d9,
     0x40941ee6,
-    0x4094ac6d,
-    0x409527d9,
-    0x4095b29b,
-    0x40962fc3,
+    0x4094ac88,
+    0x409527f4,
+    0x4095b2b6,
+    0x40962fde,
     0x4096a21e,
-    0x4097233e,
+    0x40972359,
     0x4097a139,
     0x40981d3b,
-    0x4098a7ed,
-    0x40993033,
-    0x4099a462,
-    0x409a23fb,
+    0x4098a808,
+    0x4099304e,
+    0x4099a47d,
+    0x409a2416,
     0x409a9a55,
     0x409b1f40,
     0x409b9f6b,
-    0x409c31f0,
+    0x409c320b,
     0x409c9f93,
     0x409d21da,
     0x409da186,
@@ -606,49 +606,50 @@
     0x40a0227b,
     0x40a0a153,
     0x40a121a1,
-    0x40a1a556,
-    0x40a222da,
-    0x40a2a6a8,
-    0x40a3271c,
-    0x40a3b1d4,
-    0x40a42324,
+    0x40a1a571,
+    0x40a222f5,
+    0x40a2a6c3,
+    0x40a32737,
+    0x40a3b1ef,
+    0x40a4233f,
     0x40a4a1b8,
     0x40a51ec2,
-    0x41f42b0e,
-    0x41f92ba0,
-    0x41fe2a93,
-    0x41fead49,
-    0x41ff2e77,
-    0x42032b27,
-    0x42082b49,
-    0x4208ab85,
-    0x42092a77,
-    0x4209abbf,
-    0x420a2ace,
-    0x420aaaae,
-    0x420b2aee,
-    0x420bab67,
-    0x420c2e93,
-    0x420cac7d,
-    0x420d2d30,
-    0x420dad67,
-    0x42122d9a,
-    0x42172e5a,
-    0x4217addc,
-    0x421c2dfe,
-    0x421f2db9,
-    0x42212f0b,
-    0x42262e3d,
-    0x422b2ee9,
-    0x422bad0b,
-    0x422c2ecb,
-    0x422cacbe,
-    0x422d2c97,
-    0x422daeaa,
-    0x422e2cea,
-    0x42302e19,
-    0x4230ad81,
-    0x42312689,
+    0x40a5a2ad,
+    0x41f42b29,
+    0x41f92bbb,
+    0x41fe2aae,
+    0x41fead64,
+    0x41ff2e92,
+    0x42032b42,
+    0x42082b64,
+    0x4208aba0,
+    0x42092a92,
+    0x4209abda,
+    0x420a2ae9,
+    0x420aaac9,
+    0x420b2b09,
+    0x420bab82,
+    0x420c2eae,
+    0x420cac98,
+    0x420d2d4b,
+    0x420dad82,
+    0x42122db5,
+    0x42172e75,
+    0x4217adf7,
+    0x421c2e19,
+    0x421f2dd4,
+    0x42212f26,
+    0x42262e58,
+    0x422b2f04,
+    0x422bad26,
+    0x422c2ee6,
+    0x422cacd9,
+    0x422d2cb2,
+    0x422daec5,
+    0x422e2d05,
+    0x42302e34,
+    0x4230ad9c,
+    0x423126a4,
     0x44320778,
     0x44328787,
     0x44330793,
@@ -704,71 +705,71 @@
     0x4c419501,
     0x4c42166a,
     0x4c429449,
-    0x503236f0,
-    0x5032b6ff,
-    0x5033370a,
-    0x5033b71a,
-    0x50343733,
-    0x5034b74d,
-    0x5035375b,
-    0x5035b771,
-    0x50363783,
-    0x5036b799,
-    0x503737b2,
-    0x5037b7c5,
-    0x503837dd,
-    0x5038b7ee,
-    0x50393803,
-    0x5039b817,
-    0x503a3837,
-    0x503ab84d,
-    0x503b3865,
-    0x503bb877,
-    0x503c3893,
-    0x503cb8aa,
-    0x503d38c3,
-    0x503db8d9,
-    0x503e38e6,
-    0x503eb8fc,
-    0x503f390e,
+    0x5032370b,
+    0x5032b71a,
+    0x50333725,
+    0x5033b735,
+    0x5034374e,
+    0x5034b768,
+    0x50353776,
+    0x5035b78c,
+    0x5036379e,
+    0x5036b7b4,
+    0x503737cd,
+    0x5037b7e0,
+    0x503837f8,
+    0x5038b809,
+    0x5039381e,
+    0x5039b832,
+    0x503a3852,
+    0x503ab868,
+    0x503b3880,
+    0x503bb892,
+    0x503c38ae,
+    0x503cb8c5,
+    0x503d38de,
+    0x503db8f4,
+    0x503e3901,
+    0x503eb917,
+    0x503f3929,
     0x503f83b3,
-    0x50403921,
-    0x5040b931,
-    0x5041394b,
-    0x5041b95a,
-    0x50423974,
-    0x5042b991,
-    0x504339a1,
-    0x5043b9b1,
-    0x504439ce,
+    0x5040393c,
+    0x5040b94c,
+    0x50413966,
+    0x5041b975,
+    0x5042398f,
+    0x5042b9ac,
+    0x504339bc,
+    0x5043b9cc,
+    0x504439e9,
     0x50448469,
-    0x504539e2,
-    0x5045ba00,
-    0x50463a13,
-    0x5046ba29,
-    0x50473a3b,
-    0x5047ba50,
-    0x50483a76,
-    0x5048ba84,
-    0x50493a97,
-    0x5049baac,
-    0x504a3ac2,
-    0x504abad2,
-    0x504b3af2,
-    0x504bbb05,
-    0x504c3b28,
-    0x504cbb56,
-    0x504d3b83,
-    0x504dbba0,
-    0x504e3bbb,
-    0x504ebbd7,
-    0x504f3be9,
-    0x504fbc00,
-    0x50503c0f,
+    0x504539fd,
+    0x5045ba1b,
+    0x50463a2e,
+    0x5046ba44,
+    0x50473a56,
+    0x5047ba6b,
+    0x50483a91,
+    0x5048ba9f,
+    0x50493ab2,
+    0x5049bac7,
+    0x504a3add,
+    0x504abaed,
+    0x504b3b0d,
+    0x504bbb20,
+    0x504c3b43,
+    0x504cbb71,
+    0x504d3b9e,
+    0x504dbbbb,
+    0x504e3bd6,
+    0x504ebbf2,
+    0x504f3c04,
+    0x504fbc1b,
+    0x50503c2a,
     0x50508729,
-    0x50513c22,
-    0x5051b9c0,
-    0x50523b68,
+    0x50513c3d,
+    0x5051b9db,
+    0x50523b83,
     0x58321011,
     0x68320fd3,
     0x68328d2b,
@@ -813,19 +814,19 @@
     0x7c3212c8,
     0x80321514,
     0x80328090,
-    0x803333ec,
+    0x80333407,
     0x803380b9,
-    0x803433fb,
-    0x8034b363,
-    0x80353381,
-    0x8035b40f,
-    0x803633c3,
-    0x8036b372,
-    0x803733b5,
-    0x8037b350,
-    0x803833d6,
-    0x8038b392,
-    0x803933a7,
+    0x80343416,
+    0x8034b37e,
+    0x8035339c,
+    0x8035b42a,
+    0x803633de,
+    0x8036b38d,
+    0x803733d0,
+    0x8037b36b,
+    0x803833f1,
+    0x8038b3ad,
+    0x803933c2,
     0x84320bb0,
     0x84328bc9,
 };
@@ -1266,6 +1267,7 @@
     "INVALID_MESSAGE\0"
     "INVALID_OUTER_EXTENSION\0"
     "INVALID_OUTER_RECORD_TYPE\0"
+    "INVALID_PSK_FOR_CONNECTION\0"
     "INVALID_SCT_LIST\0"
     "INVALID_SIGNATURE_ALGORITHM\0"
     "INVALID_SPAKE2PLUSV1_VALUE\0"
diff --git a/include/openssl/prefix_symbols.h b/include/openssl/prefix_symbols.h
index bf94685..86bf926 100644
--- a/include/openssl/prefix_symbols.h
+++ b/include/openssl/prefix_symbols.h
@@ -1837,6 +1837,7 @@
 #pragma redefine_extname SSL_CREDENTIAL_get_ex_new_index BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_get_ex_new_index))
 #pragma redefine_extname SSL_CREDENTIAL_is_complete BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_is_complete))
 #pragma redefine_extname SSL_CREDENTIAL_new_delegated BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_delegated))
+#pragma redefine_extname SSL_CREDENTIAL_new_pre_shared_key BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_pre_shared_key))
 #pragma redefine_extname SSL_CREDENTIAL_new_spake2plusv1_client BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_spake2plusv1_client))
 #pragma redefine_extname SSL_CREDENTIAL_new_spake2plusv1_server BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_spake2plusv1_server))
 #pragma redefine_extname SSL_CREDENTIAL_new_x509 BORINGSSL_SYMBOL(BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_x509))
@@ -4898,6 +4899,7 @@
 #define SSL_CREDENTIAL_get_ex_new_index BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_get_ex_new_index)
 #define SSL_CREDENTIAL_is_complete BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_is_complete)
 #define SSL_CREDENTIAL_new_delegated BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_delegated)
+#define SSL_CREDENTIAL_new_pre_shared_key BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_pre_shared_key)
 #define SSL_CREDENTIAL_new_spake2plusv1_client BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_spake2plusv1_client)
 #define SSL_CREDENTIAL_new_spake2plusv1_server BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_spake2plusv1_server)
 #define SSL_CREDENTIAL_new_x509 BORINGSSL_ADD_PREFIX(SSL_CREDENTIAL_new_x509)
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index 430273e..fcfd67d 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -3703,11 +3703,71 @@
     SSL *ssl);
 
 
-// Pre-shared keys.
+// TLS 1.3 pre-shared keys.
 //
-// Connections may be configured with PSK (Pre-Shared Key) cipher suites. These
-// authenticate using out-of-band pre-shared keys rather than certificates. See
-// RFC 4279.
+// TLS 1.3 connections can be authenticated using external pre-shared keys
+// (PSKs), as described in RFC 9258. These are represented in BoringSSL with
+// |SSL_CREDENTIAL| objects.
+//
+// BoringSSL only implements the PSK importer interface from RFC 9258. The
+// underlying protocol- and cipher-specific TLS 1.3 PSK mechanism is not exposed
+// directly.
+
+// SSL_CREDENTIAL_new_pre_shared_key returns a newly-allocated |SSL_CREDENTIAL|
+// representing an external pre-shared key, as described in RFC 9258, or NULL on
+// error. |key| is the base key, |id| is the external identity, and |md| is the
+// hash function. The result may be added to the credential list with
+// |SSL_CTX_add1_credential| or |SSL_add1_credential|. |context| is the context
+// string to use when importing to TLS.
+//
+// Callers can configure the credential list with multiple PSKs, or a mix of
+// PSKs and other credentials, in some preference order. Due to protocol
+// differences, clients and servers evaluate PSKs in the credential list
+// differently:
+//
+// - As a server, PSK credentials behave similarly to other credentials. Callers
+//   can configure them dynamically in the certificate callbacks (see
+//   |SSL_CTX_set_select_certificate_cb| and |SSL_CTX_set_cert_cb|). After those
+//   callbacks run, BoringSSL will select a credential to use from the list.
+//
+// - As a client, PSK credentials are offered in the ClientHello and selected by
+//   the server. This means all PSK credentials must be configured before
+//   starting the handshake, and the order between PSK and non-PSK credentials
+//   will be ignored. PSK credentials cannot be configured dynamically in
+//   callbacks such as |SSL_CTX_set_cert_cb|.
+//
+// A single connection may be configured to accept only certificate-based
+// handshakes, only PSK-based handshakes, or both. The server credential
+// determines the kind of handshake, so this is implicitly controlled by the
+// credential list as a server:
+//
+// - If the credential list only contains PSKs, BoringSSL will only consider
+//   PSK-based handshakes.
+//
+// - If the credential list contains PSKs and certificates, BoringSSL will
+//   consider both, depending on the client.
+//
+// As a client, if any PSK credentials are configured, BoringSSL will only
+// accept PSK-based handshakes by default. Setting the verify mode to
+// |SSL_VERIFY_PEER| (see |SSL_CTX_set_verify| and |SSL_CTX_set_custom_verify|)
+// overrides this behavior and causes BoringSSL to accept either.
+//
+// In both clients and servers, if a caller configures one or more PSK
+// credentials, and calls no certificate-related functions, the connection will
+// only accept one of those PSKs.
+//
+// TODO(crbug.com/369963041): These credentials are currently only implemented
+// as a client. Implement these as a server as well.
+OPENSSL_EXPORT SSL_CREDENTIAL *SSL_CREDENTIAL_new_pre_shared_key(
+    const uint8_t *key, size_t key_len, const uint8_t *id, size_t id_len,
+    const EVP_MD *md, const uint8_t *context, size_t context_len);
+
+
+// TLS 1.2 pre-shared keys.
+//
+// TLS 1.2 connections may be configured with PSK (Pre-Shared Key) cipher
+// suites. These authenticate using out-of-band pre-shared keys rather than
+// certificates. See RFC 4279.
 //
 // This implementation uses NUL-terminated C strings for identities and identity
 // hints, so values with a NUL character are not supported. (RFC 4279 does not
@@ -6759,6 +6819,7 @@
 #define SSL_R_INVALID_TRUST_ANCHOR_LIST 328
 #define SSL_R_INVALID_CERTIFICATE_PROPERTY_LIST 329
 #define SSL_R_DUPLICATE_GROUP 330
+#define SSL_R_INVALID_PSK_FOR_CONNECTION 331
 #define SSL_R_SSLV3_ALERT_CLOSE_NOTIFY 1000
 #define SSL_R_SSLV3_ALERT_UNEXPECTED_MESSAGE 1010
 #define SSL_R_SSLV3_ALERT_BAD_RECORD_MAC 1020
diff --git a/ssl/extensions.cc b/ssl/extensions.cc
index d9683e9..79d89a0 100644
--- a/ssl/extensions.cc
+++ b/ssl/extensions.cc
@@ -21,6 +21,7 @@
 
 #include <algorithm>
 #include <optional>
+#include <variant>
 #include <utility>
 
 #include <openssl/aead.h>
@@ -1878,40 +1879,68 @@
 //
 // https://tools.ietf.org/html/rfc8446#section-4.2.11
 
-static bool should_offer_psk(const SSL_HANDSHAKE *hs,
-                             ssl_client_hello_type_t type) {
+bool ssl_setup_pre_shared_keys(SSL_HANDSHAKE *hs) {
   const SSL *const ssl = hs->ssl;
-  if (hs->max_version < TLS1_3_VERSION || ssl->session == nullptr ||
-      ssl_session_get_type(ssl->session.get()) !=
-          SSLSessionType::kPreSharedKey ||
-      // TODO(https://crbug.com/boringssl/275): Should we synthesize a
-      // placeholder PSK, at least when we offer early data? Otherwise
-      // ClientHelloOuter will contain an early_data extension without a
-      // pre_shared_key extension and potentially break the recovery flow.
-      type == ssl_client_hello_outer) {
+  if (hs->max_version < TLS1_3_VERSION) {
+    return true;
+  }
+
+  if (SSL_SESSION *session = ssl->session.get();
+      session != nullptr &&
+      ssl_session_get_type(session) == SSLSessionType::kPreSharedKey &&
+      !hs->pre_shared_keys.Push(UpRef(session))) {
     return false;
   }
 
-  // Per RFC 8446 section 4.1.4, skip offering the session if the selected
-  // cipher in HelloRetryRequest does not match. This avoids performing the
-  // transcript hash transformation for multiple hashes.
-  if (ssl->s3->used_hello_retry_request &&
-      ssl->session->cipher->algorithm_prf != hs->new_cipher->algorithm_prf) {
-    return false;
+  const uint16_t epsk_protocol =
+      SSL_is_dtls(ssl) ? DTLS1_3_VERSION : TLS1_3_VERSION;
+#if defined(TLS1_4_VERSION)
+#error "More imported PSKs may be needed when TLS 1.4 exists"
+#endif
+  for (const auto &cred : hs->config->cert->credentials) {
+    if (cred->type != SSLCredentialType::kPreSharedKey) {
+      continue;
+    }
+    // We need SHA-256 and SHA-384 variants to cover supported cipher suites.
+    for (const EVP_MD *md : {EVP_sha256(), EVP_sha384()}) {
+      std::optional<SSLImportedPSK> psk =
+          tls13_derive_imported_psk(hs, cred.get(), epsk_protocol, md);
+      if (!psk || !hs->pre_shared_keys.Push(*std::move(psk))) {
+        return false;
+      }
+    }
   }
 
   return true;
 }
 
+static bool should_offer_psk(const SSL_HANDSHAKE *hs,
+                             ssl_client_hello_type_t type) {
+  // TODO(https://crbug.com/boringssl/275): Should we synthesize a placeholder
+  // PSK, at least when we offer early data? Otherwise ClientHelloOuter will
+  // contain an early_data extension without a pre_shared_key extension and
+  // potentially break the recovery flow.
+  return hs->max_version >= TLS1_3_VERSION && !hs->pre_shared_keys.empty() &&
+         type != ssl_client_hello_outer;
+}
+
 static size_t ext_pre_shared_key_clienthello_length(
     const SSL_HANDSHAKE *hs, ssl_client_hello_type_t type) {
-  const SSL *const ssl = hs->ssl;
   if (!should_offer_psk(hs, type)) {
     return 0;
   }
 
-  size_t binder_len = EVP_MD_size(ssl_session_get_digest(ssl->session.get()));
-  return 15 + ssl->session->ticket.size() + binder_len;
+  // extension value, extension length, identities length, binders length.
+  size_t ret = 2 + 2 + 2 + 2;
+  for (const auto &psk : hs->pre_shared_keys) {
+    // identity
+    ret += 2 + ssl_pre_shared_key_identity(psk).size();
+    // obfuscated_ticket_age
+    ret += 4;
+    // binder
+    ret += 1 + EVP_MD_size(ssl_pre_shared_key_hash(psk));
+  }
+  return ret;
 }
 
 // ext_pre_shared_key_add_clienthello writes a pre_shared_key extension to
@@ -1936,30 +1965,61 @@
     return CBB_flush(out_client_hello);
   }
 
-  OPENSSL_timeval now = ssl_ctx_get_current_time(ssl->ctx.get());
-  uint32_t ticket_age = 1000 * (now.tv_sec - ssl->session->time);
-  uint32_t obfuscated_ticket_age = ticket_age + ssl->session->ticket_age_add;
-
-  const size_t one_binder_len =
-      EVP_MD_size(ssl_session_get_digest(ssl->session.get()));
   const size_t len_before = CBB_len(out_extensions);
-  CBB contents, identity, ticket, binders, binder;
+  CBB contents, identities, identity, binders, binder;
   if (!CBB_add_u16(out_extensions, TLSEXT_TYPE_pre_shared_key) ||
       !CBB_add_u16_length_prefixed(out_extensions, &contents) ||
-      !CBB_add_u16_length_prefixed(&contents, &identity) ||
-      !CBB_add_u16_length_prefixed(&identity, &ticket) ||
-      !CBB_add_bytes(&ticket, ssl->session->ticket.data(),
-                     ssl->session->ticket.size()) ||
-      !CBB_add_u32(&identity, obfuscated_ticket_age) ||
-      !CBB_add_u16_length_prefixed(&contents, &binders) ||
-      !CBB_add_u8_length_prefixed(&binders, &binder) ||
-      // Fill in a placeholder zero binder of the appropriate length. It will be
-      // computed and filled in later after length prefixes are computed.
-      !CBB_add_zeros(&binder, one_binder_len) ||  //
-      !CBB_flush(out_extensions)) {
+      !CBB_add_u16_length_prefixed(&contents, &identities)) {
     return false;
   }
-  // Sample the length of the PSK extension.
+
+  // Fill in the PSK identities.
+  for (const auto &psk : hs->pre_shared_keys) {
+    if (const auto *imported = std::get_if<SSLImportedPSK>(&psk);
+        imported != nullptr) {
+      if (!CBB_add_u16_length_prefixed(&identities, &identity) ||
+          !CBB_add_bytes(&identity, imported->imported_identity.data(),
+                         imported->imported_identity.size()) ||
+          !CBB_add_u32(&identities, 0 /* obfuscated_ticket_age */)) {
+        return false;
+      }
+    } else {
+      const SSL_SESSION *session = std::get<UniquePtr<SSL_SESSION>>(psk).get();
+      // At most one PSK will be a session.
+      assert(session == ssl->session.get());
+      OPENSSL_timeval now = ssl_ctx_get_current_time(ssl->ctx.get());
+      uint32_t ticket_age = 1000 * (now.tv_sec - session->time);
+      uint32_t obfuscated_ticket_age = ticket_age + session->ticket_age_add;
+      if (!CBB_add_u16_length_prefixed(&identities, &identity) ||
+          !CBB_add_bytes(&identity, session->ticket.data(),
+                         session->ticket.size()) ||
+          !CBB_add_u32(&identities, obfuscated_ticket_age)) {
+        return false;
+      }
+    }
+  }
+
+  // Fill in placeholder zero binders of the appropriate length. They will be
+  // computed and filled in later after length prefixes are computed.
+  if (!CBB_add_u16_length_prefixed(&contents, &binders)) {
+    return false;
+  }
+  for (const auto &psk : hs->pre_shared_keys) {
+    size_t binder_len = EVP_MD_size(ssl_pre_shared_key_hash(psk));
+    if (!CBB_add_u8_length_prefixed(&binders, &binder) ||
+        !CBB_add_zeros(&binder, binder_len)) {
+      return false;
+    }
+  }
+  if (!CBB_flush(&binders)) {
+    return false;
+  }
+  const size_t binders_len = CBB_len(&binders);
+
+  // Finish the PSK extension and save the length.
+  if (!CBB_flush(out_extensions)) {
+    return false;
+  }
   *out_psk_len = CBB_len(out_extensions) - len_before;
 
   // Fill in |out_extensions|'s length prefix.
@@ -1967,41 +2027,43 @@
     return false;
   }
 
-  // Fill in the binder.
+  // Fill in binders.
+  const auto client_hello = CBBAsSpan(out_client_hello);
+  auto remaining_binders_out = client_hello.last(binders_len);
   const auto &transcript =
       type == ssl_client_hello_inner ? hs->inner_transcript : hs->transcript;
-  auto client_hello = CBBAsSpan(out_client_hello);
-  auto binder_out = client_hello.last(one_binder_len);
-  size_t binders_len = 3 + one_binder_len;  // u16 and u8 length prefix
-  size_t actual_binder_len;
-  if (!tls13_psk_binder(hs, binder_out, &actual_binder_len, ssl->session.get(),
-                        transcript, client_hello, binders_len)) {
-    return false;
+  for (const auto &psk : hs->pre_shared_keys) {
+    // Skip length prefix.
+    remaining_binders_out = remaining_binders_out.subspan(1);
+    size_t one_binder_len;
+    if (!tls13_psk_binder(hs, remaining_binders_out, &one_binder_len, psk,
+                          transcript, client_hello,
+                          2 /* length */ + binders_len)) {
+      return false;
+    }
+    remaining_binders_out = remaining_binders_out.subspan(one_binder_len);
   }
-  assert(actual_binder_len == one_binder_len);
-
+  assert(remaining_binders_out.empty());
   return true;
 }
 
-bool ssl_ext_pre_shared_key_parse_serverhello(SSL_HANDSHAKE *hs,
-                                              uint8_t *out_alert,
-                                              CBS *contents) {
-  uint16_t psk_id;
-  if (!CBS_get_u16(contents, &psk_id) ||  //
+const SSLPreSharedKey *ssl_ext_pre_shared_key_parse_serverhello(
+    SSL_HANDSHAKE *hs, uint8_t *out_alert, CBS *contents) {
+  uint16_t selected_identity;
+  if (!CBS_get_u16(contents, &selected_identity) ||  //
       CBS_len(contents) != 0) {
     OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
     *out_alert = SSL_AD_DECODE_ERROR;
-    return false;
+    return nullptr;
   }
 
-  // We only advertise one PSK identity, so the only legal index is zero.
-  if (psk_id != 0) {
+  if (selected_identity >= hs->pre_shared_keys.size()) {
     OPENSSL_PUT_ERROR(SSL, SSL_R_PSK_IDENTITY_NOT_FOUND);
     *out_alert = SSL_AD_UNKNOWN_PSK_IDENTITY;
-    return false;
+    return nullptr;
   }
 
-  return true;
+  return &hs->pre_shared_keys[selected_identity];
 }
 
 bool ssl_ext_pre_shared_key_parse_clienthello(
diff --git a/ssl/handshake_client.cc b/ssl/handshake_client.cc
index e8ccacc..23dc602 100644
--- a/ssl/handshake_client.cc
+++ b/ssl/handshake_client.cc
@@ -328,8 +328,26 @@
 }
 
 bool ssl_accepts_server_certificate_auth(const SSL_HANDSHAKE *hs) {
-  // In PAKE mode, the server must respond with a PAKE.
-  return hs->pake_prover == nullptr;
+  assert(!hs->ssl->server);
+  // In PAKE mode, the server must respond with a PAKE. We do not support
+  // accepting both PAKE and certificates together.
+  if (hs->pake_prover != nullptr) {
+    return false;
+  }
+
+  // If the caller has explicitly asked for certificate verification, it is
+  // willing to accept a certificate, even if it has non-certificate
+  // credentials, such as a PSK.
+  if (hs->config->verify_mode != SSL_VERIFY_NONE) {
+    return true;
+  }
+
+  // Otherwise, we fall back to default behavior: if there is at least one
+  // non-certificate credential configured, assume by default that the caller is
+  // only willing to use that non-certificate mode.
+  return std::none_of(hs->config->cert->credentials.begin(),
+                      hs->config->cert->credentials.end(),
+                      [](const auto &cred) { return !cred->UsesPrivateKey(); });
 }
 
 static enum ssl_hs_wait_t do_start_connect(SSL_HANDSHAKE *hs) {
@@ -423,7 +441,8 @@
     hs->early_data_offered = true;
   }
 
-  if (!ssl_setup_key_shares(hs, /*override_group_id=*/0) ||
+  if (!ssl_setup_pre_shared_keys(hs) ||
+      !ssl_setup_key_shares(hs, /*override_group_id=*/0) ||
       !ssl_setup_extension_permutation(hs) ||
       !ssl_encrypt_client_hello(hs, Span(ech_enc, ech_enc_len)) ||
       !ssl_add_client_hello(hs)) {
@@ -658,6 +677,7 @@
 
   // Clear some TLS 1.3 state that no longer needs to be retained.
   hs->key_shares.clear();
+  hs->pre_shared_keys.clear();
   ssl_done_writing_client_hello(hs);
 
   // TLS 1.2 handshakes cannot accept ECH.
@@ -1297,6 +1317,8 @@
     return ssl_hs_error;
   }
 
+  // TODO(crbug.com/369963041): It is currently impossible for a client that
+  // accepts PSKs or server certs to then decline to send a client cert.
   if (creds.empty()) {
     // If there were no credentials, proceed without a client certificate. In
     // this case, the handshake buffer may be released early.
diff --git a/ssl/internal.h b/ssl/internal.h
index ccdb9e9..189a904 100644
--- a/ssl/internal.h
+++ b/ssl/internal.h
@@ -31,6 +31,7 @@
 #include <string_view>
 #include <type_traits>
 #include <utility>
+#include <variant>
 
 #include <openssl/aead.h>
 #include <openssl/curve25519.h>
@@ -1212,19 +1213,46 @@
 bool tls13_derive_session_psk(SSL_SESSION *session, Span<const uint8_t> nonce,
                               bool is_dtls);
 
-// tls13_psk_binder calculates PSK binder value for |session| over
-// |transcript| and |client_hello|. On success, it writes the result to |out|,
-// sets |*out_len| to the length, and returns true. Otherwise, it returns false.
+struct SSLImportedPSK {
+  static constexpr bool kAllowUniquePtr = true;
+  UniquePtr<SSL_CREDENTIAL> credential;
+  Array<uint8_t> imported_identity;
+  InplaceVector<uint8_t, SSL_MAX_MD_SIZE> ipskx;
+  uint16_t protocol = 0;
+  const EVP_MD *md = nullptr;
+};
+
+// tls13_derive_imported_psk computes the imported PSK value for |cred|, which
+// must be an PSK credential, for use with a target KDF of HKDF with |hkdf_md|.
+// |protocol| should be the wire version (i.e. |TLS1_3_VERSION| or
+// |DTLS1_3_VERSION|) of the target protocol. It returns the imported PSK on
+// success and std::nullopt on error.
+std::optional<SSLImportedPSK> tls13_derive_imported_psk(const SSL_HANDSHAKE *hs,
+                                                        SSL_CREDENTIAL *cred,
+                                                        uint16_t protocol,
+                                                        const EVP_MD *hkdf_md);
+
+using SSLPreSharedKey = std::variant<SSLImportedPSK, UniquePtr<SSL_SESSION>>;
+
+// ssl_pre_shared_key_hash return's |psk|'s hash.
+const EVP_MD *ssl_pre_shared_key_hash(const SSLPreSharedKey &psk);
+
+// ssl_pre_shared_key_identity return's |psk|'s identity.
+Span<const uint8_t> ssl_pre_shared_key_identity(const SSLPreSharedKey &psk);
+
+// tls13_psk_binder calculates the PSK binder value for |psk| over |transcript|
+// and |client_hello|. On success, it writes the result to |out|, sets
+// |*out_len| to the length, and returns true. Otherwise, it returns false.
 // |binders_len| must be the length of the binders field, covering all binders
 // and the overall length prefix, in |client_hello|.
 bool tls13_psk_binder(const SSL_HANDSHAKE *hs, Span<uint8_t> out,
-                      size_t *out_len, const SSL_SESSION *session,
+                      size_t *out_len, const SSLPreSharedKey &psk,
                       const SSLTranscript &transcript,
                       Span<const uint8_t> client_hello, size_t binders_len);
 
 // tls13_verify_psk_binder verifies that the handshake transcript, truncated up
-// to the binders has a valid signature using the value of |session|'s
-// resumption secret. It returns true on success, and false on failure.
+// to the binders has a valid binder for |session|. It returns true on success,
+// and false on failure.
 bool tls13_verify_psk_binder(const SSL_HANDSHAKE *hs,
                              const SSL_SESSION *session, const SSLMessage &msg,
                              CBS *binders);
@@ -1365,6 +1393,7 @@
   kDelegated,
   kSPAKE2PlusV1Client,
   kSPAKE2PlusV1Server,
+  kPreSharedKey,
 };
 
 BSSL_NAMESPACE_END
@@ -1468,6 +1497,13 @@
   bssl::Array<uint8_t> registration_record;   // client-only
   mutable std::atomic<uint32_t> pake_limit;
 
+  // External-PSK-specific information. epskx is the HKDF-Extract-ed value, from
+  // Section 5.1 of RFC 9258.
+  bssl::Array<uint8_t> epskx;
+  bssl::Array<uint8_t> epsk_id;
+  const EVP_MD *epsk_md = nullptr;
+  bssl::Array<uint8_t> epsk_context;
+
   // Checks whether there are still permitted PAKE attempts remaining, without
   // changing the counter.
   bool HasPAKEAttempts() const;
@@ -1731,6 +1767,9 @@
   // members of this vector must be non-null.
   InplaceVector<UniquePtr<SSLKeyShare>, kNumNamedGroups> key_shares;
 
+  // pre_shared_keys are the pre-shared keys to be offered by the client.
+  Vector<SSLPreSharedKey> pre_shared_keys;
+
   // transcript is the current handshake transcript.
   SSLTranscript transcript;
 
@@ -2084,6 +2123,10 @@
 // for |hs|, if applicable. It returns true on success and false on error.
 bool ssl_setup_extension_permutation(SSL_HANDSHAKE *hs);
 
+// ssl_setup_pre_shared_keys computes the offered client PSKs and saves them in
+// |hs|. It returns true on success and false on failure.
+bool ssl_setup_pre_shared_keys(SSL_HANDSHAKE *hs);
+
 // ssl_setup_key_shares computes client key shares and saves them in |hs|. It
 // returns true on success and false on failure. In order of precedence:
 //
@@ -2121,9 +2164,8 @@
                                     Array<uint8_t> *out_secret,
                                     uint8_t *out_alert, CBS *contents);
 
-bool ssl_ext_pre_shared_key_parse_serverhello(SSL_HANDSHAKE *hs,
-                                              uint8_t *out_alert,
-                                              CBS *contents);
+const SSLPreSharedKey *ssl_ext_pre_shared_key_parse_serverhello(
+    SSL_HANDSHAKE *hs, uint8_t *out_alert, CBS *contents);
 bool ssl_ext_pre_shared_key_parse_clienthello(
     SSL_HANDSHAKE *hs, CBS *out_ticket, CBS *out_binders,
     uint32_t *out_obfuscated_ticket_age, uint8_t *out_alert,
diff --git a/ssl/ssl_credential.cc b/ssl/ssl_credential.cc
index 49cef15..36ee339 100644
--- a/ssl/ssl_credential.cc
+++ b/ssl/ssl_credential.cc
@@ -16,6 +16,7 @@
 
 #include <assert.h>
 
+#include <openssl/hkdf.h>
 #include <openssl/span.h>
 
 #include "../crypto/internal.h"
@@ -164,6 +165,7 @@
       return true;
     case SSLCredentialType::kSPAKE2PlusV1Client:
     case SSLCredentialType::kSPAKE2PlusV1Server:
+    case SSLCredentialType::kPreSharedKey:
       return false;
   }
   abort();
@@ -176,6 +178,7 @@
       return true;
     case SSLCredentialType::kSPAKE2PlusV1Client:
     case SSLCredentialType::kSPAKE2PlusV1Server:
+    case SSLCredentialType::kPreSharedKey:
       return false;
   }
   abort();
@@ -330,6 +333,31 @@
   return New<SSL_CREDENTIAL>(SSLCredentialType::kX509);
 }
 
+SSL_CREDENTIAL *SSL_CREDENTIAL_new_pre_shared_key(
+    const uint8_t *key, size_t key_len, const uint8_t *id, size_t id_len,
+    const EVP_MD *md, const uint8_t *context, size_t context_len) {
+  if (id_len == 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_PSK_IDENTITY_NOT_FOUND);
+    return nullptr;
+  }
+
+  auto cred = MakeUnique<SSL_CREDENTIAL>(SSLCredentialType::kPreSharedKey);
+  size_t epskx_len;
+  if (cred == nullptr ||
+      // Precompute epskx, to avoid recomputing it on every use of the
+      // credential.
+      !cred->epskx.InitForOverwrite(EVP_MD_size(md)) ||
+      !HKDF_extract(cred->epskx.data(), &epskx_len, md, key, key_len,
+                    /*salt=*/nullptr, /*salt_len=*/0) ||
+      !cred->epsk_id.CopyFrom(Span(id, id_len)) ||
+      !cred->epsk_context.CopyFrom(Span(context, context_len))) {
+    return nullptr;
+  }
+  BSSL_CHECK(epskx_len == cred->epskx.size());
+  cred->epsk_md = md;
+  return cred.release();
+}
+
 SSL_CREDENTIAL *SSL_CREDENTIAL_new_delegated() {
   return New<SSL_CREDENTIAL>(SSLCredentialType::kDelegated);
 }
diff --git a/ssl/test/bssl_shim.cc b/ssl/test/bssl_shim.cc
index a6ed9aa..4e306a1 100644
--- a/ssl/test/bssl_shim.cc
+++ b/ssl/test/bssl_shim.cc
@@ -433,6 +433,12 @@
                          CredentialConfigType::kSPAKE2PlusV1;
 }
 
+static bool IsTLS13PSK(const SSL *ssl) {
+  int idx = GetTestState(ssl)->selected_credential;
+  return idx >= 0 && GetTestConfig(ssl)->credentials[idx].type ==
+                         CredentialConfigType::kPreSharedKey;
+}
+
 // CheckHandshakeProperties checks, immediately after |ssl| completes its
 // initial handshake (or False Starts), whether all the properties are
 // consistent with the test configuration and invariants.
@@ -663,14 +669,10 @@
     return false;
   }
 
-  if (!config->psk.empty()) {
+  if (config->expect_no_peer_cert || !config->psk.empty() || IsTLS13PSK(ssl) ||
+      IsPAKE(ssl)) {
     if (SSL_get_peer_cert_chain(ssl) != nullptr) {
-      fprintf(stderr, "Received peer certificate on a PSK cipher.\n");
-      return false;
-    }
-  } else if (IsPAKE(ssl)) {
-    if (SSL_get_peer_cert_chain(ssl) != nullptr) {
-      fprintf(stderr, "Received peer certificate on a PAKE handshake.\n");
+      fprintf(stderr, "Received unexpected peer certificate.\n");
       return false;
     }
   } else if (!config->is_server || config->require_any_client_certificate) {
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index b7f14c7..1eea175 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -367,6 +367,12 @@
 // Temporary value; pre RFC.
 const spakeID uint16 = 0x7d96
 
+// KDF identifiers (RFC 9258)
+const (
+	kdfHKDFWithSHA256 uint16 = 0x0001
+	kdfHKDFWithSHA384 uint16 = 0x0002
+)
+
 // ConnectionState records basic TLS details about the connection.
 type ConnectionState struct {
 	Version                    uint16                // TLS version used by the connection (e.g. VersionTLS12)
@@ -1714,13 +1720,9 @@
 	// resumption.
 	NegotiatePSKResumption bool
 
-	// AlwaysSelectPSKIdentity, if true, causes the server in TLS 1.3 to
-	// always acknowledge a session, regardless of one was offered.
-	AlwaysSelectPSKIdentity bool
-
-	// SelectPSKIdentityOnResume, if non-zero, causes the server to select
-	// the specified PSK identity index rather than the actual value.
-	SelectPSKIdentityOnResume uint16
+	// AlwaysSelectPSKIdentity, if not nil, causes the server in TLS 1.3 to
+	// select the specified PSK identity index.
+	AlwaysSelectPSKIdentity *uint16
 
 	// ExtraPSKIdentity, if true, causes the client to send an extra PSK
 	// identity.
@@ -2356,6 +2358,7 @@
 	CredentialTypeX509 CredentialType = iota
 	CredentialTypeDelegated
 	CredentialTypeSPAKE2PlusV1
+	CredentialTypePreSharedKey
 )
 
 // A Credential is a certificate chain and private key that a TLS endpoint may
@@ -2408,6 +2411,11 @@
 	// OverridePAKECodepoint, if non-zero, causes the runner to send the
 	// specified value instead of the actual PAKE codepoint.
 	OverridePAKECodepoint uint16
+	// The following fields are used for PSK credentials.
+	PreSharedKey []byte
+	PSKIdentity  []byte
+	PSKHash      crypto.Hash
+	PSKContext   []byte
 	// TrustAnchorID, if not empty, is the trust anchor ID for the issuer
 	// of the certificate chain.
 	TrustAnchorID []byte
diff --git a/ssl/test/runner/handshake_messages.go b/ssl/test/runner/handshake_messages.go
index f5fae05..12cb52b 100644
--- a/ssl/test/runner/handshake_messages.go
+++ b/ssl/test/runner/handshake_messages.go
@@ -2968,3 +2968,20 @@
 func (*endOfEarlyDataMsg) unmarshal(data []byte) bool {
 	return len(data) == 4
 }
+
+type importedPSKIdentity struct {
+	externalIdentity []byte
+	context          []byte
+	targetProtocol   uint16
+	targetKDF        uint16
+}
+
+func (m *importedPSKIdentity) marshal() []byte {
+	// See RFC 9258, Section 5.1.
+	b := cryptobyte.NewBuilder(nil)
+	addUint16LengthPrefixedBytes(b, m.externalIdentity)
+	addUint16LengthPrefixedBytes(b, m.context)
+	b.AddUint16(m.targetProtocol)
+	b.AddUint16(m.targetKDF)
+	return b.BytesOrPanic()
+}
diff --git a/ssl/test/runner/handshake_server.go b/ssl/test/runner/handshake_server.go
index df1d380..82054ac 100644
--- a/ssl/test/runner/handshake_server.go
+++ b/ssl/test/runner/handshake_server.go
@@ -605,6 +605,28 @@
 
 	var psk *preSharedKey
 	foundKEMode := bytes.IndexByte(pskKEModes, pskDHEKEMode) >= 0
+
+	// If using PSKs, require that we are able to use it. (We may override the
+	// choice with session resumption later.)
+	if config.Credential.Type == CredentialTypePreSharedKey {
+		if !foundKEMode {
+			return errors.New("tls: no common PSK key exchange mode")
+		}
+		psk = importPSK(config.Credential, c.vers, hs.suite.hash())
+		pskIndex := slices.IndexFunc(pskIdentities, func(id pskIdentity) bool {
+			return bytes.Equal(id.ticket, psk.identity)
+		})
+		if pskIndex < 0 {
+			return errors.New("tls: client did not offer expected PSK")
+		}
+		binderToVerify := hs.clientHello.pskBinders[pskIndex]
+		if err := verifyPSKBinder(psk, hs.clientHello, binderToVerify, []byte{}, []byte{}); err != nil {
+			return err
+		}
+		hs.hello.hasPSKIdentity = true
+		hs.hello.pskIdentity = uint16(pskIndex)
+	}
+
 	if foundKEMode && !config.SessionTicketsDisabled {
 		for i, pskIdentity := range pskIdentities {
 			// TODO(svaldez): Check the obfuscatedTicketAge before accepting 0-RTT.
@@ -638,19 +660,11 @@
 			hs.sessionState = sessionState
 			hs.hello.hasPSKIdentity = true
 			hs.hello.pskIdentity = uint16(i)
-			if config.Bugs.SelectPSKIdentityOnResume != 0 {
-				hs.hello.pskIdentity = config.Bugs.SelectPSKIdentityOnResume
-			}
 			c.didResume = true
 			break
 		}
 	}
 
-	if config.Bugs.AlwaysSelectPSKIdentity {
-		hs.hello.hasPSKIdentity = true
-		hs.hello.pskIdentity = 0
-	}
-
 	// Resolve PSK and compute the early secret.
 	if psk != nil {
 		hs.finishedHash.addEntropy(psk.secret)
@@ -664,7 +678,7 @@
 
 	// Decide whether to use key_share.
 	hs.hello.hasKeyShare = config.Credential.Type != CredentialTypeSPAKE2PlusV1 && config.Bugs.UnsolicitedPAKE == 0
-	if hs.sessionState != nil && config.Bugs.NegotiatePSKResumption {
+	if psk != nil && config.Bugs.NegotiatePSKResumption {
 		hs.hello.hasKeyShare = false
 	}
 	if config.Bugs.MissingKeyShare {
@@ -946,6 +960,11 @@
 		}
 	}
 
+	if config.Bugs.AlwaysSelectPSKIdentity != nil {
+		hs.hello.hasPSKIdentity = true
+		hs.hello.pskIdentity = *config.Bugs.AlwaysSelectPSKIdentity
+	}
+
 	if config.Bugs.SendEarlyDataExtension {
 		encryptedExtensions.extensions.hasEarlyData = true
 	}
@@ -1100,7 +1119,7 @@
 	}
 
 	var requestClientCert bool
-	if (hs.sessionState == nil && hs.cert.Type != CredentialTypeSPAKE2PlusV1) || config.Bugs.AlwaysSendCertificateRequest {
+	if (psk == nil && hs.cert.Type != CredentialTypeSPAKE2PlusV1) || config.Bugs.AlwaysSendCertificateRequest {
 		requestClientCert = config.ClientAuth >= RequestClientCert
 	}
 	if requestClientCert {
@@ -1127,7 +1146,7 @@
 		c.writeRecord(recordTypeHandshake, certReq.marshal())
 	}
 
-	if (hs.sessionState == nil && hs.cert.Type != CredentialTypeSPAKE2PlusV1) || config.Bugs.AlwaysSendCertificate {
+	if (psk == nil && hs.cert.Type != CredentialTypeSPAKE2PlusV1) || config.Bugs.AlwaysSendCertificate {
 		useCert := hs.cert
 		if config.Bugs.UseCertificateCredential != nil {
 			useCert = config.Bugs.UseCertificateCredential
diff --git a/ssl/test/runner/pake_tests.go b/ssl/test/runner/pake_tests.go
index a2b89bd..794ebf7 100644
--- a/ssl/test/runner/pake_tests.go
+++ b/ssl/test/runner/pake_tests.go
@@ -511,7 +511,7 @@
 				ExpectNoTLS13PSK: true,
 				// Respond with an unsolicited PSK extension in ServerHello, to
 				// check that the client rejects it.
-				AlwaysSelectPSKIdentity: true,
+				AlwaysSelectPSKIdentity: ptrTo(uint16(0)),
 			},
 		},
 		resumeShimCredentials: []*Credential{&spakeCredential},
diff --git a/ssl/test/runner/prf.go b/ssl/test/runner/prf.go
index 8217e97..9c5354c 100644
--- a/ssl/test/runner/prf.go
+++ b/ssl/test/runner/prf.go
@@ -382,8 +382,8 @@
 }
 
 var (
-	externalPSKBinderLabel        = []byte("ext binder")
 	resumptionPSKBinderLabel      = []byte("res binder")
+	importedPSKBinderLabel        = []byte("imp binder")
 	earlyTrafficLabel             = []byte("c e traffic")
 	clientHandshakeTrafficLabel   = []byte("c hs traffic")
 	serverHandshakeTrafficLabel   = []byte("s hs traffic")
@@ -398,6 +398,8 @@
 
 	echAcceptConfirmationLabel    = []byte("ech accept confirmation")
 	echAcceptConfirmationHRRLabel = []byte("hrr ech accept confirmation")
+
+	derivedPSKLabel = []byte("derived psk")
 )
 
 // deriveSecret implements TLS 1.3's Derive-Secret function, as defined in
@@ -522,3 +524,48 @@
 	hash := suite.hash()
 	return hkdfExpandLabel(vers, hash, masterSecret, resumptionPSKLabel, nonce, hash.Size())
 }
+
+func importPSK(cred *Credential, targetProtocol version, targetHash crypto.Hash) *preSharedKey {
+	if cred.Type != CredentialTypePreSharedKey {
+		panic("not a PSK credential")
+	}
+
+	var targetKDF uint16
+	switch targetHash {
+	case crypto.SHA256:
+		targetKDF = kdfHKDFWithSHA256
+	case crypto.SHA384:
+		targetKDF = kdfHKDFWithSHA384
+	default:
+		panic("unrecogized target HKDF hash")
+	}
+
+	// See RFC 9258, Section 5.1.
+	identity := (&importedPSKIdentity{
+		externalIdentity: cred.PSKIdentity,
+		context:          cred.PSKContext,
+		targetProtocol:   targetProtocol.wire,
+		targetKDF:        targetKDF,
+	}).marshal()
+
+	h := cred.PSKHash.New()
+	h.Write(identity)
+	identityHash := h.Sum(nil)
+
+	epskx, err := hkdf.Extract(cred.PSKHash.New, cred.PreSharedKey, make([]byte, cred.PSKHash.Size()))
+	if err != nil {
+		panic(err)
+	}
+
+	// HKDF-Expand-Label in the ipskx calculation uses the label for the target protocol.
+	ipskx := hkdfExpandLabel(targetProtocol, cred.PSKHash, epskx, derivedPSKLabel, identityHash, targetHash.Size())
+
+	psk := &preSharedKey{
+		version:  targetProtocol,
+		hash:     targetHash,
+		identity: identity,
+		secret:   ipskx,
+	}
+	psk.initBinder(importedPSKBinderLabel)
+	return psk
+}
diff --git a/ssl/test/runner/psk_tests.go b/ssl/test/runner/psk_tests.go
new file mode 100644
index 0000000..d00cf86
--- /dev/null
+++ b/ssl/test/runner/psk_tests.go
@@ -0,0 +1,365 @@
+// Copyright 2026 The BoringSSL Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runner
+
+import (
+	"crypto"
+	"fmt"
+	"slices"
+)
+
+func hashToString(hash crypto.Hash) string {
+	switch hash {
+	case crypto.SHA256:
+		return "SHA256"
+	case crypto.SHA384:
+		return "SHA384"
+	default:
+		panic(fmt.Sprintf("unknown hash %d", hash))
+	}
+}
+
+func addPSKTests() {
+	pskSHA256Credential := Credential{
+		Type:         CredentialTypePreSharedKey,
+		PreSharedKey: slices.Repeat([]byte{'A', 'B', 'C', 'D'}, 8),
+		PSKIdentity:  []byte("psk1"),
+		PSKContext:   []byte("context1"),
+		PSKHash:      crypto.SHA256,
+	}
+	pskSHA384Credential := Credential{
+		Type:         CredentialTypePreSharedKey,
+		PreSharedKey: slices.Repeat([]byte{'E', 'F', 'G', 'H'}, 12),
+		PSKIdentity:  []byte("psk2"),
+		PSKContext:   []byte("context2"),
+		PSKHash:      crypto.SHA384,
+	}
+
+	hashToPSK := func(hash crypto.Hash) *Credential {
+		switch hash {
+		case crypto.SHA256:
+			return &pskSHA256Credential
+		case crypto.SHA384:
+			return &pskSHA384Credential
+		default:
+			panic(fmt.Sprintf("unknown hash %d", hash))
+		}
+	}
+	hashToCipher := func(hash crypto.Hash) uint16 {
+		switch hash {
+		case crypto.SHA256:
+			return TLS_AES_128_GCM_SHA256
+		case crypto.SHA384:
+			return TLS_AES_256_GCM_SHA384
+		default:
+			panic(fmt.Sprintf("unknown hash %d", hash))
+		}
+	}
+
+	for _, protocol := range []protocol{tls, dtls, quic} {
+		// Test that SHA-256 and SHA-384 PSKs can be used with SHA-256 and
+		// SHA-384 ciphers.
+		for _, pskHash := range []crypto.Hash{crypto.SHA256, crypto.SHA384} {
+			psk := hashToPSK(pskHash)
+			for _, cipherHash := range []crypto.Hash{crypto.SHA256, crypto.SHA384} {
+				cipher := hashToCipher(cipherHash)
+				testCases = append(testCases, testCase{
+					protocol: protocol,
+					name:     fmt.Sprintf("PSK-Client-%s-%s-%s", hashToString(pskHash), hashToString(cipherHash), protocol),
+					config: Config{
+						Credential:   psk,
+						MaxVersion:   VersionTLS13,
+						CipherSuites: []uint16{cipher},
+					},
+					shimCredentials: []*Credential{psk},
+					// Also test that the resulting session can be reused.
+					resumeSession: true,
+					// Override the default behavior of expecting a peer certificate on
+					// resumption connections.
+					flags: []string{"-expect-no-peer-cert"},
+				})
+
+				// Test with HelloRetryRequest to ensure the client computes
+				// the second ClientHello's binder correctly, and also accounts
+				// for the PSK list getting smaller once the cipher is known.
+				testCases = append(testCases, testCase{
+					protocol: protocol,
+					name:     fmt.Sprintf("PSK-Client-HRR-%s-%s-%s", hashToString(pskHash), hashToString(cipherHash), protocol),
+					config: Config{
+						Credential:   psk,
+						MaxVersion:   VersionTLS13,
+						CipherSuites: []uint16{cipher},
+						Bugs: ProtocolBugs{
+							SendHelloRetryRequestCookie: []byte("cookie"),
+						},
+					},
+					shimCredentials: []*Credential{psk},
+					// Also test that the resulting session can be reused.
+					resumeSession: true,
+					// Override the default behavior of expecting a peer certificate on
+					// resumption connections.
+					flags: []string{"-expect-no-peer-cert"},
+				})
+			}
+		}
+
+		// If the client is configured to offer multiple PSKs, it should accept
+		// either from the server.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-AcceptFirst-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential, &pskSHA384Credential},
+			flags:           []string{"-expect-selected-credential", "0"},
+		})
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-AcceptSecond-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA384Credential,
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential, &pskSHA384Credential},
+			flags:           []string{"-expect-selected-credential", "1"},
+		})
+
+		// If the client is configured (on the second connection) with both PSKs and
+		// a session, the PSK is still usable.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-DeclineSession-%s", protocol),
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+			},
+			resumeConfig: &Config{
+				MaxVersion:             VersionTLS13,
+				Credential:             &pskSHA256Credential,
+				SessionTicketsDisabled: true,
+			},
+			shimCredentials:      []*Credential{&pskSHA256Credential},
+			resumeSession:        true,
+			expectResumeRejected: true,
+			// The runner will not provision a ticket on the second connection.
+			flags: []string{"-on-resume-expect-no-session"},
+		})
+
+		// The client should reject out-of-bounds PSK indices.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-OutOfBoundsIndex-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+				Bugs: ProtocolBugs{
+					// The shim will import two PSKs from the credential, so
+					// only indices 0 and 1 are valid,
+					AlwaysSelectPSKIdentity: ptrTo(uint16(2)),
+				},
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			shouldFail:      true,
+			expectedError:   ":PSK_IDENTITY_NOT_FOUND:",
+		})
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-OutOfBoundsIndex-HRR-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+				Bugs: ProtocolBugs{
+					SendHelloRetryRequestCookie: []byte("cookie"),
+					// The shim will import two PSKs from the credential, but
+					// then prune them in the second ClientHello, so only index
+					// 0 is valid.
+					AlwaysSelectPSKIdentity: ptrTo(uint16(1)),
+				},
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			shouldFail:      true,
+			expectedError:   ":PSK_IDENTITY_NOT_FOUND:",
+		})
+
+		// The client should reject psk_ke connections. We require psk_dhe_ke.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-NoKeyShare-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+				Bugs: ProtocolBugs{
+					NegotiatePSKResumption: true,
+				},
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			shouldFail:      true,
+			expectedError:   ":MISSING_KEY_SHARE:",
+		})
+
+		// By default, if the client configures PSKs, it should reject server
+		// responses that do use certificates, including TLS 1.2.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-PSKRequired-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &rsaCertificate,
+				Bugs: ProtocolBugs{
+					// Ignore the client's lack of signature_algorithms.
+					IgnorePeerSignatureAlgorithmPreferences: true,
+				},
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			shouldFail:      true,
+			expectedError:   ":MISSING_EXTENSION:",
+			// The shim sends an alert, but alerts immediately after TLS 1.3
+			// ServerHello have an encryption mismatch.
+		})
+		if protocol != quic {
+			testCases = append(testCases, testCase{
+				protocol: protocol,
+				name:     fmt.Sprintf("PSK-Client-PSKRequired-TLS12-%s", protocol),
+				testType: clientTest,
+				config: Config{
+					MaxVersion: VersionTLS12,
+					Credential: &rsaCertificate,
+					Bugs: ProtocolBugs{
+						// Ignore the client's lack of signature_algorithms.
+						IgnorePeerSignatureAlgorithmPreferences: true,
+					},
+				},
+				shimCredentials:    []*Credential{&pskSHA256Credential},
+				shouldFail:         true,
+				expectedError:      ":UNSUPPORTED_PROTOCOL:",
+				expectedLocalError: "remote error: protocol version not supported",
+			})
+		}
+
+		// The client can be configured to accept certificates or PSKs. In this
+		// case, even TLS 1.2 certificates are acceptable.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-PSKOrCert-PSK-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			flags:           []string{"-verify-peer"},
+		})
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-PSKOrCert-Cert-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &rsaCertificate,
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential},
+			flags:           []string{"-verify-peer"},
+		})
+		if protocol != quic {
+			testCases = append(testCases, testCase{
+				protocol: protocol,
+				name:     fmt.Sprintf("PSK-Client-PSKOrCert-Cert-TLS12-%s", protocol),
+				testType: clientTest,
+				config: Config{
+					MaxVersion: VersionTLS12,
+					Credential: &rsaCertificate,
+				},
+				shimCredentials: []*Credential{&pskSHA256Credential},
+				flags:           []string{"-verify-peer"},
+			})
+		}
+
+		// When a client is configured with PSKs or certificates, it can even send
+		// client certificates, configured from the credential list.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-PSKOrCert-CertRequest-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &rsaCertificate,
+				ClientAuth: RequireAnyClientCert,
+			},
+			shimCredentials: []*Credential{&pskSHA256Credential, &rsaCertificate},
+			flags:           []string{"-verify-peer"},
+		})
+		if protocol != quic {
+			testCases = append(testCases, testCase{
+				protocol: protocol,
+				name:     fmt.Sprintf("PSK-Client-PSKOrCert-CertRequest-TLS12-%s", protocol),
+				testType: clientTest,
+				config: Config{
+					MaxVersion: VersionTLS12,
+					Credential: &rsaCertificate,
+					ClientAuth: RequireAnyClientCert,
+				},
+				shimCredentials: []*Credential{&pskSHA256Credential, &rsaCertificate},
+				flags:           []string{"-verify-peer"},
+			})
+		}
+
+		// The client should reject CertificateRequest messages on PSK connections.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-UnexpectedCertificateRequest-%s", protocol),
+			testType: clientTest,
+			config: Config{
+				MaxVersion: VersionTLS13,
+				Credential: &pskSHA256Credential,
+				ClientAuth: RequireAnyClientCert,
+				Bugs: ProtocolBugs{
+					AlwaysSendCertificateRequest: true,
+				},
+			},
+			shimCredentials:    []*Credential{&pskSHA256Credential},
+			shouldFail:         true,
+			expectedError:      ":UNEXPECTED_MESSAGE:",
+			expectedLocalError: "remote error: unexpected message",
+		})
+
+		// The client should reject Certificate messages on PSK connections.
+		testCases = append(testCases, testCase{
+			protocol: protocol,
+			name:     fmt.Sprintf("PSK-Client-UnexpectedCertificate-%s", protocol),
+			config: Config{
+				Credential: &pskSHA256Credential,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					AlwaysSendCertificate:    true,
+					UseCertificateCredential: &rsaCertificate,
+					// Ignore the client's lack of signature_algorithms.
+					IgnorePeerSignatureAlgorithmPreferences: true,
+				},
+			},
+			shimCredentials:    []*Credential{&pskSHA256Credential},
+			shouldFail:         true,
+			expectedError:      ":UNEXPECTED_MESSAGE:",
+			expectedLocalError: "remote error: unexpected message",
+		})
+	}
+}
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 786aba7..ebc1f46 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -1432,6 +1432,8 @@
 			flags = append(flags, prefix+"-new-delegated-credential")
 		case CredentialTypeSPAKE2PlusV1:
 			flags = append(flags, prefix+"-new-spake2plusv1-credential")
+		case CredentialTypePreSharedKey:
+			flags = append(flags, prefix+"-new-psk-credential")
 		default:
 			panic(fmt.Errorf("unknown credential type %d", cred.Type))
 		}
@@ -1467,6 +1469,19 @@
 	if cred.WrongPAKERole {
 		flags = append(flags, prefix+"-wrong-pake-role")
 	}
+	handleBase64Field("psk-importer-key", cred.PreSharedKey)
+	handleBase64Field("psk-importer-identity", cred.PSKIdentity)
+	handleBase64Field("psk-importer-context", cred.PSKContext)
+	switch cred.PSKHash {
+	case 0:
+		break
+	case crypto.SHA256:
+		flags = append(flags, prefix+"-psk-importer-sha256")
+	case crypto.SHA384:
+		flags = append(flags, prefix+"-psk-importer-sha384")
+	default:
+		panic(fmt.Sprintf("unknown PSK hash %s", cred.PSKHash))
+	}
 	handleBase64Field("trust-anchor-id", cred.TrustAnchorID)
 	return flags
 }
@@ -2244,6 +2259,7 @@
 	addKeyUpdateTests()
 	addPAKETests()
 	addTrustAnchorTests()
+	addPSKTests()
 
 	toAppend, err := convertToSplitHandshakeTests(testCases)
 	if err != nil {
diff --git a/ssl/test/runner/tls13_tests.go b/ssl/test/runner/tls13_tests.go
index e4af165..5853d64 100644
--- a/ssl/test/runner/tls13_tests.go
+++ b/ssl/test/runner/tls13_tests.go
@@ -1220,7 +1220,7 @@
 		config: Config{
 			MaxVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				AlwaysSelectPSKIdentity: true,
+				AlwaysSelectPSKIdentity: ptrTo(uint16(0)),
 			},
 		},
 		shouldFail:    true,
@@ -1231,8 +1231,11 @@
 		name: "InvalidPSKIdentity-TLS13",
 		config: Config{
 			MaxVersion: VersionTLS13,
+		},
+		resumeConfig: &Config{
+			MaxVersion: VersionTLS13,
 			Bugs: ProtocolBugs{
-				SelectPSKIdentityOnResume: 1,
+				AlwaysSelectPSKIdentity: ptrTo(uint16(1)),
 			},
 		},
 		resumeSession: true,
diff --git a/ssl/test/test_config.cc b/ssl/test/test_config.cc
index 90ffee1..b99985b 100644
--- a/ssl/test/test_config.cc
+++ b/ssl/test/test_config.cc
@@ -492,6 +492,7 @@
         IntFlag("-expect-cipher", &TestConfig::expect_cipher),
         StringFlag("-expect-peer-cert-file",
                    &TestConfig::expect_peer_cert_file),
+        BoolFlag("-expect-no-peer-cert", &TestConfig::expect_no_peer_cert),
         IntFlag("-resumption-delay", &TestConfig::resumption_delay),
         BoolFlag("-retain-only-sha256-client-cert",
                  &TestConfig::retain_only_sha256_client_cert),
@@ -574,6 +575,8 @@
                           CredentialConfigType::kDelegated),
         NewCredentialFlag("-new-spake2plusv1-credential",
                           CredentialConfigType::kSPAKE2PlusV1),
+        NewCredentialFlag("-new-psk-credential",
+                          CredentialConfigType::kPreSharedKey),
         CredentialFlagWithDefault(
             StringFlag("-cert-file", &TestConfig::cert_file),
             StringFlag("-cert-file", &CredentialConfig::cert_file)),
@@ -605,6 +608,15 @@
             Base64Flag("-pake-password", &CredentialConfig::pake_password)),
         CredentialFlag(
             BoolFlag("-wrong-pake-role", &CredentialConfig::wrong_pake_role)),
+        CredentialFlag(Base64Flag("-psk-importer-key", &CredentialConfig::psk)),
+        CredentialFlag(Base64Flag("-psk-importer-identity",
+                                  &CredentialConfig::psk_identity)),
+        CredentialFlag(Base64Flag("-psk-importer-context",
+                                  &CredentialConfig::psk_context)),
+        CredentialFlag(SetValueFlag("-psk-importer-sha256",
+                                    &CredentialConfig::psk_hash, EVP_sha256())),
+        CredentialFlag(SetValueFlag("-psk-importer-sha384",
+                                    &CredentialConfig::psk_hash, EVP_sha384())),
         CredentialFlag(
             Base64Flag("-trust-anchor-id", &CredentialConfig::trust_anchor_id)),
         IntFlag("-private-key-delay-ms", &TestConfig::private_key_delay_ms),
@@ -1487,6 +1499,12 @@
       }
       break;
     }
+    case CredentialConfigType::kPreSharedKey:
+      cred.reset(SSL_CREDENTIAL_new_pre_shared_key(
+          cred_config.psk.data(), cred_config.psk.size(),
+          cred_config.psk_identity.data(), cred_config.psk_identity.size(),
+          cred_config.psk_hash, cred_config.psk_context.data(),
+          cred_config.psk_context.size()));
   }
   if (cred == nullptr) {
     return nullptr;
diff --git a/ssl/test/test_config.h b/ssl/test/test_config.h
index 440a6a4..164232f 100644
--- a/ssl/test/test_config.h
+++ b/ssl/test/test_config.h
@@ -29,6 +29,7 @@
   kX509,
   kDelegated,
   kSPAKE2PlusV1,
+  kPreSharedKey,
 };
 
 struct CredentialConfig {
@@ -46,6 +47,10 @@
   std::vector<uint8_t> pake_password;
   std::vector<uint8_t> trust_anchor_id;
   bool wrong_pake_role = false;
+  std::vector<uint8_t> psk;
+  std::vector<uint8_t> psk_identity;
+  std::vector<uint8_t> psk_context;
+  const EVP_MD *psk_hash;
 };
 
 struct TestConfig {
@@ -188,6 +193,7 @@
   uint16_t expect_cipher_aes = 0;
   uint16_t expect_cipher_no_aes = 0;
   uint16_t expect_cipher = 0;
+  bool expect_no_peer_cert = false;
   std::string expect_peer_cert_file;
   int resumption_delay = 0;
   bool retain_only_sha256_client_cert = false;
diff --git a/ssl/tls13_client.cc b/ssl/tls13_client.cc
index 0027861..59dc387 100644
--- a/ssl/tls13_client.cc
+++ b/ssl/tls13_client.cc
@@ -335,6 +335,14 @@
     return ssl_hs_error;
   }
 
+  // Per RFC 8446 section 4.1.4, skip any PSKs whose hash does not match the
+  // selected cipher. This avoids performing the transcript hash transformation
+  // for multiple hashes.
+  const EVP_MD *cipher_md = SSL_CIPHER_get_handshake_digest(cipher);
+  hs->pre_shared_keys.EraseIf([=](const SSLPreSharedKey &psk) {
+    return ssl_pre_shared_key_hash(psk) != cipher_md;
+  });
+
   // HelloRetryRequest should be the end of the flight.
   if (ssl->method->has_unprocessed_handshake_data(ssl)) {
     ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_UNEXPECTED_MESSAGE);
@@ -373,6 +381,56 @@
   return ssl_hs_flush;
 }
 
+static bool check_session(const SSL_HANDSHAKE *hs, uint8_t *out_alert,
+                          const SSL_SESSION *session) {
+  const SSL *const ssl = hs->ssl;
+  if (session->ssl_version != ssl->s3->version) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_OLD_SESSION_VERSION_NOT_RETURNED);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+
+  if (session->cipher->algorithm_prf != hs->new_cipher->algorithm_prf) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_OLD_SESSION_PRF_HASH_MISMATCH);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+
+  if (!ssl_session_is_context_valid(hs, session)) {
+    // This is actually a client application bug.
+    OPENSSL_PUT_ERROR(SSL, SSL_R_ATTEMPT_TO_REUSE_SESSION_IN_DIFFERENT_CONTEXT);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+  return true;
+}
+
+static bool check_imported_psk(const SSL_HANDSHAKE *hs, uint8_t *out_alert,
+                               const SSLImportedPSK &imported) {
+  const SSL *const ssl = hs->ssl;
+  const EVP_MD *md =
+      ssl_get_handshake_digest(ssl_protocol_version(ssl), hs->new_cipher);
+  if (imported.md != md || imported.protocol != ssl->s3->version) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_INVALID_PSK_FOR_CONNECTION);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+  return true;
+}
+
+static bool using_certificate(const SSL_HANDSHAKE *hs) {
+  const SSL *const ssl = hs->ssl;
+  // Resumption is not a certificate-based handshake.
+  if (ssl->s3->session_reused) {
+    return false;
+  }
+  // Non-private-key credentials imply a non-certificate handshake (PSK, etc.).
+  if (hs->credential != nullptr && !hs->credential->UsesPrivateKey()) {
+    return false;
+  }
+  return true;
+}
+
 static enum ssl_hs_wait_t do_read_server_hello(SSL_HANDSHAKE *hs) {
   SSL *const ssl = hs->ssl;
   SSLMessage msg;
@@ -425,12 +483,9 @@
   OPENSSL_memcpy(ssl->s3->server_random, CBS_data(&server_hello.random),
                  SSL3_RANDOM_SIZE);
 
-  // When offering ECH, |ssl->session| is only offered in ClientHelloInner.
+  // When offering ECH, pre-shared keys are only offered in ClientHelloInner.
   const bool pre_shared_key_allowed =
-      ssl->session != nullptr &&
-      ssl_session_get_type(ssl->session.get()) ==
-          SSLSessionType::kPreSharedKey &&
-      ssl->s3->ech_status != ssl_ech_rejected;
+      !hs->pre_shared_keys.empty() && ssl->s3->ech_status != ssl_ech_rejected;
   SSLExtension key_share(TLSEXT_TYPE_key_share, !hs->key_shares.empty()),
       pake_share(TLSEXT_TYPE_pake, hs->pake_prover != nullptr),
       pre_shared_key(TLSEXT_TYPE_pre_shared_key, pre_shared_key_allowed),
@@ -495,61 +550,65 @@
       // PAKE handshake
       (!key_share.present && pake_share.present && !pre_shared_key.present));
 
+  // Determine the PSK used.
+  const EVP_MD *cipher_md =
+      ssl_get_handshake_digest(ssl_protocol_version(ssl), hs->new_cipher);
+  Span<const uint8_t> psk_secret = Span(kZeroes, EVP_MD_size(cipher_md));
   alert = SSL_AD_DECODE_ERROR;
   if (pre_shared_key.present) {
-    if (!ssl_ext_pre_shared_key_parse_serverhello(hs, &alert,
-                                                  &pre_shared_key.data)) {
+    const SSLPreSharedKey *psk = ssl_ext_pre_shared_key_parse_serverhello(
+        hs, &alert, &pre_shared_key.data);
+    if (psk == nullptr) {
       ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
       return ssl_hs_error;
     }
 
-    if (ssl->session->ssl_version != ssl->s3->version) {
-      OPENSSL_PUT_ERROR(SSL, SSL_R_OLD_SESSION_VERSION_NOT_RETURNED);
-      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_ILLEGAL_PARAMETER);
-      return ssl_hs_error;
-    }
+    if (const auto *imported = std::get_if<SSLImportedPSK>(psk);
+        imported != nullptr) {
+      if (!check_imported_psk(hs, &alert, *imported)) {
+        ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
+        return ssl_hs_error;
+      }
 
-    if (ssl->session->cipher->algorithm_prf != hs->new_cipher->algorithm_prf) {
-      OPENSSL_PUT_ERROR(SSL, SSL_R_OLD_SESSION_PRF_HASH_MISMATCH);
-      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_ILLEGAL_PARAMETER);
-      return ssl_hs_error;
-    }
+      psk_secret = imported->ipskx;
+      hs->credential = UpRef(imported->credential);
+      if (!ssl_get_new_session(hs)) {
+        ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_INTERNAL_ERROR);
+        return ssl_hs_error;
+      }
+    } else {
+      const SSL_SESSION *session = std::get<UniquePtr<SSL_SESSION>>(*psk).get();
+      assert(session == ssl->session.get());
+      if (!check_session(hs, &alert, session)) {
+        ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
+        return ssl_hs_error;
+      }
 
-    if (!ssl_session_is_context_valid(hs, ssl->session.get())) {
-      // This is actually a client application bug.
-      OPENSSL_PUT_ERROR(SSL,
-                        SSL_R_ATTEMPT_TO_REUSE_SESSION_IN_DIFFERENT_CONTEXT);
-      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_ILLEGAL_PARAMETER);
-      return ssl_hs_error;
-    }
+      psk_secret = session->secret;
+      ssl->s3->session_reused = true;
+      // Only authentication information carries over in TLS 1.3.
+      hs->new_session = SSL_SESSION_dup(session, SSL_SESSION_DUP_AUTH_ONLY);
+      if (!hs->new_session) {
+        ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_INTERNAL_ERROR);
+        return ssl_hs_error;
+      }
+      ssl_set_session(ssl, nullptr);
 
-    ssl->s3->session_reused = true;
-    hs->can_release_private_key = true;
-    // Only authentication information carries over in TLS 1.3.
-    hs->new_session =
-        SSL_SESSION_dup(ssl->session.get(), SSL_SESSION_DUP_AUTH_ONLY);
-    if (!hs->new_session) {
-      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_INTERNAL_ERROR);
-      return ssl_hs_error;
+      // Resumption incorporates fresh key material, so refresh the timeout.
+      ssl_session_renew_timeout(ssl, hs->new_session.get(),
+                                ssl->session_ctx->session_psk_dhe_timeout);
     }
-    ssl_set_session(ssl, nullptr);
-
-    // Resumption incorporates fresh key material, so refresh the timeout.
-    ssl_session_renew_timeout(ssl, hs->new_session.get(),
-                              ssl->session_ctx->session_psk_dhe_timeout);
   } else if (!ssl_get_new_session(hs)) {
     ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_INTERNAL_ERROR);
     return ssl_hs_error;
   }
 
   hs->new_session->cipher = hs->new_cipher;
+  hs->can_release_private_key = !using_certificate(hs);
+  assert(!using_certificate(hs) || ssl_accepts_server_certificate_auth(hs));
 
   // Set up the key schedule and incorporate the PSK into the running secret.
-  size_t hash_len = EVP_MD_size(
-      ssl_get_handshake_digest(ssl_protocol_version(ssl), hs->new_cipher));
-  if (!tls13_init_key_schedule(hs, ssl->s3->session_reused
-                                       ? Span(hs->new_session->secret)
-                                       : Span(kZeroes, hash_len))) {
+  if (!tls13_init_key_schedule(hs, psk_secret)) {
     return ssl_hs_error;
   }
 
@@ -686,9 +745,10 @@
 
 static enum ssl_hs_wait_t do_read_certificate_request(SSL_HANDSHAKE *hs) {
   SSL *const ssl = hs->ssl;
-  // CertificateRequest may only be sent in non-resumption handshakes.
-  if (ssl->s3->session_reused) {
-    if (ssl->ctx->reverify_on_resume && !ssl->s3->early_data_accepted) {
+  // CertificateRequest may only be sent in certificate-based handshakes.
+  if (!using_certificate(hs)) {
+    if (ssl->s3->session_reused && ssl->ctx->reverify_on_resume &&
+        !ssl->s3->early_data_accepted) {
       hs->tls13_state = state_server_certificate_reverify;
       return ssl_hs_ok;
     }
@@ -696,11 +756,6 @@
     return ssl_hs_ok;
   }
 
-  if (hs->pake_prover) {
-    hs->tls13_state = state_read_server_finished;
-    return ssl_hs_ok;
-  }
-
   SSLMessage msg;
   if (!ssl->method->get_message(ssl, &msg)) {
     return ssl_hs_read_message;
@@ -952,6 +1007,8 @@
     return ssl_hs_error;
   }
 
+  // TODO(crbug.com/369963041): It is currently impossible for a client that
+  // accepts PSKs or server certs to then decline to send a client cert.
   if (!creds.empty()) {
     // Select the credential to use.
     for (SSL_CREDENTIAL *cred : creds) {
diff --git a/ssl/tls13_enc.cc b/ssl/tls13_enc.cc
index 2c82fad..c24cf58 100644
--- a/ssl/tls13_enc.cc
+++ b/ssl/tls13_enc.cc
@@ -30,6 +30,7 @@
 #include <openssl/hmac.h>
 #include <openssl/mem.h>
 
+#include "../crypto/bytestring/internal.h"
 #include "../crypto/fipsmodule/tls/internal.h"
 #include "../crypto/internal.h"
 #include "internal.h"
@@ -501,13 +502,40 @@
                            hash, SSL_is_dtls(ssl));
 }
 
-static const char kTLS13LabelPSKBinder[] = "res binder";
+const EVP_MD *ssl_pre_shared_key_hash(const SSLPreSharedKey &psk) {
+  if (const auto *imported = std::get_if<SSLImportedPSK>(&psk);
+      imported != nullptr) {
+    return imported->md;
+  }
+  return ssl_session_get_digest(std::get<UniquePtr<SSL_SESSION>>(psk).get());
+}
+
+Span<const uint8_t> ssl_pre_shared_key_identity(const SSLPreSharedKey &psk) {
+  if (const auto *imported = std::get_if<SSLImportedPSK>(&psk);
+      imported != nullptr) {
+    return imported->imported_identity;
+  }
+  return std::get<UniquePtr<SSL_SESSION>>(psk)->ticket;
+}
 
 bool tls13_psk_binder(const SSL_HANDSHAKE *hs, Span<uint8_t> out,
-                      size_t *out_len, const SSL_SESSION *session,
+                      size_t *out_len, const SSLPreSharedKey &psk,
                       const SSLTranscript &transcript,
                       Span<const uint8_t> client_hello, size_t binders_len) {
-  const EVP_MD *digest = ssl_session_get_digest(session);
+  const EVP_MD *digest;
+  Span<const uint8_t> secret;
+  std::string_view label;
+  if (const auto *imported = std::get_if<SSLImportedPSK>(&psk);
+      imported != nullptr) {
+    digest = imported->md;
+    secret = imported->ipskx;
+    label = "imp binder";
+  } else {
+    const SSL_SESSION *session = std::get<UniquePtr<SSL_SESSION>>(psk).get();
+    digest = ssl_session_get_digest(session);
+    secret = session->secret;
+    label = "res binder";
+  }
 
   // Compute the binder key.
   //
@@ -521,13 +549,11 @@
   auto binder_key = Span(binder_key_buf, EVP_MD_size(digest));
   if (!EVP_Digest(nullptr, 0, binder_context, &binder_context_len, digest,
                   nullptr) ||
-      !HKDF_extract(early_secret, &early_secret_len, digest,
-                    session->secret.data(), session->secret.size(), nullptr,
-                    0) ||
+      !HKDF_extract(early_secret, &early_secret_len, digest, secret.data(),
+                    secret.size(), nullptr, 0) ||
       !hkdf_expand_label(
-          binder_key, digest, Span(early_secret, early_secret_len),
-          kTLS13LabelPSKBinder, Span(binder_context, binder_context_len),
-          SSL_is_dtls(hs->ssl))) {
+          binder_key, digest, Span(early_secret, early_secret_len), label,
+          Span(binder_context, binder_context_len), SSL_is_dtls(hs->ssl))) {
     return false;
   }
 
@@ -573,7 +599,8 @@
   // The binders are computed over |msg| with |binders| and its u16 length
   // prefix removed. The caller is assumed to have parsed |msg|, extracted
   // |binders|, and verified the PSK extension is last.
-  if (!tls13_psk_binder(hs, verify_data, &verify_data_len, session,
+  if (!tls13_psk_binder(hs, verify_data, &verify_data_len,
+                        UpRef(const_cast<SSL_SESSION *>(session)),
                         hs->transcript, msg.body, 2 + CBS_len(binders)) ||
       // We only consider the first PSK, so compare against the first binder.
       !CBS_get_u8_length_prefixed(binders, &binder)) {
@@ -595,6 +622,68 @@
   return true;
 }
 
+std::optional<SSLImportedPSK> tls13_derive_imported_psk(
+    const SSL_HANDSHAKE *hs, SSL_CREDENTIAL *cred, uint16_t protocol,
+    const EVP_MD *hkdf_md) {
+  assert(cred->type == SSLCredentialType::kPreSharedKey);
+
+  // See Section 10 of RFC 9258.
+  uint16_t target_kdf;
+  switch (EVP_MD_nid(hkdf_md)) {
+    case NID_sha256:
+      target_kdf = 0x0001;  // HKDF_SHA256
+      break;
+    case NID_sha384:
+      target_kdf = 0x0002;  // HKDF_SHA384
+      break;
+    default:
+      OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+      return std::nullopt;
+  }
+
+  SSLImportedPSK ret;
+  ret.credential = UpRef(cred);
+  ret.protocol = protocol;
+  ret.md = hkdf_md;
+
+  // See Section 5.1 of RFC 9258.
+  ScopedCBB imported_id;
+  CBB external_identity, context;
+  if (!CBB_init(imported_id.get(), 2 + cred->epsk_id.size() + 2 +
+                                       cred->epsk_context.size() + 2 + 2) ||
+      !CBB_add_u16_length_prefixed(imported_id.get(), &external_identity) ||
+      !CBB_add_bytes(&external_identity, cred->epsk_id.data(),
+                     cred->epsk_id.size()) ||
+      !CBB_add_u16_length_prefixed(imported_id.get(), &context) ||
+      !CBB_add_bytes(&context, cred->epsk_context.data(),
+                     cred->epsk_context.size()) ||
+      !CBB_add_u16(imported_id.get(), protocol) ||
+      !CBB_add_u16(imported_id.get(), target_kdf) ||
+      !CBBFinishArray(imported_id.get(), &ret.imported_identity)) {
+    return std::nullopt;
+  }
+
+  ScopedEVP_MD_CTX imported_id_ctx;
+  InplaceVector<uint8_t, EVP_MAX_MD_SIZE> imported_id_hash;
+  imported_id_hash.ResizeForOverwrite(EVP_MD_size(cred->epsk_md));
+  unsigned imported_id_hash_len;
+  if (!EVP_Digest(ret.imported_identity.data(), ret.imported_identity.size(),
+                  imported_id_hash.data(), &imported_id_hash_len, cred->epsk_md,
+                  nullptr)) {
+    return std::nullopt;
+  }
+  assert(imported_id_hash.size() == imported_id_hash_len);
+
+  ret.ipskx.ResizeForOverwrite(EVP_MD_size(hkdf_md));
+  if (!hkdf_expand_label(Span(ret.ipskx), cred->epsk_md, cred->epskx,
+                         "derived psk", imported_id_hash,
+                         SSL_is_dtls(hs->ssl))) {
+    return std::nullopt;
+  }
+
+  return ret;
+}
+
 size_t ssl_ech_confirmation_signal_hello_offset(const SSL *ssl) {
   static_assert(ECH_CONFIRMATION_SIGNAL_LEN < SSL3_RANDOM_SIZE,
                 "the confirmation signal is a suffix of the random");