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