Commit d1116a17754 for php.net

commit d1116a177549ef6a526fb977359bf73fdb4688da
Author: Zachary DuBois <ZacharyDuBois@users.noreply.github.com>
Date:   Sun Sep 6 08:24:29 2026 -0400

    ext/sodium: Add crypto_kem (X-Wing) and crypto_kem_mlkem768 bindings (#23420)

    * ext/sodium: Add crypto_kem (X-Wing) bindings

    Expose the libsodium 1.0.22 generic KEM API (X-Wing, hybrid
    ML-KEM768+X25519). Guarded so older libsodium builds are unaffected.

    * ext/sodium: Add crypto_kem_mlkem768 bindings

    Expose libsodium 1.0.22's ML-KEM768 (FIPS 203) KEM with the same shape
    as the generic crypto_kem API.

    * ext/sodium: Test the KEM internal-error paths

    An invalid ML-KEM public key encoding and a small-order X25519
    ciphertext component deterministically fail inside libsodium,
    exercising the memzero-on-error branches. ML-KEM768 decapsulation
    never fails (implicit rejection), so it has no such test. Also drop
    the cross-family length check, which duplicated existing coverage.

diff --git a/NEWS b/NEWS
index 89603cf7c5e..e19c336ca81 100644
--- a/NEWS
+++ b/NEWS
@@ -71,6 +71,10 @@ PHP                                                                        NEWS
   . Fixed WSDL cache corruption when a soap:header defines headerfaults.
     (Ilia Alshanetsky)

+- Sodium:
+  . Added support for the libsodium 1.0.22 KEM APIs (X-Wing and ML-KEM768).
+    (Zachary DuBois)
+
 - Standard:
   . Fixed a segfault when a stream filter callback unsets StreamBucket::$data
     before re-attaching the bucket. (iliaal)
diff --git a/UPGRADING b/UPGRADING
index 20bff056dce..c795bc347df 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -768,6 +768,20 @@ PHP 8.6 UPGRADE NOTES
   . snmp_set_output_option()
   . snmp_set_string_output_format()

+- Sodium:
+  . sodium_crypto_kem_keypair(), sodium_crypto_kem_seed_keypair(),
+    sodium_crypto_kem_secretkey(), sodium_crypto_kem_publickey(),
+    sodium_crypto_kem_enc() and sodium_crypto_kem_dec() expose the X-Wing
+    KEM (hybrid ML-KEM768+X25519, libsodium's recommended KEM).
+    Available when PHP is built against libsodium >= 1.0.22.
+  . sodium_crypto_kem_mlkem768_keypair(),
+    sodium_crypto_kem_mlkem768_seed_keypair(),
+    sodium_crypto_kem_mlkem768_secretkey(),
+    sodium_crypto_kem_mlkem768_publickey(),
+    sodium_crypto_kem_mlkem768_enc() and sodium_crypto_kem_mlkem768_dec()
+    expose the ML-KEM768 (FIPS 203) KEM.
+    Available when PHP is built against libsodium >= 1.0.22.
+
 - Standard:
   . clamp() returns the given value if in range, else returns the nearest
     bound.
@@ -922,6 +936,18 @@ PHP 8.6 UPGRADE NOTES
   . SODIUM_CRYPTO_XOF_TURBOSHAKE128_STATEBYTES.
   . SODIUM_CRYPTO_XOF_TURBOSHAKE256_BLOCKBYTES.
   . SODIUM_CRYPTO_XOF_TURBOSHAKE256_STATEBYTES.
+  . SODIUM_CRYPTO_KEM_PUBLICKEYBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_SECRETKEYBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_SHAREDSECRETBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_SEEDBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_KEYPAIRBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_CIPHERTEXTBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_SHAREDSECRETBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES (libsodium >= 1.0.22).
+  . SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES (libsodium >= 1.0.22).

 - Standard:
   . ARRAY_FILTER_USE_VALUE.
diff --git a/ext/sodium/libsodium.c b/ext/sodium/libsodium.c
index 7b8f41f2f4b..814258d6a84 100644
--- a/ext/sodium/libsodium.c
+++ b/ext/sodium/libsodium.c
@@ -4830,3 +4830,311 @@ PHP_FUNCTION(sodium_crypto_xof_turboshake256_squeeze)
 	RETURN_NEW_STR(out);
 }
 #endif
+
+#ifdef crypto_kem_PUBLICKEYBYTES
+PHP_FUNCTION(sodium_crypto_kem_keypair)
+{
+	zend_string *keypair;
+	size_t       keypair_len;
+
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	keypair_len = crypto_kem_SECRETKEYBYTES + crypto_kem_PUBLICKEYBYTES;
+	keypair = zend_string_alloc(keypair_len, 0);
+	if (crypto_kem_keypair((unsigned char *) ZSTR_VAL(keypair) +
+						   crypto_kem_SECRETKEYBYTES,
+						   (unsigned char *) ZSTR_VAL(keypair)) != 0) {
+		sodium_memzero(ZSTR_VAL(keypair), keypair_len);
+		zend_string_efree(keypair);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(keypair)[keypair_len] = 0;
+
+	RETURN_NEW_STR(keypair);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_seed_keypair)
+{
+	zend_string   *keypair;
+	unsigned char *seed;
+	size_t         keypair_len;
+	size_t         seed_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&seed, &seed_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (seed_len != crypto_kem_SEEDBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_SEEDBYTES bytes long");
+		RETURN_THROWS();
+	}
+	keypair_len = crypto_kem_SECRETKEYBYTES + crypto_kem_PUBLICKEYBYTES;
+	keypair = zend_string_alloc(keypair_len, 0);
+	if (crypto_kem_seed_keypair((unsigned char *) ZSTR_VAL(keypair) +
+								crypto_kem_SECRETKEYBYTES,
+								(unsigned char *) ZSTR_VAL(keypair),
+								seed) != 0) {
+		sodium_memzero(ZSTR_VAL(keypair), keypair_len);
+		zend_string_efree(keypair);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(keypair)[keypair_len] = 0;
+
+	RETURN_NEW_STR(keypair);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_secretkey)
+{
+	unsigned char *keypair;
+	size_t         keypair_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&keypair, &keypair_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (keypair_len !=
+		crypto_kem_SECRETKEYBYTES + crypto_kem_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_KEYPAIRBYTES bytes long");
+		RETURN_THROWS();
+	}
+	RETURN_STRINGL((const char *) keypair, crypto_kem_SECRETKEYBYTES);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_publickey)
+{
+	unsigned char *keypair;
+	size_t         keypair_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&keypair, &keypair_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (keypair_len !=
+		crypto_kem_SECRETKEYBYTES + crypto_kem_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_KEYPAIRBYTES bytes long");
+		RETURN_THROWS();
+	}
+	RETURN_STRINGL((const char *) keypair + crypto_kem_SECRETKEYBYTES, crypto_kem_PUBLICKEYBYTES);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_enc)
+{
+	unsigned char  ciphertext[crypto_kem_CIPHERTEXTBYTES];
+	unsigned char  shared_secret[crypto_kem_SHAREDSECRETBYTES];
+	unsigned char *publickey;
+	size_t         publickey_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&publickey, &publickey_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (publickey_len != crypto_kem_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_PUBLICKEYBYTES bytes long");
+		RETURN_THROWS();
+	}
+	if (crypto_kem_enc(ciphertext, shared_secret, publickey) != 0) {
+		sodium_memzero(shared_secret, sizeof shared_secret);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	array_init(return_value);
+	add_next_index_stringl(return_value, (const char *) ciphertext, sizeof ciphertext);
+	add_next_index_stringl(return_value, (const char *) shared_secret, sizeof shared_secret);
+	sodium_memzero(shared_secret, sizeof shared_secret);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_dec)
+{
+	zend_string   *shared_secret;
+	unsigned char *ciphertext;
+	unsigned char *secretkey;
+	size_t         ciphertext_len;
+	size_t         secretkey_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss",
+									&ciphertext, &ciphertext_len,
+									&secretkey, &secretkey_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (ciphertext_len != crypto_kem_CIPHERTEXTBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES bytes long");
+		RETURN_THROWS();
+	}
+	if (secretkey_len != crypto_kem_SECRETKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 2, "must be SODIUM_CRYPTO_KEM_SECRETKEYBYTES bytes long");
+		RETURN_THROWS();
+	}
+	shared_secret = zend_string_alloc(crypto_kem_SHAREDSECRETBYTES, 0);
+	if (crypto_kem_dec((unsigned char *) ZSTR_VAL(shared_secret),
+					   ciphertext, secretkey) != 0) {
+		sodium_memzero(ZSTR_VAL(shared_secret), crypto_kem_SHAREDSECRETBYTES);
+		zend_string_efree(shared_secret);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(shared_secret)[crypto_kem_SHAREDSECRETBYTES] = 0;
+
+	RETURN_NEW_STR(shared_secret);
+}
+#endif
+
+#ifdef crypto_kem_mlkem768_PUBLICKEYBYTES
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_keypair)
+{
+	zend_string *keypair;
+	size_t       keypair_len;
+
+	ZEND_PARSE_PARAMETERS_NONE();
+
+	keypair_len = crypto_kem_mlkem768_SECRETKEYBYTES + crypto_kem_mlkem768_PUBLICKEYBYTES;
+	keypair = zend_string_alloc(keypair_len, 0);
+	if (crypto_kem_mlkem768_keypair((unsigned char *) ZSTR_VAL(keypair) +
+									crypto_kem_mlkem768_SECRETKEYBYTES,
+									(unsigned char *) ZSTR_VAL(keypair)) != 0) {
+		sodium_memzero(ZSTR_VAL(keypair), keypair_len);
+		zend_string_efree(keypair);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(keypair)[keypair_len] = 0;
+
+	RETURN_NEW_STR(keypair);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_seed_keypair)
+{
+	zend_string   *keypair;
+	unsigned char *seed;
+	size_t         keypair_len;
+	size_t         seed_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&seed, &seed_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (seed_len != crypto_kem_mlkem768_SEEDBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES bytes long");
+		RETURN_THROWS();
+	}
+	keypair_len = crypto_kem_mlkem768_SECRETKEYBYTES + crypto_kem_mlkem768_PUBLICKEYBYTES;
+	keypair = zend_string_alloc(keypair_len, 0);
+	if (crypto_kem_mlkem768_seed_keypair((unsigned char *) ZSTR_VAL(keypair) +
+										 crypto_kem_mlkem768_SECRETKEYBYTES,
+										 (unsigned char *) ZSTR_VAL(keypair),
+										 seed) != 0) {
+		sodium_memzero(ZSTR_VAL(keypair), keypair_len);
+		zend_string_efree(keypair);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(keypair)[keypair_len] = 0;
+
+	RETURN_NEW_STR(keypair);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_secretkey)
+{
+	unsigned char *keypair;
+	size_t         keypair_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&keypair, &keypair_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (keypair_len !=
+		crypto_kem_mlkem768_SECRETKEYBYTES + crypto_kem_mlkem768_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES bytes long");
+		RETURN_THROWS();
+	}
+	RETURN_STRINGL((const char *) keypair, crypto_kem_mlkem768_SECRETKEYBYTES);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_publickey)
+{
+	unsigned char *keypair;
+	size_t         keypair_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&keypair, &keypair_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (keypair_len !=
+		crypto_kem_mlkem768_SECRETKEYBYTES + crypto_kem_mlkem768_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES bytes long");
+		RETURN_THROWS();
+	}
+	RETURN_STRINGL((const char *) keypair + crypto_kem_mlkem768_SECRETKEYBYTES, crypto_kem_mlkem768_PUBLICKEYBYTES);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_enc)
+{
+	unsigned char  ciphertext[crypto_kem_mlkem768_CIPHERTEXTBYTES];
+	unsigned char  shared_secret[crypto_kem_mlkem768_SHAREDSECRETBYTES];
+	unsigned char *publickey;
+	size_t         publickey_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "s",
+									&publickey, &publickey_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (publickey_len != crypto_kem_mlkem768_PUBLICKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES bytes long");
+		RETURN_THROWS();
+	}
+	if (crypto_kem_mlkem768_enc(ciphertext, shared_secret, publickey) != 0) {
+		sodium_memzero(shared_secret, sizeof shared_secret);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	array_init(return_value);
+	add_next_index_stringl(return_value, (const char *) ciphertext, sizeof ciphertext);
+	add_next_index_stringl(return_value, (const char *) shared_secret, sizeof shared_secret);
+	sodium_memzero(shared_secret, sizeof shared_secret);
+}
+
+PHP_FUNCTION(sodium_crypto_kem_mlkem768_dec)
+{
+	zend_string   *shared_secret;
+	unsigned char *ciphertext;
+	unsigned char *secretkey;
+	size_t         ciphertext_len;
+	size_t         secretkey_len;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss",
+									&ciphertext, &ciphertext_len,
+									&secretkey, &secretkey_len) == FAILURE) {
+		sodium_remove_param_values_from_backtrace(EG(exception));
+		RETURN_THROWS();
+	}
+	if (ciphertext_len != crypto_kem_mlkem768_CIPHERTEXTBYTES) {
+		zend_argument_error(sodium_exception_ce, 1, "must be SODIUM_CRYPTO_KEM_MLKEM768_CIPHERTEXTBYTES bytes long");
+		RETURN_THROWS();
+	}
+	if (secretkey_len != crypto_kem_mlkem768_SECRETKEYBYTES) {
+		zend_argument_error(sodium_exception_ce, 2, "must be SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES bytes long");
+		RETURN_THROWS();
+	}
+	shared_secret = zend_string_alloc(crypto_kem_mlkem768_SHAREDSECRETBYTES, 0);
+	if (crypto_kem_mlkem768_dec((unsigned char *) ZSTR_VAL(shared_secret),
+								ciphertext, secretkey) != 0) {
+		sodium_memzero(ZSTR_VAL(shared_secret), crypto_kem_mlkem768_SHAREDSECRETBYTES);
+		zend_string_efree(shared_secret);
+		zend_throw_exception(sodium_exception_ce, "internal error", 0);
+		RETURN_THROWS();
+	}
+	ZSTR_VAL(shared_secret)[crypto_kem_mlkem768_SHAREDSECRETBYTES] = 0;
+
+	RETURN_NEW_STR(shared_secret);
+}
+#endif
diff --git a/ext/sodium/libsodium.stub.php b/ext/sodium/libsodium.stub.php
index 9fda5f4ddd7..80d8de2c0b2 100644
--- a/ext/sodium/libsodium.stub.php
+++ b/ext/sodium/libsodium.stub.php
@@ -967,4 +967,94 @@ function sodium_crypto_xof_turboshake256_update(string &$state, string $message)
 function sodium_crypto_xof_turboshake256_squeeze(string &$state, int $length): string {}
 #endif

+#ifdef crypto_kem_PUBLICKEYBYTES
+/**
+ * @var int
+ * @cvalue crypto_kem_PUBLICKEYBYTES
+ */
+const SODIUM_CRYPTO_KEM_PUBLICKEYBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_SECRETKEYBYTES
+ */
+const SODIUM_CRYPTO_KEM_SECRETKEYBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_CIPHERTEXTBYTES
+ */
+const SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_SHAREDSECRETBYTES
+ */
+const SODIUM_CRYPTO_KEM_SHAREDSECRETBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_SEEDBYTES
+ */
+const SODIUM_CRYPTO_KEM_SEEDBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue SODIUM_CRYPTO_KEM_KEYPAIRBYTES()
+ */
+const SODIUM_CRYPTO_KEM_KEYPAIRBYTES = UNKNOWN;
+
+function sodium_crypto_kem_keypair(): string {}
+
+function sodium_crypto_kem_seed_keypair(#[\SensitiveParameter] string $seed): string {}
+
+function sodium_crypto_kem_secretkey(#[\SensitiveParameter] string $key_pair): string {}
+
+function sodium_crypto_kem_publickey(#[\SensitiveParameter] string $key_pair): string {}
+
+function sodium_crypto_kem_enc(string $public_key): array {}
+
+function sodium_crypto_kem_dec(string $ciphertext, #[\SensitiveParameter] string $secret_key): string {}
+#endif
+
+#ifdef crypto_kem_mlkem768_PUBLICKEYBYTES
+/**
+ * @var int
+ * @cvalue crypto_kem_mlkem768_PUBLICKEYBYTES
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_mlkem768_SECRETKEYBYTES
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_mlkem768_CIPHERTEXTBYTES
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_CIPHERTEXTBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_mlkem768_SHAREDSECRETBYTES
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_SHAREDSECRETBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue crypto_kem_mlkem768_SEEDBYTES
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES = UNKNOWN;
+/**
+ * @var int
+ * @cvalue SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES()
+ */
+const SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES = UNKNOWN;
+
+function sodium_crypto_kem_mlkem768_keypair(): string {}
+
+function sodium_crypto_kem_mlkem768_seed_keypair(#[\SensitiveParameter] string $seed): string {}
+
+function sodium_crypto_kem_mlkem768_secretkey(#[\SensitiveParameter] string $key_pair): string {}
+
+function sodium_crypto_kem_mlkem768_publickey(#[\SensitiveParameter] string $key_pair): string {}
+
+function sodium_crypto_kem_mlkem768_enc(string $public_key): array {}
+
+function sodium_crypto_kem_mlkem768_dec(string $ciphertext, #[\SensitiveParameter] string $secret_key): string {}
+#endif
+
 class SodiumException extends Exception {}
diff --git a/ext/sodium/libsodium_arginfo.h b/ext/sodium/libsodium_arginfo.h
index 1b291e9a233..548cd132c55 100644
Binary files a/ext/sodium/libsodium_arginfo.h and b/ext/sodium/libsodium_arginfo.h differ
diff --git a/ext/sodium/php_libsodium.h b/ext/sodium/php_libsodium.h
index 8132472d684..ed070df7744 100644
--- a/ext/sodium/php_libsodium.h
+++ b/ext/sodium/php_libsodium.h
@@ -34,6 +34,14 @@ extern zend_module_entry sodium_module_entry;

 #define SODIUM_CRYPTO_SIGN_KEYPAIRBYTES() crypto_sign_SECRETKEYBYTES + crypto_sign_PUBLICKEYBYTES

+#ifdef crypto_kem_PUBLICKEYBYTES
+#define SODIUM_CRYPTO_KEM_KEYPAIRBYTES() crypto_kem_SECRETKEYBYTES + crypto_kem_PUBLICKEYBYTES
+#endif
+
+#ifdef crypto_kem_mlkem768_PUBLICKEYBYTES
+#define SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES() crypto_kem_mlkem768_SECRETKEYBYTES + crypto_kem_mlkem768_PUBLICKEYBYTES
+#endif
+
 #if SODIUM_LIBRARY_VERSION_MAJOR > 9 || (SODIUM_LIBRARY_VERSION_MAJOR == 9 && SODIUM_LIBRARY_VERSION_MINOR >= 6)

 /**
diff --git a/ext/sodium/tests/crypto_kem.phpt b/ext/sodium/tests/crypto_kem.phpt
new file mode 100644
index 00000000000..4a3036a98b6
--- /dev/null
+++ b/ext/sodium/tests/crypto_kem.phpt
@@ -0,0 +1,135 @@
+--TEST--
+Check for libsodium crypto_kem (X-Wing)
+--EXTENSIONS--
+sodium
+--SKIPIF--
+<?php
+if (!defined('SODIUM_CRYPTO_KEM_PUBLICKEYBYTES')) print "skip libsodium without KEM support (requires >= 1.0.22)";
+?>
+--FILE--
+<?php
+echo "crypto_kem (X-Wing):\n";
+
+/* Constant sanity */
+var_dump(SODIUM_CRYPTO_KEM_KEYPAIRBYTES ===
+         SODIUM_CRYPTO_KEM_SECRETKEYBYTES + SODIUM_CRYPTO_KEM_PUBLICKEYBYTES);
+var_dump(SODIUM_CRYPTO_KEM_SHAREDSECRETBYTES === 32);
+
+/* Round-trip: keypair -> extractors -> enc -> dec */
+$keypair = sodium_crypto_kem_keypair();
+var_dump(strlen($keypair) === SODIUM_CRYPTO_KEM_KEYPAIRBYTES);
+$secret_key = sodium_crypto_kem_secretkey($keypair);
+$public_key = sodium_crypto_kem_publickey($keypair);
+var_dump(strlen($secret_key) === SODIUM_CRYPTO_KEM_SECRETKEYBYTES);
+var_dump(strlen($public_key) === SODIUM_CRYPTO_KEM_PUBLICKEYBYTES);
+
+[$ciphertext, $shared_secret] = sodium_crypto_kem_enc($public_key);
+var_dump(strlen($ciphertext) === SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES);
+var_dump(strlen($shared_secret) === SODIUM_CRYPTO_KEM_SHAREDSECRETBYTES);
+var_dump(sodium_crypto_kem_dec($ciphertext, $secret_key) === $shared_secret);
+
+/* Encapsulation is randomized: same public key, fresh ciphertext and secret */
+[$ciphertext2, $shared_secret2] = sodium_crypto_kem_enc($public_key);
+var_dump($ciphertext2 !== $ciphertext);
+var_dump($shared_secret2 !== $shared_secret);
+var_dump(sodium_crypto_kem_dec($ciphertext2, $secret_key) === $shared_secret2);
+
+/* Key generation is randomized */
+var_dump(sodium_crypto_kem_keypair() !== $keypair);
+
+/* seed_keypair is deterministic and round-trips */
+$seed = random_bytes(SODIUM_CRYPTO_KEM_SEEDBYTES);
+$seeded_keypair = sodium_crypto_kem_seed_keypair($seed);
+var_dump(strlen($seeded_keypair) === SODIUM_CRYPTO_KEM_KEYPAIRBYTES);
+var_dump(sodium_crypto_kem_seed_keypair($seed) === $seeded_keypair);
+[$seeded_ciphertext, $seeded_shared_secret] =
+    sodium_crypto_kem_enc(sodium_crypto_kem_publickey($seeded_keypair));
+var_dump(sodium_crypto_kem_dec($seeded_ciphertext,
+                               sodium_crypto_kem_secretkey($seeded_keypair)) === $seeded_shared_secret);
+
+/* Implicit rejection: a tampered ciphertext still decapsulates to a
+ * shared secret of the right length, but not the original one */
+$tampered_ciphertext = $ciphertext;
+$tampered_ciphertext[0] = $tampered_ciphertext[0] === "\x00" ? "\x01" : "\x00";
+$tampered_shared_secret = sodium_crypto_kem_dec($tampered_ciphertext, $secret_key);
+var_dump(strlen($tampered_shared_secret) === SODIUM_CRYPTO_KEM_SHAREDSECRETBYTES);
+var_dump($tampered_shared_secret !== $shared_secret);
+
+/* Error: truncated public key */
+try {
+    sodium_crypto_kem_enc(substr($public_key, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated ciphertext */
+try {
+    sodium_crypto_kem_dec(substr($ciphertext, 0, -1), $secret_key);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated secret key */
+try {
+    sodium_crypto_kem_dec($ciphertext, substr($secret_key, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated keypair */
+try {
+    sodium_crypto_kem_secretkey(substr($keypair, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: overlong keypair */
+try {
+    sodium_crypto_kem_publickey($keypair . 'x');
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: wrong-length seed */
+try {
+    sodium_crypto_kem_seed_keypair(random_bytes(SODIUM_CRYPTO_KEM_SEEDBYTES - 1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: a public key with an invalid ML-KEM encoding is rejected */
+try {
+    sodium_crypto_kem_enc(str_repeat("\xff", SODIUM_CRYPTO_KEM_PUBLICKEYBYTES));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: a ciphertext whose X25519 component is a small-order point is rejected */
+$small_order_ciphertext = substr($ciphertext, 0, SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES - 32)
+    . str_repeat("\0", 32);
+try {
+    sodium_crypto_kem_dec($small_order_ciphertext, $secret_key);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+crypto_kem (X-Wing):
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+SodiumException: sodium_crypto_kem_enc(): Argument #1 ($public_key) must be SODIUM_CRYPTO_KEM_PUBLICKEYBYTES bytes long
+SodiumException: sodium_crypto_kem_dec(): Argument #1 ($ciphertext) must be SODIUM_CRYPTO_KEM_CIPHERTEXTBYTES bytes long
+SodiumException: sodium_crypto_kem_dec(): Argument #2 ($secret_key) must be SODIUM_CRYPTO_KEM_SECRETKEYBYTES bytes long
+SodiumException: sodium_crypto_kem_secretkey(): Argument #1 ($key_pair) must be SODIUM_CRYPTO_KEM_KEYPAIRBYTES bytes long
+SodiumException: sodium_crypto_kem_publickey(): Argument #1 ($key_pair) must be SODIUM_CRYPTO_KEM_KEYPAIRBYTES bytes long
+SodiumException: sodium_crypto_kem_seed_keypair(): Argument #1 ($seed) must be SODIUM_CRYPTO_KEM_SEEDBYTES bytes long
+SodiumException: internal error
+SodiumException: internal error
diff --git a/ext/sodium/tests/crypto_kem_mlkem768.phpt b/ext/sodium/tests/crypto_kem_mlkem768.phpt
new file mode 100644
index 00000000000..2898eebb69c
--- /dev/null
+++ b/ext/sodium/tests/crypto_kem_mlkem768.phpt
@@ -0,0 +1,126 @@
+--TEST--
+Check for libsodium crypto_kem_mlkem768
+--EXTENSIONS--
+sodium
+--SKIPIF--
+<?php
+if (!defined('SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES')) print "skip libsodium without ML-KEM768 support (requires >= 1.0.22)";
+?>
+--FILE--
+<?php
+echo "crypto_kem_mlkem768:\n";
+
+/* Constant sanity */
+var_dump(SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES ===
+         SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES + SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES);
+var_dump(SODIUM_CRYPTO_KEM_MLKEM768_SHAREDSECRETBYTES === 32);
+
+/* Round-trip: keypair -> extractors -> enc -> dec */
+$keypair = sodium_crypto_kem_mlkem768_keypair();
+var_dump(strlen($keypair) === SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES);
+$secret_key = sodium_crypto_kem_mlkem768_secretkey($keypair);
+$public_key = sodium_crypto_kem_mlkem768_publickey($keypair);
+var_dump(strlen($secret_key) === SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES);
+var_dump(strlen($public_key) === SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES);
+
+[$ciphertext, $shared_secret] = sodium_crypto_kem_mlkem768_enc($public_key);
+var_dump(strlen($ciphertext) === SODIUM_CRYPTO_KEM_MLKEM768_CIPHERTEXTBYTES);
+var_dump(strlen($shared_secret) === SODIUM_CRYPTO_KEM_MLKEM768_SHAREDSECRETBYTES);
+var_dump(sodium_crypto_kem_mlkem768_dec($ciphertext, $secret_key) === $shared_secret);
+
+/* Encapsulation is randomized: same public key, fresh ciphertext and secret */
+[$ciphertext2, $shared_secret2] = sodium_crypto_kem_mlkem768_enc($public_key);
+var_dump($ciphertext2 !== $ciphertext);
+var_dump($shared_secret2 !== $shared_secret);
+var_dump(sodium_crypto_kem_mlkem768_dec($ciphertext2, $secret_key) === $shared_secret2);
+
+/* Key generation is randomized */
+var_dump(sodium_crypto_kem_mlkem768_keypair() !== $keypair);
+
+/* seed_keypair is deterministic and round-trips */
+$seed = random_bytes(SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES);
+$seeded_keypair = sodium_crypto_kem_mlkem768_seed_keypair($seed);
+var_dump(strlen($seeded_keypair) === SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES);
+var_dump(sodium_crypto_kem_mlkem768_seed_keypair($seed) === $seeded_keypair);
+[$seeded_ciphertext, $seeded_shared_secret] =
+    sodium_crypto_kem_mlkem768_enc(sodium_crypto_kem_mlkem768_publickey($seeded_keypair));
+var_dump(sodium_crypto_kem_mlkem768_dec($seeded_ciphertext,
+                                        sodium_crypto_kem_mlkem768_secretkey($seeded_keypair)) === $seeded_shared_secret);
+
+/* Implicit rejection: a tampered ciphertext still decapsulates to a
+ * shared secret of the right length, but not the original one */
+$tampered_ciphertext = $ciphertext;
+$tampered_ciphertext[0] = $tampered_ciphertext[0] === "\x00" ? "\x01" : "\x00";
+$tampered_shared_secret = sodium_crypto_kem_mlkem768_dec($tampered_ciphertext, $secret_key);
+var_dump(strlen($tampered_shared_secret) === SODIUM_CRYPTO_KEM_MLKEM768_SHAREDSECRETBYTES);
+var_dump($tampered_shared_secret !== $shared_secret);
+
+/* Error: truncated public key */
+try {
+    sodium_crypto_kem_mlkem768_enc(substr($public_key, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated ciphertext */
+try {
+    sodium_crypto_kem_mlkem768_dec(substr($ciphertext, 0, -1), $secret_key);
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated secret key */
+try {
+    sodium_crypto_kem_mlkem768_dec($ciphertext, substr($secret_key, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: truncated keypair */
+try {
+    sodium_crypto_kem_mlkem768_secretkey(substr($keypair, 0, -1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: overlong keypair */
+try {
+    sodium_crypto_kem_mlkem768_publickey($keypair . 'x');
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: wrong-length seed */
+try {
+    sodium_crypto_kem_mlkem768_seed_keypair(random_bytes(SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES - 1));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+/* Error: a public key with an invalid ML-KEM encoding is rejected */
+try {
+    sodium_crypto_kem_mlkem768_enc(str_repeat("\xff", SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES));
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+crypto_kem_mlkem768:
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+SodiumException: sodium_crypto_kem_mlkem768_enc(): Argument #1 ($public_key) must be SODIUM_CRYPTO_KEM_MLKEM768_PUBLICKEYBYTES bytes long
+SodiumException: sodium_crypto_kem_mlkem768_dec(): Argument #1 ($ciphertext) must be SODIUM_CRYPTO_KEM_MLKEM768_CIPHERTEXTBYTES bytes long
+SodiumException: sodium_crypto_kem_mlkem768_dec(): Argument #2 ($secret_key) must be SODIUM_CRYPTO_KEM_MLKEM768_SECRETKEYBYTES bytes long
+SodiumException: sodium_crypto_kem_mlkem768_secretkey(): Argument #1 ($key_pair) must be SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES bytes long
+SodiumException: sodium_crypto_kem_mlkem768_publickey(): Argument #1 ($key_pair) must be SODIUM_CRYPTO_KEM_MLKEM768_KEYPAIRBYTES bytes long
+SodiumException: sodium_crypto_kem_mlkem768_seed_keypair(): Argument #1 ($seed) must be SODIUM_CRYPTO_KEM_MLKEM768_SEEDBYTES bytes long
+SodiumException: internal error